From 69f0f483eb6cda2ce75c5633a6c4f48789ab2fa0 Mon Sep 17 00:00:00 2001 From: wutongyuonce <147830929+wutongyuonce@users.noreply.github.com> Date: Thu, 10 Sep 2026 15:36:49 +0800 Subject: [PATCH] feat(scheduler): let scheduled tasks pin a custom agent (#5288) * feat(scheduler): let scheduled tasks pin a custom agent Create and update accept optional assistant_id, defaulting to lead_agent. Custom names are normalized and must already exist for the task owner. The workspace form exposes the same choice, and duplicate copies it. Fixes #5286 * fix(scheduler): keep assistant-id PR free of interval tests Drop the six interval tests that belonged to the interval schedule PR and fail here because this tree still only accepts once/cron. Treat lead_agent case-insensitively so LEAD_AGENT / lead-agent store as the default. Omit unchanged assistant_id on edit so a deleted custom agent does not 422 unrelated PATCH (rename, reschedule). * fix(scheduler): format task page and browser tests --------- Co-authored-by: Willem Jiang --- CHANGELOG.md | 4 + README.md | 1 + README_zh.md | 1 + .../app/gateway/routers/scheduled_tasks.py | 47 +++- backend/docs/CONFIGURATION.md | 1 + .../test_scheduled_task_router_behavior.py | 217 +++++++++++++++++- .../app/workspace/scheduled-tasks/page.tsx | 119 +++++++++- frontend/src/core/i18n/locales/en-US.ts | 3 + frontend/src/core/i18n/locales/types.ts | 3 + frontend/src/core/i18n/locales/zh-CN.ts | 3 + frontend/src/core/scheduled-tasks/api.ts | 1 + frontend/src/core/scheduled-tasks/types.ts | 1 + frontend/tests/e2e/scheduled-tasks.spec.ts | 121 ++++++++++ frontend/tests/e2e/utils/mock-api.ts | 5 + .../unit/core/scheduled-tasks/hooks.test.ts | 1 + 15 files changed, 522 insertions(+), 6 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c5e2cdea9..03870f398 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -118,6 +118,10 @@ This section accumulates work toward the **2.1.0** milestone #### Agents & runtime +- **scheduler:** Scheduled tasks can pin `assistant_id` to `lead_agent` (the + default) or a custom agent the owner already has. Unknown or malformed names + return 422. The workspace create/edit form exposes the same choice. + ([#5286]) - **gateway:** `GET /api/threads/{thread_id}/runs/page` walks thread run history with a `(created_at, run_id)` keyset cursor (`{data, has_more, next_before_created_at, next_before_run_id}`). `GET /api/threads/{thread_id}/runs` diff --git a/README.md b/README.md index 33dc6c5c6..606834a1e 100644 --- a/README.md +++ b/README.md @@ -1533,6 +1533,7 @@ Current MVP capabilities: - Manage tasks at `/workspace/scheduled-tasks` - Choose whether each scheduled task reuses a thread and its conversation history or creates a fresh thread per run +- Pin each task to `lead_agent` (default) or a custom agent the owner already has; unknown names are rejected - Duplicate an existing task into the create form as an editable draft without copying its run history - Support `once` and `cron` schedules - Run background scheduled executions as non-interactive DeerFlow runs (`ask_clarification` is not exposed there) diff --git a/README_zh.md b/README_zh.md index 1466bf045..c9e50d699 100644 --- a/README_zh.md +++ b/README_zh.md @@ -816,6 +816,7 @@ DeerFlow 现在在 workspace 里内置了一个一等的定时任务(scheduled - 在 `/workspace/scheduled-tasks` 管理任务 - 每个定时任务可以选择复用同一个 thread 及其历史对话,也可以选择每次运行新建一个 thread +- 每个任务可以固定使用 `lead_agent`(默认)或当前用户已有的自定义 agent;未知名字会被拒绝 - 将现有任务复制到创建表单中作为可编辑草稿,不复制运行历史 - 支持 `once` 和 `cron` 两种调度方式 - 后台定时执行以非交互式 DeerFlow run 运行(那里不会暴露 `ask_clarification`) diff --git a/backend/app/gateway/routers/scheduled_tasks.py b/backend/app/gateway/routers/scheduled_tasks.py index 496155763..92baa6935 100644 --- a/backend/app/gateway/routers/scheduled_tasks.py +++ b/backend/app/gateway/routers/scheduled_tasks.py @@ -1,5 +1,6 @@ from __future__ import annotations +import asyncio import uuid from datetime import UTC, datetime from typing import Any @@ -16,6 +17,7 @@ from app.gateway.deps import ( get_scheduled_task_service, get_thread_store, ) +from deerflow.config.agents_config import AGENT_NAME_PATTERN, load_agent_config from deerflow.persistence.scheduled_tasks import ActiveScheduledTaskMutationConflict from deerflow.scheduler.schedules import ( next_run_at as compute_next_run_at, @@ -28,6 +30,8 @@ from deerflow.utils.thread_id import ThreadId router = APIRouter(prefix="/api", tags=["scheduled-tasks"]) +_DEFAULT_ASSISTANT_ID = "lead_agent" + def _active_occurrence_conflict_detail(status: str) -> str: detail = f"Scheduled task has an active {status} occurrence; retry after it finishes" @@ -36,6 +40,36 @@ def _active_occurrence_conflict_detail(status: str) -> str: return detail +async def resolve_scheduled_task_assistant_id(raw: str | None, *, user_id: str) -> str: + """Return a stored assistant id, defaulting to lead_agent. + + Custom names are normalized the same way IM/run creation already does + (lowercase, underscore to hyphen) and must exist for this owner. + """ + if raw is None: + return _DEFAULT_ASSISTANT_ID + value = raw.strip() + if not value: + raise HTTPException(status_code=422, detail="assistant_id must not be empty") + normalized = value.lower().replace("_", "-") + if normalized == _DEFAULT_ASSISTANT_ID.replace("_", "-"): + return _DEFAULT_ASSISTANT_ID + if not AGENT_NAME_PATTERN.fullmatch(normalized): + raise HTTPException( + status_code=422, + detail=(f"Invalid assistant_id {raw!r}. Use 'lead_agent' or a custom agent name containing only letters, digits, and hyphens."), + ) + try: + config = await asyncio.to_thread(load_agent_config, normalized, user_id=user_id) + except FileNotFoundError as exc: + raise HTTPException(status_code=422, detail=f"Unknown assistant_id {raw!r}") from exc + except ValueError as exc: + raise HTTPException(status_code=422, detail=str(exc)) from exc + if config is None: + raise HTTPException(status_code=422, detail=f"Unknown assistant_id {raw!r}") + return normalized + + async def _ensure_task_mutable(task: dict[str, Any], repo) -> None: if task.get("status") == "running": raise HTTPException( @@ -53,6 +87,7 @@ async def _ensure_task_mutable(task: dict[str, Any], repo) -> None: class ScheduledTaskCreateRequest(BaseModel): thread_id: ThreadId | None = None context_mode: str = "fresh_thread_per_run" + assistant_id: str | None = Field(default=None, min_length=1) title: str = Field(min_length=1) prompt: str = Field(min_length=1) schedule_type: str @@ -63,6 +98,7 @@ class ScheduledTaskCreateRequest(BaseModel): class ScheduledTaskUpdateRequest(BaseModel): context_mode: str | None = None thread_id: ThreadId | None = None + assistant_id: str | None = Field(default=None, min_length=1) title: str | None = Field(default=None, min_length=1) prompt: str | None = Field(default=None, min_length=1) schedule_spec: dict[str, Any] | None = None @@ -124,12 +160,16 @@ async def create_scheduled_task(request: Request, body: ScheduledTaskCreateReque detail=(f"once schedule must be at least {config.scheduler.min_once_delay_seconds} seconds in the future"), ) + assistant_id = await resolve_scheduled_task_assistant_id( + body.assistant_id, + user_id=str(user.id), + ) return await repo.create( task_id=f"task-{uuid.uuid4().hex}", user_id=str(user.id), thread_id=body.thread_id, context_mode=body.context_mode, - assistant_id="lead_agent", + assistant_id=assistant_id, title=body.title, prompt=body.prompt, schedule_type=body.schedule_type, @@ -167,6 +207,11 @@ async def update_scheduled_task(task_id: str, request: Request, body: ScheduledT await _ensure_task_mutable(existing, repo) updates = body.model_dump(exclude_none=True) + if "assistant_id" in updates: + updates["assistant_id"] = await resolve_scheduled_task_assistant_id( + updates["assistant_id"], + user_id=str(user.id), + ) if "context_mode" in updates: if updates["context_mode"] not in {"fresh_thread_per_run", "reuse_thread"}: raise HTTPException(status_code=422, detail="Unsupported context_mode") diff --git a/backend/docs/CONFIGURATION.md b/backend/docs/CONFIGURATION.md index 5a62f1368..3d04c035e 100644 --- a/backend/docs/CONFIGURATION.md +++ b/backend/docs/CONFIGURATION.md @@ -385,6 +385,7 @@ Notes: - **Upgrade note:** `scheduler.multi_instance` and its related scheduler, ownership, and run-event settings are startup-only. Restart all Gateway Pods together after changing them; a ConfigMap update without a coordinated restart leaves the running service on its previous mode. - Multi-worker deployments (`GATEWAY_WORKERS > 1`) must use the Postgres database backend, enable run ownership heartbeats, and set `run_events.backend: db`. SQLite silently ignores row-level locks, while memory and JSONL run-event stores are process-local and cannot enforce singleton delivery receipts across workers; startup rejects these combinations. The process-local agentic browser tool group is incompatible with multiple Gateway workers; keep `GATEWAY_WORKERS=1` while `browser_navigate` is enabled. Browser control also requires the backend `browser` extra (`cd backend && uv sync --extra browser && uv run playwright install chromium`); startup detects enabled browser config and fails fast when Playwright is missing, and `/api/features` reports `browser_control.enabled=false` until the runtime is available. - The MVP supports thread reuse and fresh-thread-per-run execution modes. +- Create/update accept optional `assistant_id` (`lead_agent` by default, or an existing custom agent for the task owner). - The MVP supports only `once` and `cron`. - Manual trigger uses the same scheduled-task resource and run lifecycle. - Scheduled task definitions and task-run history are persisted in the application database. diff --git a/backend/tests/test_scheduled_task_router_behavior.py b/backend/tests/test_scheduled_task_router_behavior.py index ba7c45c03..bcd8e49eb 100644 --- a/backend/tests/test_scheduled_task_router_behavior.py +++ b/backend/tests/test_scheduled_task_router_behavior.py @@ -1,7 +1,7 @@ import asyncio from datetime import UTC, datetime, timedelta from types import SimpleNamespace -from unittest.mock import AsyncMock +from unittest.mock import AsyncMock, patch import pytest from _router_auth_helpers import call_unwrapped @@ -73,6 +73,7 @@ class _Repo: "user_id": kwargs["user_id"], "thread_id": kwargs["thread_id"], "context_mode": kwargs["context_mode"], + "assistant_id": kwargs.get("assistant_id"), "title": kwargs["title"], "prompt": kwargs["prompt"], "schedule_type": kwargs["schedule_type"], @@ -202,6 +203,7 @@ async def test_create_scheduled_task_uses_repo(): assert created["title"] == "Daily summary" assert created["user_id"] == "user-1" + assert created["assistant_id"] == "lead_agent" assert created["next_run_at"] == datetime(2027, 1, 1, 1, 0, tzinfo=UTC) @@ -940,3 +942,216 @@ async def test_update_terminal_once_task_with_future_run_at_rearms_it(): assert result["status"] == "enabled" assert result["next_run_at"] is not None + + +def _create_request(**overrides): + kwargs = { + "title": "Daily summary", + "prompt": "Summarize thread", + "schedule_type": "cron", + "schedule_spec": {"cron": "0 9 * * *"}, + "timezone": "UTC", + } + kwargs.update(overrides) + return scheduled_tasks.ScheduledTaskCreateRequest(**kwargs) + + +async def _call_create(body, repo=None): + repo = repo or _Repo() + user = SimpleNamespace(id="user-1") + thread_store = SimpleNamespace(check_access=AsyncMock(return_value=True)) + old_repo = scheduled_tasks.get_scheduled_task_repo + old_thread_store = scheduled_tasks.get_thread_store + old_config = scheduled_tasks.get_config + old_user = scheduled_tasks.get_optional_user_from_request + try: + scheduled_tasks.get_scheduled_task_repo = lambda _request: repo + scheduled_tasks.get_thread_store = lambda _request: thread_store + scheduled_tasks.get_config = lambda: _Config() + scheduled_tasks.get_optional_user_from_request = AsyncMock(return_value=user) + return await call_unwrapped( + scheduled_tasks.create_scheduled_task, + request=SimpleNamespace(), + body=body, + ) + finally: + scheduled_tasks.get_scheduled_task_repo = old_repo + scheduled_tasks.get_thread_store = old_thread_store + scheduled_tasks.get_config = old_config + scheduled_tasks.get_optional_user_from_request = old_user + + +@pytest.mark.asyncio +async def test_create_explicit_lead_agent_is_accepted(): + created = await _call_create(_create_request(assistant_id="lead_agent")) + assert created["assistant_id"] == "lead_agent" + + +@pytest.mark.asyncio +async def test_create_lead_agent_is_accepted_case_insensitively(): + # Callers writing LEAD_AGENT / lead-agent mean the default, not a custom agent. + for raw in ("LEAD_AGENT", "Lead_Agent", "lead-agent"): + created = await _call_create(_create_request(assistant_id=raw)) + assert created["assistant_id"] == "lead_agent" + + +@pytest.mark.asyncio +async def test_create_custom_assistant_id_is_normalized_and_persisted(): + with patch( + "app.gateway.routers.scheduled_tasks.load_agent_config", + return_value=object(), + ) as loader: + created = await _call_create(_create_request(assistant_id="Research_Bot")) + assert created["assistant_id"] == "research-bot" + loader.assert_called_once_with("research-bot", user_id="user-1") + + +@pytest.mark.asyncio +async def test_create_unknown_assistant_id_is_rejected(): + with patch( + "app.gateway.routers.scheduled_tasks.load_agent_config", + side_effect=FileNotFoundError("missing"), + ): + with pytest.raises(HTTPException) as exc_info: + await _call_create(_create_request(assistant_id="missing-bot")) + assert exc_info.value.status_code == 422 + assert "Unknown assistant_id" in exc_info.value.detail + + +@pytest.mark.asyncio +async def test_create_invalid_assistant_id_is_rejected(): + with pytest.raises(HTTPException) as exc_info: + await _call_create(_create_request(assistant_id="bad agent")) + assert exc_info.value.status_code == 422 + assert "Invalid assistant_id" in exc_info.value.detail + + +@pytest.mark.asyncio +async def test_update_custom_assistant_id_is_persisted(): + repo = _Repo() + task = await repo.create( + task_id="task-1", + user_id="user-1", + thread_id=None, + context_mode="fresh_thread_per_run", + assistant_id="lead_agent", + title="Daily summary", + prompt="Summarize thread", + schedule_type="cron", + schedule_spec={"cron": "0 9 * * *"}, + timezone="UTC", + next_run_at=None, + ) + old_repo = scheduled_tasks.get_scheduled_task_repo + old_config = scheduled_tasks.get_config + old_user = scheduled_tasks.get_optional_user_from_request + try: + scheduled_tasks.get_scheduled_task_repo = lambda _request: repo + scheduled_tasks.get_config = lambda: _Config() + scheduled_tasks.get_optional_user_from_request = AsyncMock(return_value=SimpleNamespace(id="user-1")) + with patch( + "app.gateway.routers.scheduled_tasks.load_agent_config", + return_value=object(), + ): + updated = await call_unwrapped( + scheduled_tasks.update_scheduled_task, + task_id=task["id"], + request=SimpleNamespace(), + body=scheduled_tasks.ScheduledTaskUpdateRequest(assistant_id="triage-bot"), + ) + finally: + scheduled_tasks.get_scheduled_task_repo = old_repo + scheduled_tasks.get_config = old_config + scheduled_tasks.get_optional_user_from_request = old_user + assert updated["assistant_id"] == "triage-bot" + + +async def _seed_task(repo: _Repo, **overrides): + kwargs = { + "task_id": "task-1", + "user_id": "user-1", + "thread_id": None, + "context_mode": "fresh_thread_per_run", + "assistant_id": "lead_agent", + "title": "Daily summary", + "prompt": "Summarize thread", + "schedule_type": "cron", + "schedule_spec": {"cron": "0 9 * * *"}, + "timezone": "UTC", + "next_run_at": None, + } + kwargs.update(overrides) + return await repo.create(**kwargs) + + +async def _call_update(repo: _Repo, task_id: str, body): + old_repo = scheduled_tasks.get_scheduled_task_repo + old_config = scheduled_tasks.get_config + old_user = scheduled_tasks.get_optional_user_from_request + try: + scheduled_tasks.get_scheduled_task_repo = lambda _request: repo + scheduled_tasks.get_config = lambda: _Config() + scheduled_tasks.get_optional_user_from_request = AsyncMock(return_value=SimpleNamespace(id="user-1")) + return await call_unwrapped( + scheduled_tasks.update_scheduled_task, + task_id=task_id, + request=SimpleNamespace(), + body=body, + ) + finally: + scheduled_tasks.get_scheduled_task_repo = old_repo + scheduled_tasks.get_config = old_config + scheduled_tasks.get_optional_user_from_request = old_user + + +@pytest.mark.asyncio +async def test_update_unknown_assistant_id_is_rejected(): + repo = _Repo() + task = await _seed_task(repo) + with patch( + "app.gateway.routers.scheduled_tasks.load_agent_config", + side_effect=FileNotFoundError("missing"), + ): + with pytest.raises(HTTPException) as exc_info: + await _call_update( + repo, + task["id"], + scheduled_tasks.ScheduledTaskUpdateRequest(assistant_id="missing-bot"), + ) + assert exc_info.value.status_code == 422 + assert "Unknown assistant_id" in exc_info.value.detail + assert repo.items[task["id"]]["assistant_id"] == "lead_agent" + + +@pytest.mark.asyncio +async def test_update_invalid_assistant_id_is_rejected(): + repo = _Repo() + task = await _seed_task(repo) + with pytest.raises(HTTPException) as exc_info: + await _call_update( + repo, + task["id"], + scheduled_tasks.ScheduledTaskUpdateRequest(assistant_id="bad agent"), + ) + assert exc_info.value.status_code == 422 + assert "Invalid assistant_id" in exc_info.value.detail + + +@pytest.mark.asyncio +async def test_update_omitting_assistant_id_keeps_existing_even_if_agent_is_gone(): + # Unrelated PATCH (rename, reschedule) must not re-resolve assistant_id. + # Otherwise a since-deleted custom agent makes the task uneditable. + repo = _Repo() + task = await _seed_task(repo, assistant_id="research-bot") + with patch( + "app.gateway.routers.scheduled_tasks.load_agent_config", + side_effect=FileNotFoundError("missing"), + ) as loader: + updated = await _call_update( + repo, + task["id"], + scheduled_tasks.ScheduledTaskUpdateRequest(title="Renamed"), + ) + loader.assert_not_called() + assert updated["title"] == "Renamed" + assert updated["assistant_id"] == "research-bot" diff --git a/frontend/src/app/workspace/scheduled-tasks/page.tsx b/frontend/src/app/workspace/scheduled-tasks/page.tsx index 45dddd5a0..348cfc675 100644 --- a/frontend/src/app/workspace/scheduled-tasks/page.tsx +++ b/frontend/src/app/workspace/scheduled-tasks/page.tsx @@ -1,8 +1,9 @@ "use client"; +import { useQuery } from "@tanstack/react-query"; import { CopyIcon, TriangleAlertIcon } from "lucide-react"; import { useSearchParams } from "next/navigation"; -import { useEffect, useRef, useState } from "react"; +import { useEffect, useMemo, useRef, useState } from "react"; import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert"; import { Button } from "@/components/ui/button"; @@ -15,6 +16,13 @@ import { DialogTitle, } from "@/components/ui/dialog"; import { Input } from "@/components/ui/input"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; import { Textarea } from "@/components/ui/textarea"; import { ScheduledTaskScheduleInput, @@ -25,6 +33,8 @@ import { WorkspaceContainer, WorkspaceHeader, } from "@/components/workspace/workspace-container"; +import { listAgents } from "@/core/agents/api"; +import { useAgentsApiEnabled } from "@/core/agents/hooks"; import { useI18n } from "@/core/i18n/hooks"; import { useCreateScheduledTask, @@ -82,6 +92,18 @@ function formatTimestamp(value: string | null, locale: string): string { }).format(date); } +const DEFAULT_ASSISTANT_ID = "lead_agent"; + +function agentDisplayName( + assistantId: string | null | undefined, + leadLabel: string, +): string { + if (!assistantId || assistantId === DEFAULT_ASSISTANT_ID) { + return leadLabel; + } + return assistantId; +} + export default function ScheduledTasksPage() { const { t, locale } = useI18n(); const st = t.scheduledTasks; @@ -89,6 +111,14 @@ export default function ScheduledTasksPage() { const threadId = searchParams.get("thread_id"); const allTasksQuery = useScheduledTasks(); const threadTasksQuery = useThreadScheduledTasks(threadId); + const { enabled: agentsApiEnabled, isLoading: agentsApiLoading } = + useAgentsApiEnabled(); + const agentsQuery = useQuery({ + queryKey: ["agents"], + queryFn: listAgents, + enabled: !agentsApiLoading && agentsApiEnabled, + retry: false, + }); const data = threadId ? threadTasksQuery.data : allTasksQuery.data; const queryError = threadId ? threadTasksQuery.error : allTasksQuery.error; const [deleteOpen, setDeleteOpen] = useState(false); @@ -99,6 +129,8 @@ export default function ScheduledTasksPage() { const [targetThreadId, setTargetThreadId] = useState(threadId ?? ""); const [title, setTitle] = useState(""); const [prompt, setPrompt] = useState(""); + const [createAssistantId, setCreateAssistantId] = + useState(DEFAULT_ASSISTANT_ID); const [createSchedule, setCreateSchedule] = useState({ schedule_type: "cron", schedule_spec: { cron: "0 9 * * *" }, @@ -112,6 +144,7 @@ export default function ScheduledTasksPage() { const [editing, setEditing] = useState(false); const [editTitle, setEditTitle] = useState(""); const [editPrompt, setEditPrompt] = useState(""); + const [editAssistantId, setEditAssistantId] = useState(DEFAULT_ASSISTANT_ID); const [editSchedule, setEditSchedule] = useState({ schedule_type: "cron", schedule_spec: { cron: "0 9 * * *" }, @@ -120,6 +153,30 @@ export default function ScheduledTasksPage() { const [createNonce, setCreateNonce] = useState(0); const createFormRef = useRef(null); const createTitleRef = useRef(null); + const agentOptions = useMemo(() => { + const names = new Set((agentsQuery.data ?? []).map((agent) => agent.name)); + const options = [ + { + value: DEFAULT_ASSISTANT_ID, + label: st.create.leadAgent, + }, + ...(agentsQuery.data ?? []) + .filter((agent) => agent.name !== DEFAULT_ASSISTANT_ID) + .map((agent) => ({ value: agent.name, label: agent.name })), + ]; + for (const extra of [createAssistantId, editAssistantId]) { + if (extra && extra !== DEFAULT_ASSISTANT_ID && !names.has(extra)) { + options.push({ value: extra, label: extra }); + names.add(extra); + } + } + return options; + }, [ + agentsQuery.data, + createAssistantId, + editAssistantId, + st.create.leadAgent, + ]); const filteredData = (data ?? []).filter((task) => { const statusPass = statusFilter === "all" || task.status === statusFilter; const typePass = typeFilter === "all" || task.schedule_type === typeFilter; @@ -170,6 +227,7 @@ export default function ScheduledTasksPage() { setPrompt(task.prompt); setContextMode(task.context_mode); setTargetThreadId(task.thread_id ?? ""); + setCreateAssistantId(task.assistant_id ?? DEFAULT_ASSISTANT_ID); setCreateSchedule({ schedule_type: task.schedule_type, schedule_spec: { ...task.schedule_spec }, @@ -208,6 +266,7 @@ export default function ScheduledTasksPage() { } setEditTitle(selectedTask.title); setEditPrompt(selectedTask.prompt); + setEditAssistantId(selectedTask.assistant_id ?? DEFAULT_ASSISTANT_ID); const spec = selectedTask.schedule_spec as { cron?: string; run_at?: string; @@ -287,6 +346,25 @@ export default function ScheduledTasksPage() { /> )} + +
+ {st.detail.agent}:{" "} + {agentDisplayName( + selectedTask.assistant_id, + st.create.leadAgent, + )} +
{selectedTask.context_mode === "reuse_thread" ? `${st.detail.thread}: ${selectedTask.thread_id ?? NONE}` @@ -523,6 +610,25 @@ export default function ScheduledTasksPage() { onChange={(event) => setEditPrompt(event.target.value)} placeholder={st.edit.promptPlaceholder} /> +