Extension seams
Every hook for reshaping the agent graph, state, and context without forking
visvoai-core is deliberately not a framework you configure — it's a small
loop you subclass. AgentRuntime (visvoai/core/runtime.py) exposes eight
hook methods; build_graph calls each of them (when a runtime instance is
passed) and falls back to a core default whenever a hook returns None.
Every seam below is exercised by real consumers: the CLI overrides the
agent node for per-turn context assembly, and a hosted platform adds HITL
and background-task nodes, a Postgres persistence pair, and an
auth-carrying context — same runtime, no forks, no private branches of this
code.
_extend_graph(workflow, tool_configs)
Add extra nodes/edges to the StateGraph before it's compiled. It is called
on every AgentRuntime.build_graph() — the base implementation is a no-op,
so a bare runtime adds nothing.
class MyRuntime(AgentRuntime):
def _extend_graph(self, workflow, tool_configs):
workflow.add_node("hitl", my_hitl_node)
workflow.add_edge("hitl", "agent")This is the seam for an approval gate, a background-task node, or any other
node your product's routing needs — combine with _get_interrupt_nodes to
actually pause execution there.
_build_agent_node(ctx) / _build_tools_node(ctx)
Return a full replacement node body for "agent" / "tools", or None to
keep the core default (call_model / ToolNode(all_tools)). Both receive
a GraphBuildContext — the exact inputs the core builder used, so your
replacement doesn't need to re-derive them:
@dataclass(frozen=True)
class GraphBuildContext:
model: BaseChatModel
core_tools: List[BaseTool]
all_tools_map: Dict[str, BaseTool]
all_tools: List[BaseTool]
system_prompt: str
tool_configs: Dict[str, Any] = field(default_factory=dict)
per_round_retrieve: Optional[Any] = None
lean_prompt: bool = False
max_agent_steps: Optional[int] = NoneOverride _build_agent_node for per-turn system-prompt assembly (e.g.
injecting a plan, trimming context by a token budget) without
re-implementing tool binding, the step cap, or retrieval — build your
replacement from ctx and it stays behaviorally consistent with core.
Override _build_tools_node to gate execution behind an approval step
before tools actually run.
_agent_routing(ctx) / _tools_routing(tool_configs)
_agent_routing returns (routing_fn, routing_map) for the "agent" →
{...} conditional edge, or None for the core default
(should_continue, {"tools": "tools", END: END}). Override to route the
agent node through an intermediate node (e.g. a finalize-check) instead of
straight to tools or END.
_tools_routing(tool_configs) is not optional to override in the same
way — the base AgentRuntime implementation always routes "tools" back
to "agent":
def _tools_routing(self, tool_configs: dict):
def _route(state: AgentState) -> str:
return "agent"
return _route, {"agent": "agent"}A subclass overrides this to add other destinations to the routing map
(e.g. {"agent": "agent", "hitl": "hitl"}) once _extend_graph has added
the corresponding node.
_get_checkpointer(checkpointer=None)
Return the checkpointer LangGraph should compile with. The base class
passes through whatever you gave build_graph's checkpointer= argument
unchanged; override to force your own (e.g. a Postgres-backed
BaseCheckpointSaver) regardless of what the caller passed, or to layer
setup around it.
graph = AgentRuntime().build_graph(
model=model, core_tools=tools, system_prompt="...",
checkpointer=MemorySaver(), # in-memory; swap for Postgres/SQLite for durability
)A checkpointer is what gives an agent memory across turns — the same
thread_id passed to ask()/astream_events resumes the same
conversation state.
_get_state_class()
Return the TypedDict LangGraph should use as its state schema. The
public AgentState (visvoai/core/state.py) is intentionally lean:
class AgentState(TypedDict, total=False):
messages: Annotated[Sequence[BaseMessage], add_messages]
active_mcp_tools: Annotated[List[str], _union_ordered]messages uses LangGraph's add_messages reducer (append/merge by id).
active_mcp_tools uses an order-preserving de-duplicating union reducer —
it's the persistent half of dynamic tool binding (see
Tool retrieval at scale). Core deliberately does
not declare state it doesn't act on — plan-mode bookkeeping, approval
flags, and similar surface-specific state belong on a subclass via
TypedDict inheritance:
class MyState(AgentState, total=False):
approval_request: Optional[dict]
pending_background_tasks: List[str]
class MyRuntime(AgentRuntime):
def _get_state_class(self):
return MyState_get_interrupt_nodes()
Return a list of node names LangGraph should interrupt_before — the
human-in-the-loop pause points. Default None (no interrupts). Pair with a
node added in _extend_graph:
class MyRuntime(AgentRuntime):
def _get_interrupt_nodes(self):
return ["approval_gate"]RuntimeContext — your state carried to tools
RuntimeContext (visvoai/core/context.py) is the only context type
visvoai-core tools receive by default:
@dataclass
class RuntimeContext:
request_id: Optional[str] = None
subagent_depth: int = 0
parent_tool_call_id: Optional[str] = NoneIt's intentionally surface-agnostic — no auth, no datastore session, no HTTP concerns. A surface that needs more subclasses it and passes its own subclass to tools instead:
@dataclass
class MyContext(RuntimeContext):
user_id: str = ""
db_session: Any = Nonesubagent_depth and parent_tool_call_id exist specifically to support
the subagent pattern — tracking nesting without any
special-cased recursion machinery in core itself.
The summary table
| Seam | Override to get |
|---|---|
_extend_graph(workflow, tool_configs) | extra graph nodes — approval gates, background tasks, custom routers |
_build_agent_node(ctx) | your own model-calling node (e.g. per-turn assembled system prompts) |
_build_tools_node(ctx) | a tools node gated behind an approval step |
_agent_routing(ctx) | routing after the agent thinks, beyond {tools, END} |
_tools_routing(tool_configs) | routing after tools run, beyond straight back to agent |
_get_checkpointer(checkpointer) | durable graph state — what gives the agent memory across turns |
_get_state_class() | your fields in the graph state (AgentState TypedDict inheritance) |
_get_interrupt_nodes() | human-in-the-loop interrupt points |
RuntimeContext (subclass) | your state carried to every tool — auth, sessions, registries |
ToolPersistence / LLMPersistence (implement) | see Persistence |
Runnable version of the tool + persistence + runtime seams together:
visvoai-core/examples/04_extend_the_runtime.py.
Next: Persistence.
Defining tools
Hand an agent a plain typed Python function and it becomes a tool — schema from the type hints, description from the docstring. No decorators required.
Persistence
Record what an agent did — every tool call, every model call and its cost — in your own datastore, through two interfaces injected once, with no call-site wrapping.