diff --git a/hensu-core/src/main/java/io/hensu/core/HensuFactory.java b/hensu-core/src/main/java/io/hensu/core/HensuFactory.java index d447eb3..3f93807 100644 --- a/hensu-core/src/main/java/io/hensu/core/HensuFactory.java +++ b/hensu-core/src/main/java/io/hensu/core/HensuFactory.java @@ -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; @@ -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 @@ -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 diff --git a/hensu-core/src/main/java/io/hensu/core/execution/executor/PlanCreationProcessor.java b/hensu-core/src/main/java/io/hensu/core/execution/executor/PlanCreationProcessor.java index 1b09c59..9f8666b 100644 --- a/hensu-core/src/main/java/io/hensu/core/execution/executor/PlanCreationProcessor.java +++ b/hensu-core/src/main/java/io/hensu/core/execution/executor/PlanCreationProcessor.java @@ -107,6 +107,13 @@ 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 tools = toolRegistry.all(); String prompt = resolvePrompt(node.getPrompt(), executionContext); @@ -114,6 +121,7 @@ private Plan createPlan(PlanContext context) throws PlanCreationException { Plan created = planner.createPlan( new PlanRequest( + agentId, prompt, tools, executionContext.getState().getContext(), diff --git a/hensu-core/src/main/java/io/hensu/core/execution/executor/PlanExecutionProcessor.java b/hensu-core/src/main/java/io/hensu/core/execution/executor/PlanExecutionProcessor.java index d549048..8dba8c2 100644 --- a/hensu-core/src/main/java/io/hensu/core/execution/executor/PlanExecutionProcessor.java +++ b/hensu-core/src/main/java/io/hensu/core/execution/executor/PlanExecutionProcessor.java @@ -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; @@ -120,12 +121,14 @@ public Optional process(PlanContext context) { Duration.ZERO); String prompt = resolvePrompt(node.getPrompt(), executionContext); List 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()); diff --git a/hensu-core/src/main/java/io/hensu/core/plan/LlmPlanner.java b/hensu-core/src/main/java/io/hensu/core/plan/LlmPlanner.java index 5b5e5cc..cbf791c 100644 --- a/hensu-core/src/main/java/io/hensu/core/plan/LlmPlanner.java +++ b/hensu-core/src/main/java/io/hensu/core/plan/LlmPlanner.java @@ -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; @@ -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 @@ -114,16 +116,16 @@ 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"); } @@ -131,7 +133,17 @@ public LlmPlanner(Agent planningAgent, PlanResponseParser responseParser) { @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()); @@ -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()); diff --git a/hensu-core/src/main/java/io/hensu/core/plan/Planner.java b/hensu-core/src/main/java/io/hensu/core/plan/Planner.java index c847c2a..c63b8fc 100644 --- a/hensu-core/src/main/java/io/hensu/core/plan/Planner.java +++ b/hensu-core/src/main/java/io/hensu/core/plan/Planner.java @@ -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 availableTools, Map context, @@ -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()); } } @@ -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 availableTools) { + List availableTools, + String agentId) { /// Compact constructor with defaults. public RevisionContext { @@ -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 availableTools) { + StepResult stepResult, + String prompt, + List availableTools, + String agentId) { return new RevisionContext( stepResult.stepIndex(), stepResult, "Step " + stepResult.stepIndex() + " failed: " + stepResult.error(), prompt, - availableTools); + availableTools, + agentId); } } } diff --git a/hensu-core/src/main/java/io/hensu/core/plan/PlanningConfig.java b/hensu-core/src/main/java/io/hensu/core/plan/PlanningConfig.java index 92aa59f..e59a2bd 100644 --- a/hensu-core/src/main/java/io/hensu/core/plan/PlanningConfig.java +++ b/hensu-core/src/main/java/io/hensu/core/plan/PlanningConfig.java @@ -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 { @@ -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. @@ -100,26 +122,34 @@ 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. @@ -127,7 +157,7 @@ public PlanningConfig withoutReview() { /// @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. @@ -135,6 +165,14 @@ public PlanningConfig withMaxDuration(Duration duration) { /// @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); } } diff --git a/hensu-core/src/test/java/io/hensu/core/execution/executor/PlanCreationProcessorTest.java b/hensu-core/src/test/java/io/hensu/core/execution/executor/PlanCreationProcessorTest.java index 03ef84b..2c1de3c 100644 --- a/hensu-core/src/test/java/io/hensu/core/execution/executor/PlanCreationProcessorTest.java +++ b/hensu-core/src/test/java/io/hensu/core/execution/executor/PlanCreationProcessorTest.java @@ -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()) diff --git a/hensu-core/src/test/java/io/hensu/core/plan/LlmPlannerTest.java b/hensu-core/src/test/java/io/hensu/core/plan/LlmPlannerTest.java index 6a946b2..fe77efd 100644 --- a/hensu-core/src/test/java/io/hensu/core/plan/LlmPlannerTest.java +++ b/hensu-core/src/test/java/io/hensu/core/plan/LlmPlannerTest.java @@ -10,6 +10,8 @@ import static org.mockito.Mockito.when; 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.plan.Planner.PlanRequest; import io.hensu.core.tool.ToolDefinition; @@ -23,15 +25,20 @@ class LlmPlannerTest { + private static final String TEST_AGENT_ID = "test-agent"; + private Agent planningAgent; + private AgentRegistry agentRegistry; private PlanResponseParser responseParser; private LlmPlanner planner; @BeforeEach - void setUp() { + void setUp() throws AgentNotFoundException { planningAgent = mock(Agent.class); + agentRegistry = mock(AgentRegistry.class); + when(agentRegistry.getAgentOrThrow(anyString())).thenReturn(planningAgent); responseParser = mock(PlanResponseParser.class); - planner = new LlmPlanner(planningAgent, responseParser); + planner = new LlmPlanner(agentRegistry, responseParser); } @Nested @@ -50,6 +57,7 @@ void shouldCreatePlanFromPlanProposalResponse() throws PlanCreationException { PlanRequest request = new PlanRequest( + "test-agent", "Find and process data", List.of(ToolDefinition.simple("search", "Search tool")), Map.of(), @@ -76,7 +84,14 @@ void shouldDelegateTextResponseToParser() throws PlanCreationException { .thenReturn(AgentResponse.TextResponse.of(rawText)); when(responseParser.parse(rawText)).thenReturn(parsedSteps); - Plan plan = planner.createPlan(PlanRequest.simple("Fetch and save data")); + Plan plan = + planner.createPlan( + new PlanRequest( + TEST_AGENT_ID, + "Fetch and save data", + List.of(), + Map.of(), + PlanConstraints.defaults())); verify(responseParser).parse(rawText); assertThat(plan.stepCount()).isEqualTo(2); @@ -100,6 +115,7 @@ void shouldTruncatePlanExceedingMaxSteps() throws PlanCreationException { Plan plan = planner.createPlan( new PlanRequest( + "test-agent", "Multi-step task", List.of(), Map.of(), @@ -114,7 +130,15 @@ void shouldThrowOnEmptyPlan() throws PlanCreationException { .thenReturn(AgentResponse.TextResponse.of("[]")); when(responseParser.parse("[]")).thenReturn(List.of()); - assertThatThrownBy(() -> planner.createPlan(PlanRequest.simple("Goal"))) + assertThatThrownBy( + () -> + planner.createPlan( + new PlanRequest( + TEST_AGENT_ID, + "Goal", + List.of(), + Map.of(), + PlanConstraints.defaults()))) .isInstanceOf(PlanCreationException.class) .hasMessageContaining("empty plan"); } @@ -124,7 +148,15 @@ void shouldThrowOnAgentError() { when(planningAgent.execute(anyString(), any())) .thenReturn(AgentResponse.Error.of("Model unavailable")); - assertThatThrownBy(() -> planner.createPlan(PlanRequest.simple("Goal"))) + assertThatThrownBy( + () -> + planner.createPlan( + new PlanRequest( + TEST_AGENT_ID, + "Goal", + List.of(), + Map.of(), + PlanConstraints.defaults()))) .isInstanceOf(PlanCreationException.class) .hasMessageContaining("Planning agent failed"); } @@ -134,7 +166,15 @@ void shouldThrowOnUnexpectedToolRequest() { when(planningAgent.execute(anyString(), any())) .thenReturn(AgentResponse.ToolRequest.of("unexpected_tool", Map.of())); - assertThatThrownBy(() -> planner.createPlan(PlanRequest.simple("Goal"))) + assertThatThrownBy( + () -> + planner.createPlan( + new PlanRequest( + TEST_AGENT_ID, + "Goal", + List.of(), + Map.of(), + PlanConstraints.defaults()))) .isInstanceOf(PlanCreationException.class) .hasMessageContaining("Unexpected tool request"); } @@ -147,6 +187,7 @@ void shouldIncludeToolsInPrompt() throws PlanCreationException { planner.createPlan( new PlanRequest( + "test-agent", "Search goal", List.of( ToolDefinition.of( @@ -196,7 +237,7 @@ void shouldRevisePlanAfterFailure() throws Exception { StepResult.failure(0, "fetch", "Connection timeout", Duration.ZERO); Planner.RevisionContext context = Planner.RevisionContext.fromFailure( - failedResult, "Fetch and process data", List.of()); + failedResult, "Fetch and process data", List.of(), "test-agent"); Plan revisedPlan = planner.revisePlan(originalPlan, context); @@ -217,7 +258,8 @@ void shouldThrowOnEmptyRevisedPlan() throws PlanCreationException { StepResult failedResult = StepResult.failure(0, "tool", "Error", Duration.ZERO); Planner.RevisionContext context = - Planner.RevisionContext.fromFailure(failedResult, "Process data", List.of()); + Planner.RevisionContext.fromFailure( + failedResult, "Process data", List.of(), "test-agent"); assertThatThrownBy(() -> planner.revisePlan(originalPlan, context)) .isInstanceOf(PlanRevisionException.class) @@ -236,7 +278,8 @@ void shouldWrapParserExceptionInRevisionException() throws PlanCreationException StepResult failedResult = StepResult.failure(0, "tool", "Error", Duration.ZERO); Planner.RevisionContext context = - Planner.RevisionContext.fromFailure(failedResult, "Process data", List.of()); + Planner.RevisionContext.fromFailure( + failedResult, "Process data", List.of(), "test-agent"); assertThatThrownBy(() -> planner.revisePlan(originalPlan, context)) .isInstanceOf(PlanRevisionException.class) @@ -256,7 +299,8 @@ void shouldIncludeFailureContextInRevisionPrompt() throws Exception { StepResult failedResult = StepResult.failure(2, "api_call", "Connection timeout", Duration.ZERO); Planner.RevisionContext context = - Planner.RevisionContext.fromFailure(failedResult, "Fetch user data", List.of()); + Planner.RevisionContext.fromFailure( + failedResult, "Fetch user data", List.of(), "test-agent"); planner.revisePlan(originalPlan, context); @@ -275,15 +319,15 @@ void shouldIncludeFailureContextInRevisionPrompt() throws Exception { class ConstructorValidation { @Test - void shouldRejectNullAgent() { + void shouldRejectNullAgentRegistry() { assertThatThrownBy(() -> new LlmPlanner(null, responseParser)) .isInstanceOf(NullPointerException.class) - .hasMessageContaining("planningAgent"); + .hasMessageContaining("agentRegistry"); } @Test void shouldRejectNullResponseParser() { - assertThatThrownBy(() -> new LlmPlanner(planningAgent, null)) + assertThatThrownBy(() -> new LlmPlanner(agentRegistry, null)) .isInstanceOf(NullPointerException.class) .hasMessageContaining("responseParser"); } diff --git a/hensu-core/src/test/java/io/hensu/core/plan/PlanningConfigTest.java b/hensu-core/src/test/java/io/hensu/core/plan/PlanningConfigTest.java index 13ef7cf..a1652ff 100644 --- a/hensu-core/src/test/java/io/hensu/core/plan/PlanningConfigTest.java +++ b/hensu-core/src/test/java/io/hensu/core/plan/PlanningConfigTest.java @@ -70,14 +70,14 @@ class DefaultValues { @Test void shouldDefaultToDisabledModeWhenNull() { - PlanningConfig config = new PlanningConfig(null, null, false); + PlanningConfig config = new PlanningConfig(null, null, false, null); assertThat(config.mode()).isEqualTo(PlanningMode.DISABLED); } @Test void shouldDefaultConstraintsWhenNull() { - PlanningConfig config = new PlanningConfig(PlanningMode.DYNAMIC, null, false); + PlanningConfig config = new PlanningConfig(PlanningMode.DYNAMIC, null, false, null); assertThat(config.constraints()).isNotNull(); assertThat(config.constraints().maxSteps()).isEqualTo(10); diff --git a/hensu-core/src/test/java/io/hensu/core/plan/StaticPlannerTest.java b/hensu-core/src/test/java/io/hensu/core/plan/StaticPlannerTest.java index df78c54..c17a501 100644 --- a/hensu-core/src/test/java/io/hensu/core/plan/StaticPlannerTest.java +++ b/hensu-core/src/test/java/io/hensu/core/plan/StaticPlannerTest.java @@ -54,7 +54,8 @@ void shouldResolvePlaceholdersInArguments() { StaticPlanner planner = new StaticPlanner(predefined); Plan result = planner.createPlan( - new PlanRequest("Goal", List.of(), Map.of("orderId", "12345"), null)); + new PlanRequest( + null, "Goal", List.of(), Map.of("orderId", "12345"), null)); assertThat(result.getStep(0).arguments()).containsEntry("id", "12345"); } @@ -71,7 +72,8 @@ void shouldResolvePlaceholdersInDescription() { StaticPlanner planner = new StaticPlanner(predefined); Plan result = planner.createPlan( - new PlanRequest("Goal", List.of(), Map.of("orderId", "ABC"), null)); + new PlanRequest( + null, "Goal", List.of(), Map.of("orderId", "ABC"), null)); assertThat(result.getStep(0).description()).isEqualTo("Process order ABC"); } @@ -92,6 +94,7 @@ void shouldResolveMultiplePlaceholders() { Plan result = planner.createPlan( new PlanRequest( + null, "Goal", List.of(), Map.of("orderId", "123", "customer", "John"), @@ -111,7 +114,8 @@ void shouldLeaveUnresolvedPlaceholdersAsIs() { 0, "tool", Map.of("key", "{unknown}"), "Desc"))); StaticPlanner planner = new StaticPlanner(predefined); - Plan result = planner.createPlan(new PlanRequest("Goal", List.of(), Map.of(), null)); + Plan result = + planner.createPlan(new PlanRequest(null, "Goal", List.of(), Map.of(), null)); assertThat(result.getStep(0).arguments()).containsEntry("key", "{unknown}"); } @@ -133,7 +137,8 @@ void shouldResolveNestedMapValues() { StaticPlanner planner = new StaticPlanner(predefined); Plan result = planner.createPlan( - new PlanRequest("Goal", List.of(), Map.of("userId", "U789"), null)); + new PlanRequest( + null, "Goal", List.of(), Map.of("userId", "U789"), null)); Map payload = (Map) result.getStep(0).arguments().get("payload"); @@ -156,7 +161,11 @@ void shouldResolveListValues() { Plan result = planner.createPlan( new PlanRequest( - "Goal", List.of(), Map.of("item1", "A", "item2", "B"), null)); + null, + "Goal", + List.of(), + Map.of("item1", "A", "item2", "B"), + null)); List items = (List) result.getStep(0).arguments().get("items"); assertThat(items).containsExactly("A", "B"); @@ -176,7 +185,8 @@ void shouldConvertNonStringContextValueToString() { StaticPlanner planner = new StaticPlanner(predefined); Plan result = planner.createPlan( - new PlanRequest("Goal", List.of(), Map.of("itemCount", 42), null)); + new PlanRequest( + null, "Goal", List.of(), Map.of("itemCount", 42), null)); assertThat(result.getStep(0).arguments()).containsEntry("count", "42"); } @@ -191,7 +201,8 @@ void shouldThrowOnRevision() { StaticPlanner planner = new StaticPlanner(predefined); StepResult failedResult = StepResult.failure(0, "tool", "error", Duration.ZERO); - RevisionContext context = RevisionContext.fromFailure(failedResult, "", List.of()); + RevisionContext context = + RevisionContext.fromFailure(failedResult, "", List.of(), null); assertThatThrownBy(() -> planner.revisePlan(predefined, context)) .isInstanceOf(PlanRevisionException.class) @@ -204,7 +215,7 @@ class PlanRequestTest { @Test void shouldDefaultNullValues() { - PlanRequest request = new PlanRequest(null, null, null, null); + PlanRequest request = new PlanRequest(null, null, null, null, null); assertThat(request.prompt()).isEmpty(); assertThat(request.availableTools()).isEmpty(); @@ -221,7 +232,7 @@ void shouldCreateFromFailure() { StepResult failure = StepResult.failure(2, "api_call", "Timeout", Duration.ofSeconds(5)); - RevisionContext context = RevisionContext.fromFailure(failure, "", List.of()); + RevisionContext context = RevisionContext.fromFailure(failure, "", List.of(), null); assertThat(context.failedAtStep()).isEqualTo(2); assertThat(context.failureResult()).isSameAs(failure); diff --git a/hensu-dsl/src/main/kotlin/io/hensu/dsl/builders/PlanningConfigBuilder.kt b/hensu-dsl/src/main/kotlin/io/hensu/dsl/builders/PlanningConfigBuilder.kt index 8530c12..6d89c1a 100644 --- a/hensu-dsl/src/main/kotlin/io/hensu/dsl/builders/PlanningConfigBuilder.kt +++ b/hensu-dsl/src/main/kotlin/io/hensu/dsl/builders/PlanningConfigBuilder.kt @@ -11,21 +11,55 @@ import java.time.Duration * Configures how a node generates and executes plans at runtime. Used with the `planning { }` block * in node definitions. * - * Example: + * ### Planner Agent + * + * Dynamic planning requires an agent to generate the plan. The agent is resolved at execution time + * from the [io.hensu.core.agent.AgentRegistry] using one of two sources (in priority order): + * 1. **Explicit** – the [agent] property set in the `planning { }` block. + * 2. **Fallback** – the node's own `agent` (the same agent that executes steps). + * + * If neither is set, execution fails fast with a clear error before the first node runs. + * + * Define the planner agent in the workflow's `agents { }` block with the desired model and + * temperature (e.g. low temperature for structured JSON plan output), then reference it: * ```kotlin - * node("research") { - * agent = "gpt-4o" - * tools = listOf("search", "analyze", "summarize") + * agents { + * agent("planner") { + * role = "Plan generation specialist" + * model = Models.GEMINI_3_1_FLASH_LITE + * temperature = 0.2 + * } + * agent("researcher") { + * role = "Deep research analyst" + * model = Models.GEMINI_3_1_PRO + * temperature = 0.7 + * } + * } + * + * graph { + * node("research") { + * agent = "researcher" + * planning { + * dynamic("planner") // explicit planner agent + * maxSteps = 15 + * review = true + * } + * prompt = "Research and analyze topic X" + * onSuccess goto "synthesize" + * } + * } + * ``` + * + * When the planner agent is omitted, the node's own agent handles both planning and execution: + * ```kotlin + * node("analyze") { + * agent = "analyst" * planning { - * mode = PlanningMode.DYNAMIC - * maxSteps = 15 - * maxReplans = 5 - * maxDuration = Duration.ofMinutes(10) - * allowReplan = true - * review = true // enables both pre- and post-execution review gates + * dynamic() // uses "analyst" for planning too + * maxSteps = 5 * } - * prompt = "Research and analyze topic X" - * onSuccess goto "synthesize" + * prompt = "Analyze the data" + * onSuccess goto "done" * } * ``` * @@ -59,6 +93,14 @@ class PlanningConfigBuilder { */ var review: Boolean = false + /** + * Identifier of the agent to use for plan generation. + * + * When set, overrides the node's own agent for planning purposes. When `null` (default), the + * node's agent is used as fallback for dynamic planning. + */ + var agent: String? = null + /** Maximum total execution time. Default: 5 minutes */ var maxDuration: Duration = Duration.ofMinutes(5) @@ -75,6 +117,7 @@ class PlanningConfigBuilder { mode, PlanConstraints(maxSteps, maxReplans, maxDuration, maxTokenBudget, allowReplan), review, + agent, ) /** @@ -95,9 +138,13 @@ class PlanningConfigBuilder { * Configures for dynamic planning mode. * * Sets mode to DYNAMIC with default constraints suitable for LLM-generated plans. + * + * @param plannerAgent identifier of the agent to use for plan generation; `null` (default) + * falls back to the node's own agent */ - fun dynamic() { + fun dynamic(plannerAgent: String? = null) { mode = PlanningMode.DYNAMIC + agent = plannerAgent allowReplan = true maxReplans = 3 maxDuration = Duration.ofMinutes(5) diff --git a/hensu-server/src/main/java/io/hensu/server/streaming/ExecutionEventBroadcaster.java b/hensu-server/src/main/java/io/hensu/server/streaming/ExecutionEventBroadcaster.java index 7a76627..a52b658 100644 --- a/hensu-server/src/main/java/io/hensu/server/streaming/ExecutionEventBroadcaster.java +++ b/hensu-server/src/main/java/io/hensu/server/streaming/ExecutionEventBroadcaster.java @@ -2,6 +2,7 @@ import io.hensu.core.plan.PlanEvent; import io.hensu.core.plan.PlanObserver; +import io.hensu.core.util.LogSanitizer; import io.smallrye.mutiny.Multi; import io.smallrye.mutiny.operators.multi.processors.BroadcastProcessor; import jakarta.enterprise.context.ApplicationScoped; @@ -93,12 +94,14 @@ public void publish(String executionId, ExecutionEvent event) { BroadcastProcessor processor = processors.get(executionId); if (processor != null) { - LOG.debugv("Publishing {0} to execution {1}", event.type(), executionId); + LOG.debugv( + "Publishing {0} to execution {1}", + event.type(), LogSanitizer.sanitize(executionId)); processor.onNext(event); } else { LOG.tracev( "No subscribers for execution {0}, event {1} dropped", - executionId, event.type()); + LogSanitizer.sanitize(executionId), event.type()); } } @@ -139,7 +142,8 @@ public void complete(String executionId) { BroadcastProcessor processor = processors.remove(executionId); if (processor != null) { - LOG.debugv("Completing broadcast for execution: {0}", executionId); + LOG.debugv( + "Completing broadcast for execution: {0}", LogSanitizer.sanitize(executionId)); processor.onComplete(); } diff --git a/hensu-server/src/test/java/io/hensu/server/integration/PlanExecutionIntegrationTest.java b/hensu-server/src/test/java/io/hensu/server/integration/PlanExecutionIntegrationTest.java index c6e359c..f1637a4 100644 --- a/hensu-server/src/test/java/io/hensu/server/integration/PlanExecutionIntegrationTest.java +++ b/hensu-server/src/test/java/io/hensu/server/integration/PlanExecutionIntegrationTest.java @@ -18,7 +18,7 @@ /// /// Covers static plan step dispatch via {@link io.hensu.core.plan.PlanExecutor} /// and dynamic plan generation via {@link io.hensu.core.plan.LlmPlanner} with -/// the auto-registered `_planning_agent`. +/// the workflow's declared planner agent. /// /// ### Contracts /// - **Precondition**: Stub mode enabled (`hensu.stub.enabled=true`) @@ -69,19 +69,19 @@ void shouldExecuteStaticPlan() { assertThat(payloads.get(1)).containsEntry("action", "process"); } - /// Verifies that dynamic planning generates a plan via the `_planning_agent` + /// Verifies that dynamic planning generates a plan via the node's planner agent /// and executes the resulting steps through the action handler. /// - /// The `plan-dynamic.json` workflow uses `planningConfig(mode=DYNAMIC)`. - /// The stub for `_planning_agent` returns a JSON array with one-step targeting - /// `"test-tool"`. The test confirms that at least one payload is dispatched + /// The `plan-dynamic.json` workflow uses `planningConfig(mode=DYNAMIC)` with + /// `agentId=planner`. The stub for `planner` returns a JSON array with one-step + /// targeting `"test-tool"`. The test confirms that the payload is dispatched /// to {@link TestActionHandler}. @Test void shouldExecuteDynamicPlan() { Workflow workflow = loadWorkflow("plan-dynamic.json"); registerStub("execute", "Dynamic execution complete"); registerStub( - "_planning_agent", + "planner", "[{\"tool\":\"test-tool\",\"arguments\":{\"action\":\"fetch\"},\"description\":\"Fetch" + " data\"}]"); @@ -132,7 +132,7 @@ void shouldPauseBeforeExecutionWhenPlanReviewRequired() { void shouldReplanAndCompleteAfterToolFailure() { Workflow workflow = loadWorkflow("plan-dynamic.json"); registerStub( - "_planning_agent", + "planner", "[{\"tool\":\"test-tool\",\"arguments\":{\"action\":\"fetch\"},\"description\":\"Fetch" + " data\"}]"); testActionHandler.enqueueResult(ActionResult.failure("first attempt failed"));