From 14382a2cf654c7ea09673392985d8b6922d29e9e Mon Sep 17 00:00:00 2001 From: Aleksandr Suvorov Date: Mon, 29 Jun 2026 01:25:25 +0400 Subject: [PATCH 1/2] feat(transition): bounded revise transitions with auto feedback injection MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add generic per-node retry budgeting and informed-backtrack semantics across all loop-prone triggers, plus automatic surfacing of prior feedback to the node that reruns. Transitions - Introduce BoundedTransition decorator: wraps any pure trigger rule with a namespaced retry budget and an escalation target. Namespaces are scoped per node and per trigger (approval, consensus, score, failure), fixing the prior global counter bug where sequential nodes shared one budget. - Add NoConsensusTransition so parallel-node consensus failure routes to the producer node for revision instead of re-running reviewers on the same input. - Add TransitionTargets / NodeTargets helpers and a requiredEngineVars() capability method on TransitionRule, removing instanceof introspection from the enricher and post-processor sites. - FailureTransition shrinks to a pure trigger; the retry-count increment moves out of evaluate() into TransitionPostProcessor. - DSL: one shared ReviseBuilder chain — `revise "producer" retry N otherwise "escalate"` — usable from onRejection, onNoConsensus, and score arms. The `retry` verb stays a blind self-loop; `revise` is an informed backtrack. - Serialization: recursive "bounded" / "noConsensus" rule types with round-trip tests. Feedback injection - Add FeedbackContextInjector as the first injector in EngineVariablePromptEnricher.DEFAULT. When the context carries a non-blank recommendation, it appends a "### Previous Feedback" section so the rerun node sees why it was sent back. - A backtracking revise arm preserves engine variables; a plain forward goto clears them unless marked withFeedback. Persistence - V3__add_retry_counters.sql adds the JSONB retry-counter column; JDBC repo read/write and WorkflowExecutionService constructor updated to match. Docs & housekeeping - Sync AGENTS.md, README, unified-architecture, core/dsl/server guides, and module READMEs with the new transition model, the 7-injector enricher order, and the corrected MCP split-pipe terminology (Hensu Server as MCP client; tenant client is a bridge to the tenant's own MCP servers, no inbound ports). - Refresh example workflows under working-dir to the revise DSL. Resolve: #73 Signed-off-by: Aleksandr Suvorov --- .claude/rules/10-java-standards.md | 1 + AGENTS.md | 3 +- README.md | 6 +- docs/developer-guide-core.md | 18 +- docs/developer-guide-serialization.md | 30 +- docs/developer-guide-server.md | 7 +- docs/dsl-reference.md | 126 ++++++- docs/unified-architecture.md | 82 ++++- .../cli/commands/WorkflowValidateCommand.java | 22 +- .../io/hensu/cli/review/ReviewTerminal.java | 2 +- .../MermaidVisualizationFormat.java | 133 +++++-- .../visualizer/TextVisualizationFormat.java | 147 +++++--- .../cli/review/CLIReviewHandlerTest.java | 2 +- .../MermaidVisualizationFormatTest.java | 11 +- .../TextVisualizationFormatTest.java | 7 +- hensu-core/README.md | 50 ++- .../hensu/core/execution/EngineVariables.java | 18 + .../enricher/ApprovalVariableInjector.java | 27 +- .../EngineVariablePromptEnricher.java | 16 +- .../enricher/FeedbackContextInjector.java | 55 +++ .../RecommendationVariableInjector.java | 36 +- .../enricher/ScoreVariableInjector.java | 26 +- .../enricher/TransitionVariableInjector.java | 42 +++ .../core/execution/executor/NodeResult.java | 2 +- .../executor/ParallelNodeExecutor.java | 35 +- .../executor/StandardNodeExecutor.java | 2 + .../OutputExtractionPostProcessor.java | 39 +-- .../pipeline/ReviewPostProcessor.java | 2 + .../pipeline/RubricPostProcessor.java | 17 +- .../pipeline/TransitionPostProcessor.java | 79 ++++- .../io/hensu/core/rubric/model/Rubric.java | 10 + .../io/hensu/core/state/HensuSnapshot.java | 5 + .../java/io/hensu/core/state/HensuState.java | 61 ++-- .../hensu/core/workflow/node/NodeTargets.java | 50 +++ .../workflow/transition/AlwaysTransition.java | 11 +- .../transition/ApprovalTransition.java | 19 +- .../transition/BoundedTransition.java | 73 ++++ .../transition/FailureTransition.java | 34 +- .../transition/NoConsensusTransition.java | 33 ++ .../transition/RubricFailTransition.java | 14 +- .../workflow/transition/ScoreTransition.java | 15 +- .../transition/SuccessTransition.java | 10 +- .../workflow/transition/TransitionRule.java | 65 +++- .../transition/TransitionTargets.java | 36 ++ .../validation/WorkflowValidator.java | 18 +- .../WorkflowExecutorActionNodeTest.java | 4 +- .../WorkflowExecutorForkJoinTest.java | 2 +- .../WorkflowExecutorGenericNodeTest.java | 2 +- .../WorkflowExecutorParallelTest.java | 9 +- .../execution/WorkflowExecutorRetryTest.java | 23 +- .../core/execution/WorkflowExecutorTest.java | 2 +- .../pipeline/RubricPostProcessorTest.java | 9 +- .../pipeline/TransitionPostProcessorTest.java | 328 +++++++++++++++--- .../hensu/core/state/HensuSnapshotTest.java | 1 + .../InMemoryWorkflowStateRepositoryTest.java | 1 + .../transition/TransitionRulesTest.java | 109 ++++-- hensu-dsl/README.md | 6 +- .../hensu/dsl/builders/ActionNodeBuilder.kt | 9 +- .../io/hensu/dsl/builders/ForkJoinBuilders.kt | 7 +- .../hensu/dsl/builders/GenericNodeBuilder.kt | 9 +- .../kotlin/io/hensu/dsl/builders/Models.kt | 4 +- .../hensu/dsl/builders/ParallelNodeBuilder.kt | 34 +- .../io/hensu/dsl/builders/RetryBuilder.kt | 6 +- .../io/hensu/dsl/builders/ReviseBuilder.kt | 109 ++++++ .../dsl/builders/ScoreConditionBuilder.kt | 62 +++- .../dsl/builders/ScoreTransitionBuilder.kt | 74 +++- .../hensu/dsl/builders/StandardNodeBuilder.kt | 50 ++- .../dsl/builders/SubWorkflowNodeBuilder.kt | 9 +- .../hensu/dsl/builders/TransitionBuilder.kt | 87 ++++- .../hensu/dsl/parsers/KotlinScriptParser.kt | 146 ++++++++ .../dsl/parsers/KotlinScriptParserTest.kt | 8 +- .../TransitionRuleDeserializer.java | 69 +++- .../TransitionRuleSerializer.java | 30 +- .../serialization/WorkflowSerializerTest.java | 104 +++++- hensu-server/README.md | 34 +- .../hensu/server/mcp/McpSessionManager.java | 4 +- .../JdbcWorkflowStateRepository.java | 31 +- .../workflow/WorkflowExecutionService.java | 1 + .../persistence/ExecutionLeaseTest.java | 1 + .../JdbcWorkflowStateRepositoryTest.java | 9 + .../workflow/ExecutionQueryServiceTest.java | 6 + .../workflow/ExecutionStateServiceTest.java | 1 + .../workflows/parallel-judge-decides.json | 4 +- .../workflows/parallel-majority-vote.json | 4 +- .../workflows/parallel-unanimous.json | 4 +- .../workflows/parallel-weighted-vote.json | 4 +- working-dir/workflows/backtrack-routing.kt | 6 +- working-dir/workflows/content-pipeline.kt | 11 +- working-dir/workflows/llm-evaluation.kt | 2 +- working-dir/workflows/scored-review.kt | 1 - working-dir/workflows/self-evaluation.kt | 19 +- working-dir/workflows/weighted-vote.kt | 3 +- 92 files changed, 2392 insertions(+), 563 deletions(-) create mode 100644 hensu-core/src/main/java/io/hensu/core/execution/enricher/FeedbackContextInjector.java create mode 100644 hensu-core/src/main/java/io/hensu/core/execution/enricher/TransitionVariableInjector.java create mode 100644 hensu-core/src/main/java/io/hensu/core/workflow/node/NodeTargets.java create mode 100644 hensu-core/src/main/java/io/hensu/core/workflow/transition/BoundedTransition.java create mode 100644 hensu-core/src/main/java/io/hensu/core/workflow/transition/NoConsensusTransition.java create mode 100644 hensu-core/src/main/java/io/hensu/core/workflow/transition/TransitionTargets.java create mode 100644 hensu-dsl/src/main/kotlin/io/hensu/dsl/builders/ReviseBuilder.kt diff --git a/.claude/rules/10-java-standards.md b/.claude/rules/10-java-standards.md index 1fd3e58..025f820 100644 --- a/.claude/rules/10-java-standards.md +++ b/.claude/rules/10-java-standards.md @@ -18,6 +18,7 @@ Generic SOLID/KISS/YAGNI/DRY apply silently. Only project-specific rules below. - Domain results → `sealed interface` (e.g. `ExecutionResult`, `TransitionStatus`), consumed via `switch` pattern matching. - All domain models immutable; construct via `hensu-serialization` builder mixins. +- Never `instanceof` on `TransitionRule` subtypes outside `io.hensu.core.workflow.transition`, the serializers, and the visualizers – consume `requiredEngineVars()` (engine-variable wiring) or `trigger()` (inner-rule evaluation/labeling) so decorators keep their semantics. ## Kotlin DSL diff --git a/AGENTS.md b/AGENTS.md index facfb8f..76c7a82 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -22,6 +22,7 @@ Hensu is a modular AI workflow engine on Java 25 + Kotlin DSL. Core design princ 3. **Sealed interfaces** for results: `ExecutionResult` → `Completed | Paused | Rejected | Failure | Success` 4. **Template resolution**: `{variable}` syntax in prompts, resolved via `SimpleTemplateResolver` 5. **`@DslMarker`** on Kotlin builders to prevent scope leakage +6. **Engine variables** (`score`, `approved`, `recommendation`): engine-managed, never declared in `writes()` or `state{}`. Surfaced to the next node by the `EngineVariablePromptEnricher` injectors (`FeedbackContextInjector` appends prior feedback as a `### Previous Feedback` section). A backtracking `revise` arm preserves them; a plain forward `goto` clears them unless marked `withFeedback`. ## Key Architectural Rules @@ -32,7 +33,7 @@ Hensu is a modular AI workflow engine on Java 25 + Kotlin DSL. Core design princ 5. **Server MCP-only**: server never executes bash locally, only MCP requests to external tools. 6. **Storage in core**: repository interfaces and in-memory defaults live in `hensu-core`. JDBC impls live in `hensu-server/persistence/` as plain classes (not CDI beans). `HensuEnvironmentProducer` conditionally wires JDBC vs in-memory. Server exposes core components via `@Produces @Singleton` — never instantiates directly. 7. **API separation**: `/api/v1/workflows` (definitions) and `/api/v1/executions` (runtime) are distinct resources. -8. **JWT authentication**: SmallRye JWT bearer auth. Tenant identity extracted from `tenant_id` claim via `RequestTenantResolver`. CLI sends `Authorization: Bearer ` via `--token` or `hensu.server.token` config. Dev/test mode disables auth (`hensu.tenant.default`). RSA keys live outside the repo (e.g. `~/.hensu/`). +8. **JWT authentication**: SmallRye JWT bearer auth. Tenant identity extracted from `tenant_id` claim via `RequestTenantResolver`. CLI sends `Authorization: Bearer ` via `--token` or `hensu.server.token` config. JWT is required in every profile **except `inmem`** (integration tests), which disables auth and uses `hensu.tenant.default`. RSA keys live outside the repo (e.g. `~/.hensu/`). ## CLI diff --git a/README.md b/README.md index 9d2a384..2779ba0 100644 --- a/README.md +++ b/README.md @@ -122,11 +122,11 @@ fun contentPipeline() = workflow("content-pipeline") { node("write") { agent = "writer" - prompt = "Write a short article about {topic}. {recommendation}" + prompt = "Write a short article about {topic}." writes("draft") rubric = "content-quality.md" onScore { - whenScore lessThan 70.0 goto "write" // score too low – retry + whenScore lessThan 70.0 goto "write" withFeedback // score too low – retry with feedback } onSuccess goto "review" } @@ -136,7 +136,7 @@ fun contentPipeline() = workflow("content-pipeline") { prompt = "Review this article: {draft}. Is it good enough to publish?" writes("draft") onApproval goto "done" - onRejection goto "write" // rejected – loop back + onRejection goto "write" withFeedback // rejected – loop back with feedback } end("done", ExitStatus.SUCCESS) diff --git a/docs/developer-guide-core.md b/docs/developer-guide-core.md index 7c6c781..8e0fc06 100644 --- a/docs/developer-guide-core.md +++ b/docs/developer-guide-core.md @@ -753,6 +753,10 @@ Rules: - Accepts `true`/`false` as Java `Boolean` or case-insensitive strings `"true"`/`"false"`. - `approved` is an **engine variable** — injected automatically when `onApproval`/`onRejection` routing is present. Never declare it in `writes()` or the state schema. +### Bounded Revise + +`BoundedTransition` decorates a trigger rule (`ApprovalTransition`, `NoConsensusTransition`, or a single-condition `ScoreTransition`) with a per-node retry budget and an escalation target. It backs the DSL `revise "producer" retry N otherwise "escalate"` form on `onRejection`, `onNoConsensus`, and score arms. Counters are namespaced per node and trigger kind in `HensuState`; `TransitionPostProcessor` increments on a backtrack and resets on any forward move. The decorator is transparent to engine-variable wiring — injectors and output extraction consume `TransitionRule.requiredEngineVars()` rather than `instanceof`, so a revise-only node still gets its `approved`/`score`/`recommendation` instructions. See the [DSL Reference](dsl-reference.md#bounded-revise-revise) for author-facing syntax. + ## State Schema `WorkflowStateSchema` is an optional typed declaration on a `Workflow` that lists all domain-specific @@ -856,19 +860,26 @@ class — the single source of truth for all engine-managed variable keys. ### Injection Pipeline -Each injector fires when **either** of two conditions is met: +`FeedbackContextInjector` runs first and is the odd one out: it surfaces *prior* feedback rather +than instructing the agent to produce a new variable. It appends a `### Previous Feedback` section +whenever the context carries a non-blank `recommendation` value (preserved across a backtrack +`revise` or a forward `withFeedback` transition — see `TransitionPostProcessor` for the lifecycle). + +Each remaining injector fires when **either** of two conditions is met: 1. The node has a matching transition rule (e.g., `ScoreTransition` for `ScoreVariableInjector`) 2. The execution context carries a `BranchExecutionConfig` where `needsSelfScoring()` returns true (consensus branch with a non-JUDGE_DECIDES strategy) ```mermaid flowchart LR - r(["RubricPromptInjector\n· rubric != null"]) --> s(["ScoreVariableInjector\n· ScoreTransition or\nconsensus branch"]) + fc(["FeedbackContextInjector\n· recommendation present"]) --> r(["RubricPromptInjector\n· rubric != null"]) + r --> s(["ScoreVariableInjector\n· ScoreTransition or\nconsensus branch"]) s --> a(["ApprovalVariableInjector\n· ApprovalTransition or\nconsensus branch"]) a --> rec(["RecommendationVariable\nInjector\n· Score/Approval or\nconsensus branch"]) rec --> w(["WritesVariableInjector\n· has writes()"]) w --> y(["YieldsVariableInjector\n· has yields()"]) + style fc fill:#2c2c2e, stroke:#48484a, color:#ebebf5, stroke-width:1px style r fill:#2c2c2e, stroke:#48484a, color:#ebebf5, stroke-width:1px style s fill:#2c2c2e, stroke:#48484a, color:#ebebf5, stroke-width:1px style a fill:#2c2c2e, stroke:#48484a, color:#ebebf5, stroke-width:1px @@ -901,6 +912,7 @@ Construct a custom enricher with additional injectors: ```java EngineVariablePromptEnricher enricher = new EngineVariablePromptEnricher( List.of( + new FeedbackContextInjector(), new RubricPromptInjector(), new ScoreVariableInjector(), new ApprovalVariableInjector(), @@ -1412,6 +1424,8 @@ Environment variables matching `*_API_KEY`, `*_KEY`, `*_SECRET`, or `*_TOKEN` pa | `workflow/state/StateVariableDeclaration.java` | Single variable declaration record (name, type, isInput) | | `workflow/state/VarType.java` | Variable type enum: STRING, NUMBER, BOOLEAN, LIST_STRING | | `workflow/transition/ApprovalTransition.java` | Boolean approval routing via the `approved` engine variable | +| `workflow/transition/NoConsensusTransition.java` | Routes when a parallel node fails to reach consensus | +| `workflow/transition/BoundedTransition.java` | Decorates a trigger with a per-node retry budget + escalation target (backs DSL `revise`) | | `workflow/validation/SubWorkflowGraphValidator.java` | Load-time cycle + dangling-reference detector for sub-workflow graphs | | `workflow/validation/WorkflowValidator.java` | Load-time validator for transition targets, `writes`, and prompt `{variable}` references | | `rubric/RubricEngine.java` | Quality evaluation engine | diff --git a/docs/developer-guide-serialization.md b/docs/developer-guide-serialization.md index 2b9e435..baf6015 100644 --- a/docs/developer-guide-serialization.md +++ b/docs/developer-guide-serialization.md @@ -239,25 +239,23 @@ Java records have no inner `Builder` class — Jackson deserializes them via the **Snapshot hierarchy** (embedded in `ExecutionStep`): -| Class | Embedded in | Reason | -|------------------------------------|----------------------------------|---------------------------------------------| -| `HensuSnapshot` | `ExecutionStep.Builder.snapshot` | Canonical constructor + component accessors | -| `PlanSnapshot` | `HensuSnapshot.planSnapshot` | Nested record inside `HensuSnapshot` | -| `PlanSnapshot.PlannedStepSnapshot` | `PlanSnapshot.steps()` | Nested record inside `PlanSnapshot` | -| `PlanSnapshot.StepResultSnapshot` | `PlanSnapshot.results()` | Nested record inside `PlanSnapshot` | +| Class | Embedded in | Reason | +|-----------------|----------------------------------|----------------------------------------------------------------------------| +| `HensuSnapshot` | `ExecutionStep.Builder.snapshot` | Canonical constructor + component accessors | +| `Plan` | `HensuSnapshot.activePlan` | Active micro-plan state; registered via the `treeToValue` exceptions above | **Parallel execution types** (manually deserialized in `NodeDeserializer`, but serialized via default Jackson `BeanSerializer` in `WorkflowSerializer.toJson()`): -| Class | Embedded in / Context | Reason | -|-----------------------------|-------------------------------------------------|-------------------------------------------------------------| -| `Branch` | `ParallelNode` branch list | Record with `yields` (`List`) component | -| `ConsensusConfig` | `ParallelNode` consensus configuration | Record with strategy enum + nullable threshold | -| `ConsensusStrategy` | `ConsensusConfig.strategy()` | Enum – Jackson needs reflection to serialize enum constants | -| `ConsensusResult` | State context map (checkpoint serialization) | Stored under `consensus_votes` key during execution | -| `ConsensusResult.Vote` | `ConsensusResult.votes()` map values | Inner record with vote type, score, weight | -| `ConsensusResult.VoteType` | `ConsensusResult.Vote.voteType()` | Enum (APPROVE / REJECT) | -| `ScoreCondition` | `ScoreTransition` condition list | Nested record inside transition rules | -| `DoubleRange` | `ScoreCondition.range()` | Nested record inside `ScoreCondition` | +| Class | Embedded in / Context | Reason | +|----------------------------|----------------------------------------------|---------------------------------------------------------------| +| `Branch` | `ParallelNode` branch list | Record with `yields` (`List`) component | +| `ConsensusConfig` | `ParallelNode` consensus configuration | Record with strategy enum + nullable threshold | +| `ConsensusStrategy` | `ConsensusConfig.strategy()` | Enum – Jackson needs reflection to serialize enum constants | +| `ConsensusResult` | State context map (checkpoint serialization) | Stored under `consensus_result:` key during execution | +| `ConsensusResult.Vote` | `ConsensusResult.votes()` map values | Inner record with vote type, score, weight | +| `ConsensusResult.VoteType` | `ConsensusResult.Vote.voteType()` | Enum (APPROVE / REJECT) | +| `ScoreCondition` | `ScoreTransition` condition list | Nested record inside transition rules | +| `DoubleRange` | `ScoreCondition.range()` | Nested record inside `ScoreCondition` | > **Why not `treeToValue`?** These types are simple records with primitive/string/enum fields. > Manual `JsonNode` extraction in `NodeDeserializer` avoids reflection during deserialization. diff --git a/docs/developer-guide-server.md b/docs/developer-guide-server.md index 7cf6687..1b97702 100644 --- a/docs/developer-guide-server.md +++ b/docs/developer-guide-server.md @@ -304,7 +304,6 @@ io.hensu.server/ │ ├── ValidMessageValidator # Size-limit + control-character validator │ ├── ValidWorkflow # Custom constraint for Workflow request bodies │ ├── ValidWorkflowValidator # Deep-validates workflow object graph -│ ├── LogSanitizer # Strips CR/LF for log injection prevention │ ├── IllegalArgumentExceptionMapper # Maps IllegalArgumentException → 400 │ └── ConstraintViolationExceptionMapper # Global 400 error mapper │ @@ -371,9 +370,7 @@ io.hensu.server/ │ └── InteractiveReviewHandler # @ApplicationScoped default ReviewHandler for plan reviews │ └── tenant/ # Multi-tenancy - ├── TenantContext # ScopedValue-based context - ├── TenantAware # Marker interface - └── TenantResolutionInterceptor + └── TenantContext # ScopedValue-based context ``` --- @@ -1420,7 +1417,7 @@ Registrations are split across five dedicated classes to keep each concern isola 4. **Simple immutable types with custom deser but default ser** — `WorkflowStateSchema` and `StateVariableDeclaration` use a custom deserializer (`WorkflowStateSchemaDeserializer`) that extracts fields manually. However, Jackson's default serializer reads `getVariables()` reflectively, so both classes must be registered. -5. **Record types embedded in builder classes** — When a `record` is a field inside a mixin-registered builder type, Jackson reaches it via its canonical constructor and component accessors. GraalVM cannot trace those calls statically. Register the record and every nested record transitively. No mixin or custom deserializer is needed — registration alone is sufficient. Current types: `ReviewConfig`, `HensuSnapshot`, `PlanSnapshot` hierarchy. +5. **Record types embedded in builder classes** — When a `record` is a field inside a mixin-registered builder type, Jackson reaches it via its canonical constructor and component accessors. GraalVM cannot trace those calls statically. Register the record and every nested record transitively. No mixin or custom deserializer is needed — registration alone is sufficient. Current types: `ReviewConfig`, `HensuSnapshot`, and `Plan` (the active micro-plan embedded in `HensuSnapshot.activePlan`). **When to add vs. fix:** if the class is a simple record with no `Duration`/nested-complex fields, fix the deserializer. If it contains `Duration` or deeply nested types, add it here. For records embedded in builder types, always register them. For types in pattern 3, registration is needed only because the serialization path uses default Jackson. See [hensu-serialization Developer Guide](developer-guide-serialization.md#the-treetovalue-rule) for the full rule. diff --git a/docs/dsl-reference.md b/docs/dsl-reference.md index 485aece..2751de9 100644 --- a/docs/dsl-reference.md +++ b/docs/dsl-reference.md @@ -17,6 +17,8 @@ This document provides a complete reference for the Hensu Kotlin DSL used to def - [Sub-Workflow Node](#sub-workflow-node) - [End Node](#end-node) - [Transitions](#transitions) + - [Bounded Revise (`revise`)](#bounded-revise-revise) + - [Feedback Preservation (`withFeedback`)](#feedback-preservation-withfeedback) - [State Variables (`writes`)](#state-variables-writes) - [State Schema](#state-schema) - [Engine Variables](#engine-variables) @@ -172,13 +174,14 @@ node("node-id") { #### Standard Node Functions -| Function | Description | -|----------------------|------------------------------------------------------------------------------------------------------------| -| `writes("a", "b")` | Declares state variables this node produces. Single name: full text stored. Multiple: JSON keys extracted. | -| `review(mode)` | Configures human review checkpoint | -| `onApproval goto` | Routes when the `approved` engine variable is `true`. Falls through if absent or non-boolean. | -| `onRejection goto` | Routes when the `approved` engine variable is `false`. Falls through if absent or non-boolean. | -| `onPlanFailure goto` | Routes to a fallback node when plan execution fails (planning nodes only) | +| Function | Description | +|----------------------|------------------------------------------------------------------------------------------------------------------| +| `writes("a", "b")` | Declares state variables this node produces. Single name: full text stored. Multiple: JSON keys extracted. | +| `review(mode)` | Configures human review checkpoint | +| `onApproval goto` | Routes when the `approved` engine variable is `true`. Falls through if absent or non-boolean. | +| `onRejection goto` | Routes when the `approved` engine variable is `false`. Falls through if absent or non-boolean. | +| `onRejection revise` | Backtracks to a producer with a bounded retry budget on rejection. See [Bounded Revise](#bounded-revise-revise). | +| `onPlanFailure goto` | Routes to a fallback node when plan execution fails (planning nodes only) | ### Parallel Node @@ -553,6 +556,8 @@ onScore { } ``` +Any arm may use `revise` instead of `goto` to backtrack to a producer with a bounded retry budget — see [Bounded Revise](#bounded-revise-revise). + ### Score Operators | Operator | Description | @@ -579,6 +584,83 @@ node("classify") { Falls through (no match) if the `approved` key is absent or not a boolean. Both transitions are optional — you can use only `onApproval`, only `onRejection`, or combine with `onScore`. +### Bounded Revise (`revise`) + +A `revise` transition backtracks to a producer node to fix its output, but bounds the loop with a retry budget. While the budget is unspent the workflow re-runs the producer; once the budget is exhausted it escalates to a fallback node instead of looping forever. + +```kotlin +revise "producer-node" retry 3 otherwise "escalate-node" +``` + +| Segment | Meaning | +|---------------------|------------------------------------------------------------------------| +| `revise "producer"` | Node to re-execute on each attempt (the backtrack target) | +| `retry N` | Maximum attempts before escalation | +| `otherwise "node"` | Escalation target once the budget is exhausted | + +`revise` is available on three triggers, each tracking its own per-node retry budget: + +```kotlin +// Approval (standard node): reviewer keeps rejecting → re-run the producer, then escalate +onRejection revise "write" retry 3 otherwise "escalate" + +// No consensus (parallel node): branches disagree → re-run the producer, then escalate +onNoConsensus revise "draft" retry 3 otherwise "manual-review" + +// Score (standard node): a low-scoring arm backtracks instead of routing forward +onScore { + whenScore greaterThanOrEqual 70.0 goto "publish" + whenScore lessThan 70.0 revise "write" retry 3 otherwise "escalate" +} +``` + +The retry budget is namespaced per node and per trigger kind (`approval`, `consensus`, `score`), so a node carrying more than one `revise` rule tracks each independently. Counters reset when the node transitions forward (any non-revise move). + +Backtracking always forwards the `recommendation` feedback to the producer (see [Feedback Preservation](#feedback-preservation-withfeedback) and [Engine Variables](#engine-variables)) — that is the point of a revise loop. Contrast with `onFailure retry N otherwise` (blind self-loop on application failure, no backtrack, no feedback). + +### Feedback Preservation (`withFeedback`) + +By default, forward transitions clear all engine variables (`score`, `approved`, `recommendation`) from the state context. Append `withFeedback` to a transition to keep the `recommendation` value so the target node sees evaluation feedback from the previous node. + +```kotlin +// Score-based: writer sees why it scored low +onScore { + whenScore lessThan 70.0 goto "write" withFeedback + whenScore greaterThanOrEqual 70.0 goto "publish" +} + +// Approval-based: rejected node sees reviewer reasoning +onRejection goto "revise" withFeedback + +// Success: forward feedback to the next node +onSuccess goto "next" withFeedback +``` + +`withFeedback` is available on all transition types except `onFailure` (application exceptions produce no agent feedback). + +#### With `revise` (bounded retry) + +Backtrack transitions from `revise` always preserve recommendation — that is the purpose of retry loops. The `withFeedback` keyword on `revise` itself is accepted as a no-op so users do not need to remember the distinction: + +```kotlin +// These are equivalent — backtrack always preserves feedback +onRejection revise "write" retry 3 otherwise "escalate" +onRejection revise "write" withFeedback retry 3 otherwise "escalate" +``` + +To preserve feedback on the **escalation** edge (when retries are exhausted), append `withFeedback` after `otherwise`: + +```kotlin +onRejection revise "write" retry 3 otherwise "escalate" withFeedback +``` + +Both positions are independent and composable: + +```kotlin +onRejection revise "write" withFeedback retry 3 otherwise "escalate" withFeedback +// ↑ no-op (backtrack already preserves) ↑ meaningful (escalation preserves) +``` + ### Consensus Transitions (Parallel Nodes) ```kotlin @@ -586,6 +668,12 @@ onConsensus goto "approved" onNoConsensus goto "rejected" ``` +`onNoConsensus` also accepts `revise` to backtrack to a producer with a bounded retry budget instead of routing forward — see [Bounded Revise](#bounded-revise-revise): + +```kotlin +onNoConsensus revise "draft" retry 3 otherwise "manual-review" +``` + ### Fork Completion Transition ```kotlin @@ -688,11 +776,13 @@ workflow("ContentPipeline") { Engine variables are managed entirely by the Hensu engine — they are injected automatically into the LLM prompt and extracted from the JSON response. They are **always implicitly valid** in `{placeholder}` references. Do **not** declare them in `state { }` or `writes()`. -| Variable | Type | When present | Description | -|------------------|---------|-------------------------------------------------------------------|--------------------------------------------------------------------------------------------------| -| `score` | Number | Node has `onScore { }` routing (rubric-evaluated or self-scoring) | Quality score 0–100. Drives `onScore` transitions. | -| `approved` | Boolean | Node has `onApproval` / `onRejection` routing | `true` = approved, `false` = rejected. Falls through if absent. | -| `recommendation` | String | Node has `onScore { }` or `onApproval` / `onRejection` routing | Improvement feedback or review reasoning. Available as `{recommendation}` in downstream prompts. | +| Variable | Type | When present | Description | +|------------------|---------|-------------------------------------------------------------------|--------------------------------------------------------------------------------------------------------------------------| +| `score` | Number | Node has `onScore { }` routing (rubric-evaluated or self-scoring) | Quality score 0–100. Drives `onScore` transitions. | +| `approved` | Boolean | Node has `onApproval` / `onRejection` routing | `true` = approved, `false` = rejected. Falls through if absent. | +| `recommendation` | String | Node has `onScore { }` or `onApproval` / `onRejection` routing | Improvement feedback or review reasoning. Auto-surfaced to the next node when preserved across a transition (see below). | + +The "When present" column lists the **standard-node** routing that drives injection. On a **parallel node**, all three are produced instead by branch self-scoring on vote-based strategies (`MAJORITY_VOTE`, `WEIGHTED_VOTE`, `UNANIMOUS`) — never under `JUDGE_DECIDES`, regardless of the `onConsensus` / `onNoConsensus` transition declared. See [Rubric-Based Consensus](#rubric-based-consensus). ### How engine variables flow @@ -712,9 +802,9 @@ flowchart TD linkStyle default stroke:#0A84FF, stroke-width:1px ``` -### Using `{recommendation}` in downstream prompts +### Feedback reaches the next node automatically -Because `recommendation` is an engine variable, you reference it as a `{placeholder}` without declaring it anywhere: +When a `recommendation` value is preserved across a transition (any backtrack `revise`, or a forward move marked [`withFeedback`](#feedback-preservation-withfeedback)), the engine appends it to the target node's prompt as a `### Previous Feedback` section before execution. The producer sees the prior feedback with no prompt changes on your part: ```kotlin node("score-content") { @@ -724,16 +814,14 @@ node("score-content") { onScore { whenScore greaterThanOrEqual 80.0 goto "publish" - whenScore lessThan 80.0 goto "revise" + whenScore lessThan 80.0 revise "revise" retry 3 otherwise "escalate" } } node("revise") { agent = "writer" - prompt = """ - Revise the article based on this feedback: {recommendation} - Original article: {article} - """.trimIndent() + // The engine appends the prior feedback as a "### Previous Feedback" section automatically. + prompt = "Revise the article: {article}" writes("article") onSuccess goto "score-content" diff --git a/docs/unified-architecture.md b/docs/unified-architecture.md index da4364d..bbdcddf 100644 --- a/docs/unified-architecture.md +++ b/docs/unified-architecture.md @@ -86,10 +86,51 @@ The server is a **pure orchestrator** — it has no shell, no `eval`, no script (tool calls, database writes, API requests) are routed to tenant-owned MCP servers via the **Split-Pipe** transport: -- **Downstream (SSE):** The server pushes JSON-RPC tool requests to the connected tenant client. -- **Upstream (HTTP POST):** The client executes the tool locally and returns the result. +- **Downstream (SSE):** The Hensu server pushes each JSON-RPC tool request — tagged with a unique request id — over the tenant client's open `/mcp/connect` stream. +- **Upstream (HTTP POST):** The tenant client relays the request to its own MCP servers, then posts the JSON-RPC result to `/mcp/message`. `McpSessionManager` correlates the response by request id, completing the matching pending future (60 s timeout; futures are cancelled if the client disconnects). -This means tenant clients connect *outbound* — no inbound ports, no firewall rules, no VPN. +```mermaid +flowchart LR + subgraph client["Tenant Client"] + direction TB + es(["EventSource\nGET /mcp/connect"]) + post(["POST /mcp/message"]) + end + + subgraph server["Hensu Server"] + direction TB + send(["sendRequest()\n· new request id\n· pending future"]) + handle(["handleResponse()\n· match id → complete future"]) + end + + subgraph backends["Tenant MCP Servers"] + direction TB + tools(["Tools · Data · Auth"]) + end + + es -->|"① opens SSE stream — outbound"| send + send -->|"② tools/call + id — pushed over open stream"| es + post -->|"③ POST result + id — outbound"| handle + es -.->|"relay"| tools + tools -.->|"result"| post + + style client fill:#2c2c2e, stroke:#3a3a3c, color:#ebebf5, stroke-width:1px + style server fill:#2c2c2e, stroke:#3a3a3c, color:#ebebf5, stroke-width:1px + style backends fill:#2c2c2e, stroke:#3a3a3c, color:#ebebf5, stroke-width:1px + style es fill:#2c2c2e, stroke:#0A84FF, color:#ebebf5, stroke-width:1px + style post fill:#2c2c2e, stroke:#0A84FF, color:#ebebf5, stroke-width:1px + style send fill:#2c2c2e, stroke:#48484a, color:#ebebf5, stroke-width:1px + style handle fill:#2c2c2e, stroke:#48484a, color:#ebebf5, stroke-width:1px + style tools fill:#2c2c2e, stroke:#48484a, color:#ebebf5, stroke-width:1px + + linkStyle 0,2 stroke:#0A84FF, stroke-width:1px + linkStyle 1,3,4 stroke:#48484a, stroke-width:1px +``` + +Both pipes (①, ③) are **opened by the tenant client** — the accent arrows point *into* Hensu. +The only server→client traffic (②) is tool-call data pushed back over the SSE stream the client +already opened. Hensu never initiates a connection to the tenant, so tenants expose **no inbound +ports, no firewall rules, no VPN**. The server never sees raw credentials or executes user-supplied code. LLM output is treated with equal suspicion — `AgentOutputValidator` sanitizes all agent responses for control characters, Unicode manipulation, and excessive payload size before the output is written to workflow state. @@ -98,16 +139,16 @@ Unicode manipulation, and excessive payload size before the output is written to Workflows are not limited to linear chains. The graph engine supports: -| Capability | Mechanism | -|:--------------------------|:------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| -| **Conditional branching** | `ScoreTransition` routes on rubric scores; `SuccessTransition` / `FailureTransition` route on result; `AlwaysTransition` for unconditional hops; `RubricFailTransition` for rubric-specific failure; `ApprovalTransition` for review-gated routing | -| **Loops** | `LoopNode` with configurable break conditions and max iterations | -| **Parallel fan-out** | `ParallelNode` executes branches concurrently on virtual threads | -| **Fork / Join** | `ForkNode` spawns independent parallel paths; `JoinNode` awaits and merges results | -| **Consensus** | Majority vote, unanimous, weighted vote, or judge-decides strategies. Branches declare domain output via `yields()`. Vote strategies merge all branch yields; JUDGE_DECIDES merges only the winning branch's yields | -| **Backtracking** | Review decisions can jump to any previous node, restoring state from execution history | -| **Sub-workflows** | `SubWorkflowNode` delegates to another workflow by id with input/output mapping; `SubWorkflowGraphValidator` rejects cycles and dangling refs at push, `SubWorkflowNodeExecutor.MAX_DEPTH = 16` bounds recursion, `_tenant_id` propagates into the child | -| **Pause / Resume** | Any node returning `PENDING` checkpoints state (including `PlanSnapshot` — micro-plan step index — alongside node position); `executeFrom()` resumes from snapshot. The SSE stream is closed on pause (reviews may take days); clients re-subscribe after submitting a resume | +| Capability | Mechanism | +|:--------------------------|:---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| +| **Conditional branching** | `ScoreTransition` routes on rubric scores; `SuccessTransition` / `FailureTransition` route on result; `NoConsensusTransition` routes when a parallel node misses consensus; `AlwaysTransition` for unconditional hops; `RubricFailTransition` for rubric-specific failure; `ApprovalTransition` for review-gated routing; `BoundedTransition` decorates any trigger with a per-node retry budget + escalation (the `revise` mechanism) | +| **Loops** | `LoopNode` with configurable break conditions and max iterations | +| **Parallel fan-out** | `ParallelNode` executes branches concurrently on virtual threads | +| **Fork / Join** | `ForkNode` spawns independent parallel paths; `JoinNode` awaits and merges results | +| **Consensus** | Majority vote, unanimous, weighted vote, or judge-decides strategies. Branches declare domain output via `yields()`. Vote strategies merge all branch yields; JUDGE_DECIDES merges only the winning branch's yields | +| **Backtracking** | Review decisions can jump to any previous node, restoring state from execution history | +| **Sub-workflows** | `SubWorkflowNode` delegates to another workflow by id with input/output mapping; `SubWorkflowGraphValidator` rejects cycles and dangling refs at push, `SubWorkflowNodeExecutor.MAX_DEPTH = 16` bounds recursion, `_tenant_id` propagates into the child | +| **Pause / Resume** | Any node returning `PENDING` checkpoints state (including the active `Plan` — micro-plan step index — alongside node position); `executeFrom()` resumes from snapshot. The SSE stream is closed on pause (reviews may take days); clients re-subscribe after submitting a resume | For non-agent steps, `GenericNode` runs custom synchronous logic registered by `executorType`; `ActionNode` dispatches asynchronous tasks to external systems via a registered `ActionHandler` @@ -149,8 +190,9 @@ Node outputs can be evaluated against markdown rubric definitions before the wor engine variable written directly to context by the agent's synthesis step — no JSON parsing required. `ScoreTransition` rules route based on thresholds, enabling self-correcting loops where low-scoring outputs are sent back for revision. The evaluator also accumulates feedback into the `recommendation` -engine variable, which `RecommendationVariableInjector` injects into the next agent's prompt -automatically when a `ScoreTransition` or `ApprovalTransition` is present on the node. +engine variable; `FeedbackContextInjector` then surfaces it to the next agent automatically as a +`### Previous Feedback` section whenever the feedback is preserved across the transition (backtracking +`revise` arms preserve it; a plain forward `goto` clears it unless marked `withFeedback`). ### 7. Storage Architecture @@ -333,9 +375,11 @@ Zero-dependency Java library. Contains: from context; accumulates feedback into `recommendation`; no JSON parsing - `EngineVariables` — SSOT for engine variable names (`score`, `approved`, `recommendation`) - `AgentLifecycleRunner` — Composition-based agent call: prompt enrichment → execution → output extraction -- `EngineVariablePromptEnricher` — Composite enricher running 6 injectors before each agent call: - `RubricPromptInjector` → `ScoreVariableInjector` → `ApprovalVariableInjector` → - `RecommendationVariableInjector` → `WritesVariableInjector` → `YieldsVariableInjector`. +- `EngineVariablePromptEnricher` — Composite enricher running 7 injectors before each agent call: + `FeedbackContextInjector` → `RubricPromptInjector` → `ScoreVariableInjector` → + `ApprovalVariableInjector` → `RecommendationVariableInjector` → `WritesVariableInjector` → + `YieldsVariableInjector`. `FeedbackContextInjector` runs first and surfaces preserved feedback + as a `### Previous Feedback` section. Score/Approval/Recommendation injectors fire for both transition-based nodes and consensus branches (via `BranchExecutionConfig.needsSelfScoring()`) - `WorkflowRepository` / `WorkflowStateRepository` — Tenant-scoped storage interfaces with in-memory defaults @@ -793,7 +837,7 @@ The unified architecture provides: 5. **Non-Linear Graphs** — Loops, conditional branches, fork/join, parallel fan-out with consensus, backtracking 6. **Structured Concurrency** — `StructuredTaskScope` (preview) for all parallel execution; no `ExecutorService`, no thread pool lifecycle 7. **Rubric Evaluation** — Quality gates that score outputs and route on thresholds for self-correcting loops -8. **Pause / Resume** — Workflows checkpoint at any node (including micro-plan step index via `PlanSnapshot`) and resume; the lease protocol protects against data races when the owning node crashes +8. **Pause / Resume** — Workflows checkpoint at any node (including micro-plan step index via the active `Plan`) and resume; the lease protocol protects against data races when the owning node crashes 9. **Distributed Recovery** — Heartbeat/sweeper lease protocol for crashed-node detection; atomic PostgreSQL `UPDATE…RETURNING` claim 10. **Sub-Workflows** — Hierarchical composition via `SubWorkflowNode` with input/output mapping; `SubWorkflowGraphValidator` rejects cycles and dangling refs at push (under `WorkflowPushLock`); recursion bounded by `MAX_DEPTH = 16`; tenant isolation preserved across the boundary via `_tenant_id` propagation 11. **Flexible Planning** — Static (predefined) or Dynamic (LLM-generated) execution plans within nodes diff --git a/hensu-cli/src/main/java/io/hensu/cli/commands/WorkflowValidateCommand.java b/hensu-cli/src/main/java/io/hensu/cli/commands/WorkflowValidateCommand.java index ec9d680..e48a190 100644 --- a/hensu-cli/src/main/java/io/hensu/cli/commands/WorkflowValidateCommand.java +++ b/hensu-cli/src/main/java/io/hensu/cli/commands/WorkflowValidateCommand.java @@ -2,11 +2,7 @@ import io.hensu.core.workflow.Workflow; import io.hensu.core.workflow.node.Node; -import io.hensu.core.workflow.node.StandardNode; -import io.hensu.core.workflow.transition.FailureTransition; -import io.hensu.core.workflow.transition.ScoreTransition; -import io.hensu.core.workflow.transition.SuccessTransition; -import io.hensu.core.workflow.transition.TransitionRule; +import io.hensu.core.workflow.node.NodeTargets; import java.util.*; import java.util.stream.Collectors; import picocli.CommandLine; @@ -62,8 +58,9 @@ protected void execute() { /// Finds nodes that are not reachable from the start node via any transition path. /// - /// Performs breadth-first traversal from the start node following all transition types - /// (success, failure, score). Nodes not visited are considered unreachable. + /// Performs breadth-first traversal from the start node following every node's successors + /// (transition rules plus fork and loop-break targets). Nodes not visited are considered + /// unreachable. /// /// @param workflow the workflow to analyze, not null /// @return list of unreachable node IDs (empty if all nodes are reachable) @@ -78,15 +75,8 @@ private List findUnreachableNodes(Workflow workflow) { reachable.add(nodeId); Node node = workflow.getNodes().get(nodeId); - if (node instanceof StandardNode standardNode) { - for (TransitionRule rule : standardNode.getTransitionRules()) { - if (rule instanceof SuccessTransition st) queue.add(st.getTargetNode()); - else if (rule instanceof FailureTransition ft) - queue.add(ft.getThenTargetNode()); - else if (rule instanceof ScoreTransition sct) { - sct.getConditions().forEach(c -> queue.add(c.getTargetNode())); - } - } + if (node != null) { + queue.addAll(NodeTargets.of(node)); } } diff --git a/hensu-cli/src/main/java/io/hensu/cli/review/ReviewTerminal.java b/hensu-cli/src/main/java/io/hensu/cli/review/ReviewTerminal.java index 4659090..ab6b8d5 100644 --- a/hensu-cli/src/main/java/io/hensu/cli/review/ReviewTerminal.java +++ b/hensu-cli/src/main/java/io/hensu/cli/review/ReviewTerminal.java @@ -229,7 +229,7 @@ private ReviewDecision handleBacktrack(ReviewData data) { print("Reason for backtracking (optional): "); String reason = readInput(); - if (reason == null || reason.isBlank()) reason = "Manual backtrack by reviewer"; + if (reason == null || reason.isBlank()) reason = "Reviewer backtrack"; Map editedContext = null; if (data.contextVariables() != null) { diff --git a/hensu-cli/src/main/java/io/hensu/cli/visualizer/MermaidVisualizationFormat.java b/hensu-cli/src/main/java/io/hensu/cli/visualizer/MermaidVisualizationFormat.java index f8e9686..75a4ee2 100644 --- a/hensu-cli/src/main/java/io/hensu/cli/visualizer/MermaidVisualizationFormat.java +++ b/hensu-cli/src/main/java/io/hensu/cli/visualizer/MermaidVisualizationFormat.java @@ -285,29 +285,7 @@ private static List> bfsOrder(Workflow workflow) { if (node == null) continue; ordered.put(nodeId, node); - for (TransitionRule rule : node.getTransitionRules()) { - switch (rule) { - case SuccessTransition s -> queue.add(s.getTargetNode()); - case FailureTransition f -> queue.add(f.getThenTargetNode()); - case ScoreTransition sc -> { - for (var cond : sc.getConditions()) { - queue.add(cond.getTargetNode()); - } - } - case ApprovalTransition a -> queue.add(a.targetNode()); - default -> {} - } - } - - switch (node) { - case LoopNode ln when ln.getBreakRules() != null -> { - for (BreakRule br : ln.getBreakRules()) { - queue.add(br.getTargetNode()); - } - } - case ForkNode fn -> queue.addAll(fn.getTargets()); - default -> {} - } + queue.addAll(NodeTargets.of(node)); } return new ArrayList<>(ordered.entrySet()); @@ -381,15 +359,23 @@ private void renderEdgesInternal( private void renderTransitionRules( StringBuilder sb, String fromId, List rules, String workflowPrefix) { for (TransitionRule rule : rules) { - if (rule instanceof SuccessTransition success) { + if (rule instanceof BoundedTransition bounded) { + renderBoundedTransition(sb, fromId, bounded, workflowPrefix); + } else if (rule instanceof SuccessTransition success) { String toId = resolveTargetId(success.getTargetNode(), workflowPrefix); sb.append(" ").append(fromId).append(" --> ").append(toId).append("\n"); - } else if (rule instanceof FailureTransition failure) { - String toId = resolveTargetId(failure.getThenTargetNode(), workflowPrefix); - String label = - failure.getRetryCount() > 0 - ? "retry " + failure.getRetryCount() - : "failure"; + } else if (rule instanceof FailureTransition(String node)) { + if (node != null) { + String toId = resolveTargetId(node, workflowPrefix); + sb.append(" ") + .append(fromId) + .append(" -.->|failure| ") + .append(toId) + .append("\n"); + } + } else if (rule instanceof NoConsensusTransition(String node, boolean fb)) { + String toId = resolveTargetId(node, workflowPrefix); + String label = fb ? "no consensus · fb" : "no consensus"; sb.append(" ") .append(fromId) .append(" -.->|") @@ -412,9 +398,12 @@ private void renderTransitionRules( .append(toId) .append("\n"); } - } else if (rule instanceof ApprovalTransition(boolean expected, String targetNode)) { + } else if (rule + instanceof + ApprovalTransition(boolean expected, String targetNode, boolean fb)) { String toId = resolveTargetId(targetNode, workflowPrefix); - String label = expected ? "approved" : "rejected"; + String base = expected ? "approved" : "rejected"; + String label = fb ? base + " · fb" : base; sb.append(" ") .append(fromId) .append(" -->|") @@ -426,6 +415,86 @@ private void renderTransitionRules( } } + private void renderBoundedTransition( + StringBuilder sb, String fromId, BoundedTransition bounded, String workflowPrefix) { + TransitionRule inner = bounded.trigger(); + String budgetLabel = " ≤" + bounded.budget(); + + // Inner target edge with budget annotation + if (inner instanceof FailureTransition(String node)) { + if (node != null) { + String toId = resolveTargetId(node, workflowPrefix); + sb.append(" ") + .append(fromId) + .append(" -.->|retry") + .append(budgetLabel) + .append("| ") + .append(toId) + .append("\n"); + } else { + // Self-loop retry + sb.append(" ") + .append(fromId) + .append(" -.->|retry") + .append(budgetLabel) + .append("| ") + .append(fromId) + .append("\n"); + } + } else if (inner instanceof NoConsensusTransition(String node, boolean fb)) { + String toId = resolveTargetId(node, workflowPrefix); + String fbSuffix = fb ? " · fb" : ""; + sb.append(" ") + .append(fromId) + .append(" -.->|no consensus · revise") + .append(budgetLabel) + .append(fbSuffix) + .append("| ") + .append(toId) + .append("\n"); + } else if (inner + instanceof ApprovalTransition(boolean expected, String targetNode, boolean fb)) { + String toId = resolveTargetId(targetNode, workflowPrefix); + String prefix = expected ? "approved" : "rejected"; + String fbSuffix = fb ? " · fb" : ""; + sb.append(" ") + .append(fromId) + .append(" -->|") + .append(prefix) + .append(" · revise") + .append(budgetLabel) + .append(fbSuffix) + .append("| ") + .append(toId) + .append("\n"); + } else if (inner instanceof ScoreTransition score) { + for (ScoreCondition cond : score.getConditions()) { + String toId = resolveTargetId(cond.getTargetNode(), workflowPrefix); + String condLabel = + cond.getOperator() == ComparisonOperator.RANGE && cond.range() != null + ? "score " + cond.range().start() + "–" + cond.range().end() + : "score " + cond.getOperator() + " " + cond.getValue(); + sb.append(" ") + .append(fromId) + .append(" -->|") + .append(condLabel) + .append(" · revise") + .append(budgetLabel) + .append("| ") + .append(toId) + .append("\n"); + } + } + + // Escalation edge + String escalationId = resolveTargetId(bounded.otherwise(), workflowPrefix); + sb.append(" ") + .append(fromId) + .append(" -.->|budget exhausted| ") + .append(escalationId) + .append("\n"); + } + private static String resolveTargetId(String nodeId, String workflowPrefix) { if (workflowPrefix != null) { return namespacedId(workflowPrefix, nodeId); diff --git a/hensu-cli/src/main/java/io/hensu/cli/visualizer/TextVisualizationFormat.java b/hensu-cli/src/main/java/io/hensu/cli/visualizer/TextVisualizationFormat.java index 36b1eca..a7cd301 100644 --- a/hensu-cli/src/main/java/io/hensu/cli/visualizer/TextVisualizationFormat.java +++ b/hensu-cli/src/main/java/io/hensu/cli/visualizer/TextVisualizationFormat.java @@ -76,7 +76,7 @@ public String render(Workflow workflow, Map subWorkflows, bool sb.append(styles.gray("─".repeat(50))).append(System.lineSeparator()); sb.append(System.lineSeparator()); - renderWorkflowNodes(sb, workflow, subWorkflows, 0, styles); + renderWorkflowNodes(sb, workflow, subWorkflows, styles); return sb.toString(); } @@ -96,11 +96,10 @@ private void renderWorkflowNodes( StringBuilder sb, Workflow workflow, Map subWorkflows, - int baseLevel, AnsiStyles styles) { Set visited = new HashSet<>(); Deque queue = new ArrayDeque<>(); - queue.add(new NodeLevel(workflow.getStartNode(), baseLevel)); + queue.add(new NodeLevel(workflow.getStartNode(), 0)); while (!queue.isEmpty()) { NodeLevel current = queue.removeFirst(); @@ -139,7 +138,7 @@ private void renderSubWorkflowBlock( // Render sub-workflow content into a buffer StringBuilder content = new StringBuilder(); - renderWorkflowNodes(content, subWorkflow, subWorkflows, 0, styles); + renderWorkflowNodes(content, subWorkflow, subWorkflows, styles); String contentStr = content.toString().stripTrailing(); // Top border with label @@ -278,6 +277,7 @@ private String renderNode(Node node, String nodeId, String indent, AnsiStyles st .getStrategy() .toString()))); } + appendTransitions(sb, indent, parallelNode.getTransitionRules(), styles); } case ForkNode forkNode -> { sb.append( @@ -421,15 +421,14 @@ private String renderNode(Node node, String nodeId, String indent, AnsiStyles st } private void appendTransitions( - StringBuilder sb, - String indent, - java.util.List rules, - AnsiStyles styles) { + StringBuilder sb, String indent, List rules, AnsiStyles styles) { if (rules.isEmpty()) { return; } for (TransitionRule rule : rules) { - if (rule instanceof SuccessTransition success) { + if (rule instanceof BoundedTransition bounded) { + appendBoundedTransition(sb, indent, bounded, styles); + } else if (rule instanceof SuccessTransition success) { sb.append( String.format( "%s%s %s %-14s %s%n", @@ -438,15 +437,27 @@ private void appendTransitions( styles.arrow(), styles.bold(success.getTargetNode()), styles.dim("on success"))); - } else if (rule instanceof FailureTransition failure) { + } else if (rule instanceof FailureTransition(String node)) { + if (node != null) { + sb.append( + String.format( + "%s%s %s %-14s %s%n", + indent, + styles.boxMid(), + styles.arrow(), + styles.bold(node), + styles.dim("on failure"))); + } + } else if (rule instanceof NoConsensusTransition(String node, boolean fb)) { + String label = fb ? "on no consensus +fb" : "on no consensus"; sb.append( String.format( "%s%s %s %-14s %s%n", indent, styles.boxMid(), styles.arrow(), - styles.bold(failure.getThenTargetNode()), - styles.dim("on failure retry " + failure.getRetryCount()))); + styles.bold(node), + styles.dim(label))); } else if (rule instanceof ScoreTransition score) { for (ScoreCondition cond : score.getConditions()) { String condValue = @@ -462,8 +473,11 @@ private void appendTransitions( styles.bold(cond.getTargetNode()), styles.dim("score " + cond.getOperator() + " " + condValue))); } - } else if (rule instanceof ApprovalTransition(boolean expected, String targetNode)) { - String label = expected ? "on approval" : "on rejection"; + } else if (rule + instanceof + ApprovalTransition(boolean expected, String targetNode, boolean fb)) { + String base = expected ? "on approval" : "on rejection"; + String label = fb ? base + " +fb" : base; sb.append( String.format( "%s%s %s %-14s %s%n", @@ -476,6 +490,77 @@ private void appendTransitions( } } + private void appendBoundedTransition( + StringBuilder sb, String indent, BoundedTransition bounded, AnsiStyles styles) { + TransitionRule inner = bounded.trigger(); + String budget = "≤" + bounded.budget(); + + if (inner instanceof FailureTransition(String node)) { + String target = node != null ? node : "(self)"; + sb.append( + String.format( + "%s%s %s %-14s %s%n", + indent, + styles.boxMid(), + styles.arrow(), + styles.bold(target), + styles.dim("on failure retry " + budget))); + } else if (inner instanceof NoConsensusTransition(String node, boolean fb)) { + String fbSuffix = fb ? " +fb" : ""; + sb.append( + String.format( + "%s%s %s %-14s %s%n", + indent, + styles.boxMid(), + styles.arrow(), + styles.bold(node), + styles.dim("on no consensus revise " + budget + fbSuffix))); + } else if (inner + instanceof ApprovalTransition(boolean expected, String targetNode, boolean fb)) { + String prefix = expected ? "on approval" : "on rejection"; + String fbSuffix = fb ? " +fb" : ""; + sb.append( + String.format( + "%s%s %s %-14s %s%n", + indent, + styles.boxMid(), + styles.arrow(), + styles.bold(targetNode), + styles.dim(prefix + " revise " + budget + fbSuffix))); + } else if (inner instanceof ScoreTransition score) { + for (ScoreCondition cond : score.getConditions()) { + String condValue = + cond.getOperator() == ComparisonOperator.RANGE && cond.range() != null + ? cond.range().start() + ".." + cond.range().end() + : String.valueOf(cond.getValue()); + sb.append( + String.format( + "%s%s %s %-14s %s%n", + indent, + styles.boxMid(), + styles.arrow(), + styles.bold(cond.getTargetNode()), + styles.dim( + "score " + + cond.getOperator() + + " " + + condValue + + " revise " + + budget))); + } + } + + // Escalation line + sb.append( + String.format( + "%s%s %s %-14s %s%n", + indent, + styles.boxMid(), + styles.arrow(), + styles.bold(bounded.otherwise()), + styles.dim("budget exhausted"))); + } + private String colorByNodeType(String text, NodeType type, AnsiStyles styles) { return switch (type) { case STANDARD, GENERIC, PARALLEL, FORK, JOIN -> styles.accent(text); @@ -486,38 +571,8 @@ private String colorByNodeType(String text, NodeType type, AnsiStyles styles) { } private void collectNextNodes(Node node, int level, Deque queue) { - switch (node) { - case LoopNode loopNode -> { - if (loopNode.getBreakRules() != null) { - for (BreakRule rule : loopNode.getBreakRules()) { - queue.add(new NodeLevel(rule.getTargetNode(), level + 1)); - } - } - } - case ForkNode forkNode -> { - for (String target : forkNode.getTargets()) { - queue.add(new NodeLevel(target, level + 1)); - } - collectTransitionTargets(forkNode.getTransitionRules(), level, queue); - } - default -> collectTransitionTargets(node.getTransitionRules(), level, queue); - } - } - - private void collectTransitionTargets( - List rules, int level, Deque queue) { - for (TransitionRule rule : rules) { - if (rule instanceof SuccessTransition success) { - queue.add(new NodeLevel(success.getTargetNode(), level + 1)); - } else if (rule instanceof FailureTransition failure) { - queue.add(new NodeLevel(failure.getThenTargetNode(), level + 1)); - } else if (rule instanceof ScoreTransition score) { - for (ScoreCondition cond : score.getConditions()) { - queue.add(new NodeLevel(cond.getTargetNode(), level + 1)); - } - } else if (rule instanceof ApprovalTransition approval) { - queue.add(new NodeLevel(approval.targetNode(), level + 1)); - } + for (String target : NodeTargets.of(node)) { + queue.add(new NodeLevel(target, level + 1)); } } diff --git a/hensu-cli/src/test/java/io/hensu/cli/review/CLIReviewHandlerTest.java b/hensu-cli/src/test/java/io/hensu/cli/review/CLIReviewHandlerTest.java index 260cba1..ff45c43 100644 --- a/hensu-cli/src/test/java/io/hensu/cli/review/CLIReviewHandlerTest.java +++ b/hensu-cli/src/test/java/io/hensu/cli/review/CLIReviewHandlerTest.java @@ -248,7 +248,7 @@ void shouldUseDefaultReasonWhenBacktrackReasonBlank() { var decision = ((ReviewOutcome.Decided) outcome).decision(); assertThat(decision).isInstanceOf(ReviewDecision.Backtrack.class); assertThat(((ReviewDecision.Backtrack) decision).getReason()) - .isEqualTo("Manual backtrack by reviewer"); + .isEqualTo("Reviewer backtrack"); } // — No-TTY / exhausted stdin ———————————————————————————————————————————— diff --git a/hensu-cli/src/test/java/io/hensu/cli/visualizer/MermaidVisualizationFormatTest.java b/hensu-cli/src/test/java/io/hensu/cli/visualizer/MermaidVisualizationFormatTest.java index b225736..fdb4d0f 100644 --- a/hensu-cli/src/test/java/io/hensu/cli/visualizer/MermaidVisualizationFormatTest.java +++ b/hensu-cli/src/test/java/io/hensu/cli/visualizer/MermaidVisualizationFormatTest.java @@ -16,6 +16,7 @@ import io.hensu.core.workflow.node.ParallelNode; import io.hensu.core.workflow.node.StandardNode; import io.hensu.core.workflow.node.SubWorkflowNode; +import io.hensu.core.workflow.transition.BoundedTransition; import io.hensu.core.workflow.transition.FailureTransition; import io.hensu.core.workflow.transition.SuccessTransition; import java.time.Instant; @@ -91,7 +92,7 @@ void shouldRenderRetryCountInFailureLabel() { String result = format.render(workflow); - assertThat(result).contains("retry 3"); + assertThat(result).contains("retry ≤3"); } @Test @@ -206,7 +207,11 @@ private Workflow createWorkflowWithFailureTransition() { .transitionRules( List.of( new SuccessTransition("end"), - new FailureTransition(3, "error"))) + new BoundedTransition( + new FailureTransition(null), + "failure", + 3, + "error"))) .build()); nodes.put("end", EndNode.builder().id("end").status(ExitStatus.SUCCESS).build()); nodes.put("error", EndNode.builder().id("error").status(ExitStatus.FAILURE).build()); @@ -488,7 +493,7 @@ void shouldDecomposeParallelNodeIntoBranchAndJoinNodes() { .transitionRules( List.of( new SuccessTransition("refine"), - new FailureTransition(0, "rejected"))) + new FailureTransition("rejected"))) .build()); nodes.put( "refine", diff --git a/hensu-cli/src/test/java/io/hensu/cli/visualizer/TextVisualizationFormatTest.java b/hensu-cli/src/test/java/io/hensu/cli/visualizer/TextVisualizationFormatTest.java index fa549b5..c453f6a 100644 --- a/hensu-cli/src/test/java/io/hensu/cli/visualizer/TextVisualizationFormatTest.java +++ b/hensu-cli/src/test/java/io/hensu/cli/visualizer/TextVisualizationFormatTest.java @@ -11,6 +11,7 @@ import io.hensu.core.workflow.node.Node; import io.hensu.core.workflow.node.StandardNode; import io.hensu.core.workflow.node.SubWorkflowNode; +import io.hensu.core.workflow.transition.BoundedTransition; import io.hensu.core.workflow.transition.FailureTransition; import io.hensu.core.workflow.transition.SuccessTransition; import java.time.Instant; @@ -443,7 +444,11 @@ private Workflow createWorkflowWithFailureTransition() { .transitionRules( List.of( new SuccessTransition("end"), - new FailureTransition(3, "error"))) + new BoundedTransition( + new FailureTransition(null), + "failure", + 3, + "error"))) .build()); nodes.put("end", EndNode.builder().id("end").status(ExitStatus.SUCCESS).build()); nodes.put("error", EndNode.builder().id("error").status(ExitStatus.FAILURE).build()); diff --git a/hensu-core/README.md b/hensu-core/README.md index 97cd9f2..e4ce1c7 100644 --- a/hensu-core/README.md +++ b/hensu-core/README.md @@ -289,6 +289,7 @@ hensu-core/src/main/java/io/hensu/core/ │ ├── enricher/ │ │ ├── EngineVariableInjector.java # Single-injector interface │ │ ├── EngineVariablePromptEnricher.java # Composite enricher — runs injector chain before each agent call +│ │ ├── FeedbackContextInjector.java # Appends prior `recommendation` as a "### Previous Feedback" section (runs first) │ │ ├── RubricPromptInjector.java # Injects rubric criteria when node has a parsed Rubric │ │ ├── ScoreVariableInjector.java # Injects `score` requirement on ScoreTransition nodes or consensus branches │ │ ├── ApprovalVariableInjector.java # Injects `approved` requirement on ApprovalTransition nodes or consensus branches @@ -330,10 +331,37 @@ hensu-core/src/main/java/io/hensu/core/ │ └── BranchExecutionConfig.java # Typed branch metadata on ExecutionContext (consensus, yields) ├── workflow/ │ ├── Workflow.java # Workflow definition (agents + graph + optional state schema) +│ ├── WorkflowConfig.java # Execution tuning (retry limits, timeouts, defaults) +│ ├── WorkflowMetadata.java # Descriptive metadata (name, version, description) │ ├── WorkflowRepository.java # Tenant-scoped workflow storage interface │ ├── InMemoryWorkflowRepository.java # Default in-memory implementation -│ ├── node/ # Node types: Standard, Parallel, Fork, etc. -│ ├── transition/ # Transition rules: Success, Failure, Score, Approval, Always, etc. +│ ├── node/ +│ │ ├── Node.java # Sealed base node interface +│ │ ├── NodeType.java # Node type enum +│ │ ├── NodeTargets.java # Outgoing-edge target set for a node +│ │ ├── StandardNode.java # LLM prompt node with writes + transitions +│ │ ├── ParallelNode.java # Concurrent branches with consensus +│ │ ├── ForkNode.java # Splits into parallel paths (StructuredTaskScope) +│ │ ├── JoinNode.java # Merges parallel results via writes/exports +│ │ ├── MergeStrategy.java # Join merge strategy enum +│ │ ├── LoopNode.java # Iterates until condition or max iterations +│ │ ├── ActionNode.java # Dispatches actions (send HTTP, execute command) +│ │ ├── GenericNode.java # Custom handler for extensible operations +│ │ ├── SubWorkflowNode.java # Delegates to another workflow +│ │ └── EndNode.java # Terminal node +│ ├── transition/ +│ │ ├── TransitionRule.java # Sealed trigger interface; exposes requiredEngineVars() +│ │ ├── TransitionTargets.java # Primary + escalation target pair +│ │ ├── SuccessTransition.java # Routes on successful execution +│ │ ├── FailureTransition.java # Routes on execution failure (pure trigger) +│ │ ├── NoConsensusTransition.java # Routes when a parallel node fails consensus +│ │ ├── ScoreTransition.java # Routes on rubric evaluation score +│ │ ├── ApprovalTransition.java # Routes on the approved engine variable +│ │ ├── BoundedTransition.java # Decorates a trigger with retry budget + escalation (backs revise) +│ │ ├── AlwaysTransition.java # Unconditional transition +│ │ ├── RubricFailTransition.java # Routes when rubric evaluation itself fails +│ │ ├── LoopCondition.java # Continuation predicate for LoopNode +│ │ └── BreakRule.java # Early-exit rule for LoopNode │ ├── state/ │ │ ├── WorkflowStateSchema.java # Typed state variable schema (optional per-workflow) │ │ ├── StateVariableDeclaration.java # Variable declaration record (name, type, isInput) @@ -412,14 +440,16 @@ hensu-core/src/main/java/io/hensu/core/ ## Transition Rules -| Rule | Description | -|------------------------|---------------------------------------------------------------------------| -| `SuccessTransition` | Routes on successful execution | -| `FailureTransition` | Routes on execution failure | -| `ScoreTransition` | Routes based on rubric evaluation score | -| `ApprovalTransition` | Routes on the `approved` boolean engine variable (fall-through if absent) | -| `AlwaysTransition` | Unconditional transition | -| `RubricFailTransition` | Routes when rubric evaluation itself fails | +| Rule | Description | +|-------------------------|-------------------------------------------------------------------------------------------------------------------| +| `SuccessTransition` | Routes on successful execution | +| `FailureTransition` | Routes on execution failure (pure trigger; no retry budget) | +| `NoConsensusTransition` | Routes when a parallel node fails to reach consensus | +| `ScoreTransition` | Routes based on rubric evaluation score | +| `ApprovalTransition` | Routes on the `approved` boolean engine variable (fall-through if absent) | +| `BoundedTransition` | Decorates a trigger with a per-node retry budget + escalation target (the `revise` / `onFailure retry` mechanism) | +| `AlwaysTransition` | Unconditional transition | +| `RubricFailTransition` | Routes when rubric evaluation itself fails | ## Agent Provider Interface diff --git a/hensu-core/src/main/java/io/hensu/core/execution/EngineVariables.java b/hensu-core/src/main/java/io/hensu/core/execution/EngineVariables.java index 7836438..87778e6 100644 --- a/hensu-core/src/main/java/io/hensu/core/execution/EngineVariables.java +++ b/hensu-core/src/main/java/io/hensu/core/execution/EngineVariables.java @@ -18,10 +18,21 @@ /// - **approved** – boolean, drives {@code ApprovalTransition} and consensus voting /// - **recommendation** – string, justification for score/approval decisions /// +/// ### Lifecycle – single owner +/// {@link io.hensu.core.execution.pipeline.TransitionPostProcessor} is the sole +/// owner of engine variable cleanup in the state context: +/// - **Forward transition** – clears all engine vars via {@link #all()} +/// - **Backtrack transition** – clears score and approved; keeps recommendation +/// so {@link io.hensu.core.execution.enricher.FeedbackContextInjector} can +/// surface it in the backtracked node's prompt +/// +/// No other component (executors, enrichers) should remove engine vars from state. +/// /// @see io.hensu.core.execution.enricher.ScoreVariableInjector /// @see io.hensu.core.execution.enricher.ApprovalVariableInjector /// @see io.hensu.core.execution.enricher.RecommendationVariableInjector /// @see io.hensu.core.execution.parallel.ConsensusEvaluator +/// @see io.hensu.core.execution.pipeline.TransitionPostProcessor public final class EngineVariables { /// Numeric score (0–100) for quality evaluation and consensus. @@ -43,6 +54,13 @@ public final class EngineVariables { private EngineVariables() {} + /// Returns an unmodifiable set of all engine variable names. + /// + /// @return unmodifiable set containing all engine variable names + public static Set all() { + return ENGINE_VAR_SET; + } + /// Checks whether the given name is a reserved engine variable. /// /// Use this to prevent user-declared {@code yields()} or {@code writes()} diff --git a/hensu-core/src/main/java/io/hensu/core/execution/enricher/ApprovalVariableInjector.java b/hensu-core/src/main/java/io/hensu/core/execution/enricher/ApprovalVariableInjector.java index 6b02642..35d27bf 100644 --- a/hensu-core/src/main/java/io/hensu/core/execution/enricher/ApprovalVariableInjector.java +++ b/hensu-core/src/main/java/io/hensu/core/execution/enricher/ApprovalVariableInjector.java @@ -1,19 +1,17 @@ package io.hensu.core.execution.enricher; -import io.hensu.core.execution.executor.ExecutionContext; -import io.hensu.core.workflow.node.Node; -import io.hensu.core.workflow.transition.ApprovalTransition; +import io.hensu.core.execution.EngineVariables; /// Injects the `approved` boolean output requirement into the node prompt. /// -/// Applied when the node has an {@link ApprovalTransition} rule or when executing -/// a consensus branch that requires self-scoring (non-JUDGE_DECIDES strategies). -/// Instructs the agent to include `"approved": true` or `"approved": false` as a +/// Applied when the node has an {@link io.hensu.core.workflow.transition.ApprovalTransition} +/// rule or when executing a consensus branch that requires self-scoring (non-JUDGE_DECIDES +/// strategies). Instructs the agent to include `"approved": true` or `"approved": false` as a /// JSON boolean in its response. /// -/// @see EngineVariableInjector +/// @see TransitionVariableInjector /// @see io.hensu.core.workflow.transition.ApprovalTransition -public final class ApprovalVariableInjector implements EngineVariableInjector { +public final class ApprovalVariableInjector extends TransitionVariableInjector { static final String INSTRUCTION = """ @@ -24,11 +22,12 @@ public final class ApprovalVariableInjector implements EngineVariableInjector { `false` if you reject it. Do not use text, only a JSON boolean."""; @Override - public String inject(String prompt, Node node, ExecutionContext ctx) { - boolean needs = - node.getTransitionRules().stream().anyMatch(r -> r instanceof ApprovalTransition) - || (ctx.getBranchConfig() != null - && ctx.getBranchConfig().needsSelfScoring()); - return needs ? prompt + INSTRUCTION : prompt; + protected String engineVariable() { + return EngineVariables.APPROVED; + } + + @Override + protected String instruction() { + return INSTRUCTION; } } diff --git a/hensu-core/src/main/java/io/hensu/core/execution/enricher/EngineVariablePromptEnricher.java b/hensu-core/src/main/java/io/hensu/core/execution/enricher/EngineVariablePromptEnricher.java index c12ac13..cbfb4d6 100644 --- a/hensu-core/src/main/java/io/hensu/core/execution/enricher/EngineVariablePromptEnricher.java +++ b/hensu-core/src/main/java/io/hensu/core/execution/enricher/EngineVariablePromptEnricher.java @@ -12,18 +12,21 @@ /// /// ### Default pipeline /// {@link #DEFAULT} runs in this order: -/// 1. {@link RubricPromptInjector} — injects rubric criteria when `node.getRubric()` is set -/// 2. {@link ScoreVariableInjector} — injects `score` requirement when a +/// 1. {@link FeedbackContextInjector} — injects previous engine feedback +/// ({@link io.hensu.core.execution.EngineVariables#RECOMMENDATION}) when present so the +/// agent sees prior evaluation context before output-format requirements +/// 2. {@link RubricPromptInjector} — injects rubric criteria when `node.getRubric()` is set +/// 3. {@link ScoreVariableInjector} — injects `score` requirement when a /// {@link io.hensu.core.workflow.transition.ScoreTransition} exists or consensus branch /// needs self-scoring -/// 3. {@link ApprovalVariableInjector} — injects `approved` requirement when an +/// 4. {@link ApprovalVariableInjector} — injects `approved` requirement when an /// {@link io.hensu.core.workflow.transition.ApprovalTransition} exists or consensus branch /// needs self-scoring -/// 4. {@link RecommendationVariableInjector} — injects `recommendation` requirement when a +/// 5. {@link RecommendationVariableInjector} — injects `recommendation` requirement when a /// score/approval transition exists or consensus branch needs self-scoring -/// 5. {@link WritesVariableInjector} — injects field requirements for all user-declared +/// 6. {@link WritesVariableInjector} — injects field requirements for all user-declared /// {@code writes()} variables so the LLM produces extractable JSON keys -/// 6. {@link YieldsVariableInjector} — injects field requirements for branch +/// 7. {@link YieldsVariableInjector} — injects field requirements for branch /// {@code yields()} variables so the LLM produces extractable domain output /// /// ### Extension @@ -39,6 +42,7 @@ public final class EngineVariablePromptEnricher { public static final EngineVariablePromptEnricher DEFAULT = new EngineVariablePromptEnricher( List.of( + new FeedbackContextInjector(), new RubricPromptInjector(), new ScoreVariableInjector(), new ApprovalVariableInjector(), diff --git a/hensu-core/src/main/java/io/hensu/core/execution/enricher/FeedbackContextInjector.java b/hensu-core/src/main/java/io/hensu/core/execution/enricher/FeedbackContextInjector.java new file mode 100644 index 0000000..1e74515 --- /dev/null +++ b/hensu-core/src/main/java/io/hensu/core/execution/enricher/FeedbackContextInjector.java @@ -0,0 +1,55 @@ +package io.hensu.core.execution.enricher; + +import io.hensu.core.execution.EngineVariables; +import io.hensu.core.execution.executor.ExecutionContext; +import io.hensu.core.workflow.node.Node; + +/// Injects previous engine feedback into the prompt when the state context +/// carries a non-blank {@link EngineVariables#RECOMMENDATION} value. +/// +/// Unlike the other injectors in the pipeline, this one surfaces feedback +/// from a prior evaluation pass rather than instructing the agent to +/// *produce* a new engine variable. It runs first in +/// {@link EngineVariablePromptEnricher#DEFAULT} so the agent sees feedback +/// context before any output-format requirements. +/// +/// ### Lifecycle +/// The recommendation value is produced by one node (via output extraction or +/// consensus feedback) and survives into the next node on two paths: +/// backtrack transitions (bounded, under budget) and forward transitions +/// whose rule returns {@link io.hensu.core.workflow.transition.TransitionRule#withFeedback()} +/// {@code true}. {@link io.hensu.core.execution.pipeline.TransitionPostProcessor} +/// owns the cleanup decision: forward transitions without feedback clear all +/// engine vars; backtracks and feedback-enabled forwards preserve +/// recommendation so this injector can surface it. +/// +/// This injector is read-only — it never mutates the state context. Cleanup +/// responsibility belongs exclusively to {@code TransitionPostProcessor}. +/// +/// ### Append format +/// ``` +/// +/// +/// ### Previous Feedback +/// +/// ``` +/// +/// @implNote **No instance state.** Stateless and read-only; safe to share +/// across Virtual Threads. +/// +/// @see EngineVariablePromptEnricher +/// @see io.hensu.core.execution.pipeline.TransitionPostProcessor for engine var lifecycle +/// @see io.hensu.core.execution.pipeline.RubricPostProcessor for rubric feedback +public final class FeedbackContextInjector implements EngineVariableInjector { + + static final String FEEDBACK_SECTION_PREFIX = "\n\n### Previous Feedback\n"; + + @Override + public String inject(String prompt, Node node, ExecutionContext ctx) { + Object value = ctx.getState().getContext().get(EngineVariables.RECOMMENDATION); + if (value instanceof String s && !s.isBlank()) { + return prompt + FEEDBACK_SECTION_PREFIX + s; + } + return prompt; + } +} diff --git a/hensu-core/src/main/java/io/hensu/core/execution/enricher/RecommendationVariableInjector.java b/hensu-core/src/main/java/io/hensu/core/execution/enricher/RecommendationVariableInjector.java index 67223c0..ec843f4 100644 --- a/hensu-core/src/main/java/io/hensu/core/execution/enricher/RecommendationVariableInjector.java +++ b/hensu-core/src/main/java/io/hensu/core/execution/enricher/RecommendationVariableInjector.java @@ -1,30 +1,27 @@ package io.hensu.core.execution.enricher; -import io.hensu.core.execution.executor.ExecutionContext; -import io.hensu.core.workflow.node.Node; -import io.hensu.core.workflow.transition.ApprovalTransition; -import io.hensu.core.workflow.transition.ScoreTransition; +import io.hensu.core.execution.EngineVariables; /// Injects the `recommendation` string output requirement into the node prompt. /// -/// Applied when the node has a {@link ScoreTransition} or {@link ApprovalTransition} rule, -/// or when executing a consensus branch that requires self-scoring (non-JUDGE_DECIDES -/// strategies). Ensures the agent justifies its judgment with improvement feedback or -/// review reasoning. +/// Applied when the node has a {@link io.hensu.core.workflow.transition.ScoreTransition} +/// or {@link io.hensu.core.workflow.transition.ApprovalTransition} rule, or when executing +/// a consensus branch that requires self-scoring (non-JUDGE_DECIDES strategies). Ensures the +/// agent justifies its judgment with improvement feedback or review reasoning. /// /// ### Engine variable contract /// /// `recommendation` is an engine-managed variable alongside `score` and `approved`. -/// Developers do not declare it in `writes()` — the engine infers it from the graph. +/// Developers do not declare it in `writes()` – the engine infers it from the graph. /// {@link io.hensu.core.execution.pipeline.OutputExtractionPostProcessor} extracts it /// automatically when either transition type is present. /// -/// @see EngineVariableInjector +/// @see TransitionVariableInjector /// @see ScoreVariableInjector /// @see ApprovalVariableInjector /// @see io.hensu.core.workflow.transition.ScoreTransition /// @see io.hensu.core.workflow.transition.ApprovalTransition -public final class RecommendationVariableInjector implements EngineVariableInjector { +public final class RecommendationVariableInjector extends TransitionVariableInjector { static final String INSTRUCTION = """ @@ -35,15 +32,12 @@ public final class RecommendationVariableInjector implements EngineVariableInjec plain string."""; @Override - public String inject(String prompt, Node node, ExecutionContext ctx) { - boolean needs = - node.getTransitionRules().stream() - .anyMatch( - r -> - r instanceof ScoreTransition - || r instanceof ApprovalTransition) - || (ctx.getBranchConfig() != null - && ctx.getBranchConfig().needsSelfScoring()); - return needs ? prompt + INSTRUCTION : prompt; + protected String engineVariable() { + return EngineVariables.RECOMMENDATION; + } + + @Override + protected String instruction() { + return INSTRUCTION; } } diff --git a/hensu-core/src/main/java/io/hensu/core/execution/enricher/ScoreVariableInjector.java b/hensu-core/src/main/java/io/hensu/core/execution/enricher/ScoreVariableInjector.java index 70778e8..054a04b 100644 --- a/hensu-core/src/main/java/io/hensu/core/execution/enricher/ScoreVariableInjector.java +++ b/hensu-core/src/main/java/io/hensu/core/execution/enricher/ScoreVariableInjector.java @@ -1,17 +1,16 @@ package io.hensu.core.execution.enricher; -import io.hensu.core.execution.executor.ExecutionContext; -import io.hensu.core.workflow.node.Node; -import io.hensu.core.workflow.transition.ScoreTransition; +import io.hensu.core.execution.EngineVariables; /// Injects the `score` numeric output requirement into the node prompt. /// -/// Applied when the node has a {@link ScoreTransition} rule or when executing -/// a consensus branch that requires self-scoring (non-JUDGE_DECIDES strategies). +/// Applied when the node has a {@link io.hensu.core.workflow.transition.ScoreTransition} +/// rule or when executing a consensus branch that requires self-scoring +/// (non-JUDGE_DECIDES strategies). /// -/// @see EngineVariableInjector +/// @see TransitionVariableInjector /// @see io.hensu.core.workflow.transition.ScoreTransition -public final class ScoreVariableInjector implements EngineVariableInjector { +public final class ScoreVariableInjector extends TransitionVariableInjector { static final String INSTRUCTION = """ @@ -22,11 +21,12 @@ public final class ScoreVariableInjector implements EngineVariableInjector { Do not use text, only a JSON number."""; @Override - public String inject(String prompt, Node node, ExecutionContext ctx) { - boolean needs = - node.getTransitionRules().stream().anyMatch(r -> r instanceof ScoreTransition) - || (ctx.getBranchConfig() != null - && ctx.getBranchConfig().needsSelfScoring()); - return needs ? prompt + INSTRUCTION : prompt; + protected String engineVariable() { + return EngineVariables.SCORE; + } + + @Override + protected String instruction() { + return INSTRUCTION; } } diff --git a/hensu-core/src/main/java/io/hensu/core/execution/enricher/TransitionVariableInjector.java b/hensu-core/src/main/java/io/hensu/core/execution/enricher/TransitionVariableInjector.java new file mode 100644 index 0000000..b383b09 --- /dev/null +++ b/hensu-core/src/main/java/io/hensu/core/execution/enricher/TransitionVariableInjector.java @@ -0,0 +1,42 @@ +package io.hensu.core.execution.enricher; + +import io.hensu.core.execution.executor.ExecutionContext; +import io.hensu.core.workflow.node.Node; + +/// Base for injectors that append an engine output requirement when a transition rule +/// declares a specific engine variable. +/// +/// Subclasses define the engine variable name and instruction text as static constants +/// and expose them via {@link #engineVariable()} and {@link #instruction()}. This class +/// provides the shared activation logic: the instruction is appended when at least one +/// transition rule on the node declares the variable via +/// {@link io.hensu.core.workflow.transition.TransitionRule#requiredEngineVars()}, or when +/// the execution context indicates a consensus branch requiring self-scoring. +/// +/// Not all {@link EngineVariableInjector} implementations extend this class – +/// {@link RubricPromptInjector}, for example, activates on rubric presence rather than +/// transition rules. +/// +/// @see EngineVariableInjector +abstract class TransitionVariableInjector implements EngineVariableInjector { + + /// Returns the engine variable name to match against transition rules. + /// + /// @return engine variable name, not null + protected abstract String engineVariable(); + + /// Returns the prompt instruction to append when the variable is required. + /// + /// @return instruction text, not null + protected abstract String instruction(); + + @Override + public String inject(String prompt, Node node, ExecutionContext ctx) { + boolean needs = + node.getTransitionRules().stream() + .anyMatch(r -> r.requiredEngineVars().contains(engineVariable())) + || (ctx.getBranchConfig() != null + && ctx.getBranchConfig().needsSelfScoring()); + return needs ? prompt + instruction() : prompt; + } +} diff --git a/hensu-core/src/main/java/io/hensu/core/execution/executor/NodeResult.java b/hensu-core/src/main/java/io/hensu/core/execution/executor/NodeResult.java index 3036cb6..8c98d8b 100644 --- a/hensu-core/src/main/java/io/hensu/core/execution/executor/NodeResult.java +++ b/hensu-core/src/main/java/io/hensu/core/execution/executor/NodeResult.java @@ -44,7 +44,7 @@ public NodeResult(Builder builder) { public NodeResult(ResultStatus status, Object output, Map metadata) { this.status = status; this.output = output; - this.metadata = metadata; + this.metadata = metadata != null ? metadata : Map.of(); } /// Returns the execution status. diff --git a/hensu-core/src/main/java/io/hensu/core/execution/executor/ParallelNodeExecutor.java b/hensu-core/src/main/java/io/hensu/core/execution/executor/ParallelNodeExecutor.java index 4d3e2f9..f3e4ccd 100644 --- a/hensu-core/src/main/java/io/hensu/core/execution/executor/ParallelNodeExecutor.java +++ b/hensu-core/src/main/java/io/hensu/core/execution/executor/ParallelNodeExecutor.java @@ -77,6 +77,10 @@ public Class getNodeType() { public NodeResult execute(ParallelNode node, ExecutionContext context) throws Exception { HensuState state = context.getState(); + // Parallel nodes do not support per-branch prompt override; consume any staged + // override so it does not leak into downstream nodes. + state.getContext().remove("_prompt_override"); + logger.info( "Executing parallel node: " + node.getId() @@ -176,6 +180,9 @@ private BranchResult executeBranch(Branch branch, ParallelNode node, ExecutionCo String branchNodeId = node.getId() + "/" + branch.getId(); + // Propagate current node for agent awareness. + branchSnapshot.put("current_node", node.getId()); + // Resolve template against branch-isolated snapshot TemplateResolver resolver = branchCtx.getTemplateResolver(); String resolvedPrompt = @@ -184,6 +191,11 @@ private BranchResult executeBranch(Branch branch, ParallelNode node, ExecutionCo branch.getPrompt(), branchSnapshot) : ""; + // Clear stale branch yields after template resolution. + // Engine vars (score, approved, recommendation) are managed + // by TransitionPostProcessor — see EngineVariables Javadoc. + branch.getYields().forEach(branchSnapshot.keySet()::remove); + // Delegate to shared agent execution lifecycle NodeResult nodeResult = AgentLifecycleRunner.execute( @@ -327,16 +339,27 @@ private NodeResult evaluateConsensus( for (BranchResult br : branchResults) { if (!judgeStrategy || winnerIds.contains(br.getBranchId())) { - state.getContext().putAll(br.yields()); + // Promote only domain yields. Engine variables (score/approved/recommendation) + // are consensus-internal; promoting them would poison the feedback channel + // for downstream nodes. + br.yields() + .forEach( + (k, v) -> { + if (!EngineVariables.isEngineVar(k)) { + state.getContext().put(k, v); + } + }); } } - // Store consensus metadata in context for downstream processing - state.getContext().put("consensus_reached", consensusResult.consensusReached()); - state.getContext().put("consensus_result", consensusResult); - state.getContext().put("consensus_votes", consensusResult.votes()); + // Store consensus metadata under node-scoped keys for multi-parallel safety. + String nodeId = state.getCurrentNode(); + state.getContext().put("consensus_reached:" + nodeId, consensusResult.consensusReached()); + state.getContext().put("consensus_result:" + nodeId, consensusResult); + state.getContext().put("consensus_votes:" + nodeId, consensusResult.votes()); if (consensusResult.winningBranchId() != null) { - state.getContext().put("consensus_winning_branch", consensusResult.winningBranchId()); + state.getContext() + .put("consensus_winning_branch:" + nodeId, consensusResult.winningBranchId()); } // Build result metadata diff --git a/hensu-core/src/main/java/io/hensu/core/execution/executor/StandardNodeExecutor.java b/hensu-core/src/main/java/io/hensu/core/execution/executor/StandardNodeExecutor.java index af5818c..a371baf 100644 --- a/hensu-core/src/main/java/io/hensu/core/execution/executor/StandardNodeExecutor.java +++ b/hensu-core/src/main/java/io/hensu/core/execution/executor/StandardNodeExecutor.java @@ -41,6 +41,8 @@ public NodeResult execute(StandardNode node, ExecutionContext context) { // Remove stale output variables so they don't shadow this node's computation. // OutputExtractionPostProcessor will repopulate these with fresh values after execution. + // Engine vars (score, approved, recommendation) are managed exclusively by + // TransitionPostProcessor — see EngineVariables Javadoc for lifecycle. node.getWrites().forEach(state.getContext().keySet()::remove); // Delegate to shared agent execution lifecycle (prompt already resolved) diff --git a/hensu-core/src/main/java/io/hensu/core/execution/pipeline/OutputExtractionPostProcessor.java b/hensu-core/src/main/java/io/hensu/core/execution/pipeline/OutputExtractionPostProcessor.java index dd35dc0..3497b4e 100644 --- a/hensu-core/src/main/java/io/hensu/core/execution/pipeline/OutputExtractionPostProcessor.java +++ b/hensu-core/src/main/java/io/hensu/core/execution/pipeline/OutputExtractionPostProcessor.java @@ -7,9 +7,7 @@ import io.hensu.core.util.JsonUtil; import io.hensu.core.workflow.node.GenericNode; import io.hensu.core.workflow.node.StandardNode; -import io.hensu.core.workflow.transition.ApprovalTransition; -import io.hensu.core.workflow.transition.ScoreTransition; -import java.io.Serial; +import io.hensu.core.workflow.transition.TransitionRule; import java.util.*; import java.util.logging.Logger; @@ -22,7 +20,8 @@ /// /// ### Engine variables /// `score`, `approved`, and `recommendation` are extracted automatically based on transitions. -/// `score` → {@link ScoreTransition}, `approved` → {@link ApprovalTransition}, +/// `score` → {@link io.hensu.core.workflow.transition.ScoreTransition}, +/// `approved` → {@link io.hensu.core.workflow.transition.ApprovalTransition}, /// `recommendation` → either transition (scoring and approval both require justification). /// Developers do not declare these in `writes` — the engine infers them from the graph. /// @@ -85,17 +84,9 @@ public ProcessorOutcome process(ProcessorContext context) { List engineVars = engineVarsFor(standardNode); if (!writes.isEmpty() || !engineVars.isEmpty()) { - List allKeys = - new ArrayList<>( - new LinkedHashSet<>(writes) { - @Serial - private static final long serialVersionUID = - 6441855807738151353L; - - { - addAll(engineVars); - } - }); + Set keySet = new LinkedHashSet<>(writes); + keySet.addAll(engineVars); + List allKeys = new ArrayList<>(keySet); if (engineVars.isEmpty() && writes.size() == 1) { // Single domain write, no engine vars: fall back to raw text if JSON misses key @@ -118,17 +109,15 @@ public ProcessorOutcome process(ProcessorContext context) { } private List engineVarsFor(StandardNode node) { - List vars = new ArrayList<>(); - boolean hasScore = node.getRubric() != null; - boolean hasApproval = false; - for (var rule : node.getTransitionRules()) { - if (rule instanceof ScoreTransition) hasScore = true; - else if (rule instanceof ApprovalTransition) hasApproval = true; + Set vars = new LinkedHashSet<>(); + if (node.getRubric() != null) { + vars.add(EngineVariables.SCORE); + vars.add(EngineVariables.RECOMMENDATION); + } + for (TransitionRule rule : node.getTransitionRules()) { + vars.addAll(rule.requiredEngineVars()); } - if (hasScore) vars.add(EngineVariables.SCORE); - if (hasApproval) vars.add(EngineVariables.APPROVED); - if (hasScore || hasApproval) vars.add(EngineVariables.RECOMMENDATION); - return vars; + return List.copyOf(vars); } private ProcessorOutcome rejectOutput(HensuState state, String nodeId, String reason) { diff --git a/hensu-core/src/main/java/io/hensu/core/execution/pipeline/ReviewPostProcessor.java b/hensu-core/src/main/java/io/hensu/core/execution/pipeline/ReviewPostProcessor.java index c9d0d9f..d3dfbb7 100644 --- a/hensu-core/src/main/java/io/hensu/core/execution/pipeline/ReviewPostProcessor.java +++ b/hensu-core/src/main/java/io/hensu/core/execution/pipeline/ReviewPostProcessor.java @@ -1,6 +1,7 @@ package io.hensu.core.execution.pipeline; import io.hensu.core.execution.result.BacktrackEvent; +import io.hensu.core.execution.result.BacktrackType; import io.hensu.core.execution.result.ExecutionResult; import io.hensu.core.execution.result.ResultStatus; import io.hensu.core.resume.ResumeInput; @@ -162,6 +163,7 @@ private ProcessorOutcome handleBacktrack( .from(fromNodeId) .to(targetStep) .reason(backtrack.getReason()) + .type(BacktrackType.MANUAL) .build()); return ProcessorOutcome.CONTINUE; diff --git a/hensu-core/src/main/java/io/hensu/core/execution/pipeline/RubricPostProcessor.java b/hensu-core/src/main/java/io/hensu/core/execution/pipeline/RubricPostProcessor.java index 3832866..d24d717 100644 --- a/hensu-core/src/main/java/io/hensu/core/execution/pipeline/RubricPostProcessor.java +++ b/hensu-core/src/main/java/io/hensu/core/execution/pipeline/RubricPostProcessor.java @@ -1,5 +1,7 @@ package io.hensu.core.execution.pipeline; +import io.hensu.core.execution.EngineVariables; +import io.hensu.core.execution.executor.NodeResult; import io.hensu.core.execution.result.AutoBacktrack; import io.hensu.core.execution.result.ExecutionStep; import io.hensu.core.rubric.RubricEngine; @@ -14,6 +16,7 @@ import java.util.HashMap; import java.util.List; import java.util.Map; +import java.util.Objects; import java.util.Optional; import java.util.logging.Logger; @@ -143,8 +146,7 @@ private AutoBacktrack determineAutoBacktrack( } if (evaluation.getScore() < MINOR_FAILURE_THRESHOLD) { - Integer retryAttempt = (Integer) state.getContext().get("retry_attempt"); - int currentAttempt = retryAttempt != null ? retryAttempt : 0; + int currentAttempt = state.getRetryCount("rubric_backtrack", currentNode.getId()); if (currentAttempt >= DEFAULT_MAX_BACKTRACK_RETRIES) { logger.warning( @@ -157,8 +159,8 @@ private AutoBacktrack determineAutoBacktrack( return null; } + state.incrementRetryCount("rubric_backtrack", currentNode.getId()); Map updates = new HashMap<>(); - updates.put("retry_attempt", currentAttempt + 1); updates.put("improvement_hints", evaluation.getSuggestions()); addRecommendationsToContext(updates, selfRecommendations, evaluation); return new AutoBacktrack(currentNode.getId(), updates); @@ -201,7 +203,7 @@ private void addRecommendationsToContext( } if (!recommendations.isEmpty()) { - updates.put("recommendations", recommendations.toString().trim()); + updates.put(EngineVariables.RECOMMENDATION, recommendations.toString().trim()); } } @@ -228,7 +230,7 @@ private String findPreviousPhase(String currentNodeId, HensuState state, Workflo Node stepNode = workflow.getNodes().get(step.getNodeId()); if (stepNode != null && stepNode.getRubric() != null - && stepNode.getRubric() != currentRubric) { + && !Objects.equals(stepNode.getRubric(), currentRubric)) { return step.getNodeId(); } } @@ -236,10 +238,9 @@ private String findPreviousPhase(String currentNodeId, HensuState state, Workflo return null; } - private boolean hasMatchingScoreTransition( - Node node, HensuState state, io.hensu.core.execution.executor.NodeResult result) { + private boolean hasMatchingScoreTransition(Node node, HensuState state, NodeResult result) { for (TransitionRule rule : node.getTransitionRules()) { - if (rule instanceof ScoreTransition st && st.evaluate(state, result) != null) { + if (rule.trigger() instanceof ScoreTransition && rule.evaluate(state, result) != null) { return true; } } diff --git a/hensu-core/src/main/java/io/hensu/core/execution/pipeline/TransitionPostProcessor.java b/hensu-core/src/main/java/io/hensu/core/execution/pipeline/TransitionPostProcessor.java index b729edd..a8fc253 100644 --- a/hensu-core/src/main/java/io/hensu/core/execution/pipeline/TransitionPostProcessor.java +++ b/hensu-core/src/main/java/io/hensu/core/execution/pipeline/TransitionPostProcessor.java @@ -1,7 +1,15 @@ package io.hensu.core.execution.pipeline; +import io.hensu.core.execution.EngineVariables; +import io.hensu.core.execution.parallel.ConsensusResult; +import io.hensu.core.execution.parallel.ConsensusStrategy; +import io.hensu.core.state.HensuState; import io.hensu.core.workflow.node.LoopNode; +import io.hensu.core.workflow.transition.BoundedTransition; +import io.hensu.core.workflow.transition.FailureTransition; +import io.hensu.core.workflow.transition.NoConsensusTransition; import io.hensu.core.workflow.transition.TransitionRule; +import java.util.stream.Collectors; /// Evaluates transition rules to determine the next node after execution. /// @@ -64,7 +72,7 @@ private String resolveNextNode(ProcessorContext context) { } if (node instanceof LoopNode) { - String loopExit = (String) state.getContext().get("loop_exit_target"); + String loopExit = (String) state.getContext().remove("loop_exit_target"); if (loopExit != null) { return loopExit; } @@ -73,10 +81,79 @@ private String resolveNextNode(ProcessorContext context) { for (TransitionRule rule : node.getTransitionRules()) { String target = rule.evaluate(state, context.result()); if (target != null) { + applyTransitionEffects(state, rule); return target; } } throw new IllegalStateException("No valid transition from " + node.getId()); } + + /// Applies retry-counter, feedback, and engine var cleanup after a transition rule matches. + /// + /// Engine variable lifecycle is centralized here — no other component clears engine vars + /// from the state context. Three paths: + /// - **Backtrack** (bounded, under budget): clear routing vars (score, approved). Keep + /// recommendation for {@link io.hensu.core.execution.enricher.FeedbackContextInjector}, + /// except for failure retries where no agent feedback exists. + /// - **Forward with feedback** ({@link TransitionRule#withFeedback()} is true): clear + /// routing vars but keep recommendation so the target node sees evaluation context. + /// - **Forward** (default): clear all engine vars. + /// + /// @param state current workflow state (mutated in place) + /// @param rule the transition rule that matched + private void applyTransitionEffects(HensuState state, TransitionRule rule) { + String nodeId = state.getCurrentNode(); + if (rule instanceof BoundedTransition bt && bt.underBudget(state)) { + state.incrementRetryCount(bt.namespace(), nodeId); + if (bt.inner() instanceof NoConsensusTransition) { + injectConsensusFeedback(state, nodeId); + } + state.getContext().remove(EngineVariables.SCORE); + state.getContext().remove(EngineVariables.APPROVED); + if (bt.inner() instanceof FailureTransition) { + state.getContext().remove(EngineVariables.RECOMMENDATION); + } + return; + } + state.resetRetryCounts(nodeId); + if (rule.withFeedback()) { + state.getContext().remove(EngineVariables.SCORE); + state.getContext().remove(EngineVariables.APPROVED); + } else { + EngineVariables.all().forEach(state.getContext().keySet()::remove); + } + } + + /// Injects prior-round consensus feedback into the state context so the producer + /// agent can see why consensus failed and adjust its output. + /// + /// Reads the {@code consensus_result:} context key written by + /// {@code ParallelNodeExecutor} and formats vote details into + /// {@link EngineVariables#RECOMMENDATION}. For {@code JUDGE_DECIDES} strategies the + /// judge's reasoning is used directly; for algorithmic strategies each vote's output + /// is joined with separator lines. + /// + /// @param state current workflow state (context is mutated) + /// @param nodeId the parallel node whose consensus failed + private void injectConsensusFeedback(HensuState state, String nodeId) { + Object raw = state.getContext().remove("consensus_result:" + nodeId); + if (!(raw instanceof ConsensusResult cr)) { + return; + } + + String feedback; + if (cr.strategyUsed() == ConsensusStrategy.JUDGE_DECIDES && cr.reasoning() != null) { + feedback = cr.reasoning(); + } else { + feedback = + cr.votes().values().stream() + .map(v -> v.branchId() + " (" + v.voteType() + "): " + v.output()) + .collect(Collectors.joining("\n---\n")); + } + + if (!feedback.isBlank()) { + state.getContext().put(EngineVariables.RECOMMENDATION, feedback); + } + } } diff --git a/hensu-core/src/main/java/io/hensu/core/rubric/model/Rubric.java b/hensu-core/src/main/java/io/hensu/core/rubric/model/Rubric.java index 441ade5..8fc3fa0 100644 --- a/hensu-core/src/main/java/io/hensu/core/rubric/model/Rubric.java +++ b/hensu-core/src/main/java/io/hensu/core/rubric/model/Rubric.java @@ -196,4 +196,14 @@ public Rubric build() { return new Rubric(this); } } + + @Override + public boolean equals(Object o) { + return this == o || (o instanceof Rubric r && Objects.equals(id, r.id)); + } + + @Override + public int hashCode() { + return Objects.hashCode(id); + } } diff --git a/hensu-core/src/main/java/io/hensu/core/state/HensuSnapshot.java b/hensu-core/src/main/java/io/hensu/core/state/HensuSnapshot.java index 93184a6..ee02ac3 100644 --- a/hensu-core/src/main/java/io/hensu/core/state/HensuSnapshot.java +++ b/hensu-core/src/main/java/io/hensu/core/state/HensuSnapshot.java @@ -34,6 +34,7 @@ /// @param executionId unique identifier for this execution run, not null /// @param currentNodeId the node where execution should resume, may be null if completed /// @param context workflow variables and data, not null +/// @param retryCounters per-node retry budgets keyed by `namespace:nodeId`, not null /// @param history execution history including steps and backtracks, not null /// @param activePlan current micro-plan state if planning is active, may be null /// @param phase execution phase within the current node's lifecycle, not null after construction @@ -47,6 +48,7 @@ public record HensuSnapshot( String executionId, String currentNodeId, Map context, + Map retryCounters, ExecutionHistory history, Plan activePlan, ExecutionPhase phase, @@ -66,6 +68,7 @@ public record HensuSnapshot( Objects.requireNonNull(executionId, "executionId must not be null"); //noinspection Java9CollectionFactory context = context != null ? Collections.unmodifiableMap(new HashMap<>(context)) : Map.of(); + retryCounters = retryCounters != null ? Map.copyOf(retryCounters) : Map.of(); history = history != null ? history.copy() : new ExecutionHistory(); phase = phase != null ? phase : ExecutionPhase.INITIAL; createdAt = createdAt != null ? createdAt : Instant.now(); @@ -94,6 +97,7 @@ public static HensuSnapshot from(HensuState state, String reason) { state.getExecutionId(), state.getCurrentNode(), state.getContext(), + state.getRetryCounters(), state.getHistory(), state.getActivePlan(), state.getPhase(), @@ -110,6 +114,7 @@ public HensuState toState() { .executionId(executionId) .currentNode(currentNodeId) .context(context) + .retryCounters(retryCounters) .history(history) .activePlan(activePlan) .phase(phase) diff --git a/hensu-core/src/main/java/io/hensu/core/state/HensuState.java b/hensu-core/src/main/java/io/hensu/core/state/HensuState.java index 7a7ca1b..bc0501c 100644 --- a/hensu-core/src/main/java/io/hensu/core/state/HensuState.java +++ b/hensu-core/src/main/java/io/hensu/core/state/HensuState.java @@ -1,11 +1,11 @@ package io.hensu.core.state; -import io.hensu.core.execution.result.BacktrackEvent; import io.hensu.core.execution.result.ExecutionHistory; import io.hensu.core.execution.result.ExecutionStep; import io.hensu.core.plan.Plan; import io.hensu.core.resume.ResumeInput; import io.hensu.core.rubric.evaluator.RubricEvaluation; +import java.util.Collections; import java.util.HashMap; import java.util.Map; import java.util.Objects; @@ -22,7 +22,7 @@ /// - **Immutable**: `executionId`, `workflowId` (set at construction) /// - **Mutable context**: `context` map for variable storage /// - **Mutable position**: `currentNode`, `loopBreakTarget` -/// - **Mutable evaluation**: `rubricEvaluation`, `retryCount` +/// - **Mutable evaluation**: `rubricEvaluation`, `retryCounters` /// - **Append-only**: `history` for execution tracking /// /// ### Thread Safety @@ -44,7 +44,7 @@ public final class HensuState { private Plan activePlan; private RubricEvaluation rubricEvaluation; private String currentNode; - private int retryCount; + private final Map retryCounters; private String loopBreakTarget; private ExecutionPhase phase = ExecutionPhase.INITIAL; @@ -63,7 +63,7 @@ public HensuState(Builder builder) { this.history = builder.history; this.activePlan = builder.activePlan; this.rubricEvaluation = builder.rubricEvaluation; - this.retryCount = builder.retryCount; + this.retryCounters = new HashMap<>(builder.retryCounters); this.phase = builder.phase != null ? builder.phase : ExecutionPhase.INITIAL; } @@ -77,6 +77,7 @@ public HensuState( this.workflowId = workflowId; this.currentNode = currentNode; this.history = history; + this.retryCounters = new HashMap<>(); } // Getters @@ -108,10 +109,6 @@ public RubricEvaluation getRubricEvaluation() { return rubricEvaluation; } - public int getRetryCount() { - return retryCount; - } - public String getCurrentNode() { return currentNode; } @@ -120,8 +117,36 @@ public void setCurrentNode(String currentNode) { this.currentNode = currentNode; } - public void incrementRetryCount() { - ++retryCount; + /// Returns the retry count for the given namespace and node, or 0 if none recorded. + /// + /// @param namespace counter namespace isolating budgets per trigger kind + /// @param nodeId the node the budget applies to + /// @return current count, never negative + public int getRetryCount(String namespace, String nodeId) { + return retryCounters.getOrDefault(namespace + ":" + nodeId, 0); + } + + /// Increments the retry count for the given namespace and node. + /// + /// @param namespace counter namespace isolating budgets per trigger kind + /// @param nodeId the node the budget applies to + public void incrementRetryCount(String namespace, String nodeId) { + retryCounters.merge(namespace + ":" + nodeId, 1, Integer::sum); + } + + /// Clears counters in all namespaces for the given node — called when the node + /// transitions forward (any non-revise transition). + /// + /// @param nodeId the node whose counters are cleared + public void resetRetryCounts(String nodeId) { + retryCounters.keySet().removeIf(k -> k.endsWith(":" + nodeId)); + } + + /// Returns a read-only view of all retry counters keyed by `namespace:nodeId`. + /// + /// @return unmodifiable view of the counter map, never null + public Map getRetryCounters() { + return Collections.unmodifiableMap(retryCounters); } public void setRubricEvaluation(RubricEvaluation rubricEvaluation) { @@ -204,13 +229,6 @@ public HensuState withRubricEvaluation(RubricEvaluation evaluation) { return toBuilder().rubricEvaluation(evaluation).build(); } - /// Add backtrack event (immutable). - public HensuState withBacktrack(BacktrackEvent backtrack) { - ExecutionHistory newHistory = history.addBacktrack(backtrack); - - return toBuilder().history(newHistory).retryCount(retryCount + 1).build(); - } - /// Creates an isolated state copy for concurrent branch execution. /// /// Returns a new `HensuState` with a defensive copy of the context map, positioned @@ -229,6 +247,7 @@ public HensuState branch(String branchNode) { .currentNode(branchNode) .context(context) .history(history) + .retryCounters(retryCounters) .build(); } @@ -263,7 +282,7 @@ private Builder toBuilder() { .history(history) .activePlan(activePlan) .rubricEvaluation(rubricEvaluation) - .retryCount(retryCount) + .retryCounters(retryCounters) .phase(phase); } @@ -275,7 +294,7 @@ public static final class Builder { private ExecutionHistory history = new ExecutionHistory(); private Plan activePlan; private RubricEvaluation rubricEvaluation; - private int retryCount = 0; + private Map retryCounters = new HashMap<>(); private ExecutionPhase phase = ExecutionPhase.INITIAL; public Builder() {} @@ -315,8 +334,8 @@ public Builder rubricEvaluation(RubricEvaluation evaluation) { return this; } - public Builder retryCount(int retryCount) { - this.retryCount = retryCount; + public Builder retryCounters(Map retryCounters) { + this.retryCounters = new HashMap<>(retryCounters); // defensive copy — B3 return this; } diff --git a/hensu-core/src/main/java/io/hensu/core/workflow/node/NodeTargets.java b/hensu-core/src/main/java/io/hensu/core/workflow/node/NodeTargets.java new file mode 100644 index 0000000..00020c7 --- /dev/null +++ b/hensu-core/src/main/java/io/hensu/core/workflow/node/NodeTargets.java @@ -0,0 +1,50 @@ +package io.hensu.core.workflow.node; + +import io.hensu.core.workflow.transition.BreakRule; +import io.hensu.core.workflow.transition.TransitionRule; +import io.hensu.core.workflow.transition.TransitionTargets; +import java.util.ArrayList; +import java.util.List; + +/// Enumerates every node ID a node can route control flow to, across all node types. +/// +/// This is the single source of truth for graph traversal — used by reachability +/// analysis in {@code WorkflowValidateCommand} and by the visualization formats. +/// It layers node-specific successors on top of {@link TransitionTargets}, which only +/// understands transition rules: +/// +/// - All nodes contribute their {@link Node#getTransitionRules() transition rule} targets. +/// - {@link ForkNode} additionally contributes its fork {@link ForkNode#getTargets() targets}. +/// - {@link LoopNode} additionally contributes its {@link BreakRule break-rule} targets. +/// +/// A {@link JoinNode}'s await targets are deliberately excluded: they are predecessors that +/// feed into the join, not successors it routes to. +/// +/// @see TransitionTargets for the rule-level target extraction this builds on +public final class NodeTargets { + + private NodeTargets() {} + + /// Returns every node ID the given node can transition to. + /// + /// @param node the node to inspect, not null + /// @return list of successor node IDs (may be empty, never null) + public static List of(Node node) { + List targets = new ArrayList<>(); + for (TransitionRule rule : node.getTransitionRules()) { + targets.addAll(TransitionTargets.of(rule)); + } + switch (node) { + case ForkNode fork -> targets.addAll(fork.getTargets()); + case LoopNode loop -> { + if (loop.getBreakRules() != null) { + for (BreakRule rule : loop.getBreakRules()) { + targets.add(rule.getTargetNode()); + } + } + } + default -> {} + } + return targets; + } +} diff --git a/hensu-core/src/main/java/io/hensu/core/workflow/transition/AlwaysTransition.java b/hensu-core/src/main/java/io/hensu/core/workflow/transition/AlwaysTransition.java index 0626ea3..8d017ff 100644 --- a/hensu-core/src/main/java/io/hensu/core/workflow/transition/AlwaysTransition.java +++ b/hensu-core/src/main/java/io/hensu/core/workflow/transition/AlwaysTransition.java @@ -3,7 +3,16 @@ import io.hensu.core.execution.executor.NodeResult; import io.hensu.core.state.HensuState; -public non-sealed class AlwaysTransition implements TransitionRule { +/// Unconditional transition that always fires. +/// +/// @param withFeedback when true, recommendation survives this transition +public record AlwaysTransition(boolean withFeedback) implements TransitionRule { + + /// Creates an always transition without feedback preservation. + public AlwaysTransition() { + this(false); + } + @Override public String evaluate(HensuState state, NodeResult result) { return ""; diff --git a/hensu-core/src/main/java/io/hensu/core/workflow/transition/ApprovalTransition.java b/hensu-core/src/main/java/io/hensu/core/workflow/transition/ApprovalTransition.java index a0023ea..f053055 100644 --- a/hensu-core/src/main/java/io/hensu/core/workflow/transition/ApprovalTransition.java +++ b/hensu-core/src/main/java/io/hensu/core/workflow/transition/ApprovalTransition.java @@ -4,6 +4,7 @@ import io.hensu.core.execution.executor.NodeResult; import io.hensu.core.state.HensuState; import java.util.Map; +import java.util.Set; /// Boolean approval transition that routes based on the `approved` engine variable. /// @@ -26,14 +27,26 @@ /// } /// ``` /// -/// @param expected `true` to match approval; `false` to match rejection -/// @param targetNode node to route to when the context value equals `expected`, not null +/// @param expected `true` to match approval; `false` to match rejection +/// @param targetNode node to route to when the context value equals `expected`, not null +/// @param withFeedback when true, recommendation survives this transition /// @see TransitionRule for the evaluation contract /// @see ScoreTransition for numeric score-based routing /// @see io.hensu.core.workflow.state.WorkflowStateSchema#ENGINE_VARIABLES /// /// @implNote **Immutable.** Safe for concurrent evaluation in parallel workflow branches. -public record ApprovalTransition(boolean expected, String targetNode) implements TransitionRule { +public record ApprovalTransition(boolean expected, String targetNode, boolean withFeedback) + implements TransitionRule { + + /// Creates an approval transition without feedback preservation. + public ApprovalTransition(boolean expected, String targetNode) { + this(expected, targetNode, false); + } + + @Override + public Set requiredEngineVars() { + return Set.of(EngineVariables.APPROVED, EngineVariables.RECOMMENDATION); + } @Override public String evaluate(HensuState state, NodeResult result) { diff --git a/hensu-core/src/main/java/io/hensu/core/workflow/transition/BoundedTransition.java b/hensu-core/src/main/java/io/hensu/core/workflow/transition/BoundedTransition.java new file mode 100644 index 0000000..3a8c942 --- /dev/null +++ b/hensu-core/src/main/java/io/hensu/core/workflow/transition/BoundedTransition.java @@ -0,0 +1,73 @@ +package io.hensu.core.workflow.transition; + +import io.hensu.core.execution.executor.NodeResult; +import io.hensu.core.state.HensuState; +import java.util.Set; + +/// Decorates any transition trigger with a per-node retry budget and an escalation target. +/// +/// When the inner trigger fires, routes to the inner target while the namespaced counter +/// ({@code namespace:nodeId}) is under budget, then escalates to {@code otherwise}. Evaluation +/// is pure – the counter increment happens in {@code TransitionPostProcessor} after the rule +/// matches, using the same {@link #underBudget(HensuState)} predicate (single source of truth). +/// +/// ### Namespaces +/// Each trigger kind uses its own namespace to isolate budgets: +/// - {@code failure} – agent execution failures (retry) +/// - {@code consensus} – parallel-node consensus failures (revise) +/// - {@code approval} – reviewer rejections (revise) +/// - {@code score} – rubric score below threshold (revise) +/// +/// @param inner the trigger rule supplying the condition and the revise/retry +/// target +/// @param namespace counter namespace isolating budgets per trigger kind +/// @param budget maximum attempts before escalation (must be positive) +/// @param otherwise escalation node when the budget is exhausted, not null +/// @param escalationWithFeedback when true, recommendation survives the escalation transition +/// (over budget) +/// @see TransitionRule#trigger() for unwrapping to the inner rule +/// @see TransitionRule#requiredEngineVars() for engine-variable delegation +public record BoundedTransition( + TransitionRule inner, + String namespace, + int budget, + String otherwise, + boolean escalationWithFeedback) + implements TransitionRule { + + /// Creates a bounded transition without escalation feedback preservation. + public BoundedTransition(TransitionRule inner, String namespace, int budget, String otherwise) { + this(inner, namespace, budget, otherwise, false); + } + + @Override + public String evaluate(HensuState state, NodeResult result) { + String target = inner.evaluate(state, result); + if (target == null) return null; + return underBudget(state) ? target : otherwise; + } + + /// Single source of truth for the budget-check predicate – shared by {@code evaluate()} + /// and {@code TransitionPostProcessor.applyTransitionEffects} so the two can never diverge. + /// + /// @param state current workflow state providing the retry counter map + /// @return true if the counter for this namespace and current node is below the budget + public boolean underBudget(HensuState state) { + return state.getRetryCount(namespace, state.getCurrentNode()) < budget; + } + + @Override + public boolean withFeedback() { + return escalationWithFeedback; + } + + @Override + public Set requiredEngineVars() { + return inner.requiredEngineVars(); + } + + @Override + public TransitionRule trigger() { + return inner.trigger(); + } +} diff --git a/hensu-core/src/main/java/io/hensu/core/workflow/transition/FailureTransition.java b/hensu-core/src/main/java/io/hensu/core/workflow/transition/FailureTransition.java index 35a1181..c159864 100644 --- a/hensu-core/src/main/java/io/hensu/core/workflow/transition/FailureTransition.java +++ b/hensu-core/src/main/java/io/hensu/core/workflow/transition/FailureTransition.java @@ -4,26 +4,28 @@ import io.hensu.core.execution.result.ResultStatus; import io.hensu.core.state.HensuState; -public record FailureTransition(Integer retryCount, String targetNode) implements TransitionRule { +/// Pure failure trigger – routes on {@link ResultStatus#FAILURE} without mutating state. +/// +/// A {@code null} target means "retry the current node" (self-loop), used by the +/// {@code onFailure retry} DSL desugaring. Counter management and budget enforcement +/// are handled by wrapping this rule in {@link BoundedTransition}. +/// +/// Consensus failures ({@code consensus_reached = false} in result metadata) are +/// explicitly excluded – those belong to {@link NoConsensusTransition}. +/// +/// @param targetNode node to transition to on failure, or null for self-loop retry +/// @see BoundedTransition for retry budgeting +/// @see NoConsensusTransition for the consensus-failure counterpart +public record FailureTransition(String targetNode) implements TransitionRule { @Override public String evaluate(HensuState state, NodeResult result) { - if (result.getStatus() != ResultStatus.FAILURE) { - return null; - } - if (state.getRetryCount() < retryCount) { - state.incrementRetryCount(); - return state.getCurrentNode(); // Retry same node - } else { - return targetNode; - } - } + if (result.getStatus() != ResultStatus.FAILURE) return null; - public Integer getRetryCount() { - return retryCount; - } + // B2: consensus failures belong to NoConsensusTransition + Object consensusReached = result.getMetadata().get("consensus_reached"); + if (Boolean.FALSE.equals(consensusReached)) return null; - public String getThenTargetNode() { - return targetNode; + return targetNode != null ? targetNode : state.getCurrentNode(); } } diff --git a/hensu-core/src/main/java/io/hensu/core/workflow/transition/NoConsensusTransition.java b/hensu-core/src/main/java/io/hensu/core/workflow/transition/NoConsensusTransition.java new file mode 100644 index 0000000..86ea3dc --- /dev/null +++ b/hensu-core/src/main/java/io/hensu/core/workflow/transition/NoConsensusTransition.java @@ -0,0 +1,33 @@ +package io.hensu.core.workflow.transition; + +import io.hensu.core.execution.executor.NodeResult; +import io.hensu.core.execution.result.ResultStatus; +import io.hensu.core.state.HensuState; + +/// Pure consensus-failure trigger – routes when a parallel node fails to reach consensus. +/// +/// Fires only when the result status is {@link ResultStatus#FAILURE} and the result metadata +/// contains {@code consensus_reached = false} (set by {@code ParallelNodeExecutor}). This is +/// the consensus-specific counterpart to {@link FailureTransition}, which explicitly excludes +/// consensus failures. +/// +/// @param targetNode node to transition to when consensus fails, not null +/// @param withFeedback when true, recommendation survives this transition +/// @see FailureTransition for agent-execution failures +/// @see BoundedTransition for wrapping with a retry budget +public record NoConsensusTransition(String targetNode, boolean withFeedback) + implements TransitionRule { + + /// Creates a no-consensus transition without feedback preservation. + public NoConsensusTransition(String targetNode) { + this(targetNode, false); + } + + @Override + public String evaluate(HensuState state, NodeResult result) { + if (result.getStatus() != ResultStatus.FAILURE) return null; + Object consensusReached = result.getMetadata().get("consensus_reached"); + if (!Boolean.FALSE.equals(consensusReached)) return null; + return targetNode; + } +} diff --git a/hensu-core/src/main/java/io/hensu/core/workflow/transition/RubricFailTransition.java b/hensu-core/src/main/java/io/hensu/core/workflow/transition/RubricFailTransition.java index 1d85ef5..20c906c 100644 --- a/hensu-core/src/main/java/io/hensu/core/workflow/transition/RubricFailTransition.java +++ b/hensu-core/src/main/java/io/hensu/core/workflow/transition/RubricFailTransition.java @@ -5,8 +5,20 @@ import io.hensu.core.state.HensuState; import java.util.function.Function; -public record RubricFailTransition(Function function) +/// Transition rule that fires when rubric evaluation fails, delegating target +/// resolution to a predicate function. +/// +/// @param function predicate mapping rubric evaluation to target node, not null +/// @param withFeedback when true, recommendation survives this transition +public record RubricFailTransition( + Function function, boolean withFeedback) implements TransitionRule { + + /// Creates a rubric-fail transition without feedback preservation. + public RubricFailTransition(Function function) { + this(function, false); + } + @Override public String evaluate(HensuState state, NodeResult result) { return function.apply(state.getRubricEvaluation()); diff --git a/hensu-core/src/main/java/io/hensu/core/workflow/transition/ScoreTransition.java b/hensu-core/src/main/java/io/hensu/core/workflow/transition/ScoreTransition.java index 8df074a..4301fca 100644 --- a/hensu-core/src/main/java/io/hensu/core/workflow/transition/ScoreTransition.java +++ b/hensu-core/src/main/java/io/hensu/core/workflow/transition/ScoreTransition.java @@ -6,6 +6,7 @@ import io.hensu.core.state.HensuState; import java.util.List; import java.util.Map; +import java.util.Set; /// Score-based transition rule that evaluates conditions against the agent-reported score. /// @@ -14,16 +15,28 @@ /// JSON response (via `writes("score")` declared on the node). /// /// @param conditions list of score conditions to evaluate in order, not null +/// @param withFeedback when true, recommendation survives this transition /// @see ScoreCondition for condition matching logic /// @see TransitionRule for transition evaluation contract /// /// @implNote **Immutable after construction.** The conditions list is defensively copied. -public record ScoreTransition(List conditions) implements TransitionRule { +public record ScoreTransition(List conditions, boolean withFeedback) + implements TransitionRule { + + /// Creates a score transition without feedback preservation. + public ScoreTransition(List conditions) { + this(conditions, false); + } public ScoreTransition { conditions = List.copyOf(conditions); } + @Override + public Set requiredEngineVars() { + return Set.of(EngineVariables.SCORE, EngineVariables.RECOMMENDATION); + } + @Override public String evaluate(HensuState state, NodeResult result) { Double score = extractScore(state); diff --git a/hensu-core/src/main/java/io/hensu/core/workflow/transition/SuccessTransition.java b/hensu-core/src/main/java/io/hensu/core/workflow/transition/SuccessTransition.java index c866b79..02260b0 100644 --- a/hensu-core/src/main/java/io/hensu/core/workflow/transition/SuccessTransition.java +++ b/hensu-core/src/main/java/io/hensu/core/workflow/transition/SuccessTransition.java @@ -9,10 +9,16 @@ /// Returns the target node when the node result status is {@link ResultStatus#SUCCESS}. /// This is the most common transition type for happy-path workflow progression. /// -/// @param targetNode the node ID to transition to on success, not null +/// @param targetNode the node ID to transition to on success, not null +/// @param withFeedback when true, recommendation survives this transition /// @see FailureTransition for the complementary failure case /// @see TransitionRule for transition evaluation contract -public record SuccessTransition(String targetNode) implements TransitionRule { +public record SuccessTransition(String targetNode, boolean withFeedback) implements TransitionRule { + + /// Creates a success transition without feedback preservation. + public SuccessTransition(String targetNode) { + this(targetNode, false); + } /// Evaluates whether execution succeeded. /// diff --git a/hensu-core/src/main/java/io/hensu/core/workflow/transition/TransitionRule.java b/hensu-core/src/main/java/io/hensu/core/workflow/transition/TransitionRule.java index a7d1a90..9171c92 100644 --- a/hensu-core/src/main/java/io/hensu/core/workflow/transition/TransitionRule.java +++ b/hensu-core/src/main/java/io/hensu/core/workflow/transition/TransitionRule.java @@ -2,6 +2,7 @@ import io.hensu.core.execution.executor.NodeResult; import io.hensu.core.state.HensuState; +import java.util.Set; /// Sealed interface for workflow transition rules. /// @@ -10,12 +11,20 @@ /// node completes; the first rule returning a non-null target is used. /// /// ### Permitted Implementations -/// - {@link AlwaysTransition} - Unconditional transition to target -/// - {@link SuccessTransition} - Transitions on successful execution -/// - {@link FailureTransition} - Transitions on failed execution -/// - {@link ScoreTransition} - Conditional on rubric score thresholds -/// - {@link RubricFailTransition} - Transitions when rubric evaluation fails -/// - {@link ApprovalTransition} - Conditional on the `approved` boolean engine variable +/// - {@link AlwaysTransition} – unconditional transition to target +/// - {@link SuccessTransition} – transitions on successful execution +/// - {@link FailureTransition} – transitions on failed execution (retry) +/// - {@link NoConsensusTransition} – transitions when a parallel node reaches no consensus +/// - {@link ScoreTransition} – conditional on rubric score thresholds +/// - {@link RubricFailTransition} – transitions when rubric evaluation fails +/// - {@link ApprovalTransition} – conditional on the `approved` boolean engine variable +/// - {@link BoundedTransition} – decorates any trigger with a per-node retry budget and +/// an escalation target +/// +/// ### Capability Methods +/// Engine components that need to know *what* a rule routes on MUST call +/// {@link #requiredEngineVars()} – never {@code instanceof}. This keeps decorators +/// (e.g. {@link BoundedTransition}) transparent by construction. /// /// @implNote Implementations must be immutable and stateless. The same rule /// instance may be evaluated concurrently for parallel workflow branches. @@ -25,7 +34,9 @@ public sealed interface TransitionRule permits AlwaysTransition, ApprovalTransition, + BoundedTransition, FailureTransition, + NoConsensusTransition, RubricFailTransition, ScoreTransition, SuccessTransition { @@ -36,4 +47,46 @@ public sealed interface TransitionRule /// @param result the node execution result to evaluate, not null /// @return target node ID if rule applies, null otherwise String evaluate(HensuState state, NodeResult result); + + /// Returns the engine variables this rule routes on, which the engine must + /// instruct the agent to produce and extract from its output. Decorators + /// delegate to their inner rule. Empty for rules that route on execution + /// status alone. + /// + /// Engine components (prompt injectors, output extraction) MUST consume this + /// declaration – never {@code instanceof} on rule types – so decorated rules + /// keep their semantics. + /// + /// @return the engine variable names this rule depends on, never null + default Set requiredEngineVars() { + return Set.of(); + } + + /// Returns whether this transition preserves the + /// {@link io.hensu.core.execution.EngineVariables#RECOMMENDATION} + /// value across the transition, allowing the target node to see feedback from + /// the source node's evaluation. + /// + /// When {@code true}, {@link io.hensu.core.execution.pipeline.TransitionPostProcessor} + /// clears routing variables ({@code score}, {@code approved}) but keeps + /// {@code recommendation} in the state context. When {@code false} (default), + /// all engine variables are cleared on forward transitions. + /// + /// Records that declare a {@code boolean withFeedback} component override this + /// method automatically via the generated accessor – no explicit override needed. + /// + /// @return true if recommendation should survive this transition + default boolean withFeedback() { + return false; + } + + /// Returns the underlying trigger rule. Decorators return their wrapped rule; + /// plain rules return themselves. Use for components that must evaluate or label + /// the inner rule (rubric matching, visualization). For engine-variable wiring + /// prefer {@link #requiredEngineVars()}. + /// + /// @return the leaf trigger rule, never null + default TransitionRule trigger() { + return this; + } } diff --git a/hensu-core/src/main/java/io/hensu/core/workflow/transition/TransitionTargets.java b/hensu-core/src/main/java/io/hensu/core/workflow/transition/TransitionTargets.java new file mode 100644 index 0000000..ac925a3 --- /dev/null +++ b/hensu-core/src/main/java/io/hensu/core/workflow/transition/TransitionTargets.java @@ -0,0 +1,36 @@ +package io.hensu.core.workflow.transition; + +import io.hensu.core.rubric.model.ScoreCondition; +import java.util.List; +import java.util.stream.Stream; + +/// Extracts every node ID a transition rule can route to, including decorator escalation targets. +/// +/// This is the single source of truth for reachability analysis — used by +/// {@code WorkflowValidator}, {@code WorkflowValidateCommand}, and the visualization formats. +/// Null targets (the self-loop form of {@link FailureTransition}) are omitted. +/// +/// @see BoundedTransition for rules that have both inner targets and an escalation target +public final class TransitionTargets { + + private TransitionTargets() {} + + /// Returns every node ID the given rule can transition to. + /// + /// @param rule the transition rule to inspect, not null + /// @return list of reachable node IDs (may be empty, never null) + public static List of(TransitionRule rule) { + return switch (rule) { + case SuccessTransition s -> List.of(s.targetNode()); + case FailureTransition f -> + f.targetNode() != null ? List.of(f.targetNode()) : List.of(); + case ScoreTransition s -> + s.conditions().stream().map(ScoreCondition::targetNode).toList(); + case ApprovalTransition a -> List.of(a.targetNode()); + case NoConsensusTransition n -> List.of(n.targetNode()); + case BoundedTransition b -> + Stream.concat(of(b.inner()).stream(), Stream.of(b.otherwise())).toList(); + case AlwaysTransition _, RubricFailTransition _ -> List.of(); + }; + } +} diff --git a/hensu-core/src/main/java/io/hensu/core/workflow/validation/WorkflowValidator.java b/hensu-core/src/main/java/io/hensu/core/workflow/validation/WorkflowValidator.java index 245ab02..86caf16 100644 --- a/hensu-core/src/main/java/io/hensu/core/workflow/validation/WorkflowValidator.java +++ b/hensu-core/src/main/java/io/hensu/core/workflow/validation/WorkflowValidator.java @@ -1,12 +1,12 @@ package io.hensu.core.workflow.validation; -import io.hensu.core.rubric.model.ScoreCondition; import io.hensu.core.workflow.Workflow; import io.hensu.core.workflow.node.Node; import io.hensu.core.workflow.node.StandardNode; import io.hensu.core.workflow.node.SubWorkflowNode; import io.hensu.core.workflow.state.WorkflowStateSchema; -import io.hensu.core.workflow.transition.*; +import io.hensu.core.workflow.transition.TransitionRule; +import io.hensu.core.workflow.transition.TransitionTargets; import java.util.ArrayList; import java.util.List; import java.util.Map; @@ -110,7 +110,7 @@ private static void validateTransitionTargets(Workflow workflow, List er if (!(node instanceof StandardNode standardNode)) continue; for (TransitionRule rule : standardNode.getTransitionRules()) { - for (String target : extractTargets(rule)) { + for (String target : TransitionTargets.of(rule)) { if (!nodeIds.contains(target)) { errors.add( "Node '" @@ -124,18 +124,6 @@ private static void validateTransitionTargets(Workflow workflow, List er } } - private static List extractTargets(TransitionRule rule) { - return switch (rule) { - case SuccessTransition s -> List.of(s.targetNode()); - case FailureTransition f -> - f.targetNode() != null ? List.of(f.targetNode()) : List.of(); - case ScoreTransition s -> - s.conditions().stream().map(ScoreCondition::targetNode).toList(); - case ApprovalTransition a -> List.of(a.targetNode()); - case AlwaysTransition _, RubricFailTransition _ -> List.of(); - }; - } - private static void throwErrors(Workflow workflow, List errors) { throw new IllegalStateException( "Workflow '" diff --git a/hensu-core/src/test/java/io/hensu/core/execution/WorkflowExecutorActionNodeTest.java b/hensu-core/src/test/java/io/hensu/core/execution/WorkflowExecutorActionNodeTest.java index 7e77aa9..ef31ca4 100644 --- a/hensu-core/src/test/java/io/hensu/core/execution/WorkflowExecutorActionNodeTest.java +++ b/hensu-core/src/test/java/io/hensu/core/execution/WorkflowExecutorActionNodeTest.java @@ -82,7 +82,7 @@ void shouldPropagateUncheckedExceptionFromActionExecutor() { .transitionRules( List.of( new SuccessTransition("success-end"), - new FailureTransition(0, "failure-end"))) + new FailureTransition("failure-end"))) .build()); nodes.put("success-end", end("success-end")); nodes.put("failure-end", failEnd("failure-end")); @@ -108,7 +108,7 @@ void shouldHandleActionFailure() throws Exception { .transitionRules( List.of( new SuccessTransition("success-end"), - new FailureTransition(0, "failure-end"))) + new FailureTransition("failure-end"))) .build()); nodes.put("success-end", end("success-end")); nodes.put("failure-end", failEnd("failure-end")); diff --git a/hensu-core/src/test/java/io/hensu/core/execution/WorkflowExecutorForkJoinTest.java b/hensu-core/src/test/java/io/hensu/core/execution/WorkflowExecutorForkJoinTest.java index 596a61d..40df554 100644 --- a/hensu-core/src/test/java/io/hensu/core/execution/WorkflowExecutorForkJoinTest.java +++ b/hensu-core/src/test/java/io/hensu/core/execution/WorkflowExecutorForkJoinTest.java @@ -443,7 +443,7 @@ private Workflow buildForkJoinWorkflow(MergeStrategy mergeStrategy, boolean fail .transitionRules( List.of( new SuccessTransition("end"), - new FailureTransition(0, "fail-end"))) + new FailureTransition("fail-end"))) .build()); nodes.put("end", end("end")); nodes.put("fail-end", failEnd("fail-end")); diff --git a/hensu-core/src/test/java/io/hensu/core/execution/WorkflowExecutorGenericNodeTest.java b/hensu-core/src/test/java/io/hensu/core/execution/WorkflowExecutorGenericNodeTest.java index 3533c2a..38c3d77 100644 --- a/hensu-core/src/test/java/io/hensu/core/execution/WorkflowExecutorGenericNodeTest.java +++ b/hensu-core/src/test/java/io/hensu/core/execution/WorkflowExecutorGenericNodeTest.java @@ -105,7 +105,7 @@ public NodeResult handle(GenericNode node, ExecutionContext context) { .transitionRules( List.of( new SuccessTransition("success-end"), - new FailureTransition(0, "failure-end"))) + new FailureTransition("failure-end"))) .build()); nodes.put("success-end", end("success-end")); nodes.put("failure-end", failEnd("failure-end")); diff --git a/hensu-core/src/test/java/io/hensu/core/execution/WorkflowExecutorParallelTest.java b/hensu-core/src/test/java/io/hensu/core/execution/WorkflowExecutorParallelTest.java index 367732d..3aa5fb7 100644 --- a/hensu-core/src/test/java/io/hensu/core/execution/WorkflowExecutorParallelTest.java +++ b/hensu-core/src/test/java/io/hensu/core/execution/WorkflowExecutorParallelTest.java @@ -16,6 +16,7 @@ import io.hensu.core.workflow.node.Node; import io.hensu.core.workflow.node.ParallelNode; import io.hensu.core.workflow.transition.FailureTransition; +import io.hensu.core.workflow.transition.NoConsensusTransition; import io.hensu.core.workflow.transition.SuccessTransition; import java.util.HashMap; import java.util.List; @@ -173,7 +174,7 @@ void shouldMergeAllBranchYieldsForVoteStrategies() throws Exception { .transitionRules( List.of( new SuccessTransition("end"), - new FailureTransition(0, "fail-end"))) + new FailureTransition("fail-end"))) .build()); nodes.put("end", end("end")); nodes.put("fail-end", failEnd("fail-end")); @@ -311,7 +312,8 @@ void shouldProduceFailureWhenBranchThrowsRuntimeException() throws Exception { .transitionRules( List.of( new SuccessTransition("end"), - new FailureTransition(0, "fail-end"))) + new NoConsensusTransition("fail-end"), + new FailureTransition("fail-end"))) .build()); nodes.put("end", end("end")); nodes.put("fail-end", failEnd("fail-end")); @@ -365,7 +367,8 @@ private Workflow buildMajorityWorkflow() { .transitionRules( List.of( new SuccessTransition("success-end"), - new FailureTransition(0, "failure-end"))) + new NoConsensusTransition("failure-end"), + new FailureTransition("failure-end"))) .build()); nodes.put("success-end", end("success-end")); nodes.put("failure-end", failEnd("failure-end")); diff --git a/hensu-core/src/test/java/io/hensu/core/execution/WorkflowExecutorRetryTest.java b/hensu-core/src/test/java/io/hensu/core/execution/WorkflowExecutorRetryTest.java index 64d74e7..161ffb1 100644 --- a/hensu-core/src/test/java/io/hensu/core/execution/WorkflowExecutorRetryTest.java +++ b/hensu-core/src/test/java/io/hensu/core/execution/WorkflowExecutorRetryTest.java @@ -11,6 +11,7 @@ import io.hensu.core.execution.result.ExitStatus; import io.hensu.core.workflow.WorkflowTest; import io.hensu.core.workflow.node.StandardNode; +import io.hensu.core.workflow.transition.BoundedTransition; import io.hensu.core.workflow.transition.FailureTransition; import io.hensu.core.workflow.transition.SuccessTransition; import java.util.HashMap; @@ -23,7 +24,7 @@ class WorkflowExecutorRetryTest extends WorkflowExecutorTestBase { @Test void shouldRetryOnFailureAndSucceedOnSecondAttempt() throws Exception { - // FailureTransition(maxRetries=3) — agent fails once then succeeds → SUCCESS end. + // BoundedTransition(budget=3) — agent fails once then succeeds → SUCCESS end. var start = StandardNode.builder() .id("start") @@ -32,7 +33,11 @@ void shouldRetryOnFailureAndSucceedOnSecondAttempt() throws Exception { .transitionRules( List.of( new SuccessTransition("end"), - new FailureTransition(3, "fallback"))) + new BoundedTransition( + new FailureTransition(null), + "failure", + 3, + "fallback"))) .build(); var workflow = WorkflowTest.TestWorkflowBuilder.create("retry") @@ -56,7 +61,7 @@ void shouldRetryOnFailureAndSucceedOnSecondAttempt() throws Exception { @Test void shouldExhaustRetriesAndTransitionToFallback() throws Exception { - // 1 original + 3 retries all fail → FailureTransition fires → fallback (FAILURE). + // 1 original + 3 retries all fail → BoundedTransition escalates → fallback (FAILURE). var start = StandardNode.builder() .id("start") @@ -65,7 +70,11 @@ void shouldExhaustRetriesAndTransitionToFallback() throws Exception { .transitionRules( List.of( new SuccessTransition("end"), - new FailureTransition(3, "fallback"))) + new BoundedTransition( + new FailureTransition(null), + "failure", + 3, + "fallback"))) .build(); var workflow = WorkflowTest.TestWorkflowBuilder.create("exhaust-retry") @@ -99,7 +108,11 @@ void shouldPassIdenticalResolvedPromptToEachRetryAttempt() throws Exception { .transitionRules( List.of( new SuccessTransition("end"), - new FailureTransition(2, "fallback"))) + new BoundedTransition( + new FailureTransition(null), + "failure", + 2, + "fallback"))) .build(); var workflow = WorkflowTest.TestWorkflowBuilder.create("retry-prompt-capture") diff --git a/hensu-core/src/test/java/io/hensu/core/execution/WorkflowExecutorTest.java b/hensu-core/src/test/java/io/hensu/core/execution/WorkflowExecutorTest.java index 2de6d65..c8c56d5 100644 --- a/hensu-core/src/test/java/io/hensu/core/execution/WorkflowExecutorTest.java +++ b/hensu-core/src/test/java/io/hensu/core/execution/WorkflowExecutorTest.java @@ -103,7 +103,7 @@ void shouldHandleAgentFailureResponse() throws Exception { .transitionRules( List.of( new SuccessTransition("success-end"), - new FailureTransition(0, "failure-end"))) + new FailureTransition("failure-end"))) .build(); var workflow = WorkflowTest.TestWorkflowBuilder.create("test") diff --git a/hensu-core/src/test/java/io/hensu/core/execution/pipeline/RubricPostProcessorTest.java b/hensu-core/src/test/java/io/hensu/core/execution/pipeline/RubricPostProcessorTest.java index ab82d72..ff044ba 100644 --- a/hensu-core/src/test/java/io/hensu/core/execution/pipeline/RubricPostProcessorTest.java +++ b/hensu-core/src/test/java/io/hensu/core/execution/pipeline/RubricPostProcessorTest.java @@ -190,7 +190,7 @@ void shouldRetryWhenNoPhaseFoundOnModerateFailure() { assertThat(result).isInstanceOf(ProcessorOutcome.Continue.class); assertThat(ctx.state().getCurrentNode()).isEqualTo("current"); - assertThat(ctx.state().getContext().get("retry_attempt")).isEqualTo(1); + assertThat(ctx.state().getRetryCount("rubric_backtrack", "current")).isEqualTo(1); assertThat(ctx.state().getHistory().getBacktracks()).hasSize(1); } @@ -215,14 +215,17 @@ void shouldIncrementRetryCounter() { processor.process(ctx); - assertThat(ctx.state().getContext().get("retry_attempt")).isEqualTo(1); + assertThat(ctx.state().getRetryCount("rubric_backtrack", "node")).isEqualTo(1); } @Test @DisplayName("does not backtrack and records no history entry after max retry attempts") void shouldStopAfterMaxRetries() { var ctx = contextWithRubric(RUBRIC_CONTENT); - ctx.state().getContext().put("retry_attempt", 3); + // Seed namespaced retry counter to max (3) so next attempt is refused + for (int i = 0; i < 3; i++) { + ctx.state().incrementRetryCount("rubric_backtrack", "node"); + } mockRubricEvaluation(70.0, false); var result = processor.process(ctx); diff --git a/hensu-core/src/test/java/io/hensu/core/execution/pipeline/TransitionPostProcessorTest.java b/hensu-core/src/test/java/io/hensu/core/execution/pipeline/TransitionPostProcessorTest.java index aae0cc6..e400196 100644 --- a/hensu-core/src/test/java/io/hensu/core/execution/pipeline/TransitionPostProcessorTest.java +++ b/hensu-core/src/test/java/io/hensu/core/execution/pipeline/TransitionPostProcessorTest.java @@ -3,16 +3,29 @@ import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatThrownBy; +import io.hensu.core.execution.EngineVariables; import io.hensu.core.execution.executor.ExecutionContext; import io.hensu.core.execution.executor.NodeResult; +import io.hensu.core.execution.parallel.ConsensusResult; +import io.hensu.core.execution.parallel.ConsensusResult.Vote; +import io.hensu.core.execution.parallel.ConsensusResult.VoteType; +import io.hensu.core.execution.parallel.ConsensusStrategy; import io.hensu.core.execution.result.ExecutionHistory; +import io.hensu.core.execution.result.ResultStatus; import io.hensu.core.plan.Plan; import io.hensu.core.plan.PlannedStep; +import io.hensu.core.rubric.model.ComparisonOperator; +import io.hensu.core.rubric.model.ScoreCondition; import io.hensu.core.state.HensuState; import io.hensu.core.workflow.Workflow; import io.hensu.core.workflow.node.LoopNode; import io.hensu.core.workflow.node.Node; import io.hensu.core.workflow.node.StandardNode; +import io.hensu.core.workflow.transition.ApprovalTransition; +import io.hensu.core.workflow.transition.BoundedTransition; +import io.hensu.core.workflow.transition.FailureTransition; +import io.hensu.core.workflow.transition.NoConsensusTransition; +import io.hensu.core.workflow.transition.ScoreTransition; import io.hensu.core.workflow.transition.SuccessTransition; import io.hensu.core.workflow.transition.TransitionRule; import java.util.HashMap; @@ -20,6 +33,7 @@ import java.util.Map; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; import org.junit.jupiter.api.Test; @DisplayName("TransitionProcessor") @@ -37,8 +51,6 @@ void setUp() { @Test @DisplayName("throws when no transition matches — catches missing-transition misconfiguration") void shouldThrowWhenNoTransitionMatches() { - // Integration tests always configure transitions; this exercises the error branch - // that only fires on misconfiguration. Message must contain the node ID for diagnosis. var ctx = contextWithTransitions("orphan", List.of()); assertThatThrownBy(() -> processor.process(ctx)) @@ -49,9 +61,6 @@ void shouldThrowWhenNoTransitionMatches() { @Test @DisplayName("LoopNode without loop_exit_target throws — LoopNode has no fallback transitions") void shouldThrowWhenLoopNodeHasNoExitTarget() { - // LoopNode.getTransitionRules() always returns List.of() — the only legal - // exit path is via loop_exit_target in context. If that key is absent, the - // processor reaches the no-match throw, exposing a workflow misconfiguration. var ctx = loopNodeContext(); assertThatThrownBy(() -> processor.process(ctx)) @@ -75,8 +84,6 @@ void shouldUseLoopExitTargetWhenSetInContext() { @Test @DisplayName("loopBreakTarget takes priority over loop_exit_target for LoopNode") void shouldPreferLoopBreakTargetOverLoopExitTarget() { - // Two override mechanisms exist for LoopNode. loopBreakTarget is consumed - // before the LoopNode branch even runs, so it must always win. var ctx = loopNodeContext(); ctx.state().setLoopBreakTarget("break-target"); ctx.state().getContext().put("loop_exit_target", "exit-node"); @@ -84,23 +91,13 @@ void shouldPreferLoopBreakTargetOverLoopExitTarget() { processor.process(ctx); assertThat(ctx.state().getCurrentNode()).isEqualTo("break-target"); - } - - @Test - @DisplayName("loopBreakTarget is cleared after being consumed on a LoopNode") - void shouldClearLoopBreakTargetAfterConsumingOnLoopNode() { - var ctx = loopNodeContext(); - ctx.state().setLoopBreakTarget("break-target"); - - processor.process(ctx); - assertThat(ctx.state().getLoopBreakTarget()).isNull(); } // — Loop break override (StandardNode) ——————————————————————————————— @Test - @DisplayName("loopBreakTarget overrides normal transition on StandardNode") + @DisplayName("loopBreakTarget overrides normal transition on StandardNode and is consumed") void shouldPrioritizeLoopBreakOverRuleOnStandardNode() { var ctx = contextWithTransitions("node", List.of(new SuccessTransition("rule-target"))); ctx.state().setLoopBreakTarget("override-target"); @@ -108,16 +105,6 @@ void shouldPrioritizeLoopBreakOverRuleOnStandardNode() { processor.process(ctx); assertThat(ctx.state().getCurrentNode()).isEqualTo("override-target"); - } - - @Test - @DisplayName("loopBreakTarget is cleared after consuming it on StandardNode") - void shouldClearLoopBreakTargetAfterConsuming() { - var ctx = contextWithTransitions("node", List.of(new SuccessTransition("next"))); - ctx.state().setLoopBreakTarget("break-target"); - - processor.process(ctx); - assertThat(ctx.state().getLoopBreakTarget()).isNull(); } @@ -140,7 +127,7 @@ void shouldSkipWhenPriorProcessorAlreadyRedirected() { @Test @DisplayName("clears activePlan when transitioning to a different node") - void shouldClearActivePlanOnTransitionToDifferentNode() { + void shouldClearActivePlanOnTransition() { var ctx = contextWithTransitions("node-a", List.of(new SuccessTransition("node-b"))); Plan plan = Plan.staticPlan( @@ -152,34 +139,287 @@ void shouldClearActivePlanOnTransitionToDifferentNode() { assertThat(ctx.state().getActivePlan()).isNull(); } - @Test - @DisplayName("clears activePlan on self-transition to prevent exhausted plan reuse") - void shouldClearActivePlanOnSelfTransition() { - var ctx = contextWithTransitions("node-a", List.of(new SuccessTransition("node-a"))); - Plan plan = - Plan.staticPlan( - "node-a", List.of(PlannedStep.pending(0, "tool", Map.of(), "step"))); - ctx.state().setActivePlan(plan); + // — Engine variable lifecycle ———————————————————————————————————————— + + @Nested + @DisplayName("Engine variable lifecycle") + class EngineVarLifecycle { + + @Test + @DisplayName("forward transition clears all engine vars from context") + void forwardClearsAllEngineVars() { + var ctx = contextWithTransitions("node", List.of(new SuccessTransition("next"))); + seedEngineVars(ctx); + + processor.process(ctx); + + assertThat(ctx.state().getContext()) + .doesNotContainKeys( + EngineVariables.SCORE, + EngineVariables.APPROVED, + EngineVariables.RECOMMENDATION); + } + + @Test + @DisplayName( + "forward transition with withFeedback keeps recommendation, clears routing vars") + void forwardWithFeedbackKeepsRecommendation() { + var rule = new SuccessTransition("next", true); + var ctx = contextWithTransitions("node", List.of(rule)); + seedEngineVars(ctx); + + processor.process(ctx); + + assertThat(ctx.state().getContext()) + .doesNotContainKeys(EngineVariables.SCORE, EngineVariables.APPROVED) + .containsEntry(EngineVariables.RECOMMENDATION, "improve section 2"); + } + + @Test + @DisplayName("score transition with withFeedback preserves recommendation on forward") + void scoreWithFeedbackPreservesRecommendation() { + var rule = + new ScoreTransition( + List.of(new ScoreCondition(ComparisonOperator.LT, 70.0, null, "write")), + true); + var ctx = contextWithTransitions("review", List.of(rule)); + ctx.state().getContext().put(EngineVariables.SCORE, 50.0); + ctx.state().getContext().put(EngineVariables.RECOMMENDATION, "needs more detail"); + + processor.process(ctx); + + assertThat(ctx.state().getContext()) + .doesNotContainKey(EngineVariables.SCORE) + .containsEntry(EngineVariables.RECOMMENDATION, "needs more detail"); + } + + @Test + @DisplayName("approval transition with withFeedback preserves recommendation on forward") + void approvalWithFeedbackPreservesRecommendation() { + var rule = new ApprovalTransition(true, "next", true); + var ctx = contextWithTransitions("review", List.of(rule)); + ctx.state().getContext().put(EngineVariables.APPROVED, true); + ctx.state().getContext().put(EngineVariables.RECOMMENDATION, "well structured"); + + processor.process(ctx); + + assertThat(ctx.state().getContext()) + .doesNotContainKey(EngineVariables.APPROVED) + .containsEntry(EngineVariables.RECOMMENDATION, "well structured"); + } + + @Test + @DisplayName("bounded backtrack keeps recommendation for non-failure inner") + void boundedBacktrackKeepsRecommendation() { + var inner = new ApprovalTransition(false, "write"); + var rule = new BoundedTransition(inner, "approval", 3, "escalate"); + var ctx = contextWithTransitions("review", List.of(rule)); + ctx.state().getContext().put(EngineVariables.APPROVED, false); + ctx.state().getContext().put(EngineVariables.RECOMMENDATION, "rewrite intro"); + + processor.process(ctx); + + assertThat(ctx.state().getContext()) + .doesNotContainKeys(EngineVariables.SCORE, EngineVariables.APPROVED) + .containsEntry(EngineVariables.RECOMMENDATION, "rewrite intro"); + } + + @Test + @DisplayName("bounded backtrack clears recommendation for failure inner") + void boundedBacktrackClearsRecommendationForFailure() { + var inner = new FailureTransition(null); + var rule = new BoundedTransition(inner, "failure", 3, "error"); + var ctx = + contextWithTransitionsAndResult( + "node", List.of(rule), NodeResult.failure("timeout")); + ctx.state().getContext().put(EngineVariables.RECOMMENDATION, "stale"); + + processor.process(ctx); + + assertThat(ctx.state().getContext()).doesNotContainKey(EngineVariables.RECOMMENDATION); + } + + @Test + @DisplayName("bounded escalation with escalationWithFeedback keeps recommendation") + void boundedEscalationWithFeedbackKeepsRecommendation() { + var inner = new ApprovalTransition(false, "write"); + var rule = new BoundedTransition(inner, "approval", 1, "escalate", true); + var ctx = contextWithTransitions("review", List.of(rule)); + ctx.state().getContext().put(EngineVariables.APPROVED, false); + ctx.state().getContext().put(EngineVariables.RECOMMENDATION, "final feedback"); + ctx.state().incrementRetryCount("approval", "review"); + + processor.process(ctx); + + assertThat(ctx.state().getCurrentNode()).isEqualTo("escalate"); + assertThat(ctx.state().getContext()) + .doesNotContainKeys(EngineVariables.SCORE, EngineVariables.APPROVED) + .containsEntry(EngineVariables.RECOMMENDATION, "final feedback"); + } + + @Test + @DisplayName("bounded escalation without feedback clears all engine vars") + void boundedEscalationWithoutFeedbackClearsAll() { + var inner = new ApprovalTransition(false, "write"); + var rule = new BoundedTransition(inner, "approval", 1, "escalate"); + var ctx = contextWithTransitions("review", List.of(rule)); + ctx.state().getContext().put(EngineVariables.APPROVED, false); + ctx.state().getContext().put(EngineVariables.RECOMMENDATION, "gone"); + ctx.state().incrementRetryCount("approval", "review"); + + processor.process(ctx); + + assertThat(ctx.state().getCurrentNode()).isEqualTo("escalate"); + assertThat(ctx.state().getContext()) + .doesNotContainKeys( + EngineVariables.SCORE, + EngineVariables.APPROVED, + EngineVariables.RECOMMENDATION); + } + } - processor.process(ctx); + // — Bounded retry counters ——————————————————————————————————————————— - assertThat(ctx.state().getActivePlan()).isNull(); + @Nested + @DisplayName("Bounded retry counters") + class BoundedRetryCounters { + + @Test + @DisplayName("bounded backtrack increments retry counter") + void backtrackIncrementsCounter() { + var inner = new ApprovalTransition(false, "write"); + var rule = new BoundedTransition(inner, "approval", 3, "escalate"); + var ctx = contextWithTransitions("review", List.of(rule)); + ctx.state().getContext().put(EngineVariables.APPROVED, false); + + processor.process(ctx); + + assertThat(ctx.state().getRetryCount("approval", "review")).isEqualTo(1); + } + + @Test + @DisplayName("bounded escalation resets retry counters for the node") + void escalationResetsCounters() { + var inner = new ApprovalTransition(false, "write"); + var rule = new BoundedTransition(inner, "approval", 1, "escalate"); + var ctx = contextWithTransitions("review", List.of(rule)); + ctx.state().getContext().put(EngineVariables.APPROVED, false); + ctx.state().incrementRetryCount("approval", "review"); + + processor.process(ctx); + + assertThat(ctx.state().getCurrentNode()).isEqualTo("escalate"); + assertThat(ctx.state().getRetryCount("approval", "review")).isZero(); + } + + @Test + @DisplayName("forward transition resets retry counters for the node") + void forwardResetsCounters() { + var ctx = contextWithTransitions("node", List.of(new SuccessTransition("next"))); + ctx.state().incrementRetryCount("failure", "node"); + ctx.state().incrementRetryCount("failure", "node"); + + processor.process(ctx); + + assertThat(ctx.state().getRetryCount("failure", "node")).isZero(); + } + } + + // — Consensus feedback injection ————————————————————————————————————— + + @Nested + @DisplayName("Consensus feedback injection") + class ConsensusFeedbackInjection { + + @Test + @DisplayName("injects vote details into recommendation on no-consensus backtrack") + void injectsVoteFeedbackOnNoConsensusBacktrack() { + var inner = new NoConsensusTransition("producer"); + var rule = new BoundedTransition(inner, "consensus", 3, "escalate"); + var votes = + Map.of( + "branch-a", + new Vote( + "branch-a", "agent-a", VoteType.APPROVE, 80.0, 1.0, "output-a"), + "branch-b", + new Vote( + "branch-b", "agent-b", VoteType.REJECT, 40.0, 1.0, "output-b")); + var consensus = + new ConsensusResult( + false, ConsensusStrategy.MAJORITY_VOTE, List.of(), null, votes, null); + var result = + NodeResult.builder() + .status(ResultStatus.FAILURE) + .output("no consensus") + .metadata(new HashMap<>(Map.of("consensus_reached", false))) + .build(); + var ctx = contextWithTransitionsAndResult("parallel", List.of(rule), result); + ctx.state().getContext().put("consensus_result:parallel", consensus); + + processor.process(ctx); + + String recommendation = + (String) ctx.state().getContext().get(EngineVariables.RECOMMENDATION); + assertThat(recommendation).isNotBlank(); + assertThat(recommendation).contains("branch-a", "branch-b"); + } + + @Test + @DisplayName("injects judge reasoning on JUDGE_DECIDES no-consensus backtrack") + void injectsJudgeReasoningOnNoConsensusBacktrack() { + var inner = new NoConsensusTransition("producer"); + var rule = new BoundedTransition(inner, "consensus", 3, "escalate"); + var result = + NodeResult.builder() + .status(ResultStatus.FAILURE) + .output("no consensus") + .metadata(new HashMap<>(Map.of("consensus_reached", false))) + .build(); + var ctx = contextWithTransitionsAndResult("parallel", List.of(rule), result); + ctx.state() + .getContext() + .put( + "consensus_result:parallel", + new ConsensusResult( + false, + ConsensusStrategy.JUDGE_DECIDES, + List.of(), + null, + Map.of(), + "branches disagree on tone")); + + processor.process(ctx); + + assertThat(ctx.state().getContext().get(EngineVariables.RECOMMENDATION)) + .isEqualTo("branches disagree on tone"); + } } // — Helpers ——————————————————————————————————————————————————————————— + private void seedEngineVars(ProcessorContext ctx) { + ctx.state().getContext().put(EngineVariables.SCORE, 85.0); + ctx.state().getContext().put(EngineVariables.APPROVED, true); + ctx.state().getContext().put(EngineVariables.RECOMMENDATION, "improve section 2"); + } + private ProcessorContext contextWithTransitions(String nodeId, List rules) { + return contextWithTransitionsAndResult( + nodeId, rules, NodeResult.success("output", Map.of())); + } + + private ProcessorContext contextWithTransitionsAndResult( + String nodeId, List rules, NodeResult result) { Node node = StandardNode.builder().id(nodeId).transitionRules(rules).build(); - return buildContext(nodeId, node); + return buildContext(nodeId, node, result); } private ProcessorContext loopNodeContext() { - // LoopNode has no transition rules — loop_exit_target in context is the only exit path. LoopNode loopNode = new LoopNode("loop"); - return buildContext("loop", loopNode); + return buildContext("loop", loopNode, NodeResult.success("output", Map.of())); } - private ProcessorContext buildContext(String nodeId, Node node) { + private ProcessorContext buildContext(String nodeId, Node node, NodeResult result) { var state = new HensuState.Builder() .executionId("test") @@ -197,6 +437,6 @@ private ProcessorContext buildContext(String nodeId, Node node) { .build(); var execCtx = ExecutionContext.builder().state(state).workflow(workflow).build(); - return new ProcessorContext(execCtx, node, NodeResult.success("output", Map.of())); + return new ProcessorContext(execCtx, node, result); } } diff --git a/hensu-core/src/test/java/io/hensu/core/state/HensuSnapshotTest.java b/hensu-core/src/test/java/io/hensu/core/state/HensuSnapshotTest.java index f139d11..516470a 100644 --- a/hensu-core/src/test/java/io/hensu/core/state/HensuSnapshotTest.java +++ b/hensu-core/src/test/java/io/hensu/core/state/HensuSnapshotTest.java @@ -21,6 +21,7 @@ void shouldRestoreStateWithMutableCollections() { "exec-1", "current-node", Map.of("key", "value"), + Map.of(), new ExecutionHistory(), null, null, diff --git a/hensu-core/src/test/java/io/hensu/core/state/InMemoryWorkflowStateRepositoryTest.java b/hensu-core/src/test/java/io/hensu/core/state/InMemoryWorkflowStateRepositoryTest.java index a1b9b61..5e4a3cb 100644 --- a/hensu-core/src/test/java/io/hensu/core/state/InMemoryWorkflowStateRepositoryTest.java +++ b/hensu-core/src/test/java/io/hensu/core/state/InMemoryWorkflowStateRepositoryTest.java @@ -24,6 +24,7 @@ private HensuSnapshot createSnapshot( executionId, currentNodeId, Map.of(), + Map.of(), null, null, null, diff --git a/hensu-core/src/test/java/io/hensu/core/workflow/transition/TransitionRulesTest.java b/hensu-core/src/test/java/io/hensu/core/workflow/transition/TransitionRulesTest.java index 11a6077..2dc2033 100644 --- a/hensu-core/src/test/java/io/hensu/core/workflow/transition/TransitionRulesTest.java +++ b/hensu-core/src/test/java/io/hensu/core/workflow/transition/TransitionRulesTest.java @@ -5,6 +5,7 @@ import io.hensu.core.execution.EngineVariables; import io.hensu.core.execution.executor.NodeResult; import io.hensu.core.execution.result.ExecutionHistory; +import io.hensu.core.execution.result.ResultStatus; import io.hensu.core.rubric.model.ComparisonOperator; import io.hensu.core.rubric.model.ScoreCondition; import io.hensu.core.state.HensuState; @@ -46,65 +47,101 @@ void shouldReturnNullOnFailure() { } @Nested - class FailureTransitionTest { + class NoConsensusTransitionTest { @Test - void shouldRetryOnFirstFailure() { - // Given - FailureTransition transition = new FailureTransition(3, "fallback"); - NodeResult result = NodeResult.failure("Error"); + void shouldFireOnConsensusFailure() { + // FAILURE + consensus_reached=false → routes to target + NoConsensusTransition transition = new NoConsensusTransition("revise-node"); + NodeResult result = + new NodeResult( + ResultStatus.FAILURE, null, Map.of("consensus_reached", Boolean.FALSE)); + + assertThat(transition.evaluate(state, result)).isEqualTo("revise-node"); + } - // When - String target = transition.evaluate(state, result); + @Test + void shouldNotFireOnNonConsensusFailure() { + // FAILURE without consensus_reached=false → fall-through (FailureTransition's domain) + NoConsensusTransition transition = new NoConsensusTransition("revise-node"); + NodeResult result = NodeResult.failure("Agent error"); - // Then - should retry current node - assertThat(target).isEqualTo("current-node"); - assertThat(state.getRetryCount()).isEqualTo(1); + assertThat(transition.evaluate(state, result)).isNull(); } @Test - void shouldRetryUntilMaxRetriesReached() { - // Given - FailureTransition transition = new FailureTransition(2, "fallback"); - NodeResult result = NodeResult.failure("Error"); + void shouldNotFireOnSuccess() { + // SUCCESS with consensus_reached=false in metadata → must not fire + NoConsensusTransition transition = new NoConsensusTransition("revise-node"); + NodeResult result = + NodeResult.success("Output", Map.of("consensus_reached", Boolean.FALSE)); - // When - first retry - transition.evaluate(state, result); - assertThat(state.getRetryCount()).isEqualTo(1); + assertThat(transition.evaluate(state, result)).isNull(); + } - // When - second retry - transition.evaluate(state, result); - assertThat(state.getRetryCount()).isEqualTo(2); + @Test + void shouldNotThrowOnNullMetadata() { + // NodeResult constructed via 3-arg ctor with null metadata — must not NPE + NoConsensusTransition transition = new NoConsensusTransition("revise-node"); + NodeResult result = new NodeResult(ResultStatus.FAILURE, null, null); - // When - max retries reached, go to fallback - String target = transition.evaluate(state, result); - assertThat(target).isEqualTo("fallback"); + assertThat(transition.evaluate(state, result)).isNull(); } + } + + @Nested + class FailureTransitionTest { @Test - void shouldReturnNullOnSuccess() { - // Given - FailureTransition transition = new FailureTransition(3, "fallback"); - NodeResult result = NodeResult.success("Output", Map.of()); + void shouldNotFireOnConsensusFailure() { + // consensus_reached=false with FAILURE status — FailureTransition must yield to + // NoConsensusTransition, not swallow the event + FailureTransition transition = new FailureTransition("fallback"); + NodeResult result = + new NodeResult( + ResultStatus.FAILURE, null, Map.of("consensus_reached", Boolean.FALSE)); - // When String target = transition.evaluate(state, result); - // Then assertThat(target).isNull(); } + } + + @Nested + class BoundedTransitionTest { @Test - void shouldGoDirectlyToFallbackWhenRetryCountIsZero() { - // Given - FailureTransition transition = new FailureTransition(0, "fallback"); + void shouldRetryAtBudgetBoundaryThenEscalate() { + // budget=2: at count=1 (one below budget) → inner target; at count=2 → escalation + BoundedTransition bounded = + new BoundedTransition(new FailureTransition(null), "failure", 2, "fallback"); NodeResult result = NodeResult.failure("Error"); - // When - String target = transition.evaluate(state, result); + // One attempt used — still under budget + state.incrementRetryCount("failure", "current-node"); + assertThat(bounded.evaluate(state, result)).isEqualTo("current-node"); - // Then - assertThat(target).isEqualTo("fallback"); + // Second attempt — budget exhausted → escalate + state.incrementRetryCount("failure", "current-node"); + assertThat(bounded.evaluate(state, result)).isEqualTo("fallback"); + } + + @Test + void selfLoopShouldRespectBudget() { + // FailureTransition(null) self-loops to current node. If counter wiring is wrong, + // this spins forever. Verify escalation still fires after budget attempts. + BoundedTransition bounded = + new BoundedTransition(new FailureTransition(null), "failure", 1, "fallback"); + NodeResult result = NodeResult.failure("Error"); + + // First evaluation — under budget → self-loop target + assertThat(bounded.evaluate(state, result)).isEqualTo("current-node"); + + // Simulate TransitionPostProcessor incrementing counter after the match + state.incrementRetryCount("failure", "current-node"); + + // Second evaluation — budget exhausted → must escalate, not self-loop + assertThat(bounded.evaluate(state, result)).isEqualTo("fallback"); } } diff --git a/hensu-dsl/README.md b/hensu-dsl/README.md index ac9176c..3a857a8 100644 --- a/hensu-dsl/README.md +++ b/hensu-dsl/README.md @@ -122,7 +122,7 @@ hensu-dsl/src/main/kotlin/io/hensu/dsl/ │ ├── TransitionBuilder.kt # Transition rule builders │ ├── ScoreTransitionBuilder.kt # Score-based routing │ ├── ScoreConditionBuilder.kt # Score condition expressions -│ ├── RubricBuilder.kt # Quality gate definition +│ ├── ReviseBuilder.kt # Bounded `revise` retry/escalation chain builder │ ├── RetryBuilder.kt # Retry configuration │ ├── ReviewConfigBuilder.kt # Human review settings │ ├── PlanBuilder.kt # Agentic planning config @@ -187,8 +187,8 @@ The `Models` object provides constants for supported AI models: | `Models.GEMINI_3_1_PRO` | `gemini-3.1-pro-preview` | | `Models.GEMINI_2_5_FLASH` | `gemini-2.5-flash` | | `Models.GEMINI_2_5_PRO` | `gemini-2.5-pro` | -| `Models.DEEPSEEK_CHAT` | `deepseek-chat` | -| `Models.DEEPSEEK_CODER` | `deepseek-coder` | +| `Models.DEEPSEEK_V4_FLASH` | `deepseek-v4-flash` | +| `Models.DEEPSEEK_V4_PRO` | `deepseek-v4-pro` | ## Documentation diff --git a/hensu-dsl/src/main/kotlin/io/hensu/dsl/builders/ActionNodeBuilder.kt b/hensu-dsl/src/main/kotlin/io/hensu/dsl/builders/ActionNodeBuilder.kt index fcb350a..9187b43 100644 --- a/hensu-dsl/src/main/kotlin/io/hensu/dsl/builders/ActionNodeBuilder.kt +++ b/hensu-dsl/src/main/kotlin/io/hensu/dsl/builders/ActionNodeBuilder.kt @@ -2,6 +2,7 @@ package io.hensu.dsl.builders import io.hensu.core.execution.action.Action import io.hensu.core.workflow.node.ActionNode +import io.hensu.core.workflow.transition.SuccessTransition /** * DSL builder for action nodes. @@ -101,9 +102,13 @@ class ActionNodeBuilder(private val id: String) : BaseNodeBuilder, TransitionMar actions.add(Action.Execute(commandId)) } - /** Define transition on success. Usage: `onSuccess goto "next_node"` */ - infix fun onSuccess.goto(targetNode: String) { + /** + * Define transition on success. Usage: `onSuccess goto "next_node"` or `onSuccess goto + * "next_node" withFeedback` + */ + infix fun onSuccess.goto(targetNode: String): GotoHandle { transitionBuilder.addSuccessTransition(targetNode) + return GotoHandle(transitionBuilder.rulesRef()) { SuccessTransition(targetNode, true) } } /** Define direct failure transition without retry. Usage: `onFailure goto "error-node"` */ diff --git a/hensu-dsl/src/main/kotlin/io/hensu/dsl/builders/ForkJoinBuilders.kt b/hensu-dsl/src/main/kotlin/io/hensu/dsl/builders/ForkJoinBuilders.kt index 58124e8..df4dc14 100644 --- a/hensu-dsl/src/main/kotlin/io/hensu/dsl/builders/ForkJoinBuilders.kt +++ b/hensu-dsl/src/main/kotlin/io/hensu/dsl/builders/ForkJoinBuilders.kt @@ -3,6 +3,7 @@ package io.hensu.dsl.builders import io.hensu.core.workflow.node.ForkNode import io.hensu.core.workflow.node.JoinNode import io.hensu.core.workflow.node.MergeStrategy +import io.hensu.core.workflow.transition.SuccessTransition /** * DSL builder for fork nodes that spawn parallel execution paths. @@ -64,8 +65,9 @@ class ForkNodeBuilder(private val id: String) : BaseNodeBuilder, ForkJoinMarkers * * @param targetNode the node to transition to after forking, not null */ - infix fun onComplete.goto(targetNode: String) { + infix fun onComplete.goto(targetNode: String): GotoHandle { transitionBuilder.addSuccessTransition(targetNode) + return GotoHandle(transitionBuilder.rulesRef()) { SuccessTransition(targetNode, true) } } /** @@ -179,8 +181,9 @@ class JoinNodeBuilder(private val id: String) : BaseNodeBuilder, TransitionMarke * * @param targetNode the node to transition to on success, not null */ - infix fun onSuccess.goto(targetNode: String) { + infix fun onSuccess.goto(targetNode: String): GotoHandle { transitionBuilder.addSuccessTransition(targetNode) + return GotoHandle(transitionBuilder.rulesRef()) { SuccessTransition(targetNode, true) } } /** diff --git a/hensu-dsl/src/main/kotlin/io/hensu/dsl/builders/GenericNodeBuilder.kt b/hensu-dsl/src/main/kotlin/io/hensu/dsl/builders/GenericNodeBuilder.kt index c360b62..f5aae8a 100644 --- a/hensu-dsl/src/main/kotlin/io/hensu/dsl/builders/GenericNodeBuilder.kt +++ b/hensu-dsl/src/main/kotlin/io/hensu/dsl/builders/GenericNodeBuilder.kt @@ -3,6 +3,7 @@ package io.hensu.dsl.builders import io.hensu.core.rubric.RubricParser import io.hensu.core.workflow.node.GenericNode import io.hensu.core.workflow.node.Node +import io.hensu.core.workflow.transition.SuccessTransition /** * Builder for generic nodes that allow custom execution logic. @@ -73,9 +74,13 @@ class GenericNodeBuilder(private val id: String) : BaseNodeBuilder, TransitionMa configMap.putAll(builder.entries) } - /** Define transition on success. Usage: onSuccess goto "next_node" */ - infix fun onSuccess.goto(targetNode: String) { + /** + * Define transition on success. Usage: `onSuccess goto "next_node"` or `onSuccess goto + * "next_node" withFeedback` + */ + infix fun onSuccess.goto(targetNode: String): GotoHandle { transitionBuilder.addSuccessTransition(targetNode) + return GotoHandle(transitionBuilder.rulesRef()) { SuccessTransition(targetNode, true) } } /** Define direct failure transition without retry. Usage: onFailure goto "error-node" */ diff --git a/hensu-dsl/src/main/kotlin/io/hensu/dsl/builders/Models.kt b/hensu-dsl/src/main/kotlin/io/hensu/dsl/builders/Models.kt index 9d771a1..7520831 100644 --- a/hensu-dsl/src/main/kotlin/io/hensu/dsl/builders/Models.kt +++ b/hensu-dsl/src/main/kotlin/io/hensu/dsl/builders/Models.kt @@ -31,6 +31,6 @@ object Models { const val GEMINI_2_5_PRO = "gemini-2.5-pro" // DeepSeek models - const val DEEPSEEK_CHAT = "deepseek-chat" - const val DEEPSEEK_CODER = "deepseek-coder" + const val DEEPSEEK_V4_FLASH = "deepseek-v4-flash" + const val DEEPSEEK_V4_PRO = "deepseek-v4-pro" } diff --git a/hensu-dsl/src/main/kotlin/io/hensu/dsl/builders/ParallelNodeBuilder.kt b/hensu-dsl/src/main/kotlin/io/hensu/dsl/builders/ParallelNodeBuilder.kt index 780700f..7bb8771 100644 --- a/hensu-dsl/src/main/kotlin/io/hensu/dsl/builders/ParallelNodeBuilder.kt +++ b/hensu-dsl/src/main/kotlin/io/hensu/dsl/builders/ParallelNodeBuilder.kt @@ -6,6 +6,8 @@ import io.hensu.core.execution.parallel.ConsensusConfig import io.hensu.core.execution.parallel.ConsensusStrategy import io.hensu.core.rubric.RubricParser import io.hensu.core.workflow.node.ParallelNode +import io.hensu.core.workflow.transition.NoConsensusTransition +import io.hensu.core.workflow.transition.SuccessTransition import io.hensu.dsl.WorkingDirectory import io.hensu.dsl.extensions.resolveAsPrompt import io.hensu.dsl.extensions.resolveAsRubric @@ -37,7 +39,7 @@ import java.util.logging.Logger * } * * onConsensus goto "approved" - * onNoConsensus goto "needs_review" + * onNoConsensus revise "producer" retry 3 otherwise "escalate" * } * ``` * @@ -87,34 +89,52 @@ class ParallelNodeBuilder(private val id: String, private val workingDirectory: * * @param targetNode the node to transition to on consensus, not null */ - infix fun onConsensus.goto(targetNode: String) { + infix fun onConsensus.goto(targetNode: String): GotoHandle { transitionBuilder.addSuccessTransition(targetNode) + return GotoHandle(transitionBuilder.rulesRef()) { SuccessTransition(targetNode, true) } } /** * Defines transition when consensus is not reached. * - * Usage: `onNoConsensus goto "needs_review"` + * Usage: `onNoConsensus goto "needs_review"` or `onNoConsensus goto "needs_review" + * withFeedback` * * @param targetNode the node to transition to when no consensus, not null */ - infix fun onNoConsensus.goto(targetNode: String) { - transitionBuilder.addFailureTransition(targetNode) + infix fun onNoConsensus.goto(targetNode: String): GotoHandle { + transitionBuilder.addNoConsensusTransition(targetNode) + return GotoHandle(transitionBuilder.rulesRef()) { NoConsensusTransition(targetNode, true) } } + /** + * Defines a bounded-revise transition when consensus is not reached. + * + * Retries the producer node up to a budget, then escalates to a fallback target. + * + * Usage: `onNoConsensus revise "producer" retry 3 otherwise "escalate"` + * + * @param producerNode the node to re-execute on each retry + * @return builder for specifying retry budget and escalation target + */ + infix fun onNoConsensus.revise(producerNode: String): ReviseBuilder = + transitionBuilder.createReviseBuilder(NoConsensusTransition(producerNode), "consensus") + /** * Defines transition on success (alias for [onConsensus]). * * @param targetNode the node to transition to on success, not null */ - infix fun onSuccess.goto(targetNode: String) { + infix fun onSuccess.goto(targetNode: String): GotoHandle { transitionBuilder.addSuccessTransition(targetNode) + return GotoHandle(transitionBuilder.rulesRef()) { SuccessTransition(targetNode, true) } } /** - * Defines transition on failure (alias for [onNoConsensus]). + * Defines transition on agent execution failure (not consensus failure). * * @param targetNode the node to transition to on failure, not null + * @see onNoConsensus for consensus-specific failure handling */ infix fun onFailure.goto(targetNode: String) { transitionBuilder.addFailureTransition(targetNode) diff --git a/hensu-dsl/src/main/kotlin/io/hensu/dsl/builders/RetryBuilder.kt b/hensu-dsl/src/main/kotlin/io/hensu/dsl/builders/RetryBuilder.kt index 2604f96..b7c607f 100644 --- a/hensu-dsl/src/main/kotlin/io/hensu/dsl/builders/RetryBuilder.kt +++ b/hensu-dsl/src/main/kotlin/io/hensu/dsl/builders/RetryBuilder.kt @@ -1,5 +1,6 @@ package io.hensu.dsl.builders +import io.hensu.core.workflow.transition.BoundedTransition import io.hensu.core.workflow.transition.FailureTransition import io.hensu.core.workflow.transition.TransitionRule @@ -7,7 +8,8 @@ import io.hensu.core.workflow.transition.TransitionRule * Builder for failure transitions with retry semantics. * * Completes the `onFailure retry N otherwise "target"` DSL syntax by specifying the fallback node - * after retries are exhausted. + * after retries are exhausted. Desugars to a [BoundedTransition] wrapping a [FailureTransition] + * with a `"failure"` namespace. * * Example: * ```kotlin @@ -24,6 +26,6 @@ class RetryBuilder(private val retryCount: Int, private val rules: MutableList, +) { + /** + * Accepted as no-op – revise backtracks always preserve feedback by design. + * + * Allows `revise "producer" withFeedback retry 3 otherwise "escalate"` without breaking the + * chain. Redundant but harmless. + */ + val withFeedback: ReviseBuilder + get() = this + + /** + * Specifies the maximum retry attempts before escalation. + * + * Usage: `revise "producer" retry 3 otherwise "escalate"` + * + * @param count maximum retry attempts (must be positive) + * @return builder for specifying the escalation target + */ + infix fun retry(count: Int): ReviseRetryBuilder = + ReviseRetryBuilder(trigger, namespace, count, rules) +} + +/** + * DSL builder for the final segment of a bounded-revise chain. + * + * Captures the retry budget and emits the [BoundedTransition] when the escalation target is + * specified. + * + * @property trigger the inner transition rule fired on each retry attempt + * @property namespace counter namespace for budget tracking + * @property budget maximum retry attempts before escalation + * @property rules mutable list to add the compiled [BoundedTransition] + */ +@WorkflowDsl +class ReviseRetryBuilder +internal constructor( + private val trigger: TransitionRule, + private val namespace: String, + private val budget: Int, + private val rules: MutableList, +) { + /** + * Specifies the escalation target after retries are exhausted. + * + * Adds a [BoundedTransition] wrapping the inner trigger with the configured namespace and + * budget. + * + * Usage: `otherwise "escalate"` or `otherwise "escalate" withFeedback` + * + * @param targetNode the node to transition to after all retries fail + * @return handle for optional `withFeedback` chaining on the escalation edge + */ + infix fun otherwise(targetNode: String): OtherwiseHandle { + rules.add(BoundedTransition(trigger, namespace, budget, targetNode)) + return OtherwiseHandle(rules, trigger, namespace, budget, targetNode) + } +} + +/** + * Handle returned by [ReviseRetryBuilder.otherwise] to allow optional `withFeedback` on the + * escalation edge. + * + * When `withFeedback` is accessed, the last-added [BoundedTransition] is replaced with one that has + * `escalationWithFeedback=true`. + */ +class OtherwiseHandle +internal constructor( + private val rules: MutableList, + private val trigger: TransitionRule, + private val namespace: String, + private val budget: Int, + private val targetNode: String, +) { + /** + * Marks the escalation transition as feedback-preserving. + * + * Usage: `otherwise "escalate" withFeedback` + */ + val withFeedback: Unit + get() { + rules.removeLastOrNull() + rules.add(BoundedTransition(trigger, namespace, budget, targetNode, true)) + } +} diff --git a/hensu-dsl/src/main/kotlin/io/hensu/dsl/builders/ScoreConditionBuilder.kt b/hensu-dsl/src/main/kotlin/io/hensu/dsl/builders/ScoreConditionBuilder.kt index 5dac761..95872c2 100644 --- a/hensu-dsl/src/main/kotlin/io/hensu/dsl/builders/ScoreConditionBuilder.kt +++ b/hensu-dsl/src/main/kotlin/io/hensu/dsl/builders/ScoreConditionBuilder.kt @@ -3,32 +3,84 @@ package io.hensu.dsl.builders import io.hensu.core.rubric.model.ComparisonOperator import io.hensu.core.rubric.model.DoubleRange import io.hensu.core.rubric.model.ScoreCondition +import io.hensu.core.workflow.transition.ScoreTransition +import io.hensu.core.workflow.transition.TransitionRule /** * Builder for completing an individual score condition with a transition target. * * Created by [ScoreTransitionBuilder] comparison methods to enable fluent syntax: `whenScore - * greaterThan 80.0 goto "success"` + * greaterThan 80.0 goto "success"` or `whenScore lessThan 60.0 revise "producer" retry 3 otherwise + * "escalate"`. * * @property operator comparison operator (GT, GTE, LT, LTE, RANGE) * @property value threshold value for comparison (unused for RANGE) * @property range inclusive range for RANGE operator, null otherwise - * @property conditions mutable list to add the completed condition + * @property conditions mutable list to add goto conditions + * @property feedbackConditions mutable list to add goto conditions with feedback preservation + * @property boundedRules mutable list to add bounded revise rules */ class ScoreConditionBuilder( private val operator: ComparisonOperator, private val value: Double, private val range: DoubleRange?, private val conditions: MutableList, + private val feedbackConditions: MutableList, + private val boundedRules: MutableList, ) { /** * Completes the score condition by specifying the transition target. * - * Usage: `whenScore greaterThan 80.0 goto "success"` + * Usage: `whenScore greaterThan 80.0 goto "success"` or `whenScore lessThan 70.0 goto "write" + * withFeedback` * * @param targetNode the node to transition to when this condition matches, not null + * @return handle for optional `withFeedback` chaining */ - infix fun goto(targetNode: String) { - conditions.add(ScoreCondition(operator, value, range, targetNode)) + infix fun goto(targetNode: String): ScoreGotoHandle { + val condition = ScoreCondition(operator, value, range, targetNode) + conditions.add(condition) + return ScoreGotoHandle(condition, conditions, feedbackConditions) } + + /** + * Creates a bounded-revise transition for this score condition. + * + * The inner trigger is a [ScoreTransition] containing this condition, so the bounded decorator + * fires only when the score matches. + * + * Usage: `whenScore lessThan 60.0 revise "producer" retry 3 otherwise "escalate"` + * + * @param producerNode the node to re-execute on each retry + * @return builder for specifying retry budget and escalation target + */ + infix fun revise(producerNode: String): ReviseBuilder { + val arm = ScoreTransition(listOf(ScoreCondition(operator, value, range, producerNode))) + return ReviseBuilder(arm, "score", boundedRules) + } +} + +/** + * Handle returned by [ScoreConditionBuilder.goto] to allow optional `withFeedback` chaining. + * + * When `withFeedback` is accessed, the condition is moved from the standard list to the feedback + * list, causing [ScoreTransitionBuilder.buildAll] to emit a separate [ScoreTransition] with + * `withFeedback=true`. + */ +class ScoreGotoHandle +internal constructor( + private val condition: ScoreCondition, + private val conditions: MutableList, + private val feedbackConditions: MutableList, +) { + /** + * Marks this transition as feedback-preserving. + * + * Usage: `whenScore lessThan 70.0 goto "write" withFeedback` + */ + val withFeedback: Unit + get() { + conditions.remove(condition) + feedbackConditions.add(condition) + } } diff --git a/hensu-dsl/src/main/kotlin/io/hensu/dsl/builders/ScoreTransitionBuilder.kt b/hensu-dsl/src/main/kotlin/io/hensu/dsl/builders/ScoreTransitionBuilder.kt index ac687fc..2341be7 100644 --- a/hensu-dsl/src/main/kotlin/io/hensu/dsl/builders/ScoreTransitionBuilder.kt +++ b/hensu-dsl/src/main/kotlin/io/hensu/dsl/builders/ScoreTransitionBuilder.kt @@ -3,20 +3,21 @@ package io.hensu.dsl.builders import io.hensu.core.rubric.model.ComparisonOperator import io.hensu.core.rubric.model.ScoreCondition import io.hensu.core.workflow.transition.ScoreTransition +import io.hensu.core.workflow.transition.TransitionRule import io.hensu.dsl.extensions.asJavaRange /** * DSL builder for score-based conditional transitions. * * Enables routing based on rubric evaluation scores. Conditions are evaluated in order; the first - * matching condition determines the transition target. + * matching condition determines the transition target. Supports both direct `goto` transitions and + * bounded `revise` transitions with retry budgets. * * Example: * ```kotlin * onScore { * whenScore greaterThan 80.0 goto "approved" - * whenScore `in` 60.0..80.0 goto "revise" - * whenScore lessThan 60.0 goto "rejected" + * whenScore lessThan 60.0 revise "producer" retry 3 otherwise "escalate" * } * ``` * @@ -26,6 +27,8 @@ import io.hensu.dsl.extensions.asJavaRange @WorkflowDsl class ScoreTransitionBuilder { private val conditions = mutableListOf() + private val feedbackConditions = mutableListOf() + private val boundedRules = mutableListOf() /** Access to [whenScore] marker for condition syntax. */ @Suppress("RemoveRedundantQualifierName") @@ -41,7 +44,14 @@ class ScoreTransitionBuilder { * @return builder for specifying transition target */ infix fun whenScore.greaterThan(threshold: Double): ScoreConditionBuilder = - ScoreConditionBuilder(ComparisonOperator.GT, threshold, null, conditions) + ScoreConditionBuilder( + ComparisonOperator.GT, + threshold, + null, + conditions, + feedbackConditions, + boundedRules, + ) /** * Creates a greater-than-or-equal condition. @@ -52,7 +62,14 @@ class ScoreTransitionBuilder { * @return builder for specifying transition target */ infix fun whenScore.greaterThanOrEqual(threshold: Double): ScoreConditionBuilder = - ScoreConditionBuilder(ComparisonOperator.GTE, threshold, null, conditions) + ScoreConditionBuilder( + ComparisonOperator.GTE, + threshold, + null, + conditions, + feedbackConditions, + boundedRules, + ) /** * Creates a less-than condition. @@ -63,7 +80,14 @@ class ScoreTransitionBuilder { * @return builder for specifying transition target */ infix fun whenScore.lessThan(threshold: Double): ScoreConditionBuilder = - ScoreConditionBuilder(ComparisonOperator.LT, threshold, null, conditions) + ScoreConditionBuilder( + ComparisonOperator.LT, + threshold, + null, + conditions, + feedbackConditions, + boundedRules, + ) /** * Creates a less-than-or-equal condition. @@ -74,7 +98,14 @@ class ScoreTransitionBuilder { * @return builder for specifying transition target */ infix fun whenScore.lessThanOrEqual(threshold: Double): ScoreConditionBuilder = - ScoreConditionBuilder(ComparisonOperator.LTE, threshold, null, conditions) + ScoreConditionBuilder( + ComparisonOperator.LTE, + threshold, + null, + conditions, + feedbackConditions, + boundedRules, + ) /** * Creates a range condition (inclusive). @@ -85,12 +116,33 @@ class ScoreTransitionBuilder { * @return builder for specifying transition target */ infix fun whenScore.`in`(range: ClosedFloatingPointRange): ScoreConditionBuilder = - ScoreConditionBuilder(ComparisonOperator.RANGE, 0.0, range.asJavaRange, conditions) + ScoreConditionBuilder( + ComparisonOperator.RANGE, + 0.0, + range.asJavaRange, + conditions, + feedbackConditions, + boundedRules, + ) /** - * Builds the [ScoreTransition] containing all defined conditions. + * Builds all transition rules from this score block. * - * @return compiled score transition rule, never null + * Returns the [ScoreTransition] containing goto conditions (if any) followed by any bounded + * revise rules. A revise-only score block (no goto arms) emits only the bounded rules – score + * extraction still engages because `requiredEngineVars()` flows through the decorator. + * + * @return list of compiled transition rules, never empty */ - fun build(): ScoreTransition = ScoreTransition(conditions.toList()) + fun buildAll(): List { + val result = mutableListOf() + if (conditions.isNotEmpty()) { + result.add(ScoreTransition(conditions.toList())) + } + if (feedbackConditions.isNotEmpty()) { + result.add(ScoreTransition(feedbackConditions.toList(), true)) + } + result.addAll(boundedRules) + return result + } } diff --git a/hensu-dsl/src/main/kotlin/io/hensu/dsl/builders/StandardNodeBuilder.kt b/hensu-dsl/src/main/kotlin/io/hensu/dsl/builders/StandardNodeBuilder.kt index 807fb9a..e40c7fe 100644 --- a/hensu-dsl/src/main/kotlin/io/hensu/dsl/builders/StandardNodeBuilder.kt +++ b/hensu-dsl/src/main/kotlin/io/hensu/dsl/builders/StandardNodeBuilder.kt @@ -7,6 +7,8 @@ import io.hensu.core.review.ReviewConfig import io.hensu.core.review.ReviewMode import io.hensu.core.rubric.RubricParser import io.hensu.core.workflow.node.StandardNode +import io.hensu.core.workflow.transition.ApprovalTransition +import io.hensu.core.workflow.transition.SuccessTransition import io.hensu.dsl.WorkingDirectory import io.hensu.dsl.extensions.resolveAsPrompt import io.hensu.dsl.extensions.resolveAsRubric @@ -24,9 +26,10 @@ import io.hensu.dsl.extensions.resolveAsRubric * prompt = "research.md" * rubric = "quality" * writes("findings", "confidence") - * review(ReviewMode.OPTIONAL) + * review(ReviewMode.REQUIRED) * onSuccess goto "write" * onFailure retry 2 otherwise "error" + * onRejection revise "research" retry 3 otherwise "escalate" * } * ``` * @@ -59,9 +62,13 @@ class StandardNodeBuilder(private val id: String, private val workingDirectory: private var planningConfig: PlanningConfig = PlanningConfig.disabled() private var planFailureTarget: String? = null - /** Define transition on success. Usage: onSuccess goto "next_node" */ - infix fun onSuccess.goto(targetNode: String) { + /** + * Define transition on success. Usage: `onSuccess goto "next_node"` or `onSuccess goto + * "next_node" withFeedback` + */ + infix fun onSuccess.goto(targetNode: String): GotoHandle { transitionBuilder.addSuccessTransition(targetNode) + return GotoHandle(transitionBuilder.rulesRef()) { SuccessTransition(targetNode, true) } } /** Define direct failure transition without retry. Usage: onFailure goto "error-node" */ @@ -83,16 +90,41 @@ class StandardNodeBuilder(private val id: String, private val workingDirectory: val onRejection: onRejection get() = io.hensu.dsl.builders.onRejection - /** Define transition on agent approval. Usage: onApproval goto "finalize" */ - infix fun onApproval.goto(targetNode: String) { + /** + * Define transition on agent approval. Usage: `onApproval goto "finalize"` or `onApproval goto + * "finalize" withFeedback` + */ + infix fun onApproval.goto(targetNode: String): GotoHandle { transitionBuilder.addApprovalTransition(true, targetNode) + return GotoHandle(transitionBuilder.rulesRef()) { + ApprovalTransition(true, targetNode, true) + } } - /** Define transition on agent rejection. Usage: onRejection goto "improve" */ - infix fun onRejection.goto(targetNode: String) { + /** + * Define transition on agent rejection. Usage: `onRejection goto "improve"` or `onRejection + * goto "improve" withFeedback` + */ + infix fun onRejection.goto(targetNode: String): GotoHandle { transitionBuilder.addApprovalTransition(false, targetNode) + return GotoHandle(transitionBuilder.rulesRef()) { + ApprovalTransition(false, targetNode, true) + } } + /** + * Defines a bounded-revise transition on agent rejection. + * + * Retries the producer node up to a budget, then escalates to a fallback target. + * + * Usage: `onRejection revise "producer" retry 3 otherwise "escalate"` + * + * @param producerNode the node to re-execute on each retry + * @return builder for specifying retry budget and escalation target + */ + infix fun onRejection.revise(producerNode: String): ReviseBuilder = + transitionBuilder.createReviseBuilder(ApprovalTransition(false, producerNode), "approval") + /** * Declares the semantic state variable names this node produces. * - Single name: full text response stored under that variable key. @@ -127,8 +159,8 @@ class StandardNodeBuilder(private val id: String, private val workingDirectory: * Usage: * ```kotlin * onScore { - * whenScore greaterThan 80.0 goto "success" - * whenScore lessThan 60.0 goto "failure" + * whenScore greaterThanOrEqual 80.0 goto "success" + * whenScore lessThan 80.0 revise "producer" retry 3 otherwise "escalate" * } * ``` */ diff --git a/hensu-dsl/src/main/kotlin/io/hensu/dsl/builders/SubWorkflowNodeBuilder.kt b/hensu-dsl/src/main/kotlin/io/hensu/dsl/builders/SubWorkflowNodeBuilder.kt index 4e37c80..19194a9 100644 --- a/hensu-dsl/src/main/kotlin/io/hensu/dsl/builders/SubWorkflowNodeBuilder.kt +++ b/hensu-dsl/src/main/kotlin/io/hensu/dsl/builders/SubWorkflowNodeBuilder.kt @@ -2,6 +2,7 @@ package io.hensu.dsl.builders import io.hensu.core.execution.EngineVariables import io.hensu.core.workflow.node.SubWorkflowNode +import io.hensu.core.workflow.transition.SuccessTransition /** * DSL builder for sub-workflow delegation nodes. @@ -81,9 +82,13 @@ class SubWorkflowNodeBuilder(private val id: String) : BaseNodeBuilder, Transiti writes = names.toList() } - /** Define transition on success. Usage: `onSuccess goto "next_node"` */ - infix fun onSuccess.goto(targetNode: String) { + /** + * Define transition on success. Usage: `onSuccess goto "next_node"` or `onSuccess goto + * "next_node" withFeedback` + */ + infix fun onSuccess.goto(targetNode: String): GotoHandle { transitionBuilder.addSuccessTransition(targetNode) + return GotoHandle(transitionBuilder.rulesRef()) { SuccessTransition(targetNode, true) } } /** Define transition on failure. Usage: `onFailure goto "error_node"` */ diff --git a/hensu-dsl/src/main/kotlin/io/hensu/dsl/builders/TransitionBuilder.kt b/hensu-dsl/src/main/kotlin/io/hensu/dsl/builders/TransitionBuilder.kt index ebc1878..327934f 100644 --- a/hensu-dsl/src/main/kotlin/io/hensu/dsl/builders/TransitionBuilder.kt +++ b/hensu-dsl/src/main/kotlin/io/hensu/dsl/builders/TransitionBuilder.kt @@ -2,6 +2,7 @@ package io.hensu.dsl.builders import io.hensu.core.workflow.transition.ApprovalTransition import io.hensu.core.workflow.transition.FailureTransition +import io.hensu.core.workflow.transition.NoConsensusTransition import io.hensu.core.workflow.transition.SuccessTransition import io.hensu.core.workflow.transition.TransitionRule @@ -9,45 +10,69 @@ import io.hensu.core.workflow.transition.TransitionRule * DSL builder for workflow transition rules. * * Collects and compiles transition rules that determine the next node based on execution results. - * Supports success, failure (with retry), and score-based transitions. + * Supports success, failure (with retry), no-consensus, approval/rejection, and score-based + * transitions. Failure retry and revise triggers desugar to + * [io.hensu.core.workflow.transition.BoundedTransition] decorators with namespaced retry budgets. * * Example: * ```kotlin * onSuccess goto "next" * onFailure retry 3 otherwise "error" * onScore { - * whenScore greaterThan 80.0 goto "success" - * whenScore lessThan 60.0 goto "failure" + * whenScore greaterThanOrEqual 80.0 goto "success" + * whenScore lessThan 80.0 revise "producer" retry 3 otherwise "escalate" * } * ``` * * @see TransitionRule for compiled rule types * @see ScoreTransitionBuilder for score-based transitions + * @see ReviseBuilder for bounded-revise chain */ @WorkflowDsl class TransitionBuilder { private val rules = mutableListOf() + internal fun rulesRef(): MutableList = rules + /** * Adds a success transition (internal). * * @param targetNode the node to transition to on success */ - internal fun addSuccessTransition(targetNode: String) { - rules.add(SuccessTransition(targetNode)) + internal fun addSuccessTransition(targetNode: String, withFeedback: Boolean = false) { + rules.add(SuccessTransition(targetNode, withFeedback)) } /** * Adds a failure transition (internal). * - * Used for `onNoConsensus goto` in parallel nodes. - * * @param targetNode the node to transition to on failure */ internal fun addFailureTransition(targetNode: String) { - rules.add(FailureTransition(0, targetNode)) + rules.add(FailureTransition(targetNode)) + } + + /** + * Adds a no-consensus transition (internal). + * + * Used for `onNoConsensus goto` in parallel nodes. + * + * @param targetNode the node to transition to when consensus fails + */ + internal fun addNoConsensusTransition(targetNode: String, withFeedback: Boolean = false) { + rules.add(NoConsensusTransition(targetNode, withFeedback)) } + /** + * Creates a revise builder for bounded-retry transitions (internal). + * + * @param trigger the inner transition rule fired on each retry attempt + * @param namespace counter namespace for budget tracking + * @return builder for configuring retry budget and escalation target + */ + internal fun createReviseBuilder(trigger: TransitionRule, namespace: String): ReviseBuilder = + ReviseBuilder(trigger, namespace, rules) + /** * Creates a retry builder (internal). * @@ -63,8 +88,9 @@ class TransitionBuilder { * * @param targetNode the node to transition to */ - infix fun onSuccess.goto(targetNode: String) { + infix fun onSuccess.goto(targetNode: String): GotoHandle { addSuccessTransition(targetNode) + return GotoHandle(rules) { SuccessTransition(targetNode, true) } } /** @@ -94,18 +120,25 @@ class TransitionBuilder { * @param expected `true` to route on approval; `false` to route on rejection * @param targetNode the node to transition to */ - internal fun addApprovalTransition(expected: Boolean, targetNode: String) { - rules.add(ApprovalTransition(expected, targetNode)) + internal fun addApprovalTransition( + expected: Boolean, + targetNode: String, + withFeedback: Boolean = false, + ) { + rules.add(ApprovalTransition(expected, targetNode, withFeedback)) } /** * Defines score-based conditional transitions. * + * Supports both direct `goto` and bounded `revise` arms. Goto arms evaluate before revise arms; + * score conditions are mutually exclusive ranges in practice. + * * Usage: * ```kotlin * onScore { - * whenScore greaterThan 80.0 goto "success" - * whenScore lessThan 60.0 goto "failure" + * whenScore greaterThanOrEqual 80.0 goto "success" + * whenScore lessThan 80.0 revise "producer" retry 3 otherwise "escalate" * } * ``` * @@ -114,7 +147,7 @@ class TransitionBuilder { fun onScore(block: ScoreTransitionBuilder.() -> Unit) { val builder = ScoreTransitionBuilder() builder.apply(block) - rules.add(builder.build()) + rules.addAll(builder.buildAll()) } /** @@ -124,3 +157,29 @@ class TransitionBuilder { */ fun build(): List = rules.toList() } + +/** + * Handle returned by `goto` methods to allow optional `withFeedback` chaining. + * + * When `withFeedback` is accessed, the last-added rule is replaced with a feedback-preserving + * variant via the provided factory. + * + * @property rules the mutable rule list containing the just-added rule + * @property feedbackFactory produces the feedback-preserving replacement rule + */ +class GotoHandle +internal constructor( + private val rules: MutableList, + private val feedbackFactory: () -> TransitionRule, +) { + /** + * Marks this transition as feedback-preserving. + * + * Usage: `onSuccess goto "node" withFeedback` + */ + val withFeedback: Unit + get() { + rules.removeLastOrNull() + rules.add(feedbackFactory()) + } +} diff --git a/hensu-dsl/src/main/kotlin/io/hensu/dsl/parsers/KotlinScriptParser.kt b/hensu-dsl/src/main/kotlin/io/hensu/dsl/parsers/KotlinScriptParser.kt index 9fcd3d5..cea6c17 100644 --- a/hensu-dsl/src/main/kotlin/io/hensu/dsl/parsers/KotlinScriptParser.kt +++ b/hensu-dsl/src/main/kotlin/io/hensu/dsl/parsers/KotlinScriptParser.kt @@ -40,6 +40,31 @@ class KotlinScriptParser { * ensures visibility across threads. */ @Volatile private var useJsr223: Boolean? = null + + /** + * Markers that begin a transition chain. A bare `withFeedback` always punctuates a chain + * rooted at one of these, so [desugarWithFeedback] wraps from the nearest preceding marker. + */ + private val TRANSITION_MARKERS = + listOf( + "onSuccess", + "onFailure", + "onConsensus", + "onNoConsensus", + "onComplete", + "OnPlanFailure", + "onApproval", + "onRejection", + "whenScore", + ) + + /** Matches a standalone `withFeedback` keyword that is not already dotted. */ + private val WITH_FEEDBACK_TOKEN = + Regex("""(? (whenScore lessThan 70.0 goto "write").withFeedback + * + * onRejection revise "write" withFeedback retry 3 otherwise "x" withFeedback + * -> ((onRejection revise "write").withFeedback retry 3 otherwise "x").withFeedback + * ``` + */ + private fun desugarWithFeedback(content: String): String { + var source = content + while (true) { + val mask = codeMask(source) + + // Rightmost bare keyword that sits in code (not a string/comment). Rewriting it to + // `.withFeedback` removes it from the next pass via the not-dotted lookbehind. + val keyword = + WITH_FEEDBACK_TOKEN.findAll(source).lastOrNull { mask[it.range.first] } ?: break + + val keywordStart = keyword.range.first + val keywordEnd = keyword.range.last + 1 + + // Collapse the whitespace between the chain and the keyword into the inserted `).`. + var chainEnd = keywordStart + while (chainEnd > 0 && source[chainEnd - 1].isWhitespace()) chainEnd-- + + // Chain start = nearest preceding marker in code; fall back to line start if absent. + val chainStart = + MARKER_TOKEN.findAll(source) + .lastOrNull { it.range.first < keywordStart && mask[it.range.first] } + ?.range + ?.first + ?: source.lastIndexOf('\n', keywordStart).let { if (it < 0) 0 else it + 1 } + + source = + source.substring(0, chainStart) + + "(" + + source.substring(chainStart, chainEnd) + + ").withFeedback" + + source.substring(keywordEnd) + } + return source + } + + /** + * Builds a per-character mask marking which positions are source code rather than string + * literals or comments. + * + * Recognizes line comments (`//`), block comments, single- and triple-quoted string literals + * (honoring backslash escapes outside triple quotes), and character literals. Characters inside + * any of these are marked `false`; everything else is `true`. Used by [desugarWithFeedback] to + * avoid rewriting a `withFeedback` keyword that appears inside a prompt string or comment. + */ + private fun codeMask(s: String): BooleanArray { + val code = BooleanArray(s.length) + val n = s.length + var i = 0 + while (i < n) { + val c = s[i] + when { + c == '/' && i + 1 < n && s[i + 1] == '/' -> { + while (i < n && s[i] != '\n') i++ + } + c == '/' && i + 1 < n && s[i + 1] == '*' -> { + i += 2 + while (i < n && !(s[i] == '*' && i + 1 < n && s[i + 1] == '/')) i++ + i += 2 + } + c == '"' && i + 2 < n && s[i + 1] == '"' && s[i + 2] == '"' -> { + i += 3 + while (i + 2 < n && !(s[i] == '"' && s[i + 1] == '"' && s[i + 2] == '"')) i++ + i += 3 + } + c == '"' -> { + i++ + while (i < n && s[i] != '"') { + if (s[i] == '\\') i++ + i++ + } + i++ + } + c == '\'' -> { + i++ + while (i < n && s[i] != '\'') { + if (s[i] == '\\') i++ + i++ + } + i++ + } + else -> { + code[i] = true + i++ + } + } + } + return code + } + /** * Skip leading comments before the function definition. Handles: * - Single-line comments: // ... diff --git a/hensu-dsl/src/test/kotlin/io/hensu/dsl/parsers/KotlinScriptParserTest.kt b/hensu-dsl/src/test/kotlin/io/hensu/dsl/parsers/KotlinScriptParserTest.kt index 4ff93b5..1ece69c 100644 --- a/hensu-dsl/src/test/kotlin/io/hensu/dsl/parsers/KotlinScriptParserTest.kt +++ b/hensu-dsl/src/test/kotlin/io/hensu/dsl/parsers/KotlinScriptParserTest.kt @@ -4,7 +4,7 @@ import io.hensu.core.review.ReviewConfig import io.hensu.core.review.ReviewMode import io.hensu.core.rubric.model.ComparisonOperator import io.hensu.core.workflow.node.StandardNode -import io.hensu.core.workflow.transition.FailureTransition +import io.hensu.core.workflow.transition.BoundedTransition import io.hensu.core.workflow.transition.ScoreTransition import io.hensu.core.workflow.transition.SuccessTransition import io.hensu.dsl.WorkingDirectory @@ -141,9 +141,9 @@ class KotlinScriptParserTest { val successRule = reviewNode.transitionRules.filterIsInstance().single() assertThat(successRule.targetNode).isEqualTo("quality-check") - val failureRule = reviewNode.transitionRules.filterIsInstance().single() - assertThat(failureRule.retryCount).isEqualTo(2) - assertThat(failureRule.thenTargetNode).isEqualTo("reject") + val boundedRule = reviewNode.transitionRules.filterIsInstance().single() + assertThat(boundedRule.budget()).isEqualTo(2) + assertThat(boundedRule.otherwise()).isEqualTo("reject") // Verify review node review config: OPTIONAL assertThat(reviewNode.reviewConfig) diff --git a/hensu-serialization/src/main/java/io/hensu/serialization/TransitionRuleDeserializer.java b/hensu-serialization/src/main/java/io/hensu/serialization/TransitionRuleDeserializer.java index a41745f..99a1a0a 100644 --- a/hensu-serialization/src/main/java/io/hensu/serialization/TransitionRuleDeserializer.java +++ b/hensu-serialization/src/main/java/io/hensu/serialization/TransitionRuleDeserializer.java @@ -16,6 +16,18 @@ /// Deserializes the `TransitionRule` sealed hierarchy using a `"type"` discriminator field. /// +/// Handled types: +/// - **`"success"`** → `SuccessTransition(targetNode)` +/// - **`"failure"`** → `FailureTransition(targetNode)` +/// - **`"noConsensus"`** → `NoConsensusTransition(targetNode)` +/// - **`"bounded"`** → `BoundedTransition(inner, namespace, budget, otherwise)` — the `inner` +/// field is recursively deserialized via {@link #deserializeNode(JsonNode)} +/// - **`"always"`** → `AlwaysTransition()` +/// - **`"score"`** → `ScoreTransition` with a manually extracted `ScoreCondition` list +/// - **`"rubricFail"`** → `RubricFailTransition` with a no-op lambda; the original +/// predicate is not serializable and cannot be restored from JSON +/// - **`"approval"`** → `ApprovalTransition(expected, targetNode)` +/// /// All nested domain types (`ScoreCondition`, `DoubleRange`) are extracted manually from the /// `JsonNode` tree to avoid POJO reflection, keeping this deserializer native-image safe. /// @@ -29,16 +41,7 @@ class TransitionRuleDeserializer extends StdDeserializer { super(TransitionRule.class); } - /// Reads the `"type"` field and dispatches to the appropriate `TransitionRule` constructor. - /// - /// Handled types: - /// - **`"success"`** → `SuccessTransition(targetNode)` - /// - **`"failure"`** → `FailureTransition(retryCount, targetNode)` - /// - **`"always"`** → `AlwaysTransition()` - /// - **`"score"`** → `ScoreTransition` with a manually extracted `ScoreCondition` list - /// - **`"rubricFail"`** → `RubricFailTransition` with a no-op lambda; the original - /// predicate is not serializable and cannot be restored from JSON - /// - **`"approval"`** → `ApprovalTransition(expected, targetNode)` + /// Reads the `"type"` field and delegates to {@link #deserializeNode(JsonNode)}. /// /// @param p the JSON parser positioned at the start of the transition rule object, not null /// @param ctx the deserialization context, not null @@ -48,15 +51,38 @@ class TransitionRuleDeserializer extends StdDeserializer { public TransitionRule deserialize(JsonParser p, DeserializationContext ctx) throws IOException { ObjectMapper mapper = (ObjectMapper) p.getCodec(); JsonNode root = mapper.readTree(p); + return deserializeNode(root); + } + /// Walks a `JsonNode` tree and constructs the matching `TransitionRule` subtype. + /// + /// Extracted from {@link #deserialize} so that `BoundedTransition`'s nested `inner` field + /// can be deserialized recursively without re-entering the Jackson streaming pipeline. + /// + /// @param root the JSON object node containing a `"type"` discriminator, not null + /// @return the deserialized `TransitionRule`, never null + /// @throws IOException if the `"type"` value is unknown or a required field is absent + private TransitionRule deserializeNode(JsonNode root) throws IOException { String type = root.get("type").asText(); + boolean feedback = boolField(root, "withFeedback"); return switch (type) { - case "success" -> new SuccessTransition(root.get("targetNode").asText()); - case "failure" -> - new FailureTransition( - root.get("retryCount").asInt(), root.get("targetNode").asText()); - case "always" -> new AlwaysTransition(); + case "success" -> new SuccessTransition(root.get("targetNode").asText(), feedback); + case "failure" -> { + JsonNode tn = root.get("targetNode"); + String target = tn == null || tn.isNull() ? null : tn.asText(); + yield new FailureTransition(target); + } + case "noConsensus" -> + new NoConsensusTransition(root.get("targetNode").asText(), feedback); + case "bounded" -> + new BoundedTransition( + deserializeNode(root.get("inner")), + root.get("namespace").asText(), + root.get("budget").asInt(), + root.get("otherwise").asText(), + boolField(root, "escalationWithFeedback")); + case "always" -> new AlwaysTransition(feedback); case "score" -> { List conditions = new ArrayList<>(); for (JsonNode c : root.get("conditions")) { @@ -75,13 +101,20 @@ public TransitionRule deserialize(JsonParser p, DeserializationContext ctx) thro conditions.add( new ScoreCondition(op, value, range, c.get("targetNode").asText())); } - yield new ScoreTransition(conditions); + yield new ScoreTransition(conditions, feedback); } - case "rubricFail" -> new RubricFailTransition(_ -> null); + case "rubricFail" -> new RubricFailTransition(_ -> null, feedback); case "approval" -> new ApprovalTransition( - root.get("expected").asBoolean(), root.get("targetNode").asText()); + root.get("expected").asBoolean(), + root.get("targetNode").asText(), + feedback); default -> throw new IOException("Unknown TransitionRule type: " + type); }; } + + private static boolean boolField(JsonNode node, String field) { + JsonNode f = node.get(field); + return f != null && f.asBoolean(false); + } } diff --git a/hensu-serialization/src/main/java/io/hensu/serialization/TransitionRuleSerializer.java b/hensu-serialization/src/main/java/io/hensu/serialization/TransitionRuleSerializer.java index a3e893c..bdd2c1c 100644 --- a/hensu-serialization/src/main/java/io/hensu/serialization/TransitionRuleSerializer.java +++ b/hensu-serialization/src/main/java/io/hensu/serialization/TransitionRuleSerializer.java @@ -13,7 +13,11 @@ /// /// Emitted JSON shape per subtype: /// - **`SuccessTransition`**: `{"type":"success","targetNode":"..."}` -/// - **`FailureTransition`**: `{"type":"failure","retryCount":N,"targetNode":"..."}` +/// - **`FailureTransition`**: `{"type":"failure","targetNode":"..."|null}` +/// - **`NoConsensusTransition`**: `{"type":"noConsensus","targetNode":"..."}` +/// - **`BoundedTransition`**: +/// `{"type":"bounded","namespace":"...","budget":N,"otherwise":"...","inner":{...}}` +/// — the `inner` field is a recursively serialized `TransitionRule` /// - **`AlwaysTransition`**: `{"type":"always"}` /// - **`ScoreTransition`**: `{"type":"score","conditions":[...]}` — each condition written /// manually: `operator` (string), `value` (number|null), `range` (object|null), `targetNode` @@ -49,9 +53,27 @@ public void serialize(TransitionRule rule, JsonGenerator gen, SerializerProvider } case FailureTransition t -> { gen.writeStringField("type", "failure"); - gen.writeNumberField("retryCount", t.retryCount()); + if (t.targetNode() != null) { + gen.writeStringField("targetNode", t.targetNode()); + } else { + gen.writeNullField("targetNode"); + } + } + case NoConsensusTransition t -> { + gen.writeStringField("type", "noConsensus"); gen.writeStringField("targetNode", t.targetNode()); } + case BoundedTransition t -> { + gen.writeStringField("type", "bounded"); + gen.writeStringField("namespace", t.namespace()); + gen.writeNumberField("budget", t.budget()); + gen.writeStringField("otherwise", t.otherwise()); + if (t.escalationWithFeedback()) { + gen.writeBooleanField("escalationWithFeedback", true); + } + gen.writeFieldName("inner"); + serialize(t.inner(), gen, provider); + } case AlwaysTransition _ -> gen.writeStringField("type", "always"); case ScoreTransition t -> { gen.writeStringField("type", "score"); @@ -86,6 +108,10 @@ public void serialize(TransitionRule rule, JsonGenerator gen, SerializerProvider } } + if (!(rule instanceof BoundedTransition) && rule.withFeedback()) { + gen.writeBooleanField("withFeedback", true); + } + gen.writeEndObject(); } } diff --git a/hensu-serialization/src/test/java/io/hensu/serialization/WorkflowSerializerTest.java b/hensu-serialization/src/test/java/io/hensu/serialization/WorkflowSerializerTest.java index 57b9fd5..ce6c99d 100644 --- a/hensu-serialization/src/test/java/io/hensu/serialization/WorkflowSerializerTest.java +++ b/hensu-serialization/src/test/java/io/hensu/serialization/WorkflowSerializerTest.java @@ -25,7 +25,9 @@ import io.hensu.core.workflow.state.VarType; import io.hensu.core.workflow.state.WorkflowStateSchema; import io.hensu.core.workflow.transition.ApprovalTransition; +import io.hensu.core.workflow.transition.BoundedTransition; import io.hensu.core.workflow.transition.FailureTransition; +import io.hensu.core.workflow.transition.NoConsensusTransition; import io.hensu.core.workflow.transition.RubricFailTransition; import io.hensu.core.workflow.transition.ScoreTransition; import io.hensu.core.workflow.transition.SuccessTransition; @@ -593,6 +595,103 @@ void roundTrip_rubricFailTransition() { assertThat(restoredRule.function()).isNotNull(); } + @Test + void roundTrip_boundedNoConsensusTransition() { + // BoundedTransition wrapping NoConsensusTransition — verifies recursive inner + // serialization with the "noConsensus" type discriminator + StandardNode start = + StandardNode.builder() + .id("start") + .transitionRules( + List.of( + new BoundedTransition( + new NoConsensusTransition("producer"), + "consensus", + 3, + "escalate"))) + .build(); + + Workflow workflow = buildWorkflowWith(start); + Workflow restored = WorkflowSerializer.fromJson(WorkflowSerializer.toJson(workflow)); + + StandardNode restoredStart = (StandardNode) restored.getNodes().get("start"); + BoundedTransition bounded = + (BoundedTransition) restoredStart.getTransitionRules().getFirst(); + assertThat(bounded.namespace()).isEqualTo("consensus"); + assertThat(bounded.budget()).isEqualTo(3); + assertThat(bounded.otherwise()).isEqualTo("escalate"); + assertThat(bounded.inner()).isInstanceOf(NoConsensusTransition.class); + assertThat(((NoConsensusTransition) bounded.inner()).targetNode()).isEqualTo("producer"); + } + + @Test + void roundTrip_boundedApprovalTransition() { + // BoundedTransition wrapping ApprovalTransition(false, ...) — the "rejection revise" case + StandardNode start = + StandardNode.builder() + .id("start") + .transitionRules( + List.of( + new BoundedTransition( + new ApprovalTransition(false, "producer"), + "approval", + 2, + "escalate"))) + .build(); + + Workflow workflow = buildWorkflowWith(start); + Workflow restored = WorkflowSerializer.fromJson(WorkflowSerializer.toJson(workflow)); + + StandardNode restoredStart = (StandardNode) restored.getNodes().get("start"); + BoundedTransition bounded = + (BoundedTransition) restoredStart.getTransitionRules().getFirst(); + assertThat(bounded.namespace()).isEqualTo("approval"); + assertThat(bounded.budget()).isEqualTo(2); + assertThat(bounded.otherwise()).isEqualTo("escalate"); + assertThat(bounded.inner()).isInstanceOf(ApprovalTransition.class); + ApprovalTransition inner = (ApprovalTransition) bounded.inner(); + assertThat(inner.expected()).isFalse(); + assertThat(inner.targetNode()).isEqualTo("producer"); + } + + @Test + void roundTrip_boundedScoreTransition() { + // BoundedTransition wrapping ScoreTransition — the "score revise" case + StandardNode start = + StandardNode.builder() + .id("start") + .transitionRules( + List.of( + new BoundedTransition( + new ScoreTransition( + List.of( + new ScoreCondition( + ComparisonOperator.LT, + 60.0, + null, + "producer"))), + "score", + 3, + "escalate"))) + .build(); + + Workflow workflow = buildWorkflowWith(start); + Workflow restored = WorkflowSerializer.fromJson(WorkflowSerializer.toJson(workflow)); + + StandardNode restoredStart = (StandardNode) restored.getNodes().get("start"); + BoundedTransition bounded = + (BoundedTransition) restoredStart.getTransitionRules().getFirst(); + assertThat(bounded.namespace()).isEqualTo("score"); + assertThat(bounded.budget()).isEqualTo(3); + assertThat(bounded.otherwise()).isEqualTo("escalate"); + assertThat(bounded.inner()).isInstanceOf(ScoreTransition.class); + ScoreTransition inner = (ScoreTransition) bounded.inner(); + assertThat(inner.conditions()).hasSize(1); + assertThat(inner.conditions().getFirst().operator()).isEqualTo(ComparisonOperator.LT); + assertThat(inner.conditions().getFirst().value()).isEqualTo(60.0); + assertThat(inner.conditions().getFirst().targetNode()).isEqualTo("producer"); + } + // --- helpers --- private Workflow buildStandardWorkflow() { @@ -625,7 +724,10 @@ private StandardNode standardNode() { .rubric(RubricParser.parseContent("start", "quality")) .writes(List.of("sentiment", "score")) .transitionRules( - List.of(new SuccessTransition("done"), new FailureTransition(3, "done"))) + List.of( + new SuccessTransition("done"), + new BoundedTransition( + new FailureTransition(null), "failure", 3, "done"))) .build(); } diff --git a/hensu-server/README.md b/hensu-server/README.md index 936de65..426e574 100644 --- a/hensu-server/README.md +++ b/hensu-server/README.md @@ -137,8 +137,9 @@ curl -X POST http://localhost:8080/api/v1/executions \ ## REST API All API and MCP endpoints require a valid JWT bearer token (`Authorization: Bearer `). -Tenant identity is extracted from the `tenant_id` claim. In dev/test mode, authentication -is disabled and a default tenant is used (see [Local Development](#local-development)). +Tenant identity is extracted from the `tenant_id` claim. JWT is required in every profile +except `inmem` (the integration-test profile), where authentication is disabled and a default +tenant is used (see [Local Development](#local-development)). ### Workflow Definition Management @@ -174,18 +175,18 @@ Runtime operations for starting and managing workflow executions (client integra Implements MCP (Model Context Protocol) over SSE using a "split-pipe" architecture: -- **Downstream (SSE)**: Hensu pushes JSON-RPC tool call requests to connected clients -- **Upstream (HTTP POST)**: Clients send JSON-RPC responses back (requests time out after 60 s if no response is received — see `McpSessionManager.DEFAULT_TIMEOUT`) +- **Downstream (SSE)**: the Hensu server pushes JSON-RPC tool call requests to connected tenant clients +- **Upstream (HTTP POST)**: tenant clients relay each request to their own MCP servers and post the JSON-RPC response back (requests time out after 60 s if no response is received — see `McpSessionManager.DEFAULT_TIMEOUT`) ```mermaid flowchart LR - subgraph engine["Hensu Engine"] + subgraph server["Hensu Server"] direction TB send(["sendRequest()"]) handle(["handleResponse\n(Future.done)"]) end - subgraph client["Tenant Client (MCP Server)"] + subgraph client["Tenant Client"] direction TB es(["EventSource"]) post(["POST /message"]) @@ -194,7 +195,7 @@ flowchart LR send -->|"SSE (tools/call)"| es post -->|"POST (result/error)"| handle - style engine fill:#2c2c2e, stroke:#3a3a3c, color:#ebebf5, stroke-width:1px + style server fill:#2c2c2e, stroke:#3a3a3c, color:#ebebf5, stroke-width:1px style client fill:#2c2c2e, stroke:#3a3a3c, color:#ebebf5, stroke-width:1px style send fill:#2c2c2e, stroke:#48484a, color:#ebebf5, stroke-width:1px style handle fill:#2c2c2e, stroke:#48484a, color:#ebebf5, stroke-width:1px @@ -499,6 +500,25 @@ hensu-server/ ./gradlew :hensu-server:test --tests "*.persistence.*" ``` +## Native Image + +`hensu-server` ships as a GraalVM native image built by Quarkus. Because `hensu-core` carries no +serialization metadata, every reflective access Jackson needs is registered explicitly in the +`config/*NativeConfig` classes — never in the core or DSL modules. + +```bash +# Native build (requires GraalVM JDK 25+ with native-image; takes several minutes) +./gradlew hensu-server:build -Dquarkus.native.enabled=true -Dquarkus.package.type=native + +# Run the binary (inmem profile needs no PostgreSQL) +QUARKUS_PROFILE=inmem ./hensu-server/build/hensu-server-*-runner +``` + +For day-to-day work use JVM mode (`./gradlew :hensu-server:quarkusDev`) and reserve native builds +for release verification. See the [Server Developer Guide — GraalVM Native Image](../docs/developer-guide-server.md#graalvm-native-image) +for the reflection-registration patterns, the `CoreModelNativeConfig` decision ladder, and resource +bundling rules. + ## Dependencies - **hensu-core**: Core workflow engine diff --git a/hensu-server/src/main/java/io/hensu/server/mcp/McpSessionManager.java b/hensu-server/src/main/java/io/hensu/server/mcp/McpSessionManager.java index 0f37818..8c9840b 100644 --- a/hensu-server/src/main/java/io/hensu/server/mcp/McpSessionManager.java +++ b/hensu-server/src/main/java/io/hensu/server/mcp/McpSessionManager.java @@ -22,8 +22,8 @@ /// ### Architecture /// ``` /// +—————————————————+ +—————————————————+ -/// │ Hensu Engine │ │ Tenant Client │ -/// │ │ │ (MCP Server) │ +/// │ Hensu Server │ │ Tenant Client │ +/// │ │ │ (bridge) │ /// │ sendRequest() │———— SSE ——————————>│ EventSource │ /// │ │ (tools/call) │ │ /// │ │ │ │ diff --git a/hensu-server/src/main/java/io/hensu/server/persistence/JdbcWorkflowStateRepository.java b/hensu-server/src/main/java/io/hensu/server/persistence/JdbcWorkflowStateRepository.java index fb3b089..c0d42cb 100644 --- a/hensu-server/src/main/java/io/hensu/server/persistence/JdbcWorkflowStateRepository.java +++ b/hensu-server/src/main/java/io/hensu/server/persistence/JdbcWorkflowStateRepository.java @@ -19,8 +19,8 @@ /// PostgreSQL-backed workflow execution state repository. /// /// Stores {@link HensuSnapshot} records with JSONB columns for `context`, -/// `history`, and `active_plan`. Each execution has at most one row — -/// checkpoints overwrite the previous state (UPSERT semantics). +/// `history`, `active_plan`, and `retry_counters`. Each execution has at most one +/// row — checkpoints overwrite the previous state (UPSERT semantics). /// /// ### Lease Management /// `save()` automatically maintains the distributed recovery lease: @@ -50,9 +50,9 @@ public class JdbcWorkflowStateRepository implements WorkflowStateRepository { """ INSERT INTO runtime.execution_states (tenant_id, execution_id, workflow_id, current_node_id, - context, history, active_plan, phase, checkpoint_reason, created_at, - server_node_id, last_heartbeat_at) - VALUES (?, ?, ?, ?, ?::jsonb, ?::jsonb, ?::jsonb, ?::jsonb, ?, ?, ?, ?) + context, history, active_plan, phase, retry_counters, + checkpoint_reason, created_at, server_node_id, last_heartbeat_at) + VALUES (?, ?, ?, ?, ?::jsonb, ?::jsonb, ?::jsonb, ?::jsonb, ?::jsonb, ?, ?, ?, ?) ON CONFLICT (tenant_id, execution_id) DO UPDATE SET current_node_id = EXCLUDED.current_node_id, @@ -60,6 +60,7 @@ ON CONFLICT (tenant_id, execution_id) history = EXCLUDED.history, active_plan = EXCLUDED.active_plan, phase = EXCLUDED.phase, + retry_counters = EXCLUDED.retry_counters, checkpoint_reason = EXCLUDED.checkpoint_reason, server_node_id = EXCLUDED.server_node_id, last_heartbeat_at = EXCLUDED.last_heartbeat_at @@ -67,7 +68,7 @@ ON CONFLICT (tenant_id, execution_id) private static final String SQL_FIND_BY_EXECUTION_ID = """ - SELECT workflow_id, current_node_id, context, history, + SELECT workflow_id, current_node_id, context, retry_counters, history, active_plan, phase, checkpoint_reason, created_at FROM runtime.execution_states WHERE tenant_id = ? AND execution_id = ? @@ -79,7 +80,7 @@ ON CONFLICT (tenant_id, execution_id) /// (completed, failed, rejected) that also have a null server lease. private static final String SQL_FIND_PAUSED = """ - SELECT execution_id, workflow_id, current_node_id, context, history, + SELECT execution_id, workflow_id, current_node_id, context, retry_counters, history, active_plan, phase, checkpoint_reason, created_at FROM runtime.execution_states WHERE tenant_id = ? AND current_node_id IS NOT NULL @@ -89,7 +90,7 @@ ON CONFLICT (tenant_id, execution_id) private static final String SQL_FIND_BY_WORKFLOW_ID = """ - SELECT execution_id, workflow_id, current_node_id, context, history, + SELECT execution_id, workflow_id, current_node_id, context, retry_counters, history, active_plan, phase, checkpoint_reason, created_at FROM runtime.execution_states WHERE tenant_id = ? AND workflow_id = ? @@ -105,6 +106,8 @@ ON CONFLICT (tenant_id, execution_id) // --- Fields --- private static final TypeReference> MAP_TYPE = new TypeReference<>() {}; + private static final TypeReference> RETRY_COUNTERS_TYPE = + new TypeReference<>() {}; private final JdbcSupport jdbc; private final ObjectMapper objectMapper; @@ -148,11 +151,12 @@ public void save(String tenantId, HensuSnapshot snapshot) { ? writeJson(snapshot.activePlan()) : null); ps.setString(8, writeJson(snapshot.phase())); - ps.setString(9, snapshot.checkpointReason()); + ps.setString(9, writeJson(snapshot.retryCounters())); + ps.setString(10, snapshot.checkpointReason()); ps.setObject( - 10, OffsetDateTime.ofInstant(snapshot.createdAt(), ZoneOffset.UTC)); - ps.setString(11, leaseNodeId); - ps.setObject(12, heartbeatAt); + 11, OffsetDateTime.ofInstant(snapshot.createdAt(), ZoneOffset.UTC)); + ps.setString(12, leaseNodeId); + ps.setObject(13, heartbeatAt); }, "Failed to save execution state: " + snapshot.executionId()); } @@ -227,6 +231,8 @@ public int deleteAllForTenant(String tenantId) { private HensuSnapshot mapSnapshot(String executionId, ResultSet rs) throws SQLException { Map context = readJson(rs.getString("context"), MAP_TYPE); + Map retryCounters = + readJson(rs.getString("retry_counters"), RETRY_COUNTERS_TYPE); ExecutionHistory history = readJson(rs.getString("history"), ExecutionHistory.class); String activePlanJson = rs.getString("active_plan"); Plan activePlan = activePlanJson != null ? readJson(activePlanJson, Plan.class) : null; @@ -243,6 +249,7 @@ private HensuSnapshot mapSnapshot(String executionId, ResultSet rs) throws SQLEx executionId, rs.getString("current_node_id"), new HashMap<>(context), + retryCounters, history, activePlan, phase, diff --git a/hensu-server/src/main/java/io/hensu/server/workflow/WorkflowExecutionService.java b/hensu-server/src/main/java/io/hensu/server/workflow/WorkflowExecutionService.java index aaff2ec..b4b3f7c 100644 --- a/hensu-server/src/main/java/io/hensu/server/workflow/WorkflowExecutionService.java +++ b/hensu-server/src/main/java/io/hensu/server/workflow/WorkflowExecutionService.java @@ -176,6 +176,7 @@ private void runExecutionAsync( executionId, null, executionContext, + Map.of(), null, null, null, diff --git a/hensu-server/src/test/java/io/hensu/server/persistence/ExecutionLeaseTest.java b/hensu-server/src/test/java/io/hensu/server/persistence/ExecutionLeaseTest.java index 3aa625c..e84af20 100644 --- a/hensu-server/src/test/java/io/hensu/server/persistence/ExecutionLeaseTest.java +++ b/hensu-server/src/test/java/io/hensu/server/persistence/ExecutionLeaseTest.java @@ -102,6 +102,7 @@ private static HensuSnapshot checkpoint(String executionId) { executionId, "step-1", Map.of(), + Map.of(), new ExecutionHistory(), null, null, diff --git a/hensu-server/src/test/java/io/hensu/server/persistence/JdbcWorkflowStateRepositoryTest.java b/hensu-server/src/test/java/io/hensu/server/persistence/JdbcWorkflowStateRepositoryTest.java index f1b0806..4ca57a2 100644 --- a/hensu-server/src/test/java/io/hensu/server/persistence/JdbcWorkflowStateRepositoryTest.java +++ b/hensu-server/src/test/java/io/hensu/server/persistence/JdbcWorkflowStateRepositoryTest.java @@ -72,6 +72,7 @@ void save_upsertOverwritesCheckpoint() { "exec-u", "done", Map.of("topic", "ML"), + Map.of(), new ExecutionHistory(), null, null, @@ -106,6 +107,7 @@ void save_completed_clearsLease() throws SQLException { "exec-done", null, Map.of(), + Map.of(), new ExecutionHistory(), null, null, @@ -128,6 +130,7 @@ void save_paused_clearsLease() throws SQLException { "exec-paused", "process", Map.of(), + Map.of(), new ExecutionHistory(), null, null, @@ -149,6 +152,7 @@ void findPaused_returnsOnlyHumanPausedSnapshots() { "exec-paused", "process", Map.of(), + Map.of(), new ExecutionHistory(), null, null, @@ -166,6 +170,7 @@ void findPaused_returnsOnlyHumanPausedSnapshots() { "exec-done", null, Map.of(), + Map.of(), new ExecutionHistory(), null, null, @@ -181,6 +186,7 @@ void findPaused_returnsOnlyHumanPausedSnapshots() { "exec-failed", "process", Map.of(), + Map.of(), new ExecutionHistory(), null, null, @@ -195,6 +201,7 @@ void findPaused_returnsOnlyHumanPausedSnapshots() { "exec-rejected", "process", Map.of(), + Map.of(), new ExecutionHistory(), null, null, @@ -253,6 +260,7 @@ void historySerializationRoundTrip() { "exec-hist", "process", Map.of("topic", "history"), + Map.of(), history, null, null, @@ -281,6 +289,7 @@ private static HensuSnapshot makeSnapshot(String executionId, String currentNode executionId, currentNodeId, Map.of("topic", "AI"), + Map.of(), new ExecutionHistory(), null, null, diff --git a/hensu-server/src/test/java/io/hensu/server/workflow/ExecutionQueryServiceTest.java b/hensu-server/src/test/java/io/hensu/server/workflow/ExecutionQueryServiceTest.java index 63a400b..dbce901 100644 --- a/hensu-server/src/test/java/io/hensu/server/workflow/ExecutionQueryServiceTest.java +++ b/hensu-server/src/test/java/io/hensu/server/workflow/ExecutionQueryServiceTest.java @@ -43,6 +43,7 @@ void shouldReturnPlanInfoWithCorrectFieldOrderWhenPlanActive() { "exec-1", "review-node", Map.of(), + Map.of(), null, active, null, @@ -72,6 +73,7 @@ void shouldFilterUnderscorePrefixedKeysFromOutput() { "exec-1", null, mixed, + Map.of(), null, null, null, @@ -100,6 +102,7 @@ void shouldIncludeCorrelationIdWhenPhasIsAwaiting() { "exec-1", "draft", Map.of(), + Map.of(), null, null, awaiting, @@ -122,6 +125,7 @@ void shouldReturnNullCorrelationIdWhenPhaseIsNotAwaiting() { "exec-1", null, Map.of(), + Map.of(), null, null, null, @@ -147,6 +151,7 @@ void shouldIncludeCorrelationIdInPausedExecutionSummary() { "exec-1", "draft", Map.of(), + Map.of(), null, null, awaiting, @@ -171,6 +176,7 @@ void shouldReturnEmptyMapWhenAllKeysAreInternal() { "exec-1", null, internalOnly, + Map.of(), null, null, null, diff --git a/hensu-server/src/test/java/io/hensu/server/workflow/ExecutionStateServiceTest.java b/hensu-server/src/test/java/io/hensu/server/workflow/ExecutionStateServiceTest.java index dc64a66..e2fe6a1 100644 --- a/hensu-server/src/test/java/io/hensu/server/workflow/ExecutionStateServiceTest.java +++ b/hensu-server/src/test/java/io/hensu/server/workflow/ExecutionStateServiceTest.java @@ -72,6 +72,7 @@ private HensuSnapshot pausedSnapshot() { "exec-1", "node-1", new HashMap<>(Map.of("k", "v")), + Map.of(), null, null, null, diff --git a/hensu-server/src/test/resources/workflows/parallel-judge-decides.json b/hensu-server/src/test/resources/workflows/parallel-judge-decides.json index 33d4809..6655e8e 100644 --- a/hensu-server/src/test/resources/workflows/parallel-judge-decides.json +++ b/hensu-server/src/test/resources/workflows/parallel-judge-decides.json @@ -52,9 +52,11 @@ "transitionRules" : [ { "type" : "success", "targetNode" : "consensus-reached" + }, { + "type" : "noConsensus", + "targetNode" : "no-consensus" }, { "type" : "failure", - "retryCount" : 0, "targetNode" : "no-consensus" } ] }, diff --git a/hensu-server/src/test/resources/workflows/parallel-majority-vote.json b/hensu-server/src/test/resources/workflows/parallel-majority-vote.json index a5b6eb5..53ab77d 100644 --- a/hensu-server/src/test/resources/workflows/parallel-majority-vote.json +++ b/hensu-server/src/test/resources/workflows/parallel-majority-vote.json @@ -56,9 +56,11 @@ "transitionRules" : [ { "type" : "success", "targetNode" : "consensus-reached" + }, { + "type" : "noConsensus", + "targetNode" : "no-consensus" }, { "type" : "failure", - "retryCount" : 0, "targetNode" : "no-consensus" } ] }, diff --git a/hensu-server/src/test/resources/workflows/parallel-unanimous.json b/hensu-server/src/test/resources/workflows/parallel-unanimous.json index 69af08a..7f052e8 100644 --- a/hensu-server/src/test/resources/workflows/parallel-unanimous.json +++ b/hensu-server/src/test/resources/workflows/parallel-unanimous.json @@ -56,9 +56,11 @@ "transitionRules" : [ { "type" : "success", "targetNode" : "consensus-reached" + }, { + "type" : "noConsensus", + "targetNode" : "no-consensus" }, { "type" : "failure", - "retryCount" : 0, "targetNode" : "no-consensus" } ] }, diff --git a/hensu-server/src/test/resources/workflows/parallel-weighted-vote.json b/hensu-server/src/test/resources/workflows/parallel-weighted-vote.json index bf0f665..be8600d 100644 --- a/hensu-server/src/test/resources/workflows/parallel-weighted-vote.json +++ b/hensu-server/src/test/resources/workflows/parallel-weighted-vote.json @@ -45,9 +45,11 @@ "transitionRules" : [ { "type" : "success", "targetNode" : "consensus-reached" + }, { + "type" : "noConsensus", + "targetNode" : "no-consensus" }, { "type" : "failure", - "retryCount" : 0, "targetNode" : "no-consensus" } ] }, diff --git a/working-dir/workflows/backtrack-routing.kt b/working-dir/workflows/backtrack-routing.kt index ca2ea74..98f3939 100644 --- a/working-dir/workflows/backtrack-routing.kt +++ b/working-dir/workflows/backtrack-routing.kt @@ -31,7 +31,7 @@ fun backtrackRoutingWorkflow() = workflow("backtrack-routing") { model = Models.CLAUDE_HAIKU_4_5 } agent("critic") { - role = "Quality Reviewer. Return JSON with key: recommendation." + role = "Quality Reviewer" model = Models.GEMINI_3_1_PRO } } @@ -41,7 +41,7 @@ fun backtrackRoutingWorkflow() = workflow("backtrack-routing") { node("draft") { agent = "writer" - prompt = "Write a short article about {topic}. {recommendation}" + prompt = "Write a short article about {topic}." writes("article") onSuccess goto "review" onFailure retry 2 otherwise "reject" @@ -61,7 +61,7 @@ fun backtrackRoutingWorkflow() = workflow("backtrack-routing") { node("refine") { agent = "writer" - prompt = "Refine the following draft based on the recommendation.\n\nDraft: {article}\nRecommendation: {recommendation}" + prompt = "Refine the following draft.\n\nDraft: {article}" writes("article") onSuccess goto "final_check" onFailure retry 2 otherwise "reject" diff --git a/working-dir/workflows/content-pipeline.kt b/working-dir/workflows/content-pipeline.kt index 087c2f5..d5359b4 100644 --- a/working-dir/workflows/content-pipeline.kt +++ b/working-dir/workflows/content-pipeline.kt @@ -2,7 +2,7 @@ * Two-agent content pipeline from the Hensu README example. * * Flow: writer drafts an article → rubric scores it (retry if < 70) → - * reviewer approves (done) or rejects (loop back to writer). + * reviewer approves (done) or rejects (revise back to writer, up to 3 times). * * Run: hensu run content-pipeline -d working-dir -v -c "{\"topic\": \"Cold Fusion\"}" * -v (verbose) shows node inputs and outputs in the console @@ -13,7 +13,7 @@ fun contentPipeline() = workflow("content-pipeline") { agents { agent("writer") { role = "Content Writer"; model = Models.GEMINI_3_1_FLASH_LITE } - agent("reviewer") { role = "Content Reviewer"; model = Models.GEMINI_3_1_PRO } + agent("reviewer") { role = "Content Reviewer"; model = Models.GEMINI_3_1_FLASH_LITE } } state { @@ -30,19 +30,20 @@ fun contentPipeline() = workflow("content-pipeline") { writes("draft") rubric = "content-quality.md" onScore { - whenScore lessThan 70.0 goto "write" + whenScore lessThan 70.0 goto "write" withFeedback } onSuccess goto "review" } node("review") { agent = "reviewer" - prompt = "Review this article: {draft}. Is it good enough to publish?" + prompt = "Review this article as it must be about dogs: {draft}." writes("draft") onApproval goto "done" - onRejection goto "write" + onRejection revise "write" withFeedback retry 3 otherwise "needs-work" withFeedback } end("done", ExitStatus.SUCCESS) + end("needs-work", ExitStatus.FAILURE) } } diff --git a/working-dir/workflows/llm-evaluation.kt b/working-dir/workflows/llm-evaluation.kt index 4e1596a..b3dfbb3 100644 --- a/working-dir/workflows/llm-evaluation.kt +++ b/working-dir/workflows/llm-evaluation.kt @@ -56,7 +56,7 @@ fun llmEvalTestWorkflow() = workflow("llm-eval-test") { onScore { whenScore greaterThanOrEqual 80.0 goto "approve" - whenScore `in` 60.0..79.0 goto "revise" + whenScore `in` 60.0..79.0 revise "revise" retry 2 otherwise "escalate" whenScore lessThan 60.0 goto "rewrite" } } diff --git a/working-dir/workflows/scored-review.kt b/working-dir/workflows/scored-review.kt index a55d827..c55f6dc 100644 --- a/working-dir/workflows/scored-review.kt +++ b/working-dir/workflows/scored-review.kt @@ -29,7 +29,6 @@ fun workflow() = workflow("demo") { prompt = """ Write a focused article about: {topic} Keep it under 300 words. Be specific, avoid filler. - If feedback was provided, incorporate it: {recommendation} """.trimIndent() writes("article") rubric = "content-quality.md" diff --git a/working-dir/workflows/self-evaluation.kt b/working-dir/workflows/self-evaluation.kt index ffb48c9..f51e26d 100644 --- a/working-dir/workflows/self-evaluation.kt +++ b/working-dir/workflows/self-evaluation.kt @@ -4,6 +4,7 @@ * Demonstrates the rubric evaluation system including: * - Self-evaluation: Agent returns its own score and recommendations * - Auto-backtracking: Automatic retry when scores are low + * - Bounded revise: Reviewer rejection backtracks to draft, capped at 2 attempts * - Score-based transitions: Different paths based on evaluation scores * * Usage: @@ -30,21 +31,21 @@ fun selfEvaluationWorkflow() = workflow("self-evaluation") { agents { // Main content writer - provides self-evaluation in output agent("writer") { - role = "Content writer who produces articles and self-evaluates. Return JSON with keys: article, recommendation." + role = "Content writer who produces articles and self-evaluates." model = Models.GEMINI_2_5_PRO temperature = 0.7 } // Reviewer agent - evaluates content independently agent("reviewer") { - role = "Content reviewer. Return JSON with key: recommendation." + role = "Content reviewer." model = Models.GEMINI_3_1_PRO temperature = 0.3 } // Editor for improvements agent("editor") { - role = "Editor who improves content based on recommendations. Return JSON with keys: article, recommendation." + role = "Editor who improves content based on feedback." model = Models.GEMINI_3_1_FLASH_LITE temperature = 0.5 } @@ -56,7 +57,7 @@ fun selfEvaluationWorkflow() = workflow("self-evaluation") { // Step 1: Initial draft with self-evaluation node("draft") { agent = "writer" - prompt = "Write about: {topic}. {recommendation}. Requirements: 1) Clear introduction, 2) At least 3 main points, 3) Concrete examples, 4) Conclusion." + prompt = "Write about: {topic}. Requirements: 1) Clear introduction, 2) At least 3 main points, 3) Concrete examples, 4) Conclusion." writes("article") onScore { @@ -75,13 +76,13 @@ fun selfEvaluationWorkflow() = workflow("self-evaluation") { // Critical failure overrides approval: score below 60 means restart draft onScore { whenScore lessThan 60.0 goto "draft" } onApproval goto "finalize" - onRejection goto "improve" + onRejection revise "draft" retry 2 otherwise "escalate" } // Step 3: Improvement based on feedback node("improve") { agent = "editor" - prompt = "Improve this content: {article}. Feedback: {recommendation}." + prompt = "Improve this content: {article}." writes("article") onScore { @@ -95,11 +96,11 @@ fun selfEvaluationWorkflow() = workflow("self-evaluation") { // Step 4: Restart from scratch node("restart") { agent = "writer" - prompt = "Previous attempt failed. Starting fresh. Topic: {topic}. Issues: {recommendation}. Create a new draft." + prompt = "Previous attempt failed. Starting fresh. Topic: {topic}. Create a new draft." writes("article") onScore { - whenScore greaterThanOrEqual 70.0 goto "review" + whenScore greaterThanOrEqual 70.0 goto "review" withFeedback whenScore lessThan 70.0 goto "fail" } } @@ -107,7 +108,7 @@ fun selfEvaluationWorkflow() = workflow("self-evaluation") { // Step 5: Finalize node("finalize") { agent = "editor" - prompt = "Finalize this approved content: {article}. Apply final recommendation: {recommendation}. Format for publication." + prompt = "Finalize this approved content: {article}. Format for publication." writes("article") onSuccess goto "success" } diff --git a/working-dir/workflows/weighted-vote.kt b/working-dir/workflows/weighted-vote.kt index d7baff4..f24d417 100644 --- a/working-dir/workflows/weighted-vote.kt +++ b/working-dir/workflows/weighted-vote.kt @@ -4,6 +4,7 @@ * This workflow demonstrates: * - Parallel execution of reviewers with different weights * - Weighted vote consensus: weighted approval ratio must meet threshold + * - Bounded revise on no-consensus: retries up to 2 times before escalating * - Senior reviewer has higher weight (influence) than junior reviewers * - Branch yields: each reviewer produces domain feedback * - All yields merge into context regardless of vote outcome @@ -100,7 +101,7 @@ fun workflow() = workflow("weighted-vote-test") { } onConsensus goto "revise" - onNoConsensus goto "rejected" + onNoConsensus revise "write-docs" retry 2 otherwise "rejected" } node("revise") { From 05dd55f4ad70ecb6b208b754db308e133cc4d13f Mon Sep 17 00:00:00 2001 From: Aleksandr Suvorov Date: Mon, 29 Jun 2026 01:32:51 +0400 Subject: [PATCH 2/2] feat(transition): add forgotten files Resolve: #73 Signed-off-by: Aleksandr Suvorov --- .../enricher/FeedbackContextInjectorTest.java | 98 +++++++++++++++++++ .../db/migration/V3__add_retry_counters.sql | 8 ++ 2 files changed, 106 insertions(+) create mode 100644 hensu-core/src/test/java/io/hensu/core/execution/enricher/FeedbackContextInjectorTest.java create mode 100644 hensu-server/src/main/resources/db/migration/V3__add_retry_counters.sql diff --git a/hensu-core/src/test/java/io/hensu/core/execution/enricher/FeedbackContextInjectorTest.java b/hensu-core/src/test/java/io/hensu/core/execution/enricher/FeedbackContextInjectorTest.java new file mode 100644 index 0000000..9104c32 --- /dev/null +++ b/hensu-core/src/test/java/io/hensu/core/execution/enricher/FeedbackContextInjectorTest.java @@ -0,0 +1,98 @@ +package io.hensu.core.execution.enricher; + +import static org.assertj.core.api.Assertions.assertThat; + +import io.hensu.core.execution.EngineVariables; +import io.hensu.core.execution.executor.ExecutionContext; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +@DisplayName("FeedbackContextInjector") +class FeedbackContextInjectorTest extends EnricherTestBase { + + private FeedbackContextInjector injector; + + @BeforeEach + void setUp() { + injector = new FeedbackContextInjector(); + } + + @Test + @DisplayName("appends feedback section with heading when recommendation is present") + void shouldAppendFeedbackWhenRecommendationPresent() { + ExecutionContext ctx = ctx(null, null); + ctx.getState().getContext().put(EngineVariables.RECOMMENDATION, "Needs more examples."); + + String result = injector.inject("Write about AI.", minimalNode(), ctx); + + assertThat(result) + .startsWith("Write about AI.") + .contains(FeedbackContextInjector.FEEDBACK_SECTION_PREFIX) + .endsWith("Needs more examples."); + } + + @Test + @DisplayName( + "does not remove recommendation from context — cleanup is TransitionPostProcessor's job") + void shouldNotRemoveRecommendationFromContext() { + ExecutionContext ctx = ctx(null, null); + ctx.getState().getContext().put(EngineVariables.RECOMMENDATION, "Fix structure."); + + injector.inject("prompt", minimalNode(), ctx); + + assertThat(ctx.getState().getContext()).containsKey(EngineVariables.RECOMMENDATION); + } + + @Test + @DisplayName( + "feedback injector is first in DEFAULT pipeline — agent sees feedback before format instructions") + void shouldBeFirstInDefaultPipeline() { + ExecutionContext ctx = ctx(null, null); + ctx.getState().getContext().put(EngineVariables.RECOMMENDATION, "Fix structure."); + + String result = EngineVariablePromptEnricher.DEFAULT.enrich("base", minimalNode(), ctx); + + int feedbackPos = result.indexOf("### Previous Feedback"); + assertThat(feedbackPos).as("feedback section must be present").isGreaterThan(0); + + assertThat(result.substring(0, feedbackPos).trim()) + .as("nothing between base prompt and feedback section") + .isEqualTo("base"); + + // Recommendation still in context — cleanup is TransitionPostProcessor's job + assertThat(ctx.getState().getContext()).containsKey(EngineVariables.RECOMMENDATION); + } + + @Test + @DisplayName("no recommendation in context — prompt returned unchanged") + void shouldReturnPromptUnchangedWhenNoRecommendation() { + ExecutionContext ctx = ctx(null, null); + + String result = injector.inject("Write about AI.", minimalNode(), ctx); + + assertThat(result).isEqualTo("Write about AI."); + } + + @Test + @DisplayName("blank recommendation — prompt returned unchanged") + void shouldReturnPromptUnchangedWhenRecommendationBlank() { + ExecutionContext ctx = ctx(null, null); + ctx.getState().getContext().put(EngineVariables.RECOMMENDATION, " "); + + String result = injector.inject("Write about AI.", minimalNode(), ctx); + + assertThat(result).isEqualTo("Write about AI."); + } + + @Test + @DisplayName("non-String recommendation — prompt returned unchanged") + void shouldReturnPromptUnchangedWhenRecommendationNotString() { + ExecutionContext ctx = ctx(null, null); + ctx.getState().getContext().put(EngineVariables.RECOMMENDATION, 42); + + String result = injector.inject("Write about AI.", minimalNode(), ctx); + + assertThat(result).isEqualTo("Write about AI."); + } +} diff --git a/hensu-server/src/main/resources/db/migration/V3__add_retry_counters.sql b/hensu-server/src/main/resources/db/migration/V3__add_retry_counters.sql new file mode 100644 index 0000000..0d3130a --- /dev/null +++ b/hensu-server/src/main/resources/db/migration/V3__add_retry_counters.sql @@ -0,0 +1,8 @@ +-- Per-node retry budgets for bounded transitions (ticket #73). +-- +-- Stores the namespaced retry counter map (keyed by "namespace:nodeId") so that +-- bounded revise/retry budgets survive checkpoint, resume, and recovery. The +-- '{}' default backfills pre-migration rows with an empty counter map, so no +-- data migration is required. +ALTER TABLE runtime.execution_states + ADD COLUMN retry_counters JSONB NOT NULL DEFAULT '{}';