euron-code 0.1.0__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.
- euron_code/__init__.py +3 -0
- euron_code/__main__.py +4 -0
- euron_code/agent.py +595 -0
- euron_code/api.py +364 -0
- euron_code/auth.py +115 -0
- euron_code/cli.py +351 -0
- euron_code/config.py +207 -0
- euron_code/context.py +131 -0
- euron_code/credentials.py +197 -0
- euron_code/doctor.py +88 -0
- euron_code/keys.py +92 -0
- euron_code/memory.py +97 -0
- euron_code/permissions.py +245 -0
- euron_code/prompts.py +98 -0
- euron_code/repl.py +425 -0
- euron_code/sessions.py +186 -0
- euron_code/tools/__init__.py +33 -0
- euron_code/tools/base.py +167 -0
- euron_code/tools/db.py +368 -0
- euron_code/tools/fs.py +412 -0
- euron_code/tools/ignore.py +75 -0
- euron_code/tools/search.py +204 -0
- euron_code/tools/shell.py +214 -0
- euron_code/tools/task.py +41 -0
- euron_code/tools/todo.py +64 -0
- euron_code/tools/web.py +112 -0
- euron_code/ui.py +269 -0
- euron_code/usage_view.py +108 -0
- euron_code-0.1.0.dist-info/METADATA +278 -0
- euron_code-0.1.0.dist-info/RECORD +33 -0
- euron_code-0.1.0.dist-info/WHEEL +4 -0
- euron_code-0.1.0.dist-info/entry_points.txt +2 -0
- euron_code-0.1.0.dist-info/licenses/LICENSE +23 -0
|
@@ -0,0 +1,214 @@
|
|
|
1
|
+
"""bash / run_command: runs a shell command with a persistent working directory.
|
|
2
|
+
|
|
3
|
+
Windows uses PowerShell (pwsh if installed, else Windows PowerShell 5.1), falling back to cmd.
|
|
4
|
+
macOS / Linux use $SHELL when it is bash or zsh, else bash, else sh.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import base64
|
|
10
|
+
import os
|
|
11
|
+
import shutil
|
|
12
|
+
import signal
|
|
13
|
+
import subprocess
|
|
14
|
+
import sys
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
from typing import List, Optional, Tuple
|
|
17
|
+
|
|
18
|
+
from euron_code.permissions import Request
|
|
19
|
+
from euron_code.tools.base import Tool, ToolError, ToolResult, opt_int, require, truncate
|
|
20
|
+
|
|
21
|
+
MARK = "__EURON_CWD__"
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def detect_shell(preference: Optional[str] = None) -> Tuple[str, str]:
|
|
25
|
+
"""Return (kind, executable). kind: bash | sh | zsh | powershell | cmd."""
|
|
26
|
+
pref = (preference or "").strip()
|
|
27
|
+
if pref:
|
|
28
|
+
exe = shutil.which(pref) or pref
|
|
29
|
+
low = Path(exe).name.lower()
|
|
30
|
+
if "pwsh" in low or "powershell" in low:
|
|
31
|
+
return "powershell", exe
|
|
32
|
+
if low.startswith("cmd"):
|
|
33
|
+
return "cmd", exe
|
|
34
|
+
if "zsh" in low:
|
|
35
|
+
return "zsh", exe
|
|
36
|
+
if "bash" in low:
|
|
37
|
+
return "bash", exe
|
|
38
|
+
return "sh", exe
|
|
39
|
+
if os.name == "nt":
|
|
40
|
+
for cand in ("pwsh", "powershell"):
|
|
41
|
+
exe = shutil.which(cand)
|
|
42
|
+
if exe:
|
|
43
|
+
return "powershell", exe
|
|
44
|
+
sysroot = os.environ.get("SystemRoot") or os.environ.get("SYSTEMROOT") or r"C:\Windows"
|
|
45
|
+
builtin = Path(sysroot) / "System32" / "WindowsPowerShell" / "v1.0" / "powershell.exe"
|
|
46
|
+
if builtin.is_file():
|
|
47
|
+
return "powershell", str(builtin)
|
|
48
|
+
return "cmd", os.environ.get("COMSPEC") or str(Path(sysroot) / "System32" / "cmd.exe")
|
|
49
|
+
sh = os.environ.get("SHELL", "")
|
|
50
|
+
if sh and Path(sh).name in ("bash", "zsh") and os.path.exists(sh):
|
|
51
|
+
return Path(sh).name, sh
|
|
52
|
+
exe = shutil.which("bash")
|
|
53
|
+
if exe:
|
|
54
|
+
return "bash", exe
|
|
55
|
+
return "sh", shutil.which("sh") or "/bin/sh"
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def build_argv(kind: str, exe: str, command: str):
|
|
59
|
+
"""Return the argv list (or, for cmd.exe, a raw command line string)."""
|
|
60
|
+
if kind == "powershell":
|
|
61
|
+
script = (
|
|
62
|
+
"[Console]::OutputEncoding = [System.Text.Encoding]::UTF8\n"
|
|
63
|
+
"$OutputEncoding = [System.Text.Encoding]::UTF8\n"
|
|
64
|
+
"$ProgressPreference = 'SilentlyContinue'\n"
|
|
65
|
+
"$global:LASTEXITCODE = 0\n"
|
|
66
|
+
f"{command}\n"
|
|
67
|
+
"$__euron_ok = $?\n"
|
|
68
|
+
"$__euron_ec = if ($global:LASTEXITCODE -ne 0) { $global:LASTEXITCODE } elseif ($__euron_ok) { 0 } else { 1 }\n"
|
|
69
|
+
f"[Console]::Out.Write(\"`n{MARK}\" + (Get-Location).ProviderPath + \"`n\")\n"
|
|
70
|
+
"exit $__euron_ec\n"
|
|
71
|
+
)
|
|
72
|
+
enc = base64.b64encode(script.encode("utf-16-le")).decode("ascii")
|
|
73
|
+
return [exe, "-NoProfile", "-NonInteractive", "-ExecutionPolicy", "Bypass", "-EncodedCommand", enc]
|
|
74
|
+
if kind == "cmd":
|
|
75
|
+
# delayed expansion so ERRORLEVEL/CD are read after the command runs
|
|
76
|
+
# a raw string: list2cmdline's backslash-escaping confuses cmd.exe. /s strips the outer quotes.
|
|
77
|
+
script = f"{command} & set __EURON_EC=!ERRORLEVEL! & echo.{MARK}!CD! & exit /b !__EURON_EC!"
|
|
78
|
+
return f'"{exe}" /d /v:on /s /c "{script}"'
|
|
79
|
+
|
|
80
|
+
# Git Bash / MSYS on Windows: `pwd -W` prints a C:/... path Python can use
|
|
81
|
+
pwd = "pwd -W 2>/dev/null || pwd" if os.name == "nt" else "pwd"
|
|
82
|
+
script = (
|
|
83
|
+
f"{command}\n"
|
|
84
|
+
"__euron_ec=$?\n"
|
|
85
|
+
f"printf '\\n{MARK}%s\\n' \"$({pwd})\"\n"
|
|
86
|
+
"exit $__euron_ec\n"
|
|
87
|
+
)
|
|
88
|
+
return [exe, "-c", script]
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def _kill_tree(proc: subprocess.Popen) -> None:
|
|
92
|
+
try:
|
|
93
|
+
if os.name == "nt":
|
|
94
|
+
subprocess.run(["taskkill", "/F", "/T", "/PID", str(proc.pid)],
|
|
95
|
+
capture_output=True, timeout=10)
|
|
96
|
+
else:
|
|
97
|
+
os.killpg(proc.pid, signal.SIGKILL)
|
|
98
|
+
except Exception:
|
|
99
|
+
try:
|
|
100
|
+
proc.kill()
|
|
101
|
+
except Exception:
|
|
102
|
+
pass
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
class ShellSession:
|
|
106
|
+
def __init__(self, root: Path, preference: Optional[str] = None):
|
|
107
|
+
self.root = root
|
|
108
|
+
self.cwd = root
|
|
109
|
+
self.kind, self.exe = detect_shell(preference)
|
|
110
|
+
|
|
111
|
+
def run(self, command: str, timeout: float) -> Tuple[int, str, bool]:
|
|
112
|
+
"""Return (exit_code, output, timed_out). Raises KeyboardInterrupt after killing the child."""
|
|
113
|
+
argv = build_argv(self.kind, self.exe, command)
|
|
114
|
+
env = dict(os.environ)
|
|
115
|
+
env.setdefault("PAGER", "cat")
|
|
116
|
+
env.setdefault("GIT_PAGER", "cat")
|
|
117
|
+
env["EURON_CODE"] = "1"
|
|
118
|
+
kw = {}
|
|
119
|
+
if os.name == "nt":
|
|
120
|
+
kw["creationflags"] = subprocess.CREATE_NEW_PROCESS_GROUP # type: ignore[attr-defined]
|
|
121
|
+
else:
|
|
122
|
+
kw["start_new_session"] = True
|
|
123
|
+
cwd = self.cwd if self.cwd.is_dir() else self.root
|
|
124
|
+
proc = subprocess.Popen(argv, cwd=str(cwd), stdin=subprocess.DEVNULL, stdout=subprocess.PIPE,
|
|
125
|
+
stderr=subprocess.STDOUT, env=env, **kw)
|
|
126
|
+
timed_out = False
|
|
127
|
+
try:
|
|
128
|
+
out, _ = proc.communicate(timeout=timeout)
|
|
129
|
+
except subprocess.TimeoutExpired:
|
|
130
|
+
timed_out = True
|
|
131
|
+
_kill_tree(proc)
|
|
132
|
+
try:
|
|
133
|
+
out, _ = proc.communicate(timeout=5)
|
|
134
|
+
except Exception:
|
|
135
|
+
out = b""
|
|
136
|
+
except KeyboardInterrupt:
|
|
137
|
+
_kill_tree(proc)
|
|
138
|
+
raise
|
|
139
|
+
text = (out or b"").decode("utf-8", errors="replace").replace("\r\n", "\n")
|
|
140
|
+
code = proc.returncode if proc.returncode is not None else -1
|
|
141
|
+
idx = text.rfind(MARK)
|
|
142
|
+
if idx != -1:
|
|
143
|
+
line_end = text.find("\n", idx)
|
|
144
|
+
new_cwd = text[idx + len(MARK): line_end if line_end != -1 else None].strip()
|
|
145
|
+
text = text[:idx].rstrip("\n") + (text[line_end + 1:] if line_end != -1 else "")
|
|
146
|
+
if new_cwd and Path(new_cwd).is_dir():
|
|
147
|
+
self.cwd = Path(new_cwd)
|
|
148
|
+
if code < 0 and os.name != "nt" and not timed_out:
|
|
149
|
+
code = 128 + abs(code)
|
|
150
|
+
return code, text.rstrip(), timed_out
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
class Bash(Tool):
|
|
154
|
+
name = "bash"
|
|
155
|
+
description = (
|
|
156
|
+
"Run a shell command on the user's machine and return its output and exit code. The working "
|
|
157
|
+
"directory persists between calls (a `cd` carries over). Use for tests, builds, git and package "
|
|
158
|
+
"commands. Do not use it to read or edit files (use read_file/edit_file) or to search (use "
|
|
159
|
+
"grep/glob). Do not start servers or watchers that never exit; they are killed at the timeout."
|
|
160
|
+
)
|
|
161
|
+
parameters = {
|
|
162
|
+
"type": "object",
|
|
163
|
+
"properties": {
|
|
164
|
+
"command": {"type": "string", "description": "The command to run."},
|
|
165
|
+
"timeout": {"type": "integer", "description": "Seconds before the command is killed (default 120, max 600)."},
|
|
166
|
+
"description": {"type": "string", "description": "5-10 words on what the command does."},
|
|
167
|
+
},
|
|
168
|
+
"required": ["command"],
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
def _session(self, ctx) -> ShellSession:
|
|
172
|
+
if ctx.shell is None:
|
|
173
|
+
ctx.shell = ShellSession(ctx.root, ctx.settings.get("shell"))
|
|
174
|
+
return ctx.shell
|
|
175
|
+
|
|
176
|
+
def permission(self, args, ctx):
|
|
177
|
+
return Request("Bash", self.name, command=require(args, "command").strip())
|
|
178
|
+
|
|
179
|
+
def describe(self, args, ctx):
|
|
180
|
+
return args.get("command", "").strip().splitlines()[0][:160] if args.get("command") else ""
|
|
181
|
+
|
|
182
|
+
def preview(self, args, ctx):
|
|
183
|
+
sh = self._session(ctx)
|
|
184
|
+
cmd = require(args, "command")
|
|
185
|
+
where = ctx.rel(sh.cwd)
|
|
186
|
+
return f"$ {cmd}\n(in {where or '.'}, shell: {sh.kind})"
|
|
187
|
+
|
|
188
|
+
def run(self, args, ctx):
|
|
189
|
+
cmd = require(args, "command").strip()
|
|
190
|
+
if not cmd:
|
|
191
|
+
raise ToolError("Empty command.")
|
|
192
|
+
default_t = int(ctx.settings.get("bashTimeout") or 120)
|
|
193
|
+
timeout = max(1, min(opt_int(args, "timeout", default_t) or default_t, 600))
|
|
194
|
+
sh = self._session(ctx)
|
|
195
|
+
before = sh.cwd
|
|
196
|
+
try:
|
|
197
|
+
code, out, timed_out = sh.run(cmd, timeout)
|
|
198
|
+
except FileNotFoundError as e:
|
|
199
|
+
raise ToolError(f"Shell not found: {e}")
|
|
200
|
+
except KeyboardInterrupt:
|
|
201
|
+
return ToolResult("The user interrupted the command (Ctrl-C); it was killed.", is_error=True,
|
|
202
|
+
preview="interrupted")
|
|
203
|
+
body = truncate(out, ctx.max_output_chars, "pipe through a filter such as `| tail -50` for less")
|
|
204
|
+
head = f"Exit code: {code}"
|
|
205
|
+
if timed_out:
|
|
206
|
+
head += f" (killed after {timeout}s timeout)"
|
|
207
|
+
if sh.cwd != before:
|
|
208
|
+
head += f"\nWorking directory is now: {ctx.rel(sh.cwd)}"
|
|
209
|
+
content = head + ("\n" + body if body else "\n(no output)")
|
|
210
|
+
return ToolResult(content, is_error=(code != 0), preview=(body or "(no output)") + f"\n[exit {code}]")
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
def python_executable() -> str:
|
|
214
|
+
return sys.executable
|
euron_code/tools/task.py
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
"""task: a read-only sub-agent with its own context window.
|
|
2
|
+
|
|
3
|
+
The sub-agent runs sequentially (never in parallel with the main agent: the gateway allows one
|
|
4
|
+
generation per user), sees only read-only tools, and returns its final answer as the tool result.
|
|
5
|
+
Useful for broad searches whose file dumps would otherwise fill the main context.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from euron_code.tools.base import Tool, ToolError, ToolResult, require, truncate
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class Task(Tool):
|
|
14
|
+
name = "task"
|
|
15
|
+
read_only = True
|
|
16
|
+
description = (
|
|
17
|
+
"Delegate a self-contained research question to a sub-agent with a fresh context and READ-ONLY "
|
|
18
|
+
"tools (read_file, list_dir, glob, grep). It cannot edit files or run commands. Use it for broad "
|
|
19
|
+
"searches (\"where is X handled?\", \"summarise how module Y works\") so the file contents do not "
|
|
20
|
+
"fill your own context. Give it a complete, specific prompt; it returns a concise report."
|
|
21
|
+
)
|
|
22
|
+
parameters = {
|
|
23
|
+
"type": "object",
|
|
24
|
+
"properties": {
|
|
25
|
+
"description": {"type": "string", "description": "3-5 word label."},
|
|
26
|
+
"prompt": {"type": "string", "description": "The full task for the sub-agent."},
|
|
27
|
+
},
|
|
28
|
+
"required": ["prompt"],
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
def describe(self, args, ctx):
|
|
32
|
+
return args.get("description") or (args.get("prompt") or "")[:80]
|
|
33
|
+
|
|
34
|
+
def run(self, args, ctx):
|
|
35
|
+
prompt = require(args, "prompt")
|
|
36
|
+
if ctx.agent is None:
|
|
37
|
+
raise ToolError("Sub-agents are not available here.")
|
|
38
|
+
if getattr(ctx.agent, "is_subagent", False):
|
|
39
|
+
raise ToolError("A sub-agent cannot start another sub-agent.")
|
|
40
|
+
report = ctx.agent.run_subagent(prompt)
|
|
41
|
+
return ToolResult(truncate(report, ctx.max_output_chars), preview=report[:300])
|
euron_code/tools/todo.py
ADDED
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
"""todo_write: the task list the model keeps for multi-step work (shown to the user)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Any, Dict, List
|
|
6
|
+
|
|
7
|
+
from euron_code.tools.base import Tool, ToolError, ToolResult
|
|
8
|
+
|
|
9
|
+
STATUSES = ("pending", "in_progress", "completed")
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def render_todos(todos: List[Dict[str, Any]]) -> str:
|
|
13
|
+
if not todos:
|
|
14
|
+
return "(no todos)"
|
|
15
|
+
mark = {"completed": "[x]", "in_progress": "[~]", "pending": "[ ]"}
|
|
16
|
+
return "\n".join(f"{mark.get(t['status'], '[ ]')} {t['content']}" for t in todos)
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class TodoWrite(Tool):
|
|
20
|
+
name = "todo_write"
|
|
21
|
+
read_only = True
|
|
22
|
+
description = (
|
|
23
|
+
"Create or update your task list for multi-step work. Send the WHOLE list each time. "
|
|
24
|
+
"Keep exactly one item in_progress while working; mark items completed as soon as they are done. "
|
|
25
|
+
"Skip this for trivial one-step requests."
|
|
26
|
+
)
|
|
27
|
+
parameters = {
|
|
28
|
+
"type": "object",
|
|
29
|
+
"properties": {
|
|
30
|
+
"todos": {
|
|
31
|
+
"type": "array",
|
|
32
|
+
"items": {
|
|
33
|
+
"type": "object",
|
|
34
|
+
"properties": {
|
|
35
|
+
"content": {"type": "string"},
|
|
36
|
+
"status": {"type": "string", "enum": list(STATUSES)},
|
|
37
|
+
},
|
|
38
|
+
"required": ["content", "status"],
|
|
39
|
+
},
|
|
40
|
+
}
|
|
41
|
+
},
|
|
42
|
+
"required": ["todos"],
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
def describe(self, args, ctx):
|
|
46
|
+
todos = args.get("todos") or []
|
|
47
|
+
done = sum(1 for t in todos if isinstance(t, dict) and t.get("status") == "completed")
|
|
48
|
+
return f"{done}/{len(todos)} done"
|
|
49
|
+
|
|
50
|
+
def run(self, args, ctx):
|
|
51
|
+
todos = args.get("todos")
|
|
52
|
+
if not isinstance(todos, list):
|
|
53
|
+
raise ToolError("`todos` must be a list.")
|
|
54
|
+
clean = []
|
|
55
|
+
for i, t in enumerate(todos, 1):
|
|
56
|
+
if not isinstance(t, dict) or not str(t.get("content", "")).strip():
|
|
57
|
+
raise ToolError(f"todo {i} needs a non-empty `content`.")
|
|
58
|
+
st = t.get("status", "pending")
|
|
59
|
+
if st not in STATUSES:
|
|
60
|
+
raise ToolError(f"todo {i} has invalid status {st!r}; use {', '.join(STATUSES)}.")
|
|
61
|
+
clean.append({"content": str(t["content"]).strip(), "status": st})
|
|
62
|
+
ctx.todos[:] = clean
|
|
63
|
+
ctx.ui.show_todos(clean)
|
|
64
|
+
return ToolResult("Todo list updated:\n" + render_todos(clean), preview="")
|
euron_code/tools/web.py
ADDED
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
"""web_fetch: fetch a URL and return readable text (no JavaScript, no binaries)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import html
|
|
6
|
+
import re
|
|
7
|
+
from html.parser import HTMLParser
|
|
8
|
+
from typing import List
|
|
9
|
+
from urllib.parse import urlparse
|
|
10
|
+
|
|
11
|
+
import httpx
|
|
12
|
+
|
|
13
|
+
from euron_code import api
|
|
14
|
+
from euron_code.permissions import Request
|
|
15
|
+
from euron_code.tools.base import Tool, ToolError, ToolResult, require, truncate
|
|
16
|
+
|
|
17
|
+
SKIP_TAGS = {"script", "style", "noscript", "svg", "head", "template", "iframe"}
|
|
18
|
+
BLOCK_TAGS = {"p", "div", "br", "li", "tr", "h1", "h2", "h3", "h4", "h5", "h6", "pre", "section",
|
|
19
|
+
"article", "header", "footer", "table", "ul", "ol", "blockquote", "hr"}
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class _TextExtractor(HTMLParser):
|
|
23
|
+
def __init__(self) -> None:
|
|
24
|
+
super().__init__(convert_charrefs=True)
|
|
25
|
+
self.parts: List[str] = []
|
|
26
|
+
self.skip = 0
|
|
27
|
+
|
|
28
|
+
def handle_starttag(self, tag, attrs):
|
|
29
|
+
if tag in SKIP_TAGS:
|
|
30
|
+
self.skip += 1
|
|
31
|
+
elif tag in BLOCK_TAGS:
|
|
32
|
+
self.parts.append("\n")
|
|
33
|
+
if tag == "a":
|
|
34
|
+
href = dict(attrs).get("href")
|
|
35
|
+
if href and href.startswith("http"):
|
|
36
|
+
self._href = href
|
|
37
|
+
|
|
38
|
+
def handle_endtag(self, tag):
|
|
39
|
+
if tag in SKIP_TAGS and self.skip:
|
|
40
|
+
self.skip -= 1
|
|
41
|
+
elif tag in BLOCK_TAGS:
|
|
42
|
+
self.parts.append("\n")
|
|
43
|
+
|
|
44
|
+
def handle_data(self, data):
|
|
45
|
+
if not self.skip:
|
|
46
|
+
self.parts.append(data)
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def html_to_text(src: str) -> str:
|
|
50
|
+
p = _TextExtractor()
|
|
51
|
+
try:
|
|
52
|
+
p.feed(src)
|
|
53
|
+
p.close()
|
|
54
|
+
except Exception:
|
|
55
|
+
return re.sub(r"<[^>]+>", " ", html.unescape(src))
|
|
56
|
+
text = "".join(p.parts)
|
|
57
|
+
text = re.sub(r"[ \t\r\f\v]+", " ", text)
|
|
58
|
+
text = re.sub(r"\n\s*\n+", "\n\n", text)
|
|
59
|
+
return text.strip()
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
class WebFetch(Tool):
|
|
63
|
+
name = "web_fetch"
|
|
64
|
+
read_only = True
|
|
65
|
+
description = (
|
|
66
|
+
"Fetch an http(s) URL and return its text content (HTML is converted to plain text). "
|
|
67
|
+
"Use for documentation pages and API references. Asks the user before each new domain."
|
|
68
|
+
)
|
|
69
|
+
parameters = {
|
|
70
|
+
"type": "object",
|
|
71
|
+
"properties": {"url": {"type": "string"}},
|
|
72
|
+
"required": ["url"],
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
def permission(self, args, ctx):
|
|
76
|
+
url = require(args, "url")
|
|
77
|
+
return Request("WebFetch", self.name, domain=(urlparse(url).hostname or ""))
|
|
78
|
+
|
|
79
|
+
def describe(self, args, ctx):
|
|
80
|
+
return args.get("url", "")
|
|
81
|
+
|
|
82
|
+
def run(self, args, ctx):
|
|
83
|
+
url = require(args, "url").strip()
|
|
84
|
+
u = urlparse(url)
|
|
85
|
+
if u.scheme not in ("http", "https") or not u.hostname:
|
|
86
|
+
raise ToolError("Only http:// and https:// URLs are supported.")
|
|
87
|
+
host = u.hostname.lower()
|
|
88
|
+
try:
|
|
89
|
+
with httpx.Client(timeout=20.0, follow_redirects=False,
|
|
90
|
+
headers={"User-Agent": api.user_agent()}) as c:
|
|
91
|
+
resp = c.get(url)
|
|
92
|
+
for _ in range(5): # follow redirects only within the approved site
|
|
93
|
+
if resp.status_code not in (301, 302, 303, 307, 308) or "location" not in resp.headers:
|
|
94
|
+
break
|
|
95
|
+
nxt = resp.url.join(resp.headers["location"])
|
|
96
|
+
nh = (nxt.host or "").lower()
|
|
97
|
+
if nh != host and not nh.endswith("." + host) and not host.endswith("." + nh):
|
|
98
|
+
return ToolResult(f"{url} redirects to another site: {nxt}. Call web_fetch on that URL "
|
|
99
|
+
"if you need it (the user will be asked).", preview="redirect")
|
|
100
|
+
resp = c.get(nxt)
|
|
101
|
+
except httpx.HTTPError as e:
|
|
102
|
+
raise ToolError(f"Fetch failed: {e}")
|
|
103
|
+
ctype = resp.headers.get("content-type", "")
|
|
104
|
+
if resp.status_code >= 400:
|
|
105
|
+
raise ToolError(f"HTTP {resp.status_code} from {url}")
|
|
106
|
+
if not any(t in ctype for t in ("text/", "json", "xml", "javascript")) and ctype:
|
|
107
|
+
raise ToolError(f"Not a text page (content-type {ctype}).")
|
|
108
|
+
body = resp.text
|
|
109
|
+
if "html" in ctype or body.lstrip()[:15].lower().startswith(("<!doctype", "<html")):
|
|
110
|
+
body = html_to_text(body)
|
|
111
|
+
header = f"URL: {resp.url}\nStatus: {resp.status_code}\n\n"
|
|
112
|
+
return ToolResult(header + truncate(body, ctx.max_output_chars), preview=f"{len(body)} chars")
|