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: + *
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 Blackboard inputs:
+ * Blackboard output:
+ * For each {@link AggregateServiceRecord}, the summary includes:
+ * 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):
+ * 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):
+ * 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:
+ * 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
+ *
+ *
+ *
+ *
+ *
+ * @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.
+ *
+ *
+ *
+ *
+ * @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
+ *
+ *
+ *
+ *
+ *
+ *
+ *
+ *
+ * @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.
+ *
+ *