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

+ * + * + *

Conditions

+ * + * + *

Goals

+ * + * + * @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: + *

+ * + *

Constraints enforced by the LLM: + *

+ * + * @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: *