Skip to content

Add MemoryDistillerAgent for post-completion memory distillation#335

Merged
jwijgerd merged 11 commits into
mainfrom
copilot/distill-relevant-memories-completed-process
Apr 11, 2026
Merged

Add MemoryDistillerAgent for post-completion memory distillation#335
jwijgerd merged 11 commits into
mainfrom
copilot/distill-relevant-memories-completed-process

Conversation

Copilot AI commented Apr 11, 2026

Copy link
Copy Markdown
Contributor
  • MemoryDistillationInput — strongly-typed record for Embabel type-based Blackboard injection
  • MemoryDistillerAgent — GOAP planner, ObjectMapper, JSON markdown fencing, no explicit return format
  • MemoryDistillationResult — uses MemoryStoredEvent/MemoryRevokedEvent directly
  • @AgenticAggregateInfomaxTotalMemories (default 50), maxMemoriesAdded (default 5)
  • Agent naming: DefaultAgent.AGENT_NAME = "Default", MemoryDistillerAgent.AGENT_NAME = "MemoryDistiller"
  • Runtime: capacity enforcement, revocation de-duplication, WARN for missing agent
  • Remove unused aggregateId param from translateDistillationResult — all log messages already use getName()
  • Verify JSON bracket correctness in MemoryDistillerAgent prompt builder
  • All 125 tests passing

Copilot AI and others added 2 commits April 11, 2026 11:31
…ocess

- 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>
@jwijgerd
jwijgerd marked this pull request as ready for review April 11, 2026 11:38
Copilot AI review requested due to automatic review settings April 11, 2026 11:38

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Adds post-completion “memory distillation” to the agentic runtime: when an AgentProcess finishes COMPLETED, a dedicated Embabel agent is run to propose MemoryStoredEvent / MemoryRevokedEvent updates, constrained by the aggregate’s maxMemories.

Changes:

  • Integrates memory distillation into KafkaAgenticAggregateRuntime.resumeNextAgentTask() for COMPLETED processes, with max-memory enforcement.
  • Introduces MemoryDistillerAgent + structured output record MemoryDistillationResult.
  • Updates runtime factory wiring and expands/adjusts unit tests for the new constructor and distillation behavior.

Reviewed changes

Copilot reviewed 9 out of 9 changed files in this pull request and generated 7 comments.

Show a summary per file
File Description
main/agentic/src/main/java/org/elasticsoftware/akces/agentic/runtime/KafkaAgenticAggregateRuntime.java Adds maxMemories configuration and runs a distiller agent on COMPLETED processes; translates result into memory events with capacity enforcement.
main/agentic/src/main/java/org/elasticsoftware/akces/agentic/embabel/MemoryDistillerAgent.java New Embabel agent that builds an LLM prompt from process context to produce a MemoryDistillationResult.
main/agentic/src/main/java/org/elasticsoftware/akces/agentic/embabel/MemoryDistillationResult.java New structured LLM output record for stored/revoked memories.
main/agentic/src/main/java/org/elasticsoftware/akces/agentic/beans/AgenticAggregateRuntimeFactory.java Wires @AgenticAggregateInfo.maxMemories() into the runtime constructor.
main/agentic/src/test/java/org/elasticsoftware/akces/agentic/runtime/MemoryDistillationTest.java New unit tests for distillation integration, limits, skipping behavior, and bindings.
main/agentic/src/test/java/org/elasticsoftware/akces/agentic/runtime/ResumeNextAgentTaskTest.java Updates runtime construction to pass maxMemories.
main/agentic/src/test/java/org/elasticsoftware/akces/agentic/runtime/KafkaAgenticAggregateRuntimeTest.java Updates runtime construction to pass maxMemories.
main/agentic/src/test/java/org/elasticsoftware/akces/agentic/runtime/AgenticAggregateAutoCreateTest.java Updates runtime construction to pass maxMemories.
main/agentic/src/test/java/org/elasticsoftware/akces/agentic/embabel/MemoryDistillationResultTest.java Adds tests for the new MemoryDistillationResult record.

Comment on lines +82 to +85
List<?> history = blackboard.objectsOfType(ActionInvocation.class);
List<?> blackboardObjects = (List<?>) blackboard.get("blackboardObjects");
List<?> existingMemories = (List<?>) blackboard.get("existingMemories");
int maxNewMemories = (int) blackboard.get("maxNewMemories");

Copilot AI Apr 11, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

distillMemories builds a "history" binding (see runtime), but this agent ignores it and instead reads blackboard.objectsOfType(ActionInvocation.class). Unless the distiller process itself adds ActionInvocation objects, the prompt will likely always show “(no actions recorded)”. Read the history binding from the blackboard (and fall back safely if it’s missing) so the distiller sees the completed process history as intended.

Suggested change
List<?> history = blackboard.objectsOfType(ActionInvocation.class);
List<?> blackboardObjects = (List<?>) blackboard.get("blackboardObjects");
List<?> existingMemories = (List<?>) blackboard.get("existingMemories");
int maxNewMemories = (int) blackboard.get("maxNewMemories");
Object historyBinding = blackboard.get("history");
List<?> history = historyBinding instanceof List<?> historyList
? historyList
: blackboard.objectsOfType(ActionInvocation.class);
Object blackboardObjectsBinding = blackboard.get("blackboardObjects");
List<?> blackboardObjects = blackboardObjectsBinding instanceof List<?> blackboardObjectList
? blackboardObjectList
: List.of();
Object existingMemoriesBinding = blackboard.get("existingMemories");
List<?> existingMemories = existingMemoriesBinding instanceof List<?> memoryList
? memoryList
: List.of();
Object maxNewMemoriesBinding = blackboard.get("maxNewMemories");
int maxNewMemories = maxNewMemoriesBinding instanceof Number number
? number.intValue()
: 0;

Copilot uses AI. Check for mistakes.
Comment on lines +123 to +129
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 {

Copilot AI Apr 11, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This prompt construction serializes all blackboard objects (via obj.toString()) and sends them to the default LLM. That can exfiltrate secrets/PII or large internal payloads to an external model provider and can also blow up prompt size. Consider whitelisting/summarizing only safe + bounded fields, redacting sensitive patterns, and/or enforcing a strict size limit before sending content to the LLM.

Copilot uses AI. Check for mistakes.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

don't use obj.toString() here but use an (injected) ObjectMapper. Annotations can be used to avoid serializing dangerous information and exposing it (outside the scope). Use this approach for all data. Also take a look at how Embabel exposes the Blackboard to a LLM

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Injected ObjectMapper via constructor and now use objectMapper.writeValueAsString(obj) for all serialization (history, blackboard objects, and existing memories). Falls back to toString() only if serialization fails. Commit 3103bbc.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Injected ObjectMapper via constructor and now use objectMapper.writeValueAsString(obj) for all data serialization in the prompt. Falls back to toString() only if serialization fails. Commit 3103bbc.

Comment on lines +521 to +577
// Distill memories from successfully completed processes
if (statusCode == AgentProcessStatusCode.COMPLETED) {
List<DomainEvent> 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.
*
* <p>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()}.
*
* <p>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<DomainEvent> 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<AgenticAggregateMemory> currentMemories = state instanceof MemoryAwareState mas
? mas.getMemories()
: List.of();
int maxNewMemories = Math.max(0, maxMemories - currentMemories.size());

Map<String, Object> 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();

Copilot AI Apr 11, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

resumeNextAgentTask is invoked inside AgenticAggregatePartition.resumeAgentTasks() after producer.beginTransaction(); running distillerProcess.run() here performs a full (potentially slow) LLM call while holding an open Kafka transaction. This can significantly increase transaction duration and risk timeouts/abort under load. Consider moving memory distillation outside the Kafka transaction (e.g., enqueue a follow-up command/event for distillation), or make it non-blocking / bounded with timeouts so it can’t stall the poll/transaction loop.

Copilot uses AI. Check for mistakes.
if (storedCount >= maxStoredCount) {
logger.debug("Memory distillation limit reached ({} stored, {} revoked, max {}); "
+ "truncating remaining stored memories for aggregate {}",
storedCount, revokedCount, maxNewMemories, getName());

Copilot AI Apr 11, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The debug log message uses maxNewMemories for the “max” placeholder, but the truncation threshold is maxStoredCount (= maxNewMemories + revokedCount). This makes the log misleading when revocations increase capacity. Log maxStoredCount (and/or both values) so the message reflects the actual enforced limit.

Suggested change
storedCount, revokedCount, maxNewMemories, getName());
storedCount, revokedCount, maxStoredCount, getName());

Copilot uses AI. Check for mistakes.
Comment on lines +20 to +75
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");
}

Copilot AI Apr 11, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This test class is exercising the autogenerated accessors of a Java record in isolation. These tests tend to be brittle/low-signal (the JVM already guarantees record component accessors) and increase maintenance without validating any Akces behavior. Prefer removing these tests or folding any meaningful assertions into tests that cover the record’s usage in the runtime integration.

Suggested change
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");
}
/**
* Dedicated unit tests for {@link MemoryDistillationResult} were removed because they only
* exercised compiler-generated Java record accessors in isolation. Any meaningful verification
* of this type should happen through higher-level tests that cover its runtime usage.
*/
class MemoryDistillationResultTest {

Copilot uses AI. Check for mistakes.
Comment on lines +628 to +647
// Collect valid revocations first (only for memories that actually exist)
Set<String> currentMemoryIds = new HashSet<>();
for (AgenticAggregateMemory mem : currentMemories) {
currentMemoryIds.add(mem.memoryId());
}

List<MemoryDistillationResult.RevokedMemory> 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.memoryId(), getName());
}
}
}

// Calculate how many stored memories we can accept
int revokedCount = validRevocations.size();

Copilot AI Apr 11, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

maxStoredCount is derived from validRevocations.size(), but validRevocations can include duplicate memoryIds. Duplicate revocations typically become no-ops at apply time, which would let the code accept too many stored memories and potentially exceed maxMemories. De-duplicate revocations by memoryId (and base revokedCount on unique, actually-present IDs) before calculating the allowed stored count.

Suggested change
// Collect valid revocations first (only for memories that actually exist)
Set<String> currentMemoryIds = new HashSet<>();
for (AgenticAggregateMemory mem : currentMemories) {
currentMemoryIds.add(mem.memoryId());
}
List<MemoryDistillationResult.RevokedMemory> 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.memoryId(), getName());
}
}
}
// Calculate how many stored memories we can accept
int revokedCount = validRevocations.size();
// Collect valid revocations first (only for memories that actually exist),
// de-duplicated by memoryId so capacity calculations match effective revocations.
Set<String> currentMemoryIds = new HashSet<>();
for (AgenticAggregateMemory mem : currentMemories) {
currentMemoryIds.add(mem.memoryId());
}
List<MemoryDistillationResult.RevokedMemory> validRevocations = new ArrayList<>();
Set<String> revokedMemoryIds = new HashSet<>();
if (result.revoked() != null) {
for (MemoryDistillationResult.RevokedMemory revoked : result.revoked()) {
String memoryId = revoked.memoryId();
if (memoryId == null || !currentMemoryIds.contains(memoryId)) {
logger.debug("Skipping revocation of non-existent memory '{}' for aggregate {}",
memoryId, getName());
continue;
}
if (!revokedMemoryIds.add(memoryId)) {
logger.debug("Skipping duplicate revocation of memory '{}' for aggregate {}",
memoryId, getName());
continue;
}
validRevocations.add(revoked);
}
}
// Calculate how many stored memories we can accept
int revokedCount = revokedMemoryIds.size();

Copilot uses AI. Check for mistakes.
return new KafkaAgenticAggregateRuntime(
kafkaRuntime, objectMapper, agenticInfo.stateClass(), agentPlatform, aggregate);
kafkaRuntime, objectMapper, agenticInfo.stateClass(), agentPlatform, aggregate,
agenticInfo.maxMemories());

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We need an extra param in the agentic info annotation: maxMemoriesAdded, rename the current maxMemories param to MaxTotalMemories. Ensure both are passes into the MemoryDistillerAgent

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Added maxMemoriesAdded (default 10) and renamed maxMemories to maxTotalMemories in @AgenticAggregateInfo. Both values are passed into the MemoryDistillerAgent via blackboard bindings (maxTotalMemories and maxMemoriesAdded). The effective limit is min(capacityLeft, maxMemoriesAdded). Commit 6aa01aa.

* @param revoked the list of memories to revoke; each entry contains memoryId and reason
*/
public record MemoryDistillationResult(
List<StoredMemory> stored,

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

use the MemoryStoredEvent directly, not need for intermediate DTO

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

MemoryDistillationResult now uses List<MemoryStoredEvent> directly. Commit 6aa01aa.

*/
public record MemoryDistillationResult(
List<StoredMemory> stored,
List<RevokedMemory> revoked

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

use the MemoryRevokedEvent directly

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

MemoryDistillationResult now uses List<MemoryRevokedEvent> directly. Commit 6aa01aa.

* 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";

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

rename to MemoryDistiller (not the Class). Also apply the same pattern to the exsiting DefaultAgent

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Renamed AGENT_NAME to "MemoryDistiller" in MemoryDistillerAgent and to "Default" in DefaultAgent (same pattern). Commit 6aa01aa.

.createObject(prompt, MemoryDistillationResult.class);
}

private String buildPrompt(List<?> history,

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

use multi-line String here for readability

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Replaced the StringBuilder.append() chains with Java text blocks (multi-line strings) for readability. Commit 6aa01aa.

private List<DomainEvent> distillMemories(AgentProcess completedProcess, AggregateState state) {
Agent memoryDistillerAgent = resolveMemoryDistillerAgent();
if (memoryDistillerAgent == null) {
logger.debug("MemoryDistillerAgent not deployed on the platform; skipping memory distillation");

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

make this a warning, this cannot happen so something is wrongly configured. probably path scanning

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Changed to logger.warn(...). Commit 6aa01aa.

…e agent names, multi-line prompt, warn log

- Rename maxMemories to maxTotalMemories and add maxMemoriesAdded in @AgenticAggregateInfo
- Use MemoryStoredEvent/MemoryRevokedEvent directly in MemoryDistillationResult (no intermediate DTOs)
- Rename agent names: DefaultAgent → "Default", MemoryDistillerAgent → "MemoryDistiller"
- Use multi-line String (text blocks) in MemoryDistillerAgent prompt building
- Change "not deployed" log level from DEBUG to WARN
- Pass both maxTotalMemories and maxMemoriesAdded to distiller bindings
- De-duplicate revocations by memoryId
- Add safe binding reads with instanceof checks
- Update all tests for new signatures and types

Agent-Logs-Url: https://github.com/elasticsoftwarefoundation/akces-framework/sessions/40e6b583-f254-41d2-95c4-718e34edd19e

Co-authored-by: jwijgerd <914840+jwijgerd@users.noreply.github.com>
@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, 10);
Copilot AI requested a review from jwijgerd April 11, 2026 12:02
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
*/
@Agent(name = MemoryDistillerAgent.AGENT_NAME,
description = "Distills relevant memories from a completed agent process",
planner = PlannerType.UTILITY)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

since we have an AchievesGoal we should use GOAP here

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Changed to PlannerType.GOAP in commit 3103bbc.

Comment on lines +123 to +129
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 {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

don't use obj.toString() here but use an (injected) ObjectMapper. Annotations can be used to avoid serializing dangerous information and exposing it (outside the scope). Use this approach for all data. Also take a look at how Embabel exposes the Blackboard to a LLM

sb.append("\nEXISTING MEMORIES:\n");
if (existingMemories != null && !existingMemories.isEmpty()) {
for (Object memory : existingMemories) {
sb.append("- ").append(memory).append("\n");

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

use ObjectMapper to serialize

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Now using objectMapper.writeValueAsString(memory) to serialize existing memories. Commit 3103bbc.


sb.append("""

Return a JSON object with 'stored' (list of MemoryStoredEvent instances) \

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

see how Embabel ensure that the LLM knows what to return. I think this is already handled by calling .createObject on the prompt

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Removed the explicit return format instructions from the prompt. The .createObject(prompt, MemoryDistillationResult.class) call already provides the LLM with the required schema. Commit 3103bbc.

… return format

- Change MemoryDistillerAgent planner from UTILITY to GOAP (has @AchievesGoal)
- Inject ObjectMapper via constructor and use it for JSON serialization
  of history, blackboard objects, and existing memories in the prompt
- Remove explicit return format instructions from prompt since
  .createObject() already instructs the LLM on the return schema
- Fix maxMemories → maxTotalMemories bug in constructor validation
- Add maxMemoriesAdded >= 0 validation

Agent-Logs-Url: https://github.com/elasticsoftwarefoundation/akces-framework/sessions/c67ccc3c-4bc2-4df9-84d7-9a3c6fbc9a46

Co-authored-by: jwijgerd <914840+jwijgerd@users.noreply.github.com>
Copilot AI requested a review from jwijgerd April 11, 2026 12:28
}

sb.append("\nBLACKBOARD OBJECTS (process context and results):\n");
if (blackboardObjects != null && !blackboardObjects.isEmpty()) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

use ```json markdown code fencing (also for other JSON lists / objects)

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

All three data sections (history, blackboard objects, existing memories) now use ```json markdown code fencing with proper JSON arrays. Commit 3d6dda0.

}

sb.append("\nEXISTING MEMORIES:\n");
if (existingMemories != null && !existingMemories.isEmpty()) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

should be a JSON list with markdown fencing

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Existing memories now rendered as a JSON array with ```json markdown fencing. Commit 3d6dda0.

* Set to a low value to avoid overwhelming the memory store with many entries in a single
* agent run.
*/
int maxMemoriesAdded() default 10;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

default should be 5

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Changed to default 5. Commit 3d6dda0.

* exceeds this limit the oldest entries are evicted to make room for new ones.
*/
int maxMemories() default 100;
int maxTotalMemories() default 100;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

the default max memories should be 50

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Changed to default 50. Commit 3d6dda0.

…Memories=50, maxMemoriesAdded=5

Agent-Logs-Url: https://github.com/elasticsoftwarefoundation/akces-framework/sessions/7180951b-dc17-4c5f-89fe-515a51c44b07

Co-authored-by: jwijgerd <914840+jwijgerd@users.noreply.github.com>
Copilot AI requested a review from jwijgerd April 11, 2026 14:14

@jwijgerd jwijgerd left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

there are test failures, fix them

? mas.getMemories()
: List.of();

Map<String, Object> bindings = new LinkedHashMap<>();

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

also put the AgentTask on the blackboard

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Added agentTask to the distiller blackboard bindings. The MemoryDistillerAgent now reads it and includes it in the LLM prompt as a json-fenced section. The bindings test verifies the new binding. Commit c8ec319.

Copilot AI commented Apr 11, 2026

Copy link
Copy Markdown
Contributor Author

there are test failures, fix them

Fixed in c8ec319. The AgenticAggregateInfoTest tests were still expecting the old defaults (100 and 10) instead of the updated values (50 and 5).

Copilot AI requested a review from jwijgerd April 11, 2026 14:26
public MemoryDistillationResult distillMemories(ActionContext context) {
var blackboard = context.getProcessContext().getAgentProcess().getBlackboard();

Object agentTaskBinding = blackboard.get("agentTask");

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

instead of reading from the blackboard use String binding in the action method params. The embabel framework should inject the correct values

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Created a strongly-typed MemoryDistillationInput record that wraps all distillation inputs (agentTask, history, blackboardObjects, existingMemories, maxTotalMemories, maxMemoriesAdded). The action method now takes MemoryDistillationInput input as a parameter — Embabel injects it from the Blackboard via type-based resolution. No more manual blackboard.get(...) calls. Commit 6358a91.

Copilot AI requested a review from jwijgerd April 11, 2026 14:45
} else {
sb.append("[]\n");
}
sb.append("```\n");

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

aren't we missing a closing bracket here to close the json list?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The closing ] bracket is present on line 163 (sb.append("]\n")) inside the if-block, before the markdown fence closing on line 167. The empty case on line 165 also outputs []. The generated prompt correctly produces a valid JSON array for the BLACKBOARD OBJECTS section.


sb.append("\nEXISTING MEMORIES:\n```json\n");
if (existingMemories != null && !existingMemories.isEmpty()) {
sb.append("[\n");

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

check if we are not missing the closing bracket

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The closing ] bracket is present on line 177 (sb.append("]\n")) inside the if-block. The empty case on line 179 also outputs []. The EXISTING MEMORIES section is correctly bracket-closed.

Copilot AI requested a review from jwijgerd April 11, 2026 15:04
@jwijgerd
jwijgerd merged commit 4a9c1bc into main Apr 11, 2026
7 checks passed
@jwijgerd
jwijgerd deleted the copilot/distill-relevant-memories-completed-process branch April 11, 2026 15:33
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants