tether-sdk 0.0.2__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.
- tether/__init__.py +35 -0
- tether/cli.py +235 -0
- tether/context.py +170 -0
- tether/decorator.py +280 -0
- tether/harness.py +435 -0
- tether/multiplexer.py +124 -0
- tether/providers/__init__.py +66 -0
- tether/providers/anthropic.py +135 -0
- tether/providers/base.py +136 -0
- tether/providers/ollama.py +112 -0
- tether/providers/openai.py +89 -0
- tether/sandbox.py +342 -0
- tether/skills.py +157 -0
- tether/store.py +660 -0
- tether/tools/__init__.py +22 -0
- tether/tools/agent.py +190 -0
- tether/tools/bash.py +29 -0
- tether/tools/done.py +33 -0
- tether/tools/edit.py +107 -0
- tether/tools/mem.py +93 -0
- tether/tools/web.py +222 -0
- tether_sdk-0.0.2.dist-info/METADATA +346 -0
- tether_sdk-0.0.2.dist-info/RECORD +26 -0
- tether_sdk-0.0.2.dist-info/WHEEL +4 -0
- tether_sdk-0.0.2.dist-info/entry_points.txt +2 -0
- tether_sdk-0.0.2.dist-info/licenses/LICENSE +20 -0
tether/decorator.py
ADDED
|
@@ -0,0 +1,280 @@
|
|
|
1
|
+
"""@tool decorator introspection and ultra-compact JIT --help synthesis."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import inspect
|
|
6
|
+
from collections.abc import Awaitable, Callable
|
|
7
|
+
from dataclasses import dataclass
|
|
8
|
+
from typing import Any, Literal, get_args, get_origin, get_type_hints
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
@dataclass(slots=True)
|
|
12
|
+
class ParamSpec:
|
|
13
|
+
name: str
|
|
14
|
+
flag: str
|
|
15
|
+
type_name: str
|
|
16
|
+
annotation: Any
|
|
17
|
+
required: bool
|
|
18
|
+
default: Any
|
|
19
|
+
is_variadic: bool = False
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
@dataclass(slots=True)
|
|
23
|
+
class ToolSpec:
|
|
24
|
+
name: str
|
|
25
|
+
description: str
|
|
26
|
+
fn: Callable[..., Any]
|
|
27
|
+
params: list[ParamSpec]
|
|
28
|
+
accepts_stdin: bool
|
|
29
|
+
is_async: bool
|
|
30
|
+
custom_help: str | None = None
|
|
31
|
+
raw_dispatch: bool = False # If True, passes (args: list[str], stdin: str | None) directly
|
|
32
|
+
|
|
33
|
+
def index_line(self) -> str:
|
|
34
|
+
"""Return a 1-line summary (strictly <= 12 tokens) for the static system prompt."""
|
|
35
|
+
short_desc = " ".join(self.description.strip().split())
|
|
36
|
+
line = f"- {self.name}: {short_desc}"
|
|
37
|
+
if len(line) > 44:
|
|
38
|
+
line = line[:41].rstrip() + "..."
|
|
39
|
+
return line
|
|
40
|
+
|
|
41
|
+
def help_text(self) -> str:
|
|
42
|
+
"""Return ultra-compact --help output (target <= 60 tokens)."""
|
|
43
|
+
if self.custom_help:
|
|
44
|
+
return self.custom_help.strip()
|
|
45
|
+
|
|
46
|
+
parts: list[str] = [self.name]
|
|
47
|
+
for p in self.params:
|
|
48
|
+
if p.is_variadic:
|
|
49
|
+
parts.append(f"<{p.name}...>")
|
|
50
|
+
elif p.required:
|
|
51
|
+
parts.append(f"<{p.name}:{p.type_name}>")
|
|
52
|
+
else:
|
|
53
|
+
default_repr = repr(p.default) if isinstance(p.default, str) else str(p.default)
|
|
54
|
+
parts.append(f"[{p.flag} {p.type_name}={default_repr}]")
|
|
55
|
+
if self.accepts_stdin:
|
|
56
|
+
parts.append("[stdin]")
|
|
57
|
+
|
|
58
|
+
usage_line = " ".join(parts)
|
|
59
|
+
doc = (inspect.getdoc(self.fn) or self.description).strip().splitlines()[0]
|
|
60
|
+
if len(doc) > 100:
|
|
61
|
+
doc = doc[:97] + "..."
|
|
62
|
+
return f"Usage: {usage_line}\nDesc : {doc}"
|
|
63
|
+
|
|
64
|
+
async def invoke(self, args: list[str], stdin: str | None = None) -> str:
|
|
65
|
+
"""Parse CLI arguments against ParamSpecs and invoke the underlying function."""
|
|
66
|
+
if self.raw_dispatch:
|
|
67
|
+
res = self.fn(args, stdin)
|
|
68
|
+
if inspect.isawaitable(res):
|
|
69
|
+
res = await res
|
|
70
|
+
return str(res) if res is not None else ""
|
|
71
|
+
|
|
72
|
+
if any(a in ("--help", "-h") for a in args):
|
|
73
|
+
return self.help_text()
|
|
74
|
+
|
|
75
|
+
parsed_kwargs = self._parse_args(args, stdin)
|
|
76
|
+
if isinstance(parsed_kwargs, str):
|
|
77
|
+
# Validation error string with self-healing --help
|
|
78
|
+
return f"Error: {parsed_kwargs}\n{self.help_text()}"
|
|
79
|
+
|
|
80
|
+
res = self.fn(**parsed_kwargs)
|
|
81
|
+
if inspect.isawaitable(res):
|
|
82
|
+
res = await res
|
|
83
|
+
return str(res) if res is not None else ""
|
|
84
|
+
|
|
85
|
+
def _parse_args(self, args: list[str], stdin: str | None) -> dict[str, Any] | str:
|
|
86
|
+
param_by_flag = {p.flag: p for p in self.params}
|
|
87
|
+
param_by_name = {p.name: p for p in self.params}
|
|
88
|
+
values: dict[str, Any] = {}
|
|
89
|
+
positionals: list[str] = []
|
|
90
|
+
|
|
91
|
+
i = 0
|
|
92
|
+
while i < len(args):
|
|
93
|
+
tok = args[i]
|
|
94
|
+
if tok.startswith("--"):
|
|
95
|
+
if "=" in tok:
|
|
96
|
+
flag, raw_val = tok.split("=", 1)
|
|
97
|
+
else:
|
|
98
|
+
flag = tok
|
|
99
|
+
raw_val = None
|
|
100
|
+
p = param_by_flag.get(flag) or param_by_name.get(flag.lstrip("-").replace("-", "_"))
|
|
101
|
+
if not p:
|
|
102
|
+
return f"unknown flag `{flag}`."
|
|
103
|
+
if p.type_name == "bool" and raw_val is None:
|
|
104
|
+
if i + 1 < len(args) and args[i + 1].lower() in ("true", "false", "1", "0"):
|
|
105
|
+
raw_val = args[i + 1]
|
|
106
|
+
i += 1
|
|
107
|
+
else:
|
|
108
|
+
values[p.name] = True
|
|
109
|
+
i += 1
|
|
110
|
+
continue
|
|
111
|
+
elif raw_val is None:
|
|
112
|
+
if i + 1 >= len(args):
|
|
113
|
+
return f"flag `{flag}` requires a value."
|
|
114
|
+
raw_val = args[i + 1]
|
|
115
|
+
i += 1
|
|
116
|
+
converted = _coerce_value(raw_val, p)
|
|
117
|
+
if isinstance(converted, tuple):
|
|
118
|
+
return str(converted[1])
|
|
119
|
+
values[p.name] = converted
|
|
120
|
+
else:
|
|
121
|
+
positionals.append(tok)
|
|
122
|
+
i += 1
|
|
123
|
+
|
|
124
|
+
# Map remaining positional arguments in order
|
|
125
|
+
unfilled = [p for p in self.params if p.name not in values]
|
|
126
|
+
pos_idx = 0
|
|
127
|
+
for p in unfilled:
|
|
128
|
+
if p.is_variadic:
|
|
129
|
+
rem = positionals[pos_idx:]
|
|
130
|
+
pos_idx = len(positionals)
|
|
131
|
+
values[p.name] = " ".join(rem)
|
|
132
|
+
elif pos_idx < len(positionals):
|
|
133
|
+
converted = _coerce_value(positionals[pos_idx], p)
|
|
134
|
+
if isinstance(converted, tuple):
|
|
135
|
+
return str(converted[1])
|
|
136
|
+
values[p.name] = converted
|
|
137
|
+
pos_idx += 1
|
|
138
|
+
elif not p.required:
|
|
139
|
+
values[p.name] = p.default
|
|
140
|
+
elif stdin is not None and len(self.params) == 1 and p.type_name == "str":
|
|
141
|
+
# Allow single required string param to be satisfied via stdin
|
|
142
|
+
values[p.name] = stdin
|
|
143
|
+
stdin = None
|
|
144
|
+
else:
|
|
145
|
+
return f"missing required argument `<{p.name}:{p.type_name}>`."
|
|
146
|
+
|
|
147
|
+
if pos_idx < len(positionals):
|
|
148
|
+
# If the last parameter is a string and not set by flag, join trailing unquoted words
|
|
149
|
+
if self.params and self.params[-1].type_name == "str":
|
|
150
|
+
last_p = self.params[-1]
|
|
151
|
+
extra = " ".join(positionals[pos_idx:])
|
|
152
|
+
values[last_p.name] = f"{values[last_p.name]} {extra}".strip()
|
|
153
|
+
else:
|
|
154
|
+
return f"unexpected positional arguments: {' '.join(positionals[pos_idx:])}"
|
|
155
|
+
|
|
156
|
+
if self.accepts_stdin:
|
|
157
|
+
values["stdin"] = stdin
|
|
158
|
+
|
|
159
|
+
return values
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def _type_label(ann: Any) -> str:
|
|
163
|
+
origin = get_origin(ann)
|
|
164
|
+
if origin is Literal:
|
|
165
|
+
choices = "|".join(str(a) for a in get_args(ann))
|
|
166
|
+
return choices
|
|
167
|
+
if ann is int:
|
|
168
|
+
return "int"
|
|
169
|
+
if ann is float:
|
|
170
|
+
return "float"
|
|
171
|
+
if ann is bool:
|
|
172
|
+
return "bool"
|
|
173
|
+
return "str"
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
def _coerce_value(raw: str, p: ParamSpec) -> Any | tuple[Literal["err"], str]:
|
|
177
|
+
origin = get_origin(p.annotation)
|
|
178
|
+
if origin is Literal:
|
|
179
|
+
allowed = [str(a) for a in get_args(p.annotation)]
|
|
180
|
+
if raw not in allowed:
|
|
181
|
+
return ("err", f"`{p.name}` must be one of {'|'.join(allowed)} (got `{raw}`).")
|
|
182
|
+
return raw
|
|
183
|
+
if p.annotation is int:
|
|
184
|
+
try:
|
|
185
|
+
return int(raw)
|
|
186
|
+
except ValueError:
|
|
187
|
+
return ("err", f"`{p.name}` expects int (got `{raw}`).")
|
|
188
|
+
if p.annotation is float:
|
|
189
|
+
try:
|
|
190
|
+
return float(raw)
|
|
191
|
+
except ValueError:
|
|
192
|
+
return ("err", f"`{p.name}` expects float (got `{raw}`).")
|
|
193
|
+
if p.annotation is bool:
|
|
194
|
+
low = raw.lower()
|
|
195
|
+
if low in ("true", "1", "yes"):
|
|
196
|
+
return True
|
|
197
|
+
if low in ("false", "0", "no"):
|
|
198
|
+
return False
|
|
199
|
+
return ("err", f"`{p.name}` expects bool (got `{raw}`).")
|
|
200
|
+
return raw
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
def build_tool_spec(
|
|
204
|
+
fn: Callable[..., Any],
|
|
205
|
+
*,
|
|
206
|
+
name: str | None = None,
|
|
207
|
+
description: str | None = None,
|
|
208
|
+
custom_help: str | None = None,
|
|
209
|
+
raw_dispatch: bool = False,
|
|
210
|
+
) -> ToolSpec:
|
|
211
|
+
"""Introspect a Python function into a ToolSpec."""
|
|
212
|
+
tool_name = (name or fn.__name__).strip().replace("_", "-")
|
|
213
|
+
doc = inspect.getdoc(fn) or ""
|
|
214
|
+
first_line = doc.splitlines()[0].strip() if doc else f"Run {tool_name}"
|
|
215
|
+
tool_desc = description or first_line
|
|
216
|
+
|
|
217
|
+
sig = inspect.signature(fn)
|
|
218
|
+
try:
|
|
219
|
+
hints = get_type_hints(fn)
|
|
220
|
+
except Exception:
|
|
221
|
+
hints = {}
|
|
222
|
+
|
|
223
|
+
params: list[ParamSpec] = []
|
|
224
|
+
accepts_stdin = False
|
|
225
|
+
|
|
226
|
+
if not raw_dispatch:
|
|
227
|
+
for param_name, param in sig.parameters.items():
|
|
228
|
+
if param_name == "stdin":
|
|
229
|
+
accepts_stdin = True
|
|
230
|
+
continue
|
|
231
|
+
ann = hints.get(param_name, param.annotation)
|
|
232
|
+
if ann is inspect.Parameter.empty:
|
|
233
|
+
ann = str
|
|
234
|
+
is_var = param.kind == inspect.Parameter.VAR_POSITIONAL
|
|
235
|
+
required = param.default is inspect.Parameter.empty and not is_var
|
|
236
|
+
default_val = None if required else param.default
|
|
237
|
+
flag = f"--{param_name.replace('_', '-')}"
|
|
238
|
+
params.append(
|
|
239
|
+
ParamSpec(
|
|
240
|
+
name=param_name,
|
|
241
|
+
flag=flag,
|
|
242
|
+
type_name=_type_label(ann),
|
|
243
|
+
annotation=ann,
|
|
244
|
+
required=required,
|
|
245
|
+
default=default_val,
|
|
246
|
+
is_variadic=is_var,
|
|
247
|
+
)
|
|
248
|
+
)
|
|
249
|
+
|
|
250
|
+
spec = ToolSpec(
|
|
251
|
+
name=tool_name,
|
|
252
|
+
description=tool_desc,
|
|
253
|
+
fn=fn,
|
|
254
|
+
params=params,
|
|
255
|
+
accepts_stdin=accepts_stdin,
|
|
256
|
+
is_async=inspect.iscoroutinefunction(fn),
|
|
257
|
+
custom_help=custom_help,
|
|
258
|
+
raw_dispatch=raw_dispatch,
|
|
259
|
+
)
|
|
260
|
+
setattr(fn, "__tether_tool__", spec) # noqa: B010
|
|
261
|
+
return spec
|
|
262
|
+
|
|
263
|
+
|
|
264
|
+
def tool(
|
|
265
|
+
fn: Callable[..., Any] | None = None,
|
|
266
|
+
*,
|
|
267
|
+
name: str | None = None,
|
|
268
|
+
namespace: str | None = None,
|
|
269
|
+
description: str | None = None,
|
|
270
|
+
) -> Any:
|
|
271
|
+
"""Decorator to register a sync or async Python function as a Tether virtual CLI tool."""
|
|
272
|
+
effective_name = name or namespace
|
|
273
|
+
|
|
274
|
+
def decorator(target: Callable[..., Any | Awaitable[Any]]) -> Callable[..., Any]:
|
|
275
|
+
build_tool_spec(target, name=effective_name, description=description)
|
|
276
|
+
return target
|
|
277
|
+
|
|
278
|
+
if fn is not None:
|
|
279
|
+
return decorator(fn)
|
|
280
|
+
return decorator
|