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/tools/agent.py
ADDED
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
"""Always-async multi-agent orchestration and SQLite FTS5 BM25 RAG (`agent`)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
from collections.abc import Callable
|
|
7
|
+
|
|
8
|
+
from tether.decorator import ToolSpec, build_tool_spec
|
|
9
|
+
from tether.sandbox import SandboxPolicy
|
|
10
|
+
from tether.skills import SkillLoader
|
|
11
|
+
from tether.store import SQLiteStore
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def make_agent_tool(
|
|
15
|
+
store: SQLiteStore,
|
|
16
|
+
parent_session_id: str,
|
|
17
|
+
default_model: str,
|
|
18
|
+
parent_sandbox: SandboxPolicy,
|
|
19
|
+
skill_loader: SkillLoader,
|
|
20
|
+
worker_spawner: Callable[[str], int | None],
|
|
21
|
+
wait_timeout: float = 120.0,
|
|
22
|
+
) -> ToolSpec:
|
|
23
|
+
help_str = (
|
|
24
|
+
"Usage: agent spawn [--skill s] [--model m] [--tools t] <goal> | "
|
|
25
|
+
"wait <id...> | query <id|--all> <q> [--top 3] | read <id> [--full] | send <id> <msg>\n"
|
|
26
|
+
"Desc : Spawn async sub-agents or query results via BM25 RAG."
|
|
27
|
+
)
|
|
28
|
+
|
|
29
|
+
async def _agent(args: list[str], stdin: str | None = None) -> str:
|
|
30
|
+
if not args or args[0] in ("--help", "-h"):
|
|
31
|
+
return help_str
|
|
32
|
+
|
|
33
|
+
sub = args[0]
|
|
34
|
+
|
|
35
|
+
if sub == "spawn":
|
|
36
|
+
skill_name: str | None = None
|
|
37
|
+
model_name: str | None = None
|
|
38
|
+
tools_list: list[str] = []
|
|
39
|
+
goal_parts: list[str] = []
|
|
40
|
+
i = 1
|
|
41
|
+
while i < len(args):
|
|
42
|
+
if args[i] == "--skill" and i + 1 < len(args):
|
|
43
|
+
skill_name = args[i + 1]
|
|
44
|
+
i += 2
|
|
45
|
+
elif args[i] == "--model" and i + 1 < len(args):
|
|
46
|
+
model_name = args[i + 1]
|
|
47
|
+
i += 2
|
|
48
|
+
elif args[i] == "--tools" and i + 1 < len(args):
|
|
49
|
+
tools_list = [t.strip() for t in args[i + 1].split(",") if t.strip()]
|
|
50
|
+
i += 2
|
|
51
|
+
else:
|
|
52
|
+
goal_parts.append(args[i])
|
|
53
|
+
i += 1
|
|
54
|
+
|
|
55
|
+
goal = " ".join(goal_parts).strip() or (stdin or "").strip()
|
|
56
|
+
if not goal:
|
|
57
|
+
return f"Error: missing `<goal>` for `agent spawn`.\n{help_str}"
|
|
58
|
+
|
|
59
|
+
if skill_name:
|
|
60
|
+
sk = skill_loader.load(skill_name)
|
|
61
|
+
if not sk:
|
|
62
|
+
return f"Error: skill `{skill_name}` not found."
|
|
63
|
+
if not model_name and sk.model:
|
|
64
|
+
model_name = sk.model
|
|
65
|
+
if not tools_list and sk.tools:
|
|
66
|
+
tools_list = list(sk.tools)
|
|
67
|
+
|
|
68
|
+
child_sandbox = parent_sandbox.attenuate()
|
|
69
|
+
child = store.create_session(
|
|
70
|
+
goal=goal,
|
|
71
|
+
model=model_name or default_model,
|
|
72
|
+
skill=skill_name,
|
|
73
|
+
tools=tools_list,
|
|
74
|
+
sandbox_config=child_sandbox.to_dict(),
|
|
75
|
+
parent_id=parent_session_id,
|
|
76
|
+
)
|
|
77
|
+
worker_spawner(child.id)
|
|
78
|
+
return child.id
|
|
79
|
+
|
|
80
|
+
if sub == "status":
|
|
81
|
+
if len(args) < 2:
|
|
82
|
+
return f"Error: missing `<session_id>`.\n{help_str}"
|
|
83
|
+
rec = store.get_session(args[1])
|
|
84
|
+
if not rec:
|
|
85
|
+
return f"Error: session `{args[1]}` not found."
|
|
86
|
+
turns = store.get_turns(rec.id)
|
|
87
|
+
last_cmd = next((t.tool_cmd for t in reversed(turns) if t.tool_cmd), "-")
|
|
88
|
+
return (
|
|
89
|
+
f"{rec.id} [{rec.status}] step {rec.step_count}/{rec.max_steps} | "
|
|
90
|
+
f"model: {rec.model} | last: {last_cmd}"
|
|
91
|
+
)
|
|
92
|
+
|
|
93
|
+
if sub == "wait":
|
|
94
|
+
target_ids = [a for a in args[1:] if not a.startswith("-")]
|
|
95
|
+
if not target_ids:
|
|
96
|
+
return f"Error: specify one or more session IDs to wait for.\n{help_str}"
|
|
97
|
+
|
|
98
|
+
elapsed = 0.0
|
|
99
|
+
poll_interval = 0.05
|
|
100
|
+
while elapsed < wait_timeout:
|
|
101
|
+
all_done = True
|
|
102
|
+
for sid in target_ids:
|
|
103
|
+
rec = store.get_session(sid)
|
|
104
|
+
if rec and rec.status in ("PENDING", "RUNNING"):
|
|
105
|
+
all_done = False
|
|
106
|
+
break
|
|
107
|
+
if all_done:
|
|
108
|
+
break
|
|
109
|
+
await asyncio.sleep(poll_interval)
|
|
110
|
+
elapsed += poll_interval
|
|
111
|
+
|
|
112
|
+
digests = [store.get_session_digest(sid) for sid in target_ids]
|
|
113
|
+
return "\n\n".join(digests)
|
|
114
|
+
|
|
115
|
+
if sub == "query":
|
|
116
|
+
if len(args) < 2:
|
|
117
|
+
return f"Error: usage `agent query <id|--all> <query> [--top 3]`.\n{help_str}"
|
|
118
|
+
target = args[1]
|
|
119
|
+
top_k = 3
|
|
120
|
+
q_parts: list[str] = []
|
|
121
|
+
i = 2
|
|
122
|
+
while i < len(args):
|
|
123
|
+
if args[i] == "--top" and i + 1 < len(args):
|
|
124
|
+
try:
|
|
125
|
+
top_k = int(args[i + 1])
|
|
126
|
+
except ValueError:
|
|
127
|
+
return "Error: `--top` expects an integer."
|
|
128
|
+
i += 2
|
|
129
|
+
else:
|
|
130
|
+
q_parts.append(args[i])
|
|
131
|
+
i += 1
|
|
132
|
+
query = " ".join(q_parts).strip() or (stdin or "").strip()
|
|
133
|
+
if not query:
|
|
134
|
+
return f"Error: missing `<query>`.\n{help_str}"
|
|
135
|
+
|
|
136
|
+
if target == "--all":
|
|
137
|
+
children = store.list_sessions(parent_id=parent_session_id)
|
|
138
|
+
session_ids = [c.id for c in children]
|
|
139
|
+
if not session_ids:
|
|
140
|
+
return "No child sub-agent sessions found."
|
|
141
|
+
else:
|
|
142
|
+
session_ids = [target]
|
|
143
|
+
|
|
144
|
+
hits = store.search_chunks(query, session_ids=session_ids, top_k=top_k)
|
|
145
|
+
if not hits:
|
|
146
|
+
return f"No BM25 RAG chunks matched `{query}` in {', '.join(session_ids)}."
|
|
147
|
+
blocks = [
|
|
148
|
+
f"[{h.session_id} {h.source_type}:{h.source_id}#{h.chunk_index}]\n{h.content}"
|
|
149
|
+
for h in hits
|
|
150
|
+
]
|
|
151
|
+
return "\n\n---\n\n".join(blocks)
|
|
152
|
+
|
|
153
|
+
if sub == "read":
|
|
154
|
+
if len(args) < 2:
|
|
155
|
+
return f"Error: missing `<session_id>`.\n{help_str}"
|
|
156
|
+
sid = args[1]
|
|
157
|
+
full = "--full" in args
|
|
158
|
+
rec = store.get_session(sid)
|
|
159
|
+
if not rec:
|
|
160
|
+
return f"Error: session `{sid}` not found."
|
|
161
|
+
if not full:
|
|
162
|
+
return store.get_session_digest(sid)
|
|
163
|
+
return rec.result or f"[{sid} {rec.status}: no final result yet]"
|
|
164
|
+
|
|
165
|
+
if sub == "send":
|
|
166
|
+
if len(args) < 2:
|
|
167
|
+
return f"Error: missing `<session_id>`.\n{help_str}"
|
|
168
|
+
sid = args[1]
|
|
169
|
+
msg = " ".join(args[2:]).strip() or (stdin or "").strip()
|
|
170
|
+
if not msg:
|
|
171
|
+
return f"Error: missing `<message>`.\n{help_str}"
|
|
172
|
+
rec = store.get_session(sid)
|
|
173
|
+
if not rec:
|
|
174
|
+
return f"Error: session `{sid}` not found."
|
|
175
|
+
store.enqueue_inbox(sid, msg)
|
|
176
|
+
if rec.status in ("COMPLETED", "FAILED", "PENDING"):
|
|
177
|
+
store.update_session(sid, status="PENDING")
|
|
178
|
+
worker_spawner(sid)
|
|
179
|
+
return f"Queued message and resumed session `{sid}`."
|
|
180
|
+
return f"Queued message into running session `{sid}` inbox."
|
|
181
|
+
|
|
182
|
+
return f"Error: unknown subcommand `{sub}`.\n{help_str}"
|
|
183
|
+
|
|
184
|
+
return build_tool_spec(
|
|
185
|
+
_agent,
|
|
186
|
+
name="agent",
|
|
187
|
+
description="Spawn async sub-agents and query their work via BM25 RAG",
|
|
188
|
+
custom_help=help_str,
|
|
189
|
+
raw_dispatch=True,
|
|
190
|
+
)
|
tether/tools/bash.py
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
"""Sandboxed shell execution tool (`bash`)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from tether.decorator import ToolSpec, build_tool_spec
|
|
6
|
+
from tether.sandbox import SandboxPolicy
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def make_bash_tool(sandbox: SandboxPolicy) -> ToolSpec:
|
|
10
|
+
help_str = (
|
|
11
|
+
"Usage: bash <command> (or pass script via stdin)\n"
|
|
12
|
+
"Desc : Run shell command inside workspace sandbox."
|
|
13
|
+
)
|
|
14
|
+
|
|
15
|
+
async def _bash(args: list[str], stdin: str | None = None) -> str:
|
|
16
|
+
if args and args[0] in ("--help", "-h"):
|
|
17
|
+
return help_str
|
|
18
|
+
cmd_str = " ".join(args).strip()
|
|
19
|
+
if not cmd_str and not stdin:
|
|
20
|
+
return f"Error: missing `<command>`.\n{help_str}"
|
|
21
|
+
return await sandbox.execute_bash(cmd_str, stdin=stdin)
|
|
22
|
+
|
|
23
|
+
return build_tool_spec(
|
|
24
|
+
_bash,
|
|
25
|
+
name="bash",
|
|
26
|
+
description="Execute sandboxed shell commands",
|
|
27
|
+
custom_help=help_str,
|
|
28
|
+
raw_dispatch=True,
|
|
29
|
+
)
|
tether/tools/done.py
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
"""Mandatory explicit task completion tool (`done`)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from tether.decorator import ToolSpec, build_tool_spec
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def make_done_tool() -> ToolSpec:
|
|
9
|
+
help_str = (
|
|
10
|
+
"Usage: done [answer] (or pass multiline report via stdin)\n"
|
|
11
|
+
"Desc : Complete the task and submit final deliverable."
|
|
12
|
+
)
|
|
13
|
+
|
|
14
|
+
def _done(args: list[str], stdin: str | None = None) -> str:
|
|
15
|
+
if args and args[0] in ("--help", "-h"):
|
|
16
|
+
return help_str
|
|
17
|
+
body_parts: list[str] = []
|
|
18
|
+
if args:
|
|
19
|
+
body_parts.append(" ".join(args).strip())
|
|
20
|
+
if stdin and stdin.strip():
|
|
21
|
+
body_parts.append(stdin.strip())
|
|
22
|
+
final_text = "\n\n".join(body_parts).strip()
|
|
23
|
+
if not final_text:
|
|
24
|
+
return f"Error: provide final deliverable via `stdin` or argument.\n{help_str}"
|
|
25
|
+
return final_text
|
|
26
|
+
|
|
27
|
+
return build_tool_spec(
|
|
28
|
+
_done,
|
|
29
|
+
name="done",
|
|
30
|
+
description="Complete task and submit final deliverable",
|
|
31
|
+
custom_help=help_str,
|
|
32
|
+
raw_dispatch=True,
|
|
33
|
+
)
|
tether/tools/edit.py
ADDED
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
"""Workspace-jailed file viewing and stdin patching tool (`edit`)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from tether.decorator import ToolSpec, build_tool_spec
|
|
6
|
+
from tether.sandbox import SandboxPolicy
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def _parse_line_range(spec: str, total_lines: int) -> tuple[int, int] | str:
|
|
10
|
+
if ":" not in spec:
|
|
11
|
+
try:
|
|
12
|
+
n = int(spec)
|
|
13
|
+
return max(1, n), min(total_lines, n)
|
|
14
|
+
except ValueError:
|
|
15
|
+
return f"invalid line range `{spec}` (expected `start:end`)."
|
|
16
|
+
left, right = spec.split(":", 1)
|
|
17
|
+
try:
|
|
18
|
+
start = int(left) if left else 1
|
|
19
|
+
end = int(right) if right else total_lines
|
|
20
|
+
except ValueError:
|
|
21
|
+
return f"invalid line range `{spec}` (expected integers `start:end`)."
|
|
22
|
+
start = max(1, start)
|
|
23
|
+
end = min(max(start, end), max(1, total_lines))
|
|
24
|
+
return start, end
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def make_edit_tool(sandbox: SandboxPolicy) -> ToolSpec:
|
|
28
|
+
help_str = (
|
|
29
|
+
"Usage: edit view <path> [--lines a:b] | edit write <path> [stdin] | "
|
|
30
|
+
"edit replace <path> --lines a:b [stdin]\n"
|
|
31
|
+
"Desc : View numbered lines, write file from stdin, or replace line range."
|
|
32
|
+
)
|
|
33
|
+
|
|
34
|
+
def _edit(args: list[str], stdin: str | None = None) -> str:
|
|
35
|
+
if not args or args[0] in ("--help", "-h"):
|
|
36
|
+
return help_str
|
|
37
|
+
|
|
38
|
+
sub = args[0]
|
|
39
|
+
if sub == "view":
|
|
40
|
+
if len(args) < 2:
|
|
41
|
+
return f"Error: missing `<path>`.\n{help_str}"
|
|
42
|
+
target = sandbox.resolve_safe_path(args[1], write=False)
|
|
43
|
+
if not target.is_file():
|
|
44
|
+
return f"Error: file `{args[1]}` not found."
|
|
45
|
+
lines = target.read_text(encoding="utf-8", errors="replace").splitlines()
|
|
46
|
+
start, end = 1, min(len(lines), 100)
|
|
47
|
+
if "--lines" in args:
|
|
48
|
+
idx = args.index("--lines")
|
|
49
|
+
if idx + 1 >= len(args):
|
|
50
|
+
return f"Error: `--lines` requires `start:end`.\n{help_str}"
|
|
51
|
+
rng = _parse_line_range(args[idx + 1], len(lines))
|
|
52
|
+
if isinstance(rng, str):
|
|
53
|
+
return f"Error: {rng}"
|
|
54
|
+
start, end = rng
|
|
55
|
+
numbered = [f"{i:4d}: {lines[i - 1]}" for i in range(start, end + 1)]
|
|
56
|
+
header = f"[{args[1]} lines {start}-{end} of {len(lines)}]"
|
|
57
|
+
return "\n".join([header, *numbered])
|
|
58
|
+
|
|
59
|
+
if sub == "write":
|
|
60
|
+
if len(args) < 2:
|
|
61
|
+
return f"Error: missing `<path>`.\n{help_str}"
|
|
62
|
+
if stdin is None:
|
|
63
|
+
return f"Error: `edit write` requires file content in `stdin`.\n{help_str}"
|
|
64
|
+
target = sandbox.resolve_safe_path(args[1], write=True)
|
|
65
|
+
target.parent.mkdir(parents=True, exist_ok=True)
|
|
66
|
+
target.write_text(stdin, encoding="utf-8")
|
|
67
|
+
line_cnt = len(stdin.splitlines())
|
|
68
|
+
return f"Wrote {len(stdin):,} chars ({line_cnt} lines) to `{args[1]}`."
|
|
69
|
+
|
|
70
|
+
if sub == "replace":
|
|
71
|
+
if len(args) < 2 or "--lines" not in args:
|
|
72
|
+
return (
|
|
73
|
+
f"Error: `edit replace` requires `<path> --lines start:end` "
|
|
74
|
+
f"and `stdin`.\n{help_str}"
|
|
75
|
+
)
|
|
76
|
+
if stdin is None:
|
|
77
|
+
return f"Error: `edit replace` requires replacement block in `stdin`.\n{help_str}"
|
|
78
|
+
target = sandbox.resolve_safe_path(args[1], write=True)
|
|
79
|
+
if not target.is_file():
|
|
80
|
+
return f"Error: file `{args[1]}` not found."
|
|
81
|
+
idx = args.index("--lines")
|
|
82
|
+
if idx + 1 >= len(args):
|
|
83
|
+
return f"Error: `--lines` requires `start:end`.\n{help_str}"
|
|
84
|
+
lines = target.read_text(encoding="utf-8", errors="replace").splitlines()
|
|
85
|
+
rng = _parse_line_range(args[idx + 1], len(lines))
|
|
86
|
+
if isinstance(rng, str):
|
|
87
|
+
return f"Error: {rng}"
|
|
88
|
+
start, end = rng
|
|
89
|
+
replacement_lines = stdin.splitlines()
|
|
90
|
+
updated = lines[: start - 1] + replacement_lines + lines[end:]
|
|
91
|
+
target.write_text(
|
|
92
|
+
"\n".join(updated) + ("\n" if stdin.endswith("\n") or lines else ""),
|
|
93
|
+
encoding="utf-8",
|
|
94
|
+
)
|
|
95
|
+
return (
|
|
96
|
+
f"Replaced lines {start}:{end} in `{args[1]}` with {len(replacement_lines)} lines."
|
|
97
|
+
)
|
|
98
|
+
|
|
99
|
+
return f"Error: unknown subcommand `{sub}`.\n{help_str}"
|
|
100
|
+
|
|
101
|
+
return build_tool_spec(
|
|
102
|
+
_edit,
|
|
103
|
+
name="edit",
|
|
104
|
+
description="View, create, or patch workspace files via stdin",
|
|
105
|
+
custom_help=help_str,
|
|
106
|
+
raw_dispatch=True,
|
|
107
|
+
)
|
tether/tools/mem.py
ADDED
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
"""Working scratchpad (`pin`/`unpin`), FTS5 BM25 RAG (`search`), and observation `recall`."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from tether.decorator import ToolSpec, build_tool_spec
|
|
6
|
+
from tether.store import SQLiteStore
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def make_mem_tool(store: SQLiteStore, session_id: str) -> ToolSpec:
|
|
10
|
+
help_str = (
|
|
11
|
+
"Usage: mem pin <key> <val> | mem unpin <key> | mem search <query> [--obs id] [--top 3] | "
|
|
12
|
+
"mem recall <obs_id>\n"
|
|
13
|
+
"Desc : Pin facts, search folded observations via BM25, or recall raw output."
|
|
14
|
+
)
|
|
15
|
+
|
|
16
|
+
def _mem(args: list[str], stdin: str | None = None) -> str:
|
|
17
|
+
if not args or args[0] in ("--help", "-h"):
|
|
18
|
+
return help_str
|
|
19
|
+
|
|
20
|
+
sub = args[0]
|
|
21
|
+
if sub == "pin":
|
|
22
|
+
if len(args) < 2:
|
|
23
|
+
return f"Error: missing `<key>`.\n{help_str}"
|
|
24
|
+
key = args[1]
|
|
25
|
+
val = " ".join(args[2:]).strip() if len(args) > 2 else (stdin or "").strip()
|
|
26
|
+
if not val:
|
|
27
|
+
return f"Error: missing `<val>` (pass inline or via `stdin`).\n{help_str}"
|
|
28
|
+
store.set_pin(session_id, key, val)
|
|
29
|
+
return f"Pinned `{key}`."
|
|
30
|
+
|
|
31
|
+
if sub == "unpin":
|
|
32
|
+
if len(args) < 2:
|
|
33
|
+
return f"Error: missing `<key>`.\n{help_str}"
|
|
34
|
+
removed = store.remove_pin(session_id, args[1])
|
|
35
|
+
return f"Unpinned `{args[1]}`." if removed else f"Key `{args[1]}` not found."
|
|
36
|
+
|
|
37
|
+
if sub == "list":
|
|
38
|
+
pins = store.get_pins(session_id)
|
|
39
|
+
if not pins:
|
|
40
|
+
return "No pinned facts."
|
|
41
|
+
return "\n".join(f"- {k}: {v}" for k, v in pins.items())
|
|
42
|
+
|
|
43
|
+
if sub == "search":
|
|
44
|
+
obs_filter: str | None = None
|
|
45
|
+
top_k = 3
|
|
46
|
+
q_parts: list[str] = []
|
|
47
|
+
i = 1
|
|
48
|
+
while i < len(args):
|
|
49
|
+
if args[i] == "--obs" and i + 1 < len(args):
|
|
50
|
+
obs_filter = args[i + 1]
|
|
51
|
+
i += 2
|
|
52
|
+
elif args[i] == "--top" and i + 1 < len(args):
|
|
53
|
+
try:
|
|
54
|
+
top_k = int(args[i + 1])
|
|
55
|
+
except ValueError:
|
|
56
|
+
return "Error: `--top` expects an integer."
|
|
57
|
+
i += 2
|
|
58
|
+
else:
|
|
59
|
+
q_parts.append(args[i])
|
|
60
|
+
i += 1
|
|
61
|
+
query = " ".join(q_parts).strip() or (stdin or "").strip()
|
|
62
|
+
if not query:
|
|
63
|
+
return f"Error: missing `<query>`.\n{help_str}"
|
|
64
|
+
hits = store.search_chunks(
|
|
65
|
+
query,
|
|
66
|
+
session_ids=[session_id],
|
|
67
|
+
source_id=obs_filter,
|
|
68
|
+
top_k=top_k,
|
|
69
|
+
)
|
|
70
|
+
if not hits:
|
|
71
|
+
return f"No matching chunks for `{query}`."
|
|
72
|
+
formatted = [
|
|
73
|
+
f"[{h.source_type}:{h.source_id}#{h.chunk_index}]\n{h.content}" for h in hits
|
|
74
|
+
]
|
|
75
|
+
return "\n\n---\n\n".join(formatted)
|
|
76
|
+
|
|
77
|
+
if sub == "recall":
|
|
78
|
+
if len(args) < 2:
|
|
79
|
+
return f"Error: missing `<obs_id>`.\n{help_str}"
|
|
80
|
+
obs = store.get_observation(session_id, args[1])
|
|
81
|
+
if not obs:
|
|
82
|
+
return f"Error: observation `{args[1]}` not found."
|
|
83
|
+
return f"[{obs.id} `{obs.cmd}`]\n{obs.raw_output}"
|
|
84
|
+
|
|
85
|
+
return f"Error: unknown subcommand `{sub}`.\n{help_str}"
|
|
86
|
+
|
|
87
|
+
return build_tool_spec(
|
|
88
|
+
_mem,
|
|
89
|
+
name="mem",
|
|
90
|
+
description="Pin working memory facts or BM25-search/recall folded observations",
|
|
91
|
+
custom_help=help_str,
|
|
92
|
+
raw_dispatch=True,
|
|
93
|
+
)
|
tether/tools/web.py
ADDED
|
@@ -0,0 +1,222 @@
|
|
|
1
|
+
"""Zero-config web search (DuckDuckGo Lite + Brave/Tavily/SearXNG env override) and reader."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import html
|
|
6
|
+
import os
|
|
7
|
+
import re
|
|
8
|
+
import urllib.parse
|
|
9
|
+
from html.parser import HTMLParser
|
|
10
|
+
|
|
11
|
+
import httpx
|
|
12
|
+
|
|
13
|
+
from tether.decorator import ToolSpec, build_tool_spec
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class _HTMLToTextParser(HTMLParser):
|
|
17
|
+
"""Lightweight stdlib HTML-to-Markdown/text extractor."""
|
|
18
|
+
|
|
19
|
+
def __init__(self) -> None:
|
|
20
|
+
super().__init__()
|
|
21
|
+
self._skip_depth = 0
|
|
22
|
+
self._parts: list[str] = []
|
|
23
|
+
|
|
24
|
+
def handle_starttag(self, tag: str, attrs: list[tuple[str, str | None]]) -> None:
|
|
25
|
+
t = tag.lower()
|
|
26
|
+
if t in ("script", "style", "noscript", "svg", "head", "nav", "footer"):
|
|
27
|
+
self._skip_depth += 1
|
|
28
|
+
return
|
|
29
|
+
if self._skip_depth > 0:
|
|
30
|
+
return
|
|
31
|
+
if t in ("h1", "h2", "h3", "h4"):
|
|
32
|
+
level = int(t[1])
|
|
33
|
+
self._parts.append(f"\n\n{'#' * level} ")
|
|
34
|
+
elif t in ("p", "div", "section", "article", "br", "tr"):
|
|
35
|
+
self._parts.append("\n")
|
|
36
|
+
elif t == "li":
|
|
37
|
+
self._parts.append("\n- ")
|
|
38
|
+
|
|
39
|
+
def handle_endtag(self, tag: str) -> None:
|
|
40
|
+
t = tag.lower()
|
|
41
|
+
if t in ("script", "style", "noscript", "svg", "head", "nav", "footer"):
|
|
42
|
+
self._skip_depth = max(0, self._skip_depth - 1)
|
|
43
|
+
elif self._skip_depth == 0 and t in ("p", "h1", "h2", "h3", "h4", "ul", "ol"):
|
|
44
|
+
self._parts.append("\n")
|
|
45
|
+
|
|
46
|
+
def handle_data(self, data: str) -> None:
|
|
47
|
+
if self._skip_depth == 0:
|
|
48
|
+
cleaned = re.sub(r"[ \t]+", " ", data)
|
|
49
|
+
self._parts.append(cleaned)
|
|
50
|
+
|
|
51
|
+
def get_text(self) -> str:
|
|
52
|
+
raw = "".join(self._parts)
|
|
53
|
+
lines = [line.strip() for line in raw.splitlines()]
|
|
54
|
+
collapsed = "\n".join(lines)
|
|
55
|
+
return re.sub(r"\n{3,}", "\n\n", collapsed).strip()
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def html_to_markdown(raw_html: str, max_chars: int = 8000) -> str:
|
|
59
|
+
"""Convert raw HTML into compact markdown-friendly text."""
|
|
60
|
+
parser = _HTMLToTextParser()
|
|
61
|
+
parser.feed(raw_html)
|
|
62
|
+
text = parser.get_text()
|
|
63
|
+
if len(text) > max_chars:
|
|
64
|
+
return f"{text[:max_chars]}\n... [truncated {len(text) - max_chars:,} chars]"
|
|
65
|
+
return text
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def _unwrap_ddg_url(href: str) -> str:
|
|
69
|
+
if "uddg=" in href:
|
|
70
|
+
parsed = urllib.parse.urlparse(href)
|
|
71
|
+
qs = urllib.parse.parse_qs(parsed.query)
|
|
72
|
+
if "uddg" in qs and qs["uddg"]:
|
|
73
|
+
return urllib.parse.unquote(qs["uddg"][0])
|
|
74
|
+
return href
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
async def search_web_backend(query: str, max_results: int = 5) -> str:
|
|
78
|
+
"""Search the web via SearXNG/Brave/Tavily if configured, else DuckDuckGo HTML."""
|
|
79
|
+
headers = {
|
|
80
|
+
"User-Agent": (
|
|
81
|
+
"Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 "
|
|
82
|
+
"(KHTML, like Gecko) Chrome/126.0.0.0 Safari/537.36"
|
|
83
|
+
)
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
searx_url = os.environ.get("SEARXNG_URL")
|
|
87
|
+
brave_key = os.environ.get("BRAVE_API_KEY")
|
|
88
|
+
tavily_key = os.environ.get("TAVILY_API_KEY")
|
|
89
|
+
|
|
90
|
+
async with httpx.AsyncClient(timeout=20.0, follow_redirects=True, headers=headers) as client:
|
|
91
|
+
if searx_url:
|
|
92
|
+
resp = await client.get(
|
|
93
|
+
f"{searx_url.rstrip('/')}/search",
|
|
94
|
+
params={"q": query, "format": "json"},
|
|
95
|
+
)
|
|
96
|
+
resp.raise_for_status()
|
|
97
|
+
items = (resp.json().get("results") or [])[:max_results]
|
|
98
|
+
return _format_results(
|
|
99
|
+
[(i.get("title", ""), i.get("url", ""), i.get("content", "")) for i in items]
|
|
100
|
+
)
|
|
101
|
+
|
|
102
|
+
if brave_key:
|
|
103
|
+
resp = await client.get(
|
|
104
|
+
"https://api.search.brave.com/res/v1/web/search",
|
|
105
|
+
params={"q": query, "count": max_results},
|
|
106
|
+
headers={"X-Subscription-Token": brave_key, "Accept": "application/json"},
|
|
107
|
+
)
|
|
108
|
+
resp.raise_for_status()
|
|
109
|
+
items = ((resp.json().get("web") or {}).get("results") or [])[:max_results]
|
|
110
|
+
return _format_results(
|
|
111
|
+
[(i.get("title", ""), i.get("url", ""), i.get("description", "")) for i in items]
|
|
112
|
+
)
|
|
113
|
+
|
|
114
|
+
if tavily_key:
|
|
115
|
+
resp = await client.post(
|
|
116
|
+
"https://api.tavily.com/search",
|
|
117
|
+
json={"api_key": tavily_key, "query": query, "max_results": max_results},
|
|
118
|
+
)
|
|
119
|
+
resp.raise_for_status()
|
|
120
|
+
items = (resp.json().get("results") or [])[:max_results]
|
|
121
|
+
return _format_results(
|
|
122
|
+
[(i.get("title", ""), i.get("url", ""), i.get("content", "")) for i in items]
|
|
123
|
+
)
|
|
124
|
+
|
|
125
|
+
# Default zero-config DuckDuckGo HTML endpoint
|
|
126
|
+
resp = await client.post(
|
|
127
|
+
"https://html.duckduckgo.com/html/",
|
|
128
|
+
data={"q": query},
|
|
129
|
+
)
|
|
130
|
+
resp.raise_for_status()
|
|
131
|
+
body = resp.text
|
|
132
|
+
|
|
133
|
+
link_re = re.compile(
|
|
134
|
+
r'<a[^>]+class="[^"]*result__a[^"]*"[^>]+href="([^"]+)"[^>]*>(.*?)</a>',
|
|
135
|
+
re.DOTALL,
|
|
136
|
+
)
|
|
137
|
+
snip_re = re.compile(
|
|
138
|
+
r'<a[^>]+class="[^"]*result__snippet[^"]*"[^>]*>(.*?)</a>',
|
|
139
|
+
re.DOTALL,
|
|
140
|
+
)
|
|
141
|
+
links = link_re.findall(body)
|
|
142
|
+
snippets = snip_re.findall(body)
|
|
143
|
+
|
|
144
|
+
results: list[tuple[str, str, str]] = []
|
|
145
|
+
for idx, (raw_href, raw_title) in enumerate(links[:max_results]):
|
|
146
|
+
title = html.unescape(re.sub(r"<[^>]+>", "", raw_title)).strip()
|
|
147
|
+
url = _unwrap_ddg_url(html.unescape(raw_href).strip())
|
|
148
|
+
snippet = ""
|
|
149
|
+
if idx < len(snippets):
|
|
150
|
+
snippet = html.unescape(re.sub(r"<[^>]+>", "", snippets[idx])).strip()
|
|
151
|
+
results.append((title, url, snippet))
|
|
152
|
+
|
|
153
|
+
return _format_results(results)
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def _format_results(items: list[tuple[str, str, str]]) -> str:
|
|
157
|
+
if not items:
|
|
158
|
+
return "No search results found."
|
|
159
|
+
out: list[str] = []
|
|
160
|
+
for idx, (title, url, snippet) in enumerate(items, 1):
|
|
161
|
+
out.append(f"{idx}. {title}\n URL: {url}\n {snippet}".strip())
|
|
162
|
+
return "\n\n".join(out)
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def make_web_tool(max_fetch_chars: int = 8000) -> ToolSpec:
|
|
166
|
+
help_str = (
|
|
167
|
+
"Usage: web search <query> [--max 5] | web fetch <url>\n"
|
|
168
|
+
"Desc : Search the web or fetch clean markdown text from a URL."
|
|
169
|
+
)
|
|
170
|
+
|
|
171
|
+
async def _web(args: list[str], stdin: str | None = None) -> str:
|
|
172
|
+
if not args or args[0] in ("--help", "-h"):
|
|
173
|
+
return help_str
|
|
174
|
+
sub = args[0]
|
|
175
|
+
if sub == "search":
|
|
176
|
+
max_res = 5
|
|
177
|
+
query_parts: list[str] = []
|
|
178
|
+
i = 1
|
|
179
|
+
while i < len(args):
|
|
180
|
+
if args[i] == "--max" and i + 1 < len(args):
|
|
181
|
+
try:
|
|
182
|
+
max_res = int(args[i + 1])
|
|
183
|
+
except ValueError:
|
|
184
|
+
return "Error: `--max` expects an integer."
|
|
185
|
+
i += 2
|
|
186
|
+
else:
|
|
187
|
+
query_parts.append(args[i])
|
|
188
|
+
i += 1
|
|
189
|
+
query = " ".join(query_parts).strip() or (stdin or "").strip()
|
|
190
|
+
if not query:
|
|
191
|
+
return f"Error: missing `<query>`.\n{help_str}"
|
|
192
|
+
return await search_web_backend(query, max_results=max_res)
|
|
193
|
+
|
|
194
|
+
if sub == "fetch":
|
|
195
|
+
if len(args) < 2:
|
|
196
|
+
return f"Error: missing `<url>`.\n{help_str}"
|
|
197
|
+
url = args[1].strip()
|
|
198
|
+
if not url.startswith(("http://", "https://")):
|
|
199
|
+
return "Error: URL must start with http:// or https://."
|
|
200
|
+
headers = {"User-Agent": "Tether-MicroHarness/0.1"}
|
|
201
|
+
async with httpx.AsyncClient(
|
|
202
|
+
timeout=20.0, follow_redirects=True, headers=headers
|
|
203
|
+
) as client:
|
|
204
|
+
resp = await client.get(url)
|
|
205
|
+
resp.raise_for_status()
|
|
206
|
+
ctype = resp.headers.get("content-type", "")
|
|
207
|
+
if "html" in ctype.lower() or "<html" in resp.text[:500].lower():
|
|
208
|
+
return html_to_markdown(resp.text, max_chars=max_fetch_chars)
|
|
209
|
+
text = resp.text.strip()
|
|
210
|
+
if len(text) > max_fetch_chars:
|
|
211
|
+
return f"{text[:max_fetch_chars]}\n... [truncated]"
|
|
212
|
+
return text
|
|
213
|
+
|
|
214
|
+
return f"Error: unknown subcommand `{sub}`.\n{help_str}"
|
|
215
|
+
|
|
216
|
+
return build_tool_spec(
|
|
217
|
+
_web,
|
|
218
|
+
name="web",
|
|
219
|
+
description="Search the web or fetch clean URL text",
|
|
220
|
+
custom_help=help_str,
|
|
221
|
+
raw_dispatch=True,
|
|
222
|
+
)
|