diff --git a/main/agentic/src/main/java/org/elasticsoftware/akces/agentic/agent/AnalyzeAggregateStateAction.java b/main/agentic/src/main/java/org/elasticsoftware/akces/agentic/agent/AnalyzeAggregateStateAction.java new file mode 100644 index 00000000..b3583e4b --- /dev/null +++ b/main/agentic/src/main/java/org/elasticsoftware/akces/agentic/agent/AnalyzeAggregateStateAction.java @@ -0,0 +1,99 @@ +/* + * 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.EmbabelComponent; +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.elasticsoftware.akces.aggregate.AggregateState; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * Embabel action that analyzes the current aggregate state using LLM reasoning. + * + *

This is a framework building block — a reusable, read-only action that + * serializes the current aggregate state into a structured JSON representation suitable for + * LLM consumption. The serialized state is returned as a {@link String} on the blackboard, + * making it available for subsequent LLM reasoning steps, MCP tool calls, or other actions + * within the agent's plan. + * + *

The action does not invoke the LLM directly; instead, it prepares the state in a format + * that the Embabel GOAP planner can feed into LLM reasoning steps. This separation of + * concerns allows the planner to compose state analysis with other actions (e.g., + * {@code RecallMemoriesAction}) as part of a larger goal plan. + * + *

Blackboard inputs: + *

+ * + *

Blackboard output: + *

+ * + * @see ProcessCommandGoal + * @see ReactToExternalEventGoal + */ +@EmbabelComponent +public class AnalyzeAggregateStateAction { + + private static final Logger logger = LoggerFactory.getLogger(AnalyzeAggregateStateAction.class); + + private final ObjectMapper objectMapper; + + /** + * Creates a new {@code AnalyzeAggregateStateAction}. + * + * @param objectMapper the Jackson {@link ObjectMapper} used to serialize aggregate state; + * provided by Spring auto-configuration + */ + public AnalyzeAggregateStateAction(ObjectMapper objectMapper) { + this.objectMapper = objectMapper; + } + + /** + * Serializes the current aggregate state to a structured JSON representation for + * LLM reasoning. + * + *

The aggregate state is resolved from the blackboard by type. The resulting JSON + * string is placed back on the blackboard and can be consumed by LLM reasoning steps + * or other actions within the agent's plan. + * + *

If serialization fails, a fallback representation using {@link Object#toString()} + * is used to ensure the action does not fail the entire agent process. + * + * @param state the current aggregate state; resolved from the blackboard by type + * @return a JSON string representation of the aggregate state + */ + @Action(description = "Analyze the current aggregate state using LLM reasoning", readOnly = true) + public String analyzeState(AggregateState state) { + logger.debug("Analyzing aggregate state of type {}", state.getClass().getSimpleName()); + try { + String serialized = objectMapper.writerWithDefaultPrettyPrinter().writeValueAsString(state); + logger.debug("Aggregate state serialized successfully ({} chars)", serialized.length()); + return serialized; + } catch (JsonProcessingException e) { + logger.warn("Failed to serialize aggregate state of type {}, falling back to toString()", + state.getClass().getSimpleName(), e); + return state.toString(); + } + } +} diff --git a/main/agentic/src/main/java/org/elasticsoftware/akces/agentic/agent/DiscoverAggregateServicesAction.java b/main/agentic/src/main/java/org/elasticsoftware/akces/agentic/agent/DiscoverAggregateServicesAction.java new file mode 100644 index 00000000..78716f12 --- /dev/null +++ b/main/agentic/src/main/java/org/elasticsoftware/akces/agentic/agent/DiscoverAggregateServicesAction.java @@ -0,0 +1,129 @@ +/* + * 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.EmbabelComponent; +import org.elasticsoftware.akces.control.AggregateServiceRecord; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.util.Collection; + +/** + * Embabel action that discovers other aggregates in the system and their supported commands. + * + *

This read-only action exposes {@link AggregateServiceRecord}s loaded from the + * {@code Akces-Control} topic to the agent, enabling it to discover what other aggregates + * exist in the system and what commands they accept. This supports dynamic, system-aware + * reasoning — for example, a TradingAdvisor agent can discover that a {@code Wallet} + * aggregate accepts {@code CreditWalletCommand} and a {@code Portfolio} aggregate accepts + * {@code PlaceOrderCommand}. + * + *

The aggregate service records are populated on the blackboard by the agentic handler + * adapters before agent invocation. The + * {@link org.elasticsoftware.akces.agentic.runtime.AkcesAgenticAggregateController} + * maintains a {@code Map} loaded from the + * {@code Akces-Control} topic at startup. + * + *

Blackboard inputs: + *

+ * + *

Blackboard output: + *

+ * + * @see org.elasticsoftware.akces.control.AggregateServiceRecord + * @see org.elasticsoftware.akces.agentic.runtime.AkcesAgenticAggregateController + */ +@EmbabelComponent +public class DiscoverAggregateServicesAction { + + private static final Logger logger = LoggerFactory.getLogger(DiscoverAggregateServicesAction.class); + + /** + * Discovers other aggregates in the system and returns a structured summary. + * + *

For each {@link AggregateServiceRecord}, the summary includes: + *

+ * + * @param aggregateServices the collection of aggregate service records; resolved from + * the blackboard (populated as {@code "aggregateServices"} binding) + * @return a structured text summary of all discovered aggregate services + */ + @Action(description = "Discover other aggregates in the system and their supported commands", + readOnly = true) + @SuppressWarnings("unchecked") + public String discoverServices(Collection aggregateServices) { + if (aggregateServices == null || aggregateServices.isEmpty()) { + logger.debug("No aggregate services discovered"); + return "No aggregate services are currently available in the system."; + } + + logger.debug("Discovering {} aggregate services", aggregateServices.size()); + + StringBuilder summary = new StringBuilder("Discovered Aggregate Services:\n\n"); + + for (AggregateServiceRecord record : aggregateServices) { + summary.append("--- ").append(record.aggregateName()).append(" ---\n"); + summary.append(" Type: ").append(record.effectiveType()).append('\n'); + summary.append(" Command Topic: ").append(record.commandTopic()).append('\n'); + summary.append(" Event Topic: ").append(record.domainEventTopic()).append('\n'); + + if (record.supportedCommands() != null && !record.supportedCommands().isEmpty()) { + summary.append(" Supported Commands:\n"); + record.supportedCommands().forEach(cmd -> + summary.append(" - ").append(cmd.typeName()) + .append(" (v").append(cmd.version()).append(")\n")); + } + + if (record.producedEvents() != null && !record.producedEvents().isEmpty()) { + summary.append(" Produced Events:\n"); + record.producedEvents().forEach(evt -> + summary.append(" - ").append(evt.typeName()) + .append(" (v").append(evt.version()).append(")\n")); + } + + if (record.consumedEvents() != null && !record.consumedEvents().isEmpty()) { + summary.append(" Consumed Events:\n"); + record.consumedEvents().forEach(evt -> + summary.append(" - ").append(evt.typeName()) + .append(" (v").append(evt.version()).append(")\n")); + } + + summary.append('\n'); + } + + String result = summary.toString(); + logger.debug("Aggregate service discovery summary generated ({} chars)", result.length()); + return result; + } +} diff --git a/main/agentic/src/main/java/org/elasticsoftware/akces/agentic/agent/IsCommandProcessingCondition.java b/main/agentic/src/main/java/org/elasticsoftware/akces/agentic/agent/IsCommandProcessingCondition.java new file mode 100644 index 00000000..db0be05d --- /dev/null +++ b/main/agentic/src/main/java/org/elasticsoftware/akces/agentic/agent/IsCommandProcessingCondition.java @@ -0,0 +1,67 @@ +/* + * 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.core.Condition; +import com.embabel.agent.core.ComputedBooleanCondition; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +/** + * Embabel {@link Condition} that evaluates to {@code true} when the current agent context + * contains a command to process, as opposed to an external domain event. + * + *

This condition is used as a precondition for goals that handle command processing + * (e.g., {@link ProcessCommandGoal}). It enables the GOAP planner to select different + * goal plans depending on whether the agent was triggered by a command or an external event. + * + *

The condition reads the {@code "isCommandProcessing"} binding from the agent's + * blackboard, which is set to {@code true} by the + * {@link org.elasticsoftware.akces.agentic.runtime.AgenticCommandHandlerFunctionAdapter} + * and to {@code false} by the + * {@link org.elasticsoftware.akces.agentic.runtime.AgenticEventHandlerFunctionAdapter}. + * + * @see IsExternalEventCondition + * @see ProcessCommandGoal + */ +@Configuration +public class IsCommandProcessingCondition { + + /** + * The name of this condition as referenced by goal preconditions and action + * pre/postconditions in the GOAP planning system. + */ + public static final String CONDITION_NAME = "isCommandProcessing"; + + /** + * Creates the {@code isCommandProcessing} condition bean. + * + *

The condition reads the {@code "isCommandProcessing"} binding from the blackboard + * and returns {@code true} if it is a {@link Boolean#TRUE} value. If the binding is + * absent or not a {@code Boolean}, the condition evaluates to {@code false}. + * + * @return a {@link Condition} that evaluates to {@code true} during command processing + */ + @Bean + public Condition isCommandProcessingCondition() { + return new ComputedBooleanCondition(CONDITION_NAME, 0.0, (context, condition) -> { + Object value = context.get(CONDITION_NAME); + return value instanceof Boolean b && b; + }); + } +} diff --git a/main/agentic/src/main/java/org/elasticsoftware/akces/agentic/agent/IsExternalEventCondition.java b/main/agentic/src/main/java/org/elasticsoftware/akces/agentic/agent/IsExternalEventCondition.java new file mode 100644 index 00000000..3e7bbd71 --- /dev/null +++ b/main/agentic/src/main/java/org/elasticsoftware/akces/agentic/agent/IsExternalEventCondition.java @@ -0,0 +1,67 @@ +/* + * 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.core.Condition; +import com.embabel.agent.core.ComputedBooleanCondition; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +/** + * Embabel {@link Condition} that evaluates to {@code true} when the current agent context + * was triggered by an external domain event, as opposed to an incoming command. + * + *

This condition is the complement of {@link IsCommandProcessingCondition}. External events + * often require different reasoning strategies (reactive vs. proactive), so the GOAP planner + * uses this condition to select appropriate goals and actions. + * + *

The condition reads the {@code "isExternalEvent"} binding from the agent's blackboard, + * which is set to {@code true} by the + * {@link org.elasticsoftware.akces.agentic.runtime.AgenticEventHandlerFunctionAdapter} + * and to {@code false} by the + * {@link org.elasticsoftware.akces.agentic.runtime.AgenticCommandHandlerFunctionAdapter}. + * + * @see IsCommandProcessingCondition + * @see ReactToExternalEventGoal + */ +@Configuration +public class IsExternalEventCondition { + + /** + * The name of this condition as referenced by goal preconditions and action + * pre/postconditions in the GOAP planning system. + */ + public static final String CONDITION_NAME = "isExternalEvent"; + + /** + * Creates the {@code isExternalEvent} condition bean. + * + *

The condition reads the {@code "isExternalEvent"} binding from the blackboard + * and returns {@code true} if it is a {@link Boolean#TRUE} value. If the binding is + * absent or not a {@code Boolean}, the condition evaluates to {@code false}. + * + * @return a {@link Condition} that evaluates to {@code true} during external event processing + */ + @Bean + public Condition isExternalEventCondition() { + return new ComputedBooleanCondition(CONDITION_NAME, 0.0, (context, condition) -> { + Object value = context.get(CONDITION_NAME); + return value instanceof Boolean b && b; + }); + } +} diff --git a/main/agentic/src/main/java/org/elasticsoftware/akces/agentic/agent/ProcessCommandGoal.java b/main/agentic/src/main/java/org/elasticsoftware/akces/agentic/agent/ProcessCommandGoal.java new file mode 100644 index 00000000..db90dd92 --- /dev/null +++ b/main/agentic/src/main/java/org/elasticsoftware/akces/agentic/agent/ProcessCommandGoal.java @@ -0,0 +1,86 @@ +/* + * 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.core.Goal; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +/** + * Defines the {@code ProcessCommand} goal for agentic aggregates. + * + *

This is the top-level goal for command processing in an agentic aggregate. It replaces + * the deterministic {@code @CommandHandler} flow with an AI-assisted reasoning loop for + * commands declared in {@code @AgenticAggregateInfo.agentHandledCommands()}. + * + *

Precondition: {@code isCommandProcessing} — the goal is only + * applicable when the agent was triggered by an incoming command (as opposed to an + * external domain event). + * + *

Expected plan (determined by the GOAP planner based on available + * actions and conditions): + *

    + *
  1. {@code RecallMemoriesAction} → load relevant prior knowledge
  2. + *
  3. {@code AnalyzeAggregateStateAction} → understand current state
  4. + *
  5. (LLM reasoning with MCP tools) → determine actions and produce domain events + * on the blackboard (including error events from {@code agentProducedErrors})
  6. + *
  7. {@code LearnFromProcessGoal} (sub-goal) → learn from experience
  8. + *
+ * + *

Domain events produced during the reasoning step are placed directly on the blackboard. + * The {@link org.elasticsoftware.akces.agentic.runtime.AgentProcessResultTranslator} + * collects all {@code DomainEvent} objects from the blackboard after each {@code tick()} — + * no explicit "emit" action is needed. + * + * @see IsCommandProcessingCondition + * @see AnalyzeAggregateStateAction + * @see ReactToExternalEventGoal + */ +@Configuration +public class ProcessCommandGoal { + + /** + * The name of this goal as used by the GOAP planner. + */ + public static final String GOAL_NAME = "ProcessCommand"; + + /** + * A human-readable description of what this goal achieves. + */ + public static final String GOAL_DESCRIPTION = + "Process an incoming command through AI-assisted reasoning and produce domain events"; + + /** + * Creates the {@code ProcessCommand} goal bean. + * + *

The goal is created with the {@code isCommandProcessing} precondition, ensuring + * that the GOAP planner only selects this goal when the agent was triggered by a + * command. The planner determines the optimal sequence of actions to achieve this + * goal based on the available actions and the current world state. + * + * @return a {@link Goal} representing the command processing objective + */ + @Bean + public Goal processCommandGoal() { + return Goal.Companion.createInstance( + GOAL_DESCRIPTION, + Void.class, + GOAL_NAME + ).withPreconditions(IsCommandProcessingCondition.CONDITION_NAME); + } +} diff --git a/main/agentic/src/main/java/org/elasticsoftware/akces/agentic/agent/ReactToExternalEventGoal.java b/main/agentic/src/main/java/org/elasticsoftware/akces/agentic/agent/ReactToExternalEventGoal.java new file mode 100644 index 00000000..06a41ec3 --- /dev/null +++ b/main/agentic/src/main/java/org/elasticsoftware/akces/agentic/agent/ReactToExternalEventGoal.java @@ -0,0 +1,88 @@ +/* + * 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.core.Goal; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +/** + * Defines the {@code ReactToExternalEvent} goal for agentic aggregates. + * + *

This is the top-level goal for handling external domain events in an agentic aggregate. + * It replaces the deterministic {@code @EventHandler} flow with an AI-assisted reasoning + * loop for events declared in {@code @AgenticAggregateInfo.agentHandledEvents()}. + * + *

Precondition: {@code isExternalEvent} — the goal is only applicable + * when the agent was triggered by an external domain event (as opposed to an incoming + * command). + * + *

Expected plan (determined by the GOAP planner based on available + * actions and conditions): + *

    + *
  1. {@code RecallMemoriesAction} → load relevant prior knowledge
  2. + *
  3. {@code AnalyzeAggregateStateAction} → understand current state
  4. + *
  5. (LLM reasoning) → determine appropriate response and produce domain events + * on the blackboard (including error events from {@code agentProducedErrors})
  6. + *
  7. {@code SendCommandAction} → send commands to other aggregates if needed
  8. + *
  9. {@code LearnFromProcessGoal} (sub-goal) → learn from experience
  10. + *
+ * + *

Domain events produced during the reasoning step are placed directly on the blackboard. + * The {@link org.elasticsoftware.akces.agentic.runtime.AgentProcessResultTranslator} + * collects all {@code DomainEvent} objects from the blackboard after each {@code tick()} — + * no explicit "emit" action is needed. + * + * @see IsExternalEventCondition + * @see AnalyzeAggregateStateAction + * @see SendCommandAction + * @see ProcessCommandGoal + */ +@Configuration +public class ReactToExternalEventGoal { + + /** + * The name of this goal as used by the GOAP planner. + */ + public static final String GOAL_NAME = "ReactToExternalEvent"; + + /** + * A human-readable description of what this goal achieves. + */ + public static final String GOAL_DESCRIPTION = + "React to an external domain event through AI-assisted reasoning"; + + /** + * Creates the {@code ReactToExternalEvent} goal bean. + * + *

The goal is created with the {@code isExternalEvent} precondition, ensuring + * that the GOAP planner only selects this goal when the agent was triggered by an + * external domain event. The planner determines the optimal sequence of actions to + * achieve this goal based on the available actions and the current world state. + * + * @return a {@link Goal} representing the external event processing objective + */ + @Bean + public Goal reactToExternalEventGoal() { + return Goal.Companion.createInstance( + GOAL_DESCRIPTION, + Void.class, + GOAL_NAME + ).withPreconditions(IsExternalEventCondition.CONDITION_NAME); + } +} diff --git a/main/agentic/src/main/java/org/elasticsoftware/akces/agentic/agent/SendCommandAction.java b/main/agentic/src/main/java/org/elasticsoftware/akces/agentic/agent/SendCommandAction.java new file mode 100644 index 00000000..def9fdea --- /dev/null +++ b/main/agentic/src/main/java/org/elasticsoftware/akces/agentic/agent/SendCommandAction.java @@ -0,0 +1,71 @@ +/* + * 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.EmbabelComponent; +import org.elasticsoftware.akces.commands.Command; +import org.elasticsoftware.akces.commands.CommandBus; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * Embabel action that sends a {@link Command} to another aggregate via the {@link CommandBus}. + * + *

This action enables cross-aggregate coordination from within an agentic aggregate's + * AI-assisted reasoning loop. When the agent determines that it needs to interact with + * another aggregate (e.g., sending a {@code CreditWalletCommand} to a Wallet aggregate), + * it places the command on the blackboard and this action picks it up and routes it + * through the {@link CommandBus}. + * + *

The {@link CommandBus} implementation is provided by + * {@link org.elasticsoftware.akces.agentic.runtime.AgenticAggregatePartition}, which + * resolves the target aggregate's command topic and sends the command via Kafka. + * + *

Blackboard inputs: + *

+ * + * @see org.elasticsoftware.akces.commands.CommandBus + * @see org.elasticsoftware.akces.agentic.runtime.AgenticAggregatePartition + */ +@EmbabelComponent +public class SendCommandAction { + + private static final Logger logger = LoggerFactory.getLogger(SendCommandAction.class); + + /** + * Sends a command to another aggregate via the command bus. + * + *

The {@link Command} and {@link CommandBus} are resolved from the agent's blackboard. + * The command bus routes the command to the appropriate aggregate's command topic + * based on the command type. + * + * @param command the command to send; resolved from the blackboard by type + * @param commandBus the command bus for routing; resolved from the blackboard by type + */ + @Action(description = "Send a command to another aggregate via the command bus") + public void sendCommand(Command command, CommandBus commandBus) { + logger.debug("Sending command {} to aggregate via command bus", + command.getClass().getSimpleName()); + commandBus.send(command); + logger.debug("Command {} dispatched successfully", command.getClass().getSimpleName()); + } +} diff --git a/main/agentic/src/test/java/org/elasticsoftware/akces/agentic/agent/AnalyzeAggregateStateActionTest.java b/main/agentic/src/test/java/org/elasticsoftware/akces/agentic/agent/AnalyzeAggregateStateActionTest.java new file mode 100644 index 00000000..5412d135 --- /dev/null +++ b/main/agentic/src/test/java/org/elasticsoftware/akces/agentic/agent/AnalyzeAggregateStateActionTest.java @@ -0,0 +1,93 @@ +/* + * 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.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.elasticsoftware.akces.aggregate.AggregateState; +import org.elasticsoftware.akces.annotations.AggregateStateInfo; +import org.junit.jupiter.api.Test; + +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Unit tests for {@link AnalyzeAggregateStateAction}. + */ +class AnalyzeAggregateStateActionTest { + + private final ObjectMapper objectMapper = new ObjectMapper(); + private final AnalyzeAggregateStateAction action = new AnalyzeAggregateStateAction(objectMapper); + + @AggregateStateInfo(type = "TestState", version = 1) + record TestState(String id, String name, int count) implements AggregateState { + @Override + public String getAggregateId() { + return id; + } + } + + @Test + void analyzeStateSerializesToJson() { + TestState state = new TestState("agg-1", "TestAggregate", 42); + + String result = action.analyzeState(state); + + assertThat(result).contains("\"id\""); + assertThat(result).contains("\"agg-1\""); + assertThat(result).contains("\"name\""); + assertThat(result).contains("\"TestAggregate\""); + assertThat(result).contains("\"count\""); + assertThat(result).contains("42"); + } + + @Test + void analyzeStateProducesPrettyPrintedJson() { + TestState state = new TestState("agg-1", "Test", 1); + + String result = action.analyzeState(state); + + // Pretty-printed JSON contains newlines and indentation + assertThat(result).contains("\n"); + } + + @Test + void analyzeStateFallsBackToToStringOnSerializationError() { + // Use a mock ObjectMapper that throws on serialization + ObjectMapper failingMapper = new ObjectMapper() { + @Override + public com.fasterxml.jackson.databind.ObjectWriter writerWithDefaultPrettyPrinter() { + return new com.fasterxml.jackson.databind.ObjectWriter(this, + this.getSerializationConfig()) { + @Override + public String writeValueAsString(Object value) throws JsonProcessingException { + throw new JsonProcessingException("Simulated failure") {}; + } + }; + } + }; + AnalyzeAggregateStateAction failAction = new AnalyzeAggregateStateAction(failingMapper); + TestState state = new TestState("agg-1", "Test", 1); + + String result = failAction.analyzeState(state); + + // Falls back to toString() + assertThat(result).isEqualTo(state.toString()); + } +} diff --git a/main/agentic/src/test/java/org/elasticsoftware/akces/agentic/agent/DiscoverAggregateServicesActionTest.java b/main/agentic/src/test/java/org/elasticsoftware/akces/agentic/agent/DiscoverAggregateServicesActionTest.java new file mode 100644 index 00000000..784e5ecf --- /dev/null +++ b/main/agentic/src/test/java/org/elasticsoftware/akces/agentic/agent/DiscoverAggregateServicesActionTest.java @@ -0,0 +1,213 @@ +/* + * 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.control.AggregateServiceRecord; +import org.elasticsoftware.akces.control.AggregateServiceCommandType; +import org.elasticsoftware.akces.control.AggregateServiceDomainEventType; +import org.elasticsoftware.akces.control.AggregateServiceType; +import org.junit.jupiter.api.Test; + +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Unit tests for {@link DiscoverAggregateServicesAction}. + */ +class DiscoverAggregateServicesActionTest { + + private final DiscoverAggregateServicesAction action = new DiscoverAggregateServicesAction(); + + @Test + void discoverServicesReturnsEmptyMessageForNullCollection() { + String result = action.discoverServices(null); + + assertThat(result).contains("No aggregate services"); + } + + @Test + void discoverServicesReturnsEmptyMessageForEmptyCollection() { + String result = action.discoverServices(List.of()); + + assertThat(result).contains("No aggregate services"); + } + + @Test + void discoverServicesIncludesAggregateName() { + AggregateServiceRecord record = createRecord( + "Wallet", + "wallet-commands", + "wallet-events", + AggregateServiceType.STANDARD, + List.of(), + List.of(), + List.of() + ); + + String result = action.discoverServices(List.of(record)); + + assertThat(result).contains("Wallet"); + } + + @Test + void discoverServicesIncludesTopicNames() { + AggregateServiceRecord record = createRecord( + "Wallet", + "wallet-commands", + "wallet-events", + AggregateServiceType.STANDARD, + List.of(), + List.of(), + List.of() + ); + + String result = action.discoverServices(List.of(record)); + + assertThat(result).contains("wallet-commands"); + assertThat(result).contains("wallet-events"); + } + + @Test + void discoverServicesIncludesServiceType() { + AggregateServiceRecord standardRecord = createRecord( + "Wallet", "cmd", "evt", + AggregateServiceType.STANDARD, + List.of(), List.of(), List.of() + ); + AggregateServiceRecord agenticRecord = createRecord( + "Advisor", "cmd2", "evt2", + AggregateServiceType.AGENTIC, + List.of(), List.of(), List.of() + ); + + String result = action.discoverServices(List.of(standardRecord, agenticRecord)); + + assertThat(result).contains("STANDARD"); + assertThat(result).contains("AGENTIC"); + } + + @Test + void discoverServicesIncludesSupportedCommands() { + AggregateServiceRecord record = createRecord( + "Wallet", "cmd", "evt", + AggregateServiceType.STANDARD, + List.of(cmd("CreditWallet", 1)), + List.of(), + List.of() + ); + + String result = action.discoverServices(List.of(record)); + + assertThat(result).contains("CreditWallet"); + assertThat(result).contains("v1"); + } + + @Test + void discoverServicesIncludesProducedEvents() { + AggregateServiceRecord record = createRecord( + "Wallet", "cmd", "evt", + AggregateServiceType.STANDARD, + List.of(), + List.of(evt("WalletCredited", 1)), + List.of() + ); + + String result = action.discoverServices(List.of(record)); + + assertThat(result).contains("WalletCredited"); + } + + @Test + void discoverServicesIncludesConsumedEvents() { + AggregateServiceRecord record = createRecord( + "Wallet", "cmd", "evt", + AggregateServiceType.STANDARD, + List.of(), + List.of(), + List.of(evt("OrderPlaced", 1)) + ); + + String result = action.discoverServices(List.of(record)); + + assertThat(result).contains("OrderPlaced"); + } + + @Test + void discoverServicesHandlesMultipleAggregates() { + AggregateServiceRecord wallet = createRecord( + "Wallet", "wallet-cmd", "wallet-evt", + AggregateServiceType.STANDARD, + List.of(cmd("CreditWallet", 1)), + List.of(evt("WalletCredited", 1)), + List.of() + ); + AggregateServiceRecord portfolio = createRecord( + "Portfolio", "portfolio-cmd", "portfolio-evt", + AggregateServiceType.AGENTIC, + List.of(cmd("PlaceOrder", 2)), + List.of(evt("OrderPlaced", 1)), + List.of(evt("MarketDataUpdated", 1)) + ); + + String result = action.discoverServices(List.of(wallet, portfolio)); + + assertThat(result).contains("Wallet"); + assertThat(result).contains("Portfolio"); + assertThat(result).contains("CreditWallet"); + assertThat(result).contains("PlaceOrder"); + assertThat(result).contains("WalletCredited"); + assertThat(result).contains("OrderPlaced"); + assertThat(result).contains("MarketDataUpdated"); + } + + @Test + void discoverServicesIncludesVersionNumbers() { + AggregateServiceRecord record = createRecord( + "Wallet", "cmd", "evt", + AggregateServiceType.STANDARD, + List.of(cmd("CreditWallet", 3)), + List.of(evt("WalletCredited", 2)), + List.of() + ); + + String result = action.discoverServices(List.of(record)); + + assertThat(result).contains("v3"); + assertThat(result).contains("v2"); + } + + private AggregateServiceRecord createRecord( + String name, + String commandTopic, + String eventTopic, + AggregateServiceType type, + List commands, + List produced, + List consumed) { + return new AggregateServiceRecord(name, commandTopic, eventTopic, type, commands, produced, consumed); + } + + private static AggregateServiceCommandType cmd(String typeName, int version) { + return new AggregateServiceCommandType(typeName, version, false, typeName); + } + + private static AggregateServiceDomainEventType evt(String typeName, int version) { + return new AggregateServiceDomainEventType(typeName, version, false, false, typeName); + } +} diff --git a/main/agentic/src/test/java/org/elasticsoftware/akces/agentic/agent/IsCommandProcessingConditionTest.java b/main/agentic/src/test/java/org/elasticsoftware/akces/agentic/agent/IsCommandProcessingConditionTest.java new file mode 100644 index 00000000..9e07d9c4 --- /dev/null +++ b/main/agentic/src/test/java/org/elasticsoftware/akces/agentic/agent/IsCommandProcessingConditionTest.java @@ -0,0 +1,84 @@ +/* + * 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.core.Condition; +import com.embabel.agent.test.unit.FakeOperationContext; +import com.embabel.plan.common.condition.ConditionDetermination; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Unit tests for {@link IsCommandProcessingCondition}. + */ +class IsCommandProcessingConditionTest { + + private final IsCommandProcessingCondition config = new IsCommandProcessingCondition(); + private final Condition condition = config.isCommandProcessingCondition(); + + @Test + void conditionNameIsCorrect() { + assertThat(condition.getName()).isEqualTo("isCommandProcessing"); + } + + @Test + void evaluatesTrueWhenCommandProcessingBindingIsTrue() { + FakeOperationContext context = FakeOperationContext.Companion.create(); + context.bind("isCommandProcessing", true); + + ConditionDetermination result = condition.evaluate(context); + + assertThat(result).isEqualTo(ConditionDetermination.TRUE); + } + + @Test + void evaluatesFalseWhenCommandProcessingBindingIsFalse() { + FakeOperationContext context = FakeOperationContext.Companion.create(); + context.bind("isCommandProcessing", false); + + ConditionDetermination result = condition.evaluate(context); + + assertThat(result).isEqualTo(ConditionDetermination.FALSE); + } + + @Test + void evaluatesFalseWhenBindingIsAbsent() { + FakeOperationContext context = FakeOperationContext.Companion.create(); + + ConditionDetermination result = condition.evaluate(context); + + assertThat(result).isEqualTo(ConditionDetermination.FALSE); + } + + @Test + void evaluatesFalseWhenBindingIsNonBoolean() { + FakeOperationContext context = FakeOperationContext.Companion.create(); + context.bind("isCommandProcessing", "true"); + + ConditionDetermination result = condition.evaluate(context); + + assertThat(result).isEqualTo(ConditionDetermination.FALSE); + } + + @Test + void conditionNameConstantMatchesBean() { + assertThat(IsCommandProcessingCondition.CONDITION_NAME).isEqualTo("isCommandProcessing"); + assertThat(condition.getName()).isEqualTo(IsCommandProcessingCondition.CONDITION_NAME); + } +} diff --git a/main/agentic/src/test/java/org/elasticsoftware/akces/agentic/agent/IsExternalEventConditionTest.java b/main/agentic/src/test/java/org/elasticsoftware/akces/agentic/agent/IsExternalEventConditionTest.java new file mode 100644 index 00000000..0893987a --- /dev/null +++ b/main/agentic/src/test/java/org/elasticsoftware/akces/agentic/agent/IsExternalEventConditionTest.java @@ -0,0 +1,84 @@ +/* + * 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.core.Condition; +import com.embabel.agent.test.unit.FakeOperationContext; +import com.embabel.plan.common.condition.ConditionDetermination; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Unit tests for {@link IsExternalEventCondition}. + */ +class IsExternalEventConditionTest { + + private final IsExternalEventCondition config = new IsExternalEventCondition(); + private final Condition condition = config.isExternalEventCondition(); + + @Test + void conditionNameIsCorrect() { + assertThat(condition.getName()).isEqualTo("isExternalEvent"); + } + + @Test + void evaluatesTrueWhenExternalEventBindingIsTrue() { + FakeOperationContext context = FakeOperationContext.Companion.create(); + context.bind("isExternalEvent", true); + + ConditionDetermination result = condition.evaluate(context); + + assertThat(result).isEqualTo(ConditionDetermination.TRUE); + } + + @Test + void evaluatesFalseWhenExternalEventBindingIsFalse() { + FakeOperationContext context = FakeOperationContext.Companion.create(); + context.bind("isExternalEvent", false); + + ConditionDetermination result = condition.evaluate(context); + + assertThat(result).isEqualTo(ConditionDetermination.FALSE); + } + + @Test + void evaluatesFalseWhenBindingIsAbsent() { + FakeOperationContext context = FakeOperationContext.Companion.create(); + + ConditionDetermination result = condition.evaluate(context); + + assertThat(result).isEqualTo(ConditionDetermination.FALSE); + } + + @Test + void evaluatesFalseWhenBindingIsNonBoolean() { + FakeOperationContext context = FakeOperationContext.Companion.create(); + context.bind("isExternalEvent", 1); + + ConditionDetermination result = condition.evaluate(context); + + assertThat(result).isEqualTo(ConditionDetermination.FALSE); + } + + @Test + void conditionNameConstantMatchesBean() { + assertThat(IsExternalEventCondition.CONDITION_NAME).isEqualTo("isExternalEvent"); + assertThat(condition.getName()).isEqualTo(IsExternalEventCondition.CONDITION_NAME); + } +} diff --git a/main/agentic/src/test/java/org/elasticsoftware/akces/agentic/agent/ProcessCommandGoalTest.java b/main/agentic/src/test/java/org/elasticsoftware/akces/agentic/agent/ProcessCommandGoalTest.java new file mode 100644 index 00000000..6434addd --- /dev/null +++ b/main/agentic/src/test/java/org/elasticsoftware/akces/agentic/agent/ProcessCommandGoalTest.java @@ -0,0 +1,64 @@ +/* + * 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.core.Goal; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Unit tests for {@link ProcessCommandGoal}. + */ +class ProcessCommandGoalTest { + + private final ProcessCommandGoal config = new ProcessCommandGoal(); + private final Goal goal = config.processCommandGoal(); + + @Test + void goalNameIsProcessCommand() { + assertThat(goal.getName()).isEqualTo("ProcessCommand"); + } + + @Test + void goalDescriptionIsSet() { + assertThat(goal.getDescription()).isEqualTo( + "Process an incoming command through AI-assisted reasoning and produce domain events"); + } + + @Test + void goalHasIsCommandProcessingPrecondition() { + assertThat(goal.getPre()).contains("isCommandProcessing"); + } + + @Test + void goalPreconditionsMapContainsIsCommandProcessing() { + assertThat(goal.getPreconditions()).containsKey("isCommandProcessing"); + } + + @Test + void goalNameConstantMatchesBean() { + assertThat(ProcessCommandGoal.GOAL_NAME).isEqualTo("ProcessCommand"); + assertThat(goal.getName()).isEqualTo(ProcessCommandGoal.GOAL_NAME); + } + + @Test + void goalDescriptionConstantMatchesBean() { + assertThat(ProcessCommandGoal.GOAL_DESCRIPTION).isEqualTo(goal.getDescription()); + } +} diff --git a/main/agentic/src/test/java/org/elasticsoftware/akces/agentic/agent/ReactToExternalEventGoalTest.java b/main/agentic/src/test/java/org/elasticsoftware/akces/agentic/agent/ReactToExternalEventGoalTest.java new file mode 100644 index 00000000..1f34a4a8 --- /dev/null +++ b/main/agentic/src/test/java/org/elasticsoftware/akces/agentic/agent/ReactToExternalEventGoalTest.java @@ -0,0 +1,64 @@ +/* + * 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.core.Goal; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Unit tests for {@link ReactToExternalEventGoal}. + */ +class ReactToExternalEventGoalTest { + + private final ReactToExternalEventGoal config = new ReactToExternalEventGoal(); + private final Goal goal = config.reactToExternalEventGoal(); + + @Test + void goalNameIsReactToExternalEvent() { + assertThat(goal.getName()).isEqualTo("ReactToExternalEvent"); + } + + @Test + void goalDescriptionIsSet() { + assertThat(goal.getDescription()).isEqualTo( + "React to an external domain event through AI-assisted reasoning"); + } + + @Test + void goalHasIsExternalEventPrecondition() { + assertThat(goal.getPre()).contains("isExternalEvent"); + } + + @Test + void goalPreconditionsMapContainsIsExternalEvent() { + assertThat(goal.getPreconditions()).containsKey("isExternalEvent"); + } + + @Test + void goalNameConstantMatchesBean() { + assertThat(ReactToExternalEventGoal.GOAL_NAME).isEqualTo("ReactToExternalEvent"); + assertThat(goal.getName()).isEqualTo(ReactToExternalEventGoal.GOAL_NAME); + } + + @Test + void goalDescriptionConstantMatchesBean() { + assertThat(ReactToExternalEventGoal.GOAL_DESCRIPTION).isEqualTo(goal.getDescription()); + } +} diff --git a/main/agentic/src/test/java/org/elasticsoftware/akces/agentic/agent/SendCommandActionTest.java b/main/agentic/src/test/java/org/elasticsoftware/akces/agentic/agent/SendCommandActionTest.java new file mode 100644 index 00000000..e3bdc7fd --- /dev/null +++ b/main/agentic/src/test/java/org/elasticsoftware/akces/agentic/agent/SendCommandActionTest.java @@ -0,0 +1,52 @@ +/* + * 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.commands.Command; +import org.elasticsoftware.akces.commands.CommandBus; +import org.junit.jupiter.api.Test; + +import static org.mockito.Mockito.*; + +/** + * Unit tests for {@link SendCommandAction}. + */ +class SendCommandActionTest { + + private final SendCommandAction action = new SendCommandAction(); + + @Test + void sendCommandDispatchesViaCommandBus() { + Command command = mock(Command.class); + CommandBus commandBus = mock(CommandBus.class); + + action.sendCommand(command, commandBus); + + verify(commandBus, times(1)).send(command); + } + + @Test + void sendCommandUsesExactCommandInstance() { + Command command = mock(Command.class); + CommandBus commandBus = mock(CommandBus.class); + + action.sendCommand(command, commandBus); + + verify(commandBus).send(same(command)); + } +}