fix(tools): preserve cron job id tool inputs

This commit is contained in:
matevip 2026-08-13 04:01:32 -04:00
parent 04a61fda19
commit c4751ea69c
2 changed files with 49 additions and 7 deletions

View File

@ -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<String, Object> 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<String, Object> 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

View File

@ -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);
}
}