squadron-sdk 0.1.1__py3-none-any.whl

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,29 @@
1
+ """squadron-sdk — Python SDK for writing Squadron tool plugins.
2
+
3
+ Wire-compatible with the Go ``github.com/mlund01/squadron-sdk`` package: a host
4
+ built against either SDK can launch plugins built against either SDK, in either
5
+ language.
6
+
7
+ The high-level API is :class:`Squadron`. Decorate functions with ``@app.tool``;
8
+ the JSON Schema is derived from type hints, and arguments are validated by
9
+ pydantic. Drop down to :class:`ToolProvider` if you need fully dynamic tools.
10
+ """
11
+ from __future__ import annotations
12
+
13
+ from .app import Squadron, ToolGroup
14
+ from .handshake import HANDSHAKE
15
+ from .interface import ToolInfo, ToolProvider
16
+ from .plugin import PLUGIN_KEY, ToolClient, ToolPlugin
17
+ from .server import serve
18
+
19
+ __all__ = [
20
+ "HANDSHAKE",
21
+ "PLUGIN_KEY",
22
+ "Squadron",
23
+ "ToolClient",
24
+ "ToolGroup",
25
+ "ToolInfo",
26
+ "ToolPlugin",
27
+ "ToolProvider",
28
+ "serve",
29
+ ]
File without changes
@@ -0,0 +1,88 @@
1
+ # Generated by the Protocol Buffers compiler. DO NOT EDIT!
2
+ # source: plugin.proto
3
+ # plugin: grpclib.plugin.main
4
+ import abc
5
+ import typing
6
+
7
+ import grpclib.const
8
+ import grpclib.client
9
+ if typing.TYPE_CHECKING:
10
+ import grpclib.server
11
+
12
+ from . import plugin_pb2
13
+
14
+
15
+ class ToolPluginBase(abc.ABC):
16
+
17
+ @abc.abstractmethod
18
+ async def Configure(self, stream: 'grpclib.server.Stream[plugin_pb2.ConfigureRequest, plugin_pb2.ConfigureResponse]') -> None:
19
+ pass
20
+
21
+ @abc.abstractmethod
22
+ async def Call(self, stream: 'grpclib.server.Stream[plugin_pb2.CallRequest, plugin_pb2.CallResponse]') -> None:
23
+ pass
24
+
25
+ @abc.abstractmethod
26
+ async def GetToolInfo(self, stream: 'grpclib.server.Stream[plugin_pb2.GetToolInfoRequest, plugin_pb2.GetToolInfoResponse]') -> None:
27
+ pass
28
+
29
+ @abc.abstractmethod
30
+ async def ListTools(self, stream: 'grpclib.server.Stream[plugin_pb2.ListToolsRequest, plugin_pb2.ListToolsResponse]') -> None:
31
+ pass
32
+
33
+ def __mapping__(self) -> typing.Dict[str, grpclib.const.Handler]:
34
+ return {
35
+ '/plugin.ToolPlugin/Configure': grpclib.const.Handler(
36
+ self.Configure,
37
+ grpclib.const.Cardinality.UNARY_UNARY,
38
+ plugin_pb2.ConfigureRequest,
39
+ plugin_pb2.ConfigureResponse,
40
+ ),
41
+ '/plugin.ToolPlugin/Call': grpclib.const.Handler(
42
+ self.Call,
43
+ grpclib.const.Cardinality.UNARY_UNARY,
44
+ plugin_pb2.CallRequest,
45
+ plugin_pb2.CallResponse,
46
+ ),
47
+ '/plugin.ToolPlugin/GetToolInfo': grpclib.const.Handler(
48
+ self.GetToolInfo,
49
+ grpclib.const.Cardinality.UNARY_UNARY,
50
+ plugin_pb2.GetToolInfoRequest,
51
+ plugin_pb2.GetToolInfoResponse,
52
+ ),
53
+ '/plugin.ToolPlugin/ListTools': grpclib.const.Handler(
54
+ self.ListTools,
55
+ grpclib.const.Cardinality.UNARY_UNARY,
56
+ plugin_pb2.ListToolsRequest,
57
+ plugin_pb2.ListToolsResponse,
58
+ ),
59
+ }
60
+
61
+
62
+ class ToolPluginStub:
63
+
64
+ def __init__(self, channel: grpclib.client.Channel) -> None:
65
+ self.Configure = grpclib.client.UnaryUnaryMethod(
66
+ channel,
67
+ '/plugin.ToolPlugin/Configure',
68
+ plugin_pb2.ConfigureRequest,
69
+ plugin_pb2.ConfigureResponse,
70
+ )
71
+ self.Call = grpclib.client.UnaryUnaryMethod(
72
+ channel,
73
+ '/plugin.ToolPlugin/Call',
74
+ plugin_pb2.CallRequest,
75
+ plugin_pb2.CallResponse,
76
+ )
77
+ self.GetToolInfo = grpclib.client.UnaryUnaryMethod(
78
+ channel,
79
+ '/plugin.ToolPlugin/GetToolInfo',
80
+ plugin_pb2.GetToolInfoRequest,
81
+ plugin_pb2.GetToolInfoResponse,
82
+ )
83
+ self.ListTools = grpclib.client.UnaryUnaryMethod(
84
+ channel,
85
+ '/plugin.ToolPlugin/ListTools',
86
+ plugin_pb2.ListToolsRequest,
87
+ plugin_pb2.ListToolsResponse,
88
+ )
@@ -0,0 +1,59 @@
1
+ # -*- coding: utf-8 -*-
2
+ # Generated by the protocol buffer compiler. DO NOT EDIT!
3
+ # NO CHECKED-IN PROTOBUF GENCODE
4
+ # source: plugin.proto
5
+ # Protobuf Python Version: 6.31.1
6
+ """Generated protocol buffer code."""
7
+ from google.protobuf import descriptor as _descriptor
8
+ from google.protobuf import descriptor_pool as _descriptor_pool
9
+ from google.protobuf import runtime_version as _runtime_version
10
+ from google.protobuf import symbol_database as _symbol_database
11
+ from google.protobuf.internal import builder as _builder
12
+ _runtime_version.ValidateProtobufRuntimeVersion(
13
+ _runtime_version.Domain.PUBLIC,
14
+ 6,
15
+ 31,
16
+ 1,
17
+ '',
18
+ 'plugin.proto'
19
+ )
20
+ # @@protoc_insertion_point(imports)
21
+
22
+ _sym_db = _symbol_database.Default()
23
+
24
+
25
+
26
+
27
+ DESCRIPTOR = _descriptor_pool.Default().AddSerializedFile(b'\n\x0cplugin.proto\x12\x06plugin\"}\n\x10\x43onfigureRequest\x12\x38\n\x08settings\x18\x01 \x03(\x0b\x32&.plugin.ConfigureRequest.SettingsEntry\x1a/\n\rSettingsEntry\x12\x0b\n\x03key\x18\x01 \x01(\t\x12\r\n\x05value\x18\x02 \x01(\t:\x02\x38\x01\"3\n\x11\x43onfigureResponse\x12\x0f\n\x07success\x18\x01 \x01(\x08\x12\r\n\x05\x65rror\x18\x02 \x01(\t\"1\n\x0b\x43\x61llRequest\x12\x11\n\ttool_name\x18\x01 \x01(\t\x12\x0f\n\x07payload\x18\x02 \x01(\t\"\x1e\n\x0c\x43\x61llResponse\x12\x0e\n\x06result\x18\x01 \x01(\t\"\'\n\x12GetToolInfoRequest\x12\x11\n\ttool_name\x18\x01 \x01(\t\"5\n\x13GetToolInfoResponse\x12\x1e\n\x04tool\x18\x01 \x01(\x0b\x32\x10.plugin.ToolInfo\"\x12\n\x10ListToolsRequest\"4\n\x11ListToolsResponse\x12\x1f\n\x05tools\x18\x01 \x03(\x0b\x32\x10.plugin.ToolInfo\"^\n\x08ToolInfo\x12\x0c\n\x04name\x18\x01 \x01(\t\x12\x13\n\x0b\x64\x65scription\x18\x02 \x01(\t\x12\x13\n\x0bschema_json\x18\x03 \x01(\t\x12\x1a\n\x12output_schema_json\x18\x04 \x01(\t2\x8b\x02\n\nToolPlugin\x12@\n\tConfigure\x12\x18.plugin.ConfigureRequest\x1a\x19.plugin.ConfigureResponse\x12\x31\n\x04\x43\x61ll\x12\x13.plugin.CallRequest\x1a\x14.plugin.CallResponse\x12\x46\n\x0bGetToolInfo\x12\x1a.plugin.GetToolInfoRequest\x1a\x1b.plugin.GetToolInfoResponse\x12@\n\tListTools\x12\x18.plugin.ListToolsRequest\x1a\x19.plugin.ListToolsResponseB\x14Z\x12squad/plugin/protob\x06proto3')
28
+
29
+ _globals = globals()
30
+ _builder.BuildMessageAndEnumDescriptors(DESCRIPTOR, _globals)
31
+ _builder.BuildTopDescriptorsAndMessages(DESCRIPTOR, 'plugin_pb2', _globals)
32
+ if not _descriptor._USE_C_DESCRIPTORS:
33
+ _globals['DESCRIPTOR']._loaded_options = None
34
+ _globals['DESCRIPTOR']._serialized_options = b'Z\022squad/plugin/proto'
35
+ _globals['_CONFIGUREREQUEST_SETTINGSENTRY']._loaded_options = None
36
+ _globals['_CONFIGUREREQUEST_SETTINGSENTRY']._serialized_options = b'8\001'
37
+ _globals['_CONFIGUREREQUEST']._serialized_start=24
38
+ _globals['_CONFIGUREREQUEST']._serialized_end=149
39
+ _globals['_CONFIGUREREQUEST_SETTINGSENTRY']._serialized_start=102
40
+ _globals['_CONFIGUREREQUEST_SETTINGSENTRY']._serialized_end=149
41
+ _globals['_CONFIGURERESPONSE']._serialized_start=151
42
+ _globals['_CONFIGURERESPONSE']._serialized_end=202
43
+ _globals['_CALLREQUEST']._serialized_start=204
44
+ _globals['_CALLREQUEST']._serialized_end=253
45
+ _globals['_CALLRESPONSE']._serialized_start=255
46
+ _globals['_CALLRESPONSE']._serialized_end=285
47
+ _globals['_GETTOOLINFOREQUEST']._serialized_start=287
48
+ _globals['_GETTOOLINFOREQUEST']._serialized_end=326
49
+ _globals['_GETTOOLINFORESPONSE']._serialized_start=328
50
+ _globals['_GETTOOLINFORESPONSE']._serialized_end=381
51
+ _globals['_LISTTOOLSREQUEST']._serialized_start=383
52
+ _globals['_LISTTOOLSREQUEST']._serialized_end=401
53
+ _globals['_LISTTOOLSRESPONSE']._serialized_start=403
54
+ _globals['_LISTTOOLSRESPONSE']._serialized_end=455
55
+ _globals['_TOOLINFO']._serialized_start=457
56
+ _globals['_TOOLINFO']._serialized_end=551
57
+ _globals['_TOOLPLUGIN']._serialized_start=554
58
+ _globals['_TOOLPLUGIN']._serialized_end=821
59
+ # @@protoc_insertion_point(module_scope)
squadron_sdk/app.py ADDED
@@ -0,0 +1,219 @@
1
+ from __future__ import annotations
2
+
3
+ import inspect
4
+ import json
5
+ import typing
6
+ from dataclasses import dataclass
7
+ from typing import Any, Awaitable, Callable
8
+
9
+ from pydantic import TypeAdapter, create_model
10
+
11
+ from .interface import ToolInfo, ToolProvider
12
+
13
+ ConfigureHandler = Callable[[dict[str, str]], None | Awaitable[None]]
14
+ ToolFunc = Callable[..., Any]
15
+
16
+ _ANY_ADAPTER: TypeAdapter[Any] = TypeAdapter(Any)
17
+
18
+
19
+ @dataclass
20
+ class _Tool:
21
+ name: str
22
+ description: str
23
+ fn: ToolFunc
24
+ args_model: type
25
+ return_adapter: TypeAdapter[Any]
26
+ return_is_str: bool
27
+ info: ToolInfo
28
+
29
+ async def invoke(self, payload: str) -> str:
30
+ params = json.loads(payload) if payload else {}
31
+ validated = self.args_model.model_validate(params)
32
+ kwargs = {k: getattr(validated, k) for k in self.args_model.model_fields}
33
+ result = self.fn(**kwargs)
34
+ if inspect.isawaitable(result):
35
+ result = await result
36
+ if self.return_is_str and isinstance(result, str):
37
+ return result
38
+ return self.return_adapter.dump_json(result).decode()
39
+
40
+
41
+ def _build_args_model(name: str, fn: ToolFunc) -> type:
42
+ sig = inspect.signature(fn)
43
+ try:
44
+ hints = typing.get_type_hints(fn, include_extras=True)
45
+ except Exception:
46
+ hints = {}
47
+ fields: dict[str, tuple[Any, Any]] = {}
48
+ for pname, param in sig.parameters.items():
49
+ if param.kind in (inspect.Parameter.VAR_POSITIONAL, inspect.Parameter.VAR_KEYWORD):
50
+ raise TypeError(
51
+ f"@tool {name!r}: *args/**kwargs are not supported in tool signatures"
52
+ )
53
+ annotation = hints.get(pname, str)
54
+ default = ... if param.default is inspect.Parameter.empty else param.default
55
+ fields[pname] = (annotation, default)
56
+ return create_model(f"{name}_args", **fields)
57
+
58
+
59
+ def _strip_titles(schema: dict[str, Any]) -> dict[str, Any]:
60
+ if not isinstance(schema, dict):
61
+ return schema
62
+ out = {k: v for k, v in schema.items() if k != "title"}
63
+ if "properties" in out and isinstance(out["properties"], dict):
64
+ out["properties"] = {k: _strip_titles(v) for k, v in out["properties"].items()}
65
+ if "items" in out:
66
+ out["items"] = _strip_titles(out["items"])
67
+ if "$defs" in out and isinstance(out["$defs"], dict):
68
+ out["$defs"] = {k: _strip_titles(v) for k, v in out["$defs"].items()}
69
+ return out
70
+
71
+
72
+ def _build_tool(
73
+ fn: ToolFunc,
74
+ name: str | None,
75
+ description: str | None,
76
+ ) -> _Tool:
77
+ tool_name = name or fn.__name__
78
+ doc = (inspect.getdoc(fn) or "").strip()
79
+ tool_desc = description if description is not None else doc.split("\n\n")[0]
80
+ args_model = _build_args_model(tool_name, fn)
81
+ schema = _strip_titles(args_model.model_json_schema())
82
+
83
+ return_type, output_schema = _resolve_return_type(fn)
84
+ return_adapter: TypeAdapter[Any] = TypeAdapter(return_type)
85
+ return_is_str = return_type is str
86
+
87
+ return _Tool(
88
+ name=tool_name,
89
+ description=tool_desc,
90
+ fn=fn,
91
+ args_model=args_model,
92
+ return_adapter=return_adapter,
93
+ return_is_str=return_is_str,
94
+ info=ToolInfo(
95
+ name=tool_name,
96
+ description=tool_desc,
97
+ schema=schema,
98
+ output_schema=output_schema,
99
+ ),
100
+ )
101
+
102
+
103
+ def _resolve_return_type(fn: ToolFunc) -> tuple[Any, dict[str, Any] | None]:
104
+ try:
105
+ hints = typing.get_type_hints(fn, include_extras=True)
106
+ except Exception:
107
+ return Any, None
108
+ rt = hints.get("return", Any)
109
+ if rt is None or rt is type(None):
110
+ return type(None), None
111
+ if rt is Any or rt is inspect.Signature.empty:
112
+ return Any, None
113
+ try:
114
+ adapter = TypeAdapter(rt)
115
+ schema = _strip_titles(adapter.json_schema())
116
+ return rt, schema
117
+ except Exception:
118
+ return rt, None
119
+
120
+
121
+ class _ToolRegistry:
122
+ def __init__(self) -> None:
123
+ self._tools: dict[str, _Tool] = {}
124
+
125
+ def tool(
126
+ self,
127
+ fn: ToolFunc | None = None,
128
+ *,
129
+ name: str | None = None,
130
+ description: str | None = None,
131
+ ) -> ToolFunc:
132
+ def decorator(f: ToolFunc) -> ToolFunc:
133
+ built = _build_tool(f, name, description)
134
+ if built.name in self._tools:
135
+ raise ValueError(f"tool {built.name!r} is already registered")
136
+ self._tools[built.name] = built
137
+ return f
138
+
139
+ if fn is not None and callable(fn):
140
+ return decorator(fn)
141
+ return decorator # type: ignore[return-value]
142
+
143
+
144
+ class ToolGroup(_ToolRegistry):
145
+ def __init__(self) -> None:
146
+ super().__init__()
147
+ self.app: Squadron | None = None
148
+
149
+
150
+ class Squadron(_ToolRegistry):
151
+ def __init__(self) -> None:
152
+ super().__init__()
153
+ self._configure_handler: ConfigureHandler | None = None
154
+
155
+ def configure(self, fn: ConfigureHandler) -> ConfigureHandler:
156
+ self._configure_handler = fn
157
+ return fn
158
+
159
+ def include(self, group: ToolGroup, *, prefix: str = "") -> None:
160
+ if group.app is not None and group.app is not self:
161
+ raise ValueError("ToolGroup has already been included in a different Squadron app")
162
+ group.app = self
163
+ for tool in group._tools.values():
164
+ full_name = f"{prefix}{tool.name}"
165
+ if full_name in self._tools:
166
+ raise ValueError(f"tool {full_name!r} is already registered")
167
+ if prefix:
168
+ self._tools[full_name] = _Tool(
169
+ name=full_name,
170
+ description=tool.description,
171
+ fn=tool.fn,
172
+ args_model=tool.args_model,
173
+ return_adapter=tool.return_adapter,
174
+ return_is_str=tool.return_is_str,
175
+ info=ToolInfo(
176
+ name=full_name,
177
+ description=tool.description,
178
+ schema=tool.info.schema,
179
+ output_schema=tool.info.output_schema,
180
+ ),
181
+ )
182
+ else:
183
+ self._tools[full_name] = tool
184
+
185
+ def as_provider(self) -> ToolProvider:
186
+ return _SquadronProvider(self)
187
+
188
+ def serve(self) -> None:
189
+ from .server import serve
190
+
191
+ serve(self.as_provider())
192
+
193
+
194
+ class _SquadronProvider(ToolProvider):
195
+ def __init__(self, app: Squadron) -> None:
196
+ self._app = app
197
+
198
+ async def configure(self, settings: dict[str, str]) -> None:
199
+ handler = self._app._configure_handler
200
+ if handler is None:
201
+ return
202
+ result = handler(settings)
203
+ if inspect.isawaitable(result):
204
+ await result
205
+
206
+ async def call(self, tool_name: str, payload: str) -> str:
207
+ tool = self._app._tools.get(tool_name)
208
+ if tool is None:
209
+ raise ValueError(f"unknown tool: {tool_name!r}")
210
+ return await tool.invoke(payload)
211
+
212
+ async def get_tool_info(self, tool_name: str) -> ToolInfo:
213
+ tool = self._app._tools.get(tool_name)
214
+ if tool is None:
215
+ raise ValueError(f"unknown tool: {tool_name!r}")
216
+ return tool.info
217
+
218
+ async def list_tools(self) -> list[ToolInfo]:
219
+ return [t.info for t in self._app._tools.values()]
@@ -0,0 +1,10 @@
1
+ """Handshake config — must match the Go squadron-sdk."""
2
+ from __future__ import annotations
3
+
4
+ from pyplugin import HandshakeConfig
5
+
6
+ HANDSHAKE = HandshakeConfig(
7
+ protocol_version=1,
8
+ magic_cookie_key="SQUAD_PLUGIN",
9
+ magic_cookie_value="squadron-tool-plugin-v1",
10
+ )
@@ -0,0 +1,29 @@
1
+ from __future__ import annotations
2
+
3
+ import abc
4
+ from dataclasses import dataclass, field
5
+ from typing import Any
6
+
7
+
8
+ @dataclass
9
+ class ToolInfo:
10
+ name: str
11
+ description: str = ""
12
+ schema: dict[str, Any] = field(
13
+ default_factory=lambda: {"type": "object", "properties": {}}
14
+ )
15
+ output_schema: dict[str, Any] | None = None
16
+
17
+
18
+ class ToolProvider(abc.ABC):
19
+ @abc.abstractmethod
20
+ async def configure(self, settings: dict[str, str]) -> None: ...
21
+
22
+ @abc.abstractmethod
23
+ async def call(self, tool_name: str, payload: str) -> str: ...
24
+
25
+ @abc.abstractmethod
26
+ async def get_tool_info(self, tool_name: str) -> ToolInfo: ...
27
+
28
+ @abc.abstractmethod
29
+ async def list_tools(self) -> list[ToolInfo]: ...
squadron_sdk/plugin.py ADDED
@@ -0,0 +1,124 @@
1
+ """pyplugin glue: servicer, host-side stub, and the Plugin class."""
2
+ from __future__ import annotations
3
+
4
+ import json
5
+ from typing import Any
6
+
7
+ from grpclib.client import Channel
8
+ from pyplugin import Plugin
9
+ from pyplugin.broker import GRPCBroker
10
+
11
+ from ._generated import plugin_grpc, plugin_pb2
12
+ from .interface import ToolInfo, ToolProvider
13
+
14
+
15
+ def _info_to_proto(info: ToolInfo) -> plugin_pb2.ToolInfo:
16
+ return plugin_pb2.ToolInfo(
17
+ name=info.name,
18
+ description=info.description,
19
+ schema_json=json.dumps(info.schema or {}),
20
+ output_schema_json=json.dumps(info.output_schema) if info.output_schema else "",
21
+ )
22
+
23
+
24
+ def _info_from_proto(t: plugin_pb2.ToolInfo) -> ToolInfo:
25
+ schema = json.loads(t.schema_json) if t.schema_json else {}
26
+ output_schema = json.loads(t.output_schema_json) if t.output_schema_json else None
27
+ return ToolInfo(
28
+ name=t.name,
29
+ description=t.description,
30
+ schema=schema,
31
+ output_schema=output_schema,
32
+ )
33
+
34
+
35
+ class _ToolPluginServicer(plugin_grpc.ToolPluginBase):
36
+ """Plugin-side servicer that delegates to a ``ToolProvider``."""
37
+
38
+ def __init__(self, impl: ToolProvider) -> None:
39
+ self._impl = impl
40
+
41
+ async def Configure(self, stream) -> None:
42
+ request = await stream.recv_message()
43
+ try:
44
+ await self._impl.configure(dict(request.settings))
45
+ except Exception as exc:
46
+ await stream.send_message(
47
+ plugin_pb2.ConfigureResponse(success=False, error=str(exc))
48
+ )
49
+ return
50
+ await stream.send_message(plugin_pb2.ConfigureResponse(success=True))
51
+
52
+ async def Call(self, stream) -> None:
53
+ request = await stream.recv_message()
54
+ result = await self._impl.call(request.tool_name, request.payload)
55
+ await stream.send_message(plugin_pb2.CallResponse(result=result))
56
+
57
+ async def GetToolInfo(self, stream) -> None:
58
+ request = await stream.recv_message()
59
+ info = await self._impl.get_tool_info(request.tool_name)
60
+ await stream.send_message(plugin_pb2.GetToolInfoResponse(tool=_info_to_proto(info)))
61
+
62
+ async def ListTools(self, stream) -> None:
63
+ await stream.recv_message()
64
+ tools = await self._impl.list_tools()
65
+ await stream.send_message(
66
+ plugin_pb2.ListToolsResponse(tools=[_info_to_proto(t) for t in tools])
67
+ )
68
+
69
+
70
+ class ToolClient:
71
+ """Host-side wrapper around the generated grpclib stub.
72
+
73
+ Returned by ``Client.dispense("tool")``; presents a Pythonic API on top of
74
+ the raw protobuf calls.
75
+ """
76
+
77
+ def __init__(self, stub: plugin_grpc.ToolPluginStub) -> None:
78
+ self._stub = stub
79
+
80
+ async def configure(self, settings: dict[str, str]) -> None:
81
+ resp = await self._stub.Configure(plugin_pb2.ConfigureRequest(settings=settings))
82
+ if not resp.success:
83
+ raise RuntimeError(f"configure failed: {resp.error}")
84
+
85
+ async def call(self, tool_name: str, payload: str) -> str:
86
+ resp = await self._stub.Call(
87
+ plugin_pb2.CallRequest(tool_name=tool_name, payload=payload)
88
+ )
89
+ return resp.result
90
+
91
+ async def get_tool_info(self, tool_name: str) -> ToolInfo:
92
+ resp = await self._stub.GetToolInfo(
93
+ plugin_pb2.GetToolInfoRequest(tool_name=tool_name)
94
+ )
95
+ return _info_from_proto(resp.tool)
96
+
97
+ async def list_tools(self) -> list[ToolInfo]:
98
+ resp = await self._stub.ListTools(plugin_pb2.ListToolsRequest())
99
+ return [_info_from_proto(t) for t in resp.tools]
100
+
101
+
102
+ class ToolPlugin(Plugin):
103
+ """The pyplugin ``Plugin`` glue for squadron tool plugins.
104
+
105
+ On the plugin side, ``servicers()`` is called with the broker and an
106
+ implementation must have been provided to the constructor. On the host
107
+ side, ``stub()`` returns a :class:`ToolClient` wrapping the generated stub.
108
+ """
109
+
110
+ def __init__(self, impl: ToolProvider | None = None) -> None:
111
+ self._impl = impl
112
+
113
+ def servicers(self, broker: GRPCBroker) -> list:
114
+ if self._impl is None:
115
+ raise RuntimeError(
116
+ "ToolPlugin used as a server but no ToolProvider was supplied"
117
+ )
118
+ return [_ToolPluginServicer(self._impl)]
119
+
120
+ def stub(self, broker: GRPCBroker, channel: Channel) -> Any:
121
+ return ToolClient(plugin_grpc.ToolPluginStub(channel))
122
+
123
+
124
+ PLUGIN_KEY = "tool"
@@ -0,0 +1,59 @@
1
+ syntax = "proto3";
2
+
3
+ package plugin;
4
+
5
+ option go_package = "squad/plugin/proto";
6
+
7
+ // ToolPlugin is the service that all tool plugins must implement
8
+ service ToolPlugin {
9
+ // Configure passes plugin settings from HCL config
10
+ rpc Configure(ConfigureRequest) returns (ConfigureResponse);
11
+
12
+ // Call invokes a tool on the plugin with the given payload
13
+ rpc Call(CallRequest) returns (CallResponse);
14
+
15
+ // GetToolInfo returns metadata about a specific tool
16
+ rpc GetToolInfo(GetToolInfoRequest) returns (GetToolInfoResponse);
17
+
18
+ // ListTools returns info for all tools this plugin provides
19
+ rpc ListTools(ListToolsRequest) returns (ListToolsResponse);
20
+ }
21
+
22
+ message ConfigureRequest {
23
+ map<string, string> settings = 1;
24
+ }
25
+
26
+ message ConfigureResponse {
27
+ bool success = 1;
28
+ string error = 2;
29
+ }
30
+
31
+ message CallRequest {
32
+ string tool_name = 1;
33
+ string payload = 2;
34
+ }
35
+
36
+ message CallResponse {
37
+ string result = 1;
38
+ }
39
+
40
+ message GetToolInfoRequest {
41
+ string tool_name = 1;
42
+ }
43
+
44
+ message GetToolInfoResponse {
45
+ ToolInfo tool = 1;
46
+ }
47
+
48
+ message ListToolsRequest {}
49
+
50
+ message ListToolsResponse {
51
+ repeated ToolInfo tools = 1;
52
+ }
53
+
54
+ message ToolInfo {
55
+ string name = 1;
56
+ string description = 2;
57
+ string schema_json = 3;
58
+ string output_schema_json = 4;
59
+ }
squadron_sdk/server.py ADDED
@@ -0,0 +1,45 @@
1
+ """Plugin entry point — mirrors squadron-sdk/serve.go."""
2
+ from __future__ import annotations
3
+
4
+ import os
5
+ import sys
6
+ import threading
7
+ import time
8
+
9
+ from pyplugin import ServeConfig, serve as _pyplugin_serve
10
+
11
+ from .handshake import HANDSHAKE
12
+ from .interface import ToolProvider
13
+ from .plugin import PLUGIN_KEY, ToolPlugin
14
+
15
+
16
+ def _monitor_parent() -> None:
17
+ """Exit if the parent process dies, mirroring monitor_unix.go.
18
+
19
+ On Unix, a process whose parent dies is reparented to PID 1 (init/launchd).
20
+ Detect that and exit so we don't become an orphan.
21
+ """
22
+ if os.name == "nt":
23
+ return
24
+ initial = os.getppid()
25
+ while True:
26
+ time.sleep(5)
27
+ current = os.getppid()
28
+ if current != initial or current == 1:
29
+ os._exit(0)
30
+
31
+
32
+ def serve(impl: ToolProvider) -> None:
33
+ """Start the plugin server with ``impl`` as the tool provider.
34
+
35
+ This is the main entry point for plugin binaries — call it from
36
+ ``if __name__ == "__main__":``.
37
+ """
38
+ threading.Thread(target=_monitor_parent, daemon=True).start()
39
+ _pyplugin_serve(ServeConfig(
40
+ handshake_config=HANDSHAKE,
41
+ plugins={PLUGIN_KEY: ToolPlugin(impl)},
42
+ ))
43
+
44
+
45
+ __all__ = ["serve"]
@@ -0,0 +1,243 @@
1
+ Metadata-Version: 2.4
2
+ Name: squadron-sdk
3
+ Version: 0.1.1
4
+ Summary: Python SDK for writing Squadron tool plugins (wire-compatible with the Go squadron-sdk)
5
+ Author: Max Lund
6
+ License-Expression: MIT
7
+ License-File: LICENSE
8
+ Requires-Python: >=3.10
9
+ Requires-Dist: grpclib[protobuf]>=0.4.7
10
+ Requires-Dist: protobuf>=4.25
11
+ Requires-Dist: pydantic>=2.6
12
+ Requires-Dist: python-plugin>=0.1.0
13
+ Provides-Extra: dev
14
+ Requires-Dist: grpcio-tools>=1.60; extra == 'dev'
15
+ Requires-Dist: pytest-asyncio>=0.23; extra == 'dev'
16
+ Requires-Dist: pytest-timeout>=2.2; extra == 'dev'
17
+ Requires-Dist: pytest>=7; extra == 'dev'
18
+ Description-Content-Type: text/markdown
19
+
20
+ # squadron-sdk (Python)
21
+
22
+ Python SDK for writing [Squadron](https://github.com/mlund01/squadron) tool
23
+ plugins. Wire-compatible with the Go
24
+ [`squadron-sdk`](https://github.com/mlund01/squadron-sdk): a host built against
25
+ either SDK can launch plugins built against either SDK, in either language.
26
+
27
+ Built on [pyplugin](https://github.com/mlund01/py-plugin), the byte-for-byte
28
+ Python port of HashiCorp's go-plugin (including AutoMTLS with ECDSA P-521).
29
+
30
+ ## Quick start
31
+
32
+ ```python
33
+ # plugin.py
34
+ from typing import Literal
35
+ from pydantic import Field
36
+ from squadron_sdk import Squadron
37
+
38
+ app = Squadron()
39
+
40
+ @app.configure
41
+ def setup(settings: dict[str, str]) -> None:
42
+ app.prefix = settings.get("prefix", "")
43
+
44
+ @app.tool
45
+ async def echo(
46
+ message: str = Field(..., description="Text to echo back."),
47
+ repeat: int = Field(1, ge=1, le=100),
48
+ ) -> dict:
49
+ """Echo a message back, prefixed with the configured prefix."""
50
+ return {"echo": (app.prefix + message) * repeat}
51
+
52
+ @app.tool
53
+ def reverse(s: str, mode: Literal["chars", "words"] = "chars") -> str:
54
+ """Reverse a string by characters or words."""
55
+ return " ".join(reversed(s.split())) if mode == "words" else s[::-1]
56
+
57
+ if __name__ == "__main__":
58
+ app.serve()
59
+ ```
60
+
61
+ That's the whole plugin. The host gets:
62
+
63
+ - a `ToolPlugin.ListTools` response with `echo` and `reverse`,
64
+ - a JSON Schema derived from your type hints (including `Field(...)` metadata,
65
+ `Literal` enums, defaults, validators, nested pydantic models, …),
66
+ - input validation on every `Call`,
67
+ - automatic JSON serialization of return values.
68
+
69
+ Sync and async tool functions both work. Tool name defaults to the function
70
+ name and the description defaults to the docstring; override either with
71
+ `@app.tool(name="...", description="...")`.
72
+
73
+ ## Typed returns
74
+
75
+ The return type annotation is reflected into a JSON Schema and shipped as
76
+ the tool's `output_schema` — same machinery as the input. Plain `str`
77
+ returns pass through unwrapped (the LLM sees `hello` rather than
78
+ `"hello"`); everything else is JSON-marshaled via pydantic, so `BaseModel`,
79
+ dataclasses, `list[T]`, `dict[K, V]`, `Literal`, etc. all work.
80
+
81
+ ```python
82
+ class Item(BaseModel):
83
+ name: str
84
+ count: int
85
+
86
+ @app.tool
87
+ def make_item(name: str) -> Item:
88
+ return Item(name=name, count=3)
89
+ # wire: {"name":"x","count":3}
90
+ # output_schema: {"type":"object","properties":{"name":{"type":"string"},"count":{"type":"integer"}},"required":["name","count"]}
91
+
92
+ @app.tool
93
+ def upper(s: str) -> str:
94
+ return s.upper()
95
+ # wire: HI
96
+ # output_schema: {"type":"string"}
97
+ ```
98
+
99
+ The output schema flows over the wire and is available to LLM SDKs that
100
+ support per-tool output schemas — symmetric with the input schema.
101
+
102
+ ## What gets generated
103
+
104
+ For the `echo` tool above, the schema sent to the host looks like:
105
+
106
+ ```json
107
+ {
108
+ "type": "object",
109
+ "properties": {
110
+ "message": {"type": "string", "description": "Text to echo back."},
111
+ "repeat": {"type": "integer", "default": 1, "maximum": 100, "minimum": 1}
112
+ },
113
+ "required": ["message"]
114
+ }
115
+ ```
116
+
117
+ Nested pydantic models, `Literal[...]`, `list[T]`, `dict[K, V]`, `Annotated`,
118
+ optional fields with defaults — all the usual pydantic conveniences are
119
+ available because we go through `pydantic.create_model` and ship the
120
+ schema verbatim.
121
+
122
+ ## Calling from a Python host
123
+
124
+ ```python
125
+ import asyncio, sys
126
+ from pyplugin import Client, ClientConfig
127
+ from squadron_sdk import HANDSHAKE, PLUGIN_KEY, ToolPlugin
128
+
129
+ async def main():
130
+ async with Client(ClientConfig(
131
+ handshake_config=HANDSHAKE,
132
+ plugins={PLUGIN_KEY: ToolPlugin()},
133
+ cmd=[sys.executable, "plugin.py"],
134
+ )) as client:
135
+ tool = client.dispense(PLUGIN_KEY)
136
+ await tool.configure({"prefix": "hi: "})
137
+ for info in await tool.list_tools():
138
+ print(info.name, info.description)
139
+ print(await tool.call("echo", '{"message":"world"}'))
140
+
141
+ asyncio.run(main())
142
+ ```
143
+
144
+ A complete runnable example lives in [`examples/echo/`](examples/echo/).
145
+
146
+ ## Splitting tools across files
147
+
148
+ Two patterns work — pick whichever fits.
149
+
150
+ ### Shared app instance
151
+
152
+ A standalone Python app that owns its own tools: just import the same `app`
153
+ everywhere and decorate as you go.
154
+
155
+ ```python
156
+ # myplugin/app.py
157
+ from squadron_sdk import Squadron
158
+ app = Squadron()
159
+
160
+ # myplugin/tools/database.py
161
+ from myplugin.app import app
162
+
163
+ @app.tool
164
+ async def query(sql: str) -> dict: ...
165
+
166
+ # myplugin/main.py
167
+ from myplugin.app import app
168
+ from myplugin.tools import database # registration happens at import time
169
+
170
+ if __name__ == "__main__":
171
+ app.serve()
172
+ ```
173
+
174
+ ### Explicit `ToolGroup`
175
+
176
+ Better when tools are a reusable unit (a library, a swappable bundle, or
177
+ just clearly-bounded functionality). Tools in a group can read app-level
178
+ state via `group.app`, which is set when you `include` the group:
179
+
180
+ ```python
181
+ # myplugin/tools/text.py
182
+ from squadron_sdk import ToolGroup
183
+
184
+ text_tools = ToolGroup()
185
+
186
+ @text_tools.tool
187
+ def shout(s: str) -> str:
188
+ return text_tools.app.prefix + s.upper()
189
+
190
+ # myplugin/main.py
191
+ from squadron_sdk import Squadron
192
+ from myplugin.tools.text import text_tools
193
+
194
+ app = Squadron()
195
+
196
+ @app.configure
197
+ def setup(settings):
198
+ app.prefix = settings.get("prefix", "")
199
+
200
+ app.include(text_tools) # text_tools.app is now `app`
201
+ app.include(text_tools, prefix="t_") # or namespace: t_shout
202
+ app.serve()
203
+ ```
204
+
205
+ `ToolGroup` is just a tool registry — same `@tool` decorator, no
206
+ `@configure` or `.serve()`. Tool collisions raise on registration or
207
+ `include`. A group can only be included into one app.
208
+
209
+ ## Low-level API
210
+
211
+ If you need fully dynamic tools (e.g. discovered at runtime from a remote
212
+ schema), implement [`ToolProvider`](src/squadron_sdk/interface.py) directly
213
+ and call `serve(provider)`. `Squadron` is a thin layer over `ToolProvider`
214
+ that handles the registration plumbing.
215
+
216
+ ## Wire compatibility
217
+
218
+ Same handshake (`SQUAD_PLUGIN` / `squadron-tool-plugin-v1`, protocol
219
+ version 1) and protobuf service (`plugin.ToolPlugin`) as the Go SDK. A Go
220
+ Squadron host can launch a Python plugin built with this package, and a
221
+ Python host built with `pyplugin` can launch a Go plugin built with the Go
222
+ SDK.
223
+
224
+ The proto file lives at
225
+ [`src/squadron_sdk/proto/plugin.proto`](src/squadron_sdk/proto/plugin.proto)
226
+ and is identical to the Go SDK's. Regenerate the stubs with:
227
+
228
+ ```bash
229
+ python scripts/gen_protos.py
230
+ ```
231
+
232
+ ## Development
233
+
234
+ ```bash
235
+ python -m venv .venv
236
+ source .venv/bin/activate
237
+ pip install -e '.[dev]'
238
+ pytest
239
+ ```
240
+
241
+ ## License
242
+
243
+ MIT.
@@ -0,0 +1,14 @@
1
+ squadron_sdk/__init__.py,sha256=cUzPYw3VQf4xZMAMVOm4kSTTx9C4hvKmj-IgPIeluVs,876
2
+ squadron_sdk/app.py,sha256=O9_aQ1VOhGWQzmonwfBPBMYV2TmoIR78VX8ya1NKxfw,7215
3
+ squadron_sdk/handshake.py,sha256=RQxBsKZzbw7CqrjhCo0f0Hifozy4Y9L-ypeLs3dBXsg,275
4
+ squadron_sdk/interface.py,sha256=ikYtxLIFYRhkuLKwmpcH7NydeUNa0WT6kg59yFM5PAg,726
5
+ squadron_sdk/plugin.py,sha256=CM2C1zTbmB-BzKNXXqypxg2ZzC1BomR8JSokTFhgkNk,4366
6
+ squadron_sdk/server.py,sha256=F1SHseC66UBXu73xnN_D8r59G5Z5y-CCwdR7OpUtMaE,1204
7
+ squadron_sdk/_generated/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
8
+ squadron_sdk/_generated/plugin_grpc.py,sha256=YEGcFxJoJaieNIj-gAj3UE3tptOF52iL_L4NznQqINY,3026
9
+ squadron_sdk/_generated/plugin_pb2.py,sha256=el2LH2wRkqv2KWxiCMY3Mn706K6MkvGnUmvDOsgoFCs,3830
10
+ squadron_sdk/proto/plugin.proto,sha256=mlpLv9RYa7r6RuUKhOT1RlQS28M0JPxSxPBzsr_XRt8,1274
11
+ squadron_sdk-0.1.1.dist-info/METADATA,sha256=DTyxeKbDXM1tObiH6qlgpoJgI5L0ydDwn8wRV0B59tA,7044
12
+ squadron_sdk-0.1.1.dist-info/WHEEL,sha256=QccIxa26bgl1E6uMy58deGWi-0aeIkkangHcxk2kWfw,87
13
+ squadron_sdk-0.1.1.dist-info/licenses/LICENSE,sha256=fILwPEdelkIjeEzc0o8FY3rqqQNQW_bKBYoD0gX6zmY,1065
14
+ squadron_sdk-0.1.1.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.29.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Max Lund
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.