This is an automated email from the ASF dual-hosted git repository.

yuluo-yx pushed a commit to branch 0813-yuluo/feat
in repository https://gitbox.apache.org/repos/asf/hertzbeat.git

commit 9ff7dee29a0b59c6a369d57391e0f814a29c8301
Author: yuluo-yx <[email protected]>
AuthorDate: Thu Aug 13 22:14:51 2026 +0800

    fix(ai): persist parameters for scheduled skills
---
 .../hertzbeat/ai/tools/impl/ScheduleToolsImpl.java |  75 +++++++++++-
 .../ai/tools/impl/ScheduleToolsImplTest.java       | 129 +++++++++++++++++++++
 2 files changed, 199 insertions(+), 5 deletions(-)

diff --git 
a/hertzbeat-ai/src/main/java/org/apache/hertzbeat/ai/tools/impl/ScheduleToolsImpl.java
 
b/hertzbeat-ai/src/main/java/org/apache/hertzbeat/ai/tools/impl/ScheduleToolsImpl.java
index f82260c4ee..28b11818c7 100644
--- 
a/hertzbeat-ai/src/main/java/org/apache/hertzbeat/ai/tools/impl/ScheduleToolsImpl.java
+++ 
b/hertzbeat-ai/src/main/java/org/apache/hertzbeat/ai/tools/impl/ScheduleToolsImpl.java
@@ -19,17 +19,23 @@ package org.apache.hertzbeat.ai.tools.impl;
 
 import java.time.format.DateTimeFormatter;
 import java.util.List;
+import java.util.Map;
+import java.util.Objects;
 import lombok.extern.slf4j.Slf4j;
 import org.apache.hertzbeat.ai.service.SopScheduleService;
+import org.apache.hertzbeat.ai.sop.model.SopDefinition;
+import org.apache.hertzbeat.ai.sop.model.SopParameter;
 import org.apache.hertzbeat.ai.sop.registry.SkillRegistry;
 import org.apache.hertzbeat.ai.utils.SopMessageUtil;
 import org.apache.hertzbeat.ai.tools.ScheduleTools;
 import org.apache.hertzbeat.common.entity.ai.SopSchedule;
+import org.apache.hertzbeat.common.util.JsonUtil;
 import org.springframework.ai.tool.annotation.Tool;
 import org.springframework.ai.tool.annotation.ToolParam;
 import org.springframework.beans.factory.annotation.Autowired;
 import org.springframework.context.annotation.Lazy;
 import org.springframework.stereotype.Service;
+import tools.jackson.core.type.TypeReference;
 
 /**
  * Implementation of ScheduleTools for AI-driven schedule management.
@@ -78,18 +84,22 @@ public class ScheduleToolsImpl implements ScheduleTools {
     @Tool(name = "createScheduleWithConversation",
           description = "Create a scheduled task for a specific conversation. "
                   + "Use the conversationId from the system context. "
+                  + "Pass skill parameters as a JSON object when the skill 
requires inputs. "
                   + "The cron expression should be in 6-digit Spring format.")
     public String createScheduleWithConversation(
             @ToolParam(description = "Conversation ID from the system 
context", required = true) Long conversationId,
             @ToolParam(description = "Name of the skill to schedule (e.g., 
'daily_inspection')", required = true) String skillName,
             @ToolParam(description = "Cron expression in Spring format (e.g., 
'0 0 9 * * ?')", required = true) String cronExpression,
-            @ToolParam(description = "Description of the schedule", required = 
false) String description) {
+            @ToolParam(description = "Description of the schedule", required = 
false) String description,
+            @ToolParam(description = "Skill parameters as a JSON object (e.g., 
'{\"monitorId\":123}')",
+                       required = false) String paramsJson) {
 
         log.info("AI creating schedule: conversationId={}, skill={}, cron={}, 
desc={}", 
                  conversationId, skillName, cronExpression, description);
 
         // Validate skill exists
-        if (skillRegistry.getSkill(skillName) == null) {
+        SopDefinition skill = skillRegistry.getSkill(skillName);
+        if (skill == null) {
             String available = String.join(", ", 
                     skillRegistry.getAllSkills().stream()
                             .map(s -> s.getName())
@@ -103,11 +113,16 @@ public class ScheduleToolsImpl implements ScheduleTools {
         }
 
         try {
-            // Check for duplicate schedule (same skill + cron expression)
+            Map<String, Object> params = parseSkillParams(paramsJson);
+            validateRequiredParameters(skill, params);
+            String serializedParams = params.isEmpty() ? null : 
JsonUtil.toJson(params);
+
+            // Parameters are part of a schedule's identity so the same skill 
and cron can target different inputs.
             List<SopSchedule> existing = 
scheduleService.getSchedulesByConversation(conversationId);
             boolean duplicate = existing.stream()
-                    .anyMatch(s -> s.getSopName().equals(skillName) 
-                                && 
s.getCronExpression().equals(cronExpression));
+                    .anyMatch(schedule -> 
Objects.equals(schedule.getSopName(), skillName)
+                            && Objects.equals(schedule.getCronExpression(), 
cronExpression)
+                            && hasSameParams(schedule.getSopParams(), params));
             if (duplicate) {
                 return SopMessageUtil.getMessage("schedule.create.duplicate", 
                         new Object[]{skillName, cronExpression}, null)
@@ -118,6 +133,7 @@ public class ScheduleToolsImpl implements ScheduleTools {
             schedule.setConversationId(conversationId);
             schedule.setSopName(skillName);
             schedule.setCronExpression(cronExpression);
+            schedule.setSopParams(serializedParams);
             schedule.setEnabled(true);
 
             SopSchedule created = scheduleService.createSchedule(schedule);
@@ -150,6 +166,55 @@ public class ScheduleToolsImpl implements ScheduleTools {
         }
     }
 
+    private Map<String, Object> parseSkillParams(String paramsJson) {
+        if (paramsJson == null || paramsJson.isBlank()) {
+            return Map.of();
+        }
+        Map<String, Object> params;
+        try {
+            params = JsonUtil.fromJson(paramsJson, new TypeReference<>() {});
+        } catch (RuntimeException e) {
+            throw new IllegalArgumentException("Skill parameters must be a 
valid JSON object", e);
+        }
+        if (params == null) {
+            throw new IllegalArgumentException("Skill parameters must be a 
valid JSON object");
+        }
+        return params;
+    }
+
+    private void validateRequiredParameters(SopDefinition skill, Map<String, 
Object> params) {
+        if (skill.getParameters() == null) {
+            return;
+        }
+        for (SopParameter parameter : skill.getParameters()) {
+            Object value = params.get(parameter.getName());
+            if (isMissing(value)) {
+                value = parameter.getDefaultValue();
+            }
+            if (parameter.isRequired() && isMissing(value)) {
+                throw new IllegalArgumentException(
+                        "Required skill parameter is missing: " + 
parameter.getName());
+            }
+        }
+    }
+
+    private boolean isMissing(Object value) {
+        return value == null || value instanceof String text && text.isBlank();
+    }
+
+    private boolean hasSameParams(String existingJson, Map<String, Object> 
params) {
+        if (existingJson == null || existingJson.isBlank()) {
+            return params.isEmpty();
+        }
+        try {
+            Map<String, Object> existingParams = 
JsonUtil.fromJson(existingJson, new TypeReference<>() {});
+            return Objects.equals(existingParams, params);
+        } catch (RuntimeException e) {
+            log.warn("Failed to parse parameters of an existing schedule", e);
+            return false;
+        }
+    }
+
     @Override
     @Tool(name = "listSchedulesForConversation",
           description = "List all scheduled tasks for a specific conversation. 
"
diff --git 
a/hertzbeat-ai/src/test/java/org/apache/hertzbeat/ai/tools/impl/ScheduleToolsImplTest.java
 
b/hertzbeat-ai/src/test/java/org/apache/hertzbeat/ai/tools/impl/ScheduleToolsImplTest.java
new file mode 100644
index 0000000000..c8e8535f59
--- /dev/null
+++ 
b/hertzbeat-ai/src/test/java/org/apache/hertzbeat/ai/tools/impl/ScheduleToolsImplTest.java
@@ -0,0 +1,129 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.hertzbeat.ai.tools.impl;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+import java.util.List;
+import org.apache.hertzbeat.ai.service.SopScheduleService;
+import org.apache.hertzbeat.ai.sop.model.SopDefinition;
+import org.apache.hertzbeat.ai.sop.model.SopParameter;
+import org.apache.hertzbeat.ai.sop.registry.SkillRegistry;
+import org.apache.hertzbeat.common.entity.ai.SopSchedule;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.ExtendWith;
+import org.mockito.ArgumentCaptor;
+import org.mockito.Mock;
+import org.mockito.junit.jupiter.MockitoExtension;
+
+/**
+ * Verifies that AI-created SOP schedules validate and persist skill 
parameters.
+ */
+@ExtendWith(MockitoExtension.class)
+class ScheduleToolsImplTest {
+
+    private static final String CRON = "0 0 9 * * ?";
+
+    @Mock
+    private SopScheduleService scheduleService;
+
+    @Mock
+    private SkillRegistry skillRegistry;
+
+    private ScheduleToolsImpl scheduleTools;
+
+    @BeforeEach
+    void setUp() {
+        scheduleTools = new ScheduleToolsImpl(scheduleService, skillRegistry);
+    }
+
+    @Test
+    void createScheduleShouldPersistSkillParameters() {
+        
when(skillRegistry.getSkill("diagnosis")).thenReturn(parameterizedSkill());
+        
when(scheduleService.getSchedulesByConversation(7L)).thenReturn(List.of());
+        when(scheduleService.createSchedule(any())).thenAnswer(invocation -> {
+            SopSchedule schedule = invocation.getArgument(0);
+            schedule.setId(9L);
+            return schedule;
+        });
+
+        String result = scheduleTools.createScheduleWithConversation(
+                7L, "diagnosis", CRON, "daily diagnosis", 
"{\"monitorId\":42}");
+
+        ArgumentCaptor<SopSchedule> captor = 
ArgumentCaptor.forClass(SopSchedule.class);
+        verify(scheduleService).createSchedule(captor.capture());
+        assertEquals("{\"monitorId\":42}", captor.getValue().getSopParams());
+        assertTrue(result.contains("9"));
+    }
+
+    @Test
+    void createScheduleShouldAllowDifferentParametersAtTheSameTime() {
+        SopSchedule existing = SopSchedule.builder()
+                .sopName("diagnosis")
+                .cronExpression(CRON)
+                .sopParams("{\"monitorId\":41}")
+                .build();
+        
when(skillRegistry.getSkill("diagnosis")).thenReturn(parameterizedSkill());
+        
when(scheduleService.getSchedulesByConversation(7L)).thenReturn(List.of(existing));
+        when(scheduleService.createSchedule(any())).thenAnswer(invocation -> 
invocation.getArgument(0));
+
+        scheduleTools.createScheduleWithConversation(
+                7L, "diagnosis", CRON, null, "{\"monitorId\":42}");
+
+        verify(scheduleService).createSchedule(any());
+    }
+
+    @Test
+    void createScheduleShouldRejectMissingRequiredParameter() {
+        
when(skillRegistry.getSkill("diagnosis")).thenReturn(parameterizedSkill());
+
+        String result = scheduleTools.createScheduleWithConversation(
+                7L, "diagnosis", CRON, null, "{}");
+
+        assertTrue(result.contains("monitorId"));
+        verify(scheduleService, never()).createSchedule(any());
+    }
+
+    @Test
+    void createScheduleShouldRejectInvalidParameterJson() {
+        
when(skillRegistry.getSkill("diagnosis")).thenReturn(parameterizedSkill());
+
+        String result = scheduleTools.createScheduleWithConversation(
+                7L, "diagnosis", CRON, null, "not-json");
+
+        assertTrue(result.contains("valid JSON object"));
+        verify(scheduleService, never()).createSchedule(any());
+    }
+
+    private SopDefinition parameterizedSkill() {
+        SopParameter monitorId = SopParameter.builder()
+                .name("monitorId")
+                .required(true)
+                .build();
+        return SopDefinition.builder()
+                .name("diagnosis")
+                .parameters(List.of(monitorId))
+                .build();
+    }
+}


---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]

Reply via email to