feat: OpenMesh 基础平台与 MD/PDF 转换技能
- 后端: coworker 智能体框架, WS API, 文件上传, 附件处理 - 前端: Open WebUI, 文件全量走 upload API (含 MD/TXT/JSON 等文本类) - 技能: md-to-office (pandoc + wkhtmltopdf) - 修复: 上传文件路径丢失, Agent 搜索浪费, 输出文件跑到 uploads/ - 打包: PyInstaller one-dir, 预打包 pandoc/wkhtmltopdf/chromium
This commit is contained in:
180
coworker/selfwake.py
Normal file
180
coworker/selfwake.py
Normal file
@@ -0,0 +1,180 @@
|
||||
"""Self-wake — tools that let a long-running agent suspend and be re-invoked on a trigger.
|
||||
|
||||
Converts an always-on agent into suspend/resume (event-driven, ~zero idle cost): the session
|
||||
sleeps and the runtime re-invokes it when a wake is due. Two triggers here: a **timer**
|
||||
(`sleep_until`) and **on-completion** (`wake_on` a backgrounded job). This module
|
||||
owns the wake records + the due/complete logic; the scheduler tick consumes ``due()`` /
|
||||
``complete_job()`` and resumes the session (shares the automation scheduler — see
|
||||
``PERMISSIONS-AND-INBOX.md``).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import threading
|
||||
import uuid
|
||||
from dataclasses import asdict, dataclass, field
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
KIND_TIMER = "timer"
|
||||
KIND_COMPLETION = "completion"
|
||||
KIND_EVENT = "event" # wake when a named connector/webhook event fires (Phase 3)
|
||||
|
||||
STATE_PENDING = "pending"
|
||||
STATE_DUE = "due"
|
||||
STATE_FIRED = "fired"
|
||||
|
||||
|
||||
def _now() -> datetime:
|
||||
return datetime.now(timezone.utc)
|
||||
|
||||
|
||||
@dataclass
|
||||
class Wake:
|
||||
id: str
|
||||
session_id: str
|
||||
kind: str
|
||||
state: str = STATE_PENDING
|
||||
fire_at: Optional[str] = None # ISO, for timer wakes
|
||||
job_id: Optional[str] = None # for completion wakes
|
||||
event_key: Optional[str] = None # for on-event wakes
|
||||
note: str = ""
|
||||
created_at: str = field(default_factory=lambda: _now().isoformat())
|
||||
|
||||
|
||||
class WakeStore:
|
||||
def __init__(self, path: Optional[str | Path] = None) -> None:
|
||||
self.path = Path(path) if path else None
|
||||
self._lock = threading.Lock()
|
||||
self._wakes: dict[str, Wake] = {}
|
||||
if self.path and self.path.is_file():
|
||||
for raw in json.loads(self.path.read_text(encoding="utf-8")).get(
|
||||
"wakes", []
|
||||
):
|
||||
w = Wake(**raw)
|
||||
self._wakes[w.id] = w
|
||||
|
||||
def _save(self) -> None:
|
||||
if not self.path:
|
||||
return
|
||||
self.path.parent.mkdir(parents=True, exist_ok=True)
|
||||
self.path.write_text(
|
||||
json.dumps({"wakes": [asdict(w) for w in self._wakes.values()]}, indent=2),
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
def add_timer(self, session_id: str, fire_at: datetime, *, note: str = "") -> Wake:
|
||||
w = Wake(
|
||||
uuid.uuid4().hex,
|
||||
session_id,
|
||||
KIND_TIMER,
|
||||
fire_at=fire_at.isoformat(),
|
||||
note=note,
|
||||
)
|
||||
with self._lock:
|
||||
self._wakes[w.id] = w
|
||||
self._save()
|
||||
return w
|
||||
|
||||
def add_completion(self, session_id: str, job_id: str, *, note: str = "") -> Wake:
|
||||
w = Wake(
|
||||
uuid.uuid4().hex, session_id, KIND_COMPLETION, job_id=job_id, note=note
|
||||
)
|
||||
with self._lock:
|
||||
self._wakes[w.id] = w
|
||||
self._save()
|
||||
return w
|
||||
|
||||
def add_event(self, session_id: str, event_key: str, *, note: str = "") -> Wake:
|
||||
w = Wake(
|
||||
uuid.uuid4().hex, session_id, KIND_EVENT, event_key=event_key, note=note
|
||||
)
|
||||
with self._lock:
|
||||
self._wakes[w.id] = w
|
||||
self._save()
|
||||
return w
|
||||
|
||||
def due(self, now: Optional[datetime] = None) -> list[Wake]:
|
||||
"""Timer wakes whose fire time has passed, plus completion/event wakes marked due."""
|
||||
now = now or _now()
|
||||
out = []
|
||||
for w in self._wakes.values():
|
||||
if w.state != STATE_PENDING and w.state != STATE_DUE:
|
||||
continue
|
||||
if (
|
||||
w.kind == KIND_TIMER
|
||||
and w.fire_at
|
||||
and datetime.fromisoformat(w.fire_at) <= now
|
||||
):
|
||||
out.append(w)
|
||||
elif w.kind in (KIND_COMPLETION, KIND_EVENT) and w.state == STATE_DUE:
|
||||
out.append(w)
|
||||
return out
|
||||
|
||||
def complete_job(self, job_id: str) -> list[Wake]:
|
||||
"""Mark completion wakes for ``job_id`` as due (the job exited). Returns them."""
|
||||
return self._mark_due(
|
||||
lambda w: w.kind == KIND_COMPLETION and w.job_id == job_id
|
||||
)
|
||||
|
||||
def fire_event(self, event_key: str) -> list[Wake]:
|
||||
"""Mark on-event wakes for ``event_key`` as due (a connector/webhook fired). Returns them."""
|
||||
return self._mark_due(
|
||||
lambda w: w.kind == KIND_EVENT and w.event_key == event_key
|
||||
)
|
||||
|
||||
def _mark_due(self, pred) -> list[Wake]:
|
||||
fired = []
|
||||
with self._lock:
|
||||
for w in self._wakes.values():
|
||||
if w.state == STATE_PENDING and pred(w):
|
||||
w.state = STATE_DUE
|
||||
fired.append(w)
|
||||
if fired:
|
||||
self._save()
|
||||
return fired
|
||||
|
||||
def mark_fired(self, wake_id: str) -> None:
|
||||
with self._lock:
|
||||
w = self._wakes.get(wake_id)
|
||||
if w is not None:
|
||||
w.state = STATE_FIRED
|
||||
self._save()
|
||||
|
||||
def pending(self, session_id: Optional[str] = None) -> list[Wake]:
|
||||
return [
|
||||
w
|
||||
for w in self._wakes.values()
|
||||
if w.state != STATE_FIRED
|
||||
and (session_id is None or w.session_id == session_id)
|
||||
]
|
||||
|
||||
|
||||
def selfwake_tools(store: WakeStore, session_id: str) -> list:
|
||||
"""Tools an agent calls to schedule its own resumption."""
|
||||
|
||||
def sleep_until(when_iso: str, note: str = "") -> dict:
|
||||
"""Suspend and wake this session at an ISO-8601 timestamp (timezone-aware; bare
|
||||
timestamps are read as UTC). Use it for polling/waiting without burning context
|
||||
while idle — for a relative wait ("check again in 5 minutes"), compute the
|
||||
timestamp from the `Now:` line in your context."""
|
||||
when = datetime.fromisoformat(when_iso)
|
||||
if when.tzinfo is None:
|
||||
when = when.replace(tzinfo=timezone.utc)
|
||||
w = store.add_timer(session_id, when, note=note)
|
||||
return {"ok": True, "wake_id": w.id, "fire_at": w.fire_at}
|
||||
|
||||
def wake_on(job_id: str, note: str = "") -> dict:
|
||||
"""Suspend and wake this session when a backgrounded job (`job_id`) completes."""
|
||||
w = store.add_completion(session_id, job_id, note=note)
|
||||
return {"ok": True, "wake_id": w.id, "job_id": job_id}
|
||||
|
||||
def wake_on_event(event_key: str, note: str = "") -> dict:
|
||||
"""Suspend and wake this session when a named event (`event_key`) fires — e.g. a
|
||||
connector/webhook signal an Ops agent watches for."""
|
||||
w = store.add_event(session_id, event_key, note=note)
|
||||
return {"ok": True, "wake_id": w.id, "event_key": event_key}
|
||||
|
||||
return [sleep_until, wake_on, wake_on_event]
|
||||
Reference in New Issue
Block a user