Skip to content

Commit ab13d37

Browse files
authored
feat(plan): resolve planner agent per-request via AgentRegistry (#82)
Remove hardcoded _planning_agent (gemini-2.5-pro) from HensuFactory. LlmPlanner now takes AgentRegistry instead of a singleton Agent, resolving the planner per-request from PlanningConfig.agentId with fallback to the node's own agent. Adds agentId field to PlanningConfig, PlanRequest, and RevisionContext. Exposes agent property and dynamic(plannerAgent) in the Kotlin DSL. Resolve: #79 Signed-off-by: Aleksandr Suvorov <asuvorov@hensu.io>
1 parent c1d758d commit ab13d37

13 files changed

Lines changed: 266 additions & 95 deletions

File tree

hensu-core/src/main/java/io/hensu/core/HensuFactory.java

Lines changed: 7 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,5 @@
11
package io.hensu.core;
22

3-
import io.hensu.core.agent.AgentConfig;
43
import io.hensu.core.agent.AgentFactory;
54
import io.hensu.core.agent.AgentProvider;
65
import io.hensu.core.agent.AgentRegistry;
@@ -626,11 +625,10 @@ public Builder planner(Planner planner) {
626625
/// Configures the plan response parser and enables auto-wiring of {@link LlmPlanner}.
627626
///
628627
/// When set and no explicit {@link #planner(Planner)} is configured, {@link #build()}
629-
/// auto-constructs an {@link LlmPlanner} using the built {@link AgentRegistry}.
630-
/// A default {@code _planning_agent} is registered if absent.
628+
/// auto-constructs an {@link LlmPlanner} backed by the {@link AgentRegistry}. The
629+
/// planner resolves its agent per-request using the {@code agentId} from
630+
/// {@link io.hensu.core.plan.PlanningConfig}.
631631
///
632-
/// When only {@code planResponseParser} is set (no explicit planner), the default
633-
/// {@link LlmPlanner} is constructed automatically with a {@code _planning_agent}.
634632
/// Use {@link #planner(Planner)} to supply a custom implementation instead.
635633
///
636634
/// @param parser the parser that converts LLM text responses to step lists, not null
@@ -722,27 +720,11 @@ public HensuEnvironment build() {
722720
workflowStateRepository = new InMemoryWorkflowStateRepository();
723721
}
724722

725-
// Auto-construct LlmPlanner when a parser is provided but no explicit planner
723+
// Auto-construct LlmPlanner when a parser is provided but no explicit planner.
724+
// The planner resolves its agent per-request from the AgentRegistry using the
725+
// agentId carried in PlanRequest / RevisionContext (see PlanningConfig.agentId).
726726
if (planResponseParser != null && planner == null) {
727-
var planningAgentConfig =
728-
AgentConfig.builder()
729-
.id("_planning_agent")
730-
.role("planner")
731-
.model("gemini-2.5-pro")
732-
.temperature(0.3)
733-
.build();
734-
if (!agentRegistry.hasAgent("_planning_agent", planningAgentConfig)) {
735-
agentRegistry.registerAgent("_planning_agent", planningAgentConfig);
736-
}
737-
planner =
738-
new LlmPlanner(
739-
agentRegistry
740-
.getAgent("_planning_agent")
741-
.orElseThrow(
742-
() ->
743-
new IllegalStateException(
744-
"_planning_agent not found after registration")),
745-
planResponseParser);
727+
planner = new LlmPlanner(agentRegistry, planResponseParser);
746728
}
747729

748730
// Wire planning if a planner was configured

hensu-core/src/main/java/io/hensu/core/execution/executor/PlanCreationProcessor.java

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -107,13 +107,21 @@ private Plan createPlan(PlanContext context) throws PlanCreationException {
107107
}
108108

109109
case DYNAMIC -> {
110+
String agentId = config.resolveAgentId(node.getAgentId());
111+
if (agentId == null || agentId.isBlank()) {
112+
throw new PlanCreationException(
113+
"No planner agent configured for dynamic planning on node: "
114+
+ node.getId());
115+
}
116+
110117
List<ToolDefinition> tools = toolRegistry.all();
111118
String prompt = resolvePrompt(node.getPrompt(), executionContext);
112119

113120
executionContext.getListener().onPlannerStart(node.getId(), prompt);
114121
Plan created =
115122
planner.createPlan(
116123
new PlanRequest(
124+
agentId,
117125
prompt,
118126
tools,
119127
executionContext.getState().getContext(),

hensu-core/src/main/java/io/hensu/core/execution/executor/PlanExecutionProcessor.java

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212
import io.hensu.core.plan.PlannedStep;
1313
import io.hensu.core.plan.Planner;
1414
import io.hensu.core.plan.Planner.RevisionContext;
15+
import io.hensu.core.plan.PlanningConfig;
1516
import io.hensu.core.plan.StepResult;
1617
import io.hensu.core.tool.ToolDefinition;
1718
import io.hensu.core.tool.ToolRegistry;
@@ -120,12 +121,14 @@ public Optional<NodeResult> process(PlanContext context) {
120121
Duration.ZERO);
121122
String prompt = resolvePrompt(node.getPrompt(), executionContext);
122123
List<ToolDefinition> tools = toolRegistry.all();
124+
PlanningConfig config = node.getPlanningConfig();
125+
String agentId = config.resolveAgentId(node.getAgentId());
123126

124127
executionContext.getListener().onPlannerStart(node.getId(), prompt);
125128
Plan revisedPlan =
126129
planner.revisePlan(
127130
currentPlan,
128-
RevisionContext.fromFailure(failedStep, prompt, tools));
131+
RevisionContext.fromFailure(failedStep, prompt, tools, agentId));
129132
executionContext.getListener().onPlannerComplete(node.getId(), revisedPlan.steps());
130133

131134
revisedPlan = enrichSynthesizeSteps(revisedPlan, node.getAgentId());

hensu-core/src/main/java/io/hensu/core/plan/LlmPlanner.java

Lines changed: 29 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
package io.hensu.core.plan;
22

33
import io.hensu.core.agent.Agent;
4+
import io.hensu.core.agent.AgentNotFoundException;
5+
import io.hensu.core.agent.AgentRegistry;
46
import io.hensu.core.agent.AgentResponse;
57
import io.hensu.core.tool.ToolDefinition;
68
import io.hensu.core.tool.ToolDefinition.ParameterDef;
@@ -16,7 +18,7 @@
1618
/// Uses a planning agent to create step-by-step plans based on the goal,
1719
/// available tools, and workflow context. Supports both tool-call steps and
1820
/// synthesize steps, enabling the agent to express "call this tool, then
19-
/// summarise the results" within a single plan.
21+
/// summarize the results" within a single plan.
2022
///
2123
/// ### Plan Generation
2224
/// The planner prompts the LLM with structured instructions and parses the
@@ -114,24 +116,34 @@ public class LlmPlanner implements Planner {
114116
```
115117
""";
116118

117-
private final Agent planningAgent;
119+
private final AgentRegistry agentRegistry;
118120
private final PlanResponseParser responseParser;
119121

120-
/// Creates an LLM planner.
122+
/// Creates an LLM planner that resolves the planning agent per-request.
121123
///
122-
/// @param planningAgent the agent to use for plan generation, not null
124+
/// @param agentRegistry registry used to look up the planning agent by ID, not null
123125
/// @param responseParser parser that converts the agent's text to steps, not null
124-
public LlmPlanner(Agent planningAgent, PlanResponseParser responseParser) {
125-
this.planningAgent =
126-
Objects.requireNonNull(planningAgent, "planningAgent must not be null");
126+
public LlmPlanner(AgentRegistry agentRegistry, PlanResponseParser responseParser) {
127+
this.agentRegistry =
128+
Objects.requireNonNull(agentRegistry, "agentRegistry must not be null");
127129
this.responseParser =
128130
Objects.requireNonNull(responseParser, "responseParser must not be null");
129131
}
130132

131133
@Override
132134
public Plan createPlan(PlanRequest request) throws PlanCreationException {
133135
Objects.requireNonNull(request, "request must not be null");
136+
if (request.agentId() == null || request.agentId().isBlank()) {
137+
throw new PlanCreationException("PlanRequest.agentId must not be null or blank");
138+
}
134139

140+
Agent planningAgent;
141+
try {
142+
planningAgent = agentRegistry.getAgentOrThrow(request.agentId());
143+
} catch (AgentNotFoundException e) {
144+
throw new PlanCreationException(
145+
"Planning agent '" + request.agentId() + "' not found", e);
146+
}
135147
String prompt = buildPlanningPrompt(request);
136148
AgentResponse response = planningAgent.execute(prompt, request.context());
137149

@@ -165,7 +177,17 @@ public Plan createPlan(PlanRequest request) throws PlanCreationException {
165177
public Plan revisePlan(Plan currentPlan, RevisionContext context) throws PlanRevisionException {
166178
Objects.requireNonNull(currentPlan, "currentPlan must not be null");
167179
Objects.requireNonNull(context, "context must not be null");
180+
if (context.agentId() == null || context.agentId().isBlank()) {
181+
throw new PlanRevisionException("RevisionContext.agentId must not be null or blank");
182+
}
168183

184+
Agent planningAgent;
185+
try {
186+
planningAgent = agentRegistry.getAgentOrThrow(context.agentId());
187+
} catch (AgentNotFoundException e) {
188+
throw new PlanRevisionException(
189+
"Planning agent '" + context.agentId() + "' not found for revision", e);
190+
}
169191
String prompt = buildRevisionPrompt(currentPlan, context);
170192
AgentResponse response = planningAgent.execute(prompt, Map.of());
171193

hensu-core/src/main/java/io/hensu/core/plan/Planner.java

Lines changed: 15 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -53,11 +53,14 @@ public interface Planner {
5353

5454
/// Request for plan creation.
5555
///
56+
/// @param agentId identifier of the agent to use for plan generation; nullable
57+
/// for non-LLM planners (e.g. {@link StaticPlanner})
5658
/// @param prompt the resolved node prompt driving this plan, not null
5759
/// @param availableTools tools that can be used in the plan, not null
5860
/// @param context workflow variables for template resolution, not null
5961
/// @param constraints limits on plan generation, not null
6062
record PlanRequest(
63+
String agentId,
6164
String prompt,
6265
List<ToolDefinition> availableTools,
6366
Map<String, Object> context,
@@ -75,7 +78,7 @@ record PlanRequest(
7578
/// @param prompt the resolved node prompt
7679
/// @return new request, never null
7780
public static PlanRequest simple(String prompt) {
78-
return new PlanRequest(prompt, List.of(), Map.of(), PlanConstraints.defaults());
81+
return new PlanRequest(null, prompt, List.of(), Map.of(), PlanConstraints.defaults());
7982
}
8083
}
8184

@@ -89,12 +92,15 @@ public static PlanRequest simple(String prompt) {
8992
/// @param revisionReason explanation for why revision is needed, not null
9093
/// @param prompt the resolved node prompt that drove the original plan, not null
9194
/// @param availableTools tools available for the revised plan, not null
95+
/// @param agentId identifier of the agent to use for replanning; nullable
96+
/// for non-LLM planners
9297
record RevisionContext(
9398
int failedAtStep,
9499
StepResult failureResult,
95100
String revisionReason,
96101
String prompt,
97-
List<ToolDefinition> availableTools) {
102+
List<ToolDefinition> availableTools,
103+
String agentId) {
98104

99105
/// Compact constructor with defaults.
100106
public RevisionContext {
@@ -107,15 +113,20 @@ record RevisionContext(
107113
/// @param stepResult the failed step result, not null
108114
/// @param prompt the resolved node prompt that drove the original plan, not null
109115
/// @param availableTools tools available for replanning, not null
116+
/// @param agentId identifier of the agent to use for replanning
110117
/// @return revision context, never null
111118
public static RevisionContext fromFailure(
112-
StepResult stepResult, String prompt, List<ToolDefinition> availableTools) {
119+
StepResult stepResult,
120+
String prompt,
121+
List<ToolDefinition> availableTools,
122+
String agentId) {
113123
return new RevisionContext(
114124
stepResult.stepIndex(),
115125
stepResult,
116126
"Step " + stepResult.stepIndex() + " failed: " + stepResult.error(),
117127
prompt,
118-
availableTools);
128+
availableTools,
129+
agentId);
119130
}
120131
}
121132
}

hensu-core/src/main/java/io/hensu/core/plan/PlanningConfig.java

Lines changed: 49 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -34,9 +34,14 @@
3434
/// @param review whether to enable review gates before and after plan execution;
3535
/// when {@code true} both the pre-execution gate (review the plan structure)
3636
/// and the post-execution gate (review the plan results) are activated
37+
/// @param agentId identifier of the agent to use for plan generation; nullable —
38+
/// required for {@link PlanningMode#DYNAMIC}, ignored for STATIC and DISABLED.
39+
/// When {@code null} in DYNAMIC mode, the processor falls back to
40+
/// the node's own agent.
3741
/// @see PlanningMode for mode options
3842
/// @see PlanConstraints for constraint details
39-
public record PlanningConfig(PlanningMode mode, PlanConstraints constraints, boolean review) {
43+
public record PlanningConfig(
44+
PlanningMode mode, PlanConstraints constraints, boolean review, String agentId) {
4045

4146
/// Compact constructor with validation.
4247
public PlanningConfig {
@@ -48,35 +53,52 @@ public record PlanningConfig(PlanningMode mode, PlanConstraints constraints, boo
4853
///
4954
/// @return disabled planning config, never null
5055
public static PlanningConfig disabled() {
51-
return new PlanningConfig(PlanningMode.DISABLED, PlanConstraints.defaults(), false);
56+
return new PlanningConfig(PlanningMode.DISABLED, PlanConstraints.defaults(), false, null);
5257
}
5358

5459
/// Returns configuration for static (DSL-defined) plans.
5560
///
5661
/// @return static planning config, never null
5762
public static PlanningConfig forStatic() {
58-
return new PlanningConfig(PlanningMode.STATIC, PlanConstraints.forStaticPlan(), false);
63+
return new PlanningConfig(
64+
PlanningMode.STATIC, PlanConstraints.forStaticPlan(), false, null);
5965
}
6066

6167
/// Returns configuration for static plans with review.
6268
///
6369
/// @return static planning config with review, never null
6470
public static PlanningConfig forStaticWithReview() {
65-
return new PlanningConfig(PlanningMode.STATIC, PlanConstraints.forStaticPlan(), true);
71+
return new PlanningConfig(PlanningMode.STATIC, PlanConstraints.forStaticPlan(), true, null);
6672
}
6773

6874
/// Returns configuration for dynamic (LLM-generated) plans.
6975
///
7076
/// @return dynamic planning config, never null
7177
public static PlanningConfig forDynamic() {
72-
return new PlanningConfig(PlanningMode.DYNAMIC, PlanConstraints.defaults(), false);
78+
return new PlanningConfig(PlanningMode.DYNAMIC, PlanConstraints.defaults(), false, null);
79+
}
80+
81+
/// Returns configuration for dynamic plans with a specific planner agent.
82+
///
83+
/// @param agentId identifier of the agent to use for plan generation, not null
84+
/// @return dynamic planning config, never null
85+
public static PlanningConfig forDynamic(String agentId) {
86+
return new PlanningConfig(PlanningMode.DYNAMIC, PlanConstraints.defaults(), false, agentId);
7387
}
7488

7589
/// Returns configuration for dynamic plans with review gates enabled.
7690
///
7791
/// @return dynamic planning config with review, never null
7892
public static PlanningConfig forDynamicWithReview() {
79-
return new PlanningConfig(PlanningMode.DYNAMIC, PlanConstraints.defaults(), true);
93+
return new PlanningConfig(PlanningMode.DYNAMIC, PlanConstraints.defaults(), true, null);
94+
}
95+
96+
/// Returns configuration for dynamic plans with review and a specific planner agent.
97+
///
98+
/// @param agentId identifier of the agent to use for plan generation, not null
99+
/// @return dynamic planning config with review, never null
100+
public static PlanningConfig forDynamicWithReview(String agentId) {
101+
return new PlanningConfig(PlanningMode.DYNAMIC, PlanConstraints.defaults(), true, agentId);
80102
}
81103

82104
/// Returns whether planning is enabled.
@@ -100,41 +122,57 @@ public boolean isDynamic() {
100122
return mode == PlanningMode.DYNAMIC;
101123
}
102124

125+
/// Resolves the effective planner agent ID, falling back to the given node agent.
126+
///
127+
/// @param nodeAgentId the node's own agent ID used as fallback, may be null
128+
/// @return the resolved agent ID, or null if neither is set
129+
public String resolveAgentId(String nodeAgentId) {
130+
return agentId != null ? agentId : nodeAgentId;
131+
}
132+
103133
/// Returns a copy with updated constraints.
104134
///
105135
/// @param newConstraints the new constraints, not null
106136
/// @return new config with updated constraints, never null
107137
public PlanningConfig withConstraints(PlanConstraints newConstraints) {
108-
return new PlanningConfig(mode, newConstraints, review);
138+
return new PlanningConfig(mode, newConstraints, review, agentId);
109139
}
110140

111141
/// Returns a copy with review gates enabled (both pre- and post-execution).
112142
///
113143
/// @return new config with review enabled, never null
114144
public PlanningConfig withReview() {
115-
return new PlanningConfig(mode, constraints, true);
145+
return new PlanningConfig(mode, constraints, true, agentId);
116146
}
117147

118148
/// Returns a copy with review gates disabled.
119149
///
120150
/// @return new config with review disabled, never null
121151
public PlanningConfig withoutReview() {
122-
return new PlanningConfig(mode, constraints, false);
152+
return new PlanningConfig(mode, constraints, false, agentId);
123153
}
124154

125155
/// Returns a copy with updated max duration.
126156
///
127157
/// @param duration the new max duration, not null
128158
/// @return new config with updated duration, never null
129159
public PlanningConfig withMaxDuration(Duration duration) {
130-
return new PlanningConfig(mode, constraints.withMaxDuration(duration), review);
160+
return new PlanningConfig(mode, constraints.withMaxDuration(duration), review, agentId);
131161
}
132162

133163
/// Returns a copy with updated max steps.
134164
///
135165
/// @param maxSteps the new max steps
136166
/// @return new config with updated steps, never null
137167
public PlanningConfig withMaxSteps(int maxSteps) {
138-
return new PlanningConfig(mode, constraints.withMaxSteps(maxSteps), review);
168+
return new PlanningConfig(mode, constraints.withMaxSteps(maxSteps), review, agentId);
169+
}
170+
171+
/// Returns a copy with updated planner agent.
172+
///
173+
/// @param agentId identifier of the agent to use for plan generation
174+
/// @return new config with updated agent, never null
175+
public PlanningConfig withAgentId(String agentId) {
176+
return new PlanningConfig(mode, constraints, review, agentId);
139177
}
140178
}

hensu-core/src/test/java/io/hensu/core/execution/executor/PlanCreationProcessorTest.java

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -112,6 +112,7 @@ private PlanContext dynamicPlanContext() {
112112
StandardNode node =
113113
StandardNode.builder()
114114
.id("planning-node")
115+
.agentId("test-agent")
115116
.prompt("test prompt")
116117
.planningConfig(PlanningConfig.forDynamic())
117118
.transitionRules(List.of())

0 commit comments

Comments
 (0)