"""Prebuilt GOAP agent one-liner.
Layer A of the three-layer low-code on-ramp (AD-2). Mirrors
``langgraph.prebuilt.create_react_agent`` in shape: pass tools, a
goal, and optionally an LLM; receive a compiled LangGraph.
**Preconditions and effects are never LLM-inferred.** The caller
either passes them explicitly, keyed by tool name, or accepts empty
pre/eff actions (which is almost never what you want for a
multi-step planner — the function logs a ``WARNING`` per affected
tool so the misuse is visible).
When ``goal`` is a natural-language string, an ``llm`` must be
provided — :class:`~langgoap.interpreter.GoalInterpreter` converts
the string to a :class:`~langgoap.goals.GoalSpec` once, at
construction time, before the graph is compiled.
"""
from __future__ import annotations
import logging
from datetime import timedelta
from typing import Any
from langchain_core.language_models import BaseChatModel
from langchain_core.tools import BaseTool
from langgraph.graph.state import CompiledStateGraph
from langgoap.actions import ActionSpec
from langgoap.goals import GoalSpec
from langgoap.graph.builder import GoapGraph
from langgoap.integrations.tools import EffectValidator, goapify_tool
logger = logging.getLogger(__name__)
[docs]
def create_goap_agent(
tools: list[BaseTool],
goal: str | GoalSpec,
*,
llm: BaseChatModel | None = None,
preconditions: dict[str, dict[str, Any]] | None = None,
effects: dict[str, dict[str, Any]] | None = None,
resources: dict[str, dict[str, float]] | None = None,
costs: dict[str, float] | None = None,
result_keys: dict[str, str] | None = None,
max_retries: dict[str, int] | None = None,
durations: dict[str, timedelta] | None = None,
effect_validators: dict[str, EffectValidator] | None = None,
**graph_kwargs: Any,
) -> CompiledStateGraph:
"""Create a compiled GOAP agent from a list of LangChain tools and a goal.
**NL-at-invocation-time limitation**: the returned ``CompiledStateGraph``
only exposes LangGraph's ``invoke({"goal": GoalSpec, "world_state":
dict})`` and ``ainvoke`` interfaces. It does **not** have
:meth:`~langgoap.graph.builder.GoapGraph.invoke_nl`, because that
method lives on the ``GoapGraph`` builder, not on the compiled
graph. For single-shot NL execution pass ``goal=request_string``
directly (NL interpretation happens once at construction). For
repeated NL-driven invocations against the same tool set,
construct a :class:`GoapGraph` directly and use ``invoke_nl()``.
Args:
tools: The LangChain tools the agent may call.
goal: A :class:`GoalSpec`, or a natural-language string. If a
string, ``llm`` must be provided.
llm: Chat model used to interpret a string goal. Ignored when
``goal`` is already a :class:`GoalSpec`.
preconditions: Optional mapping of tool name → preconditions
dict. Missing tools get empty preconditions.
effects: Optional mapping of tool name → effects dict.
Missing tools get empty effects.
resources: Optional mapping of tool name → resources dict.
costs: Optional mapping of tool name → action cost override.
result_keys: Optional mapping of tool name → world-state key
that should receive the tool's raw return value at
execution time. Use this to wire one tool's output into
the next tool's input — e.g. ``{"research_topic":
"brief"}`` makes the return value of ``research_topic``
available to a downstream ``write_article(brief)`` tool
via ``world_state["brief"]``. Planning is unaffected;
A* still reasons over the boolean flags in ``effects``.
A key here must not collide with any key declared for the
same tool in ``effects`` (see :func:`goapify_tool`).
max_retries: Optional mapping of tool name → planner-level
retry budget. ``0`` (default) blacklists the action on
its first failure; ``N`` allows ``N`` extra planner-level
replans before blacklisting (i.e. ``N + 1`` total
failures). This is the knob that lets a transient
failure trigger replanning without giving up entirely.
durations: Optional mapping of tool name → :class:`datetime.timedelta`.
Used by the CSP scheduler when computing parallel
execution windows. Tools with no entry default to
instantaneous actions.
effect_validators: Optional mapping of tool name → callable
``(pre_state, post_state) -> bool`` invoked after
execution. Returning ``False`` signals the action did
not produce its declared effects (soft failure → replan).
**graph_kwargs: Forwarded to
:meth:`GoapGraph.compile` (e.g. ``checkpointer``, ``store``).
Returns:
A compiled :class:`CompiledStateGraph` ready for ``.invoke()``
or ``.ainvoke()``.
Raises:
ValueError: If ``goal`` is a string but ``llm`` is ``None``.
"""
actions = _wrap_tools_as_actions(
tools,
preconditions=preconditions or {},
effects=effects or {},
resources=resources or {},
costs=costs or {},
result_keys=result_keys or {},
max_retries=max_retries or {},
durations=durations or {},
effect_validators=effect_validators or {},
)
resolved_goal = _resolve_goal(goal, llm=llm, actions=actions)
graph = GoapGraph(actions=actions)
compiled = graph.compile(**graph_kwargs)
# Attach the resolved goal as a public attribute so callers can
# pass it back to ``invoke({"goal": agent.goap_goal, ...})`` — the
# resolved goal is otherwise opaque to the caller when the input
# was a natural-language string.
setattr(compiled, "goap_goal", resolved_goal)
return compiled
def _wrap_tools_as_actions(
tools: list[BaseTool],
*,
preconditions: dict[str, dict[str, Any]],
effects: dict[str, dict[str, Any]],
resources: dict[str, dict[str, float]],
costs: dict[str, float],
result_keys: dict[str, str],
max_retries: dict[str, int],
durations: dict[str, timedelta],
effect_validators: dict[str, EffectValidator],
) -> list[ActionSpec]:
"""Wrap every tool with ``goapify_tool`` and warn on empty-effect tools."""
actions: list[ActionSpec] = []
tools_without_eff: list[str] = []
for tool in tools:
tool_eff = effects.get(tool.name)
tool_res = resources.get(tool.name)
if not tool_eff and not tool_res:
tools_without_eff.append(tool.name)
actions.append(
goapify_tool(
tool,
preconditions=preconditions.get(tool.name),
effects=tool_eff,
cost=costs.get(tool.name, 1.0),
resources=tool_res,
result_key=result_keys.get(tool.name),
max_retries=max_retries.get(tool.name, 0),
duration=durations.get(tool.name),
effect_validator=effect_validators.get(tool.name),
)
)
if tools_without_eff:
logger.warning(
"create_goap_agent: the following tools have no effects or "
"resources declared and will be treated as no-op actions by "
"the planner: %s. Pass effects={<tool>: {...}} to make them "
"plan-visible.",
tools_without_eff,
)
return actions
def _resolve_goal(
goal: str | GoalSpec,
*,
llm: BaseChatModel | None,
actions: list[ActionSpec],
) -> GoalSpec:
"""Return a ``GoalSpec``, interpreting a natural-language string once."""
if not isinstance(goal, str):
return goal
if llm is None:
raise ValueError(
"create_goap_agent received a string goal but no llm. "
"Pass llm=ChatOpenAI(...) or convert to a GoalSpec beforehand."
)
# Local import to keep interpreter dependencies lazy.
from langgoap.interpreter import GoalInterpreter
return GoalInterpreter(llm=llm, actions=actions).interpret(goal)
__all__ = ["create_goap_agent"]