squadron-sdk 0.1.1__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.
- squadron_sdk-0.1.1/.github/workflows/publish.yml +47 -0
- squadron_sdk-0.1.1/.gitignore +7 -0
- squadron_sdk-0.1.1/LICENSE +21 -0
- squadron_sdk-0.1.1/PKG-INFO +243 -0
- squadron_sdk-0.1.1/README.md +224 -0
- squadron_sdk-0.1.1/examples/echo/failing_plugin.py +15 -0
- squadron_sdk-0.1.1/examples/echo/host.py +36 -0
- squadron_sdk-0.1.1/examples/echo/plugin.py +35 -0
- squadron_sdk-0.1.1/examples/echo/tools/__init__.py +0 -0
- squadron_sdk-0.1.1/examples/echo/tools/text.py +23 -0
- squadron_sdk-0.1.1/pyproject.toml +35 -0
- squadron_sdk-0.1.1/scripts/gen_protos.py +50 -0
- squadron_sdk-0.1.1/src/squadron_sdk/__init__.py +29 -0
- squadron_sdk-0.1.1/src/squadron_sdk/_generated/__init__.py +0 -0
- squadron_sdk-0.1.1/src/squadron_sdk/_generated/plugin_grpc.py +88 -0
- squadron_sdk-0.1.1/src/squadron_sdk/_generated/plugin_pb2.py +59 -0
- squadron_sdk-0.1.1/src/squadron_sdk/app.py +219 -0
- squadron_sdk-0.1.1/src/squadron_sdk/handshake.py +10 -0
- squadron_sdk-0.1.1/src/squadron_sdk/interface.py +29 -0
- squadron_sdk-0.1.1/src/squadron_sdk/plugin.py +124 -0
- squadron_sdk-0.1.1/src/squadron_sdk/proto/plugin.proto +59 -0
- squadron_sdk-0.1.1/src/squadron_sdk/server.py +45 -0
- squadron_sdk-0.1.1/tests/test_echo.py +69 -0
- squadron_sdk-0.1.1/tests/test_tool_groups.py +155 -0
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
name: Publish
|
|
2
|
+
|
|
3
|
+
on:
|
|
4
|
+
push:
|
|
5
|
+
branches: [main]
|
|
6
|
+
tags: ["v*"]
|
|
7
|
+
workflow_dispatch:
|
|
8
|
+
|
|
9
|
+
jobs:
|
|
10
|
+
build:
|
|
11
|
+
name: Build distribution
|
|
12
|
+
runs-on: ubuntu-latest
|
|
13
|
+
steps:
|
|
14
|
+
- uses: actions/checkout@v4
|
|
15
|
+
|
|
16
|
+
- uses: actions/setup-python@v5
|
|
17
|
+
with:
|
|
18
|
+
python-version: "3.12"
|
|
19
|
+
|
|
20
|
+
- name: Install build tooling
|
|
21
|
+
run: pip install build
|
|
22
|
+
|
|
23
|
+
- name: Build sdist + wheel
|
|
24
|
+
run: python -m build
|
|
25
|
+
|
|
26
|
+
- uses: actions/upload-artifact@v4
|
|
27
|
+
with:
|
|
28
|
+
name: dist
|
|
29
|
+
path: dist/
|
|
30
|
+
|
|
31
|
+
publish-pypi:
|
|
32
|
+
name: Publish to PyPI
|
|
33
|
+
needs: build
|
|
34
|
+
if: startsWith(github.ref, 'refs/tags/')
|
|
35
|
+
runs-on: ubuntu-latest
|
|
36
|
+
environment:
|
|
37
|
+
name: pypi
|
|
38
|
+
url: https://pypi.org/p/squadron-sdk
|
|
39
|
+
permissions:
|
|
40
|
+
id-token: write
|
|
41
|
+
steps:
|
|
42
|
+
- uses: actions/download-artifact@v4
|
|
43
|
+
with:
|
|
44
|
+
name: dist
|
|
45
|
+
path: dist/
|
|
46
|
+
|
|
47
|
+
- uses: pypa/gh-action-pypi-publish@release/v1
|
|
@@ -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.
|
|
@@ -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,224 @@
|
|
|
1
|
+
# squadron-sdk (Python)
|
|
2
|
+
|
|
3
|
+
Python SDK for writing [Squadron](https://github.com/mlund01/squadron) tool
|
|
4
|
+
plugins. Wire-compatible with the Go
|
|
5
|
+
[`squadron-sdk`](https://github.com/mlund01/squadron-sdk): a host built against
|
|
6
|
+
either SDK can launch plugins built against either SDK, in either language.
|
|
7
|
+
|
|
8
|
+
Built on [pyplugin](https://github.com/mlund01/py-plugin), the byte-for-byte
|
|
9
|
+
Python port of HashiCorp's go-plugin (including AutoMTLS with ECDSA P-521).
|
|
10
|
+
|
|
11
|
+
## Quick start
|
|
12
|
+
|
|
13
|
+
```python
|
|
14
|
+
# plugin.py
|
|
15
|
+
from typing import Literal
|
|
16
|
+
from pydantic import Field
|
|
17
|
+
from squadron_sdk import Squadron
|
|
18
|
+
|
|
19
|
+
app = Squadron()
|
|
20
|
+
|
|
21
|
+
@app.configure
|
|
22
|
+
def setup(settings: dict[str, str]) -> None:
|
|
23
|
+
app.prefix = settings.get("prefix", "")
|
|
24
|
+
|
|
25
|
+
@app.tool
|
|
26
|
+
async def echo(
|
|
27
|
+
message: str = Field(..., description="Text to echo back."),
|
|
28
|
+
repeat: int = Field(1, ge=1, le=100),
|
|
29
|
+
) -> dict:
|
|
30
|
+
"""Echo a message back, prefixed with the configured prefix."""
|
|
31
|
+
return {"echo": (app.prefix + message) * repeat}
|
|
32
|
+
|
|
33
|
+
@app.tool
|
|
34
|
+
def reverse(s: str, mode: Literal["chars", "words"] = "chars") -> str:
|
|
35
|
+
"""Reverse a string by characters or words."""
|
|
36
|
+
return " ".join(reversed(s.split())) if mode == "words" else s[::-1]
|
|
37
|
+
|
|
38
|
+
if __name__ == "__main__":
|
|
39
|
+
app.serve()
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
That's the whole plugin. The host gets:
|
|
43
|
+
|
|
44
|
+
- a `ToolPlugin.ListTools` response with `echo` and `reverse`,
|
|
45
|
+
- a JSON Schema derived from your type hints (including `Field(...)` metadata,
|
|
46
|
+
`Literal` enums, defaults, validators, nested pydantic models, …),
|
|
47
|
+
- input validation on every `Call`,
|
|
48
|
+
- automatic JSON serialization of return values.
|
|
49
|
+
|
|
50
|
+
Sync and async tool functions both work. Tool name defaults to the function
|
|
51
|
+
name and the description defaults to the docstring; override either with
|
|
52
|
+
`@app.tool(name="...", description="...")`.
|
|
53
|
+
|
|
54
|
+
## Typed returns
|
|
55
|
+
|
|
56
|
+
The return type annotation is reflected into a JSON Schema and shipped as
|
|
57
|
+
the tool's `output_schema` — same machinery as the input. Plain `str`
|
|
58
|
+
returns pass through unwrapped (the LLM sees `hello` rather than
|
|
59
|
+
`"hello"`); everything else is JSON-marshaled via pydantic, so `BaseModel`,
|
|
60
|
+
dataclasses, `list[T]`, `dict[K, V]`, `Literal`, etc. all work.
|
|
61
|
+
|
|
62
|
+
```python
|
|
63
|
+
class Item(BaseModel):
|
|
64
|
+
name: str
|
|
65
|
+
count: int
|
|
66
|
+
|
|
67
|
+
@app.tool
|
|
68
|
+
def make_item(name: str) -> Item:
|
|
69
|
+
return Item(name=name, count=3)
|
|
70
|
+
# wire: {"name":"x","count":3}
|
|
71
|
+
# output_schema: {"type":"object","properties":{"name":{"type":"string"},"count":{"type":"integer"}},"required":["name","count"]}
|
|
72
|
+
|
|
73
|
+
@app.tool
|
|
74
|
+
def upper(s: str) -> str:
|
|
75
|
+
return s.upper()
|
|
76
|
+
# wire: HI
|
|
77
|
+
# output_schema: {"type":"string"}
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
The output schema flows over the wire and is available to LLM SDKs that
|
|
81
|
+
support per-tool output schemas — symmetric with the input schema.
|
|
82
|
+
|
|
83
|
+
## What gets generated
|
|
84
|
+
|
|
85
|
+
For the `echo` tool above, the schema sent to the host looks like:
|
|
86
|
+
|
|
87
|
+
```json
|
|
88
|
+
{
|
|
89
|
+
"type": "object",
|
|
90
|
+
"properties": {
|
|
91
|
+
"message": {"type": "string", "description": "Text to echo back."},
|
|
92
|
+
"repeat": {"type": "integer", "default": 1, "maximum": 100, "minimum": 1}
|
|
93
|
+
},
|
|
94
|
+
"required": ["message"]
|
|
95
|
+
}
|
|
96
|
+
```
|
|
97
|
+
|
|
98
|
+
Nested pydantic models, `Literal[...]`, `list[T]`, `dict[K, V]`, `Annotated`,
|
|
99
|
+
optional fields with defaults — all the usual pydantic conveniences are
|
|
100
|
+
available because we go through `pydantic.create_model` and ship the
|
|
101
|
+
schema verbatim.
|
|
102
|
+
|
|
103
|
+
## Calling from a Python host
|
|
104
|
+
|
|
105
|
+
```python
|
|
106
|
+
import asyncio, sys
|
|
107
|
+
from pyplugin import Client, ClientConfig
|
|
108
|
+
from squadron_sdk import HANDSHAKE, PLUGIN_KEY, ToolPlugin
|
|
109
|
+
|
|
110
|
+
async def main():
|
|
111
|
+
async with Client(ClientConfig(
|
|
112
|
+
handshake_config=HANDSHAKE,
|
|
113
|
+
plugins={PLUGIN_KEY: ToolPlugin()},
|
|
114
|
+
cmd=[sys.executable, "plugin.py"],
|
|
115
|
+
)) as client:
|
|
116
|
+
tool = client.dispense(PLUGIN_KEY)
|
|
117
|
+
await tool.configure({"prefix": "hi: "})
|
|
118
|
+
for info in await tool.list_tools():
|
|
119
|
+
print(info.name, info.description)
|
|
120
|
+
print(await tool.call("echo", '{"message":"world"}'))
|
|
121
|
+
|
|
122
|
+
asyncio.run(main())
|
|
123
|
+
```
|
|
124
|
+
|
|
125
|
+
A complete runnable example lives in [`examples/echo/`](examples/echo/).
|
|
126
|
+
|
|
127
|
+
## Splitting tools across files
|
|
128
|
+
|
|
129
|
+
Two patterns work — pick whichever fits.
|
|
130
|
+
|
|
131
|
+
### Shared app instance
|
|
132
|
+
|
|
133
|
+
A standalone Python app that owns its own tools: just import the same `app`
|
|
134
|
+
everywhere and decorate as you go.
|
|
135
|
+
|
|
136
|
+
```python
|
|
137
|
+
# myplugin/app.py
|
|
138
|
+
from squadron_sdk import Squadron
|
|
139
|
+
app = Squadron()
|
|
140
|
+
|
|
141
|
+
# myplugin/tools/database.py
|
|
142
|
+
from myplugin.app import app
|
|
143
|
+
|
|
144
|
+
@app.tool
|
|
145
|
+
async def query(sql: str) -> dict: ...
|
|
146
|
+
|
|
147
|
+
# myplugin/main.py
|
|
148
|
+
from myplugin.app import app
|
|
149
|
+
from myplugin.tools import database # registration happens at import time
|
|
150
|
+
|
|
151
|
+
if __name__ == "__main__":
|
|
152
|
+
app.serve()
|
|
153
|
+
```
|
|
154
|
+
|
|
155
|
+
### Explicit `ToolGroup`
|
|
156
|
+
|
|
157
|
+
Better when tools are a reusable unit (a library, a swappable bundle, or
|
|
158
|
+
just clearly-bounded functionality). Tools in a group can read app-level
|
|
159
|
+
state via `group.app`, which is set when you `include` the group:
|
|
160
|
+
|
|
161
|
+
```python
|
|
162
|
+
# myplugin/tools/text.py
|
|
163
|
+
from squadron_sdk import ToolGroup
|
|
164
|
+
|
|
165
|
+
text_tools = ToolGroup()
|
|
166
|
+
|
|
167
|
+
@text_tools.tool
|
|
168
|
+
def shout(s: str) -> str:
|
|
169
|
+
return text_tools.app.prefix + s.upper()
|
|
170
|
+
|
|
171
|
+
# myplugin/main.py
|
|
172
|
+
from squadron_sdk import Squadron
|
|
173
|
+
from myplugin.tools.text import text_tools
|
|
174
|
+
|
|
175
|
+
app = Squadron()
|
|
176
|
+
|
|
177
|
+
@app.configure
|
|
178
|
+
def setup(settings):
|
|
179
|
+
app.prefix = settings.get("prefix", "")
|
|
180
|
+
|
|
181
|
+
app.include(text_tools) # text_tools.app is now `app`
|
|
182
|
+
app.include(text_tools, prefix="t_") # or namespace: t_shout
|
|
183
|
+
app.serve()
|
|
184
|
+
```
|
|
185
|
+
|
|
186
|
+
`ToolGroup` is just a tool registry — same `@tool` decorator, no
|
|
187
|
+
`@configure` or `.serve()`. Tool collisions raise on registration or
|
|
188
|
+
`include`. A group can only be included into one app.
|
|
189
|
+
|
|
190
|
+
## Low-level API
|
|
191
|
+
|
|
192
|
+
If you need fully dynamic tools (e.g. discovered at runtime from a remote
|
|
193
|
+
schema), implement [`ToolProvider`](src/squadron_sdk/interface.py) directly
|
|
194
|
+
and call `serve(provider)`. `Squadron` is a thin layer over `ToolProvider`
|
|
195
|
+
that handles the registration plumbing.
|
|
196
|
+
|
|
197
|
+
## Wire compatibility
|
|
198
|
+
|
|
199
|
+
Same handshake (`SQUAD_PLUGIN` / `squadron-tool-plugin-v1`, protocol
|
|
200
|
+
version 1) and protobuf service (`plugin.ToolPlugin`) as the Go SDK. A Go
|
|
201
|
+
Squadron host can launch a Python plugin built with this package, and a
|
|
202
|
+
Python host built with `pyplugin` can launch a Go plugin built with the Go
|
|
203
|
+
SDK.
|
|
204
|
+
|
|
205
|
+
The proto file lives at
|
|
206
|
+
[`src/squadron_sdk/proto/plugin.proto`](src/squadron_sdk/proto/plugin.proto)
|
|
207
|
+
and is identical to the Go SDK's. Regenerate the stubs with:
|
|
208
|
+
|
|
209
|
+
```bash
|
|
210
|
+
python scripts/gen_protos.py
|
|
211
|
+
```
|
|
212
|
+
|
|
213
|
+
## Development
|
|
214
|
+
|
|
215
|
+
```bash
|
|
216
|
+
python -m venv .venv
|
|
217
|
+
source .venv/bin/activate
|
|
218
|
+
pip install -e '.[dev]'
|
|
219
|
+
pytest
|
|
220
|
+
```
|
|
221
|
+
|
|
222
|
+
## License
|
|
223
|
+
|
|
224
|
+
MIT.
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
"""Plugin used by tests to verify configure() errors propagate to the host."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
from squadron_sdk import Squadron
|
|
5
|
+
|
|
6
|
+
app = Squadron()
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
@app.configure
|
|
10
|
+
def fail(settings: dict[str, str]) -> None:
|
|
11
|
+
raise RuntimeError("boom")
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
if __name__ == "__main__":
|
|
15
|
+
app.serve()
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
"""Sample host that launches the echo plugin and exercises every tool."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import asyncio
|
|
5
|
+
import json
|
|
6
|
+
import sys
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
from pyplugin import Client, ClientConfig
|
|
10
|
+
|
|
11
|
+
from squadron_sdk import HANDSHAKE, PLUGIN_KEY, ToolPlugin
|
|
12
|
+
|
|
13
|
+
PLUGIN_SCRIPT = Path(__file__).resolve().parent / "plugin.py"
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
async def main() -> None:
|
|
17
|
+
config = ClientConfig(
|
|
18
|
+
handshake_config=HANDSHAKE,
|
|
19
|
+
plugins={PLUGIN_KEY: ToolPlugin()},
|
|
20
|
+
cmd=[sys.executable, str(PLUGIN_SCRIPT)],
|
|
21
|
+
)
|
|
22
|
+
async with Client(config) as client:
|
|
23
|
+
tool = client.dispense(PLUGIN_KEY)
|
|
24
|
+
await tool.configure({"prefix": "[echo] "})
|
|
25
|
+
|
|
26
|
+
for info in await tool.list_tools():
|
|
27
|
+
print(f"\n{info.name}: {info.description}")
|
|
28
|
+
print(" schema:", json.dumps(info.schema, indent=2))
|
|
29
|
+
|
|
30
|
+
print("\necho:", await tool.call("echo", json.dumps({"message": "hi", "repeat": 2})))
|
|
31
|
+
print("count:", await tool.call("count", json.dumps({"text": "the quick brown fox"})))
|
|
32
|
+
print("reverse:", await tool.call("reverse", json.dumps({"s": "hello world", "mode": "words"})))
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
if __name__ == "__main__":
|
|
36
|
+
asyncio.run(main())
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import sys
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
|
|
6
|
+
if __package__ is None:
|
|
7
|
+
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
|
8
|
+
|
|
9
|
+
from pydantic import Field
|
|
10
|
+
|
|
11
|
+
from squadron_sdk import Squadron
|
|
12
|
+
from tools.text import text_tools
|
|
13
|
+
|
|
14
|
+
app = Squadron()
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
@app.configure
|
|
18
|
+
def setup(settings: dict[str, str]) -> None:
|
|
19
|
+
app.prefix = settings.get("prefix", "")
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
@app.tool
|
|
23
|
+
async def echo(
|
|
24
|
+
message: str = Field(..., description="Text to echo back."),
|
|
25
|
+
repeat: int = Field(1, ge=1, le=100, description="How many times to repeat."),
|
|
26
|
+
) -> dict:
|
|
27
|
+
"""Echo a message back, prefixed with the configured prefix."""
|
|
28
|
+
return {"echo": (app.prefix + message) * repeat}
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
app.include(text_tools)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
if __name__ == "__main__":
|
|
35
|
+
app.serve()
|
|
File without changes
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import Literal
|
|
4
|
+
|
|
5
|
+
from squadron_sdk import ToolGroup
|
|
6
|
+
|
|
7
|
+
text_tools = ToolGroup()
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
@text_tools.tool
|
|
11
|
+
def reverse(s: str, mode: Literal["chars", "words"] = "chars") -> str:
|
|
12
|
+
"""Reverse a string by characters or by words. Honors the app's prefix."""
|
|
13
|
+
out = " ".join(reversed(s.split())) if mode == "words" else s[::-1]
|
|
14
|
+
return text_tools.app.prefix + out
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
@text_tools.tool(name="count")
|
|
18
|
+
def count_words(text: str) -> dict:
|
|
19
|
+
"""Count letters and words in a string."""
|
|
20
|
+
return {
|
|
21
|
+
"letters": sum(1 for ch in text if ch.isalpha()),
|
|
22
|
+
"words": len(text.split()),
|
|
23
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling>=1.21"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "squadron-sdk"
|
|
7
|
+
version = "0.1.1"
|
|
8
|
+
description = "Python SDK for writing Squadron tool plugins (wire-compatible with the Go squadron-sdk)"
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.10"
|
|
11
|
+
license = "MIT"
|
|
12
|
+
authors = [{ name = "Max Lund" }]
|
|
13
|
+
dependencies = [
|
|
14
|
+
"python-plugin>=0.1.0",
|
|
15
|
+
"grpclib[protobuf]>=0.4.7",
|
|
16
|
+
"protobuf>=4.25",
|
|
17
|
+
"pydantic>=2.6",
|
|
18
|
+
]
|
|
19
|
+
|
|
20
|
+
[project.optional-dependencies]
|
|
21
|
+
dev = [
|
|
22
|
+
"grpcio-tools>=1.60",
|
|
23
|
+
"pytest>=7",
|
|
24
|
+
"pytest-asyncio>=0.23",
|
|
25
|
+
"pytest-timeout>=2.2",
|
|
26
|
+
]
|
|
27
|
+
|
|
28
|
+
[tool.hatch.build.targets.wheel]
|
|
29
|
+
packages = ["src/squadron_sdk"]
|
|
30
|
+
|
|
31
|
+
[tool.pytest.ini_options]
|
|
32
|
+
testpaths = ["tests"]
|
|
33
|
+
timeout = 30
|
|
34
|
+
addopts = "-q"
|
|
35
|
+
asyncio_mode = "auto"
|