From b0dc2552f6abfd5529e25ccb11dab70e080e6969 Mon Sep 17 00:00:00 2001
From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com>
Date: Sat, 11 Apr 2026 11:31:07 +0000
Subject: [PATCH 01/10] Add MemoryDistillerAgent and memory distillation on
completed AgentProcess
- Create MemoryDistillationResult record with stored/revoked memory lists
- Create MemoryDistillerAgent with @Agent, @Action, @AchievesGoal annotations
using ai().withDefaultLlm().createObject() for LLM-based memory distillation
- Add maxMemories parameter to KafkaAgenticAggregateRuntime constructor
- Integrate memory distillation in resumeNextAgentTask: when AgentProcess
finishes with COMPLETED status, run MemoryDistillerAgent to completion
using run() and translate results into MemoryStoredEvent/MemoryRevokedEvent
- Enforce net memory limit: stored - revoked <= maxMemories - currentCount
- Handle gracefully: missing agent, null result, exceptions
- Add comprehensive tests for distillation logic and limit enforcement
Agent-Logs-Url: https://github.com/elasticsoftwarefoundation/akces-framework/sessions/b0b7907c-8588-4b46-af3a-67b710ba24ad
Co-authored-by: jwijgerd <914840+jwijgerd@users.noreply.github.com>
---
.../beans/AgenticAggregateRuntimeFactory.java | 3 +-
.../embabel/MemoryDistillationResult.java | 70 +++
.../agentic/embabel/MemoryDistillerAgent.java | 151 +++++
.../runtime/KafkaAgenticAggregateRuntime.java | 172 ++++-
.../embabel/MemoryDistillationResultTest.java | 76 +++
.../AgenticAggregateAutoCreateTest.java | 6 +-
.../KafkaAgenticAggregateRuntimeTest.java | 14 +-
.../runtime/MemoryDistillationTest.java | 594 ++++++++++++++++++
.../runtime/ResumeNextAgentTaskTest.java | 6 +-
9 files changed, 1074 insertions(+), 18 deletions(-)
create mode 100644 main/agentic/src/main/java/org/elasticsoftware/akces/agentic/embabel/MemoryDistillationResult.java
create mode 100644 main/agentic/src/main/java/org/elasticsoftware/akces/agentic/embabel/MemoryDistillerAgent.java
create mode 100644 main/agentic/src/test/java/org/elasticsoftware/akces/agentic/embabel/MemoryDistillationResultTest.java
create mode 100644 main/agentic/src/test/java/org/elasticsoftware/akces/agentic/runtime/MemoryDistillationTest.java
diff --git a/main/agentic/src/main/java/org/elasticsoftware/akces/agentic/beans/AgenticAggregateRuntimeFactory.java b/main/agentic/src/main/java/org/elasticsoftware/akces/agentic/beans/AgenticAggregateRuntimeFactory.java
index 0dba4f4c..1361ab2e 100644
--- a/main/agentic/src/main/java/org/elasticsoftware/akces/agentic/beans/AgenticAggregateRuntimeFactory.java
+++ b/main/agentic/src/main/java/org/elasticsoftware/akces/agentic/beans/AgenticAggregateRuntimeFactory.java
@@ -134,7 +134,8 @@ public AgenticAggregateRuntime getObject() {
KafkaAggregateRuntime kafkaRuntime = createRuntime(agenticInfo, aggregate, agentPlatform);
return new KafkaAgenticAggregateRuntime(
- kafkaRuntime, objectMapper, agenticInfo.stateClass(), agentPlatform, aggregate);
+ kafkaRuntime, objectMapper, agenticInfo.stateClass(), agentPlatform, aggregate,
+ agenticInfo.maxMemories());
}
@Override
diff --git a/main/agentic/src/main/java/org/elasticsoftware/akces/agentic/embabel/MemoryDistillationResult.java b/main/agentic/src/main/java/org/elasticsoftware/akces/agentic/embabel/MemoryDistillationResult.java
new file mode 100644
index 00000000..a50b53a5
--- /dev/null
+++ b/main/agentic/src/main/java/org/elasticsoftware/akces/agentic/embabel/MemoryDistillationResult.java
@@ -0,0 +1,70 @@
+/*
+ * Copyright 2022 - 2026 The Original Authors
+ *
+ * Licensed 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.elasticsoftware.akces.agentic.embabel;
+
+import java.util.List;
+
+/**
+ * Result object produced by the {@link MemoryDistillerAgent} after distilling
+ * relevant memories from a completed {@link com.embabel.agent.core.AgentProcess}.
+ *
+ *
Contains two lists:
+ *
+ *
{@code stored} — new memory entries to be persisted as
+ * {@link org.elasticsoftware.akces.agentic.events.MemoryStoredEvent}s
+ *
{@code revoked} — existing memory entries to be removed as
+ * {@link org.elasticsoftware.akces.agentic.events.MemoryRevokedEvent}s
+ *
+ *
+ * @param stored the list of memories to store; each entry contains subject, fact,
+ * citations, and reason fields
+ * @param revoked the list of memories to revoke; each entry contains memoryId and reason
+ */
+public record MemoryDistillationResult(
+ List stored,
+ List revoked
+) {
+
+ /**
+ * A memory entry to be stored.
+ *
+ * @param subject a short (1–2 word) topic label
+ * @param fact the fact to remember (max 200 characters)
+ * @param citations the source citation for the fact
+ * @param reason why this memory should be stored
+ */
+ public record StoredMemory(
+ String subject,
+ String fact,
+ String citations,
+ String reason
+ ) {
+ }
+
+ /**
+ * A memory entry to be revoked.
+ *
+ * @param memoryId the UUID of the memory to revoke
+ * @param reason why this memory should be revoked
+ */
+ public record RevokedMemory(
+ String memoryId,
+ String reason
+ ) {
+ }
+}
diff --git a/main/agentic/src/main/java/org/elasticsoftware/akces/agentic/embabel/MemoryDistillerAgent.java b/main/agentic/src/main/java/org/elasticsoftware/akces/agentic/embabel/MemoryDistillerAgent.java
new file mode 100644
index 00000000..91dcf951
--- /dev/null
+++ b/main/agentic/src/main/java/org/elasticsoftware/akces/agentic/embabel/MemoryDistillerAgent.java
@@ -0,0 +1,151 @@
+/*
+ * Copyright 2022 - 2026 The Original Authors
+ *
+ * Licensed 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.elasticsoftware.akces.agentic.embabel;
+
+import com.embabel.agent.api.annotation.AchievesGoal;
+import com.embabel.agent.api.annotation.Action;
+import com.embabel.agent.api.annotation.Agent;
+import com.embabel.agent.api.common.ActionContext;
+import com.embabel.agent.api.common.PlannerType;
+import com.embabel.agent.core.ActionInvocation;
+
+import java.util.List;
+
+/**
+ * Embabel agent responsible for distilling relevant memories from a successfully
+ * completed {@link com.embabel.agent.core.AgentProcess}.
+ *
+ *
When an agent process finishes with status {@code COMPLETED}, this agent is
+ * invoked with the process's execution history and all objects from its blackboard.
+ * It uses an LLM to analyze the process results and determine which memories should
+ * be stored and which existing memories should be revoked.
+ *
+ *
The agent produces a {@link MemoryDistillationResult} containing:
+ *
+ *
A list of new memories to store
+ *
A list of existing memories to revoke
+ *
+ *
+ *
The net number of new memories (stored minus revoked) is constrained by the
+ * {@code maxNewMemories} binding on the blackboard, which is derived from the
+ * aggregate's {@code maxMemories} configuration minus the current memory count.
+ */
+@Agent(name = MemoryDistillerAgent.AGENT_NAME,
+ description = "Distills relevant memories from a completed agent process",
+ planner = PlannerType.UTILITY)
+public class MemoryDistillerAgent {
+
+ /**
+ * The well-known agent name used to locate this agent on the
+ * {@link com.embabel.agent.core.AgentPlatform}.
+ */
+ public static final String AGENT_NAME = "MemoryDistillerAgent";
+
+ /**
+ * Distills memories from a completed agent process by analyzing the process
+ * history and blackboard contents using an LLM.
+ *
+ *
The method reads the following bindings from the blackboard:
+ *
+ *
{@code "history"} — the {@link List} of {@link ActionInvocation}s from the
+ * completed process
+ *
{@code "blackboardObjects"} — all objects from the completed process's
+ * blackboard
+ *
{@code "existingMemories"} — the current list of memories from the aggregate
+ * state
+ *
{@code "maxNewMemories"} — the maximum number of net new memories allowed
+ * (maxMemories - currentMemoryCount)
+ *
+ *
+ * @param context the action context providing access to AI capabilities and blackboard
+ * @return a {@link MemoryDistillationResult} with memories to store and revoke
+ */
+ @Action(description = "Distill relevant memories from a completed agent process")
+ @AchievesGoal(description = "Distill and manage memories from completed agent processes")
+ public MemoryDistillationResult distillMemories(ActionContext context) {
+ List> history = context.getProcessContext().getAgentProcess()
+ .getBlackboard().objectsOfType(ActionInvocation.class);
+ List> blackboardObjects = (List>) context.getProcessContext()
+ .getAgentProcess().getBlackboard().get("blackboardObjects");
+ List> existingMemories = (List>) context.getProcessContext()
+ .getAgentProcess().getBlackboard().get("existingMemories");
+ int maxNewMemories = (int) context.getProcessContext()
+ .getAgentProcess().getBlackboard().get("maxNewMemories");
+
+ String prompt = buildPrompt(history, blackboardObjects, existingMemories, maxNewMemories);
+
+ return context.ai()
+ .withDefaultLlm()
+ .createObject(prompt, MemoryDistillationResult.class);
+ }
+
+ private String buildPrompt(List> history,
+ List> blackboardObjects,
+ List> existingMemories,
+ int maxNewMemories) {
+ StringBuilder sb = new StringBuilder();
+ sb.append("You are a memory distillation agent. Analyze the following completed agent process ");
+ sb.append("and determine which facts should be stored as new memories and which existing ");
+ sb.append("memories should be revoked (because they are outdated, incorrect, or superseded).\n\n");
+
+ sb.append("CONSTRAINTS:\n");
+ sb.append("- The net number of new memories (stored count minus revoked count) must be <= ")
+ .append(maxNewMemories).append("\n");
+ sb.append("- Each stored memory must have: subject (1-2 words), fact (max 200 chars), ");
+ sb.append("citations (source reference), and reason (why it should be stored)\n");
+ sb.append("- Each revoked memory must have: memoryId (from existing memories) and reason\n");
+ sb.append("- Only store memories that are actionable, likely to remain relevant, and ");
+ sb.append("cannot always be inferred from limited context\n");
+ sb.append("- Only revoke memories that are clearly outdated, incorrect, or superseded ");
+ sb.append("by new information\n\n");
+
+ sb.append("PROCESS HISTORY (actions executed):\n");
+ if (history != null && !history.isEmpty()) {
+ for (Object invocation : history) {
+ sb.append("- ").append(invocation).append("\n");
+ }
+ } else {
+ sb.append("(no actions recorded)\n");
+ }
+
+ sb.append("\nBLACKBOARD OBJECTS (process context and results):\n");
+ if (blackboardObjects != null && !blackboardObjects.isEmpty()) {
+ for (Object obj : blackboardObjects) {
+ sb.append("- [").append(obj.getClass().getSimpleName()).append("] ")
+ .append(obj).append("\n");
+ }
+ } else {
+ sb.append("(no objects)\n");
+ }
+
+ sb.append("\nEXISTING MEMORIES:\n");
+ if (existingMemories != null && !existingMemories.isEmpty()) {
+ for (Object memory : existingMemories) {
+ sb.append("- ").append(memory).append("\n");
+ }
+ } else {
+ sb.append("(no existing memories)\n");
+ }
+
+ sb.append("\nReturn a JSON object with 'stored' (list of new memories to store) ");
+ sb.append("and 'revoked' (list of existing memories to revoke). ");
+ sb.append("If no memories should be stored or revoked, return empty lists.");
+
+ return sb.toString();
+ }
+}
diff --git a/main/agentic/src/main/java/org/elasticsoftware/akces/agentic/runtime/KafkaAgenticAggregateRuntime.java b/main/agentic/src/main/java/org/elasticsoftware/akces/agentic/runtime/KafkaAgenticAggregateRuntime.java
index 728ad474..cc7324ac 100644
--- a/main/agentic/src/main/java/org/elasticsoftware/akces/agentic/runtime/KafkaAgenticAggregateRuntime.java
+++ b/main/agentic/src/main/java/org/elasticsoftware/akces/agentic/runtime/KafkaAgenticAggregateRuntime.java
@@ -17,12 +17,16 @@
package org.elasticsoftware.akces.agentic.runtime;
+import com.embabel.agent.core.Agent;
import com.embabel.agent.core.AgentPlatform;
import com.embabel.agent.core.AgentProcess;
import com.embabel.agent.core.AgentProcessStatusCode;
+import com.embabel.agent.core.ProcessOptions;
import jakarta.annotation.Nullable;
import org.apache.kafka.common.errors.SerializationException;
import org.elasticsoftware.akces.agentic.AgenticAggregateRuntime;
+import org.elasticsoftware.akces.agentic.embabel.MemoryDistillationResult;
+import org.elasticsoftware.akces.agentic.embabel.MemoryDistillerAgent;
import org.elasticsoftware.akces.agentic.events.AgentTaskAssignedEvent;
import org.elasticsoftware.akces.agentic.events.AgentTaskFinishedEvent;
import org.elasticsoftware.akces.agentic.events.MemoryRevokedEvent;
@@ -44,9 +48,7 @@
import java.io.IOException;
import java.time.Instant;
-import java.util.Collection;
-import java.util.List;
-import java.util.Objects;
+import java.util.*;
import java.util.concurrent.atomic.AtomicInteger;
import java.util.function.BiConsumer;
import java.util.function.Consumer;
@@ -71,6 +73,7 @@ public class KafkaAgenticAggregateRuntime implements AgenticAggregateRuntime {
private final Class extends AggregateState> stateClass;
private final AgentPlatform agentPlatform;
private final AgenticAggregate> aggregate;
+ private final int maxMemories;
/** Round-robin counter for selecting the next agent task to resume. */
private final AtomicInteger nextTaskIndex = new AtomicInteger(0);
@@ -86,17 +89,20 @@ public class KafkaAgenticAggregateRuntime implements AgenticAggregateRuntime {
* @param aggregate the agentic aggregate instance whose
* {@link AgenticAggregate#getCreateDomainEvent()} method provides the
* auto-create event
+ * @param maxMemories the maximum number of memories allowed for this aggregate
*/
public KafkaAgenticAggregateRuntime(KafkaAggregateRuntime delegate,
ObjectMapper objectMapper,
Class extends AggregateState> stateClass,
AgentPlatform agentPlatform,
- AgenticAggregate> aggregate) {
+ AgenticAggregate> aggregate,
+ int maxMemories) {
this.delegate = Objects.requireNonNull(delegate, "delegate must not be null");
this.objectMapper = Objects.requireNonNull(objectMapper, "objectMapper must not be null");
this.stateClass = Objects.requireNonNull(stateClass, "stateClass must not be null");
this.agentPlatform = Objects.requireNonNull(agentPlatform, "agentPlatform must not be null");
this.aggregate = Objects.requireNonNull(aggregate, "aggregate must not be null");
+ this.maxMemories = maxMemories;
}
// -------------------------------------------------------------------------
@@ -511,8 +517,166 @@ public void resumeNextAgentTask(Consumer protocolRecordConsumer,
statusCode,
Instant.now());
tickEvents = Stream.concat(tickEvents, Stream.of(finishedEvent));
+
+ // Distill memories from successfully completed processes
+ if (statusCode == AgentProcessStatusCode.COMPLETED) {
+ List memoryEvents = distillMemories(agentProcess, state);
+ if (!memoryEvents.isEmpty()) {
+ tickEvents = Stream.concat(tickEvents, memoryEvents.stream());
+ }
+ }
}
delegate.processDomainEvents(tickEvents, task.agentProcessId(), protocolRecordConsumer, stateRecordSupplier);
}
+
+ // -------------------------------------------------------------------------
+ // Memory distillation
+ // -------------------------------------------------------------------------
+
+ /**
+ * Distills relevant memories from a successfully completed {@link AgentProcess}
+ * by running the {@link MemoryDistillerAgent} to completion.
+ *
+ *
Creates a separate agent process for the {@link MemoryDistillerAgent}, populates
+ * its blackboard with the completed process's history and blackboard objects, the
+ * current memories, and the maximum number of net new memories allowed, then runs
+ * the process to completion via {@link AgentProcess#run()}.
+ *
+ *
The net memory constraint ensures that
+ * {@code storedCount - revokedCount <= maxMemories - currentMemoryCount}, preventing
+ * the memory system from exceeding its configured capacity.
+ *
+ * @param completedProcess the agent process that has completed successfully
+ * @param state the current aggregate state
+ * @return a list of {@link MemoryStoredEvent} and {@link MemoryRevokedEvent} instances;
+ * may be empty if no memories need to be changed
+ */
+ private List distillMemories(AgentProcess completedProcess, AggregateState state) {
+ Agent memoryDistillerAgent = resolveMemoryDistillerAgent();
+ if (memoryDistillerAgent == null) {
+ logger.debug("MemoryDistillerAgent not deployed on the platform; skipping memory distillation");
+ return List.of();
+ }
+
+ List currentMemories = state instanceof MemoryAwareState mas
+ ? mas.getMemories()
+ : List.of();
+ int maxNewMemories = Math.max(0, maxMemories - currentMemories.size());
+
+ Map bindings = new LinkedHashMap<>();
+ bindings.put("history", completedProcess.getHistory());
+ bindings.put("blackboardObjects", completedProcess.getBlackboard().getObjects());
+ bindings.put("existingMemories", currentMemories);
+ bindings.put("maxNewMemories", maxNewMemories);
+
+ try {
+ AgentProcess distillerProcess = agentPlatform.createAgentProcess(
+ memoryDistillerAgent, ProcessOptions.DEFAULT, bindings);
+ distillerProcess.run();
+
+ MemoryDistillationResult result = distillerProcess.getBlackboard()
+ .last(MemoryDistillationResult.class);
+
+ if (result == null) {
+ logger.debug("MemoryDistillerAgent produced no result for aggregate {}", getName());
+ return List.of();
+ }
+
+ return translateDistillationResult(result, state.getAggregateId(), currentMemories, maxNewMemories);
+ } catch (Exception e) {
+ logger.warn("Memory distillation failed for aggregate {}; proceeding without memory updates",
+ getName(), e);
+ return List.of();
+ }
+ }
+
+ /**
+ * Resolves the {@link MemoryDistillerAgent} from the {@link AgentPlatform}.
+ *
+ * @return the resolved agent, or {@code null} if not deployed
+ */
+ private Agent resolveMemoryDistillerAgent() {
+ for (Agent agent : agentPlatform.agents()) {
+ if (MemoryDistillerAgent.AGENT_NAME.equals(agent.getName())) {
+ return agent;
+ }
+ }
+ return null;
+ }
+
+ /**
+ * Translates a {@link MemoryDistillationResult} into a list of domain events,
+ * enforcing the net memory limit.
+ *
+ *
The constraint {@code stored.size() - revoked.size() <= maxNewMemories} is
+ * enforced by truncating stored memories when the limit would be exceeded.
+ *
+ * @param result the distillation result from the agent
+ * @param aggregateId the aggregate identifier for the events
+ * @param currentMemories the current memories from the aggregate state
+ * @param maxNewMemories the maximum number of net new memories allowed
+ * @return an unmodifiable list of domain events
+ */
+ private List translateDistillationResult(MemoryDistillationResult result,
+ String aggregateId,
+ List currentMemories,
+ int maxNewMemories) {
+ List events = new ArrayList<>();
+ Instant now = Instant.now();
+
+ // Collect valid revocations first (only for memories that actually exist)
+ Set currentMemoryIds = new HashSet<>();
+ for (AgenticAggregateMemory mem : currentMemories) {
+ currentMemoryIds.add(mem.memoryId());
+ }
+
+ List validRevocations = new ArrayList<>();
+ if (result.revoked() != null) {
+ for (MemoryDistillationResult.RevokedMemory revoked : result.revoked()) {
+ if (revoked.memoryId() != null && currentMemoryIds.contains(revoked.memoryId())) {
+ validRevocations.add(revoked);
+ } else {
+ logger.debug("Skipping revocation of non-existent memory '{}' for aggregate {}",
+ revoked != null ? revoked.memoryId() : "null", getName());
+ }
+ }
+ }
+
+ // Calculate how many stored memories we can accept
+ int revokedCount = validRevocations.size();
+ int maxStoredCount = maxNewMemories + revokedCount;
+
+ // Add revocation events
+ for (MemoryDistillationResult.RevokedMemory revoked : validRevocations) {
+ events.add(new MemoryRevokedEvent(aggregateId, revoked.memoryId(), revoked.reason(), now));
+ }
+
+ // Add stored events (respecting the limit)
+ if (result.stored() != null) {
+ int storedCount = 0;
+ for (MemoryDistillationResult.StoredMemory stored : result.stored()) {
+ if (storedCount >= maxStoredCount) {
+ logger.debug("Memory distillation limit reached ({} stored, {} revoked, max {}); "
+ + "truncating remaining stored memories for aggregate {}",
+ storedCount, revokedCount, maxNewMemories, getName());
+ break;
+ }
+ events.add(new MemoryStoredEvent(
+ aggregateId,
+ UUID.randomUUID().toString(),
+ stored.subject(),
+ stored.fact(),
+ stored.citations(),
+ stored.reason(),
+ now));
+ storedCount++;
+ }
+ }
+
+ logger.debug("Memory distillation for aggregate {} produced {} stored and {} revoked events",
+ getName(), events.size() - validRevocations.size(), validRevocations.size());
+
+ return List.copyOf(events);
+ }
}
diff --git a/main/agentic/src/test/java/org/elasticsoftware/akces/agentic/embabel/MemoryDistillationResultTest.java b/main/agentic/src/test/java/org/elasticsoftware/akces/agentic/embabel/MemoryDistillationResultTest.java
new file mode 100644
index 00000000..aa4d1eea
--- /dev/null
+++ b/main/agentic/src/test/java/org/elasticsoftware/akces/agentic/embabel/MemoryDistillationResultTest.java
@@ -0,0 +1,76 @@
+/*
+ * Copyright 2022 - 2026 The Original Authors
+ *
+ * Licensed 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.elasticsoftware.akces.agentic.embabel;
+
+import org.junit.jupiter.api.Test;
+
+import java.util.List;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+/**
+ * Unit tests for {@link MemoryDistillationResult}.
+ */
+class MemoryDistillationResultTest {
+
+ @Test
+ void shouldCreateWithStoredAndRevokedMemories() {
+ var stored = List.of(
+ new MemoryDistillationResult.StoredMemory("testing", "Use JUnit 5", "src/test", "best practice"),
+ new MemoryDistillationResult.StoredMemory("logging", "Use SLF4J", "src/main", "consistency")
+ );
+ var revoked = List.of(
+ new MemoryDistillationResult.RevokedMemory("mem-old-1", "superseded by new info")
+ );
+
+ var result = new MemoryDistillationResult(stored, revoked);
+
+ assertThat(result.stored()).hasSize(2);
+ assertThat(result.revoked()).hasSize(1);
+ assertThat(result.stored().getFirst().subject()).isEqualTo("testing");
+ assertThat(result.stored().getFirst().fact()).isEqualTo("Use JUnit 5");
+ assertThat(result.revoked().getFirst().memoryId()).isEqualTo("mem-old-1");
+ }
+
+ @Test
+ void shouldCreateWithEmptyLists() {
+ var result = new MemoryDistillationResult(List.of(), List.of());
+
+ assertThat(result.stored()).isEmpty();
+ assertThat(result.revoked()).isEmpty();
+ }
+
+ @Test
+ void storedMemoryShouldExposeAllFields() {
+ var stored = new MemoryDistillationResult.StoredMemory(
+ "conventions", "Use records for DTOs", "src/main/Model.java:10", "immutability");
+
+ assertThat(stored.subject()).isEqualTo("conventions");
+ assertThat(stored.fact()).isEqualTo("Use records for DTOs");
+ assertThat(stored.citations()).isEqualTo("src/main/Model.java:10");
+ assertThat(stored.reason()).isEqualTo("immutability");
+ }
+
+ @Test
+ void revokedMemoryShouldExposeAllFields() {
+ var revoked = new MemoryDistillationResult.RevokedMemory("mem-123", "outdated");
+
+ assertThat(revoked.memoryId()).isEqualTo("mem-123");
+ assertThat(revoked.reason()).isEqualTo("outdated");
+ }
+}
diff --git a/main/agentic/src/test/java/org/elasticsoftware/akces/agentic/runtime/AgenticAggregateAutoCreateTest.java b/main/agentic/src/test/java/org/elasticsoftware/akces/agentic/runtime/AgenticAggregateAutoCreateTest.java
index 2baa6ab4..8b14b8e4 100644
--- a/main/agentic/src/test/java/org/elasticsoftware/akces/agentic/runtime/AgenticAggregateAutoCreateTest.java
+++ b/main/agentic/src/test/java/org/elasticsoftware/akces/agentic/runtime/AgenticAggregateAutoCreateTest.java
@@ -240,7 +240,7 @@ void initializeStateShouldCallGetCreateDomainEventAndDelegateToRuntime() throws
ObjectMapper objectMapper = JsonMapper.builder().build();
KafkaAgenticAggregateRuntime runtime = new KafkaAgenticAggregateRuntime(
- delegate, objectMapper, TestBotState.class, agentPlatform, testBot);
+ delegate, objectMapper, TestBotState.class, agentPlatform, testBot, 100);
when(delegate.getName()).thenReturn("TestBot");
@@ -275,7 +275,7 @@ public Class getStateClass() {
ObjectMapper objectMapper = JsonMapper.builder().build();
KafkaAgenticAggregateRuntime runtime = new KafkaAgenticAggregateRuntime(
- delegate, objectMapper, TestBotState.class, agentPlatform, nullReturningAggregate);
+ delegate, objectMapper, TestBotState.class, agentPlatform, nullReturningAggregate, 100);
assertThatNullPointerException()
.isThrownBy(() -> runtime.initializeState(pr -> {}, (der, ip) -> {}))
@@ -289,7 +289,7 @@ void initializeStateShouldPassConsumersThroughToDelegate() throws IOException {
ObjectMapper objectMapper = JsonMapper.builder().build();
KafkaAgenticAggregateRuntime runtime = new KafkaAgenticAggregateRuntime(
- delegate, objectMapper, TestBotState.class, agentPlatform, testBot);
+ delegate, objectMapper, TestBotState.class, agentPlatform, testBot, 100);
when(delegate.getName()).thenReturn("TestBot");
diff --git a/main/agentic/src/test/java/org/elasticsoftware/akces/agentic/runtime/KafkaAgenticAggregateRuntimeTest.java b/main/agentic/src/test/java/org/elasticsoftware/akces/agentic/runtime/KafkaAgenticAggregateRuntimeTest.java
index eb6cf968..8f94e5a4 100644
--- a/main/agentic/src/test/java/org/elasticsoftware/akces/agentic/runtime/KafkaAgenticAggregateRuntimeTest.java
+++ b/main/agentic/src/test/java/org/elasticsoftware/akces/agentic/runtime/KafkaAgenticAggregateRuntimeTest.java
@@ -108,7 +108,7 @@ public String getAggregateId() {
@BeforeEach
void setUp() {
objectMapper = JsonMapper.builder().build();
- runtime = new KafkaAgenticAggregateRuntime(delegate, objectMapper, TestMemoryState.class, agentPlatform, aggregate);
+ runtime = new KafkaAgenticAggregateRuntime(delegate, objectMapper, TestMemoryState.class, agentPlatform, aggregate, 100);
}
// -------------------------------------------------------------------------
@@ -145,7 +145,7 @@ void getMemoriesShouldReturnMemoriesFromMemoryAwareState() throws IOException {
@Test
void getMemoriesShouldReturnEmptyListForNonMemoryAwareState() throws IOException {
- var plainRuntime = new KafkaAgenticAggregateRuntime(delegate, objectMapper, PlainState.class, agentPlatform, aggregate);
+ var plainRuntime = new KafkaAgenticAggregateRuntime(delegate, objectMapper, PlainState.class, agentPlatform, aggregate, 100);
var state = new PlainState("agg-1");
byte[] payload = objectMapper.writeValueAsBytes(state);
@@ -235,7 +235,7 @@ void getAgentPlatformShouldReturnInjectedPlatform() {
void constructorShouldRejectNullDelegate() {
assertThatNullPointerException()
.isThrownBy(() -> new KafkaAgenticAggregateRuntime(
- null, objectMapper, TestMemoryState.class, agentPlatform, aggregate))
+ null, objectMapper, TestMemoryState.class, agentPlatform, aggregate, 100))
.withMessageContaining("delegate");
}
@@ -243,7 +243,7 @@ void constructorShouldRejectNullDelegate() {
void constructorShouldRejectNullObjectMapper() {
assertThatNullPointerException()
.isThrownBy(() -> new KafkaAgenticAggregateRuntime(
- delegate, null, TestMemoryState.class, agentPlatform, aggregate))
+ delegate, null, TestMemoryState.class, agentPlatform, aggregate, 100))
.withMessageContaining("objectMapper");
}
@@ -251,7 +251,7 @@ void constructorShouldRejectNullObjectMapper() {
void constructorShouldRejectNullStateClass() {
assertThatNullPointerException()
.isThrownBy(() -> new KafkaAgenticAggregateRuntime(
- delegate, objectMapper, null, agentPlatform, aggregate))
+ delegate, objectMapper, null, agentPlatform, aggregate, 100))
.withMessageContaining("stateClass");
}
@@ -259,7 +259,7 @@ void constructorShouldRejectNullStateClass() {
void constructorShouldRejectNullAgentPlatform() {
assertThatNullPointerException()
.isThrownBy(() -> new KafkaAgenticAggregateRuntime(
- delegate, objectMapper, TestMemoryState.class, null, aggregate))
+ delegate, objectMapper, TestMemoryState.class, null, aggregate, 100))
.withMessageContaining("agentPlatform");
}
@@ -267,7 +267,7 @@ void constructorShouldRejectNullAgentPlatform() {
void constructorShouldRejectNullAggregate() {
assertThatNullPointerException()
.isThrownBy(() -> new KafkaAgenticAggregateRuntime(
- delegate, objectMapper, TestMemoryState.class, agentPlatform, null))
+ delegate, objectMapper, TestMemoryState.class, agentPlatform, null, 100))
.withMessageContaining("aggregate");
}
diff --git a/main/agentic/src/test/java/org/elasticsoftware/akces/agentic/runtime/MemoryDistillationTest.java b/main/agentic/src/test/java/org/elasticsoftware/akces/agentic/runtime/MemoryDistillationTest.java
new file mode 100644
index 00000000..2ad98564
--- /dev/null
+++ b/main/agentic/src/test/java/org/elasticsoftware/akces/agentic/runtime/MemoryDistillationTest.java
@@ -0,0 +1,594 @@
+/*
+ * Copyright 2022 - 2026 The Original Authors
+ *
+ * Licensed 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.elasticsoftware.akces.agentic.runtime;
+
+import com.embabel.agent.core.Agent;
+import com.embabel.agent.core.AgentPlatform;
+import com.embabel.agent.core.AgentProcess;
+import com.embabel.agent.core.AgentProcessStatusCode;
+import com.embabel.agent.core.Blackboard;
+import com.embabel.agent.core.ProcessOptions;
+import org.elasticsoftware.akces.agentic.embabel.MemoryDistillationResult;
+import org.elasticsoftware.akces.agentic.embabel.MemoryDistillerAgent;
+import org.elasticsoftware.akces.agentic.events.AgentTaskFinishedEvent;
+import org.elasticsoftware.akces.agentic.events.MemoryRevokedEvent;
+import org.elasticsoftware.akces.agentic.events.MemoryStoredEvent;
+import org.elasticsoftware.akces.aggregate.*;
+import org.elasticsoftware.akces.commands.CommandBus;
+import org.elasticsoftware.akces.events.DomainEvent;
+import org.elasticsoftware.akces.kafka.KafkaAggregateRuntime;
+import org.elasticsoftware.akces.protocol.AggregateStateRecord;
+import org.elasticsoftware.akces.protocol.PayloadEncoding;
+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;
+import tools.jackson.databind.ObjectMapper;
+import tools.jackson.databind.json.JsonMapper;
+
+import java.io.IOException;
+import java.time.Instant;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Map;
+import java.util.stream.Stream;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.mockito.ArgumentMatchers.*;
+import static org.mockito.Mockito.*;
+
+/**
+ * Unit tests for the memory distillation integration in
+ * {@link KafkaAgenticAggregateRuntime#resumeNextAgentTask}.
+ */
+@ExtendWith(MockitoExtension.class)
+class MemoryDistillationTest {
+
+ /** State that implements both TaskAwareState and MemoryAwareState. */
+ record MemoryTaskState(
+ String id,
+ List assignedTasks,
+ List memories
+ ) implements AggregateState, TaskAwareState, MemoryAwareState {
+
+ @Override
+ public String getAggregateId() {
+ return id;
+ }
+
+ @Override
+ public List getAssignedTasks() {
+ return assignedTasks;
+ }
+
+ @Override
+ public TaskAwareState withAssignedTask(AssignedTask task) {
+ var newTasks = new ArrayList<>(assignedTasks);
+ newTasks.add(task);
+ return new MemoryTaskState(id, List.copyOf(newTasks), memories);
+ }
+
+ @Override
+ public TaskAwareState withoutAssignedTask(String agentProcessId) {
+ return new MemoryTaskState(id, assignedTasks.stream()
+ .filter(t -> !t.agentProcessId().equals(agentProcessId))
+ .toList(), memories);
+ }
+
+ @Override
+ public List getMemories() {
+ return memories;
+ }
+
+ @Override
+ public MemoryAwareState withMemory(AgenticAggregateMemory memory) {
+ var updated = new ArrayList<>(memories);
+ updated.add(memory);
+ return new MemoryTaskState(id, assignedTasks, List.copyOf(updated));
+ }
+
+ @Override
+ public MemoryAwareState withoutMemory(String memoryId) {
+ return new MemoryTaskState(id, assignedTasks, memories.stream()
+ .filter(m -> !m.memoryId().equals(memoryId))
+ .toList());
+ }
+ }
+
+ @Mock
+ private KafkaAggregateRuntime delegate;
+
+ @Mock
+ private AgentPlatform agentPlatform;
+
+ @Mock
+ private AgentProcess agentProcess;
+
+ @Mock
+ private AgentProcess distillerProcess;
+
+ @Mock
+ private Blackboard blackboard;
+
+ @Mock
+ private Blackboard distillerBlackboard;
+
+ @Mock
+ private CommandBus commandBus;
+
+ @Mock
+ private AgenticAggregate> aggregate;
+
+ @Mock
+ private Agent memoryDistillerAgent;
+
+ private ObjectMapper objectMapper;
+ private KafkaAgenticAggregateRuntime runtime;
+
+ @BeforeEach
+ void setUp() {
+ objectMapper = JsonMapper.builder().build();
+ runtime = new KafkaAgenticAggregateRuntime(
+ delegate, objectMapper, MemoryTaskState.class, agentPlatform, aggregate, 100);
+ }
+
+ // -------------------------------------------------------------------------
+ // Memory distillation on COMPLETED process
+ // -------------------------------------------------------------------------
+
+ @Test
+ @SuppressWarnings("unchecked")
+ void resumeShouldDistillMemoriesOnCompletedProcess() throws IOException {
+ var party = new HumanRequestingParty("user-1", "analyst");
+ var task = new AssignedTask("proc-1", "Task", party, Map.of(), Instant.now());
+ var state = new MemoryTaskState("agg-1", List.of(task), List.of());
+ byte[] payload = objectMapper.writeValueAsBytes(state);
+ var stateRecord = new AggregateStateRecord(null, "MemoryTaskState", 1, payload,
+ PayloadEncoding.JSON, "agg-1", null, 1L);
+ when(delegate.materializeState(stateRecord)).thenReturn(state);
+
+ when(agentPlatform.getAgentProcess("proc-1")).thenReturn(agentProcess);
+ when(agentProcess.getBlackboard()).thenReturn(blackboard);
+ when(blackboard.getObjects()).thenReturn(List.of());
+ when(delegate.getAllDomainEventTypes()).thenReturn(List.of());
+
+ // Process finishes with COMPLETED status
+ when(agentProcess.getFinished()).thenReturn(true);
+ when(agentProcess.getStatus()).thenReturn(AgentProcessStatusCode.COMPLETED);
+ when(agentProcess.getHistory()).thenReturn(List.of());
+
+ // Set up MemoryDistillerAgent resolution
+ when(memoryDistillerAgent.getName()).thenReturn(MemoryDistillerAgent.AGENT_NAME);
+ when(agentPlatform.agents()).thenReturn(List.of(memoryDistillerAgent));
+
+ // Set up distiller process
+ var distillationResult = new MemoryDistillationResult(
+ List.of(new MemoryDistillationResult.StoredMemory(
+ "testing", "Use JUnit 5", "src/test", "best practice")),
+ List.of());
+ when(agentPlatform.createAgentProcess(eq(memoryDistillerAgent), eq(ProcessOptions.DEFAULT), anyMap()))
+ .thenReturn(distillerProcess);
+ when(distillerProcess.getBlackboard()).thenReturn(distillerBlackboard);
+ when(distillerBlackboard.last(MemoryDistillationResult.class)).thenReturn(distillationResult);
+
+ // Capture the events stream
+ ArgumentCaptor> eventsCaptor = ArgumentCaptor.forClass(Stream.class);
+ runtime.resumeNextAgentTask(pr -> {}, () -> stateRecord, commandBus);
+
+ verify(delegate).processDomainEvents(eventsCaptor.capture(), eq("proc-1"), any(), any());
+ List events = eventsCaptor.getValue().toList();
+
+ // Should have: tick events (empty) + AgentTaskFinishedEvent + MemoryStoredEvent
+ assertThat(events).hasSize(2);
+ assertThat(events.get(0)).isInstanceOf(AgentTaskFinishedEvent.class);
+ assertThat(events.get(1)).isInstanceOf(MemoryStoredEvent.class);
+
+ MemoryStoredEvent storedEvent = (MemoryStoredEvent) events.get(1);
+ assertThat(storedEvent.agenticAggregateId()).isEqualTo("agg-1");
+ assertThat(storedEvent.subject()).isEqualTo("testing");
+ assertThat(storedEvent.fact()).isEqualTo("Use JUnit 5");
+
+ // Verify distiller process was run to completion
+ verify(distillerProcess).run();
+ }
+
+ @Test
+ @SuppressWarnings("unchecked")
+ void resumeShouldNotDistillMemoriesOnFailedProcess() throws IOException {
+ var party = new HumanRequestingParty("user-1", "analyst");
+ var task = new AssignedTask("proc-1", "Task", party, Map.of(), Instant.now());
+ var state = new MemoryTaskState("agg-1", List.of(task), List.of());
+ byte[] payload = objectMapper.writeValueAsBytes(state);
+ var stateRecord = new AggregateStateRecord(null, "MemoryTaskState", 1, payload,
+ PayloadEncoding.JSON, "agg-1", null, 1L);
+ when(delegate.materializeState(stateRecord)).thenReturn(state);
+
+ when(agentPlatform.getAgentProcess("proc-1")).thenReturn(agentProcess);
+ when(agentProcess.getBlackboard()).thenReturn(blackboard);
+ when(blackboard.getObjects()).thenReturn(List.of());
+ when(delegate.getAllDomainEventTypes()).thenReturn(List.of());
+
+ // Process finishes with FAILED status
+ when(agentProcess.getFinished()).thenReturn(true);
+ when(agentProcess.getStatus()).thenReturn(AgentProcessStatusCode.FAILED);
+
+ ArgumentCaptor> eventsCaptor = ArgumentCaptor.forClass(Stream.class);
+ runtime.resumeNextAgentTask(pr -> {}, () -> stateRecord, commandBus);
+
+ verify(delegate).processDomainEvents(eventsCaptor.capture(), eq("proc-1"), any(), any());
+ List events = eventsCaptor.getValue().toList();
+
+ // Should only have AgentTaskFinishedEvent, no memory distillation
+ assertThat(events).hasSize(1);
+ assertThat(events.getFirst()).isInstanceOf(AgentTaskFinishedEvent.class);
+
+ // Should NOT create a distiller process
+ verify(agentPlatform, never()).createAgentProcess(
+ argThat(a -> MemoryDistillerAgent.AGENT_NAME.equals(a.getName())),
+ any(), anyMap());
+ }
+
+ @Test
+ @SuppressWarnings("unchecked")
+ void resumeShouldSkipDistillationWhenDistillerAgentNotDeployed() throws IOException {
+ var party = new HumanRequestingParty("user-1", "analyst");
+ var task = new AssignedTask("proc-1", "Task", party, Map.of(), Instant.now());
+ var state = new MemoryTaskState("agg-1", List.of(task), List.of());
+ byte[] payload = objectMapper.writeValueAsBytes(state);
+ var stateRecord = new AggregateStateRecord(null, "MemoryTaskState", 1, payload,
+ PayloadEncoding.JSON, "agg-1", null, 1L);
+ when(delegate.materializeState(stateRecord)).thenReturn(state);
+
+ when(agentPlatform.getAgentProcess("proc-1")).thenReturn(agentProcess);
+ when(agentProcess.getBlackboard()).thenReturn(blackboard);
+ when(blackboard.getObjects()).thenReturn(List.of());
+ when(delegate.getAllDomainEventTypes()).thenReturn(List.of());
+
+ when(agentProcess.getFinished()).thenReturn(true);
+ when(agentProcess.getStatus()).thenReturn(AgentProcessStatusCode.COMPLETED);
+
+ // No MemoryDistillerAgent deployed
+ when(agentPlatform.agents()).thenReturn(List.of());
+
+ ArgumentCaptor> eventsCaptor = ArgumentCaptor.forClass(Stream.class);
+ runtime.resumeNextAgentTask(pr -> {}, () -> stateRecord, commandBus);
+
+ verify(delegate).processDomainEvents(eventsCaptor.capture(), eq("proc-1"), any(), any());
+ List events = eventsCaptor.getValue().toList();
+
+ // Should only have AgentTaskFinishedEvent
+ assertThat(events).hasSize(1);
+ assertThat(events.getFirst()).isInstanceOf(AgentTaskFinishedEvent.class);
+ }
+
+ @Test
+ @SuppressWarnings("unchecked")
+ void resumeShouldHandleDistillationFailureGracefully() throws IOException {
+ var party = new HumanRequestingParty("user-1", "analyst");
+ var task = new AssignedTask("proc-1", "Task", party, Map.of(), Instant.now());
+ var state = new MemoryTaskState("agg-1", List.of(task), List.of());
+ byte[] payload = objectMapper.writeValueAsBytes(state);
+ var stateRecord = new AggregateStateRecord(null, "MemoryTaskState", 1, payload,
+ PayloadEncoding.JSON, "agg-1", null, 1L);
+ when(delegate.materializeState(stateRecord)).thenReturn(state);
+
+ when(agentPlatform.getAgentProcess("proc-1")).thenReturn(agentProcess);
+ when(agentProcess.getBlackboard()).thenReturn(blackboard);
+ when(blackboard.getObjects()).thenReturn(List.of());
+ when(delegate.getAllDomainEventTypes()).thenReturn(List.of());
+
+ when(agentProcess.getFinished()).thenReturn(true);
+ when(agentProcess.getStatus()).thenReturn(AgentProcessStatusCode.COMPLETED);
+ when(agentProcess.getHistory()).thenReturn(List.of());
+
+ when(memoryDistillerAgent.getName()).thenReturn(MemoryDistillerAgent.AGENT_NAME);
+ when(agentPlatform.agents()).thenReturn(List.of(memoryDistillerAgent));
+
+ // Distiller throws an exception
+ when(agentPlatform.createAgentProcess(eq(memoryDistillerAgent), eq(ProcessOptions.DEFAULT), anyMap()))
+ .thenThrow(new RuntimeException("LLM unavailable"));
+
+ ArgumentCaptor> eventsCaptor = ArgumentCaptor.forClass(Stream.class);
+ runtime.resumeNextAgentTask(pr -> {}, () -> stateRecord, commandBus);
+
+ verify(delegate).processDomainEvents(eventsCaptor.capture(), eq("proc-1"), any(), any());
+ List events = eventsCaptor.getValue().toList();
+
+ // Should still emit AgentTaskFinishedEvent despite distillation failure
+ assertThat(events).hasSize(1);
+ assertThat(events.getFirst()).isInstanceOf(AgentTaskFinishedEvent.class);
+ }
+
+ // -------------------------------------------------------------------------
+ // Memory limit enforcement
+ // -------------------------------------------------------------------------
+
+ @Test
+ @SuppressWarnings("unchecked")
+ void distillationShouldEnforceMaxMemoriesLimit() throws IOException {
+ // Set up runtime with maxMemories = 5
+ runtime = new KafkaAgenticAggregateRuntime(
+ delegate, objectMapper, MemoryTaskState.class, agentPlatform, aggregate, 5);
+
+ // State already has 3 memories, so maxNewMemories = 2
+ var existingMemories = List.of(
+ new AgenticAggregateMemory("mem-1", "s1", "f1", "c1", "r1", Instant.now()),
+ new AgenticAggregateMemory("mem-2", "s2", "f2", "c2", "r2", Instant.now()),
+ new AgenticAggregateMemory("mem-3", "s3", "f3", "c3", "r3", Instant.now())
+ );
+ var party = new HumanRequestingParty("user-1", "analyst");
+ var task = new AssignedTask("proc-1", "Task", party, Map.of(), Instant.now());
+ var state = new MemoryTaskState("agg-1", List.of(task), existingMemories);
+ byte[] payload = objectMapper.writeValueAsBytes(state);
+ var stateRecord = new AggregateStateRecord(null, "MemoryTaskState", 1, payload,
+ PayloadEncoding.JSON, "agg-1", null, 1L);
+ when(delegate.materializeState(stateRecord)).thenReturn(state);
+
+ when(agentPlatform.getAgentProcess("proc-1")).thenReturn(agentProcess);
+ when(agentProcess.getBlackboard()).thenReturn(blackboard);
+ when(blackboard.getObjects()).thenReturn(List.of());
+ when(delegate.getAllDomainEventTypes()).thenReturn(List.of());
+
+ when(agentProcess.getFinished()).thenReturn(true);
+ when(agentProcess.getStatus()).thenReturn(AgentProcessStatusCode.COMPLETED);
+ when(agentProcess.getHistory()).thenReturn(List.of());
+
+ when(memoryDistillerAgent.getName()).thenReturn(MemoryDistillerAgent.AGENT_NAME);
+ when(agentPlatform.agents()).thenReturn(List.of(memoryDistillerAgent));
+
+ // Agent tries to store 5 memories but only 2 should be allowed (maxMemories=5, existing=3)
+ var distillationResult = new MemoryDistillationResult(
+ List.of(
+ new MemoryDistillationResult.StoredMemory("s1", "fact1", "c1", "r1"),
+ new MemoryDistillationResult.StoredMemory("s2", "fact2", "c2", "r2"),
+ new MemoryDistillationResult.StoredMemory("s3", "fact3", "c3", "r3"),
+ new MemoryDistillationResult.StoredMemory("s4", "fact4", "c4", "r4"),
+ new MemoryDistillationResult.StoredMemory("s5", "fact5", "c5", "r5")
+ ),
+ List.of());
+ when(agentPlatform.createAgentProcess(eq(memoryDistillerAgent), eq(ProcessOptions.DEFAULT), anyMap()))
+ .thenReturn(distillerProcess);
+ when(distillerProcess.getBlackboard()).thenReturn(distillerBlackboard);
+ when(distillerBlackboard.last(MemoryDistillationResult.class)).thenReturn(distillationResult);
+
+ ArgumentCaptor> eventsCaptor = ArgumentCaptor.forClass(Stream.class);
+ runtime.resumeNextAgentTask(pr -> {}, () -> stateRecord, commandBus);
+
+ verify(delegate).processDomainEvents(eventsCaptor.capture(), eq("proc-1"), any(), any());
+ List events = eventsCaptor.getValue().toList();
+
+ // 1 finished + 2 stored (truncated from 5)
+ assertThat(events).hasSize(3);
+ assertThat(events.get(0)).isInstanceOf(AgentTaskFinishedEvent.class);
+ long storedCount = events.stream().filter(e -> e instanceof MemoryStoredEvent).count();
+ assertThat(storedCount).isEqualTo(2);
+ }
+
+ @Test
+ @SuppressWarnings("unchecked")
+ void distillationShouldAllowMoreStoredWhenMemoriesAreRevoked() throws IOException {
+ // maxMemories = 5, existing = 4, so maxNew = 1
+ // But we also revoke 2, so we can store up to 3 (1 + 2)
+ runtime = new KafkaAgenticAggregateRuntime(
+ delegate, objectMapper, MemoryTaskState.class, agentPlatform, aggregate, 5);
+
+ var existingMemories = List.of(
+ new AgenticAggregateMemory("mem-1", "s1", "f1", "c1", "r1", Instant.now()),
+ new AgenticAggregateMemory("mem-2", "s2", "f2", "c2", "r2", Instant.now()),
+ new AgenticAggregateMemory("mem-3", "s3", "f3", "c3", "r3", Instant.now()),
+ new AgenticAggregateMemory("mem-4", "s4", "f4", "c4", "r4", Instant.now())
+ );
+ var party = new HumanRequestingParty("user-1", "analyst");
+ var task = new AssignedTask("proc-1", "Task", party, Map.of(), Instant.now());
+ var state = new MemoryTaskState("agg-1", List.of(task), existingMemories);
+ byte[] payload = objectMapper.writeValueAsBytes(state);
+ var stateRecord = new AggregateStateRecord(null, "MemoryTaskState", 1, payload,
+ PayloadEncoding.JSON, "agg-1", null, 1L);
+ when(delegate.materializeState(stateRecord)).thenReturn(state);
+
+ when(agentPlatform.getAgentProcess("proc-1")).thenReturn(agentProcess);
+ when(agentProcess.getBlackboard()).thenReturn(blackboard);
+ when(blackboard.getObjects()).thenReturn(List.of());
+ when(delegate.getAllDomainEventTypes()).thenReturn(List.of());
+
+ when(agentProcess.getFinished()).thenReturn(true);
+ when(agentProcess.getStatus()).thenReturn(AgentProcessStatusCode.COMPLETED);
+ when(agentProcess.getHistory()).thenReturn(List.of());
+
+ when(memoryDistillerAgent.getName()).thenReturn(MemoryDistillerAgent.AGENT_NAME);
+ when(agentPlatform.agents()).thenReturn(List.of(memoryDistillerAgent));
+
+ // Revoke 2, store 3
+ var distillationResult = new MemoryDistillationResult(
+ List.of(
+ new MemoryDistillationResult.StoredMemory("s1", "new-fact1", "c1", "r1"),
+ new MemoryDistillationResult.StoredMemory("s2", "new-fact2", "c2", "r2"),
+ new MemoryDistillationResult.StoredMemory("s3", "new-fact3", "c3", "r3")
+ ),
+ List.of(
+ new MemoryDistillationResult.RevokedMemory("mem-1", "outdated"),
+ new MemoryDistillationResult.RevokedMemory("mem-2", "superseded")
+ ));
+ when(agentPlatform.createAgentProcess(eq(memoryDistillerAgent), eq(ProcessOptions.DEFAULT), anyMap()))
+ .thenReturn(distillerProcess);
+ when(distillerProcess.getBlackboard()).thenReturn(distillerBlackboard);
+ when(distillerBlackboard.last(MemoryDistillationResult.class)).thenReturn(distillationResult);
+
+ ArgumentCaptor> eventsCaptor = ArgumentCaptor.forClass(Stream.class);
+ runtime.resumeNextAgentTask(pr -> {}, () -> stateRecord, commandBus);
+
+ verify(delegate).processDomainEvents(eventsCaptor.capture(), eq("proc-1"), any(), any());
+ List events = eventsCaptor.getValue().toList();
+
+ // 1 finished + 2 revoked + 3 stored = 6
+ assertThat(events).hasSize(6);
+ assertThat(events.get(0)).isInstanceOf(AgentTaskFinishedEvent.class);
+
+ long revokedCount = events.stream().filter(e -> e instanceof MemoryRevokedEvent).count();
+ long storedCount = events.stream().filter(e -> e instanceof MemoryStoredEvent).count();
+ assertThat(revokedCount).isEqualTo(2);
+ assertThat(storedCount).isEqualTo(3);
+ }
+
+ @Test
+ @SuppressWarnings("unchecked")
+ void distillationShouldSkipRevocationOfNonExistentMemories() throws IOException {
+ var party = new HumanRequestingParty("user-1", "analyst");
+ var task = new AssignedTask("proc-1", "Task", party, Map.of(), Instant.now());
+ var existingMemories = List.of(
+ new AgenticAggregateMemory("mem-1", "s1", "f1", "c1", "r1", Instant.now())
+ );
+ var state = new MemoryTaskState("agg-1", List.of(task), existingMemories);
+ byte[] payload = objectMapper.writeValueAsBytes(state);
+ var stateRecord = new AggregateStateRecord(null, "MemoryTaskState", 1, payload,
+ PayloadEncoding.JSON, "agg-1", null, 1L);
+ when(delegate.materializeState(stateRecord)).thenReturn(state);
+
+ when(agentPlatform.getAgentProcess("proc-1")).thenReturn(agentProcess);
+ when(agentProcess.getBlackboard()).thenReturn(blackboard);
+ when(blackboard.getObjects()).thenReturn(List.of());
+ when(delegate.getAllDomainEventTypes()).thenReturn(List.of());
+
+ when(agentProcess.getFinished()).thenReturn(true);
+ when(agentProcess.getStatus()).thenReturn(AgentProcessStatusCode.COMPLETED);
+ when(agentProcess.getHistory()).thenReturn(List.of());
+
+ when(memoryDistillerAgent.getName()).thenReturn(MemoryDistillerAgent.AGENT_NAME);
+ when(agentPlatform.agents()).thenReturn(List.of(memoryDistillerAgent));
+
+ // Try to revoke a memory that doesn't exist
+ var distillationResult = new MemoryDistillationResult(
+ List.of(),
+ List.of(
+ new MemoryDistillationResult.RevokedMemory("mem-1", "outdated"),
+ new MemoryDistillationResult.RevokedMemory("non-existent", "cleanup")
+ ));
+ when(agentPlatform.createAgentProcess(eq(memoryDistillerAgent), eq(ProcessOptions.DEFAULT), anyMap()))
+ .thenReturn(distillerProcess);
+ when(distillerProcess.getBlackboard()).thenReturn(distillerBlackboard);
+ when(distillerBlackboard.last(MemoryDistillationResult.class)).thenReturn(distillationResult);
+
+ ArgumentCaptor> eventsCaptor = ArgumentCaptor.forClass(Stream.class);
+ runtime.resumeNextAgentTask(pr -> {}, () -> stateRecord, commandBus);
+
+ verify(delegate).processDomainEvents(eventsCaptor.capture(), eq("proc-1"), any(), any());
+ List events = eventsCaptor.getValue().toList();
+
+ // 1 finished + 1 revoked (only mem-1, not non-existent)
+ long revokedCount = events.stream().filter(e -> e instanceof MemoryRevokedEvent).count();
+ assertThat(revokedCount).isEqualTo(1);
+
+ MemoryRevokedEvent revokedEvent = events.stream()
+ .filter(e -> e instanceof MemoryRevokedEvent)
+ .map(e -> (MemoryRevokedEvent) e)
+ .findFirst()
+ .orElseThrow();
+ assertThat(revokedEvent.memoryId()).isEqualTo("mem-1");
+ }
+
+ @Test
+ @SuppressWarnings("unchecked")
+ void distillationShouldHandleNullResultGracefully() throws IOException {
+ var party = new HumanRequestingParty("user-1", "analyst");
+ var task = new AssignedTask("proc-1", "Task", party, Map.of(), Instant.now());
+ var state = new MemoryTaskState("agg-1", List.of(task), List.of());
+ byte[] payload = objectMapper.writeValueAsBytes(state);
+ var stateRecord = new AggregateStateRecord(null, "MemoryTaskState", 1, payload,
+ PayloadEncoding.JSON, "agg-1", null, 1L);
+ when(delegate.materializeState(stateRecord)).thenReturn(state);
+
+ when(agentPlatform.getAgentProcess("proc-1")).thenReturn(agentProcess);
+ when(agentProcess.getBlackboard()).thenReturn(blackboard);
+ when(blackboard.getObjects()).thenReturn(List.of());
+ when(delegate.getAllDomainEventTypes()).thenReturn(List.of());
+
+ when(agentProcess.getFinished()).thenReturn(true);
+ when(agentProcess.getStatus()).thenReturn(AgentProcessStatusCode.COMPLETED);
+ when(agentProcess.getHistory()).thenReturn(List.of());
+
+ when(memoryDistillerAgent.getName()).thenReturn(MemoryDistillerAgent.AGENT_NAME);
+ when(agentPlatform.agents()).thenReturn(List.of(memoryDistillerAgent));
+
+ // Distiller produces no result
+ when(agentPlatform.createAgentProcess(eq(memoryDistillerAgent), eq(ProcessOptions.DEFAULT), anyMap()))
+ .thenReturn(distillerProcess);
+ when(distillerProcess.getBlackboard()).thenReturn(distillerBlackboard);
+ when(distillerBlackboard.last(MemoryDistillationResult.class)).thenReturn(null);
+
+ ArgumentCaptor> eventsCaptor = ArgumentCaptor.forClass(Stream.class);
+ runtime.resumeNextAgentTask(pr -> {}, () -> stateRecord, commandBus);
+
+ verify(delegate).processDomainEvents(eventsCaptor.capture(), eq("proc-1"), any(), any());
+ List events = eventsCaptor.getValue().toList();
+
+ // Should only have AgentTaskFinishedEvent
+ assertThat(events).hasSize(1);
+ assertThat(events.getFirst()).isInstanceOf(AgentTaskFinishedEvent.class);
+ }
+
+ @Test
+ @SuppressWarnings("unchecked")
+ void distillationShouldPassCorrectBindingsToDistillerProcess() throws IOException {
+ var party = new HumanRequestingParty("user-1", "analyst");
+ var task = new AssignedTask("proc-1", "Task", party, Map.of(), Instant.now());
+ var existingMemory = new AgenticAggregateMemory(
+ "mem-1", "testing", "Use JUnit", "cite", "reason", Instant.now());
+ var state = new MemoryTaskState("agg-1", List.of(task), List.of(existingMemory));
+ byte[] payload = objectMapper.writeValueAsBytes(state);
+ var stateRecord = new AggregateStateRecord(null, "MemoryTaskState", 1, payload,
+ PayloadEncoding.JSON, "agg-1", null, 1L);
+ when(delegate.materializeState(stateRecord)).thenReturn(state);
+
+ when(agentPlatform.getAgentProcess("proc-1")).thenReturn(agentProcess);
+ when(agentProcess.getBlackboard()).thenReturn(blackboard);
+ when(blackboard.getObjects()).thenReturn(List.of("some-object"));
+ when(delegate.getAllDomainEventTypes()).thenReturn(List.of());
+
+ when(agentProcess.getFinished()).thenReturn(true);
+ when(agentProcess.getStatus()).thenReturn(AgentProcessStatusCode.COMPLETED);
+ when(agentProcess.getHistory()).thenReturn(List.of());
+
+ when(memoryDistillerAgent.getName()).thenReturn(MemoryDistillerAgent.AGENT_NAME);
+ when(agentPlatform.agents()).thenReturn(List.of(memoryDistillerAgent));
+
+ when(agentPlatform.createAgentProcess(eq(memoryDistillerAgent), eq(ProcessOptions.DEFAULT), anyMap()))
+ .thenReturn(distillerProcess);
+ when(distillerProcess.getBlackboard()).thenReturn(distillerBlackboard);
+ when(distillerBlackboard.last(MemoryDistillationResult.class)).thenReturn(null);
+
+ @SuppressWarnings("rawtypes")
+ ArgumentCaptor