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¶
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¶
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 ensemble
EnsembleOutput 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¶
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 output
Task 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¶
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¶
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¶
// 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 data
Task 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¶
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¶
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¶
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¶
-
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¶
Source: DeterministicTaskExample.java
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 deterministic
EnsembleOutput 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 both
EnsembleOutput 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.