The agent loop
LangGraph raises GraphRecursionError mid-turn when an agent loops too long. visvoai-core caps the rounds and makes the model answer instead.
An agent turn is a loop: the model thinks, optionally calls tools, reads
results, thinks again, and eventually answers. Run that loop long enough on
plain LangGraph and it hits the
recursion_limit, raising
GraphRecursionError
in the middle of a turn — which the person waiting sees
as a crash rather than an answer.
visvoai-core implements the loop once, as a small LangGraph StateGraph,
with a soft step cap that ends a long turn in a real answer instead, and
hooks to reshape it rather than rewrite it.
AgentRuntime.build_graph
from visvoai.core.runtime import AgentRuntime
from visvoai.ai import build_chat_model
graph = AgentRuntime().build_graph(
model: BaseChatModel,
core_tools: List[Any],
all_tools_map: Optional[Dict[str, Any]] = None,
system_prompt: str = "",
checkpointer: Optional[BaseCheckpointSaver] = None,
tool_configs: Optional[Dict[str, Any]] = None,
lean_prompt: bool = False,
per_round_retrieve: Optional[Any] = None,
)AgentRuntime.build_graph (visvoai/core/runtime.py) is a thin dispatch to
the real builder, visvoai.core.graph.build_graph, passing itself as
_runtime=self so its hook methods get invoked during construction. The
result is a compiled, standard LangGraph app — .invoke(),
.ainvoke(), .stream(), .astream_events() all work exactly as
LangGraph documents them.
graph = AgentRuntime().build_graph(
model=build_chat_model("gemini:gemini-2.5-flash"),
core_tools=[read_file],
system_prompt="You are a code assistant.",
)
from visvoai.core import ask
answer = await ask(graph, "What's in pyproject.toml?")ask(graph, text, thread_id=None) (visvoai/core/adapt.py) is the text
boundary over the graph: it wraps your text in a HumanMessage, invokes,
and returns out["messages"][-1].content. Pass thread_id to give the
turn conversation memory (requires a checkpointer — same id resumes the
same conversation, a new id starts fresh). For a streaming UI, drop to
graph.astream_events(...) directly:
async for ev in graph.astream_events(
{"messages": [("user", "How long is main.py?")]}, version="v2"):
if ev["event"] == "on_chat_model_stream":
chunk = ev["data"]["chunk"]
if isinstance(chunk.content, str) and chunk.content:
print(chunk.content, end="", flush=True)
elif ev["event"] == "on_tool_start":
print(f"\n[tool: {ev['name']} {ev['data'].get('input', {})}]")
elif ev["event"] == "on_tool_end":
print(f"[ → {getattr(ev['data']['output'], 'content', ev['data']['output'])}]")Graph topology
agent → should_continue → tools → (routing fn) → agent
↘ ENDTwo nodes, "agent" and "tools". "agent" calls the model (bound with
tools); if the response is an AIMessage carrying tool_calls, routing
sends it to "tools" (a LangGraph ToolNode); otherwise it routes to
END. With no runtime overrides, "tools" always routes back to
"agent" — the plain loop.
The soft step cap: no more GraphRecursionError
LangGraph enforces a hard recursion_limit (default 25 graph steps) and
raises GraphRecursionError if it's exceeded mid-turn — which your user
sees as a crash, not an answer. visvoai-core runs agent + tools as
two super-steps per tool-calling round, so a turn of N rounds costs ~2N
steps against that limit. build_graph's max_agent_steps (default
DEFAULT_MAX_AGENT_STEPS = 10, from visvoai/core/graph.py) is a soft
cap well under the hard ceiling:
_rounds_this_turn(messages)countsAIMessages since the lastHumanMessage— the current turn's depth.- At
max_agent_stepsrounds,call_modelinvokes the model unbound (no tools at all) with a[SYSTEM]instruction appended telling it to stop attempting tool calls and give its best final answer now, or summarize what's unresolved. should_continuethen forcesENDif tool calls somehow appear past the cap on an unbound round (a hallucinated call with no real declarations, from a malformed provider) — a defensive guard so a pathological model still can't loop to the hard limit.
This is on by construction — there is nothing to configure, and nothing to
remember. Note the current limitation: max_agent_steps is a parameter of
the underlying visvoai.core.graph.build_graph, but AgentRuntime.build_graph
does not yet forward it, so through the public API the budget is fixed at 10
rounds. If you need a different budget today, override build_graph on your
runtime subclass and call the graph builder directly.
Binding tools: everything, or per-round retrieval
By default every tool in all_tools_map (or just core_tools if
all_tools_map is omitted) is bound to the model on every round — simple,
and fine until you have hundreds of tools. Pass per_round_retrieve (built
by visvoai.core.retrieval.make_per_round_retrieve — see
Tool retrieval at scale) to defer binding: tools
present in all_tools_map but not in core_tools are only bound for a
round when the retriever returns them for that round's query. core_tools
are always bound, every round, regardless of retrieval. Bound models are
cached by the frozenset of active tool names, so the underlying
bind_tools() call (which rebuilds provider function declarations) only
re-runs when the active tool set actually changes between rounds.
Extending the loop without forking
build_graph calls a set of overridable hook methods on the AgentRuntime
instance passed as _runtime. Every hook defaults to None / a no-op —
returning None means "use the core default"; returning a value overrides
it. This is the entire extension mechanism; see
Extension seams for each hook in depth, and
Persistence for wiring tool-call/LLM-call recording
into your own datastore.
Next: Defining tools.