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 ADDED
@@ -0,0 +1,35 @@
1
+ """Tether: General-purpose, headless-first LLM-Native micro-harness."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from tether.decorator import ToolSpec, tool
6
+ from tether.harness import Harness
7
+ from tether.providers import (
8
+ ActionCall,
9
+ ModelProvider,
10
+ ModelResponse,
11
+ extract_fallback_action,
12
+ register_provider,
13
+ )
14
+ from tether.sandbox import SandboxPolicy, SandboxViolationError
15
+ from tether.skills import Skill, SkillLoader
16
+ from tether.store import SessionRecord, SQLiteStore
17
+
18
+ __version__ = "0.0.2"
19
+
20
+ __all__ = [
21
+ "ActionCall",
22
+ "Harness",
23
+ "ModelProvider",
24
+ "ModelResponse",
25
+ "SQLiteStore",
26
+ "SandboxPolicy",
27
+ "SandboxViolationError",
28
+ "SessionRecord",
29
+ "Skill",
30
+ "SkillLoader",
31
+ "ToolSpec",
32
+ "extract_fallback_action",
33
+ "register_provider",
34
+ "tool",
35
+ ]
tether/cli.py ADDED
@@ -0,0 +1,235 @@
1
+ """Headless-capable CLI entrypoint for Tether (`tether run|status|read|query|send|wait|list`)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import asyncio
7
+ import sys
8
+ from pathlib import Path
9
+
10
+ from tether.harness import Harness
11
+ from tether.sandbox import SandboxPolicy
12
+ from tether.store import SQLiteStore
13
+
14
+ _KNOWN_SUBCOMMANDS = {"run", "status", "read", "query", "send", "wait", "list", "_worker"}
15
+
16
+
17
+ def build_parser() -> argparse.ArgumentParser:
18
+ parser = argparse.ArgumentParser(
19
+ prog="tether",
20
+ description="Tether: Headless-first LLM-Native Micro-Harness",
21
+ )
22
+ sub = parser.add_subparsers(dest="subcommand")
23
+
24
+ # `tether run`
25
+ p_run = sub.add_parser("run", help="Run a task inline or as a detached headless session")
26
+ p_run.add_argument(
27
+ "--headless", action="store_true", help="Run detached in background and print session ID"
28
+ )
29
+ p_run.add_argument(
30
+ "--model", type=str, default=None, help="Model (e.g. ollama/qwen3.5:9b, openai/gpt-4o-mini)"
31
+ )
32
+ p_run.add_argument(
33
+ "--skill", type=str, default=None, help="Skill name from ./skills/ or ~/.tether/skills/"
34
+ )
35
+ p_run.add_argument(
36
+ "--tools", type=str, default=None, help="Comma-separated tools (e.g. web,mem,bash or all)"
37
+ )
38
+ p_run.add_argument(
39
+ "--sandbox",
40
+ type=str,
41
+ default="workspace-write",
42
+ choices=["workspace-write", "read-only"],
43
+ help="Deterministic sandbox profile",
44
+ )
45
+ p_run.add_argument("--max-steps", type=int, default=15, help="Max reasoning steps")
46
+ p_run.add_argument(
47
+ "--db", type=str, default=None, help="SQLite DB path (default .tether/tether.db)"
48
+ )
49
+ p_run.add_argument("goal", nargs="+", help="Task goal or instruction")
50
+
51
+ # `tether status`
52
+ p_status = sub.add_parser("status", help="Show live session status and token metrics")
53
+ p_status.add_argument("session_id", help="Session ID (e.g. ses_...)")
54
+ p_status.add_argument("--db", type=str, default=None)
55
+
56
+ # `tether read`
57
+ p_read = sub.add_parser("read", help="Read session final result, compact digest, or step log")
58
+ p_read.add_argument("session_id", help="Session ID")
59
+ p_read.add_argument("--digest", action="store_true", help="Show compact ~50-token digest card")
60
+ p_read.add_argument("--steps", action="store_true", help="Show step-by-step work log")
61
+ p_read.add_argument("--db", type=str, default=None)
62
+
63
+ # `tether query`
64
+ p_query = sub.add_parser(
65
+ "query", help="BM25 FTS5 RAG query over session deliverables & observations"
66
+ )
67
+ p_query.add_argument("session_id", help="Session ID")
68
+ p_query.add_argument("query", nargs="+", help="Search keywords")
69
+ p_query.add_argument("--top", type=int, default=3, help="Top K chunks to return")
70
+ p_query.add_argument("--db", type=str, default=None)
71
+
72
+ # `tether send`
73
+ p_send = sub.add_parser(
74
+ "send", help="Send a follow-up message to a running or completed session"
75
+ )
76
+ p_send.add_argument("session_id", help="Session ID")
77
+ p_send.add_argument("--headless", action="store_true", help="Resume in background if completed")
78
+ p_send.add_argument("--db", type=str, default=None)
79
+ p_send.add_argument("message", nargs="+", help="Follow-up instruction")
80
+
81
+ # `tether wait`
82
+ p_wait = sub.add_parser("wait", help="Wait for one or more headless sessions to finish")
83
+ p_wait.add_argument("session_ids", nargs="+", help="Session ID(s)")
84
+ p_wait.add_argument("--timeout", type=float, default=120.0)
85
+ p_wait.add_argument("--db", type=str, default=None)
86
+
87
+ # `tether list`
88
+ p_list = sub.add_parser("list", help="List recent sessions")
89
+ p_list.add_argument("--limit", type=int, default=20)
90
+ p_list.add_argument("--db", type=str, default=None)
91
+
92
+ # Internal `_worker`
93
+ p_worker = sub.add_parser("_worker")
94
+ p_worker.add_argument("session_id")
95
+ p_worker.add_argument("--db", type=str, default=None)
96
+
97
+ return parser
98
+
99
+
100
+ async def _async_main(argv: list[str]) -> int:
101
+ # Support shorthand `tether --headless "Goal"` by inserting `run` if needed
102
+ effective_argv = list(argv)
103
+ if (
104
+ effective_argv
105
+ and effective_argv[0] not in _KNOWN_SUBCOMMANDS
106
+ and effective_argv[0] not in ("-h", "--help")
107
+ ):
108
+ effective_argv.insert(0, "run")
109
+
110
+ parser = build_parser()
111
+ if not effective_argv:
112
+ parser.print_help()
113
+ return 0
114
+
115
+ ns = parser.parse_args(effective_argv)
116
+ cmd = ns.subcommand
117
+
118
+ if cmd == "_worker":
119
+ harness = Harness(db_path=ns.db)
120
+ await harness.execute_session(ns.session_id)
121
+ return 0
122
+
123
+ if cmd == "run":
124
+ goal = " ".join(ns.goal).strip()
125
+ sandbox = SandboxPolicy(profile=ns.sandbox, workspace_root=Path.cwd())
126
+ harness = Harness(
127
+ model=ns.model,
128
+ skill=ns.skill,
129
+ tools=ns.tools,
130
+ sandbox=sandbox,
131
+ db_path=ns.db,
132
+ max_steps=ns.max_steps,
133
+ )
134
+ if ns.headless:
135
+ sid = harness.spawn(goal)
136
+ print(sid)
137
+ return 0
138
+ rec = await harness.run(goal)
139
+ if rec.result:
140
+ print(rec.result)
141
+ return 0 if rec.status == "COMPLETED" else 1
142
+
143
+ store = SQLiteStore(db_path=getattr(ns, "db", None))
144
+
145
+ if cmd == "status":
146
+ srec = store.get_session(ns.session_id)
147
+ if not srec:
148
+ print(f"Error: session `{ns.session_id}` not found.", file=sys.stderr)
149
+ return 1
150
+ turns = store.get_turns(srec.id)
151
+ last_cmd = next((t.tool_cmd for t in reversed(turns) if t.tool_cmd), "-")
152
+ print(
153
+ f"{'ID':<14} {'STATUS':<10} {'MODEL':<22} {'STEP':<7} {'PEAK_TOK':<9} LAST_CMD\n"
154
+ f"{srec.id:<14} {srec.status:<10} {srec.model:<22} "
155
+ f"{srec.step_count}/{srec.max_steps:<4} {srec.peak_tokens:<9} {last_cmd}"
156
+ )
157
+ return 0
158
+
159
+ if cmd == "read":
160
+ srec = store.get_session(ns.session_id)
161
+ if not srec:
162
+ print(f"Error: session `{ns.session_id}` not found.", file=sys.stderr)
163
+ return 1
164
+ if ns.steps:
165
+ turns = store.get_turns(srec.id)
166
+ for t in turns:
167
+ if t.tool_cmd:
168
+ print(f"[step {t.step}] act({t.tool_cmd!r})")
169
+ else:
170
+ print(f"[step {t.step} {t.role}] {t.content[:200]}")
171
+ return 0
172
+ if ns.digest:
173
+ print(store.get_session_digest(srec.id))
174
+ return 0
175
+ print(srec.result or store.get_session_digest(srec.id))
176
+ return 0
177
+
178
+ if cmd == "query":
179
+ q = " ".join(ns.query).strip()
180
+ hits = store.search_chunks(q, session_ids=[ns.session_id], top_k=ns.top)
181
+ if not hits:
182
+ print(f"No BM25 RAG chunks matched `{q}`.")
183
+ return 0
184
+ for h in hits:
185
+ print(f"[{h.session_id} {h.source_type}:{h.source_id}#{h.chunk_index}]\n{h.content}\n")
186
+ return 0
187
+
188
+ if cmd == "send":
189
+ msg = " ".join(ns.message).strip()
190
+ harness = Harness(db_path=ns.db)
191
+ rec = await harness.send(ns.session_id, msg, headless=ns.headless)
192
+ if ns.headless or rec.status in ("RUNNING", "PENDING"):
193
+ print(f"Message queued for `{rec.id}` [{rec.status}].")
194
+ else:
195
+ print(rec.result or f"[{rec.id} {rec.status}]")
196
+ return 0
197
+
198
+ if cmd == "wait":
199
+ elapsed = 0.0
200
+ while elapsed < ns.timeout:
201
+ if all(
202
+ (r := store.get_session(sid)) is None or r.status in ("COMPLETED", "FAILED")
203
+ for sid in ns.session_ids
204
+ ):
205
+ break
206
+ await asyncio.sleep(0.05)
207
+ elapsed += 0.05
208
+ for sid in ns.session_ids:
209
+ print(store.get_session_digest(sid))
210
+ return 0
211
+
212
+ if cmd == "list":
213
+ sessions = store.list_sessions(limit=ns.limit)
214
+ if not sessions:
215
+ print("No sessions recorded.")
216
+ return 0
217
+ print(f"{'ID':<14} {'STATUS':<10} {'MODEL':<22} {'STEP':<7} GOAL")
218
+ for s in sessions:
219
+ g = s.goal.replace("\n", " ")[:48]
220
+ print(f"{s.id:<14} {s.status:<10} {s.model:<22} {s.step_count}/{s.max_steps:<4} {g}")
221
+ return 0
222
+
223
+ parser.print_help()
224
+ return 0
225
+
226
+
227
+ def main(argv: list[str] | None = None) -> None:
228
+ args = sys.argv[1:] if argv is None else argv
229
+ code = asyncio.run(_async_main(args))
230
+ if argv is None:
231
+ raise SystemExit(code)
232
+
233
+
234
+ if __name__ == "__main__": # pragma: no cover
235
+ main()
tether/context.py ADDED
@@ -0,0 +1,170 @@
1
+ """State Plane: ContextAssembler, Ephemeral Observation Folding, and Token Estimator."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ from typing import Any
7
+
8
+ from tether.store import ObservationRecord, TurnRecord
9
+
10
+ ACT_TOOL_SCHEMA: dict[str, Any] = {
11
+ "type": "function",
12
+ "function": {
13
+ "name": "act",
14
+ "description": "Run a CLI command from the toolbox.",
15
+ "parameters": {
16
+ "type": "object",
17
+ "properties": {
18
+ "cmd": {"type": "string", "description": "CLI command or `<cmd> --help`"},
19
+ "stdin": {"type": "string", "description": "Optional multiline input"},
20
+ },
21
+ "required": ["cmd"],
22
+ },
23
+ },
24
+ }
25
+
26
+
27
+ def estimate_tokens(text: str) -> int:
28
+ """Fast deterministic token approximation (~4 chars per token)."""
29
+ if not text:
30
+ return 0
31
+ return max(1, (len(text) + 3) // 4)
32
+
33
+
34
+ def estimate_messages_tokens(
35
+ messages: list[dict[str, Any]], tools: list[dict[str, Any]] | None = None
36
+ ) -> int:
37
+ """Estimate total tokens across messages and tool schema payload."""
38
+ total = 0
39
+ for msg in messages:
40
+ total += 4 # framing overhead per message
41
+ total += estimate_tokens(str(msg.get("content") or ""))
42
+ if msg.get("tool_calls"):
43
+ total += estimate_tokens(json.dumps(msg["tool_calls"]))
44
+ if tools:
45
+ total += estimate_tokens(json.dumps(tools))
46
+ return total
47
+
48
+
49
+ def elide_active_observation(obs_id: str, raw_output: str, max_chars: int = 2400) -> str:
50
+ """Slice oversized active observations into Head + Tail in L1."""
51
+ if len(raw_output) <= max_chars:
52
+ return f"[{obs_id}] {raw_output}"
53
+ head_len = int(max_chars * 0.6)
54
+ tail_len = max_chars - head_len
55
+ elided_count = len(raw_output) - (head_len + tail_len)
56
+ head = raw_output[:head_len].rstrip()
57
+ tail = raw_output[-tail_len:].lstrip()
58
+ marker = (
59
+ f"\n... [{obs_id}: {elided_count:,} middle chars elided in L1 | "
60
+ f"use `mem search <query>` or `mem recall {obs_id}`] ...\n"
61
+ )
62
+ return f"[{obs_id}] {head}{marker}{tail}"
63
+
64
+
65
+ class ContextAssembler:
66
+ """Assembles KV-cache-friendly prompts with deterministic observation folding."""
67
+
68
+ def __init__(
69
+ self,
70
+ *,
71
+ fold_after_turns: int = 2,
72
+ fold_char_threshold: int = 200,
73
+ active_char_cap: int = 2400,
74
+ max_context_tokens: int = 8192,
75
+ ) -> None:
76
+ self.fold_after_turns = fold_after_turns
77
+ self.fold_char_threshold = fold_char_threshold
78
+ self.active_char_cap = active_char_cap
79
+ self.max_context_tokens = max_context_tokens
80
+ self._static_system_prompt: str | None = None
81
+
82
+ def build_system_prompt(
83
+ self,
84
+ toolbox_index: str,
85
+ skill_instructions: str | None = None,
86
+ ) -> str:
87
+ """Build the immutable system prompt prefix once per session."""
88
+ if self._static_system_prompt is not None:
89
+ return self._static_system_prompt
90
+
91
+ parts = [
92
+ "You are an autonomous agent on Tether.",
93
+ "Use `act(cmd, stdin)` to run commands. Run `<cmd> --help` for flags.",
94
+ 'Call `act("done", stdin="<final answer>")` when finished.',
95
+ "",
96
+ "[TOOLBOX]",
97
+ toolbox_index,
98
+ ]
99
+ if skill_instructions and skill_instructions.strip():
100
+ parts.extend(["", "[SKILL]", skill_instructions.strip()])
101
+
102
+ self._static_system_prompt = "\n".join(parts)
103
+ return self._static_system_prompt
104
+
105
+ def assemble(
106
+ self,
107
+ *,
108
+ system_prompt: str,
109
+ goal: str,
110
+ turns: list[TurnRecord],
111
+ observations: dict[str, ObservationRecord],
112
+ pins: dict[str, str],
113
+ current_step: int,
114
+ ) -> list[dict[str, Any]]:
115
+ """Assemble the message array while preserving the static prefix and folding old outputs."""
116
+ messages: list[dict[str, Any]] = [
117
+ {"role": "system", "content": system_prompt},
118
+ {"role": "user", "content": f"Goal: {goal}"},
119
+ ]
120
+
121
+ history_msgs: list[dict[str, Any]] = []
122
+ for turn in turns:
123
+ content = turn.content
124
+ if turn.obs_id and turn.obs_id in observations:
125
+ obs = observations[turn.obs_id]
126
+ age = current_step - turn.step
127
+ if age >= self.fold_after_turns and obs.char_len >= self.fold_char_threshold:
128
+ content = obs.summary
129
+ else:
130
+ content = elide_active_observation(
131
+ obs.id, obs.raw_output, max_chars=self.active_char_cap
132
+ )
133
+
134
+ if turn.role == "assistant" and turn.tool_cmd:
135
+ call_preview = f"act({turn.tool_cmd!r})"
136
+ if turn.tool_stdin:
137
+ call_preview = f"act({turn.tool_cmd!r}, stdin=<{len(turn.tool_stdin)} chars>)"
138
+ text = f"{content}\n{call_preview}".strip() if content else call_preview
139
+ history_msgs.append({"role": "assistant", "content": text})
140
+ elif turn.role == "tool":
141
+ history_msgs.append({"role": "user", "content": f"Observation:\n{content}"})
142
+ else:
143
+ history_msgs.append({"role": turn.role, "content": content})
144
+
145
+ pin_block = ""
146
+ if pins:
147
+ pin_lines = ["[PINNED MEMORY]"] + [f"- {k}: {v}" for k, v in pins.items()]
148
+ pin_block = "\n".join(pin_lines)
149
+
150
+ # Enforce max_context_tokens by dropping oldest turns if needed
151
+ base_tokens = estimate_messages_tokens(messages, [ACT_TOOL_SCHEMA]) + estimate_tokens(
152
+ pin_block
153
+ )
154
+ while len(history_msgs) > 2:
155
+ total = base_tokens + estimate_messages_tokens(history_msgs)
156
+ if total <= self.max_context_tokens:
157
+ break
158
+ history_msgs.pop(0)
159
+
160
+ if pin_block:
161
+ if history_msgs and history_msgs[-1]["role"] == "user":
162
+ history_msgs[-1] = {
163
+ "role": "user",
164
+ "content": f"{history_msgs[-1]['content']}\n\n{pin_block}",
165
+ }
166
+ else:
167
+ history_msgs.append({"role": "user", "content": pin_block})
168
+
169
+ messages.extend(history_msgs)
170
+ return messages