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]
