Deterministic Tasks
Not every step in an ensemble needs AI reasoning. Sometimes you need to call a REST API, transform data, or run a tool pipeline without any LLM involvement. Deterministic tasks let you execute any Java function directly as a task step.
When to Use
Section titled “When to Use”Use a deterministic task when the output is fully predictable and does not require language model reasoning:
- Fetching data from a REST API or database
- Parsing and normalizing a response before passing it to an AI task
- Computing a formula or aggregating numbers
- Running a
ToolPipelinewithout LLM round-trips between steps - Formatting or rendering AI-produced output into a final form
Basic Example
Section titled “Basic Example”Task fetchPrices = Task.builder() .description("Fetch current stock prices") .expectedOutput("JSON with stock prices") .handler(ctx -> ToolResult.success(httpClient.get("https://api.example.com/prices"))) .build();
// No ChatModel needed for a handler-only ensembleEnsembleOutput output = Ensemble.builder() .task(fetchPrices) .workflow(Workflow.SEQUENTIAL) .build() .run();
System.out.println(output.getRaw()); // {"AAPL": 175.0, "MSFT": 320.0}The handler receives a TaskHandlerContext and must return a ToolResult:
ToolResult.success(String output)— normal completionToolResult.failure(String error)— signals task failure (throwsTaskExecutionException)
Accessing Prior Task Outputs
Section titled “Accessing Prior Task Outputs”The TaskHandlerContext provides contextOutputs() — the outputs of all tasks declared
in Task.context():
Task analyze = Task.builder() .description("Analyze the stock prices") .expectedOutput("Investment summary") .chatLanguageModel(model) .build();
// Deterministic task that transforms the AI outputTask formatReport = Task.builder() .description("Format the analysis as an HTML report") .expectedOutput("HTML report") .context(List.of(analyze)) .handler(ctx -> { String aiOutput = ctx.contextOutputs().get(0).getRaw(); String html = "<html><body>" + aiOutput + "</body></html>"; return ToolResult.success(html); }) .build();Wrapping an Existing Tool
Section titled “Wrapping an Existing Tool”Pass any AgentTool directly to the handler() builder method:
// Input = task description (no context) or last context output (with context)Task fetch = Task.builder() .description("https://api.example.com/prices") .expectedOutput("HTTP response") .handler(httpTool) .build();ToolPipeline as Handler
Section titled “ToolPipeline as Handler”ToolPipeline implements AgentTool, so it works with the same overload:
ToolPipeline extractAndCalculate = ToolPipeline.builder() .name("extract_and_calculate") .description("Extract price and apply discount") .step(new JsonParserTool()) .adapter(result -> result.getOutput() + " * 0.90") .step(new CalculatorTool()) .build();
Task computeDiscounted = Task.builder() .description(jsonPayload + "\nbase_price") .expectedOutput("Discounted price") .handler(extractAndCalculate) // executed directly, no LLM .build();This is more efficient than running the same pipeline in the LLM tool-calling loop because no LLM call is made at all.
Mixed Ensemble: Deterministic + AI
Section titled “Mixed Ensemble: Deterministic + AI”// Step 1: Deterministic -- fetch and normalize data (no LLM)Task fetchData = Task.builder() .description("Fetch product data from the catalog API") .expectedOutput("Normalized product data") .handler(ctx -> { String data = catalogApi.getProduct("WIDGET-001"); return ToolResult.success(normalize(data)); }) .build();
// Step 2: AI -- analyze the normalized dataTask analyze = Task.builder() .description("Write a 2-sentence marketing summary for the product") .expectedOutput("Marketing summary") .chatLanguageModel(model) .context(List.of(fetchData)) .build();
// Step 3: Deterministic -- format the AI output as HTML (no LLM)Task render = Task.builder() .description("Render the marketing summary as HTML") .expectedOutput("HTML snippet") .context(List.of(analyze)) .handler(ctx -> ToolResult.success( "<p>" + ctx.contextOutputs().get(0).getRaw() + "</p>")) .build();
EnsembleOutput result = Ensemble.builder() .chatLanguageModel(model) .task(fetchData) .task(analyze) .task(render) .workflow(Workflow.SEQUENTIAL) .build() .run();Structured Output
Section titled “Structured Output”If the task has outputType set, the handler can provide a pre-typed Java object via
ToolResult.success(text, typedValue) to skip JSON deserialization:
record PriceReport(String symbol, double price) {}
Task fetch = Task.builder() .description("Fetch AAPL price") .expectedOutput("Price report") .outputType(PriceReport.class) .handler(ctx -> { PriceReport report = priceApi.getPrice("AAPL"); return ToolResult.success(report.toString(), report); }) .build();
EnsembleOutput output = ...;PriceReport report = output.getOutput(fetch).getParsedOutput(PriceReport.class);Guardrails and Review Gates
Section titled “Guardrails and Review Gates”Deterministic tasks support the same lifecycle features as AI-backed tasks:
Task fetchData = Task.builder() .description("Fetch customer data") .expectedOutput("Customer JSON") .inputGuardrails(List.of(input -> { // reject if description contains sensitive terms return GuardrailResult.success(); })) .outputGuardrails(List.of(out -> { // reject if output contains PII return GuardrailResult.success(); })) .handler(ctx -> ToolResult.success(customerApi.getData())) .build();TaskOutput Metadata
Section titled “TaskOutput Metadata”Deterministic tasks appear in EnsembleOutput with:
agentRole="(deterministic)"(not an AI agent)toolCallCount=0metrics=TaskMetrics.EMPTY(no token usage)
for (TaskOutput taskOutput : output.getTaskOutputs()) { System.out.printf("[%s] %s: %s%n", taskOutput.getAgentRole(), // "(deterministic)" or agent role taskOutput.getTaskDescription(), taskOutput.getRaw());}Constraints
Section titled “Constraints”-
Hierarchical workflow: Not supported. Use
SEQUENTIALorPARALLELwhen mixing deterministic and AI-backed tasks. -
Mutually exclusive with LLM fields:
agent,chatLanguageModel,streamingChatLanguageModel,tools,maxIterations, andrateLimitcannot be set alongsidehandler(rejected at build time withValidationException).
Runnable Example
Section titled “Runnable Example”./gradlew :agentensemble-examples:runDeterministicTaskSource: DeterministicTaskExample.java
Deterministic-Only Pipeline (no AI at all)
Section titled “Deterministic-Only Pipeline (no AI at all)”When every task in the ensemble has a handler, no ChatModel is needed at any level.
Use the Ensemble.run(Task...) zero-ceremony factory for the most concise form:
Task fetchTask = Task.builder() .description("Fetch product data from API") .expectedOutput("JSON product data") .handler(ctx -> ToolResult.success(apiClient.fetchProducts())) .build();
Task parseTask = Task.builder() .description("Parse JSON into structured records") .expectedOutput("Parsed product list") .context(List.of(fetchTask)) .handler(ctx -> { String json = ctx.contextOutputs().get(0).getRaw(); return ToolResult.success(jsonParser.parse(json)); }) .build();
Task storeTask = Task.builder() .description("Write records to data warehouse") .expectedOutput("Row count written") .context(List.of(parseTask)) .handler(ctx -> { String data = ctx.contextOutputs().get(0).getRaw(); int rows = warehouse.insert(data); return ToolResult.success(rows + " rows inserted"); }) .build();
// No ChatModel required -- all tasks are deterministicEnsembleOutput output = Ensemble.run(fetchTask, parseTask, storeTask);System.out.println(output.getRaw()); // "1234 rows inserted"Parallel fan-out (three independent service calls, then merge) is inferred automatically
from context() dependencies — no explicit workflow(Workflow.PARALLEL) needed:
Task serviceA = Task.builder().description("Fetch from A").handler(ctx -> ToolResult.success(a.fetch())).build();Task serviceB = Task.builder().description("Fetch from B").handler(ctx -> ToolResult.success(b.fetch())).build();Task merge = Task.builder() .description("Merge A and B") .context(List.of(serviceA, serviceB)) .handler(ctx -> { String a = ctx.contextOutputs().get(0).getRaw(); String b = ctx.contextOutputs().get(1).getRaw(); return ToolResult.success(merge(a, b)); }) .build();
// serviceA and serviceB run concurrently; merge waits for bothEnsembleOutput output = Ensemble.builder().task(serviceA).task(serviceB).task(merge).build().run();See the Deterministic Orchestration guide for the full reference including phases, callbacks, guardrails, and failure handling.
Runnable Example (no-API-key required)
Section titled “Runnable Example (no-API-key required)”./gradlew :agentensemble-examples:runDeterministicOnlyPipeline