mirror of
https://github.com/bytedance/deer-flow.git
synced 2026-10-27 21:50:36 +00:00
* feat(scheduler): create conversation schedules and support agent self-stop * fix(scheduler): validate goals and trial requests before dispatch --------- Co-authored-by: Totoro-qaq <279883115+Totoro-qaq@users.noreply.github.com>
308 lines
18 KiB
Python
308 lines
18 KiB
Python
"""Bind scheduler operations to one authenticated owner and originating run."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import asyncio
|
|
import uuid
|
|
from collections.abc import Mapping
|
|
from dataclasses import dataclass
|
|
from datetime import UTC, datetime
|
|
from typing import Any
|
|
|
|
from fastapi import HTTPException, Request
|
|
from pydantic import AwareDatetime, BaseModel, Field, field_validator
|
|
|
|
from app.gateway.auth_disabled import AUTH_DISABLED_USER_ID, AUTH_SOURCE_AUTH_DISABLED, AUTH_SOURCE_INTERNAL, AUTH_SOURCE_SESSION, get_auth_disabled_user, is_auth_disabled
|
|
from app.gateway.authz import resolve_route_permissions
|
|
from app.gateway.deps import get_config, get_local_provider, get_scheduled_task_repo, get_scheduled_task_run_repo, get_scheduled_task_service, get_thread_store
|
|
from app.gateway.internal_auth import INTERNAL_SYSTEM_ROLE
|
|
from app.gateway.scheduled_task_validation import validate_scheduled_task_create
|
|
from deerflow.agents.interaction_policy import RunInteractionMode, RunInteractionPolicy
|
|
from deerflow.persistence.scheduled_tasks import ActiveScheduledTaskMutationConflict, ScheduledTaskQuotaExceeded
|
|
from deerflow.scheduler.runtime import SchedulerCapabilityMode, SchedulerRunCapability, scheduler_tools_enabled
|
|
from deerflow.utils.goal_objective import normalize_goal_objective
|
|
|
|
_MANAGE_PERMISSIONS = {
|
|
"create": frozenset({"threads:write", "runs:create"}),
|
|
"list": frozenset({"threads:read"}),
|
|
"pause": frozenset({"threads:write"}),
|
|
"delete": frozenset({"threads:write"}),
|
|
"note": frozenset({"threads:write"}),
|
|
"trial": frozenset({"threads:write", "runs:create"}),
|
|
}
|
|
_CREATE_FIELDS = frozenset({"title", "prompt", "schedule_type", "schedule_spec", "timezone", "context_mode", "goal_objective", "max_runs", "end_at"})
|
|
_PUBLIC_FIELDS = ("id", "title", "status", "context_mode", "goal_objective", "schedule_type", "schedule_spec", "timezone", "next_run_at", "max_runs", "end_at", "standing_notes")
|
|
_STOP_INSTRUCTIONS = "Pause on the scheduled tasks page, or ask to stop this task in the conversation where it was created."
|
|
|
|
|
|
class _TaskBounds(BaseModel):
|
|
goal_objective: str | None = Field(default=None, min_length=1)
|
|
max_runs: int | None = Field(default=None, ge=1, strict=True)
|
|
end_at: AwareDatetime | None = None
|
|
|
|
@field_validator("goal_objective")
|
|
@classmethod
|
|
def validate_goal_objective(cls, value: str | None) -> str | None:
|
|
if value is not None:
|
|
normalize_goal_objective(value)
|
|
return value
|
|
|
|
|
|
def _has_explicit_trial_request(user_text: str, task: Mapping[str, Any]) -> bool:
|
|
"""Accept bounded direct-run turns, never a keyword in arbitrary user text.
|
|
|
|
This is deliberately not a general intent parser. Quoted, conditional,
|
|
compound or bare-confirmation turns require a new direct user request.
|
|
"""
|
|
if not user_text or len(user_text) > 500:
|
|
return False
|
|
command = " ".join(user_text.strip().rstrip(".!。!").split()).casefold()
|
|
for prefix in ("yes, please ", "yes, ", "please ", "可以,请", "可以,", "好,", "请"):
|
|
if command.startswith(prefix):
|
|
command = command[len(prefix) :].strip()
|
|
break
|
|
targets = {"it", "this task"}
|
|
# Titles are arbitrary prose and may contain quoted/conditional clauses.
|
|
# Only an opaque selector can safely be interpolated into a direct request.
|
|
task_id = task.get("id")
|
|
if isinstance(task_id, str) and 5 < len(task_id) <= 64 and task_id.startswith("task-") and task_id.isascii() and task_id.replace("-", "").replace("_", "").isalnum():
|
|
targets.add(task_id.casefold())
|
|
direct_requests = {f"run {target} {timing}" for target in targets for timing in ("now", "once", "once now")}
|
|
direct_requests.update({"run a trial now", "先跑一次", "现在跑一次", "先试跑一次", "现在试跑一次", "先运行一次", "现在运行一次"})
|
|
for target in targets - {"it", "this task"}:
|
|
direct_requests.update({f"先试跑{target}", f"现在试跑{target}", f"先运行{target}一次", f"现在运行{target}一次"})
|
|
return command in direct_requests
|
|
|
|
|
|
def _public_task(task: dict[str, Any]) -> dict[str, Any]:
|
|
return {**{key: task.get(key) for key in _PUBLIC_FIELDS}, "stop_instructions": _STOP_INSTRUCTIONS}
|
|
|
|
|
|
def _error(exc: HTTPException) -> dict[str, Any]:
|
|
return {"error": exc.detail, "status_code": exc.status_code}
|
|
|
|
|
|
async def _owner(owner_id: str, token_version: int, *, auth_disabled_owner: bool) -> Any | None:
|
|
if auth_disabled_owner:
|
|
user = get_auth_disabled_user() if is_auth_disabled() and owner_id == AUTH_DISABLED_USER_ID else None
|
|
else:
|
|
user = await get_local_provider().get_user(owner_id)
|
|
if user is None or str(user.id) != owner_id or getattr(user, "token_version", 0) != token_version or getattr(user, "needs_setup", False) or getattr(user, "system_role", None) == INTERNAL_SYSTEM_ROLE:
|
|
return None
|
|
return user
|
|
|
|
|
|
@dataclass(frozen=True, repr=False)
|
|
class _SchedulerCapability:
|
|
mode: SchedulerCapabilityMode
|
|
_owner_id: str
|
|
_token_version: int
|
|
_auth_disabled_owner: bool
|
|
_permission_ceiling: frozenset[str]
|
|
_origin_thread_id: str
|
|
_run_id: str
|
|
_assistant_id: str
|
|
_original_user_text: str
|
|
_task_repo: Any
|
|
_task_run_repo: Any
|
|
_thread_store: Any
|
|
_service: Any
|
|
_task_id: str | None = None
|
|
_occurrence_id: str | None = None
|
|
|
|
async def _require(self, permissions: frozenset[str], *, check_origin: bool = True) -> None:
|
|
if not scheduler_tools_enabled(await asyncio.to_thread(get_config)):
|
|
raise HTTPException(status_code=503, detail="Conversation schedule tools are disabled")
|
|
user = await _owner(self._owner_id, self._token_version, auth_disabled_owner=self._auth_disabled_owner)
|
|
if user is None:
|
|
raise HTTPException(status_code=401, detail="Schedule authority is no longer authenticated")
|
|
effective = frozenset(await resolve_route_permissions(user, is_internal=False)) & self._permission_ceiling
|
|
if not permissions <= effective:
|
|
raise HTTPException(status_code=403, detail="Permission denied for this scheduled task action")
|
|
if check_origin:
|
|
origin = await self._thread_store.get(self._origin_thread_id, user_id=self._owner_id)
|
|
if origin is None or origin.get("user_id") != self._owner_id:
|
|
raise HTTPException(status_code=404, detail="Originating conversation not found")
|
|
|
|
async def _task(self, task_id: Any) -> dict[str, Any]:
|
|
if not isinstance(task_id, str) or not task_id:
|
|
raise HTTPException(status_code=422, detail="task_id is required")
|
|
task = await self._task_repo.get(task_id, user_id=self._owner_id)
|
|
if task is None or task.get("user_id") != self._owner_id or task.get("origin_thread_id") != self._origin_thread_id:
|
|
raise HTTPException(status_code=404, detail="Scheduled task not found")
|
|
return task
|
|
|
|
async def manage(self, *, action: str, request: dict[str, Any]) -> dict[str, Any]:
|
|
try:
|
|
if self.mode != "interactive":
|
|
raise HTTPException(status_code=403, detail="Schedule management requires an interactive run")
|
|
if action not in _MANAGE_PERMISSIONS:
|
|
raise HTTPException(status_code=422, detail="Unsupported scheduled task action")
|
|
await self._require(_MANAGE_PERMISSIONS[action])
|
|
if action == "create":
|
|
return await self._create(request)
|
|
allowed = {"task_id", "note"} if action == "note" else {"task_id"} if action != "list" else set()
|
|
if not isinstance(request, dict) or request.keys() - allowed:
|
|
raise HTTPException(status_code=422, detail="Unsupported scheduled task fields")
|
|
if action == "list":
|
|
tasks = await self._task_repo.list_by_origin_thread(self._owner_id, self._origin_thread_id)
|
|
return {"tasks": [_public_task(task) for task in tasks if task.get("user_id") == self._owner_id and task.get("origin_thread_id") == self._origin_thread_id]}
|
|
task = await self._task(request.get("task_id"))
|
|
task_id = task["id"]
|
|
if action == "note":
|
|
note = request.get("note")
|
|
if not isinstance(note, str) or not note.strip() or len(note) > 500 or note not in self._original_user_text:
|
|
raise HTTPException(status_code=422, detail="A standing note must be the current user's explicit words verbatim, at most 500 characters")
|
|
updated = await self._task_repo.append_standing_note(task_id, user_id=self._owner_id, origin_thread_id=self._origin_thread_id, note=note)
|
|
if updated is None:
|
|
raise HTTPException(status_code=404, detail="Scheduled task not found")
|
|
return _public_task(updated)
|
|
if action == "trial":
|
|
if not _has_explicit_trial_request(self._original_user_text, task):
|
|
raise HTTPException(status_code=403, detail="A trial requires a direct request in the current user turn, such as 'Run this task now' or '先跑一次'; a task mention or bare confirmation is insufficient")
|
|
result = await self._service.dispatch_task(task, now=datetime.now(UTC), trigger="manual")
|
|
status = {"not_found": 404, "conflict": 409, "failed": 502}.get(result.get("outcome"))
|
|
if status is not None:
|
|
raise HTTPException(status_code=status, detail=result.get("error") or "Scheduled task trial could not be dispatched")
|
|
return {"id": task_id, "triggered": True, "outcome": result.get("outcome"), "run_id": result.get("run_id")}
|
|
if action == "pause" and task.get("status") == "running":
|
|
raise HTTPException(status_code=409, detail="Scheduled task is currently running; retry after the active execution finishes")
|
|
operation = self._task_repo.pause_with_queue_cancellation if action == "pause" else self._task_repo.delete_with_queue_cancellation
|
|
outcome = await operation(task_id, user_id=self._owner_id, error=f"scheduled task was {('paused' if action == 'pause' else 'deleted')} while queued", now=datetime.now(UTC))
|
|
if outcome == "not_found":
|
|
raise HTTPException(status_code=404, detail="Scheduled task not found")
|
|
if outcome == "executing":
|
|
raise HTTPException(status_code=409, detail="Scheduled task is already launching or running; retry after the active execution finishes")
|
|
return {"id": task_id, "paused" if action == "pause" else "deleted": True}
|
|
except HTTPException as exc:
|
|
return _error(exc)
|
|
except ActiveScheduledTaskMutationConflict as exc:
|
|
return {"error": f"Scheduled task has an active {exc.status} occurrence; retry after it finishes", "status_code": 409}
|
|
except ScheduledTaskQuotaExceeded as exc:
|
|
return {"error": str(exc), "status_code": 409}
|
|
except ValueError as exc:
|
|
return {"error": str(exc), "status_code": 422}
|
|
|
|
async def _create(self, request: dict[str, Any]) -> dict[str, Any]:
|
|
from app.gateway.routers.scheduled_tasks import ScheduledTaskCreateRequest, resolve_scheduled_task_assistant_id
|
|
|
|
if not isinstance(request, dict) or request.keys() - _CREATE_FIELDS:
|
|
raise HTTPException(status_code=422, detail="Unsupported scheduled task fields")
|
|
bounds = _TaskBounds.model_validate(request)
|
|
if bounds.goal_objective is not None and not bounds.goal_objective.strip():
|
|
raise HTTPException(status_code=422, detail="goal_objective must not be blank")
|
|
body = ScheduledTaskCreateRequest(
|
|
**{key: value for key, value in request.items() if key not in {"goal_objective", "max_runs", "end_at"}},
|
|
assistant_id=self._assistant_id,
|
|
thread_id=self._origin_thread_id if request.get("context_mode") == "reuse_thread" else None,
|
|
)
|
|
reference = datetime.now(UTC)
|
|
config = await asyncio.to_thread(get_config)
|
|
definition = await validate_scheduled_task_create(body, user_id=self._owner_id, thread_store=self._thread_store, scheduler_config=config.scheduler, assistant_resolver=resolve_scheduled_task_assistant_id, now=reference)
|
|
if bounds.goal_objective is not None and body.context_mode != "fresh_thread_per_run":
|
|
raise HTTPException(status_code=422, detail="goal-backed schedules require fresh_thread_per_run")
|
|
if bounds.end_at is not None and bounds.end_at <= reference:
|
|
raise HTTPException(status_code=422, detail="end_at must be in the future")
|
|
frequent = body.schedule_type == "interval" and definition["schedule_spec"]["every_seconds"] < 3600
|
|
if body.schedule_type == "cron":
|
|
frequent = not definition["schedule_spec"]["cron"].split()[0].isascii() or not definition["schedule_spec"]["cron"].split()[0].isdigit()
|
|
if frequent and bounds.max_runs is None and bounds.end_at is None:
|
|
raise HTTPException(status_code=422, detail="Schedules more frequent than hourly require max_runs or end_at")
|
|
if bounds.end_at is not None and definition["next_run_at"] is not None and bounds.end_at <= definition["next_run_at"]:
|
|
raise HTTPException(status_code=422, detail="end_at must allow at least one scheduled occurrence")
|
|
task = await self._task_repo.create(
|
|
task_id=f"task-{uuid.uuid4().hex}", user_id=self._owner_id, origin_thread_id=self._origin_thread_id, goal_objective=bounds.goal_objective, max_runs=bounds.max_runs, end_at=bounds.end_at, standing_notes=None, **definition
|
|
)
|
|
return _public_task(task)
|
|
|
|
async def stop_current_schedule(self) -> dict[str, Any]:
|
|
try:
|
|
if self.mode != "scheduled" or not self._task_id or not self._occurrence_id:
|
|
raise HTTPException(status_code=403, detail="This run has no own-schedule stop authority")
|
|
await self._require(frozenset({"threads:write"}), check_origin=False)
|
|
task = await self._task_repo.get(self._task_id, user_id=self._owner_id)
|
|
if task is None or task.get("user_id") != self._owner_id or task.get("origin_thread_id") != self._origin_thread_id:
|
|
raise HTTPException(status_code=404, detail="Scheduled task not found")
|
|
requested = await self._task_run_repo.request_stop(self._occurrence_id, task_id=self._task_id, run_id=self._run_id, user_id=self._owner_id)
|
|
if not requested:
|
|
raise HTTPException(status_code=409, detail="The scheduled occurrence is no longer active or does not match this run")
|
|
return {"id": self._task_id, "stop_requested": True, "message": "The current occurrence continues; its schedule will pause when this occurrence finishes."}
|
|
except HTTPException as exc:
|
|
return _error(exc)
|
|
|
|
|
|
async def prepare_scheduler_capability(
|
|
request: Request,
|
|
*,
|
|
user: Any,
|
|
thread_id: str,
|
|
run_id: str,
|
|
assistant_id: str,
|
|
original_user_text: str,
|
|
interaction_policy: RunInteractionPolicy,
|
|
scheduled_task_runtime: Mapping[str, Any] | None = None,
|
|
) -> SchedulerRunCapability | None:
|
|
"""Bind private host authority; do not retain the Request or internal user."""
|
|
source = getattr(getattr(request, "state", None), "auth_source", None)
|
|
if source not in {AUTH_SOURCE_SESSION, AUTH_SOURCE_INTERNAL, AUTH_SOURCE_AUTH_DISABLED} or user is None or getattr(user, "system_role", None) == INTERNAL_SYSTEM_ROLE:
|
|
return None
|
|
if not scheduler_tools_enabled(await asyncio.to_thread(get_config)):
|
|
return None
|
|
owner_id = str(user.id)
|
|
auth_disabled_owner = owner_id == AUTH_DISABLED_USER_ID and is_auth_disabled()
|
|
if source == AUTH_SOURCE_AUTH_DISABLED and not auth_disabled_owner:
|
|
return None
|
|
token_version = getattr(user, "token_version", 0)
|
|
current = await _owner(owner_id, token_version, auth_disabled_owner=auth_disabled_owner)
|
|
if current is None:
|
|
return None
|
|
ceiling = frozenset(await resolve_route_permissions(current, is_internal=False))
|
|
if source != AUTH_SOURCE_INTERNAL:
|
|
auth = getattr(request.state, "auth", None)
|
|
request_user = getattr(request.state, "user", None)
|
|
if auth is None or not auth.is_authenticated or request_user is None or str(request_user.id) != owner_id or str(auth.user.id) != owner_id:
|
|
return None
|
|
ceiling &= frozenset(auth.permissions)
|
|
try:
|
|
repo = get_scheduled_task_repo(request)
|
|
occurrences = get_scheduled_task_run_repo(request)
|
|
threads = get_thread_store(request)
|
|
service = get_scheduled_task_service(request)
|
|
except HTTPException:
|
|
return None
|
|
mode = interaction_policy.mode
|
|
task_id = occurrence_id = None
|
|
if mode is RunInteractionMode.INTERACTIVE:
|
|
origin_thread_id = thread_id
|
|
origin = await threads.get(origin_thread_id, user_id=owner_id)
|
|
if origin is None or origin.get("user_id") != owner_id:
|
|
return None
|
|
elif mode is RunInteractionMode.SCHEDULED and source == AUTH_SOURCE_INTERNAL and isinstance(scheduled_task_runtime, Mapping):
|
|
task_id = scheduled_task_runtime.get("task_id")
|
|
occurrence_id = scheduled_task_runtime.get("occurrence_id")
|
|
if scheduled_task_runtime.get("user_id") != owner_id or not isinstance(task_id, str) or not isinstance(occurrence_id, str):
|
|
return None
|
|
task = await repo.get(task_id, user_id=owner_id)
|
|
if task is None or task.get("user_id") != owner_id or not isinstance(task.get("origin_thread_id"), str) or not task["origin_thread_id"]:
|
|
return None
|
|
origin_thread_id = task["origin_thread_id"]
|
|
else:
|
|
return None
|
|
return _SchedulerCapability(
|
|
mode=mode.value,
|
|
_owner_id=owner_id,
|
|
_token_version=token_version,
|
|
_auth_disabled_owner=auth_disabled_owner,
|
|
_permission_ceiling=ceiling,
|
|
_origin_thread_id=origin_thread_id,
|
|
_run_id=run_id,
|
|
_assistant_id=assistant_id,
|
|
_original_user_text=original_user_text if isinstance(original_user_text, str) else "",
|
|
_task_repo=repo,
|
|
_task_run_repo=occurrences,
|
|
_thread_store=threads,
|
|
_service=service,
|
|
_task_id=task_id,
|
|
_occurrence_id=occurrence_id,
|
|
)
|