Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .claude/rules/10-java-standards.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
3 changes: 2 additions & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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 <token>` 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 <token>` 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

Expand Down
6 changes: 3 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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"
}
Expand All @@ -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)
Expand Down
18 changes: 16 additions & 2 deletions docs/developer-guide-core.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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(),
Expand Down Expand Up @@ -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 |
Expand Down
30 changes: 14 additions & 16 deletions docs/developer-guide-serialization.md
Original file line number Diff line number Diff line change
Expand Up @@ -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<String>`) 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<String>`) 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:<nodeId>` 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.
Expand Down
7 changes: 2 additions & 5 deletions docs/developer-guide-server.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
```

---
Expand Down Expand Up @@ -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.

Expand Down
Loading
Loading