Skip to content
Merged
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
32 changes: 7 additions & 25 deletions hensu-core/src/main/java/io/hensu/core/HensuFactory.java
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
package io.hensu.core;

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

// Auto-construct LlmPlanner when a parser is provided but no explicit planner
// Auto-construct LlmPlanner when a parser is provided but no explicit planner.
// The planner resolves its agent per-request from the AgentRegistry using the
// agentId carried in PlanRequest / RevisionContext (see PlanningConfig.agentId).
if (planResponseParser != null && planner == null) {
var planningAgentConfig =
AgentConfig.builder()
.id("_planning_agent")
.role("planner")
.model("gemini-2.5-pro")
.temperature(0.3)
.build();
if (!agentRegistry.hasAgent("_planning_agent", planningAgentConfig)) {
agentRegistry.registerAgent("_planning_agent", planningAgentConfig);
}
planner =
new LlmPlanner(
agentRegistry
.getAgent("_planning_agent")
.orElseThrow(
() ->
new IllegalStateException(
"_planning_agent not found after registration")),
planResponseParser);
planner = new LlmPlanner(agentRegistry, planResponseParser);
}

// Wire planning if a planner was configured
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -107,13 +107,21 @@ private Plan createPlan(PlanContext context) throws PlanCreationException {
}

case DYNAMIC -> {
String agentId = config.resolveAgentId(node.getAgentId());
if (agentId == null || agentId.isBlank()) {
throw new PlanCreationException(
"No planner agent configured for dynamic planning on node: "
+ node.getId());
}

List<ToolDefinition> tools = toolRegistry.all();
String prompt = resolvePrompt(node.getPrompt(), executionContext);

executionContext.getListener().onPlannerStart(node.getId(), prompt);
Plan created =
planner.createPlan(
new PlanRequest(
agentId,
prompt,
tools,
executionContext.getState().getContext(),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
import io.hensu.core.plan.PlannedStep;
import io.hensu.core.plan.Planner;
import io.hensu.core.plan.Planner.RevisionContext;
import io.hensu.core.plan.PlanningConfig;
import io.hensu.core.plan.StepResult;
import io.hensu.core.tool.ToolDefinition;
import io.hensu.core.tool.ToolRegistry;
Expand Down Expand Up @@ -120,12 +121,14 @@ public Optional<NodeResult> process(PlanContext context) {
Duration.ZERO);
String prompt = resolvePrompt(node.getPrompt(), executionContext);
List<ToolDefinition> tools = toolRegistry.all();
PlanningConfig config = node.getPlanningConfig();
String agentId = config.resolveAgentId(node.getAgentId());

executionContext.getListener().onPlannerStart(node.getId(), prompt);
Plan revisedPlan =
planner.revisePlan(
currentPlan,
RevisionContext.fromFailure(failedStep, prompt, tools));
RevisionContext.fromFailure(failedStep, prompt, tools, agentId));
executionContext.getListener().onPlannerComplete(node.getId(), revisedPlan.steps());

revisedPlan = enrichSynthesizeSteps(revisedPlan, node.getAgentId());
Expand Down
36 changes: 29 additions & 7 deletions hensu-core/src/main/java/io/hensu/core/plan/LlmPlanner.java
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
package io.hensu.core.plan;

import io.hensu.core.agent.Agent;
import io.hensu.core.agent.AgentNotFoundException;
import io.hensu.core.agent.AgentRegistry;
import io.hensu.core.agent.AgentResponse;
import io.hensu.core.tool.ToolDefinition;
import io.hensu.core.tool.ToolDefinition.ParameterDef;
Expand All @@ -16,7 +18,7 @@
/// Uses a planning agent to create step-by-step plans based on the goal,
/// available tools, and workflow context. Supports both tool-call steps and
/// synthesize steps, enabling the agent to express "call this tool, then
/// summarise the results" within a single plan.
/// summarize the results" within a single plan.
///
/// ### Plan Generation
/// The planner prompts the LLM with structured instructions and parses the
Expand Down Expand Up @@ -114,24 +116,34 @@ public class LlmPlanner implements Planner {
```
""";

private final Agent planningAgent;
private final AgentRegistry agentRegistry;
private final PlanResponseParser responseParser;

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

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

Agent planningAgent;
try {
planningAgent = agentRegistry.getAgentOrThrow(request.agentId());
} catch (AgentNotFoundException e) {
throw new PlanCreationException(
"Planning agent '" + request.agentId() + "' not found", e);
}
String prompt = buildPlanningPrompt(request);
AgentResponse response = planningAgent.execute(prompt, request.context());

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

Agent planningAgent;
try {
planningAgent = agentRegistry.getAgentOrThrow(context.agentId());
} catch (AgentNotFoundException e) {
throw new PlanRevisionException(
"Planning agent '" + context.agentId() + "' not found for revision", e);
}
String prompt = buildRevisionPrompt(currentPlan, context);
AgentResponse response = planningAgent.execute(prompt, Map.of());

Expand Down
19 changes: 15 additions & 4 deletions hensu-core/src/main/java/io/hensu/core/plan/Planner.java
Original file line number Diff line number Diff line change
Expand Up @@ -53,11 +53,14 @@ public interface Planner {

/// Request for plan creation.
///
/// @param agentId identifier of the agent to use for plan generation; nullable
/// for non-LLM planners (e.g. {@link StaticPlanner})
/// @param prompt the resolved node prompt driving this plan, not null
/// @param availableTools tools that can be used in the plan, not null
/// @param context workflow variables for template resolution, not null
/// @param constraints limits on plan generation, not null
record PlanRequest(
String agentId,
String prompt,
List<ToolDefinition> availableTools,
Map<String, Object> context,
Expand All @@ -75,7 +78,7 @@ record PlanRequest(
/// @param prompt the resolved node prompt
/// @return new request, never null
public static PlanRequest simple(String prompt) {
return new PlanRequest(prompt, List.of(), Map.of(), PlanConstraints.defaults());
return new PlanRequest(null, prompt, List.of(), Map.of(), PlanConstraints.defaults());
}
}

Expand All @@ -89,12 +92,15 @@ public static PlanRequest simple(String prompt) {
/// @param revisionReason explanation for why revision is needed, not null
/// @param prompt the resolved node prompt that drove the original plan, not null
/// @param availableTools tools available for the revised plan, not null
/// @param agentId identifier of the agent to use for replanning; nullable
/// for non-LLM planners
record RevisionContext(
int failedAtStep,
StepResult failureResult,
String revisionReason,
String prompt,
List<ToolDefinition> availableTools) {
List<ToolDefinition> availableTools,
String agentId) {

/// Compact constructor with defaults.
public RevisionContext {
Expand All @@ -107,15 +113,20 @@ record RevisionContext(
/// @param stepResult the failed step result, not null
/// @param prompt the resolved node prompt that drove the original plan, not null
/// @param availableTools tools available for replanning, not null
/// @param agentId identifier of the agent to use for replanning
/// @return revision context, never null
public static RevisionContext fromFailure(
StepResult stepResult, String prompt, List<ToolDefinition> availableTools) {
StepResult stepResult,
String prompt,
List<ToolDefinition> availableTools,
String agentId) {
return new RevisionContext(
stepResult.stepIndex(),
stepResult,
"Step " + stepResult.stepIndex() + " failed: " + stepResult.error(),
prompt,
availableTools);
availableTools,
agentId);
}
}
}
60 changes: 49 additions & 11 deletions hensu-core/src/main/java/io/hensu/core/plan/PlanningConfig.java
Original file line number Diff line number Diff line change
Expand Up @@ -34,9 +34,14 @@
/// @param review whether to enable review gates before and after plan execution;
/// when {@code true} both the pre-execution gate (review the plan structure)
/// and the post-execution gate (review the plan results) are activated
/// @param agentId identifier of the agent to use for plan generation; nullable —
/// required for {@link PlanningMode#DYNAMIC}, ignored for STATIC and DISABLED.
/// When {@code null} in DYNAMIC mode, the processor falls back to
/// the node's own agent.
/// @see PlanningMode for mode options
/// @see PlanConstraints for constraint details
public record PlanningConfig(PlanningMode mode, PlanConstraints constraints, boolean review) {
public record PlanningConfig(
PlanningMode mode, PlanConstraints constraints, boolean review, String agentId) {

/// Compact constructor with validation.
public PlanningConfig {
Expand All @@ -48,35 +53,52 @@ public record PlanningConfig(PlanningMode mode, PlanConstraints constraints, boo
///
/// @return disabled planning config, never null
public static PlanningConfig disabled() {
return new PlanningConfig(PlanningMode.DISABLED, PlanConstraints.defaults(), false);
return new PlanningConfig(PlanningMode.DISABLED, PlanConstraints.defaults(), false, null);
}

/// Returns configuration for static (DSL-defined) plans.
///
/// @return static planning config, never null
public static PlanningConfig forStatic() {
return new PlanningConfig(PlanningMode.STATIC, PlanConstraints.forStaticPlan(), false);
return new PlanningConfig(
PlanningMode.STATIC, PlanConstraints.forStaticPlan(), false, null);
}

/// Returns configuration for static plans with review.
///
/// @return static planning config with review, never null
public static PlanningConfig forStaticWithReview() {
return new PlanningConfig(PlanningMode.STATIC, PlanConstraints.forStaticPlan(), true);
return new PlanningConfig(PlanningMode.STATIC, PlanConstraints.forStaticPlan(), true, null);
}

/// Returns configuration for dynamic (LLM-generated) plans.
///
/// @return dynamic planning config, never null
public static PlanningConfig forDynamic() {
return new PlanningConfig(PlanningMode.DYNAMIC, PlanConstraints.defaults(), false);
return new PlanningConfig(PlanningMode.DYNAMIC, PlanConstraints.defaults(), false, null);
}

/// Returns configuration for dynamic plans with a specific planner agent.
///
/// @param agentId identifier of the agent to use for plan generation, not null
/// @return dynamic planning config, never null
public static PlanningConfig forDynamic(String agentId) {
return new PlanningConfig(PlanningMode.DYNAMIC, PlanConstraints.defaults(), false, agentId);
}

/// Returns configuration for dynamic plans with review gates enabled.
///
/// @return dynamic planning config with review, never null
public static PlanningConfig forDynamicWithReview() {
return new PlanningConfig(PlanningMode.DYNAMIC, PlanConstraints.defaults(), true);
return new PlanningConfig(PlanningMode.DYNAMIC, PlanConstraints.defaults(), true, null);
}

/// Returns configuration for dynamic plans with review and a specific planner agent.
///
/// @param agentId identifier of the agent to use for plan generation, not null
/// @return dynamic planning config with review, never null
public static PlanningConfig forDynamicWithReview(String agentId) {
return new PlanningConfig(PlanningMode.DYNAMIC, PlanConstraints.defaults(), true, agentId);
}

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

/// Resolves the effective planner agent ID, falling back to the given node agent.
///
/// @param nodeAgentId the node's own agent ID used as fallback, may be null
/// @return the resolved agent ID, or null if neither is set
public String resolveAgentId(String nodeAgentId) {
return agentId != null ? agentId : nodeAgentId;
}

/// Returns a copy with updated constraints.
///
/// @param newConstraints the new constraints, not null
/// @return new config with updated constraints, never null
public PlanningConfig withConstraints(PlanConstraints newConstraints) {
return new PlanningConfig(mode, newConstraints, review);
return new PlanningConfig(mode, newConstraints, review, agentId);
}

/// Returns a copy with review gates enabled (both pre- and post-execution).
///
/// @return new config with review enabled, never null
public PlanningConfig withReview() {
return new PlanningConfig(mode, constraints, true);
return new PlanningConfig(mode, constraints, true, agentId);
}

/// Returns a copy with review gates disabled.
///
/// @return new config with review disabled, never null
public PlanningConfig withoutReview() {
return new PlanningConfig(mode, constraints, false);
return new PlanningConfig(mode, constraints, false, agentId);
}

/// Returns a copy with updated max duration.
///
/// @param duration the new max duration, not null
/// @return new config with updated duration, never null
public PlanningConfig withMaxDuration(Duration duration) {
return new PlanningConfig(mode, constraints.withMaxDuration(duration), review);
return new PlanningConfig(mode, constraints.withMaxDuration(duration), review, agentId);
}

/// Returns a copy with updated max steps.
///
/// @param maxSteps the new max steps
/// @return new config with updated steps, never null
public PlanningConfig withMaxSteps(int maxSteps) {
return new PlanningConfig(mode, constraints.withMaxSteps(maxSteps), review);
return new PlanningConfig(mode, constraints.withMaxSteps(maxSteps), review, agentId);
}

/// Returns a copy with updated planner agent.
///
/// @param agentId identifier of the agent to use for plan generation
/// @return new config with updated agent, never null
public PlanningConfig withAgentId(String agentId) {
return new PlanningConfig(mode, constraints, review, agentId);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -112,6 +112,7 @@ private PlanContext dynamicPlanContext() {
StandardNode node =
StandardNode.builder()
.id("planning-node")
.agentId("test-agent")
.prompt("test prompt")
.planningConfig(PlanningConfig.forDynamic())
.transitionRules(List.of())
Expand Down
Loading
Loading