Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -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.
*
* <p>This is a <strong>framework building block</strong> — 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.
*
* <p>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.
*
* <p><strong>Blackboard inputs:</strong>
* <ul>
* <li>{@link AggregateState} — the current aggregate state, populated by the handler adapter</li>
* </ul>
*
* <p><strong>Blackboard output:</strong>
* <ul>
* <li>{@link String} — a JSON representation of the aggregate state for LLM analysis</li>
* </ul>
*
* @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.
*
* <p>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.
*
* <p>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();
}
}
}
Original file line number Diff line number Diff line change
@@ -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.
*
* <p>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}.
*
* <p>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<String, AggregateServiceRecord>} loaded from the
* {@code Akces-Control} topic at startup.
*
* <p><strong>Blackboard inputs:</strong>
* <ul>
* <li>{@code Collection<AggregateServiceRecord>} — all known aggregate service records,
* populated by the handler adapter as the {@code "aggregateServices"} binding</li>
* </ul>
*
* <p><strong>Blackboard output:</strong>
* <ul>
* <li>{@link String} — a structured text summary of all discovered aggregates, their
* topics, types, supported commands, produced events, and consumed events</li>
* </ul>
*
* @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.
*
* <p>For each {@link AggregateServiceRecord}, the summary includes:
* <ul>
* <li>{@code aggregateName} — the logical name of the aggregate</li>
* <li>{@code commandTopic} — the Kafka topic for sending commands</li>
* <li>{@code domainEventTopic} — the topic where the aggregate publishes events</li>
* <li>{@code type} — {@code STANDARD} or {@code AGENTIC}</li>
* <li>{@code supportedCommands} — list of command types the aggregate accepts</li>
* <li>{@code producedEvents} — list of domain event types the aggregate produces</li>
* <li>{@code consumedEvents} — list of external event types the aggregate consumes</li>
* </ul>
*
* @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<AggregateServiceRecord> 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;
}
}
Original file line number Diff line number Diff line change
@@ -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.
*
* <p>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.
*
* <p>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.
*
* <p>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;
});
}
}
Original file line number Diff line number Diff line change
@@ -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.
*
* <p>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.
*
* <p>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.
*
* <p>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;
});
}
}
Loading
Loading