Skip to content
tnsaiDocs
GitHub

Planning

A planner picks the actions that move an agent from its current state to a goal. TnsAI has three planners, in tnsai-intelligence:

PlannerPackagePicksPlans from
BackwardChainingPlannercom.tnsai.planningAn ordered list of actions per unsatisfied goalGoals, state and action conditions on a role
UtilityAIPlannercom.tnsai.planningThe single best action right nowAction scores
LLMDynamicPlannercom.tnsai.planning.llmA list of steps for a goal written in plain textOne LLM call

The first two read a role's annotations. The third asks a model and does not look at the role.

Goals, state and actions on a role

A role declares goals in @RoleSpec(goals = ...), its state in @State fields, and what each action needs and does in @ActionSpec:

package com.example.tnsai.docs;

import com.tnsai.annotations.ActionSpec;
import com.tnsai.annotations.Goal;
import com.tnsai.annotations.RoleSpec;
import com.tnsai.annotations.State;
import com.tnsai.enums.ActionType;
import com.tnsai.enums.agent.Priority;
import com.tnsai.models.role.RoleIdentity;
import com.tnsai.roles.Role;

@RoleSpec(
    name = "Soldier",
    goals = {
        @Goal(name = "armed", condition = "ammo > 10", priority = Priority.HIGH)
    }
)
public final class PlanningRoleExample extends Role {

    @State
    private int ammo = 0;

    @State
    private int magazines = 0;

    @Override
    public RoleIdentity getIdentity() {
        return new RoleIdentity("Soldier", "Stay armed", "field");
    }

    @ActionSpec(
        type = ActionType.LOCAL,
        description = "Pick up a magazine",
        postcondition = "magazines = magazines + 1"
    )
    public void fetchMagazine() {
        magazines++;
    }

    @ActionSpec(
        type = ActionType.LOCAL,
        description = "Load a magazine",
        precondition = "magazines > 0",
        postcondition = "ammo = 30, magazines = magazines - 1",
        fulfills = {"armed"}
    )
    public void reload() {
        ammo = 30;
        magazines--;
    }
}

With ammo = 0 and magazines = 0, the goal armed is unsatisfied. reload fulfills it but needs a magazine, and fetchMagazine provides one, so the plan is [fetchMagazine, reload].

AnnotationWhat the planners read
@Goalname, condition and priority (default NORMAL). description, persistent and deadline are stored on PlanningGoal but do not affect planning.
@StateThe field value, under @State(name = ...) or the field name. Superclass fields are included; a subclass field with the same name wins. Enum values are stored as their constant names.
@ActionSpecprecondition, postcondition and fulfills. The action name is the method name. For BackwardChainingPlanner, a method with none of the three is not a planning action.

The postcondition is a promise: the planner applies it to a simulated state while it plans, and the method body has to make it true. Planning actions must be public.

Conditions

Goal conditions and preconditions use a small expression language (ConditionEvaluator):

  • comparisons ==, !=, >, >=, <, <=;
  • &&, || and !;
  • state variable names, numbers, quoted strings, true and false.

A variable that is not in the state is unknown, and a condition that uses it is not satisfied. This holds under ! as well: !ready is not satisfied when ready is missing.

Effects

A postcondition is a comma-separated list of variable = expression:

  • + and - do arithmetic and store a double;
  • a quoted string is stored as text;
  • null, true, false, integers and decimals are parsed;
  • any other word is stored as a string, which matches enum state by constant name.

An arithmetic operand that is neither a number nor a numeric variable throws IllegalArgumentException.

On an agent

Agent exposes planning through a PlannerHandle:

MethodMeaning
plan()Plan every unsatisfied goal from the current state
planForGoal(goalName)The actions that can serve one goal (see below)
executePlan(steps)Run the steps and return a PlanResult
extractCurrentState()The @State values of all roles, merged
getPlannerHandle()Optional<PlannerHandle>
isPlanningEnabled()Whether a handle is set

Set a handle with AgentBuilder.plannerHandle(...). Without one, an agent with at least one role creates a handle itself when tnsai-intelligence is on the classpath. It plans with a BackwardChainingPlanner over the first role only. Set TNSAI_FLAG_GOAP=off to turn this off. If creating the handle fails, the agent continues without one and logs nothing.

Without a handle, plan, planForGoal and executePlan throw IllegalStateException.

The agent never plans by itself. Call plan() and executePlan(...) from your own code. Group planning in tnsai-coordination uses the same handle.

Behaviour of the default handle:

  • planForGoal returns every action that can serve the goal, without helper steps. It is a candidate list, not a chained plan. An unknown goal name throws IllegalArgumentException.
  • executePlan runs the steps on the first role. A step whose action is missing or whose precondition does not hold is recorded as failed, and the next step runs. State is read again after every step. PlanResult.success() is true only when every step succeeded.
  • Steps call the method directly, not through ActionExecutor. A plan that contains an action with an execution policy or @Security is refused before any step runs.

The handle records are PlanStep(actionName, precondition, postcondition, fulfills), PlanResult(success, results, finalState) and StepResult(actionName, executed, error).

BackwardChainingPlanner

Use the planner through PlanExecutor when you work with one role object:

import com.tnsai.planning.PlanExecutor;

PlanningRoleExample soldier = new PlanningRoleExample();
PlanExecutor executor = new PlanExecutor(soldier);

executor.getPlan();           // [fetchMagazine, reload]
executor.think();             // runs both, returns [fetchMagazine, reload]
executor.getSatisfiedGoals(); // [armed]

How a plan is built

  1. Unsatisfied goals are sorted by priority, highest first.
  2. For each goal, the candidates are the actions that list it in fulfills, and the actions whose postcondition text contains the first variable name of the goal's condition. This is a plain substring match: a goal on ammo also matches a postcondition that sets ammoBox.
  3. The first candidate whose precondition holds is used. If none holds, the planner looks for helper actions whose postcondition assigns a variable that the precondition reads, and chains backwards from there.
  4. The goal's actions are applied to the simulated state, and the next goal is planned from it.

Candidates are tried in the order Java reflection returns the methods, which is not defined. When several actions can serve one goal, the plan can change between runs. Give each goal one action that fulfills it, and express alternatives through preconditions.

plan(state, false) turns chaining off: a goal is planned only if a candidate can run now.

Limits and failures

ConstructorMeaning
new BackwardChainingPlanner(roleClass)Read goals and actions from the role class, chaining depth 10
new BackwardChainingPlanner(roleClass, maxDepth)Chaining depth, clamped to 256
new BackwardChainingPlanner(goals, actions), (goals, actions, maxDepth)Plan over PlanningGoal and PlanningAction values you build

A search also stops after 10,000 expansions. When planning fails, the planner throws PlanningFailureException. reason() returns one of:

ReasonCause
UNREACHABLENo action chain reaches the goal
CYCLIC_PRECONDITIONThe search came back to the same precondition with the same simulated state
MAX_DEPTH_EXCEEDEDThe chain is longer than maxDepth
SEARCH_BUDGET_EXCEEDEDThe search hit the expansion limit
PRECONDITION_STALEDuring think(), a step's precondition no longer held
GOAL_UNSATISFIED_AFTER_EXECUTIONthink() ran every step, but a goal is still unsatisfied

goalName(), actionName(), precondition() and maxDepth() carry the details.

PlanExecutor

new PlanExecutor(role) uses a BackwardChainingPlanner. new PlanExecutor(role, planner) takes any Planner.

think() reads the state, plans, and runs the steps in order:

  • Before each step that has a precondition, it reads the state again. If the precondition no longer holds, it stops: without error when every goal is already satisfied, otherwise with PRECONDITION_STALE.
  • After the last step, a goal that the plan promised and that is still unsatisfied throws GOAL_UNSATISFIED_AFTER_EXECUTION.
  • Like the agent's handle, it refuses a plan that contains an action with an execution policy or @Security, with a SecurityException, before any step runs.

It returns the names of the actions it ran. executeAction, extractBeliefs, getUnsatisfiedGoals, getSatisfiedGoals, isGoalSatisfied, getPlan and getPlanner are also public.

Goals and actions without annotations

PlanningGoal.of(name, condition) and PlanningGoal.of(name, condition, priority) build goals. PlanningAction.of(name, precondition, postcondition, fulfills...) and PlanningAction.builder() build actions. An action built this way has no method, so isExecutable() is false: the planner can plan with it, and PlanExecutor cannot run it.

UtilityAIPlanner

UtilityAIPlanner scores every action whose precondition holds and returns the best one. It ignores goals when it picks.

import com.tnsai.annotations.ActionSpec;
import com.tnsai.annotations.Utility;

@ActionSpec(
    type = ActionType.LOCAL,
    description = "Attack the nearest enemy",
    utility = @Utility(cost = 10, value = 50, weight = 1.5f, tags = {"combat"})
)
public void attack() { }
import com.tnsai.planning.UtilityAIPlanner;

UtilityAIPlanner planner = new UtilityAIPlanner(SoldierRole.class);
planner.selectBestAction(state);    // Optional<PlanningAction>
planner.getActionsByUtility(state); // applicable actions, best first

The class constructor reads every @ActionSpec method, with cost, value, weight and tags from @Utility (defaults 1, 1, 1.0 and none). @Utility.considerations is not read.

An action's score is the weighted average of its considerations, multiplied by the action's weight:

score = (Σ consideration score × consideration weight) / (Σ consideration weight) × action weight

The default considerations are cost(0.3f), value(0.5f) and preconditionSatisfied(). Pass your own with new UtilityAIPlanner(roleClass, considerations) or the builder.

ConsiderationScore
cost()max(0, 1 − cost / 100)
value()min(1, value / 100)
utility()(value − cost + 100) / 200
preconditionSatisfied()1 when the precondition holds or there is none, else 0
hasTag(tag)1 when the action has the tag, else 0
combine(...)Weighted average of the given considerations

cost, value and utility take an optional weight, such as cost(0.3f). A Consideration is a functional interface, evaluate(action, state), so a lambda works too.

plan(state) returns a list with at most the best action. findActionsForGoal(goal, state) returns the actions that serve a goal, best first.

LLM planning

LLMDynamicPlanner asks a model for the steps toward a goal written in plain text. PlanApprovalGate lets a person or a rule review the plan. AdaptiveReplanEngine runs the steps and asks for a new plan when one fails.

LLMDynamicPlanner

import com.tnsai.planning.llm.CapabilityDescriptor;
import com.tnsai.planning.llm.LLMDynamicPlanner;
import com.tnsai.planning.llm.LLMPlan;

LLMDynamicPlanner planner = LLMDynamicPlanner.builder()
    .llm(llmClient)
    .capability(CapabilityDescriptor.of("search_web", "Search the web for a query"))
    .capability(CapabilityDescriptor.of("summarize", "Summarize a text"))
    .temperature(0.2f)
    .build();

LLMPlan plan = planner.generatePlan("Write a brief on solid-state batteries");
System.out.println(plan.toDisplayString());
BuilderDefaultMeaning
llm(LLMClient)RequiredThe model to ask
capability(CapabilityDescriptor)NoneAdd an action the model may use
capabilities(List)NoneReplace the list
additionalContext(String)""Extra text for the prompt
temperature(float)0.20 to 2, else build() throws

generatePlan(goal) and generatePlan(goal, state) make one chat call. The planner reads JSON from a fenced block or the raw reply, then a numbered list. A reply it cannot read gives an empty plan and a log warning, not an exception. A step without an action name gets unknown.

Step action names are not checked against the capabilities. Without capabilities, the model is told to write generic steps.

LLMDynamicPlanner also implements PlannerHandle, but only planForGoal does anything: it generates a plan and maps each step to a PlanStep. plan returns no steps, execute fails every step, and it reports no goals. Do not pass it to AgentBuilder.plannerHandle(...); run its plans with AdaptiveReplanEngine.

LLMPlan(goal, steps, reasoning) has size(), isEmpty(), withReorderedSteps(order), withoutStep(index), remainingFrom(index) and toDisplayString(). Each with…, without… and remaining… method returns a new plan with steps numbered again from 0. LLMPlanStep(stepIndex, actionName, description, arguments, reasoning) is one step.

PlanApprovalGate

import com.tnsai.planning.llm.PlanApprovalGate;
import com.tnsai.planning.llm.PlanApprovalGate.ApprovalDecision;

PlanApprovalGate gate = PlanApprovalGate.builder()
    .reviewCallback(p -> p.size() > 10
        ? ApprovalDecision.reject("Too many steps")
        : ApprovalDecision.approve())
    .build();

Optional<LLMPlan> approved = gate.generateAndReview(planner, "Write a brief on solid-state batteries");

review(plan) returns the plan when it is approved, nothing when it is rejected, and the changed plan when it is modified. PlanApprovalGate.autoApprove() approves everything. An empty plan is approved without calling the callback unless you set autoApproveEmpty(false).

ApprovalDecisionResult
approve()The plan as it is
reject(reason)No plan
removeSteps(indexes)The plan without those steps
reorder(order)The steps in the new order
modify(removed, order)Removes first, then reorders. The order uses indexes of the plan after removal.

AdaptiveReplanEngine

import com.tnsai.planning.llm.AdaptiveReplanEngine;
import com.tnsai.planning.llm.PlanExecutionResult;
import com.tnsai.planning.llm.StepExecutionResult;

AdaptiveReplanEngine engine = AdaptiveReplanEngine.builder()
    .llm(llmClient)
    .planner(planner)
    .stepExecutor(step -> switch (step.actionName()) {
        case "search_web" -> StepExecutionResult.success(search(step.description()));
        case "summarize" -> StepExecutionResult.success(summarize(step.description()));
        default -> StepExecutionResult.failure("Unknown action " + step.actionName());
    })
    .maxReplanAttempts(3)
    .build();

PlanExecutionResult result = engine.execute("Write a brief on solid-state batteries");

llm, planner and stepExecutor are required. maxReplanAttempts defaults to 3 and cannot be negative. execute(goal) generates a plan and runs it; executePlan(plan) runs one you already have, for example one the gate approved.

Steps run in order. An exception from the step executor counts as a failed step. When a step fails:

  • after maxReplanAttempts replans, execution stops;
  • otherwise the engine sends the model the steps completed so far in the current plan, the failed step with its error, and the remaining steps, and asks for a revised plan. The revised plan does not pass through the gate;
  • an empty revised plan stops execution;
  • otherwise the revised plan runs from its first step.

PlanExecutionResult(plan, stepResults, success, replanCount) holds the last plan and every step result, including those of earlier plans. It has completedCount(), failedCount() and totalSteps().

success() is true only when every step result succeeded, and the failed step that caused a replan stays in the list. After any replan, success() is false even when the revised plan completed. To see whether the last plan finished, check the results of its steps.

  • Roles: @RoleSpec, goals and actions.
  • Actions: how actions run through ActionExecutor.
  • State machines: fixed state transitions instead of planning.
  • Reasoning: reasoning strategies for LLM calls.