diff --git a/mateclaw-server/src/main/java/vip/mate/tool/builtin/CronJobTool.java b/mateclaw-server/src/main/java/vip/mate/tool/builtin/CronJobTool.java index 2d8c90f5..dfd7acd0 100644 --- a/mateclaw-server/src/main/java/vip/mate/tool/builtin/CronJobTool.java +++ b/mateclaw-server/src/main/java/vip/mate/tool/builtin/CronJobTool.java @@ -217,17 +217,18 @@ public class CronJobTool { @Tool(description = "Enable or disable a scheduled task by its job ID. " + "Use list_cron_jobs first to find the job ID.") public String toggle_cron_job( - @ToolParam(description = "Job ID (number)") Long jobId, + @ToolParam(description = "Job ID. Must be passed as a string to preserve large integer precision") String jobId, @ToolParam(description = "true to enable, false to disable") Boolean enabled, @Nullable ToolContext ctx) { try { + Long parsedJobId = parseJobId(jobId); // RFC-083: scope toggle to the originating workspace. Long workspaceId = workspaceFromContext(ctx); - cronJobService.toggle(jobId, enabled, workspaceId); - CronJobDTO updated = cronJobService.getById(jobId, workspaceId); + cronJobService.toggle(parsedJobId, enabled, workspaceId); + CronJobDTO updated = cronJobService.getById(parsedJobId, workspaceId); Map result = new LinkedHashMap<>(); result.put("success", true); - result.put("jobId", jobId); + result.put("jobId", parsedJobId); result.put("name", updated.getName()); result.put("enabled", updated.getEnabled()); result.put("nextRunTime", updated.getNextRunTime() != null ? updated.getNextRunTime().toString() : ""); @@ -242,14 +243,15 @@ public class CronJobTool { @Tool(description = "Delete a scheduled task by its job ID. This action requires user approval. " + "Use list_cron_jobs first to find the job ID.") public String delete_cron_job( - @ToolParam(description = "Job ID (number) to delete") Long jobId, + @ToolParam(description = "Job ID to delete. Must be passed as a string to preserve large integer precision") String jobId, @Nullable ToolContext ctx) { try { + Long parsedJobId = parseJobId(jobId); // RFC-083: scope delete to the originating workspace. Long workspaceId = workspaceFromContext(ctx); - CronJobDTO job = cronJobService.getById(jobId, workspaceId); + CronJobDTO job = cronJobService.getById(parsedJobId, workspaceId); String jobName = job.getName(); - cronJobService.delete(jobId, workspaceId); + cronJobService.delete(parsedJobId, workspaceId); Map result = new LinkedHashMap<>(); result.put("success", true); result.put("deleted", jobName); @@ -267,6 +269,18 @@ public class CronJobTool { return writeJson(result); } + private Long parseJobId(String jobId) { + String trimmed = jobId != null ? jobId.trim() : ""; + if (trimmed.isEmpty()) { + throw new IllegalArgumentException("jobId is required"); + } + try { + return Long.parseLong(trimmed); + } catch (NumberFormatException e) { + throw new IllegalArgumentException("jobId must be a numeric string"); + } + } + /** * Serialize tool output through the id-safe application ObjectMapper so every * {@code Long} (notably {@code jobId}) is rendered as a string. Falls back to diff --git a/mateclaw-server/src/test/java/vip/mate/tool/builtin/CronJobToolIdPrecisionTest.java b/mateclaw-server/src/test/java/vip/mate/tool/builtin/CronJobToolIdPrecisionTest.java index 3c175c76..7739015f 100644 --- a/mateclaw-server/src/test/java/vip/mate/tool/builtin/CronJobToolIdPrecisionTest.java +++ b/mateclaw-server/src/test/java/vip/mate/tool/builtin/CronJobToolIdPrecisionTest.java @@ -1,11 +1,14 @@ package vip.mate.tool.builtin; +import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.databind.json.JsonMapper; import com.fasterxml.jackson.databind.module.SimpleModule; import com.fasterxml.jackson.databind.ser.std.ToStringSerializer; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; +import org.springframework.ai.support.ToolCallbacks; +import org.springframework.ai.tool.ToolCallback; import vip.mate.cron.model.CronJobDTO; import vip.mate.cron.service.CronJobService; @@ -52,4 +55,29 @@ class CronJobToolIdPrecisionTest { assertFalse(out.contains(": " + bigId) || out.contains(":" + bigId), "jobId must NOT appear as a bare JSON number"); } + + @Test + @DisplayName("cron mutating tools publish jobId as a string parameter so LLM tool calls preserve precision") + void cronJobIdSchemasAreString() throws Exception { + CronJobTool tool = new CronJobTool(mock(CronJobService.class), idSafeMapper()); + + assertJobIdIsString(tool, "toggle_cron_job"); + assertJobIdIsString(tool, "delete_cron_job"); + } + + private static void assertJobIdIsString(Object tool, String name) throws Exception { + JsonNode root = idSafeMapper().readTree(callback(tool, name).getToolDefinition().inputSchema()); + + assertTrue("string".equals(root.at("/properties/jobId/type").asText()), + name + " jobId must be a string schema"); + } + + private static ToolCallback callback(Object tool, String name) { + for (ToolCallback callback : ToolCallbacks.from(tool)) { + if (name.equals(callback.getToolDefinition().name())) { + return callback; + } + } + throw new AssertionError("Missing tool callback: " + name); + } }