diff --git a/main/agentic/src/main/java/org/elasticsoftware/akces/agentic/agent/AkcesAgentComponent.java b/main/agentic/src/main/java/org/elasticsoftware/akces/agentic/agent/AkcesAgentComponent.java
new file mode 100644
index 00000000..d0008e4a
--- /dev/null
+++ b/main/agentic/src/main/java/org/elasticsoftware/akces/agentic/agent/AkcesAgentComponent.java
@@ -0,0 +1,254 @@
+/*
+ * 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.agent;
+
+import com.embabel.agent.api.annotation.AchievesGoal;
+import com.embabel.agent.api.annotation.Action;
+import com.embabel.agent.api.annotation.Condition;
+import com.embabel.agent.api.annotation.EmbabelComponent;
+import com.embabel.agent.api.common.OperationContext;
+import org.elasticsoftware.akces.aggregate.AgenticAggregate;
+import org.elasticsoftware.akces.aggregate.AgenticAggregateMemory;
+
+import java.util.List;
+
+/**
+ * Central Embabel component that provides reusable Goals, Actions, and Conditions for
+ * Akces-based agentic systems.
+ *
+ *
This class is annotated with {@link EmbabelComponent} and is automatically discovered
+ * by the {@code AgentPlatform} via component scanning. It provides the foundational agent
+ * capabilities for memory retrieval, condition evaluation, and LLM-powered memory
+ * distillation that all agentic aggregates share.
+ *
+ *
Memory storage and revocation are handled by {@code @Tool}-annotated default methods
+ * on the {@link AgenticAggregate} interface, which are exposed to the LLM via
+ * {@code withToolObject()} during the learning goal.
+ *
+ *
Actions
+ *
+ * - {@link #recallMemories recallMemories} — Search stored memories by subject or
+ * keyword (read-only)
+ *
+ *
+ * Conditions
+ *
+ * - {@link #hasMemories hasMemories} — Evaluates whether any memories are present on
+ * the blackboard
+ *
+ *
+ * Goals
+ *
+ * - {@link #learnFromProcess learnFromProcess} — Multi-step LLM reasoning goal that
+ * analyzes session context and distills useful memories via
+ * {@link OperationContext#ai()} with the aggregate as a tool object
+ *
+ *
+ * @see AgenticAggregate#storeMemory
+ * @see AgenticAggregate#forgetMemory
+ * @see AgenticAggregateMemory
+ * @see MemoryLearningResult
+ */
+@EmbabelComponent
+public class AkcesAgentComponent {
+
+ /**
+ * Maximum number of new memories that may be stored in a single agent process
+ * execution. This prevents memory bloat from a single interaction.
+ */
+ static final int MAX_NEW_MEMORIES_PER_EXECUTION = 3;
+
+ // -------------------------------------------------------------------------
+ // Actions
+ // -------------------------------------------------------------------------
+
+ /**
+ * Searches stored memories by subject or keyword to retrieve relevant facts.
+ *
+ * Reads the current {@code List} from the blackboard and
+ * filters entries based on the provided {@code query}. Matching is case-insensitive
+ * and checks the memory's subject, fact content, and reason fields. If no query is
+ * provided (null or blank), all memories are returned.
+ *
+ * This action is declared {@code readOnly = true} since it has no side effects —
+ * it only inspects the current memory list without modifying state.
+ *
+ * @param memories the current list of memories from the blackboard; may be
+ * {@code null} or empty
+ * @param query a search term to filter memories by subject, fact, or reason;
+ * if {@code null} or blank, all memories are returned
+ * @return an unmodifiable list of matching memories; never {@code null}
+ */
+ @Action(description = "Search stored memories by subject or keyword to retrieve relevant facts",
+ readOnly = true)
+ public List recallMemories(
+ List memories,
+ String query) {
+ if (memories == null || memories.isEmpty()) {
+ return List.of();
+ }
+ if (query == null || query.isBlank()) {
+ return List.copyOf(memories);
+ }
+ return memories.stream()
+ .filter(m -> matchesQuery(m, query))
+ .toList();
+ }
+
+ // -------------------------------------------------------------------------
+ // Conditions
+ // -------------------------------------------------------------------------
+
+ /**
+ * Evaluates whether the blackboard contains one or more
+ * {@link AgenticAggregateMemory} objects.
+ *
+ * This condition is used as a precondition for actions that depend on prior
+ * knowledge. For example, "recall memories" is only meaningful when memories exist.
+ *
+ * @param memories the current list of memories from the blackboard; may be
+ * {@code null} or empty
+ * @return {@code true} if at least one memory is present; {@code false} otherwise
+ */
+ @Condition(name = "hasMemories")
+ public boolean hasMemories(List memories) {
+ return memories != null && !memories.isEmpty();
+ }
+
+ // -------------------------------------------------------------------------
+ // Goals
+ // -------------------------------------------------------------------------
+
+ /**
+ * Goal action that completes the learning cycle by analyzing current session
+ * information and distilling useful memories using LLM reasoning.
+ *
+ * This method is annotated with both {@link AchievesGoal} and {@link Action}.
+ * The {@code @AchievesGoal} annotation declares the "LearnFromProcess" goal in the
+ * Embabel GOAP planner. The method uses {@link OperationContext#ai()} to invoke LLM
+ * reasoning, passing the {@link AgenticAggregate} instance as a tool object so the
+ * LLM can call the {@code @Tool}-annotated {@code storeMemory()} and
+ * {@code forgetMemory()} methods during reasoning.
+ *
+ *
The LLM produces a {@link MemoryLearningResult} containing:
+ *
+ * - {@code memoriesStored} — list of {@code MemoryStoredEvent}s created by calling
+ * {@link AgenticAggregate#storeMemory}
+ * - {@code memoriesRevoked} — list of {@code MemoryRevokedEvent}s created by calling
+ * {@link AgenticAggregate#forgetMemory}
+ * - {@code summary} — human-readable summary of the learning process
+ *
+ *
+ * Constraints enforced by the LLM:
+ *
+ * - Maximum {@value #MAX_NEW_MEMORIES_PER_EXECUTION} new memories per agent
+ * process execution
+ * - No duplicate memories — existing memories must be checked before storing
+ * - Memory capacity enforcement — after storing, if total exceeds
+ * {@code maxMemories}, evict oldest via {@code forgetMemory}
+ *
+ *
+ * @param memories the current list of memories from the blackboard
+ * @param aggregate the {@link AgenticAggregate} instance, passed as a tool object
+ * to expose {@code storeMemory()} and {@code forgetMemory()} to the LLM
+ * @param context the Embabel {@link OperationContext} providing access to LLM
+ * capabilities via {@link OperationContext#ai()}
+ * @return a {@link MemoryLearningResult} containing the events produced and a summary
+ */
+ @AchievesGoal(description = "Learned from current session and stored and/or removed memories")
+ @Action(description = "Analyze current session and distill useful memories using LLM reasoning")
+ public MemoryLearningResult learnFromProcess(
+ List memories,
+ AgenticAggregate> aggregate,
+ OperationContext context) {
+ int currentCount = (memories != null) ? memories.size() : 0;
+
+ String prompt = """
+ Analyze the current session information available on the blackboard and \
+ distill useful memories. Use the storeMemory and forgetMemory tools to \
+ create the appropriate events.
+
+ Current memories: %d
+
+ Constraints:
+ - Maximum %d new memories per agent process execution (prevents memory bloat)
+ - No duplicate memories: check existing memories before storing and skip any \
+ fact that is already stored (same subject + substantially similar fact content)
+ - Memory capacity enforcement: after storing new memories, if total memory count \
+ exceeds maxMemories, evict oldest entries using forgetMemory until within limit
+
+ Memory field guidance:
+ - subject: 1-3 word topic label (e.g. "error handling", "user preferences")
+ - fact: clear, concise factual statement (max ~200 chars), actionable and self-contained
+ - citations: source reference — the command/event type that triggered this learning
+ - reason: why this fact is worth remembering, 2-3 sentences explaining significance
+
+ Return a MemoryLearningResult with:
+ - memoriesStored: list of MemoryStoredEvent objects from storeMemory tool calls
+ - memoriesRevoked: list of MemoryRevokedEvent objects from forgetMemory tool calls
+ - summary: human-readable summary of the learning process""".formatted(
+ currentCount, MAX_NEW_MEMORIES_PER_EXECUTION);
+
+ return context.ai()
+ .withDefaultLlm()
+ .withToolObject(aggregate)
+ .createObject(prompt, MemoryLearningResult.class);
+ }
+
+ // -------------------------------------------------------------------------
+ // Internal helpers
+ // -------------------------------------------------------------------------
+
+ /**
+ * Checks whether a memory entry matches a search query.
+ *
+ * The match is case-insensitive (locale-independent) and checks the memory's
+ * {@code subject}, {@code fact}, and {@code reason} fields.
+ *
+ * @param memory the memory entry to check
+ * @param query the search query
+ * @return {@code true} if any of the checked fields contain the query
+ */
+ private static boolean matchesQuery(AgenticAggregateMemory memory, String query) {
+ return containsIgnoreCase(memory.subject(), query)
+ || containsIgnoreCase(memory.fact(), query)
+ || containsIgnoreCase(memory.reason(), query);
+ }
+
+ /**
+ * Null-safe, locale-independent, case-insensitive substring check using
+ * {@link String#regionMatches(boolean, int, String, int, int)}.
+ *
+ * @param text the text to search within; may be {@code null}
+ * @param query the search term; must not be {@code null} or empty
+ * @return {@code true} if {@code text} contains {@code query} (case-insensitive)
+ */
+ private static boolean containsIgnoreCase(String text, String query) {
+ if (text == null || query == null || query.isEmpty()) {
+ return false;
+ }
+ int searchLength = query.length();
+ int maxStart = text.length() - searchLength;
+ for (int i = 0; i <= maxStart; i++) {
+ if (text.regionMatches(true, i, query, 0, searchLength)) {
+ return true;
+ }
+ }
+ return false;
+ }
+}
diff --git a/main/agentic/src/main/java/org/elasticsoftware/akces/agentic/agent/MemoryLearningResult.java b/main/agentic/src/main/java/org/elasticsoftware/akces/agentic/agent/MemoryLearningResult.java
new file mode 100644
index 00000000..f7d679b1
--- /dev/null
+++ b/main/agentic/src/main/java/org/elasticsoftware/akces/agentic/agent/MemoryLearningResult.java
@@ -0,0 +1,48 @@
+/*
+ * 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.agent;
+
+import org.elasticsoftware.akces.agentic.events.MemoryRevokedEvent;
+import org.elasticsoftware.akces.agentic.events.MemoryStoredEvent;
+
+import java.util.List;
+
+/**
+ * Result object produced by the {@link AkcesAgentComponent#learnFromProcess
+ * learnFromProcess} action when the learning cycle completes.
+ *
+ *
This record is the output type of the
+ * {@link AkcesAgentComponent#learnFromProcess learnFromProcess} action and serves
+ * as a structured summary of the memory management operations performed during a single
+ * agent process execution. The LLM populates the event lists by calling the
+ * {@link org.elasticsoftware.akces.aggregate.AgenticAggregate#storeMemory storeMemory}
+ * and {@link org.elasticsoftware.akces.aggregate.AgenticAggregate#forgetMemory forgetMemory}
+ * {@code @Tool} methods during the learning process.
+ *
+ * @param memoriesStored the list of {@link MemoryStoredEvent}s produced during this
+ * learning cycle
+ * @param memoriesRevoked the list of {@link MemoryRevokedEvent}s produced during this
+ * learning cycle
+ * @param summary a human-readable summary of what was learned and any capacity
+ * management actions taken
+ */
+public record MemoryLearningResult(
+ List memoriesStored,
+ List memoriesRevoked,
+ String summary
+) {}
diff --git a/main/agentic/src/main/java/org/elasticsoftware/akces/agentic/runtime/AgentProcessResultTranslator.java b/main/agentic/src/main/java/org/elasticsoftware/akces/agentic/runtime/AgentProcessResultTranslator.java
index a227788b..aa26ddda 100644
--- a/main/agentic/src/main/java/org/elasticsoftware/akces/agentic/runtime/AgentProcessResultTranslator.java
+++ b/main/agentic/src/main/java/org/elasticsoftware/akces/agentic/runtime/AgentProcessResultTranslator.java
@@ -18,6 +18,7 @@
package org.elasticsoftware.akces.agentic.runtime;
import com.embabel.agent.core.Blackboard;
+import org.elasticsoftware.akces.agentic.agent.MemoryLearningResult;
import org.elasticsoftware.akces.aggregate.DomainEventType;
import org.elasticsoftware.akces.events.DomainEvent;
import org.elasticsoftware.akces.events.ErrorEvent;
@@ -40,6 +41,11 @@
* so subsequent calls to this method will not return the same events again — this
* supports both tick-to-completion and future incremental-tick processing patterns.
*
+ * In addition to direct {@link DomainEvent} objects on the blackboard, this class
+ * also extracts events from {@link MemoryLearningResult} objects, which contain lists
+ * of {@code MemoryStoredEvent} and {@code MemoryRevokedEvent} produced by the LLM
+ * calling {@code @Tool} methods on the {@code AgenticAggregate} during the learning goal.
+ *
*
Unknown {@link ErrorEvent} types (not declared in {@code agentProducedErrors} and
* therefore not registered as {@link DomainEventType}s in the runtime) are logged at
* {@code WARN} level and excluded from the returned list. This prevents
@@ -63,6 +69,10 @@ private AgentProcessResultTranslator() {
* the subset that can safely be passed to the runtime's {@code processDomainEvent()}
* method.
*
+ *
This method also extracts events from any {@link MemoryLearningResult} objects
+ * on the blackboard (which contain lists of {@code MemoryStoredEvent} and
+ * {@code MemoryRevokedEvent} produced by LLM tool calls).
+ *
*
For every collected event:
*
* - Non-error {@link DomainEvent}s are always included and passed through as-is.
@@ -89,10 +99,27 @@ public static List collectEvents(Blackboard blackboard,
.map(DomainEventType::typeClass)
.collect(Collectors.toSet());
- List allEvents = blackboard.getObjects().stream()
+ List allEvents = new ArrayList<>();
+
+ // Collect direct DomainEvent objects from the blackboard
+ blackboard.getObjects().stream()
.filter(o -> o instanceof DomainEvent)
.map(o -> (DomainEvent) o)
- .toList();
+ .forEach(allEvents::add);
+
+ // Extract events from MemoryLearningResult objects on the blackboard
+ blackboard.getObjects().stream()
+ .filter(o -> o instanceof MemoryLearningResult)
+ .map(o -> (MemoryLearningResult) o)
+ .forEach(mlr -> {
+ if (mlr.memoriesStored() != null) {
+ allEvents.addAll(mlr.memoriesStored());
+ }
+ if (mlr.memoriesRevoked() != null) {
+ allEvents.addAll(mlr.memoriesRevoked());
+ }
+ blackboard.hide(mlr);
+ });
List result = new ArrayList<>(allEvents.size());
for (DomainEvent event : allEvents) {
diff --git a/main/agentic/src/main/java/org/elasticsoftware/akces/agentic/runtime/AgenticCommandHandlerFunctionAdapter.java b/main/agentic/src/main/java/org/elasticsoftware/akces/agentic/runtime/AgenticCommandHandlerFunctionAdapter.java
index 718f90cf..c6f4150d 100644
--- a/main/agentic/src/main/java/org/elasticsoftware/akces/agentic/runtime/AgenticCommandHandlerFunctionAdapter.java
+++ b/main/agentic/src/main/java/org/elasticsoftware/akces/agentic/runtime/AgenticCommandHandlerFunctionAdapter.java
@@ -120,6 +120,7 @@ public AgenticCommandHandlerFunctionAdapter(
* - {@code "command"} — the command being processed
* - {@code "state"} — the current aggregate state
* - {@code "agenticAggregateId"} — the aggregate identifier
+ * - {@code "aggregate"} — the {@link AgenticAggregate} instance (for tool object use)
* - {@code "memories"} — the list of current memories from state
* - {@code "aggregateServices"} — all known aggregate service records
* - {@code "isCommandProcessing"} (condition) — {@code true}
@@ -142,6 +143,7 @@ public Stream apply(@Nonnull C command, S state) {
bindings.put("command", command);
bindings.put("state", state);
bindings.put("agenticAggregateId", state.getAggregateId());
+ bindings.put("aggregate", aggregate);
List memories = state instanceof MemoryAwareState mas
? mas.getMemories()
: List.of();
diff --git a/main/agentic/src/main/java/org/elasticsoftware/akces/agentic/runtime/AgenticEventHandlerFunctionAdapter.java b/main/agentic/src/main/java/org/elasticsoftware/akces/agentic/runtime/AgenticEventHandlerFunctionAdapter.java
index 5a07ec43..bd70883f 100644
--- a/main/agentic/src/main/java/org/elasticsoftware/akces/agentic/runtime/AgenticEventHandlerFunctionAdapter.java
+++ b/main/agentic/src/main/java/org/elasticsoftware/akces/agentic/runtime/AgenticEventHandlerFunctionAdapter.java
@@ -119,6 +119,7 @@ public AgenticEventHandlerFunctionAdapter(
* - {@code "event"} — the external domain event being processed
* - {@code "state"} — the current aggregate state
* - {@code "agenticAggregateId"} — the aggregate identifier
+ * - {@code "aggregate"} — the {@link AgenticAggregate} instance (for tool object use)
* - {@code "memories"} — the list of current memories from state
* - {@code "aggregateServices"} — all known aggregate service records
* - {@code "isCommandProcessing"} (condition) — {@code false}
@@ -141,6 +142,7 @@ public Stream apply(@Nonnull InputEvent event, S state) {
bindings.put("event", event);
bindings.put("state", state);
bindings.put("agenticAggregateId", state.getAggregateId());
+ bindings.put("aggregate", aggregate);
List memories = state instanceof MemoryAwareState mas
? mas.getMemories()
: List.of();
diff --git a/main/agentic/src/test/java/org/elasticsoftware/akces/agentic/agent/AkcesAgentComponentTest.java b/main/agentic/src/test/java/org/elasticsoftware/akces/agentic/agent/AkcesAgentComponentTest.java
new file mode 100644
index 00000000..bd25d361
--- /dev/null
+++ b/main/agentic/src/test/java/org/elasticsoftware/akces/agentic/agent/AkcesAgentComponentTest.java
@@ -0,0 +1,343 @@
+/*
+ * 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.agent;
+
+import com.embabel.agent.api.annotation.Action;
+import com.embabel.agent.api.annotation.AchievesGoal;
+import com.embabel.agent.api.annotation.Condition;
+import com.embabel.agent.api.annotation.EmbabelComponent;
+import com.embabel.agent.api.common.Ai;
+import com.embabel.agent.api.common.OperationContext;
+import com.embabel.agent.api.common.PromptRunner;
+import org.elasticsoftware.akces.agentic.events.MemoryRevokedEvent;
+import org.elasticsoftware.akces.agentic.events.MemoryStoredEvent;
+import org.elasticsoftware.akces.aggregate.AgenticAggregate;
+import org.elasticsoftware.akces.aggregate.AgenticAggregateMemory;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Nested;
+import org.junit.jupiter.api.Test;
+
+import java.time.Instant;
+import java.util.List;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.eq;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.when;
+
+/**
+ * Unit tests for {@link AkcesAgentComponent}, verifying that all Actions, Conditions,
+ * and Goals produce correct outputs and are properly annotated for Embabel discovery.
+ *
+ * Tests cover:
+ *
+ * - {@link AkcesAgentComponent#recallMemories recallMemories} — filters memories by
+ * subject/keyword and handles edge cases
+ * - {@link AkcesAgentComponent#hasMemories hasMemories} — evaluates memory presence
+ * correctly
+ * - {@link AkcesAgentComponent#learnFromProcess learnFromProcess} — goal action
+ * produces a valid result with tool object
+ * - Embabel annotation presence and attributes
+ *
+ */
+class AkcesAgentComponentTest {
+
+ private AkcesAgentComponent component;
+
+ @BeforeEach
+ void setUp() {
+ component = new AkcesAgentComponent();
+ }
+
+ // =========================================================================
+ // Annotation presence verification
+ // =========================================================================
+
+ @Nested
+ class AnnotationTests {
+
+ @Test
+ void classShouldBeAnnotatedWithEmbabelComponent() {
+ assertThat(AkcesAgentComponent.class.isAnnotationPresent(EmbabelComponent.class))
+ .as("AkcesAgentComponent must be annotated with @EmbabelComponent")
+ .isTrue();
+ }
+
+ @Test
+ void recallMemoriesShouldBeAnnotatedWithReadOnlyAction() throws NoSuchMethodException {
+ var method = AkcesAgentComponent.class.getMethod(
+ "recallMemories", List.class, String.class);
+ Action action = method.getAnnotation(Action.class);
+ assertThat(action).isNotNull();
+ assertThat(action.readOnly()).as("recallMemories must be readOnly").isTrue();
+ }
+
+ @Test
+ void hasMemoriesShouldBeAnnotatedWithCondition() throws NoSuchMethodException {
+ var method = AkcesAgentComponent.class.getMethod("hasMemories", List.class);
+ assertThat(method.isAnnotationPresent(Condition.class)).isTrue();
+ }
+
+ @Test
+ void learnFromProcessShouldBeAnnotatedWithAchievesGoalAndAction()
+ throws NoSuchMethodException {
+ var method = AkcesAgentComponent.class.getMethod(
+ "learnFromProcess", List.class, AgenticAggregate.class,
+ OperationContext.class);
+ assertThat(method.isAnnotationPresent(AchievesGoal.class)).isTrue();
+ assertThat(method.isAnnotationPresent(Action.class)).isTrue();
+ }
+ }
+
+ // =========================================================================
+ // RecallMemoriesAction tests
+ // =========================================================================
+
+ @Nested
+ class RecallMemoriesActionTests {
+
+ private final List sampleMemories = List.of(
+ new AgenticAggregateMemory("m1", "error handling",
+ "Use try-with-resources for auto-closeable resources",
+ "Service.java:42", "Prevents resource leaks",
+ Instant.parse("2026-01-01T00:00:00Z")),
+ new AgenticAggregateMemory("m2", "testing",
+ "Use JUnit 5 for all new tests",
+ "build.gradle:10", "Consistency across the project",
+ Instant.parse("2026-01-02T00:00:00Z")),
+ new AgenticAggregateMemory("m3", "logging",
+ "Use SLF4J with structured logging",
+ "LogConfig.java:5", "Better observability in production",
+ Instant.parse("2026-01-03T00:00:00Z"))
+ );
+
+ @Test
+ void shouldReturnAllMemoriesWhenQueryIsNull() {
+ List result = component.recallMemories(
+ sampleMemories, null);
+
+ assertThat(result).hasSize(3);
+ }
+
+ @Test
+ void shouldReturnAllMemoriesWhenQueryIsBlank() {
+ List result = component.recallMemories(
+ sampleMemories, " ");
+
+ assertThat(result).hasSize(3);
+ }
+
+ @Test
+ void shouldFilterBySubjectMatch() {
+ List result = component.recallMemories(
+ sampleMemories, "testing");
+
+ assertThat(result).hasSize(1);
+ assertThat(result.getFirst().memoryId()).isEqualTo("m2");
+ }
+
+ @Test
+ void shouldFilterByFactContentMatch() {
+ List result = component.recallMemories(
+ sampleMemories, "SLF4J");
+
+ assertThat(result).hasSize(1);
+ assertThat(result.getFirst().memoryId()).isEqualTo("m3");
+ }
+
+ @Test
+ void shouldFilterByReasonMatch() {
+ List result = component.recallMemories(
+ sampleMemories, "observability");
+
+ assertThat(result).hasSize(1);
+ assertThat(result.getFirst().memoryId()).isEqualTo("m3");
+ }
+
+ @Test
+ void shouldBeCaseInsensitive() {
+ List result = component.recallMemories(
+ sampleMemories, "JUNIT");
+
+ assertThat(result).hasSize(1);
+ assertThat(result.getFirst().memoryId()).isEqualTo("m2");
+ }
+
+ @Test
+ void shouldReturnMultipleMatchesWhenQueryMatchesMultipleMemories() {
+ // "Consistency" appears in m2's reason, and "production" in m3's reason
+ // But a broader term like "for" appears in m1 fact, m2 fact, m3 reason
+ List result = component.recallMemories(
+ sampleMemories, "for");
+
+ assertThat(result).hasSizeGreaterThanOrEqualTo(2);
+ }
+
+ @Test
+ void shouldReturnEmptyListWhenNoMatchesFound() {
+ List result = component.recallMemories(
+ sampleMemories, "nonexistent");
+
+ assertThat(result).isEmpty();
+ }
+
+ @Test
+ void shouldReturnEmptyListWhenMemoriesAreNull() {
+ List result = component.recallMemories(null, "test");
+
+ assertThat(result).isEmpty();
+ }
+
+ @Test
+ void shouldReturnEmptyListWhenMemoriesAreEmpty() {
+ List result = component.recallMemories(
+ List.of(), "test");
+
+ assertThat(result).isEmpty();
+ }
+
+ @Test
+ void shouldReturnEmptyListWhenBothMemoriesAndQueryAreNull() {
+ List result = component.recallMemories(null, null);
+
+ assertThat(result).isEmpty();
+ }
+ }
+
+ // =========================================================================
+ // HasMemoriesCondition tests
+ // =========================================================================
+
+ @Nested
+ class HasMemoriesConditionTests {
+
+ @Test
+ void shouldReturnTrueWhenMemoriesExist() {
+ List memories = List.of(
+ new AgenticAggregateMemory("m1", "s", "f", "c", "r", Instant.now()));
+
+ assertThat(component.hasMemories(memories)).isTrue();
+ }
+
+ @Test
+ void shouldReturnFalseWhenMemoriesAreEmpty() {
+ assertThat(component.hasMemories(List.of())).isFalse();
+ }
+
+ @Test
+ void shouldReturnFalseWhenMemoriesAreNull() {
+ assertThat(component.hasMemories(null)).isFalse();
+ }
+
+ @Test
+ void shouldReturnTrueWhenMultipleMemoriesExist() {
+ List memories = List.of(
+ new AgenticAggregateMemory("m1", "s1", "f1", "c1", "r1", Instant.now()),
+ new AgenticAggregateMemory("m2", "s2", "f2", "c2", "r2", Instant.now()));
+
+ assertThat(component.hasMemories(memories)).isTrue();
+ }
+ }
+
+ // =========================================================================
+ // LearnFromProcessGoal tests
+ // =========================================================================
+
+ @Nested
+ class LearnFromProcessGoalTests {
+
+ @Test
+ void shouldInvokeLlmWithToolObjectAndReturnResult() {
+ var storedEvents = List.of(
+ new MemoryStoredEvent("agg-1", "m1", "topic", "fact", "cite", "why", Instant.now()));
+ var revokedEvents = List.of();
+ var expectedResult = new MemoryLearningResult(storedEvents, revokedEvents, "Stored 1");
+
+ OperationContext context = mock(OperationContext.class);
+ Ai ai = mock(Ai.class);
+ PromptRunner promptRunner = mock(PromptRunner.class);
+ PromptRunner toolRunner = mock(PromptRunner.class);
+ @SuppressWarnings("unchecked")
+ AgenticAggregate> aggregate = mock(AgenticAggregate.class);
+
+ when(context.ai()).thenReturn(ai);
+ when(ai.withDefaultLlm()).thenReturn(promptRunner);
+ when(promptRunner.withToolObject(aggregate)).thenReturn(toolRunner);
+ when(toolRunner.createObject(any(String.class), eq(MemoryLearningResult.class)))
+ .thenReturn(expectedResult);
+
+ List memories = List.of(
+ new AgenticAggregateMemory("m1", "s", "f", "c", "r", Instant.now()));
+
+ MemoryLearningResult result = component.learnFromProcess(memories, aggregate, context);
+
+ assertThat(result).isSameAs(expectedResult);
+ assertThat(result.memoriesStored()).hasSize(1);
+ assertThat(result.memoriesRevoked()).isEmpty();
+ }
+
+ @Test
+ void shouldPassMemoryCountInPrompt() {
+ var expectedResult = new MemoryLearningResult(List.of(), List.of(), "Nothing to learn");
+ OperationContext context = mock(OperationContext.class);
+ Ai ai = mock(Ai.class);
+ PromptRunner promptRunner = mock(PromptRunner.class);
+ PromptRunner toolRunner = mock(PromptRunner.class);
+ @SuppressWarnings("unchecked")
+ AgenticAggregate> aggregate = mock(AgenticAggregate.class);
+
+ when(context.ai()).thenReturn(ai);
+ when(ai.withDefaultLlm()).thenReturn(promptRunner);
+ when(promptRunner.withToolObject(aggregate)).thenReturn(toolRunner);
+ when(toolRunner.createObject(any(String.class), eq(MemoryLearningResult.class)))
+ .thenReturn(expectedResult);
+
+ MemoryLearningResult result = component.learnFromProcess(List.of(), aggregate, context);
+
+ assertThat(result).isNotNull();
+ assertThat(result.summary()).isEqualTo("Nothing to learn");
+ }
+
+ @Test
+ void shouldHandleNullMemoriesGracefully() {
+ var expectedResult = new MemoryLearningResult(List.of(), List.of(), "No prior memories");
+ OperationContext context = mock(OperationContext.class);
+ Ai ai = mock(Ai.class);
+ PromptRunner promptRunner = mock(PromptRunner.class);
+ PromptRunner toolRunner = mock(PromptRunner.class);
+ @SuppressWarnings("unchecked")
+ AgenticAggregate> aggregate = mock(AgenticAggregate.class);
+
+ when(context.ai()).thenReturn(ai);
+ when(ai.withDefaultLlm()).thenReturn(promptRunner);
+ when(promptRunner.withToolObject(aggregate)).thenReturn(toolRunner);
+ when(toolRunner.createObject(any(String.class), eq(MemoryLearningResult.class)))
+ .thenReturn(expectedResult);
+
+ MemoryLearningResult result = component.learnFromProcess(null, aggregate, context);
+
+ assertThat(result).isNotNull();
+ }
+
+ @Test
+ void maxNewMemoriesPerExecutionShouldBeThree() {
+ assertThat(AkcesAgentComponent.MAX_NEW_MEMORIES_PER_EXECUTION).isEqualTo(3);
+ }
+ }
+}
diff --git a/main/api/pom.xml b/main/api/pom.xml
index 55649c5c..743e4fdb 100644
--- a/main/api/pom.xml
+++ b/main/api/pom.xml
@@ -31,9 +31,17 @@
UTF-8
+ 2.0.0-M4
+
+
+ org.springframework.ai
+ spring-ai-model
+ ${spring-ai.version}
+ true
+
com.fasterxml.jackson.core
jackson-annotations
diff --git a/main/agentic/src/main/java/org/elasticsoftware/akces/agentic/events/MemoryRevokedEvent.java b/main/api/src/main/java/org/elasticsoftware/akces/agentic/events/MemoryRevokedEvent.java
similarity index 100%
rename from main/agentic/src/main/java/org/elasticsoftware/akces/agentic/events/MemoryRevokedEvent.java
rename to main/api/src/main/java/org/elasticsoftware/akces/agentic/events/MemoryRevokedEvent.java
diff --git a/main/agentic/src/main/java/org/elasticsoftware/akces/agentic/events/MemoryStoredEvent.java b/main/api/src/main/java/org/elasticsoftware/akces/agentic/events/MemoryStoredEvent.java
similarity index 97%
rename from main/agentic/src/main/java/org/elasticsoftware/akces/agentic/events/MemoryStoredEvent.java
rename to main/api/src/main/java/org/elasticsoftware/akces/agentic/events/MemoryStoredEvent.java
index 3554ed57..2d04f192 100644
--- a/main/agentic/src/main/java/org/elasticsoftware/akces/agentic/events/MemoryStoredEvent.java
+++ b/main/api/src/main/java/org/elasticsoftware/akces/agentic/events/MemoryStoredEvent.java
@@ -33,7 +33,7 @@
*
* @param agenticAggregateId the unique identifier of the AgenticAggregate instance
* @param memoryId UUID uniquely identifying this memory entry
- * @param subject a short (1–2 word) topic label for the stored memory
+ * @param subject a short (1–3 word) topic label for the stored memory
* @param fact the fact that was stored (max 200 characters)
* @param citations the source citation for the fact
* @param reason the reason the memory was stored
diff --git a/main/api/src/main/java/org/elasticsoftware/akces/aggregate/AgenticAggregate.java b/main/api/src/main/java/org/elasticsoftware/akces/aggregate/AgenticAggregate.java
index 22278f69..a2f7e428 100644
--- a/main/api/src/main/java/org/elasticsoftware/akces/aggregate/AgenticAggregate.java
+++ b/main/api/src/main/java/org/elasticsoftware/akces/aggregate/AgenticAggregate.java
@@ -17,8 +17,37 @@
package org.elasticsoftware.akces.aggregate;
+import org.elasticsoftware.akces.agentic.events.MemoryRevokedEvent;
+import org.elasticsoftware.akces.agentic.events.MemoryStoredEvent;
+import org.springframework.ai.tool.annotation.Tool;
+import org.springframework.ai.tool.annotation.ToolParam;
+
+import java.time.Instant;
import java.util.List;
+import java.util.UUID;
+/**
+ * Extension of {@link Aggregate} that provides memory-awareness and tool-based memory
+ * management for agentic aggregates.
+ *
+ * This interface adds:
+ *
+ * - {@link #getMemories(AggregateState)} — extract memories from state
+ * - {@link #storeMemory} — {@link Tool @Tool} method for LLM-driven memory storage
+ * - {@link #forgetMemory} — {@link Tool @Tool} method for LLM-driven memory revocation
+ *
+ *
+ * The {@code @Tool}-annotated default methods are exposed to the LLM during the
+ * learning goal's {@code context.ai().withToolObject(aggregate)} call, allowing the LLM
+ * to invoke them as function-call tools during memory distillation. The returned events
+ * are collected from the {@code MemoryLearningResult} and applied through the standard
+ * event-sourcing flow.
+ *
+ * @param the aggregate state type
+ * @see MemoryStoredEvent
+ * @see MemoryRevokedEvent
+ * @see AgenticAggregateMemory
+ */
public interface AgenticAggregate extends Aggregate {
/**
* Returns the memories held by the given state.
@@ -36,4 +65,70 @@ default List getMemories(S state) {
}
return List.of();
}
+
+ /**
+ * Stores a learned fact as a new memory entry for this agentic aggregate.
+ *
+ * This method is annotated with {@link Tool @Tool} and is exposed to the LLM
+ * when the aggregate instance is passed via {@code withToolObject()}. The LLM calls
+ * this tool during the learning process to persist new memories.
+ *
+ * @param agenticAggregateId the unique identifier of the agentic aggregate instance
+ * @param subject a short (1–3 word) topic label for the memory
+ * @param fact a clear, concise factual statement (max ~200 characters)
+ * @param citations source reference — the command/event type that triggered
+ * this learning, or relevant data points
+ * @param reason why this fact is worth remembering — what future decisions
+ * it informs (2–3 sentences)
+ * @return a {@link MemoryStoredEvent} containing all memory metadata
+ */
+ @Tool(description = "Store a learned fact as a memory entry for the agentic aggregate")
+ default MemoryStoredEvent storeMemory(
+ @ToolParam(description = "The unique identifier of the agentic aggregate instance")
+ String agenticAggregateId,
+ @ToolParam(description = "A short 1-3 word topic label for the memory")
+ String subject,
+ @ToolParam(description = "A clear, concise factual statement (max ~200 characters)")
+ String fact,
+ @ToolParam(description = "Source reference - the command/event type that triggered this learning")
+ String citations,
+ @ToolParam(description = "Why this fact is worth remembering - 2-3 sentences explaining significance")
+ String reason) {
+ return new MemoryStoredEvent(
+ agenticAggregateId,
+ UUID.randomUUID().toString(),
+ subject,
+ fact,
+ citations,
+ reason,
+ Instant.now());
+ }
+
+ /**
+ * Revokes (forgets) a memory entry that is no longer relevant or accurate.
+ *
+ *
This method is annotated with {@link Tool @Tool} and is exposed to the LLM
+ * when the aggregate instance is passed via {@code withToolObject()}. The LLM calls
+ * this tool to remove outdated or incorrect memories, or to enforce capacity limits
+ * by evicting the oldest entries.
+ *
+ * @param agenticAggregateId the unique identifier of the agentic aggregate instance
+ * @param memoryId the UUID of the memory entry to revoke
+ * @param reason the reason the memory is being revoked
+ * @return a {@link MemoryRevokedEvent} for the identified memory entry
+ */
+ @Tool(description = "Revoke a memory entry that is no longer relevant or accurate")
+ default MemoryRevokedEvent forgetMemory(
+ @ToolParam(description = "The unique identifier of the agentic aggregate instance")
+ String agenticAggregateId,
+ @ToolParam(description = "The UUID of the memory entry to revoke")
+ String memoryId,
+ @ToolParam(description = "The reason the memory is being revoked")
+ String reason) {
+ return new MemoryRevokedEvent(
+ agenticAggregateId,
+ memoryId,
+ reason,
+ Instant.now());
+ }
}
diff --git a/plans/embabel-integration-plan.md b/plans/embabel-integration-plan.md
index e64d751f..86a4de55 100644
--- a/plans/embabel-integration-plan.md
+++ b/plans/embabel-integration-plan.md
@@ -346,37 +346,45 @@ The resulting `AggregateServiceRecord.producedEvents` would contain:
**Why**: Embabel discovers Goals/Actions/Conditions by scanning `@EmbabelComponent`-annotated classes. This provides a centralized place for Akces-specific agent capabilities that are automatically registered with the `AgentPlatform`.
-### 2.2 Memory Management Action: `StoreMemoryAction`
+### ~~2.2 Memory Management Action: `StoreMemoryAction`~~ — MOVED TO `AgenticAggregate` INTERFACE
-**What**: An `@Action`-annotated method that produces a `MemoryStoredEvent` directly. The event is applied to the state via the built-in `@EventSourcingHandler` for `MemoryStoredEvent`.
+> **Note**: The `storeMemory` method has been moved from `AkcesAgentComponent` (as an `@Action`) to the `AgenticAggregate` interface (as a `@Tool`-annotated default method). This allows the LLM to call it directly during `learnFromProcess` via `withToolObject(aggregate)`.
-```
-@Action(description = "Store a learned fact as a memory entry for the agentic aggregate")
+**What**: A `@Tool`-annotated (Spring AI `org.springframework.ai.tool.annotation.Tool`) default method on the `AgenticAggregate` interface that produces a `MemoryStoredEvent`.
+
+```java
+@Tool(description = "Store a learned fact as a memory entry for the agentic aggregate")
+default MemoryStoredEvent storeMemory(
+ @ToolParam(description = "The unique identifier of the agentic aggregate instance") String agenticAggregateId,
+ @ToolParam(description = "A short 1-3 word topic label for the memory") String subject,
+ @ToolParam(description = "A clear, concise factual statement (max ~200 characters)") String fact,
+ @ToolParam(description = "Source reference - the command/event type that triggered this learning") String citations,
+ @ToolParam(description = "Why this fact is worth remembering - 2-3 sentences explaining significance") String reason) { ... }
```
-**Behavior**:
-1. Reads the current aggregate ID from the blackboard (set by the partition before agent invocation)
-2. Takes `subject`, `fact`, `citations`, and `reason` as inputs (from blackboard or method parameters)
-3. Creates a `MemoryStoredEvent` and returns it on the blackboard
-4. The partition collects the event and applies it through the built-in `@EventSourcingHandler` (which calls `MemoryAwareState.withMemory()`)
+**Behavior**: Creates and returns a `MemoryStoredEvent` with a random UUID and current timestamp. The event is collected from the `MemoryLearningResult` by `AgentProcessResultTranslator` and applied through the built-in `@EventSourcingHandler`.
-**Why**: This is the most fundamental agent action — the ability to learn and persist knowledge across interactions. Producing events directly (rather than commands) simplifies the flow and is consistent with the event-sourcing model where actions result in domain events.
+**Why**: By placing the method on the `AgenticAggregate` interface and annotating it with `@Tool`, the LLM can call it as a function tool during the `createObject()` call in `learnFromProcess`. This eliminates the need for a separate Embabel `@Action` and leverages Spring AI's tool-calling mechanism.
-### 2.3 Memory Management Action: `ForgetMemoryAction`
+### ~~2.3 Memory Management Action: `ForgetMemoryAction`~~ — MOVED TO `AgenticAggregate` INTERFACE
-**What**: An `@Action`-annotated method that produces a `MemoryRevokedEvent` to remove a specific memory by ID or by criteria.
+> **Note**: The `forgetMemory` method has been moved from `AkcesAgentComponent` (as an `@Action`) to the `AgenticAggregate` interface (as a `@Tool`-annotated default method).
-```
-@Action(description = "Revoke a memory entry that is no longer relevant or accurate")
+**What**: A `@Tool`-annotated default method on the `AgenticAggregate` interface that produces a `MemoryRevokedEvent`.
+
+```java
+@Tool(description = "Revoke a memory entry that is no longer relevant or accurate")
+default MemoryRevokedEvent forgetMemory(
+ @ToolParam(description = "The unique identifier of the agentic aggregate instance") String agenticAggregateId,
+ @ToolParam(description = "The UUID of the memory entry to revoke") String memoryId,
+ @ToolParam(description = "The reason the memory is being revoked") String reason) { ... }
```
-**Behavior**:
-1. Takes `memoryId` and `reason` from the blackboard
-2. Creates a `MemoryRevokedEvent` and returns it on the blackboard
-3. The partition collects the event and applies it through the built-in `@EventSourcingHandler` (which calls `MemoryAwareState.withoutMemory()`)
-4. Enables the agent to self-manage its knowledge base
+**Behavior**: Creates and returns a `MemoryRevokedEvent`. The event is collected from the `MemoryLearningResult` by `AgentProcessResultTranslator`.
+
+**Why**: Same rationale as `storeMemory` — placing the method on the aggregate interface allows the LLM to call it as a tool during the learning process.
-**Why**: Agents must be able to correct their knowledge. Outdated or incorrect facts should be removable.
+**Dependency changes**: `MemoryStoredEvent` and `MemoryRevokedEvent` have been moved from `main/agentic` to `main/api` (package `org.elasticsoftware.akces.agentic.events`) since they only depend on API-module classes and are now referenced by the `AgenticAggregate` interface. The `spring-ai-model` dependency is added to the `api` module as `true` for the `@Tool` and `@ToolParam` annotations.
### 2.4 Memory Retrieval Action: `RecallMemoriesAction`
@@ -418,31 +426,36 @@ The resulting `AggregateServiceRecord.producedEvents` would contain:
```
Goal: "LearnFromProcess"
-Description: "Analyze current session information and distill useful memories"
+@AchievesGoal(description = "Learned from current session and stored and/or removed memories")
```
-**Why a Goal and not an Action**: The memory distillation process requires **multi-step LLM reasoning**, not just data manipulation. A simple `@Action` can only perform a single operation (e.g., store a memory, recall memories). The `LearnFromProcessGoal` orchestrates a reasoning pipeline:
-1. The Blackboard contains all session information: the original command/event, the current aggregate state, any domain events produced, existing memories, and aggregate service records.
-2. The Embabel GOAP planner uses LLM reasoning to analyze this session context and determine what facts are worth remembering — this is the intelligence step that cannot be reduced to a single Action.
-3. The goal then uses Actions (`RecallMemoriesAction`, `StoreMemoryAction`, `ForgetMemoryAction`) as execution primitives to actually persist the decisions.
+**Implementation**: The `learnFromProcess` method in `AkcesAgentComponent` is annotated with both `@AchievesGoal` and `@Action`. It accepts:
+1. `List memories` — current memories from the blackboard
+2. `AgenticAggregate> aggregate` — the aggregate instance, passed as a tool object
+3. `OperationContext context` — the Embabel operation context for LLM access
+
+The method uses `context.ai().withDefaultLlm().withToolObject(aggregate).createObject(prompt, MemoryLearningResult.class)` to invoke LLM reasoning. The `AgenticAggregate` is passed as a tool object so the LLM can call the `@Tool`-annotated `storeMemory()` and `forgetMemory()` default methods during reasoning.
-This separation of **analysis** (LLM reasoning within the Goal) from **execution** (Actions) is a fundamental Embabel pattern: Goals define *what* to achieve, Actions define *how* to do it, and the LLM bridges the gap.
+**Output**: `MemoryLearningResult` is a record containing:
+- `List memoriesStored` — events produced by LLM calling `storeMemory()` tools
+- `List memoriesRevoked` — events produced by LLM calling `forgetMemory()` tools
+- `String summary` — human-readable summary of the learning process
-**Constraints**:
+The `AgentProcessResultTranslator` extracts events from the `MemoryLearningResult` on the blackboard and includes them in the event stream returned by the handler adapters.
+
+**Why a Goal and not a simple Action**: The memory distillation process requires **LLM reasoning with tool use**, not just data manipulation. The LLM analyzes the session context and uses the `storeMemory` / `forgetMemory` tools to create the appropriate events.
+
+**Constraints** (encoded in the prompt passed to the LLM):
- **Maximum 3 new memories** per agent process execution. This prevents memory bloat from a single interaction.
- **No duplicate memories**: Before storing, the agent must check existing memories and skip any fact that is already stored (same subject + substantially similar fact content).
-- **Memory capacity enforcement**: After storing new memories, if the total memory count exceeds `maxMemories`, the agent must evict the oldest entries using `ForgetMemoryAction` until the count is within the allowed limit. This replaces the previous `enforceMemorySlidingWindow()` mechanism — the agent itself is responsible for managing its memory capacity as part of the learning process.
-- **Memory field guidance** (to be included in the Goal's prompt instructions):
- - `subject`: A 1-3 word topic label (e.g., "error handling", "user preferences", "market patterns"). Used for grouping and retrieval.
+- **Memory capacity enforcement**: After storing new memories, if the total memory count exceeds `maxMemories`, the agent must evict the oldest entries using `forgetMemory` until the count is within the allowed limit.
+- **Memory field guidance**:
+ - `subject`: A 1-3 word topic label (e.g., "error handling", "user preferences", "market patterns").
- `fact`: A clear, concise factual statement (max ~200 chars). Should be actionable and self-contained.
- - `citations`: Source reference — the command/event type that triggered this learning, or relevant data points from the aggregate state.
- - `reason`: Why this fact is worth remembering — what future decisions it informs. Should be 2-3 sentences explaining the significance.
-
-**Plan**: The agent planner would compose this as:
-1. `RecallMemoriesAction` → check existing memories (to avoid duplicates and assess capacity)
-2. (LLM reasoning over Blackboard session context) → determine what new facts were learned, respecting the max-3 limit
-3. `StoreMemoryAction` → persist new memories (producing `MemoryStoredEvent`)
-4. `ForgetMemoryAction` → evict oldest memories if capacity exceeded, or replace outdated memories (producing `MemoryRevokedEvent`)
+ - `citations`: Source reference — the command/event type that triggered this learning.
+ - `reason`: Why this fact is worth remembering — 2-3 sentences explaining significance.
+
+**Blackboard bindings**: The handler adapters put the `AgenticAggregate` instance on the blackboard as `"aggregate"`, enabling the `learnFromProcess` action to receive it as a parameter by type matching.
**Why**: This is the foundational agentic behavior — after processing any command, the agent should learn from the experience and update its knowledge base. The constraints ensure memory quality over quantity. By moving memory capacity enforcement into the agent's learning goal, we eliminate the need for the framework-level `enforceMemorySlidingWindow()` method and the internal `StoreMemoryCommand`/`ForgetMemoryCommand` commands.