mirror of
https://github.com/andrewyng/openworker.git
synced 2026-09-13 07:40:18 +00:00
OpenWorker: initial import
Imported from andrewyng/aisuite@1b4bbf303e (contents of its platform/ directory, hoisted to the repo root). Development history prior to this commit lives in that repository. Co-authored-by: Devika <devikaverma11@gmail.com>
This commit is contained in:
@@ -0,0 +1,18 @@
|
||||
"""Automation — scheduled tasks that run in the always-on server."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from .models import Schedule, ScheduledTask, TaskRun
|
||||
from .scheduler import Scheduler
|
||||
from .store import TaskStore, compute_next_run
|
||||
from .tools import scheduling_tools
|
||||
|
||||
__all__ = [
|
||||
"Schedule",
|
||||
"ScheduledTask",
|
||||
"TaskRun",
|
||||
"Scheduler",
|
||||
"TaskStore",
|
||||
"compute_next_run",
|
||||
"scheduling_tools",
|
||||
]
|
||||
@@ -0,0 +1,239 @@
|
||||
"""Automation data model — a scheduled task is its own persistent entity (see
|
||||
docs/AUTOMATION-SCHEDULING.md). Each fire is a fresh Run of the task's instructions, recorded
|
||||
in the task's own thread + working folder.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import time
|
||||
import uuid
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any, Optional
|
||||
|
||||
_DOW = ["Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday", "Sunday"]
|
||||
|
||||
|
||||
def _now() -> float:
|
||||
return time.time()
|
||||
|
||||
|
||||
# -- standing scoped approvals (UX-DECISIONS §25) --------------------------------
|
||||
# An `always_allowed_tools` entry is either a bare tool name (legacy, allows the tool
|
||||
# against any argument) or "tool target" — one space, tool names never contain spaces —
|
||||
# binding the allowance to one exact target (channel address, recipient, …). Rules live
|
||||
# on the task record so revocation is per-automation and deletion takes them along.
|
||||
|
||||
|
||||
def rule_entry(tool: str, target: Optional[str] = None) -> str:
|
||||
return f"{tool} {target}" if target else tool
|
||||
|
||||
|
||||
def rule_parts(entry: str) -> tuple[str, Optional[str]]:
|
||||
tool, _, target = entry.strip().partition(" ")
|
||||
return tool, (target.strip() or None)
|
||||
|
||||
|
||||
def grant_entries(permissions: Any) -> list[str]:
|
||||
"""Validate a proposed `permissions` list (from the create-tool schema or the GUI
|
||||
create payload) down to the entries actually grantable. Only `access: "write"` items
|
||||
become grants; the tool must declare a target argument (which excludes exec/destructive
|
||||
tools by construction) and the target must be non-empty. Reads are disclosure-only —
|
||||
rendered on the consent card, never stored. Anything else is dropped, fail-closed.
|
||||
"""
|
||||
from ..connectors.tool_defs import target_arg_for
|
||||
|
||||
entries: list[str] = []
|
||||
for item in permissions or []:
|
||||
if not isinstance(item, dict):
|
||||
continue
|
||||
if str(item.get("access", "")).lower() != "write":
|
||||
continue
|
||||
tool = str(item.get("tool", "")).strip()
|
||||
target = str(item.get("target", "")).strip()
|
||||
if not tool or not target or target_arg_for(tool) is None:
|
||||
continue
|
||||
entry = rule_entry(tool, target)
|
||||
if entry not in entries:
|
||||
entries.append(entry)
|
||||
return entries
|
||||
|
||||
|
||||
def _human_time(hour: int, minute: int) -> str:
|
||||
ampm = "AM" if hour < 12 else "PM"
|
||||
h12 = hour % 12 or 12
|
||||
return f"{h12}:{minute:02d} {ampm}"
|
||||
|
||||
|
||||
@dataclass
|
||||
class Schedule:
|
||||
kind: str # "cron" | "once"
|
||||
cron: Optional[str] = None
|
||||
fire_at: Optional[str] = None # ISO datetime for one-time
|
||||
timezone: str = (
|
||||
"local" # 'local' = the machine's clock (a local-first tool default)
|
||||
)
|
||||
|
||||
def human(self) -> str:
|
||||
"""Best-effort human label ('Every day at ~7:10 PM'); falls back to the raw cron."""
|
||||
if self.kind == "once":
|
||||
return f"Once at {self.fire_at}"
|
||||
parts = (self.cron or "").split()
|
||||
if len(parts) != 5:
|
||||
return self.cron or "?"
|
||||
minute, hour, dom, month, dow = parts
|
||||
try:
|
||||
t = _human_time(int(hour), int(minute))
|
||||
except ValueError:
|
||||
return self.cron # non-trivial cron (ranges/steps) — show as-is
|
||||
if dom == "*" and dow == "*":
|
||||
return f"Every day at ~{t}"
|
||||
if dom == "*" and dow.isdigit():
|
||||
return f"Every {_DOW[int(dow) % 7]} at ~{t}"
|
||||
if dom.isdigit() and dow == "*":
|
||||
return f"Monthly on day {dom} at ~{t}"
|
||||
return self.cron
|
||||
|
||||
def to_dict(self) -> dict:
|
||||
return {
|
||||
"kind": self.kind,
|
||||
"cron": self.cron,
|
||||
"fire_at": self.fire_at,
|
||||
"timezone": self.timezone,
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, d: dict) -> "Schedule":
|
||||
return cls(
|
||||
kind=d.get("kind", "cron"),
|
||||
cron=d.get("cron"),
|
||||
fire_at=d.get("fire_at"),
|
||||
timezone=d.get("timezone", "local"),
|
||||
)
|
||||
|
||||
|
||||
@dataclass
|
||||
class ScheduledTask:
|
||||
title: str
|
||||
instructions: str
|
||||
schedule: Schedule
|
||||
workspace: str
|
||||
origin_surface: str = "cowork" # where it was launched from (a reference)
|
||||
origin_session_id: str = ""
|
||||
agent: str = "cowork"
|
||||
id: str = field(default_factory=lambda: "task-" + uuid.uuid4().hex[:10])
|
||||
task_session_id: str = "" # the task's OWN thread (set to f"__task__{id}")
|
||||
model: Optional[str] = None
|
||||
notify_on_completion: bool = True
|
||||
notify_target: Optional[str] = None # extra messaging target ("telegram:123")
|
||||
always_allowed_tools: list[str] = field(default_factory=list)
|
||||
always_allowed_commands: list[str] = field(default_factory=list)
|
||||
enabled: bool = True
|
||||
created_at: float = field(default_factory=_now)
|
||||
updated_at: float = field(default_factory=_now)
|
||||
next_run: Optional[float] = None # epoch seconds; computed by the store
|
||||
last_run: Optional[float] = None
|
||||
last_status: Optional[str] = None
|
||||
run_count: int = 0
|
||||
max_runs: Optional[int] = None
|
||||
# Sidebar unread tracking (UX-023): runs started after this mark count as
|
||||
# "unseen"; opening the automation's detail advances it. 0.0 = never opened.
|
||||
seen_runs_at: float = 0.0
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
if not self.task_session_id:
|
||||
self.task_session_id = f"__task__{self.id}"
|
||||
|
||||
def to_dict(self) -> dict:
|
||||
d = self.__dict__.copy()
|
||||
d["schedule"] = self.schedule.to_dict()
|
||||
return d
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, d: dict) -> "ScheduledTask":
|
||||
d = dict(d)
|
||||
d["schedule"] = Schedule.from_dict(d.get("schedule") or {})
|
||||
return cls(**d)
|
||||
|
||||
# -- standing rules (§25) --------------------------------------------------
|
||||
def standing_rules(self) -> dict[str, set[str]]:
|
||||
"""Target-bound entries as {tool: {targets}} — the shape the permission engine
|
||||
matches against the declared target argument."""
|
||||
out: dict[str, set[str]] = {}
|
||||
for entry in self.always_allowed_tools:
|
||||
tool, target = rule_parts(entry)
|
||||
if tool and target:
|
||||
out.setdefault(tool, set()).add(target)
|
||||
return out
|
||||
|
||||
def name_allowed_tools(self) -> set[str]:
|
||||
"""Legacy name-only entries (no target binding) — back-compatible behavior."""
|
||||
return {
|
||||
tool
|
||||
for tool, target in map(rule_parts, self.always_allowed_tools)
|
||||
if tool and target is None
|
||||
}
|
||||
|
||||
def add_rule(self, tool: str, target: str) -> bool:
|
||||
entry = rule_entry(tool, target)
|
||||
if not tool or not target or entry in self.always_allowed_tools:
|
||||
return False
|
||||
self.always_allowed_tools.append(entry)
|
||||
return True
|
||||
|
||||
def revoke_rule(self, entry: str) -> bool:
|
||||
if entry in self.always_allowed_tools:
|
||||
self.always_allowed_tools.remove(entry)
|
||||
return True
|
||||
return False
|
||||
|
||||
def public(self) -> dict[str, Any]:
|
||||
"""Status shape for the API/UI (no instructions truncation; never any secret)."""
|
||||
return {
|
||||
"id": self.id,
|
||||
"title": self.title,
|
||||
"instructions": self.instructions,
|
||||
"schedule": self.schedule.human(),
|
||||
"schedule_raw": self.schedule.to_dict(),
|
||||
"workspace": self.workspace,
|
||||
"agent": self.agent,
|
||||
"enabled": self.enabled,
|
||||
"next_run": self.next_run,
|
||||
"last_run": self.last_run,
|
||||
"last_status": self.last_status,
|
||||
"run_count": self.run_count,
|
||||
"notify_on_completion": self.notify_on_completion,
|
||||
# UX-023: lets the detail freeze the pre-open mark for its "new" pills.
|
||||
"seen_runs_at": self.seen_runs_at,
|
||||
# Structured for the task page's revoke list; `entry` is the revoke handle.
|
||||
"always_allowed": [
|
||||
{"entry": e, "tool": t, "target": tg}
|
||||
for e, (t, tg) in (
|
||||
(e, rule_parts(e)) for e in sorted(set(self.always_allowed_tools))
|
||||
)
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
@dataclass
|
||||
class TaskRun:
|
||||
task_id: str
|
||||
run_id: str = field(default_factory=lambda: "run-" + uuid.uuid4().hex[:10])
|
||||
started_at: float = field(default_factory=_now)
|
||||
finished_at: Optional[float] = None
|
||||
status: str = "running" # running | ok | error | skipped
|
||||
result_text: Optional[str] = None
|
||||
artifacts: list[str] = field(default_factory=list)
|
||||
error: Optional[str] = None
|
||||
trigger: str = "schedule" # schedule | manual | catchup
|
||||
session_id: str = "" # the run's own conversation thread — persisted + continuable
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
if not self.session_id:
|
||||
self.session_id = f"__run__{self.run_id}"
|
||||
|
||||
def to_dict(self) -> dict:
|
||||
return self.__dict__.copy()
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, d: dict) -> "TaskRun":
|
||||
return cls(**d)
|
||||
@@ -0,0 +1,113 @@
|
||||
"""The scheduler loop — runs in the always-on server.
|
||||
|
||||
Policy (agreed): **run-once-catch-up** for runs missed while down (due tasks fire once on
|
||||
startup, then resume), and **skip-on-overlap** (don't stack a run if the previous is still
|
||||
going). The actual execution is injected as `runner(task, trigger) -> TaskRun` so this stays
|
||||
independent of the engine/manager.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
from typing import Awaitable, Callable, Optional
|
||||
|
||||
from .models import ScheduledTask, TaskRun
|
||||
from .store import TaskStore
|
||||
|
||||
logger = logging.getLogger("coworker.automation")
|
||||
|
||||
Runner = Callable[[ScheduledTask, str], Awaitable[TaskRun]]
|
||||
|
||||
|
||||
class Scheduler:
|
||||
def __init__(
|
||||
self,
|
||||
store: TaskStore,
|
||||
runner: Runner,
|
||||
*,
|
||||
tick_seconds: float = 30.0,
|
||||
extra_tick: Optional[Callable[[], Awaitable[None]]] = None,
|
||||
) -> None:
|
||||
self.store = store
|
||||
self.runner = runner
|
||||
self.tick_seconds = tick_seconds
|
||||
# An extra per-tick coroutine (self-wake resumption: resume sessions whose wakes are due).
|
||||
self.extra_tick = extra_tick
|
||||
self._task: Optional[asyncio.Task] = None
|
||||
self._running_ids: set[str] = set() # overlap guard
|
||||
self._spawned: set[asyncio.Task] = set() # keep spawned runs referenced
|
||||
|
||||
def start(self) -> None:
|
||||
if self._task is None:
|
||||
self._task = asyncio.create_task(self._loop())
|
||||
|
||||
async def stop(self) -> None:
|
||||
if self._task is not None:
|
||||
self._task.cancel()
|
||||
try:
|
||||
await self._task
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
self._task = None
|
||||
# In-flight runs died with the loop before they were spawned; keep that shutdown
|
||||
# contract now that they're independent tasks (a suspended run must not outlive us).
|
||||
for spawned in list(self._spawned):
|
||||
spawned.cancel()
|
||||
try:
|
||||
await spawned
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
self._spawned.clear()
|
||||
|
||||
async def _loop(self) -> None:
|
||||
# First pass = run-once-catch-up for anything missed while the server was down.
|
||||
try:
|
||||
await self._tick(trigger="catchup")
|
||||
except Exception:
|
||||
logger.exception("scheduler catch-up failed")
|
||||
while True:
|
||||
await asyncio.sleep(self.tick_seconds)
|
||||
try:
|
||||
await self._tick(trigger="schedule")
|
||||
except Exception:
|
||||
logger.exception("scheduler tick failed")
|
||||
|
||||
async def _tick(self, *, trigger: str) -> None:
|
||||
for task in self.store.due():
|
||||
# Spawn, don't await: a run can suspend on a parked approval (standing
|
||||
# scoped approvals, §25) and one blocked automation must never stall the
|
||||
# scheduler loop, other due tasks, or self-wake resumption. Overlap is
|
||||
# still guarded inside run_task via _running_ids.
|
||||
spawned = asyncio.create_task(self.run_task(task, trigger=trigger))
|
||||
self._spawned.add(spawned)
|
||||
spawned.add_done_callback(self._spawned.discard)
|
||||
if self.extra_tick is not None:
|
||||
try:
|
||||
await self.extra_tick()
|
||||
except Exception:
|
||||
logger.exception("scheduler extra_tick (wake resume) failed")
|
||||
|
||||
async def run_task(self, task: ScheduledTask, *, trigger: str) -> Optional[TaskRun]:
|
||||
if task.id in self._running_ids: # skip-on-overlap
|
||||
logger.info("skipping %s — previous run still going", task.id)
|
||||
return None
|
||||
self._running_ids.add(task.id)
|
||||
try:
|
||||
run = await self.runner(task, trigger)
|
||||
except Exception as exc:
|
||||
logger.exception("task %s run failed", task.id)
|
||||
run = TaskRun(
|
||||
task_id=task.id, status="error", error=str(exc), trigger=trigger
|
||||
)
|
||||
self.store.add_run(run)
|
||||
finally:
|
||||
self._running_ids.discard(task.id)
|
||||
# advance the task (run_count/last_run) → save recomputes next_run.
|
||||
fresh = self.store.get(task.id)
|
||||
if fresh is not None:
|
||||
fresh.run_count += 1
|
||||
fresh.last_run = run.started_at if run else None
|
||||
fresh.last_status = run.status if run else "error"
|
||||
self.store.save(fresh)
|
||||
return run
|
||||
@@ -0,0 +1,176 @@
|
||||
"""SQLite-backed store for scheduled tasks + run history.
|
||||
|
||||
Tasks/runs are stored as JSON blobs with a few indexed columns (next_run, enabled) so the
|
||||
scheduler can cheaply find what's due. `next_run` is computed with croniter, honoring the
|
||||
task's timezone. Thread-safe (check_same_thread=False + a lock) since the scheduler and the
|
||||
request handlers touch it from different threads.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import sqlite3
|
||||
import threading
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
from zoneinfo import ZoneInfo
|
||||
|
||||
from .models import ScheduledTask, TaskRun
|
||||
|
||||
|
||||
def compute_next_run(
|
||||
task: ScheduledTask, *, after: Optional[float] = None
|
||||
) -> Optional[float]:
|
||||
"""Next fire time (epoch seconds), or None if the task is exhausted/one-shot-past."""
|
||||
sched = task.schedule
|
||||
now = after if after is not None else _epoch_now()
|
||||
if sched.kind == "once":
|
||||
if not sched.fire_at:
|
||||
return None
|
||||
try:
|
||||
dt = datetime.fromisoformat(sched.fire_at)
|
||||
except ValueError:
|
||||
return None
|
||||
if dt.tzinfo is None:
|
||||
dt = dt.replace(tzinfo=_tz(sched.timezone))
|
||||
ts = dt.timestamp()
|
||||
return ts if (task.run_count == 0 and ts > now) else None
|
||||
# cron
|
||||
from croniter import croniter
|
||||
|
||||
if not sched.cron or not croniter.is_valid(sched.cron):
|
||||
return None
|
||||
if task.max_runs is not None and task.run_count >= task.max_runs:
|
||||
return None
|
||||
base = datetime.fromtimestamp(now, tz=_tz(sched.timezone))
|
||||
return croniter(sched.cron, base).get_next(datetime).timestamp()
|
||||
|
||||
|
||||
def _tz(name: str):
|
||||
"""Resolve a schedule timezone. 'local'/empty → the machine's local zone (right for a
|
||||
local-first tool: when you say '8:05 PM' you mean *your* clock, not UTC)."""
|
||||
if not name or name.lower() == "local":
|
||||
return datetime.now().astimezone().tzinfo
|
||||
try:
|
||||
return ZoneInfo(name)
|
||||
except Exception:
|
||||
return datetime.now().astimezone().tzinfo
|
||||
|
||||
|
||||
def _epoch_now() -> float:
|
||||
return datetime.now(timezone.utc).timestamp()
|
||||
|
||||
|
||||
class TaskStore:
|
||||
def __init__(self, path: str | Path) -> None:
|
||||
self.path = str(path)
|
||||
self._lock = threading.RLock()
|
||||
self._conn = sqlite3.connect(self.path, check_same_thread=False)
|
||||
self._conn.row_factory = sqlite3.Row
|
||||
self._init()
|
||||
|
||||
def _init(self) -> None:
|
||||
with self._lock:
|
||||
self._conn.executescript("""
|
||||
CREATE TABLE IF NOT EXISTS scheduled_tasks (
|
||||
id TEXT PRIMARY KEY,
|
||||
enabled INTEGER NOT NULL DEFAULT 1,
|
||||
next_run REAL,
|
||||
data TEXT NOT NULL
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS task_runs (
|
||||
run_id TEXT PRIMARY KEY,
|
||||
task_id TEXT NOT NULL,
|
||||
started_at REAL NOT NULL,
|
||||
data TEXT NOT NULL
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_runs_task ON task_runs(task_id, started_at DESC);
|
||||
""")
|
||||
self._conn.commit()
|
||||
|
||||
# -- tasks ------------------------------------------------------------------
|
||||
def save(self, task: ScheduledTask) -> ScheduledTask:
|
||||
task.updated_at = _epoch_now()
|
||||
task.next_run = compute_next_run(task) if task.enabled else None
|
||||
with self._lock:
|
||||
self._conn.execute(
|
||||
"INSERT OR REPLACE INTO scheduled_tasks (id, enabled, next_run, data) VALUES (?, ?, ?, ?)",
|
||||
(
|
||||
task.id,
|
||||
1 if task.enabled else 0,
|
||||
task.next_run,
|
||||
json.dumps(task.to_dict()),
|
||||
),
|
||||
)
|
||||
self._conn.commit()
|
||||
return task
|
||||
|
||||
def get(self, task_id: str) -> Optional[ScheduledTask]:
|
||||
with self._lock:
|
||||
row = self._conn.execute(
|
||||
"SELECT data FROM scheduled_tasks WHERE id=?", (task_id,)
|
||||
).fetchone()
|
||||
return ScheduledTask.from_dict(json.loads(row["data"])) if row else None
|
||||
|
||||
def list(self) -> list[ScheduledTask]:
|
||||
with self._lock:
|
||||
rows = self._conn.execute(
|
||||
"SELECT data FROM scheduled_tasks ORDER BY next_run IS NULL, next_run"
|
||||
).fetchall()
|
||||
return [ScheduledTask.from_dict(json.loads(r["data"])) for r in rows]
|
||||
|
||||
def delete(self, task_id: str) -> bool:
|
||||
with self._lock:
|
||||
cur = self._conn.execute(
|
||||
"DELETE FROM scheduled_tasks WHERE id=?", (task_id,)
|
||||
)
|
||||
self._conn.execute("DELETE FROM task_runs WHERE task_id=?", (task_id,))
|
||||
self._conn.commit()
|
||||
return cur.rowcount > 0
|
||||
|
||||
def due(self, *, now: Optional[float] = None) -> list[ScheduledTask]:
|
||||
now = now if now is not None else _epoch_now()
|
||||
with self._lock:
|
||||
rows = self._conn.execute(
|
||||
"SELECT data FROM scheduled_tasks WHERE enabled=1 AND next_run IS NOT NULL AND next_run<=? ORDER BY next_run",
|
||||
(now,),
|
||||
).fetchall()
|
||||
return [ScheduledTask.from_dict(json.loads(r["data"])) for r in rows]
|
||||
|
||||
# -- runs -------------------------------------------------------------------
|
||||
def add_run(self, run: TaskRun) -> TaskRun:
|
||||
with self._lock:
|
||||
self._conn.execute(
|
||||
"INSERT OR REPLACE INTO task_runs (run_id, task_id, started_at, data) VALUES (?, ?, ?, ?)",
|
||||
(run.run_id, run.task_id, run.started_at, json.dumps(run.to_dict())),
|
||||
)
|
||||
self._conn.commit()
|
||||
return run
|
||||
|
||||
def find_run(self, run_id: str) -> Optional[TaskRun]:
|
||||
with self._lock:
|
||||
row = self._conn.execute(
|
||||
"SELECT data FROM task_runs WHERE run_id=?", (run_id,)
|
||||
).fetchone()
|
||||
return TaskRun.from_dict(json.loads(row["data"])) if row else None
|
||||
|
||||
def task_for_run_session(self, session_id: str) -> Optional[ScheduledTask]:
|
||||
"""The owning task of a run session ('__run__<run_id>'), or None. How standing
|
||||
scoped approvals resolve which automation a live approval belongs to (§25)."""
|
||||
if not session_id.startswith("__run__"):
|
||||
return None
|
||||
run = self.find_run(session_id[len("__run__") :])
|
||||
return self.get(run.task_id) if run else None
|
||||
|
||||
def runs(self, task_id: str, *, limit: int = 50) -> list[TaskRun]:
|
||||
with self._lock:
|
||||
rows = self._conn.execute(
|
||||
"SELECT data FROM task_runs WHERE task_id=? ORDER BY started_at DESC LIMIT ?",
|
||||
(task_id, limit),
|
||||
).fetchall()
|
||||
return [TaskRun.from_dict(json.loads(r["data"])) for r in rows]
|
||||
|
||||
def close(self) -> None:
|
||||
with self._lock:
|
||||
self._conn.close()
|
||||
@@ -0,0 +1,233 @@
|
||||
"""Agent-facing scheduling tools (Cowork + MyHelper).
|
||||
|
||||
`create_scheduled_task` is gated (`requires_approval`) so it surfaces a confirm card before a
|
||||
standing automation is created (approve-at-creation). The agent converts natural language
|
||||
("7:10pm everyday") into a cron string itself. Tools are origin-bound: a created task records
|
||||
the launching session and runs in its workspace, so the origin conversation can read the
|
||||
results (the artifacts are real files in that folder).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, Callable, Optional
|
||||
|
||||
import aisuite as ai
|
||||
|
||||
from .models import Schedule, ScheduledTask, grant_entries
|
||||
from .store import TaskStore
|
||||
|
||||
_CREATE_SCHEMA = {
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "create_scheduled_task",
|
||||
"description": (
|
||||
"Create a scheduled automation that re-runs `instructions` on a schedule. Convert "
|
||||
"the user's natural-language timing into a cron expression yourself (e.g. "
|
||||
"'every day at 7:10pm' → '10 19 * * *'), or pass a one-time `fire_at` ISO datetime. "
|
||||
"The user confirms before it is created."
|
||||
),
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"title": {
|
||||
"type": "string",
|
||||
"description": "Short label, e.g. 'Daily news briefing'.",
|
||||
},
|
||||
"instructions": {
|
||||
"type": "string",
|
||||
"description": (
|
||||
"What to do on each run, written as a direct command to execute "
|
||||
"immediately (e.g. 'Prepare a market analysis report covering …'). Do "
|
||||
"NOT restate the schedule or timing here — timing belongs in cron/"
|
||||
"fire_at; this text is handed verbatim to the agent every run."
|
||||
),
|
||||
},
|
||||
"cron": {
|
||||
"type": "string",
|
||||
"description": "5-field cron, e.g. '10 19 * * *'. Omit for one-time.",
|
||||
},
|
||||
"fire_at": {
|
||||
"type": "string",
|
||||
"description": "ISO datetime for a one-time run. Omit for recurring.",
|
||||
},
|
||||
"timezone": {
|
||||
"type": "string",
|
||||
"description": "IANA tz, e.g. 'America/New_York'. Defaults to the machine's local time — pass it only to override.",
|
||||
},
|
||||
"permissions": {
|
||||
"type": "array",
|
||||
"description": (
|
||||
"What this automation will touch, surfaced on the creation consent "
|
||||
"card. List every external read and write the instructions imply. "
|
||||
"Reads (access:'read') are disclosure only. Writes (access:'write') "
|
||||
"become standing grants IF the user approves: the automation may then "
|
||||
"call that exact tool against that exact target without asking each "
|
||||
"run. Targets must be exact (a channel address like 'slack:T…/C…', a "
|
||||
"recipient) — no wildcards. Omit writes whose target you don't know "
|
||||
"yet; the run will ask instead."
|
||||
),
|
||||
"items": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"tool": {
|
||||
"type": "string",
|
||||
"description": "Exact tool name, e.g. 'send_message'.",
|
||||
},
|
||||
"target": {
|
||||
"type": "string",
|
||||
"description": "The exact target argument value the rule binds to.",
|
||||
},
|
||||
"access": {
|
||||
"type": "string",
|
||||
"enum": ["read", "write"],
|
||||
"description": "'write' proposes a standing grant; 'read' is disclosure.",
|
||||
},
|
||||
},
|
||||
"required": ["tool", "target", "access"],
|
||||
},
|
||||
},
|
||||
},
|
||||
"required": ["title", "instructions"],
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
_UPDATE_SCHEMA = {
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "update_scheduled_task",
|
||||
"description": "Enable/disable or edit a scheduled task (its instructions, cron, or title).",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"id": {"type": "string"},
|
||||
"enabled": {"type": "boolean"},
|
||||
"instructions": {"type": "string"},
|
||||
"cron": {"type": "string"},
|
||||
"title": {"type": "string"},
|
||||
},
|
||||
"required": ["id"],
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
_ID_SCHEMA = {
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "delete_scheduled_task",
|
||||
"description": "Delete a scheduled task and its run history.",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {"id": {"type": "string"}},
|
||||
"required": ["id"],
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
_LIST_SCHEMA = {
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "list_scheduled_tasks",
|
||||
"description": "List the user's scheduled tasks (title, schedule, next run, status).",
|
||||
"parameters": {"type": "object", "properties": {}},
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def _gated(func: Callable, schema: dict, *, approval: bool) -> Callable:
|
||||
func.__name__ = schema["function"]["name"]
|
||||
func.__doc__ = schema["function"]["description"]
|
||||
func.__aisuite_tool_metadata__ = ai.ToolMetadata(
|
||||
name=schema["function"]["name"],
|
||||
category="automation",
|
||||
risk_level="medium" if approval else "low",
|
||||
capabilities=["scheduling"],
|
||||
requires_approval=approval,
|
||||
)
|
||||
func.__coworker_schema__ = schema
|
||||
return func
|
||||
|
||||
|
||||
def scheduling_tools(
|
||||
store: TaskStore,
|
||||
*,
|
||||
origin: dict[str, Any],
|
||||
default_workspace: str,
|
||||
) -> list[Callable[..., Any]]:
|
||||
def create_scheduled_task(
|
||||
title, instructions, cron=None, fire_at=None, timezone="local", permissions=None
|
||||
):
|
||||
from croniter import croniter
|
||||
|
||||
if not cron and not fire_at:
|
||||
return {
|
||||
"error": "provide a cron (recurring) or a fire_at ISO datetime (one-time)"
|
||||
}
|
||||
if cron and not croniter.is_valid(cron):
|
||||
return {"error": f"invalid cron expression: {cron}"}
|
||||
schedule = Schedule(
|
||||
kind="once" if (fire_at and not cron) else "cron",
|
||||
cron=cron,
|
||||
fire_at=fire_at,
|
||||
timezone=timezone or "local",
|
||||
)
|
||||
workspace = origin.get("workspace") or default_workspace
|
||||
# The agent PROPOSES permissions; the human granted them by approving this gated
|
||||
# call (the consent card rendered the proposal). Only validated write grants stick:
|
||||
# tool must declare a target argument (never exec/destructive), target non-empty.
|
||||
grants = grant_entries(permissions)
|
||||
task = ScheduledTask(
|
||||
title=title,
|
||||
instructions=instructions,
|
||||
schedule=schedule,
|
||||
workspace=workspace,
|
||||
origin_surface=origin.get("surface", "cowork"),
|
||||
origin_session_id=origin.get("session_id", ""),
|
||||
agent=origin.get("agent", "cowork"),
|
||||
always_allowed_tools=grants,
|
||||
)
|
||||
store.save(task)
|
||||
return {
|
||||
"ok": True,
|
||||
"id": task.id,
|
||||
"title": title,
|
||||
"schedule": schedule.human(),
|
||||
"next_run": task.next_run,
|
||||
"workspace": workspace,
|
||||
"always_allowed": grants,
|
||||
}
|
||||
|
||||
def list_scheduled_tasks():
|
||||
return {"tasks": [t.public() for t in store.list()]}
|
||||
|
||||
def update_scheduled_task(
|
||||
id, enabled=None, instructions=None, cron=None, title=None
|
||||
):
|
||||
from croniter import croniter
|
||||
|
||||
task = store.get(id)
|
||||
if task is None:
|
||||
return {"error": f"no such task: {id}"}
|
||||
if cron is not None:
|
||||
if not croniter.is_valid(cron):
|
||||
return {"error": f"invalid cron expression: {cron}"}
|
||||
task.schedule.cron = cron
|
||||
task.schedule.kind = "cron"
|
||||
if enabled is not None:
|
||||
task.enabled = bool(enabled)
|
||||
if instructions is not None:
|
||||
task.instructions = instructions
|
||||
if title is not None:
|
||||
task.title = title
|
||||
store.save(task)
|
||||
return {"ok": True, "task": task.public()}
|
||||
|
||||
def delete_scheduled_task(id):
|
||||
return {"ok": store.delete(id), "id": id}
|
||||
|
||||
return [
|
||||
_gated(create_scheduled_task, _CREATE_SCHEMA, approval=True),
|
||||
_gated(list_scheduled_tasks, _LIST_SCHEMA, approval=False),
|
||||
_gated(update_scheduled_task, _UPDATE_SCHEMA, approval=True),
|
||||
_gated(delete_scheduled_task, _ID_SCHEMA, approval=True),
|
||||
]
|
||||
Reference in New Issue
Block a user