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.
- squadron_sdk/__init__.py +29 -0
- squadron_sdk/_generated/__init__.py +0 -0
- squadron_sdk/_generated/plugin_grpc.py +88 -0
- squadron_sdk/_generated/plugin_pb2.py +59 -0
- squadron_sdk/app.py +219 -0
- squadron_sdk/handshake.py +10 -0
- squadron_sdk/interface.py +29 -0
- squadron_sdk/plugin.py +124 -0
- squadron_sdk/proto/plugin.proto +59 -0
- squadron_sdk/server.py +45 -0
- squadron_sdk-0.1.1.dist-info/METADATA +243 -0
- squadron_sdk-0.1.1.dist-info/RECORD +14 -0
- squadron_sdk-0.1.1.dist-info/WHEEL +4 -0
- squadron_sdk-0.1.1.dist-info/licenses/LICENSE +21 -0
squadron_sdk/__init__.py
ADDED
|
@@ -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,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.
|