dispatchbox 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- dispatchbox/__init__.py +3 -0
- dispatchbox/__main__.py +127 -0
- dispatchbox/chat.py +209 -0
- dispatchbox/cron.py +56 -0
- dispatchbox/feishu.py +304 -0
- dispatchbox/hub.py +550 -0
- dispatchbox/net.py +33 -0
- dispatchbox/rules.py +236 -0
- dispatchbox/server.py +480 -0
- dispatchbox/slack.py +251 -0
- dispatchbox/store.py +99 -0
- dispatchbox/worker.py +314 -0
- dispatchbox-0.1.0.dist-info/METADATA +293 -0
- dispatchbox-0.1.0.dist-info/RECORD +18 -0
- dispatchbox-0.1.0.dist-info/WHEEL +5 -0
- dispatchbox-0.1.0.dist-info/entry_points.txt +2 -0
- dispatchbox-0.1.0.dist-info/licenses/LICENSE +21 -0
- dispatchbox-0.1.0.dist-info/top_level.txt +1 -0
dispatchbox/hub.py
ADDED
|
@@ -0,0 +1,550 @@
|
|
|
1
|
+
"""Hub logic: ingest events, route them to runs, hand runs to workers, collect results.
|
|
2
|
+
|
|
3
|
+
This module has no HTTP code so it can be tested directly; see server.py for the HTTP layer.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import fnmatch
|
|
9
|
+
import json
|
|
10
|
+
import logging
|
|
11
|
+
import os
|
|
12
|
+
import threading
|
|
13
|
+
import time
|
|
14
|
+
import urllib.request
|
|
15
|
+
from datetime import datetime
|
|
16
|
+
from pathlib import Path
|
|
17
|
+
from typing import Any, Callable
|
|
18
|
+
|
|
19
|
+
from . import store
|
|
20
|
+
from .net import ssl_context
|
|
21
|
+
from .rules import Config, load_config_file, normalize_event, render
|
|
22
|
+
|
|
23
|
+
log = logging.getLogger("dispatchbox.hub")
|
|
24
|
+
|
|
25
|
+
MAX_PAYLOAD_IN_PROMPT = 4000
|
|
26
|
+
DECISION_MARK = "DISPATCHBOX_DECISION:"
|
|
27
|
+
SUMMARY_MARK = "DISPATCHBOX_SUMMARY:"
|
|
28
|
+
STATUS_MARK = "DISPATCHBOX_STATUS:"
|
|
29
|
+
TERMINAL = {"succeeded", "failed", "lost", "decided"}
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class HubError(Exception):
|
|
33
|
+
def __init__(self, status: int, message: str):
|
|
34
|
+
super().__init__(message)
|
|
35
|
+
self.status = status
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class Hub:
|
|
39
|
+
def __init__(
|
|
40
|
+
self,
|
|
41
|
+
rules_path: str | Path,
|
|
42
|
+
db_path: str = "dispatchbox.db",
|
|
43
|
+
lost_after: int = 120,
|
|
44
|
+
public_url: str = "",
|
|
45
|
+
clock: Callable[[], float] = time.time,
|
|
46
|
+
):
|
|
47
|
+
self.rules_path = Path(rules_path)
|
|
48
|
+
self.lost_after = lost_after
|
|
49
|
+
self.public_url = public_url.rstrip("/")
|
|
50
|
+
self.clock = clock
|
|
51
|
+
self.lock = threading.RLock()
|
|
52
|
+
self.cv = threading.Condition(self.lock)
|
|
53
|
+
self.db = store.connect(db_path)
|
|
54
|
+
self._rules_mtime = 0.0
|
|
55
|
+
self.config: Config = load_config_file(self.rules_path)
|
|
56
|
+
self._rules_mtime = self.rules_path.stat().st_mtime
|
|
57
|
+
self._last_cron_minute: str | None = None
|
|
58
|
+
self.notifier: Callable[[dict[str, Any], str], None] = self._default_notify
|
|
59
|
+
self.adapters: dict[str, Any] = {} # name -> ChatAdapter, see chat.py
|
|
60
|
+
self.background: Callable[..., None] = _background
|
|
61
|
+
|
|
62
|
+
def close(self) -> None:
|
|
63
|
+
with self.lock:
|
|
64
|
+
self.db.close()
|
|
65
|
+
|
|
66
|
+
# ------------------------------------------------------------------ config
|
|
67
|
+
|
|
68
|
+
def reload_if_changed(self) -> None:
|
|
69
|
+
try:
|
|
70
|
+
mtime = self.rules_path.stat().st_mtime
|
|
71
|
+
except FileNotFoundError:
|
|
72
|
+
return
|
|
73
|
+
if mtime == self._rules_mtime:
|
|
74
|
+
return
|
|
75
|
+
try:
|
|
76
|
+
cfg = load_config_file(self.rules_path)
|
|
77
|
+
except Exception as e: # keep the last good config
|
|
78
|
+
log.error("rules file invalid, keeping previous version: %s", e)
|
|
79
|
+
self._rules_mtime = mtime
|
|
80
|
+
return
|
|
81
|
+
with self.lock:
|
|
82
|
+
self.config = cfg
|
|
83
|
+
self._rules_mtime = mtime
|
|
84
|
+
log.info("rules reloaded: %d rules, %d schedules", len(cfg.rules), len(cfg.schedules))
|
|
85
|
+
|
|
86
|
+
# ------------------------------------------------------------------ ingest
|
|
87
|
+
|
|
88
|
+
def ingest(self, raw: dict[str, Any], parent_run: int | None = None) -> dict[str, Any]:
|
|
89
|
+
try:
|
|
90
|
+
event = normalize_event(raw)
|
|
91
|
+
except ValueError as e:
|
|
92
|
+
raise HubError(400, str(e)) from e
|
|
93
|
+
self.reload_if_changed()
|
|
94
|
+
now = self.clock()
|
|
95
|
+
with self.lock:
|
|
96
|
+
cfg = self.config
|
|
97
|
+
dup = self.db.execute(
|
|
98
|
+
"SELECT id FROM events WHERE dedupe_key=? AND received_at>=? AND status!='duplicate' ORDER BY id DESC LIMIT 1",
|
|
99
|
+
(event["dedupe_key"], now - max(cfg.dedupe_window, _num(raw.get("dedupe_window"), int) or 0)),
|
|
100
|
+
).fetchone()
|
|
101
|
+
if dup:
|
|
102
|
+
eid = self._insert_event(event, now, "duplicate", f"duplicate of event {dup['id']}")
|
|
103
|
+
return {"event_id": eid, "status": "duplicate", "duplicate_of": dup["id"], "runs": []}
|
|
104
|
+
|
|
105
|
+
rules = cfg.match(event)
|
|
106
|
+
if not rules:
|
|
107
|
+
eid = self._insert_event(event, now, "unmatched")
|
|
108
|
+
return {"event_id": eid, "status": "unmatched", "runs": []}
|
|
109
|
+
|
|
110
|
+
eid = self._insert_event(event, now, "routed")
|
|
111
|
+
ctx = {**event, "rule": ""}
|
|
112
|
+
run_ids: list[int] = []
|
|
113
|
+
coalesced: list[int] = []
|
|
114
|
+
for rule in rules:
|
|
115
|
+
ctx["rule"] = rule.name
|
|
116
|
+
group_key = render(rule.group, ctx)
|
|
117
|
+
existing = None
|
|
118
|
+
if rule.coalesce:
|
|
119
|
+
existing = self.db.execute(
|
|
120
|
+
"SELECT id FROM runs WHERE rule=? AND group_key=? AND status='queued' ORDER BY id LIMIT 1",
|
|
121
|
+
(rule.name, group_key),
|
|
122
|
+
).fetchone()
|
|
123
|
+
if existing:
|
|
124
|
+
run_id = existing["id"]
|
|
125
|
+
coalesced.append(run_id)
|
|
126
|
+
else:
|
|
127
|
+
spec = rule.to_spec()
|
|
128
|
+
spec["memory"] = cfg.memory
|
|
129
|
+
spec["require_decision_for"] = cfg.require_decision_for
|
|
130
|
+
cur = self.db.execute(
|
|
131
|
+
"INSERT INTO runs (rule, worker_pattern, group_key, concurrency, spec, status, created_at, parent_run) "
|
|
132
|
+
"VALUES (?,?,?,?,?, 'queued', ?, ?)",
|
|
133
|
+
(rule.name, rule.worker, group_key, rule.concurrency, json.dumps(spec, ensure_ascii=False), now,
|
|
134
|
+
parent_run),
|
|
135
|
+
)
|
|
136
|
+
run_id = cur.lastrowid
|
|
137
|
+
run_ids.append(run_id)
|
|
138
|
+
self.db.execute("INSERT OR IGNORE INTO run_events (run_id, event_id) VALUES (?,?)", (run_id, eid))
|
|
139
|
+
if coalesced and not run_ids:
|
|
140
|
+
self.db.execute("UPDATE events SET status='coalesced', note=? WHERE id=?",
|
|
141
|
+
(f"joined queued run(s) {coalesced}", eid))
|
|
142
|
+
self.cv.notify_all()
|
|
143
|
+
return {
|
|
144
|
+
"event_id": eid,
|
|
145
|
+
"status": "coalesced" if coalesced and not run_ids else "routed",
|
|
146
|
+
"runs": run_ids,
|
|
147
|
+
"coalesced_into": coalesced,
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
def _insert_event(self, e: dict[str, Any], now: float, status: str, note: str = "") -> int:
|
|
151
|
+
cur = self.db.execute(
|
|
152
|
+
"INSERT INTO events (received_at, source, type, subject, summary, link, payload, dedupe_key, status, note) "
|
|
153
|
+
"VALUES (?,?,?,?,?,?,?,?,?,?)",
|
|
154
|
+
(now, e["source"], e["type"], e["subject"], e["summary"], e["link"],
|
|
155
|
+
json.dumps(e["payload"], ensure_ascii=False), e["dedupe_key"], status, note),
|
|
156
|
+
)
|
|
157
|
+
return cur.lastrowid
|
|
158
|
+
|
|
159
|
+
# ------------------------------------------------------------------ claim
|
|
160
|
+
|
|
161
|
+
def claim(self, worker: str, wait: float = 0.0, runners: list[str] | None = None) -> dict[str, Any] | None:
|
|
162
|
+
if not worker:
|
|
163
|
+
raise HubError(400, "worker name is required")
|
|
164
|
+
deadline = time.monotonic() + max(0.0, wait)
|
|
165
|
+
with self.cv:
|
|
166
|
+
while True:
|
|
167
|
+
task = self._try_claim(worker, runners)
|
|
168
|
+
if task is not None:
|
|
169
|
+
return task
|
|
170
|
+
remaining = deadline - time.monotonic()
|
|
171
|
+
if remaining <= 0:
|
|
172
|
+
return None
|
|
173
|
+
self.cv.wait(timeout=min(remaining, 5.0))
|
|
174
|
+
|
|
175
|
+
def _try_claim(self, worker: str, runners: list[str] | None) -> dict[str, Any] | None:
|
|
176
|
+
running = self.db.execute("SELECT rule, group_key FROM runs WHERE status='running'").fetchall()
|
|
177
|
+
busy_groups = {r["group_key"] for r in running}
|
|
178
|
+
per_rule: dict[str, int] = {}
|
|
179
|
+
for r in running:
|
|
180
|
+
per_rule[r["rule"]] = per_rule.get(r["rule"], 0) + 1
|
|
181
|
+
for row in self.db.execute("SELECT * FROM runs WHERE status='queued' ORDER BY id").fetchall():
|
|
182
|
+
if not fnmatch.fnmatchcase(worker, row["worker_pattern"]):
|
|
183
|
+
continue
|
|
184
|
+
if row["group_key"] in busy_groups:
|
|
185
|
+
continue
|
|
186
|
+
if per_rule.get(row["rule"], 0) >= row["concurrency"]:
|
|
187
|
+
continue
|
|
188
|
+
spec = json.loads(row["spec"])
|
|
189
|
+
if runners is not None and spec["runner"] not in runners:
|
|
190
|
+
continue
|
|
191
|
+
now = self.clock()
|
|
192
|
+
events = self._events_for_run(row["id"])
|
|
193
|
+
prompt = build_prompt(row["id"], spec, events, row["parent_run"],
|
|
194
|
+
attempt=row["attempt"], retry_of=row["retry_of"])
|
|
195
|
+
self.db.execute(
|
|
196
|
+
"UPDATE runs SET status='running', claimed_by=?, claimed_at=?, heartbeat_at=?, prompt=? WHERE id=?",
|
|
197
|
+
(worker, now, now, prompt, row["id"]),
|
|
198
|
+
)
|
|
199
|
+
task = {"run_id": row["id"], **spec, "prompt": prompt, "events": events}
|
|
200
|
+
if row["parent_run"]:
|
|
201
|
+
parent = self.db.execute("SELECT session_id FROM runs WHERE id=?", (row["parent_run"],)).fetchone()
|
|
202
|
+
task["resume_session"] = parent["session_id"] if parent else None
|
|
203
|
+
return task
|
|
204
|
+
return None
|
|
205
|
+
|
|
206
|
+
def _events_for_run(self, run_id: int) -> list[dict[str, Any]]:
|
|
207
|
+
rows = self.db.execute(
|
|
208
|
+
"SELECT e.* FROM events e JOIN run_events re ON re.event_id=e.id WHERE re.run_id=? ORDER BY e.id",
|
|
209
|
+
(run_id,),
|
|
210
|
+
).fetchall()
|
|
211
|
+
out = []
|
|
212
|
+
for r in rows:
|
|
213
|
+
d = store.row_to_dict(r)
|
|
214
|
+
out.append({k: d[k] for k in ("id", "source", "type", "subject", "summary", "link", "payload")})
|
|
215
|
+
return out
|
|
216
|
+
|
|
217
|
+
# ------------------------------------------------------------------ progress
|
|
218
|
+
|
|
219
|
+
def _running(self, run_id: int, worker: str) -> dict[str, Any]:
|
|
220
|
+
row = self.db.execute("SELECT * FROM runs WHERE id=?", (run_id,)).fetchone()
|
|
221
|
+
if row is None:
|
|
222
|
+
raise HubError(404, f"run {run_id} not found")
|
|
223
|
+
if row["status"] != "running" or row["claimed_by"] != worker:
|
|
224
|
+
raise HubError(409, f"run {run_id} is not running on {worker} (status {row['status']})")
|
|
225
|
+
return row
|
|
226
|
+
|
|
227
|
+
def heartbeat(self, run_id: int, worker: str) -> None:
|
|
228
|
+
with self.lock:
|
|
229
|
+
self._running(run_id, worker)
|
|
230
|
+
self.db.execute("UPDATE runs SET heartbeat_at=? WHERE id=?", (self.clock(), run_id))
|
|
231
|
+
|
|
232
|
+
def finish(self, run_id: int, worker: str, result: dict[str, Any]) -> dict[str, Any]:
|
|
233
|
+
with self.lock:
|
|
234
|
+
self._running(run_id, worker)
|
|
235
|
+
exit_code = int(result.get("exit_code", 1))
|
|
236
|
+
output = str(result.get("output", ""))
|
|
237
|
+
summary = result.get("summary") or extract_summary(output)
|
|
238
|
+
decision_req = result.get("needs_decision") or extract_decision(output)
|
|
239
|
+
if decision_req is not None and not _valid_decision(decision_req):
|
|
240
|
+
decision_req = None
|
|
241
|
+
reported = _normalize_status(result.get("status")) or extract_status(output)
|
|
242
|
+
if decision_req:
|
|
243
|
+
status = "awaiting_decision"
|
|
244
|
+
elif exit_code != 0 or reported == "failed":
|
|
245
|
+
# The agent's own verdict wins over a clean exit: a process can exit 0 without doing the job.
|
|
246
|
+
status = "failed"
|
|
247
|
+
else:
|
|
248
|
+
status = "succeeded"
|
|
249
|
+
self.db.execute(
|
|
250
|
+
"UPDATE runs SET status=?, finished_at=?, exit_code=?, summary=?, session_id=?, output_tail=?, "
|
|
251
|
+
"decision_request=?, cost_usd=?, input_tokens=?, output_tokens=? WHERE id=?",
|
|
252
|
+
(status, self.clock(), exit_code, (summary or "")[:2000], result.get("session_id"),
|
|
253
|
+
output[-8000:], json.dumps(decision_req, ensure_ascii=False) if decision_req else None,
|
|
254
|
+
_num(result.get("cost_usd"), float), _num(result.get("input_tokens"), int),
|
|
255
|
+
_num(result.get("output_tokens"), int), run_id),
|
|
256
|
+
)
|
|
257
|
+
self.cv.notify_all()
|
|
258
|
+
run = self.get_run(run_id)
|
|
259
|
+
self._maybe_notify(run)
|
|
260
|
+
return {"run_id": run_id, "status": status}
|
|
261
|
+
|
|
262
|
+
def decide(self, run_id: int, choice: str, note: str = "", by: str = "human") -> dict[str, Any]:
|
|
263
|
+
with self.lock:
|
|
264
|
+
run = self.get_run(run_id)
|
|
265
|
+
if run is None:
|
|
266
|
+
raise HubError(404, f"run {run_id} not found")
|
|
267
|
+
if run["status"] != "awaiting_decision":
|
|
268
|
+
raise HubError(409, f"run {run_id} is not awaiting a decision (status {run['status']})")
|
|
269
|
+
req = run["decision_request"] or {}
|
|
270
|
+
options = req.get("options") or []
|
|
271
|
+
if options and choice not in options:
|
|
272
|
+
raise HubError(400, f"choice must be one of {options}")
|
|
273
|
+
decision = {"choice": choice, "note": note, "by": by, "at": self.clock()}
|
|
274
|
+
self.db.execute("UPDATE runs SET status='decided', decision=? WHERE id=?",
|
|
275
|
+
(json.dumps(decision, ensure_ascii=False), run_id))
|
|
276
|
+
# A decision is just another event, so rules decide what happens next.
|
|
277
|
+
follow = self.ingest({
|
|
278
|
+
"source": "dispatchbox",
|
|
279
|
+
"type": "decision.made",
|
|
280
|
+
"subject": f"run-{run_id}",
|
|
281
|
+
"summary": f"{req.get('question', '')} -> {choice}",
|
|
282
|
+
"link": f"{self.public_url}/runs/{run_id}" if self.public_url else "",
|
|
283
|
+
"payload": {"run_id": run_id, "rule": run["rule"], "question": req.get("question"),
|
|
284
|
+
"choice": choice, "note": note, "session_id": run["session_id"]},
|
|
285
|
+
"dedupe_key": f"dispatchbox:decision:{run_id}",
|
|
286
|
+
}, parent_run=run_id)
|
|
287
|
+
return {"run_id": run_id, "status": "decided", "follow_up": follow}
|
|
288
|
+
|
|
289
|
+
# ------------------------------------------------------------------ housekeeping
|
|
290
|
+
|
|
291
|
+
def reap(self) -> list[int]:
|
|
292
|
+
"""Mark runs whose worker stopped sending heartbeats as lost."""
|
|
293
|
+
now = self.clock()
|
|
294
|
+
lost: list[int] = []
|
|
295
|
+
with self.lock:
|
|
296
|
+
rows = self.db.execute(
|
|
297
|
+
"SELECT id, heartbeat_at, claimed_at, spec, attempt FROM runs WHERE status='running'").fetchall()
|
|
298
|
+
final: list[int] = []
|
|
299
|
+
for r in rows:
|
|
300
|
+
spec = json.loads(r["spec"])
|
|
301
|
+
silent = now - (r["heartbeat_at"] or 0) > self.lost_after
|
|
302
|
+
overdue = now - (r["claimed_at"] or now) > spec.get("timeout", 1800) + self.lost_after
|
|
303
|
+
if not (silent or overdue):
|
|
304
|
+
continue
|
|
305
|
+
reason = "worker stopped reporting" if silent else "exceeded timeout"
|
|
306
|
+
if r["attempt"] <= spec.get("retries", 0):
|
|
307
|
+
cur = self.db.execute(
|
|
308
|
+
"INSERT INTO runs (rule, worker_pattern, group_key, concurrency, spec, status, created_at, "
|
|
309
|
+
"parent_run, attempt, retry_of) SELECT rule, worker_pattern, group_key, concurrency, spec, "
|
|
310
|
+
"'queued', ?, parent_run, attempt + 1, id FROM runs WHERE id=?",
|
|
311
|
+
(now, r["id"]),
|
|
312
|
+
)
|
|
313
|
+
self.db.execute("INSERT INTO run_events (run_id, event_id) SELECT ?, event_id FROM run_events "
|
|
314
|
+
"WHERE run_id=?", (cur.lastrowid, r["id"]))
|
|
315
|
+
reason += f", retrying as run #{cur.lastrowid}"
|
|
316
|
+
else:
|
|
317
|
+
final.append(r["id"])
|
|
318
|
+
self.db.execute("UPDATE runs SET status='lost', finished_at=?, summary=? WHERE id=?",
|
|
319
|
+
(now, reason, r["id"]))
|
|
320
|
+
lost.append(r["id"])
|
|
321
|
+
if lost:
|
|
322
|
+
self.cv.notify_all()
|
|
323
|
+
# Only tell the owner once nothing will retry.
|
|
324
|
+
for rid in final:
|
|
325
|
+
self._maybe_notify(self.get_run(rid))
|
|
326
|
+
return lost
|
|
327
|
+
|
|
328
|
+
def tick_cron(self, now: datetime | None = None) -> list[dict[str, Any]]:
|
|
329
|
+
now = (now or datetime.now()).replace(second=0, microsecond=0)
|
|
330
|
+
stamp = now.strftime("%Y-%m-%dT%H:%M")
|
|
331
|
+
if stamp == self._last_cron_minute:
|
|
332
|
+
return []
|
|
333
|
+
self._last_cron_minute = stamp
|
|
334
|
+
self.reload_if_changed()
|
|
335
|
+
results = []
|
|
336
|
+
for sched in self.config.schedules:
|
|
337
|
+
if sched.cron.matches(now):
|
|
338
|
+
ev = {**sched.event, "dedupe_key": f"cron:{sched.name}:{stamp}"}
|
|
339
|
+
ev.setdefault("summary", f"scheduled {sched.name} at {stamp}")
|
|
340
|
+
results.append(self.ingest(ev))
|
|
341
|
+
return results
|
|
342
|
+
|
|
343
|
+
# ------------------------------------------------------------------ notify
|
|
344
|
+
|
|
345
|
+
def _maybe_notify(self, run: dict[str, Any] | None) -> None:
|
|
346
|
+
if not run:
|
|
347
|
+
return
|
|
348
|
+
mode = run["spec"].get("notify", "on_decision")
|
|
349
|
+
status = run["status"]
|
|
350
|
+
wanted = (
|
|
351
|
+
mode == "always"
|
|
352
|
+
or (mode in {"on_decision", "on_failure"} and status == "awaiting_decision")
|
|
353
|
+
or (mode == "on_failure" and status in {"failed", "lost"})
|
|
354
|
+
)
|
|
355
|
+
link = self.run_link(run["id"])
|
|
356
|
+
if wanted:
|
|
357
|
+
try:
|
|
358
|
+
self.notifier(run, link)
|
|
359
|
+
except Exception as e: # notifications must never break the hub
|
|
360
|
+
log.error("notify failed for run %s: %s", run["id"], e)
|
|
361
|
+
# Chat adapters do network calls, so they run off the request thread.
|
|
362
|
+
for adapter in self.adapters.values():
|
|
363
|
+
if wanted:
|
|
364
|
+
self.background(adapter.notify, run, link)
|
|
365
|
+
self.background(adapter.run_finished, run, link)
|
|
366
|
+
|
|
367
|
+
def run_link(self, run_id: int) -> str:
|
|
368
|
+
return f"{self.public_url}/runs/{run_id}" if self.public_url else f"/runs/{run_id}"
|
|
369
|
+
|
|
370
|
+
def _default_notify(self, run: dict[str, Any], link: str) -> None:
|
|
371
|
+
text = format_notification(run, link)
|
|
372
|
+
url = os.environ.get("DISPATCHBOX_NOTIFY_URL")
|
|
373
|
+
if not url:
|
|
374
|
+
log.warning("NOTIFY %s", text.replace("\n", " | "))
|
|
375
|
+
return
|
|
376
|
+
if "open.feishu.cn" in url or "open.larksuite.com" in url:
|
|
377
|
+
body = {"msg_type": "text", "content": {"text": text}}
|
|
378
|
+
elif "hooks.slack.com" in url:
|
|
379
|
+
body = {"text": text}
|
|
380
|
+
else:
|
|
381
|
+
body = {"text": text, "run": run}
|
|
382
|
+
req = urllib.request.Request(url, data=json.dumps(body, ensure_ascii=False, default=str).encode(),
|
|
383
|
+
headers={"Content-Type": "application/json"})
|
|
384
|
+
urllib.request.urlopen(req, timeout=10, context=ssl_context()).read()
|
|
385
|
+
|
|
386
|
+
# ------------------------------------------------------------------ queries
|
|
387
|
+
|
|
388
|
+
def get_run(self, run_id: int) -> dict[str, Any] | None:
|
|
389
|
+
with self.lock:
|
|
390
|
+
run = store.row_to_dict(self.db.execute("SELECT * FROM runs WHERE id=?", (run_id,)).fetchone())
|
|
391
|
+
if run:
|
|
392
|
+
run["events"] = self._events_for_run(run_id)
|
|
393
|
+
return run
|
|
394
|
+
|
|
395
|
+
def list_runs(self, limit: int = 100, status: str | None = None) -> list[dict[str, Any]]:
|
|
396
|
+
with self.lock:
|
|
397
|
+
if status:
|
|
398
|
+
rows = self.db.execute("SELECT * FROM runs WHERE status=? ORDER BY id DESC LIMIT ?", (status, limit))
|
|
399
|
+
else:
|
|
400
|
+
rows = self.db.execute("SELECT * FROM runs ORDER BY id DESC LIMIT ?", (limit,))
|
|
401
|
+
return [store.row_to_dict(r) for r in rows.fetchall()]
|
|
402
|
+
|
|
403
|
+
def list_events(self, limit: int = 100) -> list[dict[str, Any]]:
|
|
404
|
+
with self.lock:
|
|
405
|
+
rows = self.db.execute("SELECT * FROM events ORDER BY id DESC LIMIT ?", (limit,)).fetchall()
|
|
406
|
+
return [store.row_to_dict(r) for r in rows]
|
|
407
|
+
|
|
408
|
+
def stats(self) -> dict[str, int]:
|
|
409
|
+
with self.lock:
|
|
410
|
+
rows = self.db.execute("SELECT status, COUNT(*) n FROM runs GROUP BY status").fetchall()
|
|
411
|
+
return {r["status"]: r["n"] for r in rows}
|
|
412
|
+
|
|
413
|
+
|
|
414
|
+
def _background(fn: Callable[..., Any], *args: Any) -> None:
|
|
415
|
+
def run() -> None:
|
|
416
|
+
try:
|
|
417
|
+
fn(*args)
|
|
418
|
+
except Exception:
|
|
419
|
+
log.exception("background task %s failed", getattr(fn, "__qualname__", fn))
|
|
420
|
+
threading.Thread(target=run, daemon=True).start()
|
|
421
|
+
|
|
422
|
+
|
|
423
|
+
# ---------------------------------------------------------------------- prompt & result contract
|
|
424
|
+
|
|
425
|
+
def build_prompt(run_id: int, spec: dict[str, Any], events: list[dict[str, Any]], parent_run: int | None,
|
|
426
|
+
attempt: int = 1, retry_of: int | None = None) -> str:
|
|
427
|
+
latest = events[-1] if events else {}
|
|
428
|
+
ctx = {**latest, "rule": spec["rule"], "event_count": len(events)}
|
|
429
|
+
parts = [render(spec.get("prompt") or "", ctx).strip()]
|
|
430
|
+
if spec.get("skill"):
|
|
431
|
+
parts.append(f"Use the `{spec['skill']}` skill for this task.")
|
|
432
|
+
|
|
433
|
+
lines = [f"## Trigger", f"Started automatically by dispatchbox: run #{run_id}, rule \"{spec['rule']}\"."]
|
|
434
|
+
if parent_run:
|
|
435
|
+
lines.append(f"This continues run #{parent_run} after a human decision (see the decision.made event).")
|
|
436
|
+
if retry_of:
|
|
437
|
+
lines.append(f"This is attempt {attempt}. The previous attempt (run #{retry_of}) stopped reporting midway "
|
|
438
|
+
"and may have done part of the work. Check the current state before repeating any step.")
|
|
439
|
+
if len(events) > 1:
|
|
440
|
+
lines.append(f"{len(events)} related events were merged into this run; handle them together.")
|
|
441
|
+
for e in events:
|
|
442
|
+
line = f"- [{e['source']}/{e['type']}] {e['subject']}: {e['summary']}".rstrip(": ")
|
|
443
|
+
if e.get("link"):
|
|
444
|
+
line += f" ({e['link']})"
|
|
445
|
+
lines.append(line)
|
|
446
|
+
payloads = [{"event": e["id"], "payload": e["payload"]} for e in events if e.get("payload")]
|
|
447
|
+
if payloads:
|
|
448
|
+
blob = json.dumps(payloads, ensure_ascii=False, indent=1)
|
|
449
|
+
if len(blob) > MAX_PAYLOAD_IN_PROMPT:
|
|
450
|
+
blob = blob[:MAX_PAYLOAD_IN_PROMPT] + "\n... (truncated)"
|
|
451
|
+
lines += ["", "Event payloads:", "```json", blob, "```"]
|
|
452
|
+
parts.append("\n".join(lines))
|
|
453
|
+
|
|
454
|
+
if spec.get("memory"):
|
|
455
|
+
files = ", ".join(f"`{m}`" for m in spec["memory"])
|
|
456
|
+
parts.append(
|
|
457
|
+
"## Standing decisions\n"
|
|
458
|
+
f"Before asking anything, read {files} if they exist. They record what the owner already decided "
|
|
459
|
+
"or refused. Follow them and do not ask again about anything they settle."
|
|
460
|
+
)
|
|
461
|
+
|
|
462
|
+
contract = ["## When to ask a human"]
|
|
463
|
+
if spec.get("require_decision_for"):
|
|
464
|
+
contract.append("Stop and ask only before these kinds of actions: "
|
|
465
|
+
+ ", ".join(spec["require_decision_for"]) + ". Do everything else yourself.")
|
|
466
|
+
else:
|
|
467
|
+
contract.append("Only ask when you genuinely cannot proceed safely. Do everything else yourself.")
|
|
468
|
+
contract += [
|
|
469
|
+
"To ask, do not take the action. End your final message with exactly one line:",
|
|
470
|
+
f'{DECISION_MARK} {{"question": "<one sentence>", "options": ["<option A>", "<option B>"], "context": "<what you found>"}}',
|
|
471
|
+
"",
|
|
472
|
+
"Always finish with a summary line. Unless you are asking, put a status line right before it:",
|
|
473
|
+
"failed if you could not do what was asked, for any reason, including a denied permission or a",
|
|
474
|
+
"missing tool; succeeded only if the task is actually done. Asking is not a failure, so when you",
|
|
475
|
+
"ask, leave the status line out.",
|
|
476
|
+
f"{STATUS_MARK} succeeded | failed",
|
|
477
|
+
f"{SUMMARY_MARK} <one sentence>",
|
|
478
|
+
]
|
|
479
|
+
parts.append("\n".join(contract))
|
|
480
|
+
return "\n\n".join(p for p in parts if p)
|
|
481
|
+
|
|
482
|
+
|
|
483
|
+
def extract_summary(output: str) -> str:
|
|
484
|
+
for line in reversed(output.splitlines()):
|
|
485
|
+
line = line.strip()
|
|
486
|
+
if line.startswith(SUMMARY_MARK):
|
|
487
|
+
return line[len(SUMMARY_MARK):].strip()
|
|
488
|
+
tail = [l.strip() for l in output.strip().splitlines()
|
|
489
|
+
if l.strip() and not l.strip().startswith((DECISION_MARK, STATUS_MARK))]
|
|
490
|
+
return tail[-1][:500] if tail else ""
|
|
491
|
+
|
|
492
|
+
|
|
493
|
+
def _normalize_status(value: Any) -> str | None:
|
|
494
|
+
if not isinstance(value, str):
|
|
495
|
+
return None
|
|
496
|
+
v = value.strip().strip("`*").lower()
|
|
497
|
+
if v in {"succeeded", "success", "done", "ok", "completed"}:
|
|
498
|
+
return "succeeded"
|
|
499
|
+
if v in {"failed", "failure", "error", "blocked", "incomplete"}:
|
|
500
|
+
return "failed"
|
|
501
|
+
return None
|
|
502
|
+
|
|
503
|
+
|
|
504
|
+
def extract_status(output: str) -> str | None:
|
|
505
|
+
"""Read the agent's own verdict from its last DISPATCHBOX_STATUS line, if any."""
|
|
506
|
+
for line in reversed(output.splitlines()):
|
|
507
|
+
line = line.strip()
|
|
508
|
+
if line.startswith(STATUS_MARK):
|
|
509
|
+
return _normalize_status(line[len(STATUS_MARK):])
|
|
510
|
+
return None
|
|
511
|
+
|
|
512
|
+
|
|
513
|
+
def _num(value: Any, kind: type) -> Any:
|
|
514
|
+
try:
|
|
515
|
+
return kind(value) if value is not None else None
|
|
516
|
+
except (TypeError, ValueError):
|
|
517
|
+
return None
|
|
518
|
+
|
|
519
|
+
|
|
520
|
+
def extract_decision(output: str) -> dict[str, Any] | None:
|
|
521
|
+
for line in reversed(output.splitlines()):
|
|
522
|
+
line = line.strip()
|
|
523
|
+
if line.startswith(DECISION_MARK):
|
|
524
|
+
try:
|
|
525
|
+
return json.loads(line[len(DECISION_MARK):].strip())
|
|
526
|
+
except json.JSONDecodeError:
|
|
527
|
+
return {"question": line[len(DECISION_MARK):].strip(), "options": []}
|
|
528
|
+
return None
|
|
529
|
+
|
|
530
|
+
|
|
531
|
+
def _valid_decision(d: Any) -> bool:
|
|
532
|
+
return isinstance(d, dict) and isinstance(d.get("question"), str) and d["question"].strip() != "" \
|
|
533
|
+
and isinstance(d.get("options", []), list)
|
|
534
|
+
|
|
535
|
+
|
|
536
|
+
def format_notification(run: dict[str, Any], link: str) -> str:
|
|
537
|
+
status = run["status"]
|
|
538
|
+
head = f"[dispatchbox] run #{run['id']} {run['rule']}: {status}"
|
|
539
|
+
lines = [head]
|
|
540
|
+
if status == "awaiting_decision" and run.get("decision_request"):
|
|
541
|
+
req = run["decision_request"]
|
|
542
|
+
lines.append(f"Question: {req.get('question')}")
|
|
543
|
+
if req.get("options"):
|
|
544
|
+
lines.append("Options: " + " / ".join(req["options"]))
|
|
545
|
+
if req.get("context"):
|
|
546
|
+
lines.append(f"Context: {req['context']}")
|
|
547
|
+
elif run.get("summary"):
|
|
548
|
+
lines.append(run["summary"])
|
|
549
|
+
lines.append(f"Open: {link}")
|
|
550
|
+
return "\n".join(lines)
|
dispatchbox/net.py
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
"""TLS settings shared by every outgoing HTTPS call (Slack, Feishu, webhooks, worker to hub)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import functools
|
|
6
|
+
import logging
|
|
7
|
+
import ssl
|
|
8
|
+
|
|
9
|
+
log = logging.getLogger("dispatchbox.net")
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
@functools.lru_cache(maxsize=1)
|
|
13
|
+
def ssl_context() -> ssl.SSLContext:
|
|
14
|
+
"""The system trust store, plus certifi's bundle when the system one is empty.
|
|
15
|
+
|
|
16
|
+
python.org builds of Python on macOS ship without CA certificates until "Install Certificates.command"
|
|
17
|
+
is run, and then every HTTPS call fails with CERTIFICATE_VERIFY_FAILED."""
|
|
18
|
+
ctx = ssl.create_default_context()
|
|
19
|
+
if ctx.cert_store_stats()["x509_ca"]:
|
|
20
|
+
return ctx
|
|
21
|
+
try:
|
|
22
|
+
import certifi
|
|
23
|
+
except ImportError:
|
|
24
|
+
log.warning("this Python has no CA certificates, so HTTPS calls will fail. Fix: pip install certifi, "
|
|
25
|
+
"or on macOS run \"/Applications/Python 3.x/Install Certificates.command\"")
|
|
26
|
+
return ctx
|
|
27
|
+
ctx.load_verify_locations(certifi.where())
|
|
28
|
+
return ctx
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def context_for(url: str) -> ssl.SSLContext | None:
|
|
32
|
+
"""TLS settings for url, or None for plain HTTP, so a local hub never triggers the certificate warning."""
|
|
33
|
+
return ssl_context() if url.lower().startswith("https:") else None
|