This guide covers API usage, adapter development, extension points, and testing strategies for developers working with or extending hensu-core.
- API Usage
- Execution Pipeline
- Pause / Resume Lifecycle
- Creating Custom Adapters
- Generic Nodes
- Sub-Workflows
- Action Handlers
- Rubric Engine
- State Schema
- Engine Variable Injection
- Tool Registry
- Plan Engine
- Template Resolution
- Human Review
- Testing
- GraalVM Native Image Constraints
- Credentials Management
- Key Classes Reference
Standalone with environment variables (stub provider only):
var env = HensuFactory.createEnvironment();With explicit providers (recommended):
var env = HensuFactory.builder()
.loadCredentials(properties)
.agentProviders(List.of(new LangChain4jProvider()))
.build();The builder provides fine-grained control over all components:
HensuEnvironment env = HensuFactory.builder()
// Credentials (multiple loading strategies)
.loadCredentials(properties) // From Properties + env vars
.loadCredentialsFromEnvironment() // From env vars only
.anthropicApiKey("sk-ant-...") // Individual key
.credential("CUSTOM_KEY", "value") // Custom key
// Agent providers (explicit wiring, GraalVM-safe)
.agentProviders(List.of(new LangChain4jProvider()))
// Optional components
.stubMode(false) // Enable for testing
.evaluatorAgent("evaluator") // LLM-based rubric evaluation
.reviewHandler(myReviewHandler) // Human review support
.actionExecutor(myActionExecutor) // Action execution
// Repositories (defaults to in-memory implementations)
.workflowRepository(myWorkflowRepo) // Custom workflow storage
.workflowStateRepository(myStateRepo) // Custom state persistence
.build();Note: When
workflowRepositoryorworkflowStateRepositoryare not set,HensuFactorydefaults toInMemoryWorkflowRepositoryandInMemoryWorkflowStateRepositoryrespectively.
Note:
StubAgentProvideris always included automatically bybuild(). Do not add it explicitly.
// Get executor from environment
HensuEnvironment env = HensuFactory.builder()
.loadCredentialsFromEnvironment()
.agentProviders(List.of(new LangChain4jProvider()))
.build();
WorkflowExecutor executor = env.getWorkflowExecutor();
// Register workflow agents
env.getAgentRegistry().registerAgents(workflow.getAgents());
// Execute with initial context
Map<String, Object> initialContext = Map.of("topic", "AI workflows");
ExecutionResult result = executor.execute(workflow, initialContext);
// Handle result
if (result instanceof ExecutionResult.Completed completed) {
System.out.println("Success! Exit status: " + completed.getExitStatus());
System.out.println("Final output: " + completed.getFinalOutput());
} else if (result instanceof ExecutionResult.Paused(HensuState pausedState)) {
System.out.println("Paused at node: " + pausedState.getCurrentNode());
// Save snapshot for later resume
HensuSnapshot snapshot = pausedState.snapshot("paused");
// ... persist snapshot, then later:
// executor.executeFrom(workflow, snapshot.toState());
} else if (result instanceof ExecutionResult.Rejected rejected) {
System.out.println("Rejected: " + rejected.getReason());
}The WorkflowExecutor processes each node through a standardized, three-phase lifecycle orchestrated by processor
pipelines. This model decouples the core node logic (e.g., agent calls) from cross-cutting concerns like state
management, history, and quality evaluation.
The lifecycle for every node is:
- Pre-Execution Pipeline — processors that run before the node's main logic
- Node Execution — the appropriate
NodeExecutoris invoked - Post-Execution Pipeline — processors that run after the node's main logic to process its result
Every processor returns a ProcessorOutcome — a sealed type with three variants:
| Variant | Meaning |
|---|---|
Continue |
Proceed to the next processor (or next loop iteration if this was the last one) |
Terminal(result) |
Short-circuit the executor with the given ExecutionResult (e.g., Rejected, Failure) |
SuspendForExternal |
Pause the post-pipeline at this processor for an out-of-band resume (see Pause / Resume Lifecycle) |
SuspendForExternal is only valid in the post-pipeline. If a pre-pipeline processor returns it, the pipeline throws IllegalStateException — no node result exists to cache yet.
flowchart LR
subgraph loop["WorkflowExecutor.executeLoop()"]
direction LR
pre(["Pre-Execution\n(placeholder)"]) --> node(["Node Executor\n(agent call)"]) --> post(["Post-Execution\n(6 processors)"])
end
style loop fill:#2c2c2e, stroke:#3a3a3c, color:#ebebf5, stroke-width:1px
style pre fill:#2c2c2e, stroke:#48484a, color:#ebebf5, stroke-width:1px
style node fill:#2c2c2e, stroke:#0A84FF, color:#ebebf5, stroke-width:1px
style post fill:#2c2c2e, stroke:#48484a, color:#ebebf5, stroke-width:1px
linkStyle default stroke:#0A84FF, stroke-width:1px
Runs before the node's primary logic. All processors receive a ProcessorContext with a null result.
| Order | Processor | Responsibility |
|---|---|---|
| 1 | CheckpointPreProcessor |
Fires listener.onCheckpoint(state) for crash-recovery persistence |
| 2 | NodeStartPreProcessor |
Fires listener.onNodeStart(node) for observability |
This is where the majority of the workflow's state management and decision-making occurs. The processors run in a fixed, critical order — changing the order breaks invariants downstream processors depend on:
| Order | Processor | Responsibility |
|---|---|---|
| 1 | OutputExtractionPostProcessor |
Validates (control chars, Unicode tricks, size) then stores output |
| 2 | RubricPostProcessor |
Automated quality evaluation and self-correction |
| 3 | ReviewPostProcessor |
Human-in-the-loop checkpoints (may suspend for async review) |
| 4 | NodeCompletePostProcessor |
Fires listener.onNodeComplete(node, result) for observability |
| 5 | HistoryPostProcessor |
Records execution step for audit and backtracking |
| 6 | TransitionPostProcessor |
Determines next node via TransitionRule evaluation |
OutputExtractionPostProcessor — First validates the raw output using AgentOutputValidator (see
Agentic Output Validation). On any violation it returns ExecutionResult.Failure
immediately, leaving the context map unmodified. If validation passes, it puts the raw output string into the context
map keyed by node ID. For StandardNodes with writes declared, it routes the output differently:
- Single write: attempts to parse the response as JSON and extract the declared key; falls back to the full raw text if the key is absent or the output is not valid JSON.
- Multiple writes: the response is parsed as JSON and each declared key is extracted into context.
RubricPostProcessor — If a node has a Rubric, this processor evaluates the output against the rubric's
criteria using the RubricEngine. It stores the evaluation result in the state for use by ScoreTransition rules. If
the evaluation fails and no explicit transition handles the low score, it can trigger an auto-backtrack to a prior
step, enabling self-correcting loops. On auto-backtrack, sets state.nodeRedirected = true — downstream processors
check this flag to skip redundant work (e.g., ReviewPostProcessor skips review when a rubric already redirected).
ReviewPostProcessor — If a node has a reviewConfig, this processor invokes the registered ReviewHandler.
The handler returns a ReviewOutcome: either Decided(ReviewDecision) for synchronous review or
Pending(correlationId) for asynchronous out-of-band review. On Pending, the processor emits
ProcessorOutcome.SuspendForExternal, pausing the execution until a resume call delivers the decision.
Based on the decision, the processor can allow the workflow to continue (Approve), reject it terminating execution
(Reject), or backtrack to a previous node (Backtrack). Skipped when state.isNodeRedirected() is true (prior
rubric backtrack).
NodeCompletePostProcessor — Fires listener.onNodeComplete(node, result) for observability. Runs after
Rubric/Review so rejected or redirected nodes are never marked complete.
HistoryPostProcessor — Appends an immutable ExecutionStep (containing a state snapshot, the node result, and a
timestamp) to the ExecutionHistory. Runs after Rubric/Review so the snapshot includes scores and review edits. This
is the foundation for time-travel debugging and backtracking.
TransitionPostProcessor — The final step. Evaluates the current node's TransitionRule list in order. The first
rule that returns a valid target node ID wins, and the state is updated to point to that next node. If
state.isNodeRedirected() is true (set by Rubric or Review backtrack), the transition evaluator is skipped — the
current node was already updated by the backtracking processor. The flag is then reset. Clears
state.activePlan on every transition to prevent stale plans from leaking across nodes. Throws
IllegalStateException if no rule matches, preventing the workflow from silently getting stuck.
Both ParallelNodeExecutor and ForkNodeExecutor run branches on named virtual threads
via StructuredTaskScope (preview, Java 25). Two concurrency guarantees apply to all
parallel execution paths:
1. ScopedValue isolation – each branch receives an isolated context snapshot bound via
ScopedValue.where(BRANCH_CONTEXT, snapshot). ParallelNodeExecutor.BRANCH_CONTEXT is a
ScopedValue<Map<String, Object>> that carries branch-scoped metadata (like BranchExecutionConfig –
consensus strategy, yield declarations) without polluting the state context map. Branch mutations
never leak into sibling branches or the parent state. This is the same mechanism used for tenant
isolation (TenantContext).
Extension rule: Never use
ThreadLocalin branch-aware code.ScopedValueis the only safe context propagation mechanism for virtual threads. See10-java-standards.mdfor details.
2. SynchronizedListenerDecorator – the parent ExecutionListener is wrapped in a
SynchronizedListenerDecorator before branch submission. Every callback is synchronized so that
multi-line output (e.g., box-drawing in verbose CLI, SSE event frames) completes atomically without
interleaving across concurrent branches.
Custom ExecutionListener implementations do not need their own synchronization when used with
parallel nodes – the decorator handles it. Only the parallel execution path pays the synchronization
cost; sequential nodes use the raw listener.
JEP 491 (Java 24+): Virtual thread pinning on
synchronizedblocks was eliminated. Monitor contention on I/O-bound listener callbacks is negligible for typical branch counts (3–10).
LLM-generated outputs are non-deterministic and treated as untrusted data. Unlike REST input (short, user-typed, identity-validated), agentic output is machine-generated and carries threats that simple control-character filtering does not cover.
AgentOutputValidator (io.hensu.core.util) is the single authority for output validation in the core engine. It is
called by OutputExtractionPostProcessor before any output is written to workflow state.
| Check | Method | Threat |
|---|---|---|
| ASCII control characters | containsDangerousChars() |
Null bytes, non-printable chars that degrade downstream processing |
| Unicode manipulation chars | containsUnicodeTricks() |
RTL overrides, zero-width chars, BOM — used to hide content or carry prompt injection |
| Payload size | exceedsSizeLimit() |
Runaway generation exhausting memory or storage in downstream stages |
The size limit is 4 MB (MAX_LLM_OUTPUT_BYTES) — higher than the 1 MB REST cap to accommodate large agentic
outputs (documents, generated code). The limits serve different threat models and are intentionally separate constants.
The containsUnicodeTricks() check targets characters outside the ASCII control range that LLMs can produce:
| Category | Codepoints | Example Attack |
|---|---|---|
| RTL/LTR directional overrides | U+202A–U+202E | Hide malicious text behind visual reversal |
| Unicode isolates | U+2066–U+2069 | Isolate bidi runs to redirect rendering |
| Zero-width characters | U+200B–U+200D | Invisible payload embedding, steganography |
| Byte-order mark | U+FEFF | Unexpected BOM in content signals injection |
Legitimate RTL scripts (Arabic, Hebrew) use natural bidi properties of their characters and do not require directional override characters. The check produces no false positives on standard multilingual content.
AgentOutputValidator is intentionally separate from InputValidator in hensu-server. The two classes serve
distinct trust boundaries and must not be merged:
| Concern | InputValidator (server) |
AgentOutputValidator (core) |
|---|---|---|
| Input source | Human-typed REST requests | LLM-generated node outputs |
| Size limit | 1 MB (MAX_JSON_MESSAGE_BYTES) |
4 MB (MAX_LLM_OUTPUT_BYTES) |
| Unicode tricks | Not checked (low risk for humans) | Checked (LLMs can produce overrides) |
| Safe-ID validation | Yes — for user-supplied identifiers | Not applicable — no IDs in output |
| Framework | Jakarta Bean Validation | Pure utility, zero dependencies |
When a post-processor cannot complete synchronously (e.g., human review via a web UI), the execution suspends and resumes later without re-running the node's agent call. Three sealed types collaborate to make this work.
ExecutionPhase is a sealed interface on HensuState that tracks where inside a node's lifecycle the execution is:
| Variant | Meaning |
|---|---|
Initial |
Top of the loop — run pre-pipeline, execute node, post-pipeline normally |
Awaiting(nodeId, processorId, cachedResult, correlationId, requestedAt) |
Paused inside the post-pipeline; resume re-enters at the named processor with the cached result |
Terminal |
Execution finished — further calls fail fast |
NodeLifecycleCoordinator dispatches on the phase:
Initial— full lifecycle: reset per-node state, pre-pipeline, execute node, post-pipeline.Awaiting— resume path: skip pre-pipeline and node execution entirely; re-enter the post-pipeline atprocessorIdviaProcessorPipeline.executePostFrom(processorId, context).Terminal— throwsIllegalStateException.
ReviewHandler.requestReview() returns ReviewOutcome — a sealed type that replaces the previous synchronous ReviewDecision return:
| Variant | Meaning |
|---|---|
Decided(decision) |
Handler answered synchronously — apply the ReviewDecision now |
Pending(correlationId) |
Handler accepted the request; answer arrives out-of-band via resume |
On Pending, ReviewPostProcessor emits ProcessorOutcome.SuspendForExternal("ReviewPostProcessor", cachedResult, correlationId). The pipeline records an ExecutionPhase.Awaiting on state and returns ExecutionResult.Paused.
When the execution resumes, the caller supplies a ResumeInput — a sealed type set transiently on HensuState before executeFrom():
| Variant | Meaning |
|---|---|
ApplyReview(correlationId, decision) |
Deliver a ReviewDecision produced out-of-band by a reviewer |
ApplyContextEdits(edits) |
Merge free-form context edits without going through review |
None |
Pure continuation (e.g., server restart recovery) |
ResumeInput is transient — it is never persisted in snapshots. It is set by the resume caller, consumed by post-processors, and cleared after the post-pipeline completes.
- Caller sets
state.setResumeInput(new ApplyReview(correlationId, decision)). NodeLifecycleCoordinatorseesExecutionPhase.Awaiting→ callspostPipeline.executePostFrom("ReviewPostProcessor", ctx).ReviewPostProcessor.process()checksstate.getResumeInput()forApplyReviewbefore callingrequestReview()— this prevents an infinitePendingloop on resume.- Validates that
correlationIdmatches theAwaitingphase viaExecutionPhase.validateCorrelation(). - Applies the decision (Approve/Backtrack/Reject) and clears
resumeInput. - Remaining post-processors run normally (NodeComplete → History → Transition).
When a reviewer or rubric backtrack redirects execution to a previous node, StandardNodeExecutor checks for a _prompt_override key in the state context. If present, it is consumed (removed) and used instead of the node's static prompt. This allows reviewers to inject corrective instructions without modifying the workflow definition.
build.gradle.kts
dependencies {
implementation(project(":hensu-core"))
implementation("com.myai:myai-sdk:1.0.0")
}package io.hensu.adapter.myai;
import io.hensu.core.agent.AgentProvider;
import io.hensu.core.agent.Agent;
import io.hensu.core.agent.AgentConfig;
import java.util.Map;
public class MyAiProvider implements AgentProvider {
@Override
public String getName() {
return "myai";
}
@Override
public boolean supportsModel(String modelName) {
return modelName.startsWith("myai-");
}
@Override
public Agent createAgent(String agentId, AgentConfig config,
Map<String, String> credentials) {
String apiKey = credentials.get("MYAI_API_KEY");
return new MyAiAgent(agentId, config, apiKey);
}
@Override
public int getPriority() {
return 100;
}
}package io.hensu.adapter.myai;
import io.hensu.core.agent.Agent;
import io.hensu.core.agent.AgentConfig;
import io.hensu.core.agent.AgentResponse;
import java.util.Map;
public class MyAiAgent implements Agent {
private final String id;
private final AgentConfig config;
private final MyAiClient client;
public MyAiAgent(String id, AgentConfig config, String apiKey) {
this.id = id;
this.config = config;
this.client = new MyAiClient(apiKey);
}
@Override
public AgentResponse execute(String prompt, Map<String, Object> context) {
try {
MyAiResponse response = client.complete(prompt);
return AgentResponse.success(response.getText(), response.getMetadata());
} catch (Exception e) {
return AgentResponse.failure(e);
}
}
@Override
public String getId() { return id; }
@Override
public AgentConfig getConfig() { return config; }
}Wire your provider via HensuFactory.builder().agentProviders(...):
var env = HensuFactory.builder()
.agentProviders(List.of(
new LangChain4jProvider(),
new MyAiProvider()
))
.loadCredentialsFromEnvironment()
.build();Or add a single provider:
var env = HensuFactory.builder()
.agentProvider(new LangChain4jProvider())
.agentProvider(new MyAiProvider())
.build();When multiple providers support the same model, higher priority wins:
public class LangChain4jProvider implements AgentProvider {
public int getPriority() { return 100; } // Preferred
}
public class MyAiProvider implements AgentProvider {
public int getPriority() { return 50; } // Fallback
}
// StubAgentProvider has priority 1000 when stub mode is enabledGeneric nodes allow custom execution logic without involving an AI agent. They're useful for data validation, transformation, external service integration, and conditional branching.
- Define a
GenericNodein your workflow with anexecutorType - Create a
GenericNodeHandlerimplementation with matching type - Register the handler with
NodeExecutorRegistry - At runtime,
GenericNodeExecutorlooks up your handler and invokes it
Implement the GenericNodeHandler interface:
package com.example.handlers;
import io.hensu.core.execution.executor.ExecutionContext;
import io.hensu.core.execution.executor.GenericNodeHandler;
import io.hensu.core.execution.executor.NodeResult;
import io.hensu.core.workflow.node.GenericNode;
import java.util.Map;
public class ValidatorHandler implements GenericNodeHandler {
public static final String TYPE = "validator";
@Override
public String getType() {
return TYPE;
}
@Override
public NodeResult handle(GenericNode node, ExecutionContext context) throws Exception {
Map<String, Object> config = node.getConfig();
String fieldName = (String) config.getOrDefault("field", "input");
Object fieldValue = context.getState().getContext().get(fieldName);
boolean isValid = fieldValue != null && !fieldValue.toString().isBlank();
if (isValid) {
return NodeResult.success("Validation passed",
Map.of("validated_field", fieldName));
} else {
return NodeResult.failure(fieldName + " is required");
}
}
}HensuEnvironment env = HensuFactory.builder()
.agentProviders(List.of(new LangChain4jProvider()))
.build();
NodeExecutorRegistry registry = env.getNodeExecutorRegistry();
registry.registerGenericHandler("validator", new ValidatorHandler());
registry.registerGenericHandler("data-transformer", new DataTransformerHandler());With CDI, handlers annotated with @ApplicationScoped are automatically discovered and registered:
import jakarta.enterprise.context.ApplicationScoped;
@ApplicationScoped
public class ValidatorHandler implements GenericNodeHandler {
// ... same implementation
}Both CLI and server register all CDI-discovered handlers via injection:
@Inject Instance<GenericNodeHandler> genericNodeHandlers;
private void registerGenericHandlers() {
for (GenericNodeHandler handler : genericNodeHandlers) {
hensuEnvironment.getNodeExecutorRegistry()
.registerGenericHandler(handler.getType(), handler);
}
}- Use descriptive type names:
"user-validator"is better than"v1" - Access config safely: Use
getOrDefault()for optional parameters - Store outputs in context: Put results in
context.getState().getContext()for subsequent nodes - Return meaningful metadata: Include relevant info in
NodeResultmetadata map - Handle errors gracefully: Return
NodeResult.failure()with clear error messages
SubWorkflowNode delegates execution to a nested workflow. The parent pauses on the boundary, the child runs to completion, and control returns to the parent with selected outputs mapped back into its state.
_tenant_idis copied from parent into child context, preserving multi-tenant isolation across the boundary.- Nested invocation is capped at depth 16 (
SubWorkflowNodeExecutor.MAX_DEPTH) via_sub_workflow_depth. The executor throws before invoking a child beyond the cap – this is a hard guard against runaway recursion, not a tunable.
Data crosses the boundary only through explicit mappings declared on SubWorkflowNode:
| Mapping | Direction | Semantics |
|---|---|---|
inputMapping |
childKey → parentKey |
Before the child starts, the executor reads parentKey from parent state and writes it as childKey in child state. |
outputMapping |
parentKey → childKey |
On successful child completion, the executor reads childKey from child state and writes it as parentKey in parent state. |
Anything not covered by these mappings stays on its side of the boundary. There is no implicit state leakage between parent and child.
SubWorkflowGraphValidator rejects cycles and dangling references at graph-load time, before any node executes. See SubWorkflowGraphValidator checks under State Schema.
Action handlers send data from workflow actions to external systems. Handlers can implement any integration: HTTP calls, messaging (Slack, email), event publishing (Kafka, RabbitMQ), database operations, or custom logic.
- Implement the
ActionHandlerinterface with your execution logic - Register the handler with
ActionExecutor - Reference the handler by ID in workflow DSL using
send() - At runtime, the executor looks up your handler and invokes it
package com.example.handlers;
import io.hensu.core.execution.action.ActionExecutor.ActionResult;
import io.hensu.core.execution.action.ActionHandler;
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.util.Map;
public class SlackHandler implements ActionHandler {
private final String webhookUrl;
private final HttpClient client = HttpClient.newHttpClient();
public SlackHandler(String webhookUrl) {
this.webhookUrl = webhookUrl;
}
@Override
public String getHandlerId() {
return "slack";
}
@Override
public ActionResult execute(Map<String, Object> payload, Map<String, Object> context) {
try {
String message = payload.getOrDefault("message", "Workflow event").toString();
String body = String.format("{\"text\": \"%s\"}", message);
HttpRequest request = HttpRequest.newBuilder()
.uri(URI.create(webhookUrl))
.header("Content-Type", "application/json")
.POST(HttpRequest.BodyPublishers.ofString(body))
.build();
HttpResponse<String> response = client.send(request,
HttpResponse.BodyHandlers.ofString());
if (response.statusCode() >= 200 && response.statusCode() < 300) {
return ActionResult.success("Slack notification sent");
} else {
return ActionResult.failure("Slack API error: " + response.statusCode());
}
} catch (Exception e) {
return ActionResult.failure("Slack call failed: " + e.getMessage(), e);
}
}
}CLIActionExecutor executor = new CLIActionExecutor();
String slackUrl = System.getenv("SLACK_WEBHOOK_URL");
executor.registerHandler(new SlackHandler(slackUrl));
HensuEnvironment env = HensuFactory.builder()
.actionExecutor(executor)
.agentProviders(List.of(new LangChain4jProvider()))
.build();import jakarta.enterprise.context.ApplicationScoped;
import jakarta.inject.Inject;
import org.eclipse.microprofile.config.inject.ConfigProperty;
@ApplicationScoped
public class SlackHandler implements ActionHandler {
@ConfigProperty(name = "slack.webhook.url")
String webhookUrl;
@Override
public String getHandlerId() {
return "slack";
}
@Override
public ActionResult execute(Map<String, Object> payload, Map<String, Object> context) {
// Implementation using injected webhookUrl
}
}- Use descriptive handler IDs:
"slack","github-dispatch"are better than"handler1" - Load credentials securely: Use environment variables, config files, or secret managers
- Handle errors gracefully: Return
ActionResult.failure()with clear error messages - Make handlers thread-safe: They may be called concurrently
The rubric engine evaluates output quality against defined criteria with score-based routing.
| Component | Description |
|---|---|
RubricEngine |
Orchestrates evaluation using evaluator |
RubricEvaluator |
Evaluates output against criteria |
ScoreExtractingEvaluator |
Reads the score engine variable from context; accumulates recommendation feedback for failing criteria into _rubric_criterion_feedback |
Rubric |
Immutable definition with pass threshold and weighted criteria |
Criterion |
Single evaluation dimension with weight and minimum score |
Rubrics are parsed at build time and stored directly on the node as typed Rubric objects.
ScoreExtractingEvaluator reads the score engine variable directly from the execution context. The score is extracted automatically by OutputExtractionPostProcessor whenever the node has a ScoreTransition — no JSON parsing is needed in the evaluator itself.
If the score falls below a criterion's minimum and a recommendation engine variable is present in context, the text is appended to _rubric_criterion_feedback. RubricPostProcessor uses that list to assemble a combined backtrack context update for self-correcting loops.
Nodes using rubrics can route based on evaluation scores via ScoreTransition:
onScore {
whenScore greaterThanOrEqual 80.0 goto "approve"
whenScore lessThan 80.0 goto "revise"
}ApprovalTransition routes based on the boolean approved engine variable — useful when a node
acts as a classifier or reviewer and produces a binary decision.
node("review") {
agent = "reviewer"
onApproval goto "finalize"
onRejection goto "improve"
}Rules:
- Falls through (no match) if
approvedis absent or cannot be parsed as a boolean. - Accepts
true/falseas JavaBooleanor case-insensitive strings"true"/"false". approvedis an engine variable — injected automatically whenonApproval/onRejectionrouting is present. Never declare it inwrites()or the state schema.
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 for author-facing syntax.
WorkflowStateSchema is an optional typed declaration on a Workflow that lists all domain-specific
state variables — inputs expected from the caller and outputs produced by nodes.
Without a schema, workflows operate in legacy mode: node outputs are stored in context keyed by node ID. Schema mode enables:
- Load-time validation —
WorkflowBuilder.build()throwsIllegalStateExceptionlisting everywritesname or{variable}prompt reference that is not declared. - Structured output — the engine can generate a JSON schema for nodes that declare
writes, guiding the LLM to produce structured output.
The following variables are always implicitly valid — never declare them in the schema or in writes():
| Variable | Type | Injected when |
|---|---|---|
score |
NUMBER |
Node has onScore routing (rubric-evaluated or self-scoring) |
approved |
BOOLEAN |
Node has onApproval / onRejection routing |
recommendation |
STRING |
Node has onScore or onApproval / onRejection routing |
workflow("ContentPipeline") {
state {
input("topic", VarType.STRING) // required in initial context
variable("article", VarType.STRING, "the full written article text") // with LLM hint
variable("confidence", VarType.NUMBER, "reviewer confidence score 0-100") // with LLM hint
}
agents { ... }
graph {
node("write") {
writes("article")
prompt = "Write about {topic}"
onSuccess goto "review"
}
node("review") {
writes("confidence")
prompt = "Review: {article}. Output JSON with confidence (0-100) and approved (true/false)."
onApproval goto "end_ok"
onRejection goto "write"
}
end("end_ok")
}
}var schema = new WorkflowStateSchema(List.of(
new StateVariableDeclaration("topic", VarType.STRING, true),
new StateVariableDeclaration("article", VarType.STRING, false, "the full written article text"),
new StateVariableDeclaration("confidence", VarType.NUMBER, false, "reviewer confidence score 0-100")
));
var workflow = Workflow.builder()
.id("content-pipeline")
.stateSchema(schema)
// ... nodes, agents, etc.
.build();
WorkflowValidator.validate(workflow); // throws IllegalStateException on violations| Check | Error example |
|---|---|
| Transition target doesn't exist | Node 'write' has transition to 'revieww' which does not exist in the workflow |
writes name not in schema |
Node 'write' writes 'draft' which is not declared in state schema |
Prompt {var} not in schema |
Node 'write' prompt references '{tone}' which is not declared in state schema |
Validation is a no-op when no schema is declared. Legacy workflows always pass through unchanged.
SubWorkflowGraphValidator runs at graph-load time over the sub-workflow reference graph. It rejects cycles and – on the server push path – unresolved references, so neither can surface mid-execution. Two overloads serve the two entry points:
| Overload | Caller | Detects | Unknown targets |
|---|---|---|---|
validate(Collection<Workflow>) |
CLI batch loader | Cycles only | Silently skipped – loader reports missing --with declarations separately with richer context |
validate(Workflow, Function) |
Server push path | Cycles and unknown referenced ids | Aggregated into a single IllegalStateException alongside any cycles, in a single DFS pass |
The (Workflow, Function) overload shadows the incoming workflow for its own id so re-push/update sees the post-push graph without an intermediate write. The resolver is queried lazily and only for ids forward-reachable from the root – bounded by a globallyVisited set so each id costs at most one repository lookup.
Before each agent call, AgentLifecycleRunner runs EngineVariablePromptEnricher to append
format requirements to the resolved prompt. The enricher is a dumb iterator over an ordered chain
of EngineVariableInjectors — each one self-contained, deciding independently whether it applies.
Engine variable names (score, approved, recommendation) are defined in the EngineVariables
class — the single source of truth for all engine-managed variable keys.
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:
- The node has a matching transition rule (e.g.,
ScoreTransitionforScoreVariableInjector) - The execution context carries a
BranchExecutionConfigwhereneedsSelfScoring()returns true (consensus branch with a non-JUDGE_DECIDES strategy)
flowchart LR
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
style rec fill:#2c2c2e, stroke:#48484a, color:#ebebf5, stroke-width:1px
style w fill:#2c2c2e, stroke:#48484a, color:#ebebf5, stroke-width:1px
style y fill:#2c2c2e, stroke:#48484a, color:#ebebf5, stroke-width:1px
linkStyle default stroke:#0A84FF, stroke-width:1px
Each injector runs unconditionally in order. The condition listed under each name is when that injector appends its instruction – otherwise it passes the prompt through unchanged.
When a variable is declared with a description in the state schema, WritesVariableInjector includes it in the appended instruction:
Engine output requirement: your JSON response MUST include:
"article" — the full written article text
"confidence" — reviewer confidence score 0-100
Without a description, only the field name is emitted. The hint removes any reliance on the LLM inferring expected content from the variable name alone.
Construct a custom enricher with additional injectors:
EngineVariablePromptEnricher enricher = new EngineVariablePromptEnricher(
List.of(
new FeedbackContextInjector(),
new RubricPromptInjector(),
new ScoreVariableInjector(),
new ApprovalVariableInjector(),
new RecommendationVariableInjector(),
new WritesVariableInjector(),
new YieldsVariableInjector(),
new MyCustomInjector() // appended after built-ins
));Inject it via HensuFactory or override the server CDI producer.
Protocol-agnostic tool descriptors used by plan generation and MCP integration. The core defines tool shapes; actual invocation happens through ActionHandler at the application layer.
ToolRegistry registry = new DefaultToolRegistry();
// Simple tool (no parameters)
registry.register(ToolDefinition.simple("search", "Search the web"));
// Tool with parameters
registry.register(ToolDefinition.of("analyze", "Analyze data",
List.of(
ParameterDef.required("input", "string", "Data to analyze"),
ParameterDef.optional("format", "string", "Output format", "json")
)));The server layer populates the tool registry from MCP server connections. Tools discovered via MCP become ToolDefinition instances available for plan generation and execution.
MCP Server ──► ToolDefinition ──► ToolRegistry ──► Planner ──► Plan
The Plan Engine executes multi-step, tool-driven logic within a single StandardNode. It is
built around a pipeline of processors (PlanPipeline) that operate on a shared mutable
PlanContext, replacing the previous monolithic execution loop with composable,
single-responsibility stages.
| Mode | Description |
|---|---|
DISABLED |
No planning, direct agent execution (default) |
STATIC |
Predefined plan from DSL plan { } block |
DYNAMIC |
LLM generates plan at runtime via LlmPlanner |
AgenticNodeExecutor drives two sequential PlanPipeline instances per node execution — one
for plan preparation and one for plan execution:
flowchart LR
subgraph prep["Preparation Pipeline"]
direction TB
pc(["PlanCreationProcessor\n(Static/LlmPlanner)"]) --> rg(["ReviewGateProcessor\n(pause if review=true)"])
end
ctx(["PlanContext"])
subgraph exec["Execution Pipeline"]
direction TB
se(["SynthesizeEnrichment\n(inject agent ID)"]) --> pe(["PlanExecutionProcessor\n(steps + replan)"]) --> prg(["PostExecutionReviewGate\n(pause if configured)"])
end
prep --> ctx --> exec
style prep fill:#2c2c2e, stroke:#3a3a3c, color:#ebebf5, stroke-width:1px
style exec fill:#2c2c2e, stroke:#3a3a3c, color:#ebebf5, stroke-width:1px
style pc fill:#2c2c2e, stroke:#48484a, color:#ebebf5, stroke-width:1px
style rg fill:#2c2c2e, stroke:#48484a, color:#ebebf5, stroke-width:1px
style ctx fill:#2c2c2e, stroke:#0A84FF, color:#ebebf5, stroke-width:1px
style se fill:#2c2c2e, stroke:#48484a, color:#ebebf5, stroke-width:1px
style pe fill:#2c2c2e, stroke:#48484a, color:#ebebf5, stroke-width:1px
style prg fill:#2c2c2e, stroke:#48484a, color:#ebebf5, stroke-width:1px
linkStyle default stroke:#0A84FF, stroke-width:1px
Each PlanProcessor receives the same PlanContext instance and may short-circuit the pipeline
by returning a terminal result — analogous to how ProcessorPipeline works at the node level.
PlanContext is the mutable state carrier that flows through the entire pipeline. It holds:
- The resolved
StandardNode(prompt, tools,PlanningConfig) - The active
Plan(written byPlanCreationProcessor, updated on replanning) - The
ExecutionContext(workflow state, tenant context)
Processors read and write PlanContext in place rather than passing individual arguments.
PlanExecutor dispatches each PlannedStep to a registered StepHandler based on the step's
PlanStepAction type:
| Handler | Action type | What it does |
|---|---|---|
ToolCallStepHandler |
ToolCall |
Sends the tool call to ActionExecutor |
SynthesizeStepHandler |
Synthesize |
Invokes an agent to produce a synthesis |
This replaces the previous if/else dispatch inside the executor with polymorphic lookup.
Custom handlers can be added to StepHandlerRegistry to support new action types without
modifying core execution logic.
Plan execution emits events for monitoring:
| Event | Description |
|---|---|
PlanCreated |
Plan created and ready to execute |
StepStarted |
Individual step execution starting |
StepCompleted |
Step finished (success or failure) |
PlanCompleted |
All steps finished |
Register observers via PlanExecutor.addObserver(PlanObserver).
Dynamic plans generated by LlmPlanner are non-deterministic — regenerating a plan after resume would produce different transitions and break execution. To solve this, HensuState carries an activePlan field that is persisted in snapshots alongside all other state.
PlanCreationProcessor handles this in two paths:
- Fresh execution —
state.getActivePlan()is null. The processor creates a plan via the planner, writes it to bothstate.setActivePlan(plan)andcontext.setPlan(plan), and continues. - Resumed execution —
state.getActivePlan()is non-null. If the plan'snodeIdmatches the current node, the processor reuses the persisted plan without calling the planner. AnodeIdmismatch throwsIllegalStateException— it indicates a bug in plan lifecycle management.
TransitionPostProcessor clears state.setActivePlan(null) on every transition, preventing stale plans from leaking across nodes.
The TemplateResolver substitutes {variable} placeholders in prompts from workflow context:
{topic}— resolved from initial context or previous node output{nodeId}— resolved from the output of node with that ID (legacy mode — no schema declared){varName}— resolved from named state variables written viawrites(schema mode)
Configure human review checkpoints via ReviewHandler:
var env = HensuFactory.builder()
.reviewHandler(new CLIReviewManager())
.build();Review modes per node:
DISABLED— no human review (default)OPTIONAL— review only on failureREQUIRED— always require human review
The stub agent system lets you run full workflow executions without consuming API tokens. It intercepts all agent calls and returns configurable mock responses, making it the foundation for both unit and integration testing.
Three classes collaborate to provide the stub infrastructure:
StubAgentProvider (priority 1000, intercepts all models when enabled)
└── creates StubAgent per agent ID
└── resolves response via StubResponseRegistry (singleton)
| Class | Role |
|---|---|
StubAgentProvider |
Intercepts all model requests when enabled (priority 1000) |
StubAgent |
Executes by resolving responses from the registry |
StubResponseRegistry |
Singleton that stores and resolves stub responses |
Stub mode can be enabled through any of these (checked in order):
// 1. Credentials map (per-execution override)
credentials.put("HENSU_STUB_ENABLED", "true");
// 2. System property
// -Dhensu.stub.enabled=true
// 3. Environment variable
// export HENSU_STUB_ENABLED=true
// 4. Application property (Quarkus)
// hensu.stub.enabled=trueWhen enabled, StubAgentProvider returns priority 1000, outranking all real providers. When disabled, it returns priority -1 and is never selected.
When StubAgent.execute() is called, it reads the current node ID from context.get("current_node") (set by StandardNodeExecutor before each agent call) and delegates to StubResponseRegistry.getResponse(nodeId, agentId, context, prompt).
The registry searches in this order:
- Programmatic response for the active scenario, matched by node ID
- Programmatic response for the active scenario, matched by agent ID
- Classpath resource at
/stubs/{scenario}/{nodeId}.txt - Classpath resource at
/stubs/{scenario}/{agentId}.txt - Filesystem stub at
{stubsDir}/{scenario}/{nodeId}.txt - Filesystem stub at
{stubsDir}/{scenario}/{agentId}.txt - Default scenario classpath at
/stubs/default/{nodeId}.txt(if scenario is not"default") - Default scenario classpath at
/stubs/default/{agentId}.txt - Default scenario filesystem at
{stubsDir}/default/{nodeId}.txt - Default scenario filesystem at
{stubsDir}/default/{agentId}.txt - Auto-generated fallback — parses the prompt for JSON structure hints or returns a labelled stub response
The first match wins. This means you can register stubs by node ID (most common in tests) or by agent ID (useful when multiple nodes share the same agent).
StandardNodeExecutor sets current_node in the execution context before each agent.execute() call:
state.getContext().put("current_node", node.getId());This allows StubAgent to resolve stubs by the workflow node ID rather than the agent ID. Since a workflow JSON typically assigns different node IDs and agent IDs (e.g., node "process" with agent "writer"), node-ID-based registration is the natural choice for tests.
Register responses directly via StubResponseRegistry:
StubResponseRegistry registry = StubResponseRegistry.getInstance();
// Register by node ID (most common — matches resolution priority 1)
registry.registerResponse("draft", "Article about AI covering key concepts.");
// Register by agent ID (matches resolution priority 2)
registry.registerResponse("writer", "Fallback content for any node using 'writer' agent");
// Register for a specific scenario
registry.registerResponse("low_score", "evaluate",
"{\"score\": 0.3, \"content\": \"Poor quality output\"}");
// Clear all responses between tests
registry.clearResponses();Place text files on the classpath for reusable, declarative stubs:
src/test/resources/
stubs/
default/ # Default scenario
writer.txt # Matched by agent ID "writer"
reviewer.txt # Matched by agent ID "reviewer"
high_score/ # Named scenario
evaluate.txt # Matched by node/agent ID "evaluate"
low_score/
evaluate.txt
Each .txt file contains the raw response text the stub agent returns.
For CLI usage where classpath resources are sealed inside the JAR, place stub files in the working directory's stubs/ folder. The CLI automatically detects this directory and configures the registry:
working-dir/
workflows/
my-workflow.kt
stubs/
default/
writer.txt
reviewer.txt
low_score/
evaluate.txt
hensu run -d working-dir georgia-discovery.ktFilesystem stubs follow the same {scenario}/{key}.txt convention as classpath resources. Classpath resources take priority over filesystem stubs, so integration tests with classpath stubs are unaffected.
You can also set the stubs directory programmatically:
StubResponseRegistry.getInstance().setStubsDirectory(Path.of("./working-dir/stubs"));Scenarios partition stub responses for different test paths (e.g., high-score vs low-score flows). The active scenario is determined by:
- Context variable:
context.put("stub_scenario", "low_score") - System property:
-Dhensu.stub.scenario=backtrack - Default:
"default"
Response templates support {{key}} substitution from the execution context:
# stubs/default/writer.txt
A detailed article about {{topic}} covering key aspects of the subject.
When the execution context contains "topic" -> "quantum computing", the placeholder is replaced at resolution time.
For lightweight unit tests that don't need the full stub system, create an inline mock provider:
@Test
void testWorkflowLogic() {
AgentProvider mockProvider = new AgentProvider() {
public String getName() { return "mock"; }
public boolean supportsModel(String model) { return true; }
public Agent createAgent(String id, AgentConfig config, Map<String, String> creds) {
return new MockAgent(id, config);
}
public int getPriority() { return 100; }
};
var env = HensuFactory.builder()
.agentProviders(List.of(mockProvider))
.build();
// Test workflow logic without real API calls
}./gradlew hensu-core:test # Core unit tests
./gradlew hensu-server:test # Server + integration tests
./gradlew hensu-core:test --tests "RubricEngineTest" # Single test class
./gradlew test # All modulesSee also: Server Developer Guide — Integration Testing for the full
@QuarkusTestintegration test framework built on this stub system.
hensu-server is deployed as a GraalVM native image. All code in hensu-core (and adapters) must be native-image safe. GraalVM performs static analysis at build time — anything not visible to the compiler at build time will not work at runtime.
No reflection. GraalVM cannot discover classes, methods, or fields at runtime unless they are registered in advance. This drives several design decisions:
// WRONG — reflection-based lookup fails in native image
Class<?> clazz = Class.forName("com.example.MyProvider");
Object instance = clazz.getDeclaredConstructor().newInstance();
// CORRECT — explicit construction
AgentProvider provider = new MyProvider();No classpath scanning. Runtime scanning for classes (e.g., ServiceLoader, annotation scanning) doesn't work without build-time metadata. This is why providers are wired explicitly:
// WRONG — ServiceLoader discovers via META-INF/services at runtime
ServiceLoader<AgentProvider> providers = ServiceLoader.load(AgentProvider.class);
// CORRECT — explicit provider list
HensuFactory.builder()
.agentProviders(List.of(new LangChain4jProvider(), new MyProvider()))
.build();Note: Quarkus does support
ServiceLoaderby processingMETA-INF/servicesat build time. Buthensu-coreavoids it entirely to remain framework-agnostic.
No dynamic proxies without registration. JDK dynamic proxies (Proxy.newProxyInstance(...)) and CGLIB require upfront registration. Prefer concrete classes or manual delegation over dynamic proxies.
No runtime bytecode generation. Libraries that generate classes at runtime (e.g., some serialization frameworks) fail silently. Use explicit, static implementations.
hensu-core must contain zero Jackson annotations. No @JsonDeserialize, @JsonProperty, @JsonTypeInfo, or any Jackson import belongs here. All serialization metadata lives in hensu-serialization; all native-image reflection registrations live in hensu-server. This keeps the core framework-agnostic.
The hensu-serialization module uses explicit SimpleModule registrations instead of Jackson's reflective annotation processing:
// In HensuJacksonModule — explicit serializer/deserializer registration
SimpleModule module = new SimpleModule("HensuModule");
module.addSerializer(Node.class, new NodeSerializer());
module.addDeserializer(Node.class, new NodeDeserializer());
module.addSerializer(TransitionRule.class, new TransitionRuleSerializer());
// ... each type explicitly registeredWhen adding new serializable types:
- Create explicit
JsonSerializer<T>andJsonDeserializer<T>classes - Register them in
HensuJacksonModule - Do not rely on
@JsonTypeInfowith class names — GraalVM cannot resolve them at runtime - Use a
"type"discriminator field with an explicitswitchin the deserializer
See also: hensu-serialization Developer Guide for the full Jackson contract — mixin/builder pattern,
treeToValuerules, and how to add new types safely.
When implementing AgentProvider for a new AI backend:
-
Avoid reflection in model builders. If the upstream SDK uses
.builder()patterns with reflection internally, the SDK's Quarkus extension (if available) registers the needed metadata. If no extension exists, you must providereflect-config.json. -
Use
SimpleModulefor custom Jackson types. Any new model or DTO must be explicitly serializable without@JsonAutoDetector field-level reflection. -
Test with native image. Run
./gradlew hensu-server:build -Dquarkus.native.enabled=true -Dquarkus.package.type=nativeto verify. Failures manifest asClassNotFoundExceptionorNoSuchMethodExceptionat runtime.
| Pattern | Safe | Unsafe |
|---|---|---|
new MyClass() |
Yes | — |
Class.forName(...) |
— | Yes |
field.setAccessible(true) |
— | Yes (unless registered) |
ServiceLoader.load(...) |
Quarkus only | Yes (standalone) |
Jackson SimpleModule |
Yes | — |
Jackson @JsonTypeInfo(use = CLASS) |
— | Yes |
Proxy.newProxyInstance(...) |
— | Yes (unless registered) |
Sealed interface switch |
Yes | — |
| Builder pattern | Yes | — |
Credentials are loaded via HensuFactory and passed to providers as a Map<String, String>.
From environment variables:
HensuFactory.builder()
.loadCredentialsFromEnvironment()
.build();From properties (hensu.credentials.* prefix, stripped automatically):
// application.properties:
// hensu.credentials.ANTHROPIC_API_KEY=sk-ant-...
// hensu.credentials.OPENAI_API_KEY=sk-...
// hensu.stub.enabled=true
HensuFactory.builder()
.loadCredentials(properties) // Loads from env + properties (properties win)
.build();Individual keys:
HensuFactory.builder()
.anthropicApiKey("sk-ant-...")
.openAiApiKey("sk-...")
.build();| Variable | Provider |
|---|---|
ANTHROPIC_API_KEY |
Anthropic Claude |
OPENAI_API_KEY |
OpenAI GPT |
GOOGLE_API_KEY |
Google Gemini |
DEEPSEEK_API_KEY |
DeepSeek |
Environment variables matching *_API_KEY, *_KEY, *_SECRET, or *_TOKEN patterns are auto-discovered.
| File | Description |
|---|---|
HensuFactory.java |
Bootstrap and environment creation |
HensuEnvironment.java |
Container for all core components |
HensuConfig.java |
Configuration (storage backend) |
agent/AgentFactory.java |
Creates agents from explicit providers |
agent/AgentProvider.java |
Provider interface for pluggable AI backends |
agent/AgentRegistry.java |
Agent lookup interface |
agent/DefaultAgentRegistry.java |
Thread-safe agent registry |
agent/stub/StubAgentProvider.java |
Testing provider (priority 1000 when enabled) |
execution/WorkflowExecutor.java |
Main execution engine |
execution/NodeLifecycleCoordinator.java |
Per-node lifecycle: phase dispatch, pipeline orchestration |
execution/executor/GenericNodeHandler.java |
Generic node handler interface |
execution/action/ActionHandler.java |
Action handler interface |
execution/action/ActionExecutor.java |
Action dispatch interface |
execution/result/ExecutionResult.java |
Workflow execution outcome (Completed, Paused, Rejected, Failure) |
workflow/Workflow.java |
Core data model |
workflow/WorkflowRepository.java |
Workflow definition persistence interface |
workflow/InMemoryWorkflowRepository.java |
In-memory workflow repository (default) |
state/HensuState.java |
Mutable workflow execution state; branch(node) creates isolated copies for concurrent branches |
state/HensuSnapshot.java |
Immutable state snapshot for persistence |
state/ExecutionPhase.java |
Sealed: Initial, Awaiting, Terminal — tracks position within a node's lifecycle |
state/WorkflowStateRepository.java |
Execution state persistence interface |
state/InMemoryWorkflowStateRepository.java |
In-memory state repository (default) |
workflow/state/WorkflowStateSchema.java |
Typed state variable schema (optional per-workflow declaration) |
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 |
rubric/model/Rubric.java |
Rubric definition model |
tool/ToolDefinition.java |
Protocol-agnostic tool descriptor |
tool/ToolRegistry.java |
Tool registration/lookup interface |
plan/PlanExecutor.java |
Iterates plan steps via StepHandlerRegistry |
plan/Plan.java |
Plan model (steps + constraints) |
plan/PlanPipeline.java |
Executes an ordered chain of PlanProcessors |
plan/PlanProcessor.java |
Single-phase processor interface for the plan lifecycle |
plan/PlanContext.java |
Mutable state carrier flowing through the plan pipeline |
plan/StepHandlerRegistry.java |
Registry for StepHandler lookup by PlanStepAction type |
plan/StepHandler.java |
Handler interface for a single PlanStepAction type |
plan/PlannedStep.java |
Immutable representation of a single plan step |
plan/PlanStepAction.java |
Sealed type hierarchy: ToolCall and Synthesize |
plan/ToolCallStepHandler.java |
Dispatches ToolCall actions to ActionExecutor |
plan/SynthesizeStepHandler.java |
Invokes an agent for Synthesize actions |
plan/Planner.java |
Planner interface (createPlan / revisePlan) |
plan/StaticPlanner.java |
Resolves predefined plan { } steps from PlanningConfig |
plan/LlmPlanner.java |
LLM-based plan generation and revision (DYNAMIC mode) |
execution/EngineVariables.java |
SSOT for engine variable names (score, approved, recommendation) |
execution/SynchronizedListenerDecorator.java |
Thread-safe listener wrapper for parallel branch execution |
execution/executor/AgentLifecycleRunner.java |
Composition-based agent call: prompt enrichment → agent execution → output extraction |
execution/executor/AgenticNodeExecutor.java |
Drives preparation + execution PlanPipelines for StandardNode |
execution/parallel/BranchExecutionConfig.java |
Typed branch metadata on ExecutionContext (consensus strategy, yields list) |
template/SimpleTemplateResolver.java |
{variable} substitution |
review/ReviewHandler.java |
Human review interface |
review/ReviewOutcome.java |
Sealed: Decided(ReviewDecision), Pending(correlationId) — sync vs async review |
resume/ResumeInput.java |
Sealed: ApplyReview, ApplyContextEdits, None — caller-supplied resume input |
execution/pipeline/ProcessorPipeline.java |
Orchestrates pre/post processor chains |
execution/pipeline/ProcessorContext.java |
Per-iteration context carrier (node + result + execution context) |
execution/pipeline/PreNodeExecutionProcessor.java |
Pre-execution processor interface |
execution/pipeline/PostNodeExecutionProcessor.java |
Post-execution processor interface |
execution/pipeline/NodeExecutionProcessor.java |
Base processor interface |
execution/pipeline/OutputExtractionPostProcessor.java |
Extracts node output into state context |
execution/pipeline/HistoryPostProcessor.java |
Records execution steps for audit/backtracking |
execution/pipeline/ReviewPostProcessor.java |
Human-in-the-loop review checkpoints |
execution/pipeline/RubricPostProcessor.java |
Quality evaluation and auto-backtrack |
execution/pipeline/ProcessorOutcome.java |
Sealed: Continue, Terminal, SuspendForExternal — pipeline flow control |
execution/pipeline/TransitionPostProcessor.java |
Evaluates transition rules, sets next node |