Skip to content

27 · If you already use LangChain, LangGraph or NeMo Guardrails

This is the one chapter written in those frameworks' own terms, for a team that already has an agent, a graph or a set of rails. Nothing in Part I needs it: a Part I agent is a LangGraph graph underneath, and this chapter is where you open it, or bring the same router and tool check to code you already have. Everything here runs in your environment, and every decision it acts on is made by the licensed runtime on the same machine.

Language support

The selector reads turns written in English and in Brazilian Portuguese (pt-BR). The language is chosen, never guessed from the turn: the console's Settings page (Language the selector reads), language on a plan request, or the settings.language of your vocabulary file; with none of them, English. A Portuguese turn may be typed with its accents or without them. European Portuguese is not a grammar the selector has, and a turn in any other language is unread.

From Part I's agent to its graph

agent.graph() is the door: the compiled graph a Part I agent runs, verified as it compiles.

desk = Agent.from_file("agent.yaml")
graph = desk.graph()                          # the compiled graph Part I's agent runs, proved as it compiles
print(sorted(graph.get_graph().nodes))
['__end__', '__start__', 'billing_model', 'billing_tools', 'compute', 'desk_model', 'desk_to_billing', 'desk_tools', 'rewind', 'rollback', 'route']
verified, findings: []
QG001 at quick_charge: 'charge_card' can run on a plain tool node, without the tool check
QG011 at quick_charge: the privileged 'charge_card' can run carrying the label untrusted:read_inbox with no confirmation or refusal
{"order_id": "4411", "total": "40.00", "card": "visa-0019", "customer": "c-208", "status": "unpaid"}
waiting for a person: charge_card [{'name': 'amount', 'value': 40, 'origin': 'typed'}, {'name': 'order_id', 'value': '4411', 'origin': 'typed'}]
the model was called 0 times
asked: charge_card amount=40.0 (model), order_id=4411 (model) | ['argument_origin: order_id', 'argument_origin: amount']
charged: {'charge_id': 'ch-4411', 'order_id': '4411', 'amount': '40.0'}

The first line of the output is the shop's graph: route reads each turn, desk_model and desk_tools are the desk's model and its guarded tool node, desk_to_billing hands a conversation to the billing desk with its own pair, compute runs a calculation, and rollback and rewind undo a failed run and then forget it. It is a StateGraph like any other: run it with your checkpointer, stream it, add to it (below). agent.export() is the same graph as the verifier reads it, and stalwart_agents.export(path, out) writes it to a file, which is what stalwart verify --export-only does.

create_agent, with the router and the check as middleware

For an agent made with LangChain's create_agent, two middlewares add the router and the tool check without changing anything else (E13):

chat = ScriptedChatModel(script=[])           # no turn below needs it: each is read or checked first
agent = create_agent(chat, tools, checkpointer=InMemorySaver(), middleware=[
    StalwartRouterMiddleware(client, agent="desk", catalogue=catalogue, on_unread="refuse"),
    # The rulebook's facts, read from the tools' answers as agent.yaml's `facts:` says.
    StalwartToolGuardMiddleware(client, agent="desk", catalogue=catalogue, context=Context(facts=desk.facts)),
])
thread = {"configurable": {"thread_id": "chat-17"}}
state = agent.invoke({"messages": [HumanMessage("show me order 4411")]}, thread)
print(state["messages"][-1].content)
state = agent.invoke({"messages": [HumanMessage("charge order 4411 40.00")]}, thread)
for waiting in state.get("__interrupt__", []):
    print("waiting for a person:", waiting.value["tool"], waiting.value["arguments"])
print("the model was called", chat.turns, "times")

StalwartRouterMiddleware reads the latest user turn into a plan before the model is called, and the plan decides:

The plan What the middleware does
a tool step that resolves returns the tool call as the model's response: no model call
an ask returns the fixed question as the reply: no model call
declined returns the refusal naming the rule; the model is never shown the turn
a compute step the runtime builds the prompt, your agent's model writes the function, and the sandbox runs it (chapter 23)
unread first the bounded request to rephrase, then your on_unread setting (chapter 20)

A plan of several steps runs in order, each tool step through LangChain's own tool execution, so every other middleware still sees every call; once the last tool has answered, its result is the reply.

StalwartToolGuardMiddleware wraps every tool call, whoever chose it -- the router, the model or another middleware -- and asks the check: run it, refuse it with the reason in one sentence, or pause for a person through LangGraph's interrupt, the mechanism LangChain's own human-in-the-loop middleware uses. A call runs only after an approval of exactly the arguments shown.

In the output, show me order 4411 is answered with the order and charge order 4411 40.00 waits for a person, each argument shown with where it came from; the model was called no times. Put NeMo Guardrails' middleware outermost, then StalwartRouterMiddleware, then the model, with StalwartToolGuardMiddleware around tool execution. Neither of ours removes another middleware's hook.

A graph of your own

For a graph you write yourself, the parts are separate. create_stalwart_agent(model, tools, catalogue=..., agent=...) is the whole graph, generated from your catalogue and compiled; build_stalwart_graph is the same, uncompiled, for you to add to. validate_state(state) names the first thing that broke a state, and validating(graph) runs a graph checking the state after every node.

The state

Use StalwartState as your state, or add our channels to yours with with_stalwart_channels(YourState):

Channel Holds When two writes meet
messages the conversation stalwart_messages (below)
stalwart.session the SDK Session, as its JSON the newer
stalwart.history the check history and its token the longer, where it extends the other; an edited, reordered or forked history raises StateConflict
stalwart.labels trust labels, by message, call and argument the union
stalwart.saga the undo stack, calls in flight, a failure the newer version
stalwart.pending open interrupts and their tokens by interrupt id; None closes one
stalwart.plan the plan being run, each call's argument origins, refused routes merged by key

Everything the router and the check need between turns lives here, in your checkpointer. Rewinding a checkpoint rewinds it too, except stalwart.history: the calls already checked stay in the chain.

The messages

stalwart_messages is LangGraph's add_messages -- the same merge by id, RemoveMessage honoured -- with four rules on top: every tool call gets a result (a call left unanswered when the conversation moves on gets an error result in one plain sentence, so the next request to a provider is well formed); the results of one message's calls follow it in the order of its calls; trimming with trim_pairs never separates a call from its result; and nothing of ours is added to a message, so nothing of ours reaches a provider.

The nodes

Each node by what it reads, what it writes and where it goes:

Node Reads Writes Goes to
stalwart_route_node the latest user turn, stalwart.session stalwart.plan, stalwart.session; a reply for an ask, a decline or a refusal the guarded tool node with the call written; the compute node; your model node for an unread turn your setting hands over; END
StalwartToolNode(tools, client, agent=...) the latest message's calls, stalwart.plan, stalwart.history, stalwart.labels each call's result, stalwart.history, stalwart.saga, stalwart.pending itself, one step a pass; back to the router or the model; the rollback node after a terminal failure
stalwart_compute_node the plan's compute step and its sources' results the checked result, with its sources' labels END
stalwart_rollback_node stalwart.saga each undo's result, checked as the agent whose write it undoes the rewind node, once the stack is empty
stalwart_rewind_node the run's messages the run's messages replaced by one summary: what failed, what was undone, what could not be END

StalwartToolNode stands where ToolNode would. A refused call is zero invocations and an allowed one exactly one; a call that waits for a person pauses with interrupt() and, on resume, is checked again with the approval's token. Inside a tool it runs, current_idempotency_key() is the key of the write being made, for a tool that passes it on to an API. stalwart_branches fans one message's calls out as Send branches, each checked in its own branch, taking turns on the one history.

The edges

stalwart_edge(allowed={...}, choose=fn) is a conditional edge whose function reads model output; a destination outside the declared set is not taken, and the edge goes to a fixed node that refuses and records it. add_stalwart_edge adds that node where your graph has none. The declared set is the edge's path map, so the export knows it: a conditional edge with no path map, or a Command node with no Command[Literal[...]] annotation, can reach any node, and the verifier reports it.

Exporting a graph to verify it

A graph's compile() is where it is verified: export_graph(compiled, catalogue) writes it as the verifier reads it (qls-graph/1), each node's kind, the tools a tool node binds and the destinations each edge declares, and the hunter searches it for a path to a tool around the check (E12):

client = Client()
catalogue = Agent.from_file("agent.yaml").catalogue()
tools = [get_order, charge_card, send_receipt, read_inbox]
model = ScriptedChatModel(script=[])

builder = build_stalwart_graph(model, tools, catalogue=catalogue, agent="desk", client=client)
print("verified, findings:", [f["rule"] for f in builder.compile().stalwart_findings if f["type"] == "vulnerability"])

# A teammate adds a quick path that runs the charge with the plain tool runner.
builder = build_stalwart_graph(model, tools, catalogue=catalogue, agent="desk", client=client)
builder.add_node("quick_charge", ToolNode([charge_card]))
builder.add_conditional_edges("model", lambda state: "quick_charge", {"quick_charge": "quick_charge", END: END})
builder.add_edge("quick_charge", "model")
try:
    builder.compile()
except GraphNotVerified as refused:
    for finding in refused.findings:
        print(f"{finding['rule']} at {finding['node']}: {finding['message']}")

The generated graph verifies with no finding. The teammate's quick path does not compile: GraphNotVerified names each way through, here QG001, a write on a plain tool node, and QG011, the same write reachable after the inbox is read, with no person asked. Each finding names its rule, its tool, the node and the witness path; replay_witness runs that path in your own environment, with every tool stubbed, to show it is real (chapter 31). A verdict is cached by the export's digest, so a process that builds its graph on every request verifies it once. service_verifier(client) verifies through the hosted graph mode; binary_verifier(path) runs the hunter installed on your machine.

Plain Python: Guard

For code with no graph, Guard makes the same decisions as plain functions -- route reads a turn, check checks a call, and run is a saga as a context manager -- and needs neither LangChain nor LangGraph:

from stalwart_agents.langchain import Guard


def ask_the_person(confirmation):
    """Your confirmation screen: here, a person who reads the call and approves it."""
    shown = ", ".join(f"{a['name']}={a['value']} ({a['origin']})" for a in confirmation["arguments"])
    print("asked:", confirmation["tool"], shown, "|", confirmation["reasons"])
    return "approve"


guard = Guard(client, agent="desk", catalogue=catalogue, tools=tools, on_confirm=ask_the_person)
with guard.run(guard.session()) as run:
    order = run.call(get_order, order_id="4411")
    # The rulebook's $card_on_file, as your code knows it.
    charged = run.call_with(charge_card, {"order_id": "4411", "amount": float(order["total"])},
                            context={"card_on_file": bool(order["card"])})
print("charged:", charged)

The last two lines of the output. Every argument run.call is given counts as the model's, so the charge is put to a person (argument_origin); run.call_with(tool, arguments, origins={"amount": "typed"}) says where each came from. Without on_confirm, the call raises ConfirmationRequired and the run pauses. A terminal failure inside the with undoes the run's writes, last in first out.

NeMo Guardrails

pip install "stalwart-agents[nemo]" adds NeMo Guardrails, on the Pythons it supports. Two actions call the same router and check as the middleware: stalwart_route reads the user's message into a plan and sets $stalwart_declined where the plan declined it, and stalwart_check_tool checks a tool call before a flow runs it and sets $stalwart_allowed only where the check allowed it. register(rails, client, agent=...) registers both on your LLMRails, and snippet("1.0") or snippet("2.x") gives the input rail and the guard in either Colang dialect. Keep the guard's shape: the tool action runs only in the branch where $stalwart_allowed holds, and every other path ends in a refusal -- the shape the bug hunter checks. Chapter 29 runs it end to end, and chapter 13 is Colang 2 authoring.

Your tools, feedback and tests

  • tool_catalogue(tools, agent=...) makes the catalogue of your LangChain tools on your machine (chapter 21), each tool's kind from its metadata's marks (read_only, compensation, ...); tool_definitions(tools) is what it reads: each tool's name, description, argument schema and metadata. Nothing of it is sent until a check carries it.
  • StalwartFeedbackCallback remembers each run's turn and tool, and thumbs(run_id, up=...) posts a person's thumb (chapter 25).
  • No model, no provider key. ScriptedChatModel(script=[...]) answers from a script -- tool_call(name, arguments, call_id) is a scripted turn that calls a tool -- and counts its turns; RespondingChatModel answers with a function of what it was shown. StubTools(catalogue) replaces every tool with a stub that records its calls and returns fixture_value of its declared output; arguments_for(tool) fills a call's arguments by their declared kinds. A replay or a test built from these never reaches a real system.

The limits

Chapter 20's limits hold here, and these besides:

  • Verification reads what the export shows. A node of your own is read as opaque code, so what it does inside is not proved; mark a tool node guarded only where it is one.
  • The middleware and the nodes act on the router's and the check's decisions. A tool called outside them -- directly from your code, or from a node that is not ours -- is not checked, and the export shows it as a way around the check.