package vip.mate.tool.builtin; import cn.hutool.json.JSONArray; import cn.hutool.json.JSONObject; import cn.hutool.json.JSONUtil; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.ai.chat.model.ToolContext; import org.springframework.ai.tool.annotation.Tool; import org.springframework.ai.tool.annotation.ToolParam; import org.springframework.lang.Nullable; import org.springframework.stereotype.Component; import vip.mate.agent.context.ChatOrigin; import vip.mate.cron.model.CronJobDTO; import vip.mate.cron.service.CronJobService; import java.util.List; /** * Built-in tool: scheduled task (cron job) management via chat. *
* Allows agents to create, list, toggle, and delete cron jobs through natural language.
* The agent_id is automatically bound to the current agent. LLM generates cron expressions
* from natural language (e.g. "every day at 9am" → "0 9 * * *").
*
* @author MateClaw Team
* @see vip.mate.cron.service.CronJobService
*/
@Slf4j
@Component
@RequiredArgsConstructor
public class CronJobTool {
private final CronJobService cronJobService;
@vip.mate.tool.ConcurrencyUnsafe("cron job creation persists to mate_cron_job; concurrent creates can race on name")
@Tool(description = "Create a scheduled task that asks the agent to do something at a specific time — "
+ "the trigger message is sent to the LLM, which can use tools (search, weather, etc.) to produce the answer. "
+ "Use this for queries like 'every morning give me a weather report' or 'daily news summary'. "
+ "DO NOT use this for plain reminders where the user already wrote the exact text they want delivered — "
+ "use create_reminder instead, otherwise the LLM will rephrase or echo the reminder. "
+ "Use 5-field cron expressions: minute hour day month weekday. "
+ "Examples: '0 9 * * *' = daily at 9am, '0 9 * * 1-5' = weekdays at 9am, '*/30 * * * *' = every 30 minutes. "
+ "If a task with the same name already exists for this agent, the existing one is returned "
+ "(deduplicated=true in the response) — do NOT call this tool again with a different name "
+ "just to retry; check list_cron_jobs first if unsure.")
public String create_cron_job(
@ToolParam(description = "Task name, e.g. 'Daily AI News Summary'") String name,
@ToolParam(description = "5-field cron expression: minute hour day month weekday") String cronExpression,
@ToolParam(description = "Message to send when the task triggers, e.g. 'Search for the latest AI news and summarize'") String triggerMessage,
@ToolParam(description = "Timezone, default Asia/Shanghai. Examples: UTC, America/New_York", required = false) String timezone,
// RFC-063r §2.4: ToolContext is *not* exposed to the LLM —
// JsonSchemaGenerator skips it (Spring AI 1.1 framework convention)
@Nullable ToolContext ctx) {
try {
// RFC-063r §2.5: the ChatOrigin must carry agentId — buildInitialState
// injects it from the agent that owns the StateGraph. If it's missing
// here, something upstream broke (no holder set, KeyStrategyFactory
// dropped CHAT_ORIGIN, etc.) — fail loudly rather than silently
// binding to agent #1, which could be disabled / non-existent /
// user-renamed and would surface as "scheduled but never runs".
ChatOrigin origin = ChatOrigin.from(ctx);
String conversationId = origin.conversationId() != null && !origin.conversationId().isEmpty()
? origin.conversationId()
: ToolExecutionContext.conversationId();
Long agentId = origin.agentId();
if (agentId == null) {
log.warn("[CronJobTool] create_cron_job invoked without an agentId in ChatOrigin " +
"(conv={}); refusing to silently bind to a default agent.", conversationId);
return errorResult("Cannot create cron job: agent context unavailable. " +
"This is an internal wiring bug — the originating agent id was not threaded " +
"through ToolContext. Re-issue the request; if it persists, see RFC-063r §2.5.");
}
CronJobDTO dto = new CronJobDTO();
dto.setName(name);
dto.setCronExpression(cronExpression);
dto.setTriggerMessage(triggerMessage);
dto.setTimezone(timezone != null && !timezone.isBlank() ? timezone : "Asia/Shanghai");
dto.setAgentId(agentId);
dto.setTaskType("text");
dto.setEnabled(true);
// RFC-063r §2.4 / PR-2: when the originating context carries a
// channelId, the cron job inherits the binding so its results can
// be delivered back to the same channel. Fields are wired via
// reflection until PR-2 adds them to CronJobDTO + CronJobEntity.
propagateChannelBinding(dto, origin);
// RFC-083: stamp workspace from the originating ChatOrigin so the
// cron job is created in the agent's current workspace; fall back
// to the default workspace when origin is unscoped (legacy paths).
Long workspaceId = origin.workspaceId() != null ? origin.workspaceId() : 1L;
CronJobDTO created = cronJobService.create(dto, workspaceId);
JSONObject result = new JSONObject();
result.set("success", true);
result.set("jobId", created.getId());
result.set("name", created.getName());
result.set("cronExpression", created.getCronExpression());
result.set("timezone", created.getTimezone());
result.set("nextRunTime", created.getNextRunTime() != null ? created.getNextRunTime().toString() : "");
result.set("enabled", created.getEnabled());
return JSONUtil.toJsonPrettyStr(result);
} catch (Exception e) {
log.error("[CronJobTool] create failed: {}", e.getMessage());
return errorResult("Failed to create cron job: " + e.getMessage());
}
}
@vip.mate.tool.ConcurrencyUnsafe("reminder creation persists to mate_cron_job; concurrent creates can race on name")
@Tool(description = "Create a scheduled REMINDER. The reminder text is delivered to the user verbatim at the "
+ "scheduled time — no LLM call, no rephrasing, no token cost. "
+ "Use this when the user wants a notification with specific content (e.g. 'remind me at 3pm to leave for the meeting' → "
+ "reminder text 'It's time to leave for the meeting'). "
+ "DO NOT use this if the message requires the agent to compute or look something up — use create_cron_job for that. "
+ "Use 5-field cron expressions: minute hour day month weekday. "
+ "Examples: '0 15 * * *' = every day at 3pm, '0 9 * * 1' = every Monday at 9am. "
+ "If a reminder with the same name already exists for this agent, the existing one is returned — "
+ "do NOT retry with the same name to force a new row.")
public String create_reminder(
@ToolParam(description = "Reminder name, e.g. 'Meeting at Room 6'") String name,
@ToolParam(description = "5-field cron expression: minute hour day month weekday") String cronExpression,
@ToolParam(description = "The exact text to deliver to the user when the reminder fires, e.g. "
+ "'⏰ It's time to leave for the meeting at Room 6.'") String reminderText,
@ToolParam(description = "Timezone, default Asia/Shanghai. Examples: UTC, America/New_York", required = false) String timezone,
@Nullable ToolContext ctx) {
try {
ChatOrigin origin = ChatOrigin.from(ctx);
String conversationId = origin.conversationId() != null && !origin.conversationId().isEmpty()
? origin.conversationId()
: ToolExecutionContext.conversationId();
Long agentId = origin.agentId();
if (agentId == null) {
log.warn("[CronJobTool] create_reminder invoked without an agentId in ChatOrigin " +
"(conv={}); refusing to silently bind to a default agent.", conversationId);
return errorResult("Cannot create reminder: agent context unavailable.");
}
CronJobDTO dto = new CronJobDTO();
dto.setName(name);
dto.setCronExpression(cronExpression);
dto.setTriggerMessage(reminderText);
dto.setTimezone(timezone != null && !timezone.isBlank() ? timezone : "Asia/Shanghai");
dto.setAgentId(agentId);
dto.setTaskType("reminder");
dto.setEnabled(true);
propagateChannelBinding(dto, origin);
Long workspaceId = origin.workspaceId() != null ? origin.workspaceId() : 1L;
CronJobDTO created = cronJobService.create(dto, workspaceId);
JSONObject result = new JSONObject();
result.set("success", true);
result.set("jobId", created.getId());
result.set("name", created.getName());
result.set("taskType", "reminder");
result.set("cronExpression", created.getCronExpression());
result.set("timezone", created.getTimezone());
result.set("nextRunTime", created.getNextRunTime() != null ? created.getNextRunTime().toString() : "");
result.set("enabled", created.getEnabled());
return JSONUtil.toJsonPrettyStr(result);
} catch (Exception e) {
log.error("[CronJobTool] create_reminder failed: {}", e.getMessage());
return errorResult("Failed to create reminder: " + e.getMessage());
}
}
@Tool(description = "List all scheduled tasks (cron jobs) for the current agent. "
+ "Returns task name, cron expression, next run time, enabled status, and last run time.")
public String list_cron_jobs(@Nullable ToolContext ctx) {
try {
// RFC-083: scope to the originating workspace so an agent only
// sees the cron jobs of the workspace it's running in.
Long workspaceId = workspaceFromContext(ctx);
List