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/harness.py
ADDED
|
@@ -0,0 +1,435 @@
|
|
|
1
|
+
"""Control Plane: Tether Harness, bounded agent loop, live SQLite inbox, and headless spawner."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
import subprocess
|
|
7
|
+
import sys
|
|
8
|
+
from collections.abc import Callable
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
from typing import Any
|
|
11
|
+
|
|
12
|
+
from tether.context import ACT_TOOL_SCHEMA, ContextAssembler, estimate_messages_tokens
|
|
13
|
+
from tether.decorator import ToolSpec, build_tool_spec
|
|
14
|
+
from tether.multiplexer import Multiplexer
|
|
15
|
+
from tether.providers import ModelProvider, resolve_provider
|
|
16
|
+
from tether.sandbox import SandboxPolicy
|
|
17
|
+
from tether.skills import Skill, SkillLoader, make_skill_tool
|
|
18
|
+
from tether.store import SessionRecord, SQLiteStore
|
|
19
|
+
from tether.tools import (
|
|
20
|
+
ALL_BUILTIN_TOOLS,
|
|
21
|
+
make_agent_tool,
|
|
22
|
+
make_bash_tool,
|
|
23
|
+
make_done_tool,
|
|
24
|
+
make_edit_tool,
|
|
25
|
+
make_mem_tool,
|
|
26
|
+
make_web_tool,
|
|
27
|
+
)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def spawn_detached_worker(db_path: Path, session_id: str) -> int:
|
|
31
|
+
"""Spawn a detached OS background worker (`python -m tether.cli _worker <session_id>`)."""
|
|
32
|
+
env = dict(os.environ)
|
|
33
|
+
env["TETHER_DB"] = str(db_path)
|
|
34
|
+
proc = subprocess.Popen(
|
|
35
|
+
[sys.executable, "-m", "tether.cli", "_worker", session_id, "--db", str(db_path)],
|
|
36
|
+
stdin=subprocess.DEVNULL,
|
|
37
|
+
stdout=subprocess.DEVNULL,
|
|
38
|
+
stderr=subprocess.DEVNULL,
|
|
39
|
+
start_new_session=True,
|
|
40
|
+
env=env,
|
|
41
|
+
)
|
|
42
|
+
return proc.pid
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
class Harness:
|
|
46
|
+
"""General-purpose LLM-Native Micro-Harness."""
|
|
47
|
+
|
|
48
|
+
def __init__(
|
|
49
|
+
self,
|
|
50
|
+
*,
|
|
51
|
+
model: str | None = None,
|
|
52
|
+
tools: list[str | ToolSpec | Callable[..., Any]] | str | None = None,
|
|
53
|
+
skill: str | Skill | None = None,
|
|
54
|
+
sandbox: SandboxPolicy | None = None,
|
|
55
|
+
db_path: str | Path | None = None,
|
|
56
|
+
max_steps: int = 15,
|
|
57
|
+
max_context_tokens: int = 8192,
|
|
58
|
+
fold_observations_after: int = 2,
|
|
59
|
+
provider: ModelProvider | None = None,
|
|
60
|
+
skill_search_dirs: list[str | Path] | None = None,
|
|
61
|
+
worker_spawner: Callable[[str], int | None] | None = None,
|
|
62
|
+
) -> None:
|
|
63
|
+
self.model = model or os.environ.get("TETHER_MODEL") or "ollama/qwen3.5:9b"
|
|
64
|
+
self.store = SQLiteStore(db_path=db_path)
|
|
65
|
+
self.sandbox = sandbox or SandboxPolicy()
|
|
66
|
+
self.max_steps = max_steps
|
|
67
|
+
self.max_context_tokens = max_context_tokens
|
|
68
|
+
self.fold_observations_after = fold_observations_after
|
|
69
|
+
self._provider = provider
|
|
70
|
+
self.skill_loader = SkillLoader(search_dirs=skill_search_dirs)
|
|
71
|
+
self._worker_spawner = worker_spawner
|
|
72
|
+
|
|
73
|
+
self._custom_tools: dict[str, ToolSpec] = {}
|
|
74
|
+
self._enabled_tool_names: list[str] = []
|
|
75
|
+
|
|
76
|
+
if isinstance(skill, Skill):
|
|
77
|
+
self._skill: Skill | None = skill
|
|
78
|
+
self.skill_name: str | None = skill.name
|
|
79
|
+
elif isinstance(skill, str) and skill.strip():
|
|
80
|
+
self.skill_name = skill.strip()
|
|
81
|
+
self._skill = self.skill_loader.load(self.skill_name)
|
|
82
|
+
else:
|
|
83
|
+
self._skill = None
|
|
84
|
+
self.skill_name = None
|
|
85
|
+
|
|
86
|
+
if self._skill:
|
|
87
|
+
if model is None and self._skill.model:
|
|
88
|
+
self.model = self._skill.model
|
|
89
|
+
for ct in self._skill.custom_tools:
|
|
90
|
+
self._custom_tools[ct.name] = ct
|
|
91
|
+
|
|
92
|
+
self._configure_initial_tools(tools)
|
|
93
|
+
|
|
94
|
+
def _configure_initial_tools(
|
|
95
|
+
self, tools: list[str | ToolSpec | Callable[..., Any]] | str | None
|
|
96
|
+
) -> None:
|
|
97
|
+
raw_items: list[str | ToolSpec | Callable[..., Any]] = []
|
|
98
|
+
if tools is None:
|
|
99
|
+
if self._skill and self._skill.tools:
|
|
100
|
+
raw_items.extend(self._skill.tools)
|
|
101
|
+
elif isinstance(tools, str):
|
|
102
|
+
for part in tools.split(","):
|
|
103
|
+
if part.strip():
|
|
104
|
+
raw_items.append(part.strip())
|
|
105
|
+
else:
|
|
106
|
+
raw_items.extend(tools)
|
|
107
|
+
|
|
108
|
+
for item in raw_items:
|
|
109
|
+
if isinstance(item, str):
|
|
110
|
+
name = item.strip()
|
|
111
|
+
if name == "all":
|
|
112
|
+
for b in ALL_BUILTIN_TOOLS:
|
|
113
|
+
if b not in self._enabled_tool_names:
|
|
114
|
+
self._enabled_tool_names.append(b)
|
|
115
|
+
elif name and name != "done" and name not in self._enabled_tool_names:
|
|
116
|
+
self._enabled_tool_names.append(name)
|
|
117
|
+
else:
|
|
118
|
+
self.register(item)
|
|
119
|
+
|
|
120
|
+
def register(self, fn_or_spec: ToolSpec | Callable[..., Any]) -> ToolSpec:
|
|
121
|
+
"""Register a custom Python function or ToolSpec with this Harness."""
|
|
122
|
+
if isinstance(fn_or_spec, ToolSpec):
|
|
123
|
+
spec = fn_or_spec
|
|
124
|
+
elif hasattr(fn_or_spec, "__tether_tool__"):
|
|
125
|
+
spec = fn_or_spec.__tether_tool__
|
|
126
|
+
else:
|
|
127
|
+
spec = build_tool_spec(fn_or_spec)
|
|
128
|
+
self._custom_tools[spec.name] = spec
|
|
129
|
+
if spec.name not in self._enabled_tool_names:
|
|
130
|
+
self._enabled_tool_names.append(spec.name)
|
|
131
|
+
return spec
|
|
132
|
+
|
|
133
|
+
def _spawner_for_store(self) -> Callable[[str], int | None]:
|
|
134
|
+
if self._worker_spawner is not None:
|
|
135
|
+
return self._worker_spawner
|
|
136
|
+
|
|
137
|
+
def _default_spawn(session_id: str) -> int | None:
|
|
138
|
+
pid = spawn_detached_worker(self.store.db_path, session_id)
|
|
139
|
+
self.store.update_session(session_id, pid=pid)
|
|
140
|
+
return pid
|
|
141
|
+
|
|
142
|
+
return _default_spawn
|
|
143
|
+
|
|
144
|
+
def build_multiplexer(
|
|
145
|
+
self,
|
|
146
|
+
session_id: str,
|
|
147
|
+
enabled_tools: list[str],
|
|
148
|
+
sandbox: SandboxPolicy,
|
|
149
|
+
model: str,
|
|
150
|
+
) -> Multiplexer:
|
|
151
|
+
"""Construct the session Multiplexer with `done` + strictly opted-in tools."""
|
|
152
|
+
mux = Multiplexer()
|
|
153
|
+
# `done` is always registered first
|
|
154
|
+
mux.register(make_done_tool())
|
|
155
|
+
|
|
156
|
+
names_to_load: list[str] = []
|
|
157
|
+
for t in enabled_tools:
|
|
158
|
+
if t == "all":
|
|
159
|
+
for b in ALL_BUILTIN_TOOLS:
|
|
160
|
+
if b not in names_to_load:
|
|
161
|
+
names_to_load.append(b)
|
|
162
|
+
elif t != "done" and t not in names_to_load:
|
|
163
|
+
names_to_load.append(t)
|
|
164
|
+
|
|
165
|
+
for name in names_to_load:
|
|
166
|
+
if name in self._custom_tools:
|
|
167
|
+
mux.register(self._custom_tools[name])
|
|
168
|
+
elif name == "bash":
|
|
169
|
+
mux.register(make_bash_tool(sandbox))
|
|
170
|
+
elif name == "edit":
|
|
171
|
+
mux.register(make_edit_tool(sandbox))
|
|
172
|
+
elif name == "web":
|
|
173
|
+
mux.register(make_web_tool())
|
|
174
|
+
elif name == "mem":
|
|
175
|
+
mux.register(make_mem_tool(self.store, session_id))
|
|
176
|
+
elif name == "skill":
|
|
177
|
+
mux.register(make_skill_tool(self.skill_loader, mux))
|
|
178
|
+
elif name == "agent":
|
|
179
|
+
mux.register(
|
|
180
|
+
make_agent_tool(
|
|
181
|
+
store=self.store,
|
|
182
|
+
parent_session_id=session_id,
|
|
183
|
+
default_model=model,
|
|
184
|
+
parent_sandbox=sandbox,
|
|
185
|
+
skill_loader=self.skill_loader,
|
|
186
|
+
worker_spawner=self._spawner_for_store(),
|
|
187
|
+
)
|
|
188
|
+
)
|
|
189
|
+
return mux
|
|
190
|
+
|
|
191
|
+
def create_session(self, goal: str, *, parent_id: str | None = None) -> SessionRecord:
|
|
192
|
+
"""Create a persisted session row in SQLite without executing it yet."""
|
|
193
|
+
return self.store.create_session(
|
|
194
|
+
goal=goal,
|
|
195
|
+
model=self.model,
|
|
196
|
+
skill=self.skill_name,
|
|
197
|
+
tools=self._enabled_tool_names,
|
|
198
|
+
sandbox_config=self.sandbox.to_dict(),
|
|
199
|
+
parent_id=parent_id,
|
|
200
|
+
max_steps=self.max_steps,
|
|
201
|
+
)
|
|
202
|
+
|
|
203
|
+
def spawn(self, goal: str, *, parent_id: str | None = None) -> str:
|
|
204
|
+
"""Create a session and launch it as a detached headless worker, returning session_id."""
|
|
205
|
+
rec = self.create_session(goal, parent_id=parent_id)
|
|
206
|
+
spawner = self._spawner_for_store()
|
|
207
|
+
spawner(rec.id)
|
|
208
|
+
return rec.id
|
|
209
|
+
|
|
210
|
+
async def run(self, goal: str, *, parent_id: str | None = None) -> SessionRecord:
|
|
211
|
+
"""Create and run a session inline until completion (`done`) or `max_steps`."""
|
|
212
|
+
rec = self.create_session(goal, parent_id=parent_id)
|
|
213
|
+
return await self.execute_session(rec.id)
|
|
214
|
+
|
|
215
|
+
async def send(
|
|
216
|
+
self,
|
|
217
|
+
session_id: str,
|
|
218
|
+
message: str,
|
|
219
|
+
*,
|
|
220
|
+
headless: bool = False,
|
|
221
|
+
) -> SessionRecord:
|
|
222
|
+
"""Send a message to a session's SQLite inbox; auto-resumes if COMPLETED/FAILED."""
|
|
223
|
+
rec = self.store.get_session(session_id)
|
|
224
|
+
if not rec:
|
|
225
|
+
raise ValueError(f"Session `{session_id}` not found.")
|
|
226
|
+
|
|
227
|
+
self.store.enqueue_inbox(session_id, message)
|
|
228
|
+
if rec.status == "RUNNING":
|
|
229
|
+
return rec
|
|
230
|
+
|
|
231
|
+
# Session was IDLE/COMPLETED/FAILED -> resume it
|
|
232
|
+
if headless:
|
|
233
|
+
self.store.update_session(session_id, status="PENDING")
|
|
234
|
+
spawner = self._spawner_for_store()
|
|
235
|
+
spawner(session_id)
|
|
236
|
+
updated = self.store.get_session(session_id)
|
|
237
|
+
assert updated is not None
|
|
238
|
+
return updated
|
|
239
|
+
|
|
240
|
+
return await self.execute_session(session_id)
|
|
241
|
+
|
|
242
|
+
async def execute_session(self, session_id: str) -> SessionRecord:
|
|
243
|
+
"""Execute the bounded agent loop for a persisted session in SQLite."""
|
|
244
|
+
rec = self.store.get_session(session_id)
|
|
245
|
+
if not rec:
|
|
246
|
+
raise ValueError(f"Session `{session_id}` not found.")
|
|
247
|
+
|
|
248
|
+
sandbox = SandboxPolicy.from_dict(rec.sandbox_config)
|
|
249
|
+
skill_obj = self._skill
|
|
250
|
+
if skill_obj is None and rec.skill:
|
|
251
|
+
skill_obj = self.skill_loader.load(rec.skill)
|
|
252
|
+
if skill_obj:
|
|
253
|
+
for ct in skill_obj.custom_tools:
|
|
254
|
+
self._custom_tools.setdefault(ct.name, ct)
|
|
255
|
+
|
|
256
|
+
enabled_tools = list(rec.tools)
|
|
257
|
+
if skill_obj and skill_obj.tools:
|
|
258
|
+
for st in skill_obj.tools:
|
|
259
|
+
if st not in enabled_tools:
|
|
260
|
+
enabled_tools.append(st)
|
|
261
|
+
|
|
262
|
+
mux = self.build_multiplexer(
|
|
263
|
+
session_id=session_id,
|
|
264
|
+
enabled_tools=enabled_tools,
|
|
265
|
+
sandbox=sandbox,
|
|
266
|
+
model=rec.model,
|
|
267
|
+
)
|
|
268
|
+
|
|
269
|
+
provider = self._provider or resolve_provider(rec.model, num_ctx=self.max_context_tokens)
|
|
270
|
+
assembler = ContextAssembler(
|
|
271
|
+
fold_after_turns=self.fold_observations_after,
|
|
272
|
+
max_context_tokens=self.max_context_tokens,
|
|
273
|
+
)
|
|
274
|
+
system_prompt = assembler.build_system_prompt(
|
|
275
|
+
toolbox_index=mux.toolbox_index(),
|
|
276
|
+
skill_instructions=skill_obj.instructions if skill_obj else None,
|
|
277
|
+
)
|
|
278
|
+
|
|
279
|
+
self.store.update_session(session_id, status="RUNNING", pid=os.getpid())
|
|
280
|
+
|
|
281
|
+
step_start = rec.step_count
|
|
282
|
+
step_limit = step_start + rec.max_steps
|
|
283
|
+
last_action_sig: tuple[str, str | None] | None = None
|
|
284
|
+
|
|
285
|
+
try:
|
|
286
|
+
for step in range(step_start + 1, step_limit + 1):
|
|
287
|
+
# 1. Poll SQLite inbox before each reasoning step
|
|
288
|
+
inbox_msgs = self.store.consume_inbox(session_id)
|
|
289
|
+
if inbox_msgs:
|
|
290
|
+
last_action_sig = None
|
|
291
|
+
for imsg in inbox_msgs:
|
|
292
|
+
self.store.add_turn(
|
|
293
|
+
session_id=session_id,
|
|
294
|
+
step=step,
|
|
295
|
+
role="user",
|
|
296
|
+
content=f"[Follow-up instruction]: {imsg}",
|
|
297
|
+
)
|
|
298
|
+
|
|
299
|
+
# 2. Assemble context with ephemeral observation folding
|
|
300
|
+
turns = self.store.get_turns(session_id)
|
|
301
|
+
observations = self.store.list_observations(session_id)
|
|
302
|
+
pins = self.store.get_pins(session_id)
|
|
303
|
+
messages = assembler.assemble(
|
|
304
|
+
system_prompt=system_prompt,
|
|
305
|
+
goal=rec.goal,
|
|
306
|
+
turns=turns,
|
|
307
|
+
observations=observations,
|
|
308
|
+
pins=pins,
|
|
309
|
+
current_step=step,
|
|
310
|
+
)
|
|
311
|
+
|
|
312
|
+
est_tokens = estimate_messages_tokens(messages, [ACT_TOOL_SCHEMA])
|
|
313
|
+
resp = await provider.generate(messages, [ACT_TOOL_SCHEMA])
|
|
314
|
+
actual_tokens = max(est_tokens, resp.prompt_tokens)
|
|
315
|
+
resolved_model = (
|
|
316
|
+
f"ollama/{provider.model_name}"
|
|
317
|
+
if rec.model.startswith("ollama/")
|
|
318
|
+
and not rec.model.endswith(provider.model_name)
|
|
319
|
+
else None
|
|
320
|
+
)
|
|
321
|
+
self.store.update_session(
|
|
322
|
+
session_id,
|
|
323
|
+
model=resolved_model,
|
|
324
|
+
step_count=step,
|
|
325
|
+
peak_tokens=actual_tokens,
|
|
326
|
+
)
|
|
327
|
+
|
|
328
|
+
# 3. If the model emitted plain text without calling `act`, nudge it (Rule 3)
|
|
329
|
+
if resp.action is None:
|
|
330
|
+
self.store.add_turn(
|
|
331
|
+
session_id=session_id,
|
|
332
|
+
step=step,
|
|
333
|
+
role="assistant",
|
|
334
|
+
content=resp.content or "(no output)",
|
|
335
|
+
)
|
|
336
|
+
self.store.add_turn(
|
|
337
|
+
session_id=session_id,
|
|
338
|
+
step=step,
|
|
339
|
+
role="user",
|
|
340
|
+
content=(
|
|
341
|
+
"System reminder: Use `act(cmd, stdin)` to execute a tool command, "
|
|
342
|
+
'or call `act("done", stdin="<final deliverable>")` to finish.'
|
|
343
|
+
),
|
|
344
|
+
)
|
|
345
|
+
continue
|
|
346
|
+
|
|
347
|
+
cmd = resp.action.cmd
|
|
348
|
+
stdin = resp.action.stdin
|
|
349
|
+
self.store.add_turn(
|
|
350
|
+
session_id=session_id,
|
|
351
|
+
step=step,
|
|
352
|
+
role="assistant",
|
|
353
|
+
content=resp.content,
|
|
354
|
+
tool_cmd=cmd,
|
|
355
|
+
tool_stdin=stdin,
|
|
356
|
+
)
|
|
357
|
+
|
|
358
|
+
action_sig = (cmd.strip(), stdin)
|
|
359
|
+
if action_sig == last_action_sig and not cmd.strip().startswith("done"):
|
|
360
|
+
stall_msg = (
|
|
361
|
+
f"[LOOP DETECTED] You just ran `{cmd.strip()}` with identical input in the "
|
|
362
|
+
"previous step. Do not repeat the same command—inspect the prior output, "
|
|
363
|
+
'adjust your arguments, or call `act("done", stdin="...")` to finish.'
|
|
364
|
+
)
|
|
365
|
+
self.store.add_turn(
|
|
366
|
+
session_id=session_id,
|
|
367
|
+
step=step,
|
|
368
|
+
role="tool",
|
|
369
|
+
content=stall_msg,
|
|
370
|
+
)
|
|
371
|
+
continue
|
|
372
|
+
last_action_sig = action_sig
|
|
373
|
+
|
|
374
|
+
dispatch = await mux.execute(cmd, stdin=stdin)
|
|
375
|
+
if dispatch.is_done:
|
|
376
|
+
# Check if any new message arrived in inbox while executing this step
|
|
377
|
+
late_inbox = self.store.consume_inbox(session_id)
|
|
378
|
+
if late_inbox:
|
|
379
|
+
for lmsg in late_inbox:
|
|
380
|
+
self.store.add_turn(
|
|
381
|
+
session_id=session_id,
|
|
382
|
+
step=step,
|
|
383
|
+
role="user",
|
|
384
|
+
content=f"[Follow-up instruction]: {lmsg}",
|
|
385
|
+
)
|
|
386
|
+
continue
|
|
387
|
+
|
|
388
|
+
final_text = dispatch.final_result or ""
|
|
389
|
+
self.store.add_turn(
|
|
390
|
+
session_id=session_id,
|
|
391
|
+
step=step,
|
|
392
|
+
role="tool",
|
|
393
|
+
content=f"[COMPLETED] {final_text}",
|
|
394
|
+
)
|
|
395
|
+
self.store.update_session(
|
|
396
|
+
session_id,
|
|
397
|
+
status="COMPLETED",
|
|
398
|
+
result=final_text,
|
|
399
|
+
step_count=step,
|
|
400
|
+
)
|
|
401
|
+
done_rec = self.store.get_session(session_id)
|
|
402
|
+
assert done_rec is not None
|
|
403
|
+
return done_rec
|
|
404
|
+
|
|
405
|
+
# Record tool observation in SQLite (auto-chunks into FTS5)
|
|
406
|
+
obs = self.store.add_observation(
|
|
407
|
+
session_id=session_id,
|
|
408
|
+
step=step,
|
|
409
|
+
cmd=cmd,
|
|
410
|
+
raw_output=dispatch.output,
|
|
411
|
+
)
|
|
412
|
+
self.store.add_turn(
|
|
413
|
+
session_id=session_id,
|
|
414
|
+
step=step,
|
|
415
|
+
role="tool",
|
|
416
|
+
content=dispatch.output,
|
|
417
|
+
obs_id=obs.id,
|
|
418
|
+
)
|
|
419
|
+
|
|
420
|
+
# Max steps reached without explicit `done`
|
|
421
|
+
self.store.update_session(
|
|
422
|
+
session_id,
|
|
423
|
+
status="FAILED",
|
|
424
|
+
result=f"Max steps ({rec.max_steps}) reached without calling `done`.",
|
|
425
|
+
)
|
|
426
|
+
except Exception as exc:
|
|
427
|
+
self.store.update_session(
|
|
428
|
+
session_id,
|
|
429
|
+
status="FAILED",
|
|
430
|
+
result=f"Worker error: {exc}",
|
|
431
|
+
)
|
|
432
|
+
|
|
433
|
+
final_rec = self.store.get_session(session_id)
|
|
434
|
+
assert final_rec is not None
|
|
435
|
+
return final_rec
|
tether/multiplexer.py
ADDED
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
"""Virtual CLI Multiplexer (`act(cmd, stdin)`) with 9B quirk normalization and JIT --help."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import shlex
|
|
6
|
+
from collections.abc import Callable
|
|
7
|
+
from dataclasses import dataclass
|
|
8
|
+
from typing import Any
|
|
9
|
+
|
|
10
|
+
from tether.decorator import ToolSpec, build_tool_spec
|
|
11
|
+
from tether.sandbox import SandboxViolationError
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
@dataclass(slots=True)
|
|
15
|
+
class DispatchResult:
|
|
16
|
+
output: str
|
|
17
|
+
is_done: bool = False
|
|
18
|
+
final_result: str | None = None
|
|
19
|
+
is_error: bool = False
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def normalize_cmd(raw_cmd: str) -> str:
|
|
23
|
+
"""Normalize common small-model CLI syntax quirks deterministically."""
|
|
24
|
+
s = raw_cmd.strip()
|
|
25
|
+
# Strip wrapping markdown code fences or inline backticks
|
|
26
|
+
if s.startswith("```") and s.endswith("```"):
|
|
27
|
+
lines = s.splitlines()
|
|
28
|
+
if len(lines) >= 2:
|
|
29
|
+
s = "\n".join(lines[1:-1]).strip()
|
|
30
|
+
if len(s) >= 2 and s.startswith("`") and s.endswith("`") and "`" not in s[1:-1]:
|
|
31
|
+
s = s[1:-1].strip()
|
|
32
|
+
# Strip leading shell prompt markers ($ or >)
|
|
33
|
+
if s.startswith("$ "):
|
|
34
|
+
s = s[2:].strip()
|
|
35
|
+
elif s.startswith("> "):
|
|
36
|
+
s = s[2:].strip()
|
|
37
|
+
# Strip redundant leading `tether ` prefix if the model typed `tether web search ...`
|
|
38
|
+
if s.startswith("tether "):
|
|
39
|
+
s = s[len("tether ") :].strip()
|
|
40
|
+
return s
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def split_cli(cmd: str) -> list[str]:
|
|
44
|
+
"""Split a CLI command string using POSIX shlex with graceful fallback on unclosed quotes."""
|
|
45
|
+
try:
|
|
46
|
+
return shlex.split(cmd, posix=True)
|
|
47
|
+
except ValueError:
|
|
48
|
+
return [tok.strip("\"'") for tok in cmd.split() if tok.strip("\"'")]
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
class Multiplexer:
|
|
52
|
+
"""Routes `act(cmd, stdin)` invocations across registered tools."""
|
|
53
|
+
|
|
54
|
+
def __init__(self) -> None:
|
|
55
|
+
self._tools: dict[str, ToolSpec] = {}
|
|
56
|
+
|
|
57
|
+
def register(self, item: ToolSpec | Callable[..., Any]) -> ToolSpec:
|
|
58
|
+
if isinstance(item, ToolSpec):
|
|
59
|
+
spec = item
|
|
60
|
+
elif hasattr(item, "__tether_tool__"):
|
|
61
|
+
spec = item.__tether_tool__
|
|
62
|
+
else:
|
|
63
|
+
spec = build_tool_spec(item)
|
|
64
|
+
self._tools[spec.name] = spec
|
|
65
|
+
return spec
|
|
66
|
+
|
|
67
|
+
def get_tool(self, name: str) -> ToolSpec | None:
|
|
68
|
+
return self._tools.get(name)
|
|
69
|
+
|
|
70
|
+
@property
|
|
71
|
+
def tool_names(self) -> list[str]:
|
|
72
|
+
return list(self._tools.keys())
|
|
73
|
+
|
|
74
|
+
def toolbox_index(self) -> str:
|
|
75
|
+
"""Render the ultra-compact 1-line-per-tool directory for the system prompt."""
|
|
76
|
+
lines = [spec.index_line() for spec in self._tools.values()]
|
|
77
|
+
return "\n".join(lines)
|
|
78
|
+
|
|
79
|
+
async def execute(self, cmd: str, stdin: str | None = None) -> DispatchResult:
|
|
80
|
+
"""Execute a virtual CLI command and return a DispatchResult."""
|
|
81
|
+
clean_cmd = normalize_cmd(cmd)
|
|
82
|
+
if not clean_cmd:
|
|
83
|
+
avail = ", ".join(self._tools.keys())
|
|
84
|
+
return DispatchResult(
|
|
85
|
+
output=f"Error: empty command. Available tools: {avail}. Run `<tool> --help`.",
|
|
86
|
+
is_error=True,
|
|
87
|
+
)
|
|
88
|
+
|
|
89
|
+
tokens = split_cli(clean_cmd)
|
|
90
|
+
if not tokens:
|
|
91
|
+
return DispatchResult(output="Error: empty command.", is_error=True)
|
|
92
|
+
|
|
93
|
+
name = tokens[0]
|
|
94
|
+
args = tokens[1:]
|
|
95
|
+
|
|
96
|
+
if name in ("help", "--help", "-h"):
|
|
97
|
+
if args and args[0] in self._tools:
|
|
98
|
+
return DispatchResult(output=self._tools[args[0]].help_text())
|
|
99
|
+
return DispatchResult(
|
|
100
|
+
output=f"Available commands:\n{self.toolbox_index()}\nRun `<cmd> --help` for usage."
|
|
101
|
+
)
|
|
102
|
+
|
|
103
|
+
spec = self._tools.get(name)
|
|
104
|
+
if spec is None:
|
|
105
|
+
avail = ", ".join(self._tools.keys())
|
|
106
|
+
return DispatchResult(
|
|
107
|
+
output=f"Error: unknown command `{name}`. Enabled tools: {avail}.",
|
|
108
|
+
is_error=True,
|
|
109
|
+
)
|
|
110
|
+
|
|
111
|
+
try:
|
|
112
|
+
out = await spec.invoke(args, stdin=stdin)
|
|
113
|
+
except SandboxViolationError as exc:
|
|
114
|
+
return DispatchResult(output=str(exc), is_error=True)
|
|
115
|
+
except Exception as exc:
|
|
116
|
+
return DispatchResult(
|
|
117
|
+
output=f"Error executing `{name}`: {exc}\n{spec.help_text()}",
|
|
118
|
+
is_error=True,
|
|
119
|
+
)
|
|
120
|
+
|
|
121
|
+
if name == "done" and not out.startswith("Error:") and not out.startswith("Usage:"):
|
|
122
|
+
return DispatchResult(output=out, is_done=True, final_result=out)
|
|
123
|
+
|
|
124
|
+
return DispatchResult(output=out, is_error=out.startswith("Error:"))
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
"""Model provider resolver and custom SDK provider registry for Tether."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections.abc import Callable
|
|
6
|
+
|
|
7
|
+
from tether.providers.anthropic import AnthropicProvider
|
|
8
|
+
from tether.providers.base import (
|
|
9
|
+
ActionCall,
|
|
10
|
+
ModelProvider,
|
|
11
|
+
ModelResponse,
|
|
12
|
+
extract_fallback_action,
|
|
13
|
+
parse_tool_arguments,
|
|
14
|
+
)
|
|
15
|
+
from tether.providers.ollama import OllamaProvider
|
|
16
|
+
from tether.providers.openai import OpenAIProvider
|
|
17
|
+
|
|
18
|
+
ProviderFactory = Callable[[str], ModelProvider]
|
|
19
|
+
_CUSTOM_PROVIDERS: dict[str, ProviderFactory] = {}
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def register_provider(prefix: str, factory: ProviderFactory) -> None:
|
|
23
|
+
"""Register a custom SDK model provider under `<prefix>/<model_name>`."""
|
|
24
|
+
clean = prefix.strip().lower().rstrip("/")
|
|
25
|
+
if not clean:
|
|
26
|
+
raise ValueError("Provider prefix cannot be empty.")
|
|
27
|
+
_CUSTOM_PROVIDERS[clean] = factory
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def resolve_provider(model: str, *, num_ctx: int = 8192) -> ModelProvider:
|
|
31
|
+
"""Resolve `<provider>/<model_name>` into an async ModelProvider instance."""
|
|
32
|
+
spec = model.strip() or "ollama/qwen3.5:9b"
|
|
33
|
+
if "/" in spec:
|
|
34
|
+
prefix, name = spec.split("/", 1)
|
|
35
|
+
else:
|
|
36
|
+
prefix, name = "ollama", spec
|
|
37
|
+
|
|
38
|
+
provider = prefix.lower()
|
|
39
|
+
if provider in _CUSTOM_PROVIDERS:
|
|
40
|
+
return _CUSTOM_PROVIDERS[provider](name)
|
|
41
|
+
if provider == "ollama":
|
|
42
|
+
return OllamaProvider(model_name=name, num_ctx=num_ctx)
|
|
43
|
+
if provider in ("openai", "openrouter", "vllm", "groq", "deepseek"):
|
|
44
|
+
return OpenAIProvider(model_name=name)
|
|
45
|
+
if provider in ("anthropic", "claude"):
|
|
46
|
+
return AnthropicProvider(model_name=name)
|
|
47
|
+
supported = ["ollama/", "openai/", "anthropic/", *[f"{k}/" for k in _CUSTOM_PROVIDERS]]
|
|
48
|
+
raise ValueError(
|
|
49
|
+
f"Unknown model provider `{prefix}` in `{model}`. "
|
|
50
|
+
f"Supported prefixes: {', '.join(supported)}."
|
|
51
|
+
)
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
__all__ = [
|
|
55
|
+
"ActionCall",
|
|
56
|
+
"AnthropicProvider",
|
|
57
|
+
"ModelProvider",
|
|
58
|
+
"ModelResponse",
|
|
59
|
+
"OllamaProvider",
|
|
60
|
+
"OpenAIProvider",
|
|
61
|
+
"ProviderFactory",
|
|
62
|
+
"extract_fallback_action",
|
|
63
|
+
"parse_tool_arguments",
|
|
64
|
+
"register_provider",
|
|
65
|
+
"resolve_provider",
|
|
66
|
+
]
|