preppergpt 0.1.0
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.
- package/LICENSE +21 -0
- package/README.md +93 -0
- package/bin/preppergpt.js +8 -0
- package/compose/preppergpt.yaml +232 -0
- package/docs/hardware.md +15 -0
- package/docs/model-sources.md +12 -0
- package/docs/preppergpt-local-parity-map.md +16 -0
- package/docs/publishing.md +24 -0
- package/installer/cli.mjs +225 -0
- package/installer/install.sh +18 -0
- package/installer/lib/detect.mjs +128 -0
- package/installer/lib/paths.mjs +26 -0
- package/installer/lib/planner.mjs +175 -0
- package/installer/lib/render.mjs +76 -0
- package/installer/lib/util.mjs +84 -0
- package/package.json +48 -0
- package/profiles/models.json +277 -0
- package/services/comfyui/flux-kontext-edit-openwebui-nodes.json +46 -0
- package/services/comfyui/flux-kontext-edit-openwebui-workflow.json +245 -0
- package/services/comfyui/flux-kontext-mask-edit-openwebui-nodes.json +51 -0
- package/services/comfyui/flux-kontext-mask-edit-openwebui-workflow.json +322 -0
- package/services/comfyui/flux2-klein-9b-openwebui-nodes.json +58 -0
- package/services/comfyui/flux2-klein-9b-openwebui-workflow.json +141 -0
- package/services/comfyui/image-invert-edit-openwebui-nodes.json +23 -0
- package/services/comfyui/image-invert-edit-openwebui-workflow.json +52 -0
- package/services/deep-research/Dockerfile +7 -0
- package/services/deep-research/app.py +1913 -0
- package/services/local-agent/Dockerfile +17 -0
- package/services/local-agent/app.py +2311 -0
- package/services/local-scheduler/Dockerfile +8 -0
- package/services/local-scheduler/app.py +15774 -0
- package/services/local-vision/Dockerfile +11 -0
- package/services/local-vision/app.py +888 -0
- package/services/searxng/settings.yml +16 -0
- package/themes/preppergpt/custom.css +15 -0
- package/themes/preppergpt/static/favicon.svg +5 -0
- package/themes/preppergpt/static/logo.svg +6 -0
|
@@ -0,0 +1,2311 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
import json
|
|
3
|
+
import os
|
|
4
|
+
import re
|
|
5
|
+
import shutil
|
|
6
|
+
import subprocess
|
|
7
|
+
import tempfile
|
|
8
|
+
import time
|
|
9
|
+
import uuid
|
|
10
|
+
from dataclasses import asdict, dataclass
|
|
11
|
+
from html import escape
|
|
12
|
+
from html.parser import HTMLParser
|
|
13
|
+
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
from threading import Lock
|
|
16
|
+
from urllib import parse, request
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
MODEL_ID = os.environ.get("LOCAL_AGENT_MODEL_ID", "local-agent-glm52")
|
|
20
|
+
GLM_MODEL = os.environ.get("LOCAL_AGENT_GLM_MODEL", "glm52-q4-local")
|
|
21
|
+
GLM_BASE_URL = os.environ.get("LOCAL_AGENT_GLM_BASE_URL", "http://127.0.0.1:11441/v1")
|
|
22
|
+
AUTO_ROUTER_MODEL_ID = os.environ.get("LOCAL_AGENT_AUTO_ROUTER_MODEL_ID", "local-auto-router")
|
|
23
|
+
AUTO_ROUTER_FAST_MODEL = os.environ.get("LOCAL_AGENT_AUTO_ROUTER_FAST_MODEL", "gemma4:12b-256k-gpu")
|
|
24
|
+
AUTO_ROUTER_FAST_BASE_URL = os.environ.get("LOCAL_AGENT_AUTO_ROUTER_FAST_BASE_URL", "http://127.0.0.1:11434/v1")
|
|
25
|
+
AUTO_ROUTER_CODE_MODEL = os.environ.get("LOCAL_AGENT_AUTO_ROUTER_CODE_MODEL", "qwen3.6-35b-a3b:slopcode-cpu-64k")
|
|
26
|
+
AUTO_ROUTER_CODE_BASE_URL = os.environ.get("LOCAL_AGENT_AUTO_ROUTER_CODE_BASE_URL", "http://127.0.0.1:11438/v1")
|
|
27
|
+
AUTO_ROUTER_RESEARCH_MODEL = os.environ.get("LOCAL_AGENT_AUTO_ROUTER_RESEARCH_MODEL", "deep-research-glm52")
|
|
28
|
+
AUTO_ROUTER_RESEARCH_BASE_URL = os.environ.get("LOCAL_AGENT_AUTO_ROUTER_RESEARCH_BASE_URL", "http://127.0.0.1:18041/v1")
|
|
29
|
+
AUTO_ROUTER_AGENT_MODEL = os.environ.get("LOCAL_AGENT_AUTO_ROUTER_AGENT_MODEL", MODEL_ID)
|
|
30
|
+
AUTO_ROUTER_AGENT_BASE_URL = os.environ.get("LOCAL_AGENT_AUTO_ROUTER_AGENT_BASE_URL", "http://127.0.0.1:18043/v1")
|
|
31
|
+
SEARXNG_URL = os.environ.get("LOCAL_AGENT_SEARXNG_URL", "http://127.0.0.1:18080/search")
|
|
32
|
+
TIKA_URL = os.environ.get("LOCAL_AGENT_TIKA_URL", "http://127.0.0.1:9998/tika")
|
|
33
|
+
SCHEDULER_URL = os.environ.get("LOCAL_AGENT_SCHEDULER_URL", "http://127.0.0.1:18042")
|
|
34
|
+
PUBLIC_BASE_URL = os.environ.get("LOCAL_AGENT_PUBLIC_BASE_URL", "http://127.0.0.1:18043")
|
|
35
|
+
PLAYWRIGHT_WS_URL = os.environ.get("LOCAL_AGENT_PLAYWRIGHT_WS_URL", "ws://127.0.0.1:18045")
|
|
36
|
+
HOST = os.environ.get("LOCAL_AGENT_HOST", "127.0.0.1")
|
|
37
|
+
PORT = int(os.environ.get("LOCAL_AGENT_PORT", "18043"))
|
|
38
|
+
STORAGE = Path(os.environ.get("LOCAL_AGENT_STORAGE", "/data"))
|
|
39
|
+
GLM_TIMEOUT = int(os.environ.get("LOCAL_AGENT_GLM_TIMEOUT_SECONDS", "21600"))
|
|
40
|
+
AUTO_ROUTER_FAST_TIMEOUT = int(os.environ.get("LOCAL_AGENT_AUTO_ROUTER_FAST_TIMEOUT_SECONDS", "180"))
|
|
41
|
+
AUTO_ROUTER_CODE_TIMEOUT = int(os.environ.get("LOCAL_AGENT_AUTO_ROUTER_CODE_TIMEOUT_SECONDS", "240"))
|
|
42
|
+
AUTO_ROUTER_RESEARCH_TIMEOUT = int(os.environ.get("LOCAL_AGENT_AUTO_ROUTER_RESEARCH_TIMEOUT_SECONDS", "300"))
|
|
43
|
+
AUTO_ROUTER_AGENT_TIMEOUT = int(os.environ.get("LOCAL_AGENT_AUTO_ROUTER_AGENT_TIMEOUT_SECONDS", "120"))
|
|
44
|
+
AUTO_ROUTER_GLM_COLD_FALLBACK = os.environ.get("LOCAL_AGENT_AUTO_ROUTER_GLM_COLD_FALLBACK", "1").lower() not in {
|
|
45
|
+
"0",
|
|
46
|
+
"false",
|
|
47
|
+
"no",
|
|
48
|
+
}
|
|
49
|
+
AUTO_ROUTER_GLM_WARM_MIN_DECODED = int(os.environ.get("LOCAL_AGENT_AUTO_ROUTER_GLM_WARM_MIN_DECODED", "1"))
|
|
50
|
+
AUTO_ROUTER_GLM_HEALTH_TIMEOUT = float(os.environ.get("LOCAL_AGENT_AUTO_ROUTER_GLM_HEALTH_TIMEOUT_SECONDS", "2"))
|
|
51
|
+
FETCH_TIMEOUT = int(os.environ.get("LOCAL_AGENT_FETCH_TIMEOUT_SECONDS", "20"))
|
|
52
|
+
MAX_FETCH_BYTES = int(os.environ.get("LOCAL_AGENT_MAX_FETCH_BYTES", str(2 * 1024 * 1024)))
|
|
53
|
+
PYTHON_TIMEOUT = int(os.environ.get("LOCAL_AGENT_PYTHON_TIMEOUT_SECONDS", "20"))
|
|
54
|
+
APPROVAL_WAIT_SECONDS = int(os.environ.get("LOCAL_AGENT_APPROVAL_WAIT_SECONDS", "120"))
|
|
55
|
+
PLAYWRIGHT_TIMEOUT_MS = int(os.environ.get("LOCAL_AGENT_PLAYWRIGHT_TIMEOUT_MS", "15000"))
|
|
56
|
+
DESKTOP_ENABLED = os.environ.get("LOCAL_AGENT_DESKTOP_ENABLED", "1").lower() not in {"0", "false", "no"}
|
|
57
|
+
DESKTOP_TIMEOUT = int(os.environ.get("LOCAL_AGENT_DESKTOP_TIMEOUT_SECONDS", "15"))
|
|
58
|
+
DESKTOP_COMMAND_MAX_OUTPUT = int(os.environ.get("LOCAL_AGENT_DESKTOP_COMMAND_MAX_OUTPUT", "6000"))
|
|
59
|
+
AUTO_ROUTER_TELEMETRY_LIMIT = max(10, int(os.environ.get("LOCAL_AGENT_AUTO_ROUTER_TELEMETRY_LIMIT", "200")))
|
|
60
|
+
|
|
61
|
+
LLM_LOCK = Lock()
|
|
62
|
+
APPROVAL_LOCK = Lock()
|
|
63
|
+
AUTO_ROUTER_TELEMETRY_LOCK = Lock()
|
|
64
|
+
AUTO_ROUTER_EVENTS: list[dict] = []
|
|
65
|
+
STORAGE.mkdir(parents=True, exist_ok=True)
|
|
66
|
+
APPROVALS_DIR = STORAGE / "approvals"
|
|
67
|
+
APPROVALS_DIR.mkdir(parents=True, exist_ok=True)
|
|
68
|
+
SCREENSHOTS_DIR = STORAGE / "screenshots"
|
|
69
|
+
SCREENSHOTS_DIR.mkdir(parents=True, exist_ok=True)
|
|
70
|
+
DESKTOP_DIR = STORAGE / "desktop"
|
|
71
|
+
DESKTOP_DIR.mkdir(parents=True, exist_ok=True)
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
@dataclass
|
|
75
|
+
class Action:
|
|
76
|
+
kind: str
|
|
77
|
+
title: str
|
|
78
|
+
input: str
|
|
79
|
+
output: str
|
|
80
|
+
status: str = "completed"
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
class TextExtractor(HTMLParser):
|
|
84
|
+
def __init__(self):
|
|
85
|
+
super().__init__()
|
|
86
|
+
self.skip = 0
|
|
87
|
+
self.parts = []
|
|
88
|
+
|
|
89
|
+
def handle_starttag(self, tag, attrs):
|
|
90
|
+
if tag in {"script", "style", "noscript", "svg"}:
|
|
91
|
+
self.skip += 1
|
|
92
|
+
|
|
93
|
+
def handle_endtag(self, tag):
|
|
94
|
+
if tag in {"script", "style", "noscript", "svg"} and self.skip:
|
|
95
|
+
self.skip -= 1
|
|
96
|
+
|
|
97
|
+
def handle_data(self, data):
|
|
98
|
+
if not self.skip:
|
|
99
|
+
text = " ".join(data.split())
|
|
100
|
+
if text:
|
|
101
|
+
self.parts.append(text)
|
|
102
|
+
|
|
103
|
+
def text(self) -> str:
|
|
104
|
+
return "\n".join(self.parts)
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
class BrowserSnapshotParser(HTMLParser):
|
|
108
|
+
def __init__(self):
|
|
109
|
+
super().__init__()
|
|
110
|
+
self.skip = 0
|
|
111
|
+
self.title_parts = []
|
|
112
|
+
self.text_parts = []
|
|
113
|
+
self.links = []
|
|
114
|
+
self.current_link = None
|
|
115
|
+
self.in_title = False
|
|
116
|
+
|
|
117
|
+
def handle_starttag(self, tag, attrs):
|
|
118
|
+
attrs_dict = dict(attrs)
|
|
119
|
+
if tag in {"script", "style", "noscript", "svg"}:
|
|
120
|
+
self.skip += 1
|
|
121
|
+
if tag == "title":
|
|
122
|
+
self.in_title = True
|
|
123
|
+
if tag == "a":
|
|
124
|
+
self.current_link = {"href": attrs_dict.get("href", ""), "text": []}
|
|
125
|
+
if tag in {"button", "input", "textarea", "select"}:
|
|
126
|
+
label = attrs_dict.get("aria-label") or attrs_dict.get("name") or attrs_dict.get("value") or tag
|
|
127
|
+
self.text_parts.append(f"[control:{tag}] {label}")
|
|
128
|
+
|
|
129
|
+
def handle_endtag(self, tag):
|
|
130
|
+
if tag in {"script", "style", "noscript", "svg"} and self.skip:
|
|
131
|
+
self.skip -= 1
|
|
132
|
+
if tag == "title":
|
|
133
|
+
self.in_title = False
|
|
134
|
+
if tag == "a" and self.current_link:
|
|
135
|
+
text = clean_text(" ".join(self.current_link["text"])) or self.current_link["href"]
|
|
136
|
+
self.links.append({"text": text[:160], "href": self.current_link["href"]})
|
|
137
|
+
self.current_link = None
|
|
138
|
+
|
|
139
|
+
def handle_data(self, data):
|
|
140
|
+
if self.skip:
|
|
141
|
+
return
|
|
142
|
+
text = " ".join(data.split())
|
|
143
|
+
if not text:
|
|
144
|
+
return
|
|
145
|
+
if self.in_title:
|
|
146
|
+
self.title_parts.append(text)
|
|
147
|
+
if self.current_link is not None:
|
|
148
|
+
self.current_link["text"].append(text)
|
|
149
|
+
self.text_parts.append(text)
|
|
150
|
+
|
|
151
|
+
def snapshot(self, url: str) -> dict:
|
|
152
|
+
text = clean_text("\n".join(self.text_parts))
|
|
153
|
+
return {
|
|
154
|
+
"url": url,
|
|
155
|
+
"title": clean_text(" ".join(self.title_parts))[:200],
|
|
156
|
+
"text": text[:3000],
|
|
157
|
+
"links": self.links[:25],
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def now() -> int:
|
|
162
|
+
return int(time.time())
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def clean_text(text: str) -> str:
|
|
166
|
+
text = re.sub(r"\r", "\n", text)
|
|
167
|
+
text = re.sub(r"[ \t]+", " ", text)
|
|
168
|
+
text = re.sub(r"\n{3,}", "\n\n", text)
|
|
169
|
+
return text.strip()
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
def read_json(handler: BaseHTTPRequestHandler) -> dict:
|
|
173
|
+
length = int(handler.headers.get("Content-Length", "0") or "0")
|
|
174
|
+
raw = handler.rfile.read(length) if length else b"{}"
|
|
175
|
+
return json.loads(raw.decode("utf-8") or "{}")
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
def send_json(handler: BaseHTTPRequestHandler, status: int, payload: dict):
|
|
179
|
+
body = json.dumps(payload, ensure_ascii=False).encode("utf-8")
|
|
180
|
+
handler.send_response(status)
|
|
181
|
+
handler.send_header("Content-Type", "application/json; charset=utf-8")
|
|
182
|
+
handler.send_header("Content-Length", str(len(body)))
|
|
183
|
+
handler.send_header("Access-Control-Allow-Origin", "*")
|
|
184
|
+
handler.end_headers()
|
|
185
|
+
handler.wfile.write(body)
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
def http_json(url: str, payload: dict | None = None, timeout: int = 60, headers: dict | None = None) -> dict:
|
|
189
|
+
data = None
|
|
190
|
+
req_headers = {"User-Agent": "openwebui-local-agent/0.1"}
|
|
191
|
+
if headers:
|
|
192
|
+
req_headers.update(headers)
|
|
193
|
+
if payload is not None:
|
|
194
|
+
data = json.dumps(payload).encode("utf-8")
|
|
195
|
+
req_headers["Content-Type"] = "application/json"
|
|
196
|
+
req = request.Request(url, data=data, headers=req_headers)
|
|
197
|
+
with request.urlopen(req, timeout=timeout) as resp:
|
|
198
|
+
return json.loads(resp.read().decode("utf-8") or "{}")
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
def http_bytes(url: str, timeout: int = FETCH_TIMEOUT) -> tuple[bytes, str]:
|
|
202
|
+
req = request.Request(url, headers={"User-Agent": "Mozilla/5.0 openwebui-local-agent/0.1"})
|
|
203
|
+
with request.urlopen(req, timeout=timeout) as resp:
|
|
204
|
+
content_type = resp.headers.get("Content-Type", "application/octet-stream").split(";")[0]
|
|
205
|
+
chunks = []
|
|
206
|
+
total = 0
|
|
207
|
+
while True:
|
|
208
|
+
chunk = resp.read(65536)
|
|
209
|
+
if not chunk:
|
|
210
|
+
break
|
|
211
|
+
total += len(chunk)
|
|
212
|
+
if total > MAX_FETCH_BYTES:
|
|
213
|
+
break
|
|
214
|
+
chunks.append(chunk)
|
|
215
|
+
return b"".join(chunks), content_type
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
def html_to_text(raw: bytes) -> str:
|
|
219
|
+
parser = TextExtractor()
|
|
220
|
+
parser.feed(raw.decode("utf-8", errors="replace"))
|
|
221
|
+
return clean_text(parser.text())
|
|
222
|
+
|
|
223
|
+
|
|
224
|
+
def html_to_snapshot(url: str, raw: bytes) -> dict:
|
|
225
|
+
parser = BrowserSnapshotParser()
|
|
226
|
+
parser.feed(raw.decode("utf-8", errors="replace"))
|
|
227
|
+
return parser.snapshot(url)
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
def tika_extract(raw: bytes, content_type: str) -> str:
|
|
231
|
+
req = request.Request(TIKA_URL, data=raw, method="PUT", headers={"Content-Type": content_type})
|
|
232
|
+
with request.urlopen(req, timeout=max(60, FETCH_TIMEOUT)) as resp:
|
|
233
|
+
return clean_text(resp.read().decode("utf-8", errors="replace"))
|
|
234
|
+
|
|
235
|
+
|
|
236
|
+
def fetch_url(url: str) -> str:
|
|
237
|
+
raw, content_type = http_bytes(url)
|
|
238
|
+
if content_type in {"text/html", "application/xhtml+xml"}:
|
|
239
|
+
return html_to_text(raw)
|
|
240
|
+
if content_type.startswith("text/"):
|
|
241
|
+
return clean_text(raw.decode("utf-8", errors="replace"))
|
|
242
|
+
return tika_extract(raw, content_type)
|
|
243
|
+
|
|
244
|
+
|
|
245
|
+
def browser_fixture(path: str) -> bytes | None:
|
|
246
|
+
if path == "/fixtures/browser-start.html":
|
|
247
|
+
return b"""<!doctype html>
|
|
248
|
+
<html>
|
|
249
|
+
<head><title>Local Agent Browser Fixture</title></head>
|
|
250
|
+
<body>
|
|
251
|
+
<main>
|
|
252
|
+
<h1>Browser Control Fixture</h1>
|
|
253
|
+
<p>START_MARKER browser control page.</p>
|
|
254
|
+
<a href="/fixtures/browser-done.html">Continue</a>
|
|
255
|
+
</main>
|
|
256
|
+
</body>
|
|
257
|
+
</html>
|
|
258
|
+
"""
|
|
259
|
+
if path == "/fixtures/browser-done.html":
|
|
260
|
+
return b"""<!doctype html>
|
|
261
|
+
<html>
|
|
262
|
+
<head><title>Browser Fixture Complete</title></head>
|
|
263
|
+
<body>
|
|
264
|
+
<main>
|
|
265
|
+
<h1>DONE_MARKER browser click completed</h1>
|
|
266
|
+
<p>The local agent followed the approved link.</p>
|
|
267
|
+
</main>
|
|
268
|
+
</body>
|
|
269
|
+
</html>
|
|
270
|
+
"""
|
|
271
|
+
return None
|
|
272
|
+
|
|
273
|
+
|
|
274
|
+
def send_html(handler: BaseHTTPRequestHandler, body: bytes):
|
|
275
|
+
handler.send_response(200)
|
|
276
|
+
handler.send_header("Content-Type", "text/html; charset=utf-8")
|
|
277
|
+
handler.send_header("Content-Length", str(len(body)))
|
|
278
|
+
handler.send_header("Access-Control-Allow-Origin", "*")
|
|
279
|
+
handler.end_headers()
|
|
280
|
+
handler.wfile.write(body)
|
|
281
|
+
|
|
282
|
+
|
|
283
|
+
def send_binary(handler: BaseHTTPRequestHandler, body: bytes, content_type: str):
|
|
284
|
+
handler.send_response(200)
|
|
285
|
+
handler.send_header("Content-Type", content_type)
|
|
286
|
+
handler.send_header("Content-Length", str(len(body)))
|
|
287
|
+
handler.send_header("Access-Control-Allow-Origin", "*")
|
|
288
|
+
handler.end_headers()
|
|
289
|
+
handler.wfile.write(body)
|
|
290
|
+
|
|
291
|
+
|
|
292
|
+
def send_redirect(handler: BaseHTTPRequestHandler, location: str):
|
|
293
|
+
handler.send_response(303)
|
|
294
|
+
handler.send_header("Location", location)
|
|
295
|
+
handler.send_header("Access-Control-Allow-Origin", "*")
|
|
296
|
+
handler.end_headers()
|
|
297
|
+
|
|
298
|
+
|
|
299
|
+
def approval_path(approval_id: str) -> Path:
|
|
300
|
+
if not re.fullmatch(r"[a-f0-9-]{36}", approval_id):
|
|
301
|
+
raise ValueError("invalid approval id")
|
|
302
|
+
return APPROVALS_DIR / f"{approval_id}.json"
|
|
303
|
+
|
|
304
|
+
|
|
305
|
+
def write_json_atomic(path: Path, payload: dict):
|
|
306
|
+
tmp = path.with_suffix(path.suffix + ".tmp")
|
|
307
|
+
tmp.write_text(json.dumps(payload, indent=2, ensure_ascii=False), encoding="utf-8")
|
|
308
|
+
os.replace(tmp, path)
|
|
309
|
+
|
|
310
|
+
|
|
311
|
+
def get_approval(approval_id: str) -> dict | None:
|
|
312
|
+
try:
|
|
313
|
+
path = approval_path(approval_id)
|
|
314
|
+
except ValueError:
|
|
315
|
+
return None
|
|
316
|
+
if not path.exists():
|
|
317
|
+
return None
|
|
318
|
+
try:
|
|
319
|
+
return json.loads(path.read_text(encoding="utf-8"))
|
|
320
|
+
except Exception:
|
|
321
|
+
return None
|
|
322
|
+
|
|
323
|
+
|
|
324
|
+
def list_approvals(status: str | None = None, limit: int = 50) -> list[dict]:
|
|
325
|
+
approvals: list[dict] = []
|
|
326
|
+
for path in APPROVALS_DIR.glob("*.json"):
|
|
327
|
+
try:
|
|
328
|
+
item = json.loads(path.read_text(encoding="utf-8"))
|
|
329
|
+
except Exception:
|
|
330
|
+
continue
|
|
331
|
+
if status and item.get("status") != status:
|
|
332
|
+
continue
|
|
333
|
+
approvals.append(item)
|
|
334
|
+
approvals.sort(key=lambda item: int(item.get("updated_at") or item.get("created_at") or 0), reverse=True)
|
|
335
|
+
return approvals[:limit]
|
|
336
|
+
|
|
337
|
+
|
|
338
|
+
def update_approval_status(approval_id: str, status: str) -> dict:
|
|
339
|
+
if status not in {"approved", "denied"}:
|
|
340
|
+
raise ValueError("unsupported approval status")
|
|
341
|
+
with APPROVAL_LOCK:
|
|
342
|
+
approval = get_approval(approval_id)
|
|
343
|
+
if not approval:
|
|
344
|
+
raise FileNotFoundError("approval not found")
|
|
345
|
+
if approval.get("status") == "pending":
|
|
346
|
+
approval["status"] = status
|
|
347
|
+
approval["updated_at"] = now()
|
|
348
|
+
approval["decision"] = status
|
|
349
|
+
write_json_atomic(approval_path(approval_id), approval)
|
|
350
|
+
return approval
|
|
351
|
+
|
|
352
|
+
|
|
353
|
+
def approval_public_url(approval_id: str) -> str:
|
|
354
|
+
return f"{PUBLIC_BASE_URL.rstrip('/')}/approvals/{approval_id}"
|
|
355
|
+
|
|
356
|
+
|
|
357
|
+
def create_browser_approval(url: str, overrides: dict, reason: str, action: str, click_text: str | None = None) -> dict:
|
|
358
|
+
parsed = parse.urlparse(url)
|
|
359
|
+
host = parsed.netloc.lower()
|
|
360
|
+
approval = {
|
|
361
|
+
"id": str(uuid.uuid4()),
|
|
362
|
+
"type": "browser",
|
|
363
|
+
"status": "pending",
|
|
364
|
+
"created_at": now(),
|
|
365
|
+
"updated_at": now(),
|
|
366
|
+
"url": url,
|
|
367
|
+
"host": host,
|
|
368
|
+
"approved_hosts": [host],
|
|
369
|
+
"action": action,
|
|
370
|
+
"click_text": click_text or "",
|
|
371
|
+
"reason": reason,
|
|
372
|
+
"requested_by": "local-agent-glm52",
|
|
373
|
+
}
|
|
374
|
+
with APPROVAL_LOCK:
|
|
375
|
+
write_json_atomic(approval_path(approval["id"]), approval)
|
|
376
|
+
return approval
|
|
377
|
+
|
|
378
|
+
|
|
379
|
+
def create_desktop_approval(action: str, command: list[str] | None, reason: str) -> dict:
|
|
380
|
+
approval = {
|
|
381
|
+
"id": str(uuid.uuid4()),
|
|
382
|
+
"type": "desktop",
|
|
383
|
+
"status": "pending",
|
|
384
|
+
"created_at": now(),
|
|
385
|
+
"updated_at": now(),
|
|
386
|
+
"action": action,
|
|
387
|
+
"command": command or [],
|
|
388
|
+
"reason": reason,
|
|
389
|
+
"requested_by": "local-agent-glm52",
|
|
390
|
+
}
|
|
391
|
+
with APPROVAL_LOCK:
|
|
392
|
+
write_json_atomic(approval_path(approval["id"]), approval)
|
|
393
|
+
return approval
|
|
394
|
+
|
|
395
|
+
|
|
396
|
+
def approval_allows_url(approval: dict, url: str) -> tuple[bool, str]:
|
|
397
|
+
if approval.get("status") == "denied":
|
|
398
|
+
return False, "approval_denied"
|
|
399
|
+
if approval.get("status") != "approved":
|
|
400
|
+
return False, f"approval_pending: {approval_public_url(approval.get('id', ''))}"
|
|
401
|
+
parsed = parse.urlparse(url)
|
|
402
|
+
host = parsed.netloc.lower()
|
|
403
|
+
approved_hosts = {str(host).lower() for host in approval.get("approved_hosts", [])}
|
|
404
|
+
if host not in approved_hosts and "*" not in approved_hosts:
|
|
405
|
+
return False, f"approval_required: host {host!r} was not approved"
|
|
406
|
+
return True, "approved_by_interactive_review"
|
|
407
|
+
|
|
408
|
+
|
|
409
|
+
def approval_allows_desktop(approval: dict, action: str, command: list[str] | None = None) -> tuple[bool, str]:
|
|
410
|
+
if approval.get("status") == "denied":
|
|
411
|
+
return False, "approval_denied"
|
|
412
|
+
if approval.get("status") != "approved":
|
|
413
|
+
return False, f"approval_pending: {approval_public_url(approval.get('id', ''))}"
|
|
414
|
+
if approval.get("type") not in {"desktop", None}:
|
|
415
|
+
return False, "approval_type_mismatch"
|
|
416
|
+
if str(approval.get("action") or "") != action:
|
|
417
|
+
return False, "approval_action_mismatch"
|
|
418
|
+
approved_command = approval.get("command") or []
|
|
419
|
+
if action == "command" and list(command or []) != list(approved_command):
|
|
420
|
+
return False, "approval_command_mismatch"
|
|
421
|
+
if command is not None and approved_command and list(command) != approved_command:
|
|
422
|
+
return False, "approval_command_mismatch"
|
|
423
|
+
return True, "approved_by_interactive_review"
|
|
424
|
+
|
|
425
|
+
|
|
426
|
+
def wait_for_approval(approval_id: str, timeout_seconds: int) -> dict | None:
|
|
427
|
+
deadline = time.time() + max(1, timeout_seconds)
|
|
428
|
+
while time.time() < deadline:
|
|
429
|
+
approval = get_approval(approval_id)
|
|
430
|
+
if approval and approval.get("status") in {"approved", "denied"}:
|
|
431
|
+
return approval
|
|
432
|
+
time.sleep(0.5)
|
|
433
|
+
return get_approval(approval_id)
|
|
434
|
+
|
|
435
|
+
|
|
436
|
+
def approval_page_html(approval: dict | None = None) -> bytes:
|
|
437
|
+
if approval is None:
|
|
438
|
+
rows = []
|
|
439
|
+
for item in list_approvals(limit=100):
|
|
440
|
+
status = escape(str(item.get("status", "")))
|
|
441
|
+
item_type = escape(str(item.get("type", "browser")))
|
|
442
|
+
label = escape(str(item.get("action", item_type)))
|
|
443
|
+
target = item.get("url") or " ".join(str(part) for part in item.get("command", []))
|
|
444
|
+
target = escape(str(target))
|
|
445
|
+
rows.append(
|
|
446
|
+
f"<tr><td>{status}</td><td>{item_type}</td><td>{label}</td><td><a href=\"/approvals/{item['id']}\">{escape(item['id'])}</a></td><td>{target}</td></tr>"
|
|
447
|
+
)
|
|
448
|
+
body = """
|
|
449
|
+
<h1>Local Agent Approvals</h1>
|
|
450
|
+
<table>
|
|
451
|
+
<thead><tr><th>Status</th><th>Type</th><th>Action</th><th>ID</th><th>Target</th></tr></thead>
|
|
452
|
+
<tbody>{rows}</tbody>
|
|
453
|
+
</table>
|
|
454
|
+
""".format(rows="\n".join(rows) or "<tr><td colspan=\"5\">No approvals yet.</td></tr>")
|
|
455
|
+
else:
|
|
456
|
+
approval_id = escape(str(approval.get("id", "")))
|
|
457
|
+
approval_type = str(approval.get("type", "browser"))
|
|
458
|
+
status = escape(str(approval.get("status", "")))
|
|
459
|
+
action = escape(str(approval.get("action", "")))
|
|
460
|
+
url = escape(str(approval.get("url", "")))
|
|
461
|
+
host = escape(str(approval.get("host", "")))
|
|
462
|
+
click_text = escape(str(approval.get("click_text", "")))
|
|
463
|
+
reason = escape(str(approval.get("reason", "")))
|
|
464
|
+
command = escape(" ".join(str(part) for part in approval.get("command", [])))
|
|
465
|
+
controls = ""
|
|
466
|
+
if approval.get("status") == "pending":
|
|
467
|
+
controls = f"""
|
|
468
|
+
<form method="post" action="/approvals/{approval_id}/approve"><button class="approve" type="submit">Approve</button></form>
|
|
469
|
+
<form method="post" action="/approvals/{approval_id}/deny"><button class="deny" type="submit">Deny</button></form>
|
|
470
|
+
"""
|
|
471
|
+
title = "Desktop Action Approval" if approval_type == "desktop" else "Browser Action Approval"
|
|
472
|
+
target_rows = ""
|
|
473
|
+
if approval_type == "desktop":
|
|
474
|
+
target_rows = f"""
|
|
475
|
+
<dt>Command</dt><dd><code>{command}</code></dd>
|
|
476
|
+
"""
|
|
477
|
+
else:
|
|
478
|
+
target_rows = f"""
|
|
479
|
+
<dt>URL</dt><dd><code>{url}</code></dd>
|
|
480
|
+
<dt>Host</dt><dd>{host}</dd>
|
|
481
|
+
<dt>Click text</dt><dd>{click_text}</dd>
|
|
482
|
+
"""
|
|
483
|
+
body = f"""
|
|
484
|
+
<p><a href="/approvals">All approvals</a></p>
|
|
485
|
+
<h1>{title}</h1>
|
|
486
|
+
<dl>
|
|
487
|
+
<dt>Status</dt><dd>{status}</dd>
|
|
488
|
+
<dt>Type</dt><dd>{escape(approval_type)}</dd>
|
|
489
|
+
<dt>Action</dt><dd>{action}</dd>
|
|
490
|
+
{target_rows}
|
|
491
|
+
<dt>Reason</dt><dd>{reason}</dd>
|
|
492
|
+
<dt>Resume ID</dt><dd><code>{approval_id}</code></dd>
|
|
493
|
+
</dl>
|
|
494
|
+
<div class="actions">{controls}</div>
|
|
495
|
+
"""
|
|
496
|
+
|
|
497
|
+
html = f"""<!doctype html>
|
|
498
|
+
<html>
|
|
499
|
+
<head>
|
|
500
|
+
<meta charset="utf-8">
|
|
501
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
502
|
+
<title>Local Agent Approvals</title>
|
|
503
|
+
<style>
|
|
504
|
+
body {{ font: 15px system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; margin: 32px; max-width: 1100px; color: #111827; }}
|
|
505
|
+
table {{ border-collapse: collapse; width: 100%; }}
|
|
506
|
+
th, td {{ border-bottom: 1px solid #d1d5db; padding: 10px 8px; text-align: left; vertical-align: top; }}
|
|
507
|
+
dt {{ font-weight: 700; margin-top: 16px; }}
|
|
508
|
+
dd {{ margin: 4px 0 0; }}
|
|
509
|
+
code {{ background: #f3f4f6; padding: 2px 4px; border-radius: 4px; overflow-wrap: anywhere; }}
|
|
510
|
+
.actions {{ display: flex; gap: 10px; margin-top: 24px; }}
|
|
511
|
+
button {{ border: 1px solid #9ca3af; border-radius: 6px; padding: 8px 14px; background: white; cursor: pointer; }}
|
|
512
|
+
.approve {{ background: #14532d; color: white; border-color: #14532d; }}
|
|
513
|
+
.deny {{ background: #7f1d1d; color: white; border-color: #7f1d1d; }}
|
|
514
|
+
</style>
|
|
515
|
+
</head>
|
|
516
|
+
<body>{body}</body>
|
|
517
|
+
</html>
|
|
518
|
+
"""
|
|
519
|
+
return html.encode("utf-8")
|
|
520
|
+
|
|
521
|
+
|
|
522
|
+
def screenshot_public_url(screenshot_id: str) -> str:
|
|
523
|
+
return f"{PUBLIC_BASE_URL.rstrip('/')}/screenshots/{screenshot_id}.png"
|
|
524
|
+
|
|
525
|
+
|
|
526
|
+
def save_screenshot(body: bytes) -> str:
|
|
527
|
+
screenshot_id = str(uuid.uuid4())
|
|
528
|
+
path = SCREENSHOTS_DIR / f"{screenshot_id}.png"
|
|
529
|
+
path.write_bytes(body)
|
|
530
|
+
return screenshot_id
|
|
531
|
+
|
|
532
|
+
|
|
533
|
+
def search_web(query: str, count: int) -> list[dict]:
|
|
534
|
+
params = parse.urlencode({"q": query, "format": "json", "language": "all"})
|
|
535
|
+
data = http_json(f"{SEARXNG_URL}?{params}", timeout=45)
|
|
536
|
+
results = []
|
|
537
|
+
for item in data.get("results", [])[:count]:
|
|
538
|
+
url = item.get("url")
|
|
539
|
+
if isinstance(url, str) and url.startswith(("http://", "https://")):
|
|
540
|
+
results.append(
|
|
541
|
+
{
|
|
542
|
+
"title": clean_text(item.get("title") or url)[:220],
|
|
543
|
+
"url": url,
|
|
544
|
+
"snippet": clean_text(item.get("content") or item.get("snippet") or "")[:800],
|
|
545
|
+
}
|
|
546
|
+
)
|
|
547
|
+
return results
|
|
548
|
+
|
|
549
|
+
|
|
550
|
+
def python_code_for(question: str) -> str | None:
|
|
551
|
+
lower = question.lower()
|
|
552
|
+
if "python" not in lower and not any(word in lower for word in ["calculate", "compute", "sum", "average", "mean"]):
|
|
553
|
+
return None
|
|
554
|
+
expr_match = re.search(r"([-+*/(). 0-9]{3,})", question)
|
|
555
|
+
if expr_match and re.search(r"\d", expr_match.group(1)):
|
|
556
|
+
expr = expr_match.group(1).strip()
|
|
557
|
+
if re.fullmatch(r"[-+*/(). 0-9]+", expr):
|
|
558
|
+
return f"print({expr})"
|
|
559
|
+
nums = [float(x) for x in re.findall(r"-?\d+(?:\.\d+)?", question)]
|
|
560
|
+
if nums and ("average" in lower or "mean" in lower):
|
|
561
|
+
return "values = " + repr(nums) + "\nprint(sum(values) / len(values))"
|
|
562
|
+
if nums and "sum" in lower:
|
|
563
|
+
return "values = " + repr(nums) + "\nprint(sum(values))"
|
|
564
|
+
return None
|
|
565
|
+
|
|
566
|
+
|
|
567
|
+
def run_python(code: str) -> str:
|
|
568
|
+
with tempfile.TemporaryDirectory(prefix="openwebui-local-agent-") as tmpdir:
|
|
569
|
+
result = subprocess.run(
|
|
570
|
+
["python3", "-I", "-c", code],
|
|
571
|
+
cwd=tmpdir,
|
|
572
|
+
text=True,
|
|
573
|
+
capture_output=True,
|
|
574
|
+
timeout=PYTHON_TIMEOUT,
|
|
575
|
+
)
|
|
576
|
+
output = []
|
|
577
|
+
if result.stdout:
|
|
578
|
+
output.append("stdout:\n" + result.stdout.strip())
|
|
579
|
+
if result.stderr:
|
|
580
|
+
output.append("stderr:\n" + result.stderr.strip())
|
|
581
|
+
if result.returncode != 0:
|
|
582
|
+
output.append(f"exit_code: {result.returncode}")
|
|
583
|
+
return "\n\n".join(output).strip() or "(no output)"
|
|
584
|
+
|
|
585
|
+
|
|
586
|
+
def glm_chat(messages: list[dict], max_tokens: int = 512, temperature: float = 0.2) -> str:
|
|
587
|
+
payload = {
|
|
588
|
+
"model": GLM_MODEL,
|
|
589
|
+
"messages": messages,
|
|
590
|
+
"max_tokens": max_tokens,
|
|
591
|
+
"temperature": temperature,
|
|
592
|
+
"stream": False,
|
|
593
|
+
}
|
|
594
|
+
with LLM_LOCK:
|
|
595
|
+
data = http_json(f"{GLM_BASE_URL}/chat/completions", payload=payload, timeout=GLM_TIMEOUT)
|
|
596
|
+
return data.get("choices", [{}])[0].get("message", {}).get("content", "").strip()
|
|
597
|
+
|
|
598
|
+
|
|
599
|
+
def content_text(content) -> str:
|
|
600
|
+
if isinstance(content, str):
|
|
601
|
+
return content
|
|
602
|
+
if isinstance(content, list):
|
|
603
|
+
parts = []
|
|
604
|
+
for part in content:
|
|
605
|
+
if not isinstance(part, dict):
|
|
606
|
+
continue
|
|
607
|
+
if part.get("type") in {"text", "input_text"}:
|
|
608
|
+
parts.append(str(part.get("text", "")))
|
|
609
|
+
elif "text" in part:
|
|
610
|
+
parts.append(str(part.get("text", "")))
|
|
611
|
+
return "\n".join(parts)
|
|
612
|
+
if content is None:
|
|
613
|
+
return ""
|
|
614
|
+
return json.dumps(content, ensure_ascii=False)
|
|
615
|
+
|
|
616
|
+
|
|
617
|
+
def user_intent_text(payload: dict) -> str:
|
|
618
|
+
messages = payload.get("messages") or []
|
|
619
|
+
parts = [
|
|
620
|
+
content_text(message.get("content", ""))
|
|
621
|
+
for message in messages
|
|
622
|
+
if isinstance(message, dict) and message.get("role") == "user"
|
|
623
|
+
]
|
|
624
|
+
return "\n".join(clean_text(part) for part in parts if clean_text(part))
|
|
625
|
+
|
|
626
|
+
|
|
627
|
+
def visible_content_from_reasoning(reasoning_content: str) -> str:
|
|
628
|
+
answer_labels = (
|
|
629
|
+
"Answer:",
|
|
630
|
+
"Summary:",
|
|
631
|
+
"Math:",
|
|
632
|
+
"Code:",
|
|
633
|
+
"Translate:",
|
|
634
|
+
"Translation:",
|
|
635
|
+
"Question:",
|
|
636
|
+
"Questions:",
|
|
637
|
+
"Hint:",
|
|
638
|
+
"Check:",
|
|
639
|
+
"Criteria:",
|
|
640
|
+
"Tradeoffs:",
|
|
641
|
+
"Verify:",
|
|
642
|
+
"Total:",
|
|
643
|
+
"Total Revenue:",
|
|
644
|
+
"Profit:",
|
|
645
|
+
"Total Profit:",
|
|
646
|
+
"Best:",
|
|
647
|
+
"Best Item:",
|
|
648
|
+
"Caveat:",
|
|
649
|
+
"Title:",
|
|
650
|
+
"Draft:",
|
|
651
|
+
"Edit:",
|
|
652
|
+
"Source note:",
|
|
653
|
+
"Recommendation:",
|
|
654
|
+
"Plan:",
|
|
655
|
+
"Result:",
|
|
656
|
+
"Final:",
|
|
657
|
+
)
|
|
658
|
+
preamble_labels = (
|
|
659
|
+
"Role:",
|
|
660
|
+
"Task:",
|
|
661
|
+
"Constraint",
|
|
662
|
+
"Context:",
|
|
663
|
+
"Goal:",
|
|
664
|
+
"Input:",
|
|
665
|
+
"Input text:",
|
|
666
|
+
"Input data:",
|
|
667
|
+
"Keywords to include:",
|
|
668
|
+
"Labels must be:",
|
|
669
|
+
"Reply with",
|
|
670
|
+
"No quotes",
|
|
671
|
+
"No labels",
|
|
672
|
+
"Summary must include",
|
|
673
|
+
"Content requirements",
|
|
674
|
+
"Exactly ",
|
|
675
|
+
"Output format:",
|
|
676
|
+
"Required output",
|
|
677
|
+
)
|
|
678
|
+
answer_blocks = []
|
|
679
|
+
current_answer_block = []
|
|
680
|
+
current_answer_labels = set()
|
|
681
|
+
answer_candidates = []
|
|
682
|
+
visible_lines = []
|
|
683
|
+
for raw_line in reasoning_content.splitlines():
|
|
684
|
+
line = re.sub(r"^\s*(?:[-*]|\d+[.)])\s*", "", raw_line).strip()
|
|
685
|
+
line = re.sub(r"^\*\*([^*]+?:)\*\*\s*", r"\1 ", line)
|
|
686
|
+
line = re.sub(r"^\*([^*]+?:)\*\s*", r"\1 ", line)
|
|
687
|
+
line = re.sub(r"^Line\s+\d+:\s*", "", line, flags=re.IGNORECASE)
|
|
688
|
+
line = re.sub(r"^Line\s+\d+\s*\(([^)]+)\):\s*", r"\1: ", line, flags=re.IGNORECASE)
|
|
689
|
+
if not line:
|
|
690
|
+
continue
|
|
691
|
+
matched_label = next((label for label in answer_labels if line.startswith(label)), None)
|
|
692
|
+
if matched_label:
|
|
693
|
+
if matched_label in current_answer_labels and current_answer_block:
|
|
694
|
+
answer_blocks.append(current_answer_block)
|
|
695
|
+
current_answer_block = []
|
|
696
|
+
current_answer_labels = set()
|
|
697
|
+
current_answer_block.append(line)
|
|
698
|
+
current_answer_labels.add(matched_label)
|
|
699
|
+
continue
|
|
700
|
+
if current_answer_block:
|
|
701
|
+
answer_blocks.append(current_answer_block)
|
|
702
|
+
current_answer_block = []
|
|
703
|
+
current_answer_labels = set()
|
|
704
|
+
if any(line.startswith(label) for label in preamble_labels):
|
|
705
|
+
continue
|
|
706
|
+
candidate = re.sub(
|
|
707
|
+
r"^(?:Option\s+[A-Z]|Candidate\s+\d+|Final answer)\s*:\s*",
|
|
708
|
+
"",
|
|
709
|
+
line,
|
|
710
|
+
flags=re.IGNORECASE,
|
|
711
|
+
).strip()
|
|
712
|
+
candidate = re.sub(
|
|
713
|
+
r"\s*\((?:good|better|best|simple|concise|preferred)\)\s*$",
|
|
714
|
+
"",
|
|
715
|
+
candidate,
|
|
716
|
+
flags=re.IGNORECASE,
|
|
717
|
+
).strip()
|
|
718
|
+
candidate = candidate.strip("\"'")
|
|
719
|
+
if candidate and candidate != line:
|
|
720
|
+
answer_candidates.append(candidate)
|
|
721
|
+
continue
|
|
722
|
+
quoted = re.match(r'^"([^"]{12,})"', line)
|
|
723
|
+
if quoted:
|
|
724
|
+
answer_candidates.append(quoted.group(1).strip())
|
|
725
|
+
continue
|
|
726
|
+
visible_lines.append(line)
|
|
727
|
+
if current_answer_block:
|
|
728
|
+
answer_blocks.append(current_answer_block)
|
|
729
|
+
complete_answer_blocks = [block for block in answer_blocks if len(block) >= 2]
|
|
730
|
+
if complete_answer_blocks:
|
|
731
|
+
return "\n".join(complete_answer_blocks[-1])
|
|
732
|
+
if visible_lines and answer_candidates:
|
|
733
|
+
return "\n".join([*visible_lines, *answer_candidates]).strip()
|
|
734
|
+
if visible_lines:
|
|
735
|
+
return "\n".join(visible_lines).strip()
|
|
736
|
+
if answer_candidates:
|
|
737
|
+
return max(answer_candidates, key=len).strip()
|
|
738
|
+
if any(label in reasoning_content for label in ("Role:", "Constraint", "Task:")):
|
|
739
|
+
return ""
|
|
740
|
+
return reasoning_content.strip()
|
|
741
|
+
|
|
742
|
+
|
|
743
|
+
def ensure_visible_chat_content(data: dict) -> dict:
|
|
744
|
+
choices = data.get("choices") if isinstance(data, dict) else None
|
|
745
|
+
if not isinstance(choices, list) or not choices:
|
|
746
|
+
return data
|
|
747
|
+
message = choices[0].get("message") if isinstance(choices[0], dict) else None
|
|
748
|
+
if not isinstance(message, dict) or message.get("content"):
|
|
749
|
+
return data
|
|
750
|
+
reasoning_content = message.get("reasoning_content") or message.get("reasoning")
|
|
751
|
+
if not isinstance(reasoning_content, str) or not reasoning_content.strip():
|
|
752
|
+
return data
|
|
753
|
+
visible_content = visible_content_from_reasoning(reasoning_content)
|
|
754
|
+
if visible_content:
|
|
755
|
+
message["content"] = visible_content
|
|
756
|
+
data["local_agent_content_fallback"] = "reasoning_content" if message.get("reasoning_content") else "reasoning"
|
|
757
|
+
return data
|
|
758
|
+
|
|
759
|
+
|
|
760
|
+
def auto_router_overrides(payload: dict) -> dict:
|
|
761
|
+
metadata = payload.get("metadata") if isinstance(payload.get("metadata"), dict) else {}
|
|
762
|
+
direct = payload.get("local_auto_router") if isinstance(payload.get("local_auto_router"), dict) else {}
|
|
763
|
+
nested = metadata.get("local_auto_router") if isinstance(metadata.get("local_auto_router"), dict) else {}
|
|
764
|
+
return {**nested, **direct}
|
|
765
|
+
|
|
766
|
+
|
|
767
|
+
def truthy(value) -> bool:
|
|
768
|
+
if isinstance(value, bool):
|
|
769
|
+
return value
|
|
770
|
+
return str(value).strip().lower() in {"1", "true", "yes", "on"}
|
|
771
|
+
|
|
772
|
+
|
|
773
|
+
def glm_warm_status(overrides: dict | None = None) -> dict:
|
|
774
|
+
overrides = overrides or {}
|
|
775
|
+
if truthy(overrides.get("simulate_glm_cold")):
|
|
776
|
+
return {
|
|
777
|
+
"available": True,
|
|
778
|
+
"warm": False,
|
|
779
|
+
"cached_or_decoded_tokens": 0,
|
|
780
|
+
"reason": "simulated cold GLM slot",
|
|
781
|
+
}
|
|
782
|
+
try:
|
|
783
|
+
base = GLM_BASE_URL.rstrip("/")
|
|
784
|
+
root = base[:-3] if base.endswith("/v1") else base
|
|
785
|
+
slots = http_json(f"{root}/slots", timeout=AUTO_ROUTER_GLM_HEALTH_TIMEOUT)
|
|
786
|
+
if not isinstance(slots, list) or not slots:
|
|
787
|
+
return {"available": True, "warm": False, "cached_or_decoded_tokens": 0, "reason": "no GLM slots reported"}
|
|
788
|
+
decoded_values = []
|
|
789
|
+
for slot in slots:
|
|
790
|
+
if not isinstance(slot, dict):
|
|
791
|
+
continue
|
|
792
|
+
next_tokens = slot.get("next_token") or []
|
|
793
|
+
if isinstance(next_tokens, list):
|
|
794
|
+
for item in next_tokens:
|
|
795
|
+
if isinstance(item, dict):
|
|
796
|
+
try:
|
|
797
|
+
decoded_values.append(int(item.get("n_decoded") or 0))
|
|
798
|
+
except (TypeError, ValueError):
|
|
799
|
+
pass
|
|
800
|
+
decoded = max(decoded_values or [0])
|
|
801
|
+
return {
|
|
802
|
+
"available": True,
|
|
803
|
+
"warm": decoded >= AUTO_ROUTER_GLM_WARM_MIN_DECODED,
|
|
804
|
+
"cached_or_decoded_tokens": decoded,
|
|
805
|
+
"reason": "GLM slot has decoded tokens" if decoded else "GLM slot has no decoded tokens yet",
|
|
806
|
+
}
|
|
807
|
+
except Exception as exc:
|
|
808
|
+
return {
|
|
809
|
+
"available": False,
|
|
810
|
+
"warm": False,
|
|
811
|
+
"cached_or_decoded_tokens": 0,
|
|
812
|
+
"reason": f"GLM slot probe failed: {exc}",
|
|
813
|
+
}
|
|
814
|
+
|
|
815
|
+
|
|
816
|
+
def maybe_apply_glm_cold_fallback(route: dict, overrides: dict) -> dict:
|
|
817
|
+
if route.get("route") != "glm" or route.get("forced"):
|
|
818
|
+
return route
|
|
819
|
+
if not AUTO_ROUTER_GLM_COLD_FALLBACK or truthy(overrides.get("allow_cold_glm")):
|
|
820
|
+
return route
|
|
821
|
+
status = glm_warm_status(overrides)
|
|
822
|
+
if status.get("warm"):
|
|
823
|
+
return {**route, "glm_warm_status": status}
|
|
824
|
+
return {
|
|
825
|
+
"route": "fast",
|
|
826
|
+
"target_model": AUTO_ROUTER_FAST_MODEL,
|
|
827
|
+
"target_base_url": AUTO_ROUTER_FAST_BASE_URL,
|
|
828
|
+
"reason": f"GLM cold fallback from {route.get('reason', 'glm route')}",
|
|
829
|
+
"fallback_from_route": route.get("route"),
|
|
830
|
+
"fallback_from_model": route.get("target_model"),
|
|
831
|
+
"glm_warm_status": status,
|
|
832
|
+
}
|
|
833
|
+
|
|
834
|
+
|
|
835
|
+
def select_auto_route(payload: dict, question: str, overrides: dict) -> dict:
|
|
836
|
+
force_route = str(overrides.get("force_route") or "").strip().lower()
|
|
837
|
+
if force_route in {"fast", "instant", "gemma", "gemma4"}:
|
|
838
|
+
return {
|
|
839
|
+
"route": "fast",
|
|
840
|
+
"target_model": AUTO_ROUTER_FAST_MODEL,
|
|
841
|
+
"target_base_url": AUTO_ROUTER_FAST_BASE_URL,
|
|
842
|
+
"reason": "forced fast route",
|
|
843
|
+
"forced": True,
|
|
844
|
+
}
|
|
845
|
+
if force_route in {"code", "coding", "slopcode", "qwen"}:
|
|
846
|
+
return {
|
|
847
|
+
"route": "code",
|
|
848
|
+
"target_model": AUTO_ROUTER_CODE_MODEL,
|
|
849
|
+
"target_base_url": AUTO_ROUTER_CODE_BASE_URL,
|
|
850
|
+
"reason": "forced coding route",
|
|
851
|
+
"forced": True,
|
|
852
|
+
}
|
|
853
|
+
if force_route in {"research", "deep-research", "deep_research", "sources"}:
|
|
854
|
+
return {
|
|
855
|
+
"route": "research",
|
|
856
|
+
"target_model": AUTO_ROUTER_RESEARCH_MODEL,
|
|
857
|
+
"target_base_url": AUTO_ROUTER_RESEARCH_BASE_URL,
|
|
858
|
+
"reason": "forced research route",
|
|
859
|
+
"forced": True,
|
|
860
|
+
}
|
|
861
|
+
if force_route in {"agent", "tool", "tools", "browser", "desktop"}:
|
|
862
|
+
return {
|
|
863
|
+
"route": "agent",
|
|
864
|
+
"target_model": AUTO_ROUTER_AGENT_MODEL,
|
|
865
|
+
"target_base_url": AUTO_ROUTER_AGENT_BASE_URL,
|
|
866
|
+
"reason": "forced tool-agent route",
|
|
867
|
+
"forced": True,
|
|
868
|
+
}
|
|
869
|
+
if force_route in {"glm", "deep", "reasoning", "quality"}:
|
|
870
|
+
return {
|
|
871
|
+
"route": "glm",
|
|
872
|
+
"target_model": GLM_MODEL,
|
|
873
|
+
"target_base_url": GLM_BASE_URL,
|
|
874
|
+
"reason": "forced GLM route",
|
|
875
|
+
"forced": True,
|
|
876
|
+
}
|
|
877
|
+
|
|
878
|
+
messages = payload.get("messages") or []
|
|
879
|
+
text = user_intent_text(payload)
|
|
880
|
+
lower = f"{question}\n{text}".lower()
|
|
881
|
+
has_tool_context = any(message.get("role") == "tool" for message in messages if isinstance(message, dict))
|
|
882
|
+
coding_terms = {
|
|
883
|
+
"api",
|
|
884
|
+
"bug",
|
|
885
|
+
"code",
|
|
886
|
+
"code review",
|
|
887
|
+
"coding",
|
|
888
|
+
"csv",
|
|
889
|
+
"dataframe",
|
|
890
|
+
"debug",
|
|
891
|
+
"error",
|
|
892
|
+
"function",
|
|
893
|
+
"jest",
|
|
894
|
+
"javascript",
|
|
895
|
+
"notebook",
|
|
896
|
+
"pandas",
|
|
897
|
+
"patch",
|
|
898
|
+
"python",
|
|
899
|
+
"query",
|
|
900
|
+
"refactor",
|
|
901
|
+
"regex",
|
|
902
|
+
"script",
|
|
903
|
+
"stack trace",
|
|
904
|
+
"sql",
|
|
905
|
+
"spreadsheet",
|
|
906
|
+
"test",
|
|
907
|
+
"typescript",
|
|
908
|
+
"unit test",
|
|
909
|
+
}
|
|
910
|
+
research_terms = {
|
|
911
|
+
"buyer guide",
|
|
912
|
+
"cite",
|
|
913
|
+
"cited",
|
|
914
|
+
"citation",
|
|
915
|
+
"compare products",
|
|
916
|
+
"deep research",
|
|
917
|
+
"due diligence",
|
|
918
|
+
"find sources",
|
|
919
|
+
"latest",
|
|
920
|
+
"literature",
|
|
921
|
+
"literature review",
|
|
922
|
+
"look up",
|
|
923
|
+
"market",
|
|
924
|
+
"market research",
|
|
925
|
+
"news",
|
|
926
|
+
"price",
|
|
927
|
+
"prices",
|
|
928
|
+
"product comparison",
|
|
929
|
+
"reviews",
|
|
930
|
+
"research",
|
|
931
|
+
"source-backed",
|
|
932
|
+
"sources",
|
|
933
|
+
"web search",
|
|
934
|
+
"web research",
|
|
935
|
+
}
|
|
936
|
+
no_search_terms = {
|
|
937
|
+
"do not browse",
|
|
938
|
+
"do not look up",
|
|
939
|
+
"do not search",
|
|
940
|
+
"don't browse",
|
|
941
|
+
"don't look up",
|
|
942
|
+
"don't search",
|
|
943
|
+
"from general knowledge",
|
|
944
|
+
"no browsing",
|
|
945
|
+
"no search",
|
|
946
|
+
"no sources needed",
|
|
947
|
+
"no web search",
|
|
948
|
+
"offline only",
|
|
949
|
+
"without browsing",
|
|
950
|
+
"without looking anything up",
|
|
951
|
+
"without search",
|
|
952
|
+
"without sources",
|
|
953
|
+
"without web search",
|
|
954
|
+
}
|
|
955
|
+
agent_terms = {
|
|
956
|
+
"browser",
|
|
957
|
+
"click",
|
|
958
|
+
"desktop",
|
|
959
|
+
"download",
|
|
960
|
+
"navigate",
|
|
961
|
+
"open page",
|
|
962
|
+
"playwright",
|
|
963
|
+
"run command",
|
|
964
|
+
"screenshot",
|
|
965
|
+
"take a screenshot",
|
|
966
|
+
"tool call",
|
|
967
|
+
"tool-use",
|
|
968
|
+
"upload",
|
|
969
|
+
"use a tool",
|
|
970
|
+
"use the browser",
|
|
971
|
+
"web page",
|
|
972
|
+
}
|
|
973
|
+
no_agent_terms = {
|
|
974
|
+
"do not use browser",
|
|
975
|
+
"do not use desktop",
|
|
976
|
+
"do not use the browser",
|
|
977
|
+
"do not use tools",
|
|
978
|
+
"don't use browser",
|
|
979
|
+
"don't use desktop",
|
|
980
|
+
"don't use the browser",
|
|
981
|
+
"don't use tools",
|
|
982
|
+
"no browser",
|
|
983
|
+
"no browser tools",
|
|
984
|
+
"no desktop",
|
|
985
|
+
"no tool calls",
|
|
986
|
+
"no tools",
|
|
987
|
+
"without browser",
|
|
988
|
+
"without desktop",
|
|
989
|
+
"without tool calls",
|
|
990
|
+
"without tools",
|
|
991
|
+
}
|
|
992
|
+
complex_terms = {
|
|
993
|
+
"analyze",
|
|
994
|
+
"architecture",
|
|
995
|
+
"decision",
|
|
996
|
+
"deep",
|
|
997
|
+
"design",
|
|
998
|
+
"derive",
|
|
999
|
+
"explain why",
|
|
1000
|
+
"implement",
|
|
1001
|
+
"multi-step",
|
|
1002
|
+
"multi step",
|
|
1003
|
+
"plan",
|
|
1004
|
+
"prove",
|
|
1005
|
+
"reason",
|
|
1006
|
+
"root cause",
|
|
1007
|
+
"research",
|
|
1008
|
+
"strategy",
|
|
1009
|
+
"tradeoff",
|
|
1010
|
+
"tradeoffs",
|
|
1011
|
+
"why",
|
|
1012
|
+
}
|
|
1013
|
+
short_reasoning_terms = {
|
|
1014
|
+
"analyze",
|
|
1015
|
+
"explain",
|
|
1016
|
+
"explain why",
|
|
1017
|
+
"reason",
|
|
1018
|
+
"why",
|
|
1019
|
+
}
|
|
1020
|
+
latency_sensitive_terms = {
|
|
1021
|
+
"brief",
|
|
1022
|
+
"briefly",
|
|
1023
|
+
"eli5",
|
|
1024
|
+
"in one sentence",
|
|
1025
|
+
"in simple terms",
|
|
1026
|
+
"one sentence",
|
|
1027
|
+
"plain language",
|
|
1028
|
+
"quick",
|
|
1029
|
+
"quickly",
|
|
1030
|
+
"short",
|
|
1031
|
+
"simple explanation",
|
|
1032
|
+
"two sentences",
|
|
1033
|
+
}
|
|
1034
|
+
heavyweight_reasoning_terms = {
|
|
1035
|
+
"architecture",
|
|
1036
|
+
"decision criteria",
|
|
1037
|
+
"deep",
|
|
1038
|
+
"derive",
|
|
1039
|
+
"due diligence",
|
|
1040
|
+
"multi-step",
|
|
1041
|
+
"multi step",
|
|
1042
|
+
"phased plan",
|
|
1043
|
+
"private",
|
|
1044
|
+
"prove",
|
|
1045
|
+
"root cause",
|
|
1046
|
+
"sensitive",
|
|
1047
|
+
"strategy",
|
|
1048
|
+
"tradeoff",
|
|
1049
|
+
"tradeoffs",
|
|
1050
|
+
}
|
|
1051
|
+
no_tools_requested = any(term in lower for term in no_agent_terms)
|
|
1052
|
+
no_search_requested = no_tools_requested or any(term in lower for term in no_search_terms)
|
|
1053
|
+
|
|
1054
|
+
if not no_tools_requested and any(term in lower for term in agent_terms):
|
|
1055
|
+
return {
|
|
1056
|
+
"route": "agent",
|
|
1057
|
+
"target_model": AUTO_ROUTER_AGENT_MODEL,
|
|
1058
|
+
"target_base_url": AUTO_ROUTER_AGENT_BASE_URL,
|
|
1059
|
+
"reason": "tool-agent keyword",
|
|
1060
|
+
}
|
|
1061
|
+
if not no_search_requested and any(term in lower for term in research_terms):
|
|
1062
|
+
return {
|
|
1063
|
+
"route": "research",
|
|
1064
|
+
"target_model": AUTO_ROUTER_RESEARCH_MODEL,
|
|
1065
|
+
"target_base_url": AUTO_ROUTER_RESEARCH_BASE_URL,
|
|
1066
|
+
"reason": "research keyword",
|
|
1067
|
+
}
|
|
1068
|
+
if any(term in lower for term in coding_terms):
|
|
1069
|
+
return {
|
|
1070
|
+
"route": "code",
|
|
1071
|
+
"target_model": AUTO_ROUTER_CODE_MODEL,
|
|
1072
|
+
"target_base_url": AUTO_ROUTER_CODE_BASE_URL,
|
|
1073
|
+
"reason": "coding keyword",
|
|
1074
|
+
}
|
|
1075
|
+
if (
|
|
1076
|
+
len(text) <= int(overrides.get("short_reasoning_chars") or 420)
|
|
1077
|
+
and any(term in lower for term in short_reasoning_terms)
|
|
1078
|
+
and any(term in lower for term in latency_sensitive_terms)
|
|
1079
|
+
and not any(term in lower for term in heavyweight_reasoning_terms)
|
|
1080
|
+
and not has_tool_context
|
|
1081
|
+
):
|
|
1082
|
+
return {
|
|
1083
|
+
"route": "fast",
|
|
1084
|
+
"target_model": AUTO_ROUTER_FAST_MODEL,
|
|
1085
|
+
"target_base_url": AUTO_ROUTER_FAST_BASE_URL,
|
|
1086
|
+
"reason": "latency-sensitive short reasoning",
|
|
1087
|
+
}
|
|
1088
|
+
if len(text) > int(overrides.get("long_prompt_chars") or 1200):
|
|
1089
|
+
reason = "long prompt"
|
|
1090
|
+
elif any(term in lower for term in complex_terms):
|
|
1091
|
+
reason = "reasoning keyword"
|
|
1092
|
+
elif has_tool_context:
|
|
1093
|
+
reason = "tool context"
|
|
1094
|
+
else:
|
|
1095
|
+
return {
|
|
1096
|
+
"route": "fast",
|
|
1097
|
+
"target_model": AUTO_ROUTER_FAST_MODEL,
|
|
1098
|
+
"target_base_url": AUTO_ROUTER_FAST_BASE_URL,
|
|
1099
|
+
"reason": "short everyday prompt",
|
|
1100
|
+
}
|
|
1101
|
+
|
|
1102
|
+
return {
|
|
1103
|
+
"route": "glm",
|
|
1104
|
+
"target_model": GLM_MODEL,
|
|
1105
|
+
"target_base_url": GLM_BASE_URL,
|
|
1106
|
+
"reason": reason,
|
|
1107
|
+
}
|
|
1108
|
+
|
|
1109
|
+
|
|
1110
|
+
def routed_payload(payload: dict, model: str) -> dict:
|
|
1111
|
+
allowed_keys = {
|
|
1112
|
+
"messages",
|
|
1113
|
+
"max_tokens",
|
|
1114
|
+
"temperature",
|
|
1115
|
+
"top_p",
|
|
1116
|
+
"stop",
|
|
1117
|
+
"presence_penalty",
|
|
1118
|
+
"frequency_penalty",
|
|
1119
|
+
"seed",
|
|
1120
|
+
"response_format",
|
|
1121
|
+
}
|
|
1122
|
+
forwarded = {key: payload[key] for key in allowed_keys if key in payload}
|
|
1123
|
+
forwarded["model"] = model
|
|
1124
|
+
forwarded["stream"] = False
|
|
1125
|
+
return forwarded
|
|
1126
|
+
|
|
1127
|
+
|
|
1128
|
+
def completion_response(model: str, content: str, usage: dict | None = None, extra: dict | None = None) -> dict:
|
|
1129
|
+
response = {
|
|
1130
|
+
"id": f"chatcmpl-{uuid.uuid4().hex[:12]}",
|
|
1131
|
+
"object": "chat.completion",
|
|
1132
|
+
"created": now(),
|
|
1133
|
+
"model": model,
|
|
1134
|
+
"choices": [{"index": 0, "message": {"role": "assistant", "content": content}, "finish_reason": "stop"}],
|
|
1135
|
+
"usage": usage or {"prompt_tokens": 0, "completion_tokens": 0, "total_tokens": 0},
|
|
1136
|
+
}
|
|
1137
|
+
if extra:
|
|
1138
|
+
response.update(extra)
|
|
1139
|
+
return response
|
|
1140
|
+
|
|
1141
|
+
|
|
1142
|
+
def auto_router_request_shape(payload: dict) -> dict:
|
|
1143
|
+
messages = [message for message in payload.get("messages") or [] if isinstance(message, dict)]
|
|
1144
|
+
prompt_text = "\n".join(content_text(message.get("content", "")) for message in messages)
|
|
1145
|
+
return {
|
|
1146
|
+
"message_count": len(messages),
|
|
1147
|
+
"prompt_chars": len(prompt_text),
|
|
1148
|
+
"has_tool_context": any(message.get("role") == "tool" for message in messages),
|
|
1149
|
+
"stream": bool(payload.get("stream")),
|
|
1150
|
+
}
|
|
1151
|
+
|
|
1152
|
+
|
|
1153
|
+
def record_auto_router_event(
|
|
1154
|
+
route: dict,
|
|
1155
|
+
payload: dict,
|
|
1156
|
+
overrides: dict,
|
|
1157
|
+
*,
|
|
1158
|
+
status: str,
|
|
1159
|
+
elapsed_ms: float,
|
|
1160
|
+
usage: dict | None = None,
|
|
1161
|
+
response_model: str | None = None,
|
|
1162
|
+
error: str | None = None,
|
|
1163
|
+
) -> dict:
|
|
1164
|
+
event = {
|
|
1165
|
+
"id": str(uuid.uuid4()),
|
|
1166
|
+
"created": now(),
|
|
1167
|
+
"trace_id": str(overrides.get("trace_id") or ""),
|
|
1168
|
+
"route": route.get("route"),
|
|
1169
|
+
"target_model": route.get("target_model"),
|
|
1170
|
+
"target_base_url": route.get("target_base_url"),
|
|
1171
|
+
"reason": route.get("reason"),
|
|
1172
|
+
"fallback_from_route": route.get("fallback_from_route", ""),
|
|
1173
|
+
"fallback_from_model": route.get("fallback_from_model", ""),
|
|
1174
|
+
"glm_warm_status": route.get("glm_warm_status") or {},
|
|
1175
|
+
"status": status,
|
|
1176
|
+
"elapsed_ms": round(float(elapsed_ms), 2),
|
|
1177
|
+
"response_model": response_model or "",
|
|
1178
|
+
"usage": usage or {},
|
|
1179
|
+
"echo_route": bool(overrides.get("echo_route")),
|
|
1180
|
+
**auto_router_request_shape(payload),
|
|
1181
|
+
}
|
|
1182
|
+
if error:
|
|
1183
|
+
event["error"] = str(error)[:500]
|
|
1184
|
+
with AUTO_ROUTER_TELEMETRY_LOCK:
|
|
1185
|
+
AUTO_ROUTER_EVENTS.append(event)
|
|
1186
|
+
if len(AUTO_ROUTER_EVENTS) > AUTO_ROUTER_TELEMETRY_LIMIT:
|
|
1187
|
+
del AUTO_ROUTER_EVENTS[: len(AUTO_ROUTER_EVENTS) - AUTO_ROUTER_TELEMETRY_LIMIT]
|
|
1188
|
+
return event
|
|
1189
|
+
|
|
1190
|
+
|
|
1191
|
+
def list_auto_router_events(query: dict) -> dict:
|
|
1192
|
+
try:
|
|
1193
|
+
limit = int((query.get("limit") or ["50"])[0])
|
|
1194
|
+
except Exception:
|
|
1195
|
+
limit = 50
|
|
1196
|
+
limit = max(1, min(limit, AUTO_ROUTER_TELEMETRY_LIMIT))
|
|
1197
|
+
route_filter = (query.get("route") or [""])[0]
|
|
1198
|
+
trace_filter = (query.get("trace_id") or [""])[0]
|
|
1199
|
+
with AUTO_ROUTER_TELEMETRY_LOCK:
|
|
1200
|
+
events = list(AUTO_ROUTER_EVENTS)
|
|
1201
|
+
if route_filter:
|
|
1202
|
+
events = [event for event in events if event.get("route") == route_filter]
|
|
1203
|
+
if trace_filter:
|
|
1204
|
+
events = [event for event in events if event.get("trace_id") == trace_filter]
|
|
1205
|
+
events = events[-limit:]
|
|
1206
|
+
route_counts: dict[str, int] = {}
|
|
1207
|
+
status_counts: dict[str, int] = {}
|
|
1208
|
+
for event in events:
|
|
1209
|
+
route_counts[event.get("route", "")] = route_counts.get(event.get("route", ""), 0) + 1
|
|
1210
|
+
status_counts[event.get("status", "")] = status_counts.get(event.get("status", ""), 0) + 1
|
|
1211
|
+
return {
|
|
1212
|
+
"events": events,
|
|
1213
|
+
"count": len(events),
|
|
1214
|
+
"limit": AUTO_ROUTER_TELEMETRY_LIMIT,
|
|
1215
|
+
"summary": {
|
|
1216
|
+
"routes": route_counts,
|
|
1217
|
+
"statuses": status_counts,
|
|
1218
|
+
"latest_created": max((event.get("created", 0) for event in events), default=0),
|
|
1219
|
+
},
|
|
1220
|
+
}
|
|
1221
|
+
|
|
1222
|
+
|
|
1223
|
+
def auto_router_chat(payload: dict, question: str) -> dict:
|
|
1224
|
+
overrides = auto_router_overrides(payload)
|
|
1225
|
+
route = maybe_apply_glm_cold_fallback(select_auto_route(payload, question, overrides), overrides)
|
|
1226
|
+
if overrides.get("echo_route"):
|
|
1227
|
+
answer = (
|
|
1228
|
+
f"route={route['route']}; target_model={route['target_model']}; "
|
|
1229
|
+
f"reason={route['reason']}"
|
|
1230
|
+
)
|
|
1231
|
+
event = record_auto_router_event(
|
|
1232
|
+
route,
|
|
1233
|
+
payload,
|
|
1234
|
+
overrides,
|
|
1235
|
+
status="echo",
|
|
1236
|
+
elapsed_ms=0,
|
|
1237
|
+
usage={"prompt_tokens": 0, "completion_tokens": len(answer.split()), "total_tokens": len(answer.split())},
|
|
1238
|
+
response_model=AUTO_ROUTER_MODEL_ID,
|
|
1239
|
+
)
|
|
1240
|
+
route = {**route, "telemetry_id": event["id"], "trace_id": event.get("trace_id", "")}
|
|
1241
|
+
return completion_response(
|
|
1242
|
+
AUTO_ROUTER_MODEL_ID,
|
|
1243
|
+
answer,
|
|
1244
|
+
usage={"prompt_tokens": 0, "completion_tokens": len(answer.split()), "total_tokens": len(answer.split())},
|
|
1245
|
+
extra={"local_auto_router": route},
|
|
1246
|
+
)
|
|
1247
|
+
|
|
1248
|
+
target_payload = routed_payload(payload, route["target_model"])
|
|
1249
|
+
timeout_by_route = {
|
|
1250
|
+
"agent": AUTO_ROUTER_AGENT_TIMEOUT,
|
|
1251
|
+
"code": AUTO_ROUTER_CODE_TIMEOUT,
|
|
1252
|
+
"fast": AUTO_ROUTER_FAST_TIMEOUT,
|
|
1253
|
+
"glm": GLM_TIMEOUT,
|
|
1254
|
+
"research": AUTO_ROUTER_RESEARCH_TIMEOUT,
|
|
1255
|
+
}
|
|
1256
|
+
timeout = timeout_by_route.get(route["route"], GLM_TIMEOUT)
|
|
1257
|
+
lock = LLM_LOCK if route["route"] == "glm" else None
|
|
1258
|
+
started = time.time()
|
|
1259
|
+
try:
|
|
1260
|
+
if lock:
|
|
1261
|
+
with lock:
|
|
1262
|
+
data = http_json(f"{route['target_base_url']}/chat/completions", payload=target_payload, timeout=timeout)
|
|
1263
|
+
else:
|
|
1264
|
+
data = http_json(f"{route['target_base_url']}/chat/completions", payload=target_payload, timeout=timeout)
|
|
1265
|
+
except Exception as exc:
|
|
1266
|
+
record_auto_router_event(
|
|
1267
|
+
route,
|
|
1268
|
+
payload,
|
|
1269
|
+
overrides,
|
|
1270
|
+
status="error",
|
|
1271
|
+
elapsed_ms=(time.time() - started) * 1000,
|
|
1272
|
+
error=str(exc),
|
|
1273
|
+
)
|
|
1274
|
+
raise
|
|
1275
|
+
event = record_auto_router_event(
|
|
1276
|
+
route,
|
|
1277
|
+
payload,
|
|
1278
|
+
overrides,
|
|
1279
|
+
status="ok",
|
|
1280
|
+
elapsed_ms=(time.time() - started) * 1000,
|
|
1281
|
+
usage=data.get("usage") if isinstance(data.get("usage"), dict) else None,
|
|
1282
|
+
response_model=str(data.get("model") or ""),
|
|
1283
|
+
)
|
|
1284
|
+
route = {**route, "telemetry_id": event["id"], "trace_id": event.get("trace_id", "")}
|
|
1285
|
+
data = ensure_visible_chat_content(data)
|
|
1286
|
+
data["model"] = AUTO_ROUTER_MODEL_ID
|
|
1287
|
+
data["local_auto_router"] = route
|
|
1288
|
+
return data
|
|
1289
|
+
|
|
1290
|
+
|
|
1291
|
+
def last_user_message(payload: dict) -> str:
|
|
1292
|
+
for message in reversed(payload.get("messages") or []):
|
|
1293
|
+
if message.get("role") == "user":
|
|
1294
|
+
content = message.get("content", "")
|
|
1295
|
+
if isinstance(content, str):
|
|
1296
|
+
return content
|
|
1297
|
+
if isinstance(content, list):
|
|
1298
|
+
return "\n".join(part.get("text", "") for part in content if isinstance(part, dict))
|
|
1299
|
+
return ""
|
|
1300
|
+
|
|
1301
|
+
|
|
1302
|
+
def system_messages(payload: dict) -> list[str]:
|
|
1303
|
+
messages = []
|
|
1304
|
+
for message in payload.get("messages") or []:
|
|
1305
|
+
if message.get("role") != "system":
|
|
1306
|
+
continue
|
|
1307
|
+
content = message.get("content", "")
|
|
1308
|
+
if isinstance(content, str):
|
|
1309
|
+
messages.append(content)
|
|
1310
|
+
elif isinstance(content, list):
|
|
1311
|
+
messages.append("\n".join(part.get("text", "") for part in content if isinstance(part, dict)))
|
|
1312
|
+
return [clean_text(message) for message in messages if clean_text(message)]
|
|
1313
|
+
|
|
1314
|
+
|
|
1315
|
+
def message_summaries(payload: dict) -> list[dict]:
|
|
1316
|
+
messages = []
|
|
1317
|
+
for message in payload.get("messages") or []:
|
|
1318
|
+
content = message.get("content", "")
|
|
1319
|
+
if isinstance(content, str):
|
|
1320
|
+
text = content
|
|
1321
|
+
elif isinstance(content, list):
|
|
1322
|
+
text = "\n".join(part.get("text", "") for part in content if isinstance(part, dict))
|
|
1323
|
+
else:
|
|
1324
|
+
text = json.dumps(content, ensure_ascii=False)
|
|
1325
|
+
messages.append({"role": message.get("role", ""), "content": clean_text(text)})
|
|
1326
|
+
return messages
|
|
1327
|
+
|
|
1328
|
+
|
|
1329
|
+
def should_search(question: str, overrides: dict) -> bool:
|
|
1330
|
+
if "web_search" in overrides:
|
|
1331
|
+
return bool(overrides.get("web_search"))
|
|
1332
|
+
lower = question.lower()
|
|
1333
|
+
return any(word in lower for word in ["search", "web", "latest", "current", "find sources", "look up"])
|
|
1334
|
+
|
|
1335
|
+
|
|
1336
|
+
def should_check_scheduler(question: str, overrides: dict) -> bool:
|
|
1337
|
+
if "check_scheduler" in overrides:
|
|
1338
|
+
return bool(overrides.get("check_scheduler"))
|
|
1339
|
+
return "schedule" in question.lower() or "task" in question.lower()
|
|
1340
|
+
|
|
1341
|
+
|
|
1342
|
+
def should_use_browser(question: str, overrides: dict) -> bool:
|
|
1343
|
+
if overrides.get("browser_url"):
|
|
1344
|
+
return True
|
|
1345
|
+
lower = question.lower()
|
|
1346
|
+
return "browser" in lower or "click" in lower or "open this page" in lower
|
|
1347
|
+
|
|
1348
|
+
|
|
1349
|
+
def requested_browser_url(question: str, overrides: dict) -> str | None:
|
|
1350
|
+
url = overrides.get("browser_url")
|
|
1351
|
+
if isinstance(url, str) and url.strip():
|
|
1352
|
+
return url.strip()
|
|
1353
|
+
match = re.search(r"https?://[^\s)>\"]+", question)
|
|
1354
|
+
return match.group(0).rstrip(".,") if match else None
|
|
1355
|
+
|
|
1356
|
+
|
|
1357
|
+
def allowed_hosts(overrides: dict) -> set[str]:
|
|
1358
|
+
hosts = overrides.get("allowed_hosts") or overrides.get("browser_allowed_hosts") or []
|
|
1359
|
+
if isinstance(hosts, str):
|
|
1360
|
+
hosts = [host.strip() for host in hosts.split(",")]
|
|
1361
|
+
return {str(host).strip().lower() for host in hosts if str(host).strip()}
|
|
1362
|
+
|
|
1363
|
+
|
|
1364
|
+
def browser_approval(url: str, overrides: dict, action: str = "snapshot", click_text: str | None = None) -> tuple[bool, str, str | None]:
|
|
1365
|
+
parsed = parse.urlparse(url)
|
|
1366
|
+
if parsed.scheme not in {"http", "https"} or not parsed.netloc:
|
|
1367
|
+
return False, "browser tool only supports absolute http(s) URLs", None
|
|
1368
|
+
|
|
1369
|
+
approval_id = str(overrides.get("approval_id") or overrides.get("browser_approval_id") or "").strip()
|
|
1370
|
+
if approval_id:
|
|
1371
|
+
approval = get_approval(approval_id)
|
|
1372
|
+
if not approval:
|
|
1373
|
+
return False, f"approval_not_found: {approval_id}", None
|
|
1374
|
+
approved, reason = approval_allows_url(approval, url)
|
|
1375
|
+
if approved:
|
|
1376
|
+
return True, reason, approval_id
|
|
1377
|
+
return False, reason, approval_id
|
|
1378
|
+
|
|
1379
|
+
if not overrides.get("allow_browser"):
|
|
1380
|
+
reason = "approval_required: browser action needs review"
|
|
1381
|
+
approval = create_browser_approval(url, overrides, reason, action, click_text)
|
|
1382
|
+
return False, f"{reason}: {approval_public_url(approval['id'])}", approval["id"]
|
|
1383
|
+
hosts = allowed_hosts(overrides)
|
|
1384
|
+
host = parsed.netloc.lower()
|
|
1385
|
+
if "*" not in hosts and host not in hosts:
|
|
1386
|
+
reason = f"approval_required: host {host!r} is not in local_agent.allowed_hosts"
|
|
1387
|
+
approval = create_browser_approval(url, overrides, reason, action, click_text)
|
|
1388
|
+
return False, f"{reason}: {approval_public_url(approval['id'])}", approval["id"]
|
|
1389
|
+
return True, "approved", None
|
|
1390
|
+
|
|
1391
|
+
|
|
1392
|
+
def maybe_wait_for_browser_approval(
|
|
1393
|
+
url: str,
|
|
1394
|
+
overrides: dict,
|
|
1395
|
+
reason: str,
|
|
1396
|
+
approval_id: str | None,
|
|
1397
|
+
action: str,
|
|
1398
|
+
click_text: str | None = None,
|
|
1399
|
+
progress=None,
|
|
1400
|
+
) -> tuple[bool, str, dict]:
|
|
1401
|
+
if not approval_id or not overrides.get("interactive_approval"):
|
|
1402
|
+
return False, reason, overrides
|
|
1403
|
+
|
|
1404
|
+
wait_seconds = int(overrides.get("approval_timeout_seconds") or APPROVAL_WAIT_SECONDS)
|
|
1405
|
+
approval_url = approval_public_url(approval_id)
|
|
1406
|
+
if progress:
|
|
1407
|
+
progress(f"Approval required: {approval_url}\n\n")
|
|
1408
|
+
approval = wait_for_approval(approval_id, wait_seconds)
|
|
1409
|
+
if not approval:
|
|
1410
|
+
return False, f"approval_timeout: {approval_url}", overrides
|
|
1411
|
+
approved, approval_reason = approval_allows_url(approval, url)
|
|
1412
|
+
if not approved:
|
|
1413
|
+
return False, approval_reason, overrides
|
|
1414
|
+
approved_overrides = dict(overrides)
|
|
1415
|
+
approved_overrides["approval_id"] = approval_id
|
|
1416
|
+
approved_overrides["allow_browser"] = True
|
|
1417
|
+
approved_overrides["allowed_hosts"] = sorted(set(allowed_hosts(overrides)) | set(approval.get("approved_hosts", [])))
|
|
1418
|
+
return True, "approved_by_interactive_review", approved_overrides
|
|
1419
|
+
|
|
1420
|
+
|
|
1421
|
+
def browser_snapshot(url: str) -> dict:
|
|
1422
|
+
raw, content_type = http_bytes(url)
|
|
1423
|
+
if content_type not in {"text/html", "application/xhtml+xml"}:
|
|
1424
|
+
text = clean_text(raw.decode("utf-8", errors="replace")) if content_type.startswith("text/") else f"{len(raw)} bytes of {content_type}"
|
|
1425
|
+
return {"url": url, "title": "", "text": text[:3000], "links": []}
|
|
1426
|
+
return html_to_snapshot(url, raw)
|
|
1427
|
+
|
|
1428
|
+
|
|
1429
|
+
def format_browser_snapshot(snapshot: dict) -> str:
|
|
1430
|
+
lines = [
|
|
1431
|
+
f"URL: {snapshot.get('url', '')}",
|
|
1432
|
+
f"Title: {snapshot.get('title', '')}",
|
|
1433
|
+
"",
|
|
1434
|
+
"Visible text:",
|
|
1435
|
+
snapshot.get("text", ""),
|
|
1436
|
+
"",
|
|
1437
|
+
"Links:",
|
|
1438
|
+
]
|
|
1439
|
+
for link in snapshot.get("links", []):
|
|
1440
|
+
lines.append(f"- {link.get('text', '')}: {link.get('href', '')}")
|
|
1441
|
+
return "\n".join(lines).strip()
|
|
1442
|
+
|
|
1443
|
+
|
|
1444
|
+
def wait_for_page_idle(page):
|
|
1445
|
+
for state, timeout in [("domcontentloaded", PLAYWRIGHT_TIMEOUT_MS), ("networkidle", 5000)]:
|
|
1446
|
+
try:
|
|
1447
|
+
page.wait_for_load_state(state, timeout=timeout)
|
|
1448
|
+
except Exception:
|
|
1449
|
+
pass
|
|
1450
|
+
|
|
1451
|
+
|
|
1452
|
+
def playwright_extract_links(page) -> list[dict]:
|
|
1453
|
+
try:
|
|
1454
|
+
return page.locator("a").evaluate_all(
|
|
1455
|
+
"""links => links.slice(0, 25).map(link => ({
|
|
1456
|
+
text: (link.innerText || link.textContent || link.href || '').trim().slice(0, 160),
|
|
1457
|
+
href: link.href || link.getAttribute('href') || ''
|
|
1458
|
+
}))"""
|
|
1459
|
+
)
|
|
1460
|
+
except Exception:
|
|
1461
|
+
return []
|
|
1462
|
+
|
|
1463
|
+
|
|
1464
|
+
def playwright_find_link_url(page, click_text: str) -> str | None:
|
|
1465
|
+
text = click_text.strip().lower()
|
|
1466
|
+
if not text:
|
|
1467
|
+
return None
|
|
1468
|
+
try:
|
|
1469
|
+
return page.locator("a").evaluate_all(
|
|
1470
|
+
"""(links, wanted) => {
|
|
1471
|
+
for (const link of links) {
|
|
1472
|
+
const label = (link.innerText || link.textContent || '').trim().toLowerCase();
|
|
1473
|
+
if (label.includes(wanted)) {
|
|
1474
|
+
return link.href || link.getAttribute('href') || null;
|
|
1475
|
+
}
|
|
1476
|
+
}
|
|
1477
|
+
return null;
|
|
1478
|
+
}""",
|
|
1479
|
+
text,
|
|
1480
|
+
)
|
|
1481
|
+
except Exception:
|
|
1482
|
+
return None
|
|
1483
|
+
|
|
1484
|
+
|
|
1485
|
+
def playwright_snapshot(url: str, click_text: str | None = None) -> dict:
|
|
1486
|
+
from playwright.sync_api import sync_playwright
|
|
1487
|
+
|
|
1488
|
+
with sync_playwright() as p:
|
|
1489
|
+
browser = p.chromium.connect(PLAYWRIGHT_WS_URL, timeout=PLAYWRIGHT_TIMEOUT_MS)
|
|
1490
|
+
page = browser.new_page(viewport={"width": 1280, "height": 720}, device_scale_factor=1)
|
|
1491
|
+
try:
|
|
1492
|
+
page.goto(url, wait_until="domcontentloaded", timeout=PLAYWRIGHT_TIMEOUT_MS)
|
|
1493
|
+
wait_for_page_idle(page)
|
|
1494
|
+
clicked = False
|
|
1495
|
+
if click_text:
|
|
1496
|
+
page.get_by_text(click_text, exact=False).first.click(timeout=PLAYWRIGHT_TIMEOUT_MS)
|
|
1497
|
+
clicked = True
|
|
1498
|
+
wait_for_page_idle(page)
|
|
1499
|
+
try:
|
|
1500
|
+
text = page.locator("body").inner_text(timeout=PLAYWRIGHT_TIMEOUT_MS)
|
|
1501
|
+
except Exception:
|
|
1502
|
+
text = page.content()
|
|
1503
|
+
screenshot_id = save_screenshot(page.screenshot(type="png", full_page=False, timeout=PLAYWRIGHT_TIMEOUT_MS))
|
|
1504
|
+
return {
|
|
1505
|
+
"url": page.url,
|
|
1506
|
+
"title": page.title()[:200],
|
|
1507
|
+
"text": clean_text(text)[:4000],
|
|
1508
|
+
"links": playwright_extract_links(page),
|
|
1509
|
+
"screenshot_id": screenshot_id,
|
|
1510
|
+
"screenshot_url": screenshot_public_url(screenshot_id),
|
|
1511
|
+
"clicked": clicked,
|
|
1512
|
+
}
|
|
1513
|
+
finally:
|
|
1514
|
+
page.close()
|
|
1515
|
+
browser.close()
|
|
1516
|
+
|
|
1517
|
+
|
|
1518
|
+
def format_playwright_snapshot(snapshot: dict) -> str:
|
|
1519
|
+
lines = [
|
|
1520
|
+
f"URL: {snapshot.get('url', '')}",
|
|
1521
|
+
f"Title: {snapshot.get('title', '')}",
|
|
1522
|
+
f"Screenshot: {snapshot.get('screenshot_url', '')}",
|
|
1523
|
+
"",
|
|
1524
|
+
"Rendered text:",
|
|
1525
|
+
snapshot.get("text", ""),
|
|
1526
|
+
"",
|
|
1527
|
+
"Links:",
|
|
1528
|
+
]
|
|
1529
|
+
for link in snapshot.get("links", []):
|
|
1530
|
+
lines.append(f"- {link.get('text', '')}: {link.get('href', '')}")
|
|
1531
|
+
return "\n".join(lines).strip()
|
|
1532
|
+
|
|
1533
|
+
|
|
1534
|
+
def run_playwright_browser_tool(url: str, action: str, click_text: str, overrides: dict) -> Action:
|
|
1535
|
+
if action == "snapshot":
|
|
1536
|
+
rendered = playwright_snapshot(url)
|
|
1537
|
+
return Action(kind="browser", title="Graphical Browser Snapshot", input=url, output=format_playwright_snapshot(rendered))
|
|
1538
|
+
|
|
1539
|
+
if action == "click_text":
|
|
1540
|
+
if not click_text:
|
|
1541
|
+
return Action(kind="browser", title="Graphical Browser Click", input=url, output="click_text is required", status="failed")
|
|
1542
|
+
target = None
|
|
1543
|
+
try:
|
|
1544
|
+
initial = playwright_snapshot(url)
|
|
1545
|
+
target = None
|
|
1546
|
+
# Re-open for the actual click after approval checks; this keeps target probing side-effect free.
|
|
1547
|
+
from playwright.sync_api import sync_playwright
|
|
1548
|
+
|
|
1549
|
+
with sync_playwright() as p:
|
|
1550
|
+
browser = p.chromium.connect(PLAYWRIGHT_WS_URL, timeout=PLAYWRIGHT_TIMEOUT_MS)
|
|
1551
|
+
page = browser.new_page(viewport={"width": 1280, "height": 720}, device_scale_factor=1)
|
|
1552
|
+
try:
|
|
1553
|
+
page.goto(url, wait_until="domcontentloaded", timeout=PLAYWRIGHT_TIMEOUT_MS)
|
|
1554
|
+
wait_for_page_idle(page)
|
|
1555
|
+
target = playwright_find_link_url(page, click_text)
|
|
1556
|
+
finally:
|
|
1557
|
+
page.close()
|
|
1558
|
+
browser.close()
|
|
1559
|
+
except Exception:
|
|
1560
|
+
initial = None
|
|
1561
|
+
|
|
1562
|
+
if target:
|
|
1563
|
+
approved, reason, approval_id = browser_approval(target, overrides, action, click_text)
|
|
1564
|
+
if not approved:
|
|
1565
|
+
output = reason
|
|
1566
|
+
if approval_id:
|
|
1567
|
+
output += f"\napproval_id: {approval_id}\napproval_url: {approval_public_url(approval_id)}"
|
|
1568
|
+
return Action(kind="browser", title="Graphical Browser Click Approval Required", input=target, output=output, status="approval_required")
|
|
1569
|
+
|
|
1570
|
+
rendered = playwright_snapshot(url, click_text=click_text)
|
|
1571
|
+
output = (
|
|
1572
|
+
f"Clicked text: {click_text}\n"
|
|
1573
|
+
f"Start URL: {url}\n"
|
|
1574
|
+
f"Final URL: {rendered.get('url', '')}\n"
|
|
1575
|
+
f"Screenshot: {rendered.get('screenshot_url', '')}\n\n"
|
|
1576
|
+
f"Final rendered snapshot:\n{format_playwright_snapshot(rendered)}"
|
|
1577
|
+
)
|
|
1578
|
+
if initial:
|
|
1579
|
+
output += f"\n\nInitial screenshot: {initial.get('screenshot_url', '')}"
|
|
1580
|
+
return Action(kind="browser", title="Graphical Browser Click", input=f"{url}\nclick_text={click_text}", output=output)
|
|
1581
|
+
|
|
1582
|
+
return Action(kind="browser", title="Graphical Browser Tool", input=url, output=f"Unsupported browser_action: {action}", status="failed")
|
|
1583
|
+
|
|
1584
|
+
|
|
1585
|
+
def run_browser_tool(question: str, overrides: dict, progress=None) -> Action:
|
|
1586
|
+
url = requested_browser_url(question, overrides)
|
|
1587
|
+
if not url:
|
|
1588
|
+
return Action(kind="browser", title="Browser Tool", input="", output="No browser_url or URL found.", status="failed")
|
|
1589
|
+
|
|
1590
|
+
action = str(overrides.get("browser_action") or "snapshot")
|
|
1591
|
+
click_text = str(overrides.get("click_text") or "").strip()
|
|
1592
|
+
approved, reason, approval_id = browser_approval(url, overrides, action, click_text)
|
|
1593
|
+
if not approved:
|
|
1594
|
+
approved, reason, overrides = maybe_wait_for_browser_approval(
|
|
1595
|
+
url, overrides, reason, approval_id, action, click_text, progress=progress
|
|
1596
|
+
)
|
|
1597
|
+
if not approved:
|
|
1598
|
+
output = reason
|
|
1599
|
+
if approval_id:
|
|
1600
|
+
output += f"\napproval_id: {approval_id}\napproval_url: {approval_public_url(approval_id)}"
|
|
1601
|
+
return Action(kind="browser", title="Browser Approval Required", input=url, output=output, status="approval_required")
|
|
1602
|
+
|
|
1603
|
+
if str(overrides.get("browser_backend") or "").lower() == "playwright":
|
|
1604
|
+
try:
|
|
1605
|
+
return run_playwright_browser_tool(url, action, click_text, overrides)
|
|
1606
|
+
except Exception as exc:
|
|
1607
|
+
return Action(kind="browser", title="Graphical Browser Tool", input=url, output=str(exc), status="failed")
|
|
1608
|
+
|
|
1609
|
+
snapshot = browser_snapshot(url)
|
|
1610
|
+
if action == "snapshot":
|
|
1611
|
+
return Action(kind="browser", title="Browser Snapshot", input=url, output=format_browser_snapshot(snapshot))
|
|
1612
|
+
|
|
1613
|
+
if action == "click_text":
|
|
1614
|
+
normalized_click_text = click_text.lower()
|
|
1615
|
+
if not normalized_click_text:
|
|
1616
|
+
return Action(kind="browser", title="Browser Click", input=url, output="click_text is required", status="failed")
|
|
1617
|
+
target = None
|
|
1618
|
+
for link in snapshot.get("links", []):
|
|
1619
|
+
if normalized_click_text in str(link.get("text", "")).lower():
|
|
1620
|
+
target = parse.urljoin(url, link.get("href", ""))
|
|
1621
|
+
break
|
|
1622
|
+
if not target:
|
|
1623
|
+
return Action(
|
|
1624
|
+
kind="browser",
|
|
1625
|
+
title="Browser Click",
|
|
1626
|
+
input=f"{url}\nclick_text={overrides.get('click_text')}",
|
|
1627
|
+
output="No matching link found.\n\n" + format_browser_snapshot(snapshot),
|
|
1628
|
+
status="failed",
|
|
1629
|
+
)
|
|
1630
|
+
approved, reason, target_approval_id = browser_approval(target, overrides, action, click_text)
|
|
1631
|
+
if not approved:
|
|
1632
|
+
approved, reason, overrides = maybe_wait_for_browser_approval(
|
|
1633
|
+
target, overrides, reason, target_approval_id, action, click_text, progress=progress
|
|
1634
|
+
)
|
|
1635
|
+
if not approved:
|
|
1636
|
+
output = reason
|
|
1637
|
+
if target_approval_id:
|
|
1638
|
+
output += f"\napproval_id: {target_approval_id}\napproval_url: {approval_public_url(target_approval_id)}"
|
|
1639
|
+
return Action(kind="browser", title="Browser Click Approval Required", input=target, output=output, status="approval_required")
|
|
1640
|
+
clicked = browser_snapshot(target)
|
|
1641
|
+
output = (
|
|
1642
|
+
f"Clicked link text: {overrides.get('click_text')}\n"
|
|
1643
|
+
f"Start URL: {url}\n"
|
|
1644
|
+
f"Final URL: {target}\n\n"
|
|
1645
|
+
f"Final snapshot:\n{format_browser_snapshot(clicked)}"
|
|
1646
|
+
)
|
|
1647
|
+
return Action(kind="browser", title="Browser Click", input=f"{url}\nclick_text={overrides.get('click_text')}", output=output)
|
|
1648
|
+
|
|
1649
|
+
return Action(kind="browser", title="Browser Tool", input=url, output=f"Unsupported browser_action: {action}", status="failed")
|
|
1650
|
+
|
|
1651
|
+
|
|
1652
|
+
def command_exists(name: str) -> bool:
|
|
1653
|
+
return shutil.which(name) is not None
|
|
1654
|
+
|
|
1655
|
+
|
|
1656
|
+
def display_probe(display: str, wayland_display: str, xdg_runtime_dir: str) -> tuple[bool, str]:
|
|
1657
|
+
if display and command_exists("xdpyinfo"):
|
|
1658
|
+
try:
|
|
1659
|
+
result = subprocess.run(
|
|
1660
|
+
["xdpyinfo"],
|
|
1661
|
+
text=True,
|
|
1662
|
+
capture_output=True,
|
|
1663
|
+
timeout=3,
|
|
1664
|
+
env=os.environ.copy(),
|
|
1665
|
+
)
|
|
1666
|
+
if result.returncode == 0:
|
|
1667
|
+
first_line = (result.stdout or "").splitlines()[0] if result.stdout else "xdpyinfo ok"
|
|
1668
|
+
return True, first_line[:200]
|
|
1669
|
+
return False, (result.stderr or result.stdout or f"xdpyinfo exit {result.returncode}").strip()[:300]
|
|
1670
|
+
except Exception as exc:
|
|
1671
|
+
return False, str(exc)[:300]
|
|
1672
|
+
|
|
1673
|
+
if wayland_display and xdg_runtime_dir:
|
|
1674
|
+
wayland_socket = Path(xdg_runtime_dir) / wayland_display
|
|
1675
|
+
if wayland_socket.exists():
|
|
1676
|
+
return True, f"Wayland socket exists: {wayland_socket}"
|
|
1677
|
+
return False, f"Wayland socket not found: {wayland_socket}"
|
|
1678
|
+
|
|
1679
|
+
return False, "DISPLAY and WAYLAND_DISPLAY are unset"
|
|
1680
|
+
|
|
1681
|
+
|
|
1682
|
+
def desktop_capabilities() -> dict:
|
|
1683
|
+
display = os.environ.get("DISPLAY", "")
|
|
1684
|
+
wayland_display = os.environ.get("WAYLAND_DISPLAY", "")
|
|
1685
|
+
xdg_runtime_dir = os.environ.get("XDG_RUNTIME_DIR", "")
|
|
1686
|
+
xauthority = os.environ.get("XAUTHORITY", "")
|
|
1687
|
+
tools = {
|
|
1688
|
+
"xdg-open": command_exists("xdg-open"),
|
|
1689
|
+
"xdotool": command_exists("xdotool"),
|
|
1690
|
+
"wmctrl": command_exists("wmctrl"),
|
|
1691
|
+
"xprop": command_exists("xprop"),
|
|
1692
|
+
"xdpyinfo": command_exists("xdpyinfo"),
|
|
1693
|
+
"xev": command_exists("xev"),
|
|
1694
|
+
"xterm": command_exists("xterm"),
|
|
1695
|
+
"gnome-screenshot": command_exists("gnome-screenshot"),
|
|
1696
|
+
"grim": command_exists("grim"),
|
|
1697
|
+
"scrot": command_exists("scrot"),
|
|
1698
|
+
"import": command_exists("import"),
|
|
1699
|
+
}
|
|
1700
|
+
gui_available, probe = display_probe(display, wayland_display, xdg_runtime_dir)
|
|
1701
|
+
return {
|
|
1702
|
+
"enabled": DESKTOP_ENABLED,
|
|
1703
|
+
"gui_available": gui_available,
|
|
1704
|
+
"display_probe": probe,
|
|
1705
|
+
"display": display,
|
|
1706
|
+
"wayland_display": wayland_display,
|
|
1707
|
+
"xdg_runtime_dir": xdg_runtime_dir,
|
|
1708
|
+
"xauthority": xauthority,
|
|
1709
|
+
"tools": tools,
|
|
1710
|
+
}
|
|
1711
|
+
|
|
1712
|
+
|
|
1713
|
+
def format_desktop_status(capabilities: dict) -> str:
|
|
1714
|
+
tool_lines = ", ".join(f"{name}={available}" for name, available in capabilities.get("tools", {}).items())
|
|
1715
|
+
lines = [
|
|
1716
|
+
"Desktop tool status:",
|
|
1717
|
+
f"enabled={capabilities.get('enabled')}",
|
|
1718
|
+
f"gui_available={capabilities.get('gui_available')}",
|
|
1719
|
+
f"DISPLAY={capabilities.get('display') or '(unset)'}",
|
|
1720
|
+
f"WAYLAND_DISPLAY={capabilities.get('wayland_display') or '(unset)'}",
|
|
1721
|
+
f"XDG_RUNTIME_DIR={capabilities.get('xdg_runtime_dir') or '(unset)'}",
|
|
1722
|
+
f"XAUTHORITY={capabilities.get('xauthority') or '(unset)'}",
|
|
1723
|
+
f"display_probe={capabilities.get('display_probe') or '(none)'}",
|
|
1724
|
+
f"tools: {tool_lines}",
|
|
1725
|
+
]
|
|
1726
|
+
if not capabilities.get("gui_available"):
|
|
1727
|
+
lines.append("GUI display is not available to the local-agent container; command/status actions still work.")
|
|
1728
|
+
return "\n".join(lines)
|
|
1729
|
+
|
|
1730
|
+
|
|
1731
|
+
def should_use_desktop(question: str, overrides: dict) -> bool:
|
|
1732
|
+
if overrides.get("desktop_action") or overrides.get("desktop_command"):
|
|
1733
|
+
return True
|
|
1734
|
+
lower = question.lower()
|
|
1735
|
+
return any(phrase in lower for phrase in ["desktop", "local app", "os control", "screen control", "take a screenshot"])
|
|
1736
|
+
|
|
1737
|
+
|
|
1738
|
+
def desktop_command_from_overrides(overrides: dict) -> list[str] | None:
|
|
1739
|
+
command = overrides.get("desktop_command")
|
|
1740
|
+
if command is None:
|
|
1741
|
+
return None
|
|
1742
|
+
if isinstance(command, list):
|
|
1743
|
+
parts = [str(part) for part in command if str(part)]
|
|
1744
|
+
elif isinstance(command, str):
|
|
1745
|
+
parts = [part for part in command.strip().split() if part]
|
|
1746
|
+
else:
|
|
1747
|
+
return None
|
|
1748
|
+
return parts or None
|
|
1749
|
+
|
|
1750
|
+
|
|
1751
|
+
def allowed_desktop_actions(overrides: dict) -> set[str]:
|
|
1752
|
+
actions = overrides.get("allowed_desktop_actions") or []
|
|
1753
|
+
if isinstance(actions, str):
|
|
1754
|
+
actions = [part.strip() for part in actions.split(",")]
|
|
1755
|
+
return {str(action).strip().lower() for action in actions if str(action).strip()}
|
|
1756
|
+
|
|
1757
|
+
|
|
1758
|
+
def desktop_approval(action: str, command: list[str] | None, overrides: dict) -> tuple[bool, str, str | None]:
|
|
1759
|
+
approval_id = str(overrides.get("desktop_approval_id") or overrides.get("approval_id") or "").strip()
|
|
1760
|
+
if approval_id:
|
|
1761
|
+
approval = get_approval(approval_id)
|
|
1762
|
+
if not approval:
|
|
1763
|
+
return False, f"approval_not_found: {approval_id}", None
|
|
1764
|
+
approved, reason = approval_allows_desktop(approval, action, command)
|
|
1765
|
+
if approved:
|
|
1766
|
+
return True, reason, approval_id
|
|
1767
|
+
return False, reason, approval_id
|
|
1768
|
+
|
|
1769
|
+
read_only = action in {"status", "list_windows"}
|
|
1770
|
+
if read_only:
|
|
1771
|
+
return True, "read_only", None
|
|
1772
|
+
|
|
1773
|
+
if overrides.get("allow_desktop"):
|
|
1774
|
+
allowed = allowed_desktop_actions(overrides)
|
|
1775
|
+
if "*" in allowed or action.lower() in allowed:
|
|
1776
|
+
return True, "approved", None
|
|
1777
|
+
|
|
1778
|
+
reason = "approval_required: desktop action needs review"
|
|
1779
|
+
approval = create_desktop_approval(action, command, reason)
|
|
1780
|
+
return False, f"{reason}: {approval_public_url(approval['id'])}", approval["id"]
|
|
1781
|
+
|
|
1782
|
+
|
|
1783
|
+
def maybe_wait_for_desktop_approval(
|
|
1784
|
+
action: str,
|
|
1785
|
+
command: list[str] | None,
|
|
1786
|
+
overrides: dict,
|
|
1787
|
+
reason: str,
|
|
1788
|
+
approval_id: str | None,
|
|
1789
|
+
progress=None,
|
|
1790
|
+
) -> tuple[bool, str, dict]:
|
|
1791
|
+
if not approval_id or not overrides.get("interactive_approval"):
|
|
1792
|
+
return False, reason, overrides
|
|
1793
|
+
|
|
1794
|
+
wait_seconds = int(overrides.get("approval_timeout_seconds") or APPROVAL_WAIT_SECONDS)
|
|
1795
|
+
approval_url = approval_public_url(approval_id)
|
|
1796
|
+
if progress:
|
|
1797
|
+
progress(f"Desktop approval required: {approval_url}\n\n")
|
|
1798
|
+
approval = wait_for_approval(approval_id, wait_seconds)
|
|
1799
|
+
if not approval:
|
|
1800
|
+
return False, f"approval_timeout: {approval_url}", overrides
|
|
1801
|
+
approved, approval_reason = approval_allows_desktop(approval, action, command)
|
|
1802
|
+
if not approved:
|
|
1803
|
+
return False, approval_reason, overrides
|
|
1804
|
+
approved_overrides = dict(overrides)
|
|
1805
|
+
approved_overrides["desktop_approval_id"] = approval_id
|
|
1806
|
+
approved_overrides["allow_desktop"] = True
|
|
1807
|
+
approved_overrides["allowed_desktop_actions"] = sorted(set(allowed_desktop_actions(overrides)) | {action})
|
|
1808
|
+
return True, "approved_by_interactive_review", approved_overrides
|
|
1809
|
+
|
|
1810
|
+
|
|
1811
|
+
def run_desktop_subprocess(command: list[str], timeout: int = DESKTOP_TIMEOUT) -> subprocess.CompletedProcess:
|
|
1812
|
+
return subprocess.run(
|
|
1813
|
+
command,
|
|
1814
|
+
text=True,
|
|
1815
|
+
capture_output=True,
|
|
1816
|
+
timeout=timeout,
|
|
1817
|
+
cwd=str(DESKTOP_DIR),
|
|
1818
|
+
env=os.environ.copy(),
|
|
1819
|
+
)
|
|
1820
|
+
|
|
1821
|
+
|
|
1822
|
+
def run_desktop_command(command: list[str]) -> str:
|
|
1823
|
+
result = run_desktop_subprocess(command)
|
|
1824
|
+
output = []
|
|
1825
|
+
if result.stdout:
|
|
1826
|
+
output.append("stdout:\n" + result.stdout.strip()[:DESKTOP_COMMAND_MAX_OUTPUT])
|
|
1827
|
+
if result.stderr:
|
|
1828
|
+
output.append("stderr:\n" + result.stderr.strip()[:DESKTOP_COMMAND_MAX_OUTPUT])
|
|
1829
|
+
if result.returncode != 0:
|
|
1830
|
+
output.append(f"exit_code: {result.returncode}")
|
|
1831
|
+
return "\n\n".join(output).strip() or "(no output)"
|
|
1832
|
+
|
|
1833
|
+
|
|
1834
|
+
def list_desktop_windows() -> str:
|
|
1835
|
+
if not desktop_capabilities().get("gui_available"):
|
|
1836
|
+
return format_desktop_status(desktop_capabilities())
|
|
1837
|
+
for command in (["wmctrl", "-l"], ["xprop", "-root", "_NET_CLIENT_LIST_STACKING"]):
|
|
1838
|
+
if command_exists(command[0]):
|
|
1839
|
+
try:
|
|
1840
|
+
return run_desktop_command(command)
|
|
1841
|
+
except Exception as exc:
|
|
1842
|
+
return str(exc)
|
|
1843
|
+
return "No supported window-listing tool is installed."
|
|
1844
|
+
|
|
1845
|
+
|
|
1846
|
+
def desktop_screenshot() -> str:
|
|
1847
|
+
capabilities = desktop_capabilities()
|
|
1848
|
+
if not capabilities.get("gui_available"):
|
|
1849
|
+
return format_desktop_status(capabilities)
|
|
1850
|
+
|
|
1851
|
+
screenshot_id = str(uuid.uuid4())
|
|
1852
|
+
path = SCREENSHOTS_DIR / f"{screenshot_id}.png"
|
|
1853
|
+
candidates = []
|
|
1854
|
+
if command_exists("gnome-screenshot"):
|
|
1855
|
+
candidates.append(["gnome-screenshot", "-f", str(path)])
|
|
1856
|
+
if command_exists("grim"):
|
|
1857
|
+
candidates.append(["grim", str(path)])
|
|
1858
|
+
if command_exists("scrot"):
|
|
1859
|
+
candidates.append(["scrot", str(path)])
|
|
1860
|
+
if command_exists("import"):
|
|
1861
|
+
candidates.append(["import", "-window", "root", str(path)])
|
|
1862
|
+
|
|
1863
|
+
errors = []
|
|
1864
|
+
for command in candidates:
|
|
1865
|
+
try:
|
|
1866
|
+
result = run_desktop_subprocess(command)
|
|
1867
|
+
if result.returncode == 0 and path.exists() and path.stat().st_size > 8:
|
|
1868
|
+
return f"Desktop screenshot: {screenshot_public_url(screenshot_id)}"
|
|
1869
|
+
errors.append(f"{' '.join(command)}: exit={result.returncode}; stderr={result.stderr.strip()[:500]}")
|
|
1870
|
+
except Exception as exc:
|
|
1871
|
+
errors.append(f"{' '.join(command)}: {exc}")
|
|
1872
|
+
return "Desktop screenshot failed.\n" + ("\n".join(errors) if errors else "No supported screenshot tool is installed.")
|
|
1873
|
+
|
|
1874
|
+
|
|
1875
|
+
def run_desktop_tool(question: str, overrides: dict, progress=None) -> Action:
|
|
1876
|
+
if not DESKTOP_ENABLED:
|
|
1877
|
+
return Action(kind="desktop", title="Desktop Tool", input="", output="Desktop tool is disabled.", status="failed")
|
|
1878
|
+
|
|
1879
|
+
action = str(overrides.get("desktop_action") or "status").strip().lower()
|
|
1880
|
+
command = desktop_command_from_overrides(overrides)
|
|
1881
|
+
if action == "command" and not command:
|
|
1882
|
+
return Action(kind="desktop", title="Desktop Command", input="", output="desktop_command is required.", status="failed")
|
|
1883
|
+
|
|
1884
|
+
approved, reason, approval_id = desktop_approval(action, command, overrides)
|
|
1885
|
+
if not approved:
|
|
1886
|
+
approved, reason, overrides = maybe_wait_for_desktop_approval(action, command, overrides, reason, approval_id, progress=progress)
|
|
1887
|
+
if not approved:
|
|
1888
|
+
output = reason
|
|
1889
|
+
if approval_id:
|
|
1890
|
+
output += f"\napproval_id: {approval_id}\napproval_url: {approval_public_url(approval_id)}"
|
|
1891
|
+
return Action(kind="desktop", title="Desktop Approval Required", input=" ".join(command or [action]), output=output, status="approval_required")
|
|
1892
|
+
|
|
1893
|
+
try:
|
|
1894
|
+
if action == "status":
|
|
1895
|
+
return Action(kind="desktop", title="Desktop Status", input=question, output=format_desktop_status(desktop_capabilities()))
|
|
1896
|
+
if action == "list_windows":
|
|
1897
|
+
return Action(kind="desktop", title="Desktop Windows", input=question, output=list_desktop_windows())
|
|
1898
|
+
if action == "screenshot":
|
|
1899
|
+
return Action(kind="desktop", title="Desktop Screenshot", input=question, output=desktop_screenshot())
|
|
1900
|
+
if action == "command":
|
|
1901
|
+
return Action(kind="desktop", title="Desktop Command", input=" ".join(command or []), output=run_desktop_command(command or []))
|
|
1902
|
+
return Action(kind="desktop", title="Desktop Tool", input=action, output=f"Unsupported desktop_action: {action}", status="failed")
|
|
1903
|
+
except Exception as exc:
|
|
1904
|
+
return Action(kind="desktop", title="Desktop Tool", input=" ".join(command or [action]), output=str(exc), status="failed")
|
|
1905
|
+
|
|
1906
|
+
|
|
1907
|
+
def source_pack(run_dir: Path, run_id: str, question: str, actions: list[Action], answer: str):
|
|
1908
|
+
run_dir.mkdir(parents=True, exist_ok=True)
|
|
1909
|
+
payload = {
|
|
1910
|
+
"run_id": run_id,
|
|
1911
|
+
"question": question,
|
|
1912
|
+
"created_at": now(),
|
|
1913
|
+
"actions": [asdict(action) for action in actions],
|
|
1914
|
+
"answer": answer,
|
|
1915
|
+
}
|
|
1916
|
+
(run_dir / "agent-run.json").write_text(json.dumps(payload, indent=2, ensure_ascii=False), encoding="utf-8")
|
|
1917
|
+
lines = [f"# Agent Run: {run_id}", "", f"Question: {question}", "", "## Actions", ""]
|
|
1918
|
+
for index, action in enumerate(actions, start=1):
|
|
1919
|
+
lines.extend(
|
|
1920
|
+
[
|
|
1921
|
+
f"### A{index}. {action.title}",
|
|
1922
|
+
f"- Kind: {action.kind}",
|
|
1923
|
+
f"- Status: {action.status}",
|
|
1924
|
+
"",
|
|
1925
|
+
"Input:",
|
|
1926
|
+
"",
|
|
1927
|
+
f"```text\n{action.input}\n```",
|
|
1928
|
+
"",
|
|
1929
|
+
"Output:",
|
|
1930
|
+
"",
|
|
1931
|
+
f"```text\n{action.output}\n```",
|
|
1932
|
+
"",
|
|
1933
|
+
]
|
|
1934
|
+
)
|
|
1935
|
+
lines.extend(["## Final Answer", "", answer])
|
|
1936
|
+
(run_dir / "agent-run.md").write_text("\n".join(lines), encoding="utf-8")
|
|
1937
|
+
|
|
1938
|
+
|
|
1939
|
+
def synthesize(question: str, actions: list[Action], overrides: dict) -> str:
|
|
1940
|
+
if not actions:
|
|
1941
|
+
return "I did not need to call tools for this request."
|
|
1942
|
+
if overrides.get("synthesize") is False:
|
|
1943
|
+
lines = ["Agent actions completed:"]
|
|
1944
|
+
for index, action in enumerate(actions, start=1):
|
|
1945
|
+
lines.append(f"- A{index} {action.kind}: {action.output[:500]}")
|
|
1946
|
+
return "\n".join(lines)
|
|
1947
|
+
|
|
1948
|
+
evidence = []
|
|
1949
|
+
for index, action in enumerate(actions, start=1):
|
|
1950
|
+
evidence.append(f"A{index} {action.kind} ({action.title})\nInput: {action.input}\nOutput: {action.output[:1800]}")
|
|
1951
|
+
prompt = (
|
|
1952
|
+
"You are a local agent. Answer the user using only the tool results below. "
|
|
1953
|
+
"Call out any tool failures or uncertainty. Keep the answer concise.\n\n"
|
|
1954
|
+
f"User request:\n{question}\n\nTool results:\n" + "\n\n".join(evidence)
|
|
1955
|
+
)
|
|
1956
|
+
try:
|
|
1957
|
+
return glm_chat(
|
|
1958
|
+
[
|
|
1959
|
+
{"role": "system", "content": "You are a careful local tool-using agent."},
|
|
1960
|
+
{"role": "user", "content": prompt},
|
|
1961
|
+
],
|
|
1962
|
+
max_tokens=int(overrides.get("max_tokens", 512)),
|
|
1963
|
+
temperature=0.2,
|
|
1964
|
+
)
|
|
1965
|
+
except Exception as exc:
|
|
1966
|
+
fallback = "\n".join(f"- {action.kind}: {action.output[:500]}" for action in actions)
|
|
1967
|
+
return f"Agent tools completed, but GLM synthesis failed: {exc}\n\n{fallback}"
|
|
1968
|
+
|
|
1969
|
+
|
|
1970
|
+
def run_agent(question: str, overrides: dict | None = None, progress=None) -> str:
|
|
1971
|
+
overrides = overrides or {}
|
|
1972
|
+
run_id = str(uuid.uuid4())
|
|
1973
|
+
run_dir = STORAGE / "runs" / run_id
|
|
1974
|
+
actions: list[Action] = []
|
|
1975
|
+
|
|
1976
|
+
def say(message: str):
|
|
1977
|
+
if progress:
|
|
1978
|
+
progress(message)
|
|
1979
|
+
|
|
1980
|
+
say(f"Agent run `{run_id}` started.\n\n")
|
|
1981
|
+
|
|
1982
|
+
code = overrides.get("python_code") or python_code_for(question)
|
|
1983
|
+
if code:
|
|
1984
|
+
say("Running local Python tool.\n\n")
|
|
1985
|
+
try:
|
|
1986
|
+
output = run_python(str(code))
|
|
1987
|
+
actions.append(Action(kind="python", title="Run Python", input=str(code), output=output))
|
|
1988
|
+
except Exception as exc:
|
|
1989
|
+
actions.append(Action(kind="python", title="Run Python", input=str(code), output=str(exc), status="failed"))
|
|
1990
|
+
|
|
1991
|
+
if should_search(question, overrides):
|
|
1992
|
+
query = str(overrides.get("query") or question)
|
|
1993
|
+
count = int(overrides.get("search_results", 5))
|
|
1994
|
+
say(f"Searching web for `{query}`.\n\n")
|
|
1995
|
+
try:
|
|
1996
|
+
results = search_web(query, count)
|
|
1997
|
+
output = "\n".join(f"- {item['title']}: {item['url']}\n {item['snippet']}" for item in results) or "No results"
|
|
1998
|
+
actions.append(Action(kind="web_search", title="Search Web", input=query, output=output))
|
|
1999
|
+
if overrides.get("fetch_first") and results:
|
|
2000
|
+
first = results[0]["url"]
|
|
2001
|
+
say(f"Reading first search result: {first}\n\n")
|
|
2002
|
+
text = fetch_url(first)[:3000]
|
|
2003
|
+
actions.append(Action(kind="fetch_url", title="Fetch URL", input=first, output=text or "No text extracted"))
|
|
2004
|
+
except Exception as exc:
|
|
2005
|
+
actions.append(Action(kind="web_search", title="Search Web", input=query, output=str(exc), status="failed"))
|
|
2006
|
+
|
|
2007
|
+
if should_check_scheduler(question, overrides):
|
|
2008
|
+
say("Checking local scheduler state.\n\n")
|
|
2009
|
+
try:
|
|
2010
|
+
state = http_json(f"{SCHEDULER_URL}/health", timeout=20)
|
|
2011
|
+
actions.append(Action(kind="scheduler", title="Check Scheduler", input=SCHEDULER_URL, output=json.dumps(state)))
|
|
2012
|
+
except Exception as exc:
|
|
2013
|
+
actions.append(Action(kind="scheduler", title="Check Scheduler", input=SCHEDULER_URL, output=str(exc), status="failed"))
|
|
2014
|
+
|
|
2015
|
+
if should_use_browser(question, overrides):
|
|
2016
|
+
say("Using local browser-control tool.\n\n")
|
|
2017
|
+
try:
|
|
2018
|
+
actions.append(run_browser_tool(question, overrides, progress=say))
|
|
2019
|
+
except Exception as exc:
|
|
2020
|
+
actions.append(Action(kind="browser", title="Browser Tool", input=str(overrides.get("browser_url", "")), output=str(exc), status="failed"))
|
|
2021
|
+
|
|
2022
|
+
if should_use_desktop(question, overrides):
|
|
2023
|
+
say("Using local desktop-control tool.\n\n")
|
|
2024
|
+
try:
|
|
2025
|
+
actions.append(run_desktop_tool(question, overrides, progress=say))
|
|
2026
|
+
except Exception as exc:
|
|
2027
|
+
actions.append(Action(kind="desktop", title="Desktop Tool", input=str(overrides.get("desktop_action", "")), output=str(exc), status="failed"))
|
|
2028
|
+
|
|
2029
|
+
answer = synthesize(question, actions, overrides)
|
|
2030
|
+
source_pack(run_dir, run_id, question, actions, answer)
|
|
2031
|
+
md_url = f"{PUBLIC_BASE_URL}/runs/{run_id}/agent-run.md"
|
|
2032
|
+
json_url = f"{PUBLIC_BASE_URL}/runs/{run_id}/agent-run.json"
|
|
2033
|
+
return answer + f"\n\nAgent run: [Markdown]({md_url}) | [JSON]({json_url})"
|
|
2034
|
+
|
|
2035
|
+
|
|
2036
|
+
def chunk_payload(content: str, finish_reason=None, model: str = MODEL_ID) -> dict:
|
|
2037
|
+
return {
|
|
2038
|
+
"id": f"chatcmpl-{uuid.uuid4().hex[:12]}",
|
|
2039
|
+
"object": "chat.completion.chunk",
|
|
2040
|
+
"created": now(),
|
|
2041
|
+
"model": model,
|
|
2042
|
+
"choices": [{"index": 0, "delta": {"content": content} if content else {}, "finish_reason": finish_reason}],
|
|
2043
|
+
}
|
|
2044
|
+
|
|
2045
|
+
|
|
2046
|
+
def model_cards() -> list[dict]:
|
|
2047
|
+
local_agent_card = {
|
|
2048
|
+
"id": MODEL_ID,
|
|
2049
|
+
"name": MODEL_ID,
|
|
2050
|
+
"object": "model",
|
|
2051
|
+
"created": now(),
|
|
2052
|
+
"owned_by": "local",
|
|
2053
|
+
"connection_type": "local",
|
|
2054
|
+
"info": {
|
|
2055
|
+
"id": MODEL_ID,
|
|
2056
|
+
"name": "Local Agent - GLM 5.2",
|
|
2057
|
+
"meta": {
|
|
2058
|
+
"description": "Local GLM-backed agent with Python, search, scheduler, browser, Playwright, and approval-gated desktop/system tools.",
|
|
2059
|
+
"capabilities": {
|
|
2060
|
+
"web_search": True,
|
|
2061
|
+
"code_interpreter": True,
|
|
2062
|
+
"browser": True,
|
|
2063
|
+
"desktop_control": DESKTOP_ENABLED,
|
|
2064
|
+
"requires_approval": True,
|
|
2065
|
+
},
|
|
2066
|
+
},
|
|
2067
|
+
},
|
|
2068
|
+
}
|
|
2069
|
+
auto_router_card = {
|
|
2070
|
+
"id": AUTO_ROUTER_MODEL_ID,
|
|
2071
|
+
"name": AUTO_ROUTER_MODEL_ID,
|
|
2072
|
+
"object": "model",
|
|
2073
|
+
"created": now(),
|
|
2074
|
+
"owned_by": "local",
|
|
2075
|
+
"connection_type": "local",
|
|
2076
|
+
"info": {
|
|
2077
|
+
"id": AUTO_ROUTER_MODEL_ID,
|
|
2078
|
+
"name": "Local Auto Router",
|
|
2079
|
+
"meta": {
|
|
2080
|
+
"description": "Local additive auto-router for fast chat, coding, research, tool-agent, and GLM 5.2 reasoning routes.",
|
|
2081
|
+
"capabilities": {
|
|
2082
|
+
"auto_routing": True,
|
|
2083
|
+
"fast_model": AUTO_ROUTER_FAST_MODEL,
|
|
2084
|
+
"coding_model": AUTO_ROUTER_CODE_MODEL,
|
|
2085
|
+
"research_model": AUTO_ROUTER_RESEARCH_MODEL,
|
|
2086
|
+
"agent_model": AUTO_ROUTER_AGENT_MODEL,
|
|
2087
|
+
"reasoning_model": GLM_MODEL,
|
|
2088
|
+
"telemetry_endpoint": "/api/auto-router/events",
|
|
2089
|
+
"glm_cold_fallback": AUTO_ROUTER_GLM_COLD_FALLBACK,
|
|
2090
|
+
"glm_warm_min_decoded_tokens": AUTO_ROUTER_GLM_WARM_MIN_DECODED,
|
|
2091
|
+
"local_only": True,
|
|
2092
|
+
},
|
|
2093
|
+
},
|
|
2094
|
+
},
|
|
2095
|
+
}
|
|
2096
|
+
return [local_agent_card, auto_router_card]
|
|
2097
|
+
|
|
2098
|
+
|
|
2099
|
+
class Handler(BaseHTTPRequestHandler):
|
|
2100
|
+
server_version = "openwebui-local-agent/0.2"
|
|
2101
|
+
|
|
2102
|
+
def log_message(self, fmt, *args):
|
|
2103
|
+
print("%s - - [%s] %s" % (self.client_address[0], self.log_date_time_string(), fmt % args), flush=True)
|
|
2104
|
+
|
|
2105
|
+
def do_OPTIONS(self):
|
|
2106
|
+
self.send_response(204)
|
|
2107
|
+
self.send_header("Access-Control-Allow-Origin", "*")
|
|
2108
|
+
self.send_header("Access-Control-Allow-Headers", "Authorization, Content-Type")
|
|
2109
|
+
self.send_header("Access-Control-Allow-Methods", "GET, POST, DELETE, OPTIONS")
|
|
2110
|
+
self.end_headers()
|
|
2111
|
+
|
|
2112
|
+
def do_GET(self):
|
|
2113
|
+
parsed_path = parse.urlparse(self.path)
|
|
2114
|
+
path = parsed_path.path
|
|
2115
|
+
query = parse.parse_qs(parsed_path.query)
|
|
2116
|
+
if path == "/health":
|
|
2117
|
+
return send_json(
|
|
2118
|
+
self,
|
|
2119
|
+
200,
|
|
2120
|
+
{
|
|
2121
|
+
"status": "ok",
|
|
2122
|
+
"model": MODEL_ID,
|
|
2123
|
+
"desktop": desktop_capabilities(),
|
|
2124
|
+
},
|
|
2125
|
+
)
|
|
2126
|
+
if path == "/approvals":
|
|
2127
|
+
return send_html(self, approval_page_html())
|
|
2128
|
+
if path == "/api/approvals":
|
|
2129
|
+
status = (query.get("status") or [None])[0]
|
|
2130
|
+
return send_json(self, 200, {"approvals": list_approvals(status=status)})
|
|
2131
|
+
if path == "/api/auto-router/events":
|
|
2132
|
+
return send_json(self, 200, list_auto_router_events(query))
|
|
2133
|
+
screenshot_match = re.match(r"^/screenshots/([a-f0-9-]{36})\.png$", path)
|
|
2134
|
+
if screenshot_match:
|
|
2135
|
+
screenshot_path = SCREENSHOTS_DIR / f"{screenshot_match.group(1)}.png"
|
|
2136
|
+
if not screenshot_path.exists():
|
|
2137
|
+
return send_json(self, 404, {"error": "not found"})
|
|
2138
|
+
return send_binary(self, screenshot_path.read_bytes(), "image/png")
|
|
2139
|
+
approval_match = re.match(r"^/approvals/([a-f0-9-]{36})$", path)
|
|
2140
|
+
if approval_match:
|
|
2141
|
+
approval = get_approval(approval_match.group(1))
|
|
2142
|
+
if not approval:
|
|
2143
|
+
return send_json(self, 404, {"error": "not found"})
|
|
2144
|
+
return send_html(self, approval_page_html(approval))
|
|
2145
|
+
approval_api_match = re.match(r"^/api/approvals/([a-f0-9-]{36})$", path)
|
|
2146
|
+
if approval_api_match:
|
|
2147
|
+
approval = get_approval(approval_api_match.group(1))
|
|
2148
|
+
if not approval:
|
|
2149
|
+
return send_json(self, 404, {"error": "not found"})
|
|
2150
|
+
return send_json(self, 200, {"approval": approval})
|
|
2151
|
+
fixture = browser_fixture(path)
|
|
2152
|
+
if fixture is not None:
|
|
2153
|
+
return send_html(self, fixture)
|
|
2154
|
+
if path in {"/v1/models", "/models"}:
|
|
2155
|
+
cards = model_cards()
|
|
2156
|
+
return send_json(
|
|
2157
|
+
self,
|
|
2158
|
+
200,
|
|
2159
|
+
{
|
|
2160
|
+
"object": "list",
|
|
2161
|
+
"data": cards,
|
|
2162
|
+
"models": [
|
|
2163
|
+
{"name": card["id"], "model": card["id"], "type": "model", "info": card["info"]}
|
|
2164
|
+
for card in cards
|
|
2165
|
+
],
|
|
2166
|
+
},
|
|
2167
|
+
)
|
|
2168
|
+
match = re.match(r"^/runs/([^/]+)/(agent-run\.(?:md|json))$", path)
|
|
2169
|
+
if match:
|
|
2170
|
+
run_id, filename = match.groups()
|
|
2171
|
+
file_path = STORAGE / "runs" / run_id / filename
|
|
2172
|
+
if not file_path.exists():
|
|
2173
|
+
return send_json(self, 404, {"error": "not found"})
|
|
2174
|
+
body = file_path.read_bytes()
|
|
2175
|
+
content_type = "application/json" if filename.endswith(".json") else "text/markdown; charset=utf-8"
|
|
2176
|
+
self.send_response(200)
|
|
2177
|
+
self.send_header("Content-Type", content_type)
|
|
2178
|
+
self.send_header("Content-Length", str(len(body)))
|
|
2179
|
+
self.send_header("Access-Control-Allow-Origin", "*")
|
|
2180
|
+
self.end_headers()
|
|
2181
|
+
self.wfile.write(body)
|
|
2182
|
+
return
|
|
2183
|
+
return send_json(self, 404, {"error": "not found"})
|
|
2184
|
+
|
|
2185
|
+
def do_POST(self):
|
|
2186
|
+
path = parse.urlparse(self.path).path
|
|
2187
|
+
approval_action_match = re.match(r"^/(api/)?approvals/([a-f0-9-]{36})/(approve|deny)$", path)
|
|
2188
|
+
if approval_action_match:
|
|
2189
|
+
is_api = bool(approval_action_match.group(1))
|
|
2190
|
+
approval_id = approval_action_match.group(2)
|
|
2191
|
+
decision = "approved" if approval_action_match.group(3) == "approve" else "denied"
|
|
2192
|
+
try:
|
|
2193
|
+
approval = update_approval_status(approval_id, decision)
|
|
2194
|
+
except FileNotFoundError:
|
|
2195
|
+
return send_json(self, 404, {"error": "not found"})
|
|
2196
|
+
except Exception as exc:
|
|
2197
|
+
return send_json(self, 400, {"error": str(exc)})
|
|
2198
|
+
if is_api:
|
|
2199
|
+
return send_json(self, 200, {"approval": approval})
|
|
2200
|
+
return send_redirect(self, f"/approvals/{approval_id}")
|
|
2201
|
+
|
|
2202
|
+
if path not in {"/v1/chat/completions", "/chat/completions"}:
|
|
2203
|
+
return send_json(self, 404, {"error": "not found"})
|
|
2204
|
+
try:
|
|
2205
|
+
payload = read_json(self)
|
|
2206
|
+
question = last_user_message(payload).strip()
|
|
2207
|
+
if not question:
|
|
2208
|
+
return send_json(self, 400, {"error": {"message": "No user message found"}})
|
|
2209
|
+
requested_model = payload.get("model") or MODEL_ID
|
|
2210
|
+
if requested_model == AUTO_ROUTER_MODEL_ID:
|
|
2211
|
+
data = auto_router_chat(payload, question)
|
|
2212
|
+
if payload.get("stream"):
|
|
2213
|
+
self.send_response(200)
|
|
2214
|
+
self.send_header("Content-Type", "text/event-stream; charset=utf-8")
|
|
2215
|
+
self.send_header("Cache-Control", "no-cache")
|
|
2216
|
+
self.send_header("Connection", "keep-alive")
|
|
2217
|
+
self.send_header("Access-Control-Allow-Origin", "*")
|
|
2218
|
+
self.end_headers()
|
|
2219
|
+
content = data.get("choices", [{}])[0].get("message", {}).get("content", "")
|
|
2220
|
+
event = "data: " + json.dumps(chunk_payload(content, model=AUTO_ROUTER_MODEL_ID), ensure_ascii=False) + "\n\n"
|
|
2221
|
+
self.wfile.write(event.encode("utf-8"))
|
|
2222
|
+
done = (
|
|
2223
|
+
"data: "
|
|
2224
|
+
+ json.dumps(chunk_payload("", "stop", model=AUTO_ROUTER_MODEL_ID), ensure_ascii=False)
|
|
2225
|
+
+ "\n\n"
|
|
2226
|
+
+ "data: [DONE]\n\n"
|
|
2227
|
+
)
|
|
2228
|
+
self.wfile.write(done.encode("utf-8"))
|
|
2229
|
+
self.wfile.flush()
|
|
2230
|
+
return
|
|
2231
|
+
return send_json(self, 200, data)
|
|
2232
|
+
overrides = payload.get("local_agent") or payload.get("metadata", {}).get("local_agent") or {}
|
|
2233
|
+
if overrides.get("echo_system_messages"):
|
|
2234
|
+
answer = "Received system messages:\n" + json.dumps(system_messages(payload), ensure_ascii=False)
|
|
2235
|
+
return send_json(
|
|
2236
|
+
self,
|
|
2237
|
+
200,
|
|
2238
|
+
{
|
|
2239
|
+
"id": f"chatcmpl-{uuid.uuid4().hex[:12]}",
|
|
2240
|
+
"object": "chat.completion",
|
|
2241
|
+
"created": now(),
|
|
2242
|
+
"model": MODEL_ID,
|
|
2243
|
+
"choices": [
|
|
2244
|
+
{"index": 0, "message": {"role": "assistant", "content": answer}, "finish_reason": "stop"}
|
|
2245
|
+
],
|
|
2246
|
+
"usage": {"prompt_tokens": 0, "completion_tokens": 0, "total_tokens": 0},
|
|
2247
|
+
},
|
|
2248
|
+
)
|
|
2249
|
+
if overrides.get("echo_messages"):
|
|
2250
|
+
answer = "Received messages:\n" + json.dumps(message_summaries(payload), ensure_ascii=False)
|
|
2251
|
+
return send_json(
|
|
2252
|
+
self,
|
|
2253
|
+
200,
|
|
2254
|
+
{
|
|
2255
|
+
"id": f"chatcmpl-{uuid.uuid4().hex[:12]}",
|
|
2256
|
+
"object": "chat.completion",
|
|
2257
|
+
"created": now(),
|
|
2258
|
+
"model": MODEL_ID,
|
|
2259
|
+
"choices": [
|
|
2260
|
+
{"index": 0, "message": {"role": "assistant", "content": answer}, "finish_reason": "stop"}
|
|
2261
|
+
],
|
|
2262
|
+
"usage": {"prompt_tokens": 0, "completion_tokens": 0, "total_tokens": 0},
|
|
2263
|
+
},
|
|
2264
|
+
)
|
|
2265
|
+
if payload.get("stream"):
|
|
2266
|
+
self.send_response(200)
|
|
2267
|
+
self.send_header("Content-Type", "text/event-stream; charset=utf-8")
|
|
2268
|
+
self.send_header("Cache-Control", "no-cache")
|
|
2269
|
+
self.send_header("Connection", "keep-alive")
|
|
2270
|
+
self.send_header("Access-Control-Allow-Origin", "*")
|
|
2271
|
+
self.end_headers()
|
|
2272
|
+
|
|
2273
|
+
def emit(text: str):
|
|
2274
|
+
event = "data: " + json.dumps(chunk_payload(text), ensure_ascii=False) + "\n\n"
|
|
2275
|
+
self.wfile.write(event.encode("utf-8"))
|
|
2276
|
+
self.wfile.flush()
|
|
2277
|
+
|
|
2278
|
+
answer = run_agent(question, overrides=overrides, progress=emit)
|
|
2279
|
+
emit(answer)
|
|
2280
|
+
done = "data: " + json.dumps(chunk_payload("", "stop")) + "\n\n" + "data: [DONE]\n\n"
|
|
2281
|
+
self.wfile.write(done.encode("utf-8"))
|
|
2282
|
+
self.wfile.flush()
|
|
2283
|
+
return
|
|
2284
|
+
|
|
2285
|
+
answer = run_agent(question, overrides=overrides)
|
|
2286
|
+
return send_json(
|
|
2287
|
+
self,
|
|
2288
|
+
200,
|
|
2289
|
+
{
|
|
2290
|
+
"id": f"chatcmpl-{uuid.uuid4().hex[:12]}",
|
|
2291
|
+
"object": "chat.completion",
|
|
2292
|
+
"created": now(),
|
|
2293
|
+
"model": MODEL_ID,
|
|
2294
|
+
"choices": [{"index": 0, "message": {"role": "assistant", "content": answer}, "finish_reason": "stop"}],
|
|
2295
|
+
"usage": {"prompt_tokens": 0, "completion_tokens": 0, "total_tokens": 0},
|
|
2296
|
+
},
|
|
2297
|
+
)
|
|
2298
|
+
except BrokenPipeError:
|
|
2299
|
+
return
|
|
2300
|
+
except Exception as exc:
|
|
2301
|
+
return send_json(self, 500, {"error": {"message": str(exc), "type": "server_error"}})
|
|
2302
|
+
|
|
2303
|
+
|
|
2304
|
+
def main():
|
|
2305
|
+
server = ThreadingHTTPServer((HOST, PORT), Handler)
|
|
2306
|
+
print(f"local agent listening on http://{HOST}:{PORT}", flush=True)
|
|
2307
|
+
server.serve_forever()
|
|
2308
|
+
|
|
2309
|
+
|
|
2310
|
+
if __name__ == "__main__":
|
|
2311
|
+
main()
|