This is an automated email from the ASF dual-hosted git repository.
tomsun28 pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/hertzbeat.git
The following commit(s) were added to refs/heads/master by this push:
new 001e229236 fix(ai): persist parameters for scheduled skills (#4317)
001e229236 is described below
commit 001e2292363917adb454d5f42af5ed0a1c6a563a
Author: shown <[email protected]>
AuthorDate: Thu Aug 13 22:38:55 2026 +0800
fix(ai): persist parameters for scheduled skills (#4317)
---
.../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]