diff --git a/base/src/main/java/ai/javaclaw/tasks/TaskHandler.java b/base/src/main/java/ai/javaclaw/tasks/TaskHandler.java index 91529f5f..6d33ba08 100644 --- a/base/src/main/java/ai/javaclaw/tasks/TaskHandler.java +++ b/base/src/main/java/ai/javaclaw/tasks/TaskHandler.java @@ -9,6 +9,9 @@ import org.slf4j.LoggerFactory; import org.springframework.stereotype.Component; +import java.nio.file.Path; +import java.util.regex.Pattern; + import static ai.javaclaw.tasks.Task.Status.awaiting_human_input; import static ai.javaclaw.tasks.Task.Status.completed; import static java.util.Optional.ofNullable; @@ -18,6 +21,12 @@ public class TaskHandler { private static final Logger LOGGER = new JobRunrDashboardLogger(LoggerFactory.getLogger(TaskHandler.class)); + private static final String CONVERSATION_ID_PREFIX = "task-"; + private static final String TASK_FILE_EXTENSION = ".md"; + /** The character set task file names are already restricted to by {@code FileSystemTaskRepository}. */ + private static final Pattern UNSAFE_CHARACTERS = Pattern.compile("[^a-zA-Z0-9._-]"); + private static final Pattern REPEATED_SEPARATORS = Pattern.compile("_{2,}"); + private final Agent agent; private final TaskRepository taskRepository; private final ChannelRegistry channelRegistry; @@ -40,7 +49,7 @@ public void executeTask(String taskId) { try { LOGGER.info("Starting task: {}", task.getName()); String agentInput = formatTaskForAgent(inProgress); - TaskResult result = agent.prompt(taskId, agentInput, TaskResult.class); + TaskResult result = agent.prompt(getConversationId(taskId), agentInput, TaskResult.class); taskRepository.save(inProgress.withFeedback(result.feedback()).withStatus(result.newStatus())); notifyUser(task.getName(), result); LOGGER.info("Finished task: {} with status {}", task.getName(), result.newStatus()); @@ -66,6 +75,56 @@ private void notifyUser(String taskName, TaskResult result) { } } + /** + * Derives the conversation id used for a task's chat memory. + * + *
A task's id is the absolute path of its markdown file. Passed straight through it reaches + * {@code FileSystemChatMemoryRepository}, which resolves {@code conversations/chat-{id}.yaml} - + * so each task creates a directory tree mirroring its own location on disk instead of a single + * conversation file, never appears in the conversation list, and on Windows is rejected outright + * because of the colon in the drive letter. + * + *
The last two path segments carry everything needed: tasks are stored as
+ * {@code {date}/{time}-{name}.md} and recurring ones as {@code recurring/{name}.md}, so the pair
+ * is unique across days and stays readable. Characters outside the set task file names already
+ * use are replaced, which keeps the result a single safe file name component whatever the id
+ * turns out to contain.
+ *
+ * @throws IllegalArgumentException if no id can be derived, rather than falling back to the
+ * path and recreating the problem this method exists to avoid
+ */
+ static String getConversationId(String taskId) {
+ if (taskId == null || taskId.isBlank()) {
+ throw new IllegalArgumentException("Cannot derive a conversation id from an empty task id");
+ }
+
+ Path taskFile = Path.of(taskId).normalize();
+ Path fileName = taskFile.getFileName();
+ Path parent = taskFile.getParent();
+ if (fileName == null) {
+ throw new IllegalArgumentException("Cannot derive a conversation id from a task id that names no file");
+ }
+
+ String name = removeExtension(fileName.toString());
+ String group = parent == null || parent.getFileName() == null ? "" : parent.getFileName().toString();
+ if (name.isBlank() && group.isBlank()) {
+ throw new IllegalArgumentException("Cannot derive a conversation id from a task id that names no file");
+ }
+
+ return sanitize(CONVERSATION_ID_PREFIX + (group.isBlank() ? name : group + "-" + name));
+ }
+
+ private static String removeExtension(String fileName) {
+ return fileName.endsWith(TASK_FILE_EXTENSION)
+ ? fileName.substring(0, fileName.length() - TASK_FILE_EXTENSION.length())
+ : fileName;
+ }
+
+ private static String sanitize(String conversationId) {
+ String replaced = UNSAFE_CHARACTERS.matcher(conversationId).replaceAll("_");
+ return REPEATED_SEPARATORS.matcher(replaced).replaceAll("_");
+ }
+
private String formatTaskForAgent(Task task) {
return String.format("""
Handle the following task and report the new status ('completed' or 'awaiting_human_input') with the feedback what was done
diff --git a/base/src/test/java/ai/javaclaw/tasks/TaskHandlerTest.java b/base/src/test/java/ai/javaclaw/tasks/TaskHandlerTest.java
new file mode 100644
index 00000000..b4e8ea88
--- /dev/null
+++ b/base/src/test/java/ai/javaclaw/tasks/TaskHandlerTest.java
@@ -0,0 +1,150 @@
+package ai.javaclaw.tasks;
+
+import ai.javaclaw.agent.Agent;
+import ai.javaclaw.channels.ChannelRegistry;
+import ai.javaclaw.tasks.TaskHandler.TaskResult;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.io.TempDir;
+import org.junit.jupiter.api.extension.ExtendWith;
+import org.mockito.ArgumentCaptor;
+import org.mockito.Mock;
+import org.mockito.junit.jupiter.MockitoExtension;
+import org.springframework.core.io.FileSystemResource;
+
+import java.io.IOException;
+import java.nio.file.Path;
+import java.time.Instant;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+import static org.mockito.ArgumentMatchers.anyString;
+import static org.mockito.ArgumentMatchers.eq;
+import static org.mockito.Mockito.when;
+
+@ExtendWith(MockitoExtension.class)
+class TaskHandlerTest {
+
+ @TempDir
+ Path workspaceDir;
+
+ @Mock
+ Agent agentMock;
+ @Mock
+ ChannelRegistry channelRegistryMock;
+
+ TaskRepository taskRepository;
+ TaskHandler taskHandler;
+
+ @BeforeEach
+ void setUp() throws IOException {
+ taskRepository = new FileSystemTaskRepository(new FileSystemResource(workspaceDir));
+ taskHandler = new TaskHandler(agentMock, taskRepository, channelRegistryMock);
+ }
+
+ // -----------------------------------------------------------------------
+ // The conversation id the agent is called with
+ //
+ // A task's id is the absolute path of its markdown file. Handing that to
+ // the agent as a conversation id makes the memory repository resolve
+ // conversations/chat-