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.