Stuck handlers#

Recover from a planning failure without writing routing code. When GoapPlanner cannot find a plan, it consults an ordered list of StuckHandler instances. Each handler can mutate world state, swap in a relaxed goal, or escalate to a human. Backed by tests/integration/test_stuck_handler_loop.py.

1. A two-action plan with a missing precondition#

license_protected only fires once license_acquired=True. We start with an empty world state, so the first plan attempt fails.

from typing import Any
from langgoap.actions import ActionSpec
from langgoap.goals import GoalSpec
from langgoap.graph.builder import GoapGraph

actions = [
    ActionSpec(
        name="setup",
        preconditions={},
        effects={"data_loaded": True},
        execute=lambda ws: {"data_loaded": True},
    ),
    ActionSpec(
        name="license_protected",
        preconditions={"data_loaded": True, "license_acquired": True},
        effects={"published": True},
        execute=lambda ws: {"published": True},
    ),
]
goal = GoalSpec(conditions={"published": True})
[a.name for a in actions]
/Users/brian.sam-bodden/Code/langgoap/.venv/lib/python3.12/site-packages/langgraph/checkpoint/serde/encrypted.py:5: LangChainPendingDeprecationWarning: The default value of `allowed_objects` will change in a future version. Pass an explicit value (e.g., allowed_objects='messages' or allowed_objects='core') to suppress this warning.
  from langgraph.checkpoint.serde.jsonplus import JsonPlusSerializer
['setup', 'license_protected']

2. Baseline — no handler, planner emits no_plan#

Without a stuck handler the planner gives up immediately.

result = GoapGraph(actions).invoke(goal=goal, world_state={})
result["status"]
A* found no plan for goal {'published': True} — Precondition(s) ['license_acquired'] are required by available actions but cannot be established from the current world state or by any other action. Check the action dependency chain.
'no_plan'

3. Add a handler that mutates state#

FunctionalStuckHandler wraps a (state, reason) -> StuckHandlerResult callable. Returning StuckHandlerResult.replan(state_updates={...}) merges the updates into world state and asks the planner to retry.

from langgoap.stuck import FunctionalStuckHandler, StuckHandlerResult

license_handler = FunctionalStuckHandler(
    name="acquire_license",
    fn=lambda state, reason: StuckHandlerResult.replan(
        handler_name="acquire_license",
        message="license acquired out-of-band",
        state_updates={"license_acquired": True},
    ),
)
graph = GoapGraph(actions, stuck_handlers=[license_handler])
result = graph.invoke(goal=goal, world_state={})
(result["status"],
 result["world_state"]["published"],
 result["world_state"]["license_acquired"])
A* found no plan for goal {'published': True} — Precondition(s) ['license_acquired'] are required by available actions but cannot be established from the current world state or by any other action. Check the action dependency chain.
('goal_achieved', True, True)

4. Chain handlers with MulticastStuckHandler#

Handlers run in order. The first one that returns REPLAN wins; downstream handlers are skipped. Exceptions in any handler are caught and treated as NO_RESOLUTION so one bad handler can’t crash the loop.

from langgoap.stuck import MulticastStuckHandler

no_op = FunctionalStuckHandler(
    name="no_op",
    fn=lambda s, r: StuckHandlerResult.no_resolution(
        handler_name="no_op", message="cannot help"
    ),
)
chained = MulticastStuckHandler([no_op, license_handler])
graph = GoapGraph(actions, stuck_handlers=[chained])
result = graph.invoke(goal=goal, world_state={})
result["status"]
A* found no plan for goal {'published': True} — Precondition(s) ['license_acquired'] are required by available actions but cannot be established from the current world state or by any other action. Check the action dependency chain.
'goal_achieved'

Next steps#

  • Cap retries with GoapGraph(..., max_stuck_iterations=N) so a no-op handler can’t spin forever.

  • A handler can substitute a relaxed goal via StuckHandlerResult.replan(new_goal=GoalSpec(...)).

  • See the end-to-end recovery story in examples/tutorials/supply_chain_disruption_mediator.ipynb.