perf: merge 3 cron tools into 1 cron manage tool, and add edit capability for cron tool. (#7445)

* perf: replace cron tools with FutureTaskTool for improved task management

* feat: enhance FutureTaskTool with edit functionality and improve descriptions

* feat: add edit functionality for cron jobs and update related UI components
This commit is contained in:
Soulter
2026-04-10 14:32:57 +08:00
committed by GitHub
parent e34d9504e4
commit c791c815e1
9 changed files with 583 additions and 199 deletions
+2 -8
View File
@@ -59,11 +59,7 @@ from astrbot.core.provider.entities import ProviderRequest
from astrbot.core.skills.skill_manager import SkillManager, build_skills_prompt
from astrbot.core.star.context import Context
from astrbot.core.star.star_handler import star_map
from astrbot.core.tools.cron_tools import (
CreateActiveCronTool,
DeleteCronJobTool,
ListCronJobsTool,
)
from astrbot.core.tools.cron_tools import FutureTaskTool
from astrbot.core.tools.knowledge_base_tools import (
KnowledgeBaseQueryTool,
retrieve_knowledge_base,
@@ -1064,9 +1060,7 @@ def _proactive_cron_job_tools(req: ProviderRequest, plugin_context: Context) ->
if req.func_tool is None:
req.func_tool = ToolSet()
tool_mgr = plugin_context.get_llm_tool_manager()
req.func_tool.add_tool(tool_mgr.get_builtin_tool(CreateActiveCronTool))
req.func_tool.add_tool(tool_mgr.get_builtin_tool(DeleteCronJobTool))
req.func_tool.add_tool(tool_mgr.get_builtin_tool(ListCronJobsTool))
req.func_tool.add_tool(tool_mgr.get_builtin_tool(FutureTaskTool))
async def _apply_web_search_tools(
+171 -131
View File
@@ -18,40 +18,58 @@ def _extract_job_session(job: Any) -> str | None:
return str(session) if session is not None else None
def _parse_run_at(run_at: Any) -> datetime | None:
if run_at in (None, ""):
return None
return datetime.fromisoformat(str(run_at))
@builtin_tool
@dataclass
class CreateActiveCronTool(FunctionTool[AstrAgentContext]):
name: str = "create_future_task"
class FutureTaskTool(FunctionTool[AstrAgentContext]):
name: str = "future_task"
description: str = (
"Create a future task for your future. Supports recurring cron expressions or one-time run_at datetime. "
"Use this when you or the user want scheduled follow-up or proactive actions."
"Manage your future tasks. "
"Use action='create' to schedule a recurring cron task or one-time run_at task. "
"Use action='edit' to update an existing task. "
"Use action='list' to inspect existing tasks. "
"Use action='delete' to remove a task by job_id."
)
parameters: dict = Field(
default_factory=lambda: {
"type": "object",
"properties": {
"action": {
"type": "string",
"enum": ["create", "edit", "delete", "list"],
"description": "Action to perform. 'list' takes no parameters. 'delete' requires only 'job_id'. 'edit' requires 'job_id' plus the fields to change.",
},
"name": {
"type": "string",
"description": "Optional task label.",
},
"cron_expression": {
"type": "string",
"description": "Cron expression defining recurring schedule (e.g., '0 8 * * *' or '0 23 * * mon-fri'). Prefer named weekdays like 'mon-fri' or 'sat,sun' instead of numeric day-of-week ranges such as '1-5' to avoid ambiguity across cron implementations.",
},
"run_at": {
"type": "string",
"description": "ISO datetime for one-time execution, e.g., 2026-02-02T08:00:00+08:00. Use with run_once=true.",
"description": "Cron expression for a recurring schedule, e.g. '0 8 * * *' or '0 23 * * mon-fri'. Prefer named weekdays like 'mon-fri' or 'sat,sun' over numeric ranges like '1-5'.",
},
"note": {
"type": "string",
"description": "Detailed instructions for your future agent to execute when it wakes.",
},
"name": {
"type": "string",
"description": "Optional label to recognize this future task.",
},
"run_once": {
"type": "boolean",
"description": "If true, the task will run only once and then be deleted. Use run_at to specify the time.",
"description": "Run only once and delete after execution. Use with run_at.",
},
"run_at": {
"type": "string",
"description": "ISO datetime for one-time execution, e.g. 2026-02-02T08:00:00+08:00.",
},
"job_id": {
"type": "string",
"description": "Task ID. Required for 'delete' and 'edit'.",
},
},
"required": ["note"],
"required": ["action"],
}
)
@@ -62,130 +80,152 @@ class CreateActiveCronTool(FunctionTool[AstrAgentContext]):
if cron_mgr is None:
return "error: cron manager is not available."
cron_expression = kwargs.get("cron_expression")
run_at = kwargs.get("run_at")
run_once = bool(kwargs.get("run_once", False))
note = str(kwargs.get("note", "")).strip()
name = str(kwargs.get("name") or "").strip() or "active_agent_task"
action = str(kwargs.get("action") or "").strip().lower()
if action == "create":
cron_expression = kwargs.get("cron_expression")
run_at = kwargs.get("run_at")
run_once = bool(kwargs.get("run_once", False))
note = str(kwargs.get("note", "")).strip()
name = str(kwargs.get("name") or "").strip() or "active_agent_task"
if not note:
return "error: note is required."
if run_once and not run_at:
return "error: run_at is required when run_once=true."
if (not run_once) and not cron_expression:
return "error: cron_expression is required when run_once=false."
if run_once and cron_expression:
cron_expression = None
run_at_dt = None
if run_at:
if not note:
return "error: note is required when action=create."
if run_once and not run_at:
return "error: run_at is required when run_once=true."
if (not run_once) and not cron_expression:
return "error: cron_expression is required when run_once=false."
if run_once and cron_expression:
cron_expression = None
try:
run_at_dt = datetime.fromisoformat(str(run_at))
run_at_dt = _parse_run_at(run_at)
except Exception:
return "error: run_at must be ISO datetime, e.g., 2026-02-02T08:00:00+08:00"
payload = {
"session": context.context.event.unified_msg_origin,
"sender_id": context.context.event.get_sender_id(),
"note": note,
"origin": "tool",
}
payload = {
"session": context.context.event.unified_msg_origin,
"sender_id": context.context.event.get_sender_id(),
"note": note,
"origin": "tool",
}
job = await cron_mgr.add_active_job(
name=name,
cron_expression=str(cron_expression) if cron_expression else None,
payload=payload,
description=note,
run_once=run_once,
run_at=run_at_dt,
)
next_run = job.next_run_time or run_at_dt
suffix = (
f"one-time at {next_run}"
if run_once
else f"expression '{cron_expression}' (next {next_run})"
)
return f"Scheduled future task {job.job_id} ({job.name}) {suffix}."
@builtin_tool
@dataclass
class DeleteCronJobTool(FunctionTool[AstrAgentContext]):
name: str = "delete_future_task"
description: str = "Delete a future task (cron job) by its job_id."
parameters: dict = Field(
default_factory=lambda: {
"type": "object",
"properties": {
"job_id": {
"type": "string",
"description": "The job_id returned when the job was created.",
}
},
"required": ["job_id"],
}
)
async def call(
self, context: ContextWrapper[AstrAgentContext], **kwargs
) -> ToolExecResult:
cron_mgr = context.context.context.cron_manager
if cron_mgr is None:
return "error: cron manager is not available."
current_umo = context.context.event.unified_msg_origin
job_id = kwargs.get("job_id")
if not job_id:
return "error: job_id is required."
job = await cron_mgr.db.get_cron_job(str(job_id))
if not job:
return f"error: cron job {job_id} not found."
if _extract_job_session(job) != current_umo:
return "error: you can only delete future tasks in the current umo."
await cron_mgr.delete_job(str(job_id))
return f"Deleted cron job {job_id}."
@builtin_tool
@dataclass
class ListCronJobsTool(FunctionTool[AstrAgentContext]):
name: str = "list_future_tasks"
description: str = "List existing future tasks (cron jobs) for inspection."
parameters: dict = Field(
default_factory=lambda: {
"type": "object",
"properties": {
"job_type": {
"type": "string",
"description": "Optional filter: basic or active_agent.",
}
},
}
)
async def call(
self, context: ContextWrapper[AstrAgentContext], **kwargs
) -> ToolExecResult:
cron_mgr = context.context.context.cron_manager
if cron_mgr is None:
return "error: cron manager is not available."
current_umo = context.context.event.unified_msg_origin
job_type = kwargs.get("job_type")
jobs = [
job
for job in await cron_mgr.list_jobs(job_type)
if _extract_job_session(job) == current_umo
]
if not jobs:
return "No cron jobs found."
lines = []
for j in jobs:
lines.append(
f"{j.job_id} | {j.name} | {j.job_type} | run_once={getattr(j, 'run_once', False)} | enabled={j.enabled} | next={j.next_run_time}"
job = await cron_mgr.add_active_job(
name=name,
cron_expression=str(cron_expression) if cron_expression else None,
payload=payload,
description=note,
run_once=run_once,
run_at=run_at_dt,
)
return "\n".join(lines)
next_run = job.next_run_time or run_at_dt
suffix = (
f"one-time at {next_run}"
if run_once
else f"expression '{cron_expression}' (next {next_run})"
)
return f"Scheduled future task {job.job_id} ({job.name}) {suffix}."
current_umo = context.context.event.unified_msg_origin
if action == "edit":
job_id = kwargs.get("job_id")
if not job_id:
return "error: job_id is required when action=edit."
if not any(
key in kwargs
for key in ("name", "note", "run_once", "cron_expression", "run_at")
):
return "error: no editable fields were provided."
job = await cron_mgr.db.get_cron_job(str(job_id))
if not job:
return f"error: cron job {job_id} not found."
if _extract_job_session(job) != current_umo:
return "error: you can only edit future tasks in the current umo."
payload = dict(job.payload) if isinstance(job.payload, dict) else {}
updates: dict[str, Any] = {}
if "name" in kwargs:
name = str(kwargs.get("name") or "").strip()
if not name:
return "error: name cannot be empty when action=edit."
updates["name"] = name
if "note" in kwargs:
note = str(kwargs.get("note") or "").strip()
if not note:
return "error: note cannot be empty when action=edit."
payload["note"] = note
updates["description"] = note
current_run_at = payload.get("run_at")
run_once = (
bool(kwargs["run_once"]) if "run_once" in kwargs else bool(job.run_once)
)
cron_expression = (
str(kwargs.get("cron_expression") or "").strip()
if "cron_expression" in kwargs
else job.cron_expression
)
cron_expression = cron_expression or None
try:
run_at_dt = (
_parse_run_at(kwargs.get("run_at"))
if "run_at" in kwargs
else _parse_run_at(current_run_at)
)
except Exception:
return "error: run_at must be ISO datetime, e.g., 2026-02-02T08:00:00+08:00"
if run_once:
if run_at_dt is None:
return "error: run_at is required when run_once=true."
cron_expression = None
payload["run_at"] = run_at_dt.isoformat()
else:
if not cron_expression:
return "error: cron_expression is required when run_once=false."
payload.pop("run_at", None)
updates["run_once"] = run_once
updates["cron_expression"] = cron_expression
updates["payload"] = payload
job = await cron_mgr.update_job(str(job_id), **updates)
if not job:
return f"error: cron job {job_id} not found."
return f"Updated future task {job.job_id} ({job.name})."
if action == "delete":
job_id = kwargs.get("job_id")
if not job_id:
return "error: job_id is required when action=delete."
job = await cron_mgr.db.get_cron_job(str(job_id))
if not job:
return f"error: cron job {job_id} not found."
if _extract_job_session(job) != current_umo:
return "error: you can only delete future tasks in the current umo."
await cron_mgr.delete_job(str(job_id))
return f"Deleted cron job {job_id}."
if action == "list":
jobs = [
job
for job in await cron_mgr.list_jobs()
if _extract_job_session(job) == current_umo
]
if not jobs:
return "No cron jobs found."
lines = []
for j in jobs:
lines.append(
f"{j.job_id} | {j.name} | {j.job_type} | run_once={getattr(j, 'run_once', False)} | enabled={j.enabled} | next={j.next_run_time}"
)
return "\n".join(lines)
return "error: action must be one of create, edit, delete, or list."
__all__ = [
"CreateActiveCronTool",
"DeleteCronJobTool",
"ListCronJobsTool",
"FutureTaskTool",
]
+121 -16
View File
@@ -135,22 +135,127 @@ class CronRoute(Route):
if not isinstance(payload, dict):
return jsonify(Response().error("Invalid payload").__dict__)
updates = {
"name": payload.get("name"),
"cron_expression": payload.get("cron_expression"),
"description": payload.get("description"),
"enabled": payload.get("enabled"),
"timezone": payload.get("timezone"),
"run_once": payload.get("run_once"),
"payload": payload.get("payload"),
}
# remove None values to avoid unwanted resets
updates = {k: v for k, v in updates.items() if v is not None}
if "run_at" in payload:
updates.setdefault("payload", {})
if updates["payload"] is None:
updates["payload"] = {}
updates["payload"]["run_at"] = payload.get("run_at")
job = await cron_mgr.db.get_cron_job(job_id)
if not job:
return jsonify(Response().error("Job not found").__dict__)
updates = {}
if "name" in payload:
name = str(payload.get("name") or "").strip()
if not name:
return jsonify(Response().error("name cannot be empty").__dict__)
updates["name"] = name
if "enabled" in payload:
updates["enabled"] = bool(payload.get("enabled"))
if "timezone" in payload:
timezone = payload.get("timezone")
updates["timezone"] = str(timezone).strip() or None
next_run_once = (
bool(payload.get("run_once"))
if "run_once" in payload
else bool(job.run_once)
)
if job.job_type == "active_agent":
merged_payload = (
dict(job.payload) if isinstance(job.payload, dict) else {}
)
if "payload" in payload and isinstance(payload.get("payload"), dict):
merged_payload.update(payload["payload"])
if "session" in payload:
session = str(payload.get("session") or "").strip()
if not session:
return jsonify(
Response().error("session cannot be empty").__dict__
)
merged_payload["session"] = session
note_updated = False
if "note" in payload:
note = str(payload.get("note") or "").strip()
if not note:
return jsonify(
Response().error("note cannot be empty").__dict__
)
merged_payload["note"] = note
updates["description"] = note
note_updated = True
elif "description" in payload:
description = str(payload.get("description") or "").strip()
if not description:
return jsonify(
Response().error("description cannot be empty").__dict__
)
updates["description"] = description
merged_payload["note"] = description
note_updated = True
if not note_updated and updates.get("description") is None:
existing_note = str(
merged_payload.get("note") or job.description or ""
).strip()
if existing_note:
merged_payload["note"] = existing_note
next_cron_expression = (
payload.get("cron_expression")
if "cron_expression" in payload
else job.cron_expression
)
if next_cron_expression is not None:
next_cron_expression = str(next_cron_expression).strip() or None
run_at_raw = (
payload.get("run_at")
if "run_at" in payload
else merged_payload.get("run_at")
)
run_at_iso = None
if run_at_raw:
try:
run_at_iso = datetime.fromisoformat(str(run_at_raw)).isoformat()
except Exception:
return jsonify(
Response().error("run_at must be ISO datetime").__dict__
)
if next_run_once:
if not run_at_iso:
return jsonify(
Response()
.error("run_at is required when run_once=true")
.__dict__
)
next_cron_expression = None
merged_payload["run_at"] = run_at_iso
else:
if not next_cron_expression:
return jsonify(
Response()
.error("cron_expression is required when run_once=false")
.__dict__
)
merged_payload.pop("run_at", None)
updates["run_once"] = next_run_once
updates["cron_expression"] = next_cron_expression
updates["payload"] = merged_payload
else:
if "cron_expression" in payload:
cron_expression = str(payload.get("cron_expression") or "").strip()
if not cron_expression:
return jsonify(
Response().error("cron_expression cannot be empty").__dict__
)
updates["cron_expression"] = cron_expression
if "description" in payload:
description = str(payload.get("description") or "").strip()
updates["description"] = description or None
job = await cron_mgr.update_job(job_id, **updates)
if not job:
@@ -14,9 +14,11 @@
},
"actions": {
"create": "New Task",
"edit": "Edit",
"refresh": "Refresh",
"delete": "Delete",
"cancel": "Cancel",
"save": "Save",
"submit": "Create"
},
"overview": {
@@ -77,6 +79,7 @@
},
"form": {
"title": "New Task",
"editTitle": "Edit Task",
"chatHint": "You can ask AstrBot in chat to create future tasks instead of adding them here.",
"runOnce": "One-off task",
"name": "Task name",
@@ -90,6 +93,7 @@
},
"messages": {
"loadFailed": "Failed to load tasks",
"updateSuccess": "Updated successfully",
"updateFailed": "Failed to update",
"deleteSuccess": "Deleted",
"deleteFailed": "Failed to delete",
@@ -14,9 +14,11 @@
},
"actions": {
"create": "Новая задача",
"edit": "Изменить",
"refresh": "Обновить",
"delete": "Удалить",
"cancel": "Отмена",
"save": "Сохранить",
"submit": "Создать"
},
"overview": {
@@ -77,6 +79,7 @@
},
"form": {
"title": "Создать задачу",
"editTitle": "Редактировать задачу",
"chatHint": "Вы можете ставить задачи прямо в чате, AstrBot создаст их автоматически без заполнения этой формы.",
"runOnce": "Разовая задача",
"name": "Имя задачи",
@@ -90,6 +93,7 @@
},
"messages": {
"loadFailed": "Ошибка загрузки задач",
"updateSuccess": "Задача обновлена",
"updateFailed": "Ошибка обновления",
"deleteSuccess": "Удалено",
"deleteFailed": "Ошибка удаления",
@@ -14,9 +14,11 @@
},
"actions": {
"create": "新建",
"edit": "编辑",
"refresh": "刷新",
"delete": "删除",
"cancel": "取消",
"save": "保存",
"submit": "创建"
},
"overview": {
@@ -77,6 +79,7 @@
},
"form": {
"title": "新建任务",
"editTitle": "编辑任务",
"chatHint": "你可以直接通过聊天的方式来让 AstrBot 创建未来任务,而不必在此添加。",
"runOnce": "一次性任务",
"name": "任务名称",
@@ -90,6 +93,7 @@
},
"messages": {
"loadFailed": "获取任务失败",
"updateSuccess": "更新成功",
"updateFailed": "更新失败",
"deleteSuccess": "已删除",
"deleteFailed": "删除失败",
+151 -20
View File
@@ -136,18 +136,31 @@
</td>
<td class="actions-col">
<div class="table-actions">
<v-switch
v-model="item.enabled"
inset
density="compact"
hide-details
color="primary"
class="mt-0"
@change="toggleJob(item)"
/>
<v-btn size="small" variant="text" color="error" @click="deleteJob(item)">
{{ tm('actions.delete') }}
</v-btn>
<div class="table-actions-toggle">
<v-switch
v-model="item.enabled"
inset
density="compact"
hide-details
color="primary"
class="table-actions-switch mt-0"
@change="toggleJob(item)"
/>
</div>
<div class="table-actions-buttons">
<v-btn
v-if="item.job_type === 'active_agent'"
size="small"
variant="text"
color="primary"
@click="openEdit(item)"
>
{{ tm('actions.edit') }}
</v-btn>
<v-btn size="small" variant="text" color="error" @click="deleteJob(item)">
{{ tm('actions.delete') }}
</v-btn>
</div>
</div>
</td>
</tr>
@@ -162,7 +175,7 @@
<v-dialog v-model="createDialog" max-width="640">
<v-card class="dashboard-dialog-card">
<v-card-title class="text-h6 pt-5 px-5">{{ tm('form.title') }}</v-card-title>
<v-card-title class="text-h6 pt-5 px-5">{{ dialogTitle }}</v-card-title>
<v-card-subtitle class="px-5 text-body-2 text-medium-emphasis">
{{ tm('form.chatHint') }}
</v-card-subtitle>
@@ -206,8 +219,8 @@
</v-card-text>
<v-card-actions class="justify-end px-5 pb-5">
<v-btn variant="text" @click="createDialog = false">{{ tm('actions.cancel') }}</v-btn>
<v-btn variant="tonal" color="primary" :loading="creating" @click="createJob">
{{ tm('actions.submit') }}
<v-btn variant="tonal" color="primary" :loading="creating" @click="submitJob">
{{ dialogSubmitText }}
</v-btn>
</v-card-actions>
</v-card>
@@ -231,6 +244,7 @@ const jobs = ref<any[]>([])
const proactivePlatforms = ref<{ id: string; name: string; display_name?: string }[]>([])
const createDialog = ref(false)
const creating = ref(false)
const editingJobId = ref('')
const newJob = ref({
run_once: false,
name: '',
@@ -286,6 +300,10 @@ const overviewCards = computed(() => [
}
])
const isEditing = computed(() => !!editingJobId.value)
const dialogTitle = computed(() => tm(isEditing.value ? 'form.editTitle' : 'form.title'))
const dialogSubmitText = computed(() => tm(isEditing.value ? 'actions.save' : 'actions.submit'))
function toast(message: string, color: 'success' | 'error' | 'warning' = 'success') {
snackbar.value = { show: true, message, color }
}
@@ -394,10 +412,26 @@ async function deleteJob(job: any) {
}
function openCreate() {
editingJobId.value = ''
resetNewJob()
createDialog.value = true
}
function toDatetimeLocalValue(value: any): string {
if (!value) return ''
const date = new Date(value)
if (Number.isNaN(date.getTime())) return ''
const offset = date.getTimezoneOffset()
const local = new Date(date.getTime() - offset * 60_000)
return local.toISOString().slice(0, 16)
}
function toIsoDatetime(value: string): string {
if (!value) return ''
const date = new Date(value)
return Number.isNaN(date.getTime()) ? value : date.toISOString()
}
function resetNewJob() {
newJob.value = {
run_once: false,
@@ -411,6 +445,21 @@ function resetNewJob() {
}
}
function openEdit(job: any) {
editingJobId.value = job.job_id
newJob.value = {
run_once: !!job.run_once,
name: job.name || '',
note: job.note || job.description || '',
cron_expression: job.cron_expression || '',
run_at: toDatetimeLocalValue(job.run_at),
session: job.session || job?.payload?.session || '',
timezone: job.timezone || '',
enabled: job.enabled !== false
}
createDialog.value = true
}
async function createJob() {
if (!newJob.value.session) {
toast(tm('messages.sessionRequired'), 'warning')
@@ -431,10 +480,15 @@ async function createJob() {
creating.value = true
try {
const res = await axios.post('/api/cron/jobs', { ...newJob.value })
const payload = {
...newJob.value,
run_at: newJob.value.run_once ? toIsoDatetime(newJob.value.run_at) : ''
}
const res = await axios.post('/api/cron/jobs', payload)
if (res.data.status === 'ok') {
toast(tm('messages.createSuccess'))
createDialog.value = false
editingJobId.value = ''
resetNewJob()
await loadJobs()
} else {
@@ -447,6 +501,59 @@ async function createJob() {
}
}
async function updateJob() {
if (!editingJobId.value) {
return
}
if (!newJob.value.session) {
toast(tm('messages.sessionRequired'), 'warning')
return
}
if (!newJob.value.note) {
toast(tm('messages.noteRequired'), 'warning')
return
}
if (!newJob.value.run_once && !newJob.value.cron_expression) {
toast(tm('messages.cronRequired'), 'warning')
return
}
if (newJob.value.run_once && !newJob.value.run_at) {
toast(tm('messages.runAtRequired'), 'warning')
return
}
creating.value = true
try {
const payload = {
...newJob.value,
run_at: newJob.value.run_once ? toIsoDatetime(newJob.value.run_at) : '',
description: newJob.value.note
}
const res = await axios.patch(`/api/cron/jobs/${editingJobId.value}`, payload)
if (res.data.status === 'ok') {
toast(tm('messages.updateSuccess'))
createDialog.value = false
editingJobId.value = ''
resetNewJob()
await loadJobs()
} else {
toast(res.data.message || tm('messages.updateFailed'), 'error')
}
} catch (e: any) {
toast(e?.response?.data?.message || tm('messages.updateFailed'), 'error')
} finally {
creating.value = false
}
}
async function submitJob() {
if (isEditing.value) {
await updateJob()
return
}
await createJob()
}
onMounted(() => {
loadJobs()
loadPlatforms()
@@ -513,7 +620,7 @@ onMounted(() => {
}
.task-table .col-actions {
width: 170px;
width: 220px;
}
.task-table th {
@@ -571,15 +678,34 @@ onMounted(() => {
}
.actions-col {
width: 170px;
width: 220px;
}
.table-actions {
display: grid;
gap: 10px;
justify-items: start;
min-width: 190px;
}
.table-actions-toggle {
display: flex;
align-items: center;
justify-content: flex-end;
}
.table-actions-switch {
flex: 0 0 auto;
}
.cron-page :deep(.table-actions-switch .v-selection-control) {
min-width: auto;
}
.table-actions-buttons {
display: flex;
align-items: center;
justify-content: flex-start;
gap: 10px;
min-width: 140px;
}
.state-panel {
@@ -600,6 +726,11 @@ onMounted(() => {
@media (max-width: 900px) {
.table-actions {
justify-items: start;
}
.table-actions-buttons,
.table-actions-toggle {
justify-content: flex-start;
}
}
+10 -19
View File
@@ -1,7 +1,7 @@
"""Tests for astr_main_agent module."""
import os
from unittest.mock import AsyncMock, MagicMock, call, patch
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
@@ -402,26 +402,16 @@ class TestBuiltinToolInjection:
req = ProviderRequest()
tool_mgr = MagicMock()
create_tool = MagicMock(spec=FunctionTool)
create_tool.name = "create_future_task"
delete_tool = MagicMock(spec=FunctionTool)
delete_tool.name = "delete_future_task"
list_tool = MagicMock(spec=FunctionTool)
list_tool.name = "list_future_tasks"
tool_mgr.get_builtin_tool.side_effect = [create_tool, delete_tool, list_tool]
future_task_tool = MagicMock(spec=FunctionTool)
future_task_tool.name = "future_task"
tool_mgr.get_builtin_tool.return_value = future_task_tool
mock_context.get_llm_tool_manager.return_value = tool_mgr
module._proactive_cron_job_tools(req, mock_context)
assert tool_mgr.get_builtin_tool.call_args_list == [
call(module.CreateActiveCronTool),
call(module.DeleteCronJobTool),
call(module.ListCronJobsTool),
]
tool_mgr.get_builtin_tool.assert_called_once_with(module.FutureTaskTool)
assert req.func_tool is not None
assert req.func_tool.get_tool("create_future_task") is create_tool
assert req.func_tool.get_tool("delete_future_task") is delete_tool
assert req.func_tool.get_tool("list_future_tasks") is list_tool
assert req.func_tool.get_tool("future_task") is future_task_tool
class TestApplyFileExtract:
@@ -621,9 +611,10 @@ class TestEnsurePersonaAndSkills:
tmgr = mock_context.get_llm_tool_manager.return_value
tmgr.func_list = [tool_a, tool_b]
tmgr.get_full_tool_set.return_value = ToolSet([tool_a, tool_b])
tmgr.get_func.side_effect = lambda name: {"tool_a": tool_a, "tool_b": tool_b}.get(
name
)
tmgr.get_func.side_effect = lambda name: {
"tool_a": tool_a,
"tool_b": tool_b,
}.get(name)
handoff = MagicMock()
handoff.name = "transfer_to_planner"
+116 -5
View File
@@ -1,15 +1,126 @@
"""Tests for cron tool metadata."""
from astrbot.core.tools.cron_tools import CreateActiveCronTool
from types import SimpleNamespace
from unittest.mock import AsyncMock
import pytest
from astrbot.core.tools.cron_tools import FutureTaskTool
def test_create_future_task_cron_description_prefers_named_weekdays():
"""The cron tool should steer users toward unambiguous named weekdays."""
tool = CreateActiveCronTool()
def test_future_task_schema_has_action_and_create_cron_guidance():
"""The merged tool should expose action routing and unambiguous cron guidance."""
tool = FutureTaskTool()
assert tool.name == "future_task"
assert tool.parameters["required"] == ["action"]
assert tool.parameters["properties"]["action"]["enum"] == [
"create",
"edit",
"delete",
"list",
]
description = tool.parameters["properties"]["cron_expression"]["description"]
assert "mon-fri" in description
assert "sat,sun" in description
assert "1-5" in description
assert "avoid ambiguity" in description
assert "Prefer named weekdays" in description
def test_future_task_schema_has_no_job_type_and_delete_job_id():
"""The merged tool should remove job_type and document delete requirements."""
tool = FutureTaskTool()
assert "job_type" not in tool.parameters["properties"]
action_description = tool.parameters["properties"]["action"]["description"]
job_id_description = tool.parameters["properties"]["job_id"]["description"]
assert "'edit' requires 'job_id'" in action_description
assert "Required for 'delete' and 'edit'" in job_id_description
@pytest.mark.asyncio
async def test_future_task_edit_requires_job_id():
"""Edit mode should require job_id."""
tool = FutureTaskTool()
cron_mgr = SimpleNamespace()
context = SimpleNamespace(
context=SimpleNamespace(
context=SimpleNamespace(cron_manager=cron_mgr),
event=SimpleNamespace(
unified_msg_origin="test:private:session",
get_sender_id=lambda: "user-1",
),
)
)
result = await tool.call(context, action="edit")
assert result == "error: job_id is required when action=edit."
@pytest.mark.asyncio
async def test_future_task_edit_updates_existing_job():
"""Edit mode should update note and one-time scheduling fields."""
tool = FutureTaskTool()
existing_job = SimpleNamespace(
job_id="job-1",
name="old name",
job_type="active_agent",
run_once=False,
cron_expression="0 8 * * *",
payload={
"session": "test:private:session",
"sender_id": "user-1",
"note": "old note",
"origin": "tool",
},
)
updated_job = SimpleNamespace(
job_id="job-1",
name="new name",
run_once=True,
cron_expression=None,
next_run_time=None,
)
cron_mgr = SimpleNamespace(
db=SimpleNamespace(get_cron_job=AsyncMock(return_value=existing_job)),
update_job=AsyncMock(return_value=updated_job),
)
context = SimpleNamespace(
context=SimpleNamespace(
context=SimpleNamespace(cron_manager=cron_mgr),
event=SimpleNamespace(
unified_msg_origin="test:private:session",
get_sender_id=lambda: "user-1",
),
)
)
result = await tool.call(
context,
action="edit",
job_id="job-1",
name="new name",
note="new note",
run_once=True,
run_at="2026-02-02T08:00:00+08:00",
)
cron_mgr.update_job.assert_awaited_once_with(
"job-1",
name="new name",
description="new note",
run_once=True,
cron_expression=None,
payload={
"session": "test:private:session",
"sender_id": "user-1",
"note": "new note",
"origin": "tool",
"run_at": "2026-02-02T08:00:00+08:00",
},
)
assert result == "Updated future task job-1 (new name)."