Introduce Belief-Desire-Intention (BDI) agentic pattern (#5730)
<!-- Thank you so much for your contribution! Please fill in all the sections below. Please open the PR as ready for review (not as a draft), with tests and documentation already included. Please note that PRs with breaking changes, or without tests and documentation, will be rejected. Please note that PRs will be reviewed based on the priority of the issues they address. We ask for your patience. We are doing our best to review your PR as quickly as possible. Please refrain from pinging and asking when it will be reviewed. Thank you for understanding! --> ## Issue <!-- Please specify the ID of the issue this PR is addressing. For example: "Closes #1234" or "Fixes #1234" --> Closes # ## Change <!-- Please describe the changes you made. --> ## General checklist <!-- Please double-check the following points and mark them like this: [X] --> - [X] There are no breaking changes (API, behaviour) - [X] I have added unit and/or integration tests for my change - [X] The tests cover both positive and negative cases - [X] I have manually run all the unit and integration tests in the module I have added/changed, and they are all green - [ ] I have manually run all the unit and integration tests in the [core](https://github.com/langchain4j/langchain4j/tree/main/langchain4j-core) and [main](https://github.com/langchain4j/langchain4j/tree/main/langchain4j) modules, and they are all green - [ ] I have added/updated the [documentation](https://github.com/langchain4j/langchain4j/tree/main/docs/docs) - [ ] I have added an example in the [examples repo](https://github.com/langchain4j/langchain4j-examples) (only for "big" features) - [ ] I have added/updated [Spring Boot starter(s)](https://github.com/langchain4j/langchain4j-spring) (if applicable) ## Checklist for adding new maven module <!-- Please double-check the following points and mark them like this: [X] --> - [ ] I have added my new module in the root `pom.xml` and `langchain4j-bom/pom.xml` ## Checklist for adding new embedding store integration <!-- Please double-check the following points and mark them like this: [X] --> - [ ] I have added a `{NameOfIntegration}EmbeddingStoreIT` that extends from either `EmbeddingStoreIT` or `EmbeddingStoreWithFilteringIT` - [ ] I have added a `{NameOfIntegration}EmbeddingStoreRemovalIT` that extends from `EmbeddingStoreWithRemovalIT` ## Checklist for changing existing embedding store integration <!-- Please double-check the following points and mark them like this: [X] --> - [ ] I have manually verified that the `{NameOfIntegration}EmbeddingStore` works correctly with the data persisted using the latest released version of LangChain4j --------- Co-authored-by: Dmytro Liubarskyi <ljubarskij@gmail.com>
This commit is contained in:
parent
6e6cf9fec4
commit
b9e3f27200
|
|
@ -1962,7 +1962,7 @@ The P2P pattern activates all ready agents in parallel, treating them as equal p
|
|||
|
||||
Like P2P, agents activate implicitly when all their arguments are present in the scope. The key difference is that only one agent fires per step, and when multiple agents are ready, a `ConflictResolutionStrategy` determines which one takes priority. If no strategy is provided, the declaration order in the `subAgents` method is used as the default tie-breaker.
|
||||
|
||||
The `BlackboardPlanner` terminates when the goal predicate is satisfied, no agent can fire (quiescence), or the maximum number of invocations is reached. By default, the goal predicate checks whether the planner's `outputKey` is present in the scope — which is the most common termination condition:
|
||||
The `BlackboardPlanner` terminates successfully when the goal predicate is satisfied or no agent can fire (quiescence); if the maximum number of invocations is reached before the goal is satisfied, it throws an `IllegalStateException`. By default, the goal predicate checks whether the planner's `outputKey` is present in the scope — which is the most common termination condition:
|
||||
|
||||
```java
|
||||
public class BlackboardPlanner implements Planner {
|
||||
|
|
@ -2366,6 +2366,118 @@ To customize the convergence check or the number of rounds:
|
|||
positions.stream().allMatch(p -> p.toString().contains("AGREE")))) // custom convergence
|
||||
```
|
||||
|
||||
### Belief-Desire-Intention (BDI) agentic pattern
|
||||
|
||||
The Belief-Desire-Intention (BDI) pattern models the classic AI concept of an agent that maintains explicit goals, evaluates which goals are currently achievable, and reactively switches between them when the environment changes. The planner implementing this pattern maintains three structures — Beliefs (the current world state from the `AgenticScope`), Desires (a set of prioritized goals), and Intentions (the committed plan currently being executed). At each step, the planner checks whether a higher-priority desire has become achievable and, if so, drops the current intention and re-deliberates. This makes BDI naturally suited for dynamic environments where multiple competing goals must be balanced and priorities can shift at any time.
|
||||
|
||||
A `Desire` is defined as a record combining a name, a priority level, an achievability predicate, a satisfaction predicate, and the ordered list of agent types that form the intention for pursuing that desire:
|
||||
|
||||
```java
|
||||
public record Desire(String name, int priority,
|
||||
Predicate<AgenticScope> achievable,
|
||||
Predicate<AgenticScope> satisfied,
|
||||
List<Class<?>> agentTypes) {
|
||||
|
||||
public static Desire of(String name, int priority,
|
||||
Predicate<AgenticScope> achievable,
|
||||
Predicate<AgenticScope> satisfied,
|
||||
Class<?>... agentTypes) {
|
||||
return new Desire(name, priority, achievable, satisfied, List.of(agentTypes));
|
||||
}
|
||||
|
||||
public static Desire of(String name, int priority,
|
||||
String achievableStateKey,
|
||||
String satisfiedStateKey,
|
||||
Class<?>... agentTypes) {
|
||||
return new Desire(name, priority,
|
||||
scope -> scope.hasState(achievableStateKey),
|
||||
scope -> scope.hasState(satisfiedStateKey),
|
||||
List.of(agentTypes));
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
The `BDIPlanner`, implementing this pattern, takes a list of `Desire` instances and implements the deliberation cycle. During initialization, it maps each registered sub-agent by its type so that desires can reference agents by class. When execution begins, the planner filters all desires to find those that are currently achievable and not yet satisfied, selects the one with the highest priority (among equal priorities, the one declared first in the list wins), and commits to its intention, defined as the ordered sequence of agents defined by that desire. On each subsequent step, the planner runs three checks: first, **satisfaction**, testing if the current desire is now satisfied, the planner re-deliberates to select the next desire; second, **preemption**, verifying if a strictly higher-priority desire has become achievable due to belief changes (new values written to the `AgenticScope`), the current intention is suspended and the higher-priority one takes over; third, **viability** checking if the current desire is still achievable and unsatisfied, the planner advances to the next agent in the intention sequence. When a preempted desire is later re-selected, it resumes from where it left off rather than restarting, so that agents that already completed are not re-invoked.
|
||||
|
||||
Execution terminates successfully when all desires are satisfied or none are achievable. The planner throws `IllegalStateException` in two misbehaving scenarios: if a desire's entire intention completes but the desire remains unsatisfied (the agents don't write the keys the satisfied predicate expects), or if the configurable maximum invocation count is reached with unsatisfied desires still pending.
|
||||
|
||||
On crash recovery, the planner re-deliberates from scratch: satisfied desires are skipped, but the selected desire's intention restarts from its first agent. Agents that already completed before the crash will run again, so intention agents should be idempotent.
|
||||
|
||||
To illustrate this pattern, consider an autonomous trading system with five AI agents and one non-AI agent. The `MarketRecommendationAgent` returns a `MarketRecommendation` enum, and hedging is only triggered when the recommendation is `SELL` or `STRONG_SELL`. The `HedgingStrategyDefaulter` is a non-AI agent that ensures `hedgingStrategy` is always present in scope (defaulting to `"None"` when hedging was skipped), so the `RebalancingAgent` can always receive it as input:
|
||||
|
||||
```java
|
||||
public enum MarketRecommendation {
|
||||
STRONG_BUY, BUY, HOLD, SELL, STRONG_SELL
|
||||
}
|
||||
|
||||
public interface MarketAnalysisAgent {
|
||||
@UserMessage("Analyze the market data and portfolio. Market: {{marketData}} Portfolio: {{portfolio}}")
|
||||
@Agent(value = "Analyze market conditions", outputKey = "marketAnalysis")
|
||||
String analyzeMarket(@V("marketData") String marketData, @V("portfolio") String portfolio);
|
||||
}
|
||||
|
||||
public interface MarketRecommendationAgent {
|
||||
@UserMessage("Based on the market analysis, provide a trading recommendation. Market analysis: {{marketAnalysis}}")
|
||||
@Agent(value = "Provide a trading recommendation", outputKey = "recommendation")
|
||||
MarketRecommendation recommend(@V("marketAnalysis") String marketAnalysis);
|
||||
}
|
||||
|
||||
public static class HedgingStrategyDefaulter {
|
||||
@Agent(outputKey = "hedgingStrategy")
|
||||
public String defaultHedging(AgenticScope scope) {
|
||||
return scope.hasState("hedgingStrategy") ? (String) scope.readState("hedgingStrategy") : "None";
|
||||
}
|
||||
}
|
||||
|
||||
public interface RebalancingAgent {
|
||||
@UserMessage("Suggest rebalancing based on: {{marketAnalysis}} Hedging strategy: {{hedgingStrategy}} Portfolio: {{portfolio}}")
|
||||
@Agent(value = "Rebalance portfolio", outputKey = "rebalancingPlan")
|
||||
String rebalance(@V("marketAnalysis") String marketAnalysis,
|
||||
@V("hedgingStrategy") String hedgingStrategy,
|
||||
@V("portfolio") String portfolio);
|
||||
}
|
||||
|
||||
public interface HedgingAgent {
|
||||
@UserMessage("Recommend hedging strategies based on: {{marketAnalysis}}")
|
||||
@Agent(value = "Hedge against risks", outputKey = "hedgingStrategy")
|
||||
String hedge(@V("marketAnalysis") String marketAnalysis);
|
||||
}
|
||||
|
||||
public interface LiquidityAgent {
|
||||
@UserMessage("Assess liquidity for portfolio: {{portfolio}}")
|
||||
@Agent(value = "Maintain liquidity", outputKey = "liquidityAssessment")
|
||||
String assessLiquidity(@V("portfolio") String portfolio);
|
||||
}
|
||||
```
|
||||
|
||||
These agents are wired into a BDI-based trading system with four desires of different priorities. Note how the "hedge risks" desire uses a predicate-based achievability check that inspects the recommendation value, and the "rebalance portfolio" desire includes the `HedgingStrategyDefaulter` before the `RebalancingAgent` to guarantee the `hedgingStrategy` scope value is present:
|
||||
|
||||
```java
|
||||
TradingSystem tradingSystem = AgenticServices.plannerBuilder(TradingSystem.class)
|
||||
.subAgents(marketAnalysis, recommendation, new HedgingStrategyDefaulter(),
|
||||
rebalancing, hedging, liquidity)
|
||||
.planner(() -> new BDIPlanner(List.of(
|
||||
Desire.of("analyze market", 1,
|
||||
"marketData", "recommendation",
|
||||
MarketAnalysisAgent.class, MarketRecommendationAgent.class),
|
||||
Desire.of("hedge risks", 2,
|
||||
scope -> scope.hasState("recommendation")
|
||||
&& Set.of(MarketRecommendation.SELL, MarketRecommendation.STRONG_SELL)
|
||||
.contains(scope.readState("recommendation")),
|
||||
scope -> scope.hasState("hedgingStrategy"),
|
||||
HedgingAgent.class),
|
||||
Desire.of("rebalance portfolio", 1,
|
||||
"recommendation", "rebalancingPlan",
|
||||
HedgingStrategyDefaulter.class, RebalancingAgent.class),
|
||||
Desire.of("maintain liquidity", 1,
|
||||
"portfolio", "liquidityAssessment",
|
||||
LiquidityAgent.class)
|
||||
)))
|
||||
.build();
|
||||
```
|
||||
|
||||
When invoked with market data and portfolio state, the planner's deliberation cycle works as follows: the "analyze market" and "maintain liquidity" desires are initially achievable. Once the `MarketAnalysisAgent` and `MarketRecommendationAgent` complete, the recommendation determines the next step. If the recommendation is `SELL` or `STRONG_SELL`, the "hedge risks" desire (priority 2) becomes achievable and preempts any lower-priority work, causing the planner to invoke the `HedgingAgent`. After hedging completes, the planner re-deliberates: the "rebalance portfolio" desire runs `HedgingStrategyDefaulter` (which preserves the existing hedging strategy) followed by `RebalancingAgent`, which receives the hedging strategy as input. If the recommendation is not `SELL` or `STRONG_SELL`, hedging is skipped entirely, and the `HedgingStrategyDefaulter` writes `"None"` so that `RebalancingAgent` can still proceed. This reactive, condition-driven switching is the essence of BDI — the system adapts its behavior based on changing beliefs rather than following a rigid plan.
|
||||
|
||||
## Non-AI agents
|
||||
|
||||
All the agents discussed so far are AI agents, meaning that they are based on LLMs and can be invoked to perform tasks that require natural language understanding and generation. However, the `langchain4j-agentic` module also supports non-AI agents, which can be used to perform tasks that do not require natural language processing, like invoking a REST API or executing a command. These non-AI agents are indeed more similar to tools, but in this context it is convenient to model them as agents, so that they can be used in the same way as AI agents, and mixed with them to compose more powerful and complete agentic systems.
|
||||
|
|
|
|||
|
|
@ -0,0 +1,172 @@
|
|||
package dev.langchain4j.agentic.patterns.bdi;
|
||||
|
||||
import dev.langchain4j.agentic.planner.Action;
|
||||
import dev.langchain4j.agentic.planner.AgentInstance;
|
||||
import dev.langchain4j.agentic.planner.AgenticSystemTopology;
|
||||
import dev.langchain4j.agentic.planner.InitPlanningContext;
|
||||
import dev.langchain4j.agentic.planner.PlanningContext;
|
||||
import dev.langchain4j.agentic.planner.Planner;
|
||||
import dev.langchain4j.agentic.scope.AgenticScope;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
|
||||
import java.util.Comparator;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
import static java.util.stream.Collectors.toMap;
|
||||
|
||||
/**
|
||||
* A Belief-Desire-Intention planner that maintains prioritized goals ({@link Desire Desires}) and
|
||||
* reactively switches between them as the {@link AgenticScope} evolves. On each step the planner
|
||||
* checks whether a strictly higher-priority desire has become achievable and, if so, preempts the
|
||||
* current intention and re-deliberates. Among desires with equal priority, the one declared first
|
||||
* in the list is selected (stable ordering).
|
||||
*/
|
||||
public class BDIPlanner implements Planner {
|
||||
|
||||
private static final Logger LOG = LoggerFactory.getLogger(BDIPlanner.class);
|
||||
|
||||
private static final int DEFAULT_MAX_INVOCATIONS = 50;
|
||||
|
||||
private final List<Desire> desires;
|
||||
private final int maxInvocations;
|
||||
|
||||
private Map<Class<?>, AgentInstance> agentsByType;
|
||||
private final Map<String, Integer> desireProgress = new HashMap<>();
|
||||
private Desire currentDesire;
|
||||
private List<AgentInstance> currentIntention;
|
||||
private int intentionCursor;
|
||||
private int invocationCounter;
|
||||
|
||||
public BDIPlanner(List<Desire> desires) {
|
||||
this(desires, DEFAULT_MAX_INVOCATIONS);
|
||||
}
|
||||
|
||||
public BDIPlanner(List<Desire> desires, int maxInvocations) {
|
||||
if (desires == null || desires.isEmpty()) {
|
||||
throw new IllegalArgumentException("BDIPlanner requires at least one desire");
|
||||
}
|
||||
if (maxInvocations <= 0) {
|
||||
throw new IllegalArgumentException("maxInvocations must be positive, got " + maxInvocations);
|
||||
}
|
||||
this.desires = desires;
|
||||
this.maxInvocations = maxInvocations;
|
||||
}
|
||||
|
||||
@Override
|
||||
public AgenticSystemTopology topology() {
|
||||
return AgenticSystemTopology.SEQUENCE;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void init(InitPlanningContext initPlanningContext) {
|
||||
this.agentsByType = initPlanningContext.subagents().stream()
|
||||
.collect(toMap(AgentInstance::type, a -> a, (a, b) -> {
|
||||
throw new IllegalArgumentException(
|
||||
"BDI desires reference agents by type, so each agent type must be unique. " +
|
||||
"Duplicate agent type: " + a.type().getName());
|
||||
}));
|
||||
for (Desire desire : desires) {
|
||||
for (Class<?> agentType : desire.agentTypes()) {
|
||||
if (!agentsByType.containsKey(agentType)) {
|
||||
throw new IllegalArgumentException(
|
||||
"Desire '" + desire.name() + "' references unknown agent type: " + agentType.getName());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public Action firstAction(PlanningContext planningContext) {
|
||||
return deliberate(planningContext.agenticScope());
|
||||
}
|
||||
|
||||
@Override
|
||||
public Action nextAction(PlanningContext planningContext) {
|
||||
AgenticScope scope = planningContext.agenticScope();
|
||||
|
||||
if (currentDesire != null && !currentDesire.satisfied().test(scope)) {
|
||||
boolean preempted = desires.stream()
|
||||
.filter(d -> d.priority() > currentDesire.priority())
|
||||
.filter(d -> d.achievable().test(scope))
|
||||
.anyMatch(d -> !d.satisfied().test(scope));
|
||||
|
||||
if (preempted) {
|
||||
desireProgress.put(currentDesire.name(), intentionCursor + 1);
|
||||
LOG.info("Preempting desire '{}' for a higher-priority desire", currentDesire.name());
|
||||
return deliberate(scope);
|
||||
}
|
||||
|
||||
if (currentDesire.achievable().test(scope)) {
|
||||
intentionCursor++;
|
||||
if (intentionCursor < currentIntention.size()) {
|
||||
return dispatch(currentIntention.get(intentionCursor));
|
||||
}
|
||||
throw new IllegalStateException(
|
||||
"Desire '" + currentDesire.name() + "' is still unsatisfied after its entire intention " +
|
||||
"completed (" + currentIntention.size() + " agents). Check that the intention's agents " +
|
||||
"write the state keys required by the desire's satisfied predicate.");
|
||||
}
|
||||
}
|
||||
|
||||
return deliberate(scope);
|
||||
}
|
||||
|
||||
private Action dispatch(AgentInstance agent) {
|
||||
if (invocationCounter >= maxInvocations) {
|
||||
throw new IllegalStateException(
|
||||
"Maximum invocations (" + maxInvocations + ") reached with unsatisfied desires. " +
|
||||
"Increase maxInvocations or check desire predicates.");
|
||||
}
|
||||
invocationCounter++;
|
||||
return call(agent);
|
||||
}
|
||||
|
||||
private Action deliberate(AgenticScope scope) {
|
||||
return desires.stream()
|
||||
.filter(d -> d.achievable().test(scope))
|
||||
.filter(d -> !d.satisfied().test(scope))
|
||||
.max(Comparator.comparingInt(Desire::priority))
|
||||
.map(desire -> {
|
||||
currentDesire = desire;
|
||||
currentIntention = desire.agentTypes().stream()
|
||||
.map(agentsByType::get)
|
||||
.toList();
|
||||
intentionCursor = desireProgress.getOrDefault(desire.name(), 0);
|
||||
if (intentionCursor >= currentIntention.size()) {
|
||||
throw new IllegalStateException(
|
||||
"Desire '" + desire.name() + "' is still unsatisfied after its entire intention " +
|
||||
"completed (" + currentIntention.size() + " agents). Check that the intention's agents " +
|
||||
"write the state keys required by the desire's satisfied predicate.");
|
||||
}
|
||||
LOG.info("Committing to desire '{}' (priority {}) at step {}/{}",
|
||||
desire.name(), desire.priority(), intentionCursor + 1, currentIntention.size());
|
||||
return dispatch(currentIntention.get(intentionCursor));
|
||||
})
|
||||
.orElseGet(() -> {
|
||||
LOG.info("All desires satisfied or none achievable after {} invocations", invocationCounter);
|
||||
return done();
|
||||
});
|
||||
}
|
||||
|
||||
@Override
|
||||
public Map<String, Object> executionState() {
|
||||
return Map.of("invocationCounter", invocationCounter);
|
||||
}
|
||||
|
||||
/**
|
||||
* Only {@code invocationCounter} is persisted. On crash recovery the framework calls
|
||||
* {@link #firstAction(PlanningContext)}, which re-deliberates from scratch: satisfied desires
|
||||
* are skipped, but the selected desire's intention restarts from its first agent — agents that
|
||||
* already completed will run again. Intention agents must therefore be idempotent.
|
||||
*/
|
||||
@Override
|
||||
public void restoreExecutionState(Map<String, Object> state) {
|
||||
Object savedCounter = state.get("invocationCounter");
|
||||
if (savedCounter instanceof Number n) {
|
||||
this.invocationCounter = n.intValue();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,46 @@
|
|||
package dev.langchain4j.agentic.patterns.bdi;
|
||||
|
||||
import dev.langchain4j.agentic.scope.AgenticScope;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.function.Predicate;
|
||||
|
||||
/**
|
||||
* A prioritized goal for the {@link BDIPlanner}. Each desire declares when it is achievable, when
|
||||
* it is satisfied, and the ordered sequence of agent types that form its intention. Higher priority
|
||||
* values take precedence; among equal priorities, declaration order wins (stable ordering).
|
||||
*
|
||||
* @param name human-readable label, used in log messages and error diagnostics
|
||||
* @param priority higher value = more important; strictly higher priority triggers preemption
|
||||
* @param achievable predicate on {@link dev.langchain4j.agentic.scope.AgenticScope} — can this desire be pursued now?
|
||||
* @param satisfied predicate on {@link dev.langchain4j.agentic.scope.AgenticScope} — has this desire been achieved?
|
||||
* @param agentTypes ordered agent classes forming the intention; resolved to instances at init time
|
||||
*/
|
||||
public record Desire(String name, int priority,
|
||||
Predicate<AgenticScope> achievable,
|
||||
Predicate<AgenticScope> satisfied,
|
||||
List<Class<?>> agentTypes) {
|
||||
|
||||
public Desire {
|
||||
if (agentTypes == null || agentTypes.isEmpty()) {
|
||||
throw new IllegalArgumentException("Desire '" + name + "' must have at least one agent type");
|
||||
}
|
||||
}
|
||||
|
||||
public static Desire of(String name, int priority,
|
||||
Predicate<AgenticScope> achievable,
|
||||
Predicate<AgenticScope> satisfied,
|
||||
Class<?>... agentTypes) {
|
||||
return new Desire(name, priority, achievable, satisfied, List.of(agentTypes));
|
||||
}
|
||||
|
||||
public static Desire of(String name, int priority,
|
||||
String achievableStateKey,
|
||||
String satisfiedStateKey,
|
||||
Class<?>... agentTypes) {
|
||||
return new Desire(name, priority,
|
||||
scope -> scope.hasState(achievableStateKey),
|
||||
scope -> scope.hasState(satisfiedStateKey),
|
||||
List.of(agentTypes));
|
||||
}
|
||||
}
|
||||
|
|
@ -26,12 +26,13 @@ import static java.util.stream.Collectors.toMap;
|
|||
* can contribute next. When multiple agents are ready, a {@link ConflictResolutionStrategy} determines
|
||||
* which one fires; if no strategy is provided, declaration order is used.
|
||||
* <p>
|
||||
* The planner terminates when:
|
||||
* The planner terminates successfully when:
|
||||
* <ul>
|
||||
* <li>The goal predicate is satisfied (by default, when the planner's outputKey is present in scope)</li>
|
||||
* <li>No agent can fire (quiescence)</li>
|
||||
* <li>The maximum number of invocations is reached</li>
|
||||
* </ul>
|
||||
* If the maximum number of invocations is reached before the goal is satisfied, the planner throws an
|
||||
* {@link IllegalStateException}.
|
||||
*/
|
||||
public class BlackboardPlanner implements Planner {
|
||||
|
||||
|
|
@ -106,8 +107,9 @@ public class BlackboardPlanner implements Planner {
|
|||
}
|
||||
|
||||
if (invocationCounter >= maxInvocations) {
|
||||
LOG.warn("Maximum invocations ({}) reached without satisfying goal", maxInvocations);
|
||||
return done();
|
||||
throw new IllegalStateException(
|
||||
"Maximum invocations (" + maxInvocations + ") reached without satisfying goal. " +
|
||||
"Increase maxInvocations or check goal predicate.");
|
||||
}
|
||||
|
||||
return selectAndCall(scope);
|
||||
|
|
|
|||
|
|
@ -0,0 +1,257 @@
|
|||
package dev.langchain4j.agentic.patterns.bdi;
|
||||
|
||||
import dev.langchain4j.agentic.Agent;
|
||||
import dev.langchain4j.agentic.AgenticServices;
|
||||
import dev.langchain4j.agentic.UntypedAgent;
|
||||
import dev.langchain4j.agentic.scope.ResultWithAgenticScope;
|
||||
import dev.langchain4j.service.V;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.concurrent.atomic.AtomicInteger;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||
|
||||
class BDIPlannerTest {
|
||||
|
||||
// ── Non-AI agents that write deterministic values to scope ──
|
||||
|
||||
public static class ProducerA {
|
||||
@Agent(outputKey = "outputA")
|
||||
public String producerA() {
|
||||
return "resultA";
|
||||
}
|
||||
}
|
||||
|
||||
public static class ProducerB {
|
||||
@Agent(outputKey = "outputB")
|
||||
public String producerB(@V("outputA") String input) {
|
||||
return "resultB from " + input;
|
||||
}
|
||||
}
|
||||
|
||||
public static class ProducerC {
|
||||
@Agent(outputKey = "outputC")
|
||||
public String producerC(@V("trigger") String trigger) {
|
||||
return "resultC from " + trigger;
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldSelectHighestPriorityAchievableDesire() {
|
||||
AtomicInteger orderCounter = new AtomicInteger();
|
||||
AtomicInteger lowOrder = new AtomicInteger();
|
||||
AtomicInteger highOrder = new AtomicInteger();
|
||||
|
||||
var trackingA = new Object() {
|
||||
@Agent(outputKey = "outputA")
|
||||
public String producer() {
|
||||
lowOrder.set(orderCounter.incrementAndGet());
|
||||
return "resultA";
|
||||
}
|
||||
};
|
||||
var trackingC = new Object() {
|
||||
@Agent(outputKey = "outputC")
|
||||
public String producer(@V("trigger") String t) {
|
||||
highOrder.set(orderCounter.incrementAndGet());
|
||||
return "resultC";
|
||||
}
|
||||
};
|
||||
|
||||
// Both desires achievable and unsatisfied from the start; only priority differs
|
||||
var lowPriority = Desire.of("low", 1,
|
||||
scope -> true, scope -> scope.hasState("outputA"), trackingA.getClass());
|
||||
var highPriority = Desire.of("high", 3,
|
||||
scope -> true, scope -> scope.hasState("outputC"), trackingC.getClass());
|
||||
|
||||
UntypedAgent system = AgenticServices.plannerBuilder()
|
||||
.subAgents(trackingA, trackingC)
|
||||
.planner(() -> new BDIPlanner(List.of(lowPriority, highPriority)))
|
||||
.build();
|
||||
|
||||
system.invokeWithAgenticScope(Map.of("trigger", "t"));
|
||||
assertThat(highOrder.get()).as("high-priority desire should run before low-priority").isLessThan(lowOrder.get());
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldProgressThroughIntentionSequence() {
|
||||
var desire = Desire.of("goal", 1,
|
||||
scope -> true, scope -> scope.hasState("outputB"), ProducerA.class, ProducerB.class);
|
||||
|
||||
UntypedAgent system = AgenticServices.plannerBuilder()
|
||||
.subAgents(new ProducerA(), new ProducerB())
|
||||
.planner(() -> new BDIPlanner(List.of(desire)))
|
||||
.build();
|
||||
|
||||
ResultWithAgenticScope<String> result = system.invokeWithAgenticScope(Map.of());
|
||||
assertThat(result.agenticScope().readState("outputA", "")).isEqualTo("resultA");
|
||||
assertThat(result.agenticScope().readState("outputB", "")).isEqualTo("resultB from resultA");
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldPreemptLowerPriorityDesire() {
|
||||
// "high" becomes achievable only after A writes outputA, so "low" is selected first
|
||||
var lowPriority = Desire.of("low", 1,
|
||||
scope -> true, scope -> scope.hasState("outputB"), ProducerA.class, ProducerB.class);
|
||||
var highPriority = Desire.of("high", 3,
|
||||
scope -> scope.hasState("outputA"), scope -> scope.hasState("outputC"), ProducerC.class);
|
||||
|
||||
UntypedAgent system = AgenticServices.plannerBuilder()
|
||||
.subAgents(new ProducerA(), new ProducerB(), new ProducerC())
|
||||
.planner(() -> new BDIPlanner(List.of(lowPriority, highPriority)))
|
||||
.build();
|
||||
|
||||
// trigger must be present for ProducerC's @V("trigger") parameter
|
||||
// sequence: A (low) -> preemption -> C (high) -> resume B (low)
|
||||
ResultWithAgenticScope<String> result = system.invokeWithAgenticScope(Map.of("trigger", "alert"));
|
||||
assertThat(result.agenticScope().hasState("outputA")).isTrue();
|
||||
assertThat(result.agenticScope().hasState("outputB")).isTrue();
|
||||
assertThat(result.agenticScope().hasState("outputC")).isTrue();
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldResumePreemptedDesireWithoutRerunningCompletedAgents() {
|
||||
AtomicInteger producerACount = new AtomicInteger();
|
||||
|
||||
var counting = new Object() {
|
||||
@Agent(outputKey = "outputA")
|
||||
public String producerA() {
|
||||
producerACount.incrementAndGet();
|
||||
return "resultA";
|
||||
}
|
||||
};
|
||||
|
||||
// low = [countingA, B], high = [C] achievable only after outputA is written
|
||||
var lowPriority = Desire.of("low", 1,
|
||||
scope -> true, scope -> scope.hasState("outputB"), counting.getClass(), ProducerB.class);
|
||||
var highPriority = Desire.of("high", 3,
|
||||
scope -> scope.hasState("outputA"), scope -> scope.hasState("outputC"), ProducerC.class);
|
||||
|
||||
UntypedAgent system = AgenticServices.plannerBuilder()
|
||||
.subAgents(counting, new ProducerB(), new ProducerC())
|
||||
.planner(() -> new BDIPlanner(List.of(lowPriority, highPriority)))
|
||||
.build();
|
||||
|
||||
ResultWithAgenticScope<String> result = system.invokeWithAgenticScope(Map.of("trigger", "alert"));
|
||||
|
||||
assertThat(result.agenticScope().hasState("outputA")).isTrue();
|
||||
assertThat(result.agenticScope().hasState("outputB")).isTrue();
|
||||
assertThat(result.agenticScope().hasState("outputC")).isTrue();
|
||||
// A should run exactly once: dispatched by "low", then "low" preempted by "high",
|
||||
// then "low" resumed at B — not restarted at A
|
||||
assertThat(producerACount.get()).isEqualTo(1);
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldThrowWhenMaxInvocationsExceeded() {
|
||||
AtomicInteger totalCalls = new AtomicInteger();
|
||||
|
||||
var countingA = new Object() {
|
||||
@Agent(outputKey = "outputA")
|
||||
public String producerA() {
|
||||
totalCalls.incrementAndGet();
|
||||
return "resultA";
|
||||
}
|
||||
};
|
||||
var countingB = new Object() {
|
||||
@Agent(outputKey = "outputB")
|
||||
public String producerB(@V("outputA") String input) {
|
||||
totalCalls.incrementAndGet();
|
||||
return "resultB";
|
||||
}
|
||||
};
|
||||
var countingC = new Object() {
|
||||
@Agent(outputKey = "outputC")
|
||||
public String producerC(@V("outputB") String input) {
|
||||
totalCalls.incrementAndGet();
|
||||
return "resultC";
|
||||
}
|
||||
};
|
||||
|
||||
var desire = Desire.of("goal", 1,
|
||||
scope -> true, scope -> scope.hasState("outputC"),
|
||||
countingA.getClass(), countingB.getClass(), countingC.getClass());
|
||||
|
||||
int max = 2;
|
||||
UntypedAgent system = AgenticServices.plannerBuilder()
|
||||
.subAgents(countingA, countingB, countingC)
|
||||
.planner(() -> new BDIPlanner(List.of(desire), max))
|
||||
.build();
|
||||
|
||||
assertThatThrownBy(() -> system.invokeWithAgenticScope(Map.of()))
|
||||
.isInstanceOf(IllegalStateException.class)
|
||||
.hasMessageContaining("Maximum invocations (2) reached");
|
||||
assertThat(totalCalls.get()).isEqualTo(max);
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldThrowWhenIntentionExhaustedButDesireUnsatisfied() {
|
||||
// Desire expects "missing" in scope, but ProducerA writes "outputA" — intention
|
||||
// completes without satisfying the desire
|
||||
var desire = Desire.of("broken", 1,
|
||||
scope -> true, scope -> scope.hasState("missing"), ProducerA.class);
|
||||
|
||||
UntypedAgent system = AgenticServices.plannerBuilder()
|
||||
.subAgents(new ProducerA())
|
||||
.planner(() -> new BDIPlanner(List.of(desire)))
|
||||
.build();
|
||||
|
||||
assertThatThrownBy(() -> system.invokeWithAgenticScope(Map.of()))
|
||||
.isInstanceOf(IllegalStateException.class)
|
||||
.hasMessageContaining("Desire 'broken' is still unsatisfied");
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldThrowWhenPreemptedDesireResumesPastEnd() {
|
||||
// low = [A, B] never satisfies; high = [C] becomes achievable after B writes outputB.
|
||||
// After B completes, preemption saves cursor = 2 (past end of [A,B]).
|
||||
// When low is re-selected, the saved cursor hits the exhaustion check.
|
||||
var low = Desire.of("low", 1,
|
||||
scope -> true, scope -> scope.hasState("never"), ProducerA.class, ProducerB.class);
|
||||
var high = Desire.of("high", 3,
|
||||
"outputB", "outputC", ProducerC.class);
|
||||
|
||||
UntypedAgent system = AgenticServices.plannerBuilder()
|
||||
.subAgents(new ProducerA(), new ProducerB(), new ProducerC())
|
||||
.planner(() -> new BDIPlanner(List.of(low, high)))
|
||||
.build();
|
||||
|
||||
assertThatThrownBy(() -> system.invokeWithAgenticScope(Map.of("trigger", "t")))
|
||||
.isInstanceOf(IllegalStateException.class)
|
||||
.hasMessageContaining("Desire 'low' is still unsatisfied");
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldTerminateWhenAllDesiresSatisfied() {
|
||||
var desire = Desire.of("goal", 1,
|
||||
scope -> true, scope -> scope.hasState("outputA"), ProducerA.class);
|
||||
|
||||
UntypedAgent system = AgenticServices.plannerBuilder()
|
||||
.subAgents(new ProducerA())
|
||||
.planner(() -> new BDIPlanner(List.of(desire)))
|
||||
.build();
|
||||
|
||||
// outputA already present -> desire already satisfied -> done immediately
|
||||
ResultWithAgenticScope<String> result = system.invokeWithAgenticScope(Map.of("outputA", "already done"));
|
||||
assertThat(result.agenticScope().readState("outputA", "")).isEqualTo("already done");
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldReDeliberateAfterDesireSatisfied() {
|
||||
var desire1 = Desire.of("first", 2,
|
||||
scope -> true, scope -> scope.hasState("outputA"), ProducerA.class);
|
||||
var desire2 = Desire.of("second", 1,
|
||||
scope -> scope.hasState("outputA"), scope -> scope.hasState("outputB"), ProducerB.class);
|
||||
|
||||
UntypedAgent system = AgenticServices.plannerBuilder()
|
||||
.subAgents(new ProducerA(), new ProducerB())
|
||||
.planner(() -> new BDIPlanner(List.of(desire1, desire2)))
|
||||
.build();
|
||||
|
||||
ResultWithAgenticScope<String> result = system.invokeWithAgenticScope(Map.of());
|
||||
assertThat(result.agenticScope().hasState("outputA")).isTrue();
|
||||
assertThat(result.agenticScope().hasState("outputB")).isTrue();
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,91 @@
|
|||
package dev.langchain4j.agentic.patterns.bdi.trading;
|
||||
|
||||
import static dev.langchain4j.agentic.patterns.Models.baseModel;
|
||||
import static dev.langchain4j.agentic.patterns.bdi.trading.TradingAgents.MarketRecommendation;
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
import dev.langchain4j.agentic.AgenticServices;
|
||||
import dev.langchain4j.agentic.observability.HtmlReportGenerator;
|
||||
import dev.langchain4j.agentic.patterns.bdi.BDIPlanner;
|
||||
import dev.langchain4j.agentic.patterns.bdi.Desire;
|
||||
import dev.langchain4j.agentic.patterns.bdi.trading.TradingAgents.HedgingAgent;
|
||||
import dev.langchain4j.agentic.patterns.bdi.trading.TradingAgents.LiquidityAgent;
|
||||
import dev.langchain4j.agentic.patterns.bdi.trading.TradingAgents.MarketAnalysisAgent;
|
||||
import dev.langchain4j.agentic.patterns.bdi.trading.TradingAgents.MarketRecommendationAgent;
|
||||
import dev.langchain4j.agentic.patterns.bdi.trading.TradingAgents.HedgingStrategyDefaulter;
|
||||
import dev.langchain4j.agentic.patterns.bdi.trading.TradingAgents.RebalancingAgent;
|
||||
import dev.langchain4j.agentic.patterns.bdi.trading.TradingAgents.TradingSystem;
|
||||
import dev.langchain4j.agentic.scope.ResultWithAgenticScope;
|
||||
import java.nio.file.Path;
|
||||
import java.util.List;
|
||||
import java.util.Set;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.condition.EnabledIfEnvironmentVariable;
|
||||
|
||||
@EnabledIfEnvironmentVariable(named = "OPENAI_API_KEY", matches = ".+")
|
||||
public class BDIPlannerTradingIT {
|
||||
|
||||
@Test
|
||||
void bdi_trading_system() {
|
||||
MarketAnalysisAgent marketAnalysis = AgenticServices.agentBuilder(MarketAnalysisAgent.class)
|
||||
.chatModel(baseModel())
|
||||
.build();
|
||||
|
||||
MarketRecommendationAgent recommendation = AgenticServices.agentBuilder(MarketRecommendationAgent.class)
|
||||
.chatModel(baseModel())
|
||||
.build();
|
||||
|
||||
RebalancingAgent rebalancing = AgenticServices.agentBuilder(RebalancingAgent.class)
|
||||
.chatModel(baseModel())
|
||||
.build();
|
||||
|
||||
HedgingAgent hedging = AgenticServices.agentBuilder(HedgingAgent.class)
|
||||
.chatModel(baseModel())
|
||||
.build();
|
||||
|
||||
LiquidityAgent liquidity = AgenticServices.agentBuilder(LiquidityAgent.class)
|
||||
.chatModel(baseModel())
|
||||
.build();
|
||||
|
||||
TradingSystem tradingSystem = AgenticServices.plannerBuilder(TradingSystem.class)
|
||||
.subAgents(marketAnalysis, recommendation, new HedgingStrategyDefaulter(), rebalancing, hedging, liquidity)
|
||||
.planner(() -> new BDIPlanner(List.of(
|
||||
Desire.of("analyze market", 1,
|
||||
"marketData", "recommendation",
|
||||
MarketAnalysisAgent.class, MarketRecommendationAgent.class),
|
||||
Desire.of("hedge risks", 2,
|
||||
scope -> scope.hasState("recommendation")
|
||||
&& Set.of(MarketRecommendation.SELL, MarketRecommendation.STRONG_SELL)
|
||||
.contains(scope.readState("recommendation")),
|
||||
scope -> scope.hasState("hedgingStrategy"),
|
||||
HedgingAgent.class),
|
||||
Desire.of("rebalance portfolio", 1,
|
||||
"recommendation", "rebalancingPlan",
|
||||
HedgingStrategyDefaulter.class, RebalancingAgent.class),
|
||||
Desire.of("maintain liquidity", 1,
|
||||
"portfolio", "liquidityAssessment",
|
||||
LiquidityAgent.class)
|
||||
)))
|
||||
.build();
|
||||
|
||||
ResultWithAgenticScope<String> result = tradingSystem.trade(
|
||||
"Markets crashing, major indices down 8%, VIX at 55, multiple sell-offs triggered across sectors.",
|
||||
"60% equities, 30% bonds, 10% cash. Total value: $1M.");
|
||||
|
||||
assertThat(result.result()).isNotBlank();
|
||||
assertThat(result.agenticScope().hasState("marketAnalysis")).isTrue();
|
||||
assertThat(result.agenticScope().hasState("recommendation")).isTrue();
|
||||
assertThat(result.agenticScope().hasState("rebalancingPlan")).isTrue();
|
||||
assertThat(result.agenticScope().hasState("liquidityAssessment")).isTrue();
|
||||
|
||||
MarketRecommendation rec = (MarketRecommendation) result.agenticScope().readState("recommendation");
|
||||
if (rec == MarketRecommendation.SELL || rec == MarketRecommendation.STRONG_SELL) {
|
||||
assertThat(result.agenticScope().hasState("hedgingStrategy")).isTrue();
|
||||
}
|
||||
|
||||
System.out.println(result.result());
|
||||
|
||||
// HtmlReportGenerator.generateReport(tradingSystem.agentMonitor(),
|
||||
// Path.of("src", "test", "resources", "bdi-trading-report.html"));
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,109 @@
|
|||
package dev.langchain4j.agentic.patterns.bdi.trading;
|
||||
|
||||
import dev.langchain4j.agentic.Agent;
|
||||
import dev.langchain4j.agentic.declarative.Output;
|
||||
import dev.langchain4j.agentic.observability.MonitoredAgent;
|
||||
import dev.langchain4j.agentic.scope.AgenticScope;
|
||||
import dev.langchain4j.agentic.scope.ResultWithAgenticScope;
|
||||
import dev.langchain4j.service.UserMessage;
|
||||
import dev.langchain4j.service.V;
|
||||
|
||||
public class TradingAgents {
|
||||
|
||||
public enum MarketRecommendation {
|
||||
STRONG_BUY, BUY, HOLD, SELL, STRONG_SELL
|
||||
}
|
||||
|
||||
public interface MarketAnalysisAgent {
|
||||
|
||||
@UserMessage("""
|
||||
You are a market analyst. Analyze the following market data and portfolio state.
|
||||
Provide a concise assessment of the current market conditions, risks, and opportunities.
|
||||
Market data: {{marketData}}
|
||||
Portfolio: {{portfolio}}
|
||||
""")
|
||||
@Agent(value = "Analyze market conditions and identify risks/opportunities", outputKey = "marketAnalysis")
|
||||
String analyzeMarket(@V("marketData") String marketData, @V("portfolio") String portfolio);
|
||||
}
|
||||
|
||||
public interface MarketRecommendationAgent {
|
||||
|
||||
@UserMessage("""
|
||||
You are a financial advisor. Based on the following market analysis,
|
||||
provide a single trading recommendation.
|
||||
Market analysis: {{marketAnalysis}}
|
||||
""")
|
||||
@Agent(value = "Provide a trading recommendation based on market analysis", outputKey = "recommendation")
|
||||
MarketRecommendation recommend(@V("marketAnalysis") String marketAnalysis);
|
||||
}
|
||||
|
||||
public static class HedgingStrategyDefaulter {
|
||||
@Agent(outputKey = "hedgingStrategy")
|
||||
public String defaultHedging(AgenticScope scope) {
|
||||
return scope.hasState("hedgingStrategy") ? (String) scope.readState("hedgingStrategy") : "None";
|
||||
}
|
||||
}
|
||||
|
||||
public interface RebalancingAgent {
|
||||
|
||||
@UserMessage("""
|
||||
You are a portfolio rebalancing specialist.
|
||||
Based on the market analysis, suggest portfolio adjustments to maximize returns.
|
||||
Keep suggestions concise and actionable.
|
||||
Market analysis: {{marketAnalysis}}
|
||||
Hedging strategy in place: {{hedgingStrategy}}
|
||||
Portfolio: {{portfolio}}
|
||||
""")
|
||||
@Agent(value = "Suggest portfolio rebalancing to maximize returns", outputKey = "rebalancingPlan")
|
||||
String rebalance(@V("marketAnalysis") String marketAnalysis,
|
||||
@V("hedgingStrategy") String hedgingStrategy,
|
||||
@V("portfolio") String portfolio);
|
||||
}
|
||||
|
||||
public interface HedgingAgent {
|
||||
|
||||
@UserMessage("""
|
||||
You are a risk management specialist.
|
||||
Based on the market analysis, recommend hedging strategies to minimize risk exposure.
|
||||
Focus on protecting against identified threats.
|
||||
Market analysis: {{marketAnalysis}}
|
||||
""")
|
||||
@Agent(value = "Recommend hedging strategies to minimize risk", outputKey = "hedgingStrategy")
|
||||
String hedge(@V("marketAnalysis") String marketAnalysis);
|
||||
}
|
||||
|
||||
public interface LiquidityAgent {
|
||||
|
||||
@UserMessage("""
|
||||
You are a liquidity management specialist.
|
||||
Based on the portfolio state, assess current liquidity and recommend actions
|
||||
to maintain adequate cash reserves.
|
||||
Portfolio: {{portfolio}}
|
||||
""")
|
||||
@Agent(value = "Assess and maintain portfolio liquidity", outputKey = "liquidityAssessment")
|
||||
String assessLiquidity(@V("portfolio") String portfolio);
|
||||
}
|
||||
|
||||
public interface TradingSystem extends MonitoredAgent {
|
||||
|
||||
@Agent
|
||||
ResultWithAgenticScope<String> trade(@V("marketData") String marketData, @V("portfolio") String portfolio);
|
||||
|
||||
@Output
|
||||
static String trade(@V("marketAnalysis") String marketAnalysis,
|
||||
@V("recommendation") MarketRecommendation recommendation,
|
||||
@V("rebalancingPlan") String rebalancingPlan,
|
||||
@V("liquidityAssessment") String liquidityAssessment,
|
||||
AgenticScope scope) {
|
||||
String hedging = scope.hasState("hedgingStrategy")
|
||||
? (String) scope.readState("hedgingStrategy")
|
||||
: "Not needed (recommendation: " + recommendation + ")";
|
||||
return "Trading system output:\n----\n" +
|
||||
"Market Analysis: " + marketAnalysis + "\n----\n" +
|
||||
"Recommendation: " + recommendation + "\n----\n" +
|
||||
"Hedging Strategy: " + hedging + "\n----\n" +
|
||||
"Rebalancing Plan: " + rebalancingPlan + "\n----\n" +
|
||||
"Liquidity Assessment: " + liquidityAssessment;
|
||||
}
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue