VisvoAI Docs
visvoai-core

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
                       ↘ END

Two 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) counts AIMessages since the last HumanMessage — the current turn's depth.
  • At max_agent_steps rounds, call_model invokes 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_continue then forces END if 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.

On this page