# Planning

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

| Planner | Package | Picks | Plans from |
|---|---|---|---|
| `BackwardChainingPlanner` | `com.tnsai.planning` | An ordered list of actions per unsatisfied goal | Goals, state and action conditions on a role |
| `UtilityAIPlanner` | `com.tnsai.planning` | The single best action right now | Action scores |
| `LLMDynamicPlanner` | `com.tnsai.planning.llm` | A list of steps for a goal written in plain text | One 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`:

<!-- java-contract: src/main/java/com/example/tnsai/docs/PlanningRoleExample.java -->
```java
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]`.

| Annotation | What the planners read |
|---|---|
| `@Goal` | `name`, `condition` and `priority` (default `NORMAL`). `description`, `persistent` and `deadline` are stored on `PlanningGoal` but do not affect planning. |
| `@State` | The 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. |
| `@ActionSpec` | `precondition`, `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`:

| Method | Meaning |
|---|---|
| `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:

```java
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

| Constructor | Meaning |
|---|---|
| `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:

| Reason | Cause |
|---|---|
| `UNREACHABLE` | No action chain reaches the goal |
| `CYCLIC_PRECONDITION` | The search came back to the same precondition with the same simulated state |
| `MAX_DEPTH_EXCEEDED` | The chain is longer than `maxDepth` |
| `SEARCH_BUDGET_EXCEEDED` | The search hit the expansion limit |
| `PRECONDITION_STALE` | During `think()`, a step's precondition no longer held |
| `GOAL_UNSATISFIED_AFTER_EXECUTION` | `think()` 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.

```java
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() { }
```

```java
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`:

```text
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.

| `Consideration` | Score |
|---|---|
| `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`

```java
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());
```

| Builder | Default | Meaning |
|---|---|---|
| `llm(LLMClient)` | Required | The model to ask |
| `capability(CapabilityDescriptor)` | None | Add an action the model may use |
| `capabilities(List)` | None | Replace the list |
| `additionalContext(String)` | `""` | Extra text for the prompt |
| `temperature(float)` | `0.2` | 0 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`

```java
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)`.

| `ApprovalDecision` | Result |
|---|---|
| `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`

```java
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.

## Related

- [Roles](../agents/roles.md): `@RoleSpec`, goals and actions.
- [Actions](../agents/actions.md): how actions run through `ActionExecutor`.
- [State machines](state-machines.md): fixed state transitions instead of planning.
- [Reasoning](reasoning.md): reasoning strategies for LLM calls.
