Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
44 changes: 44 additions & 0 deletions .github/workflows/langgraph-example.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
name: LangGraph example

# Runs examples/langgraph_agent against a freshly built sop-mcp-server, offline,
# so the Python example and the server's structured block result cannot drift
# apart unnoticed. Not a required check, so it can use a path filter.
on:
pull_request:
paths:
- 'examples/langgraph_agent/**'
- 'tools/mcpserver/**'
- 'cmd/sop-mcp-server/**'
- 'verify/**'
- '.github/workflows/langgraph-example.yml'

permissions:
contents: read

jobs:
langgraph-agent:
name: LangGraph agent recovers from a block
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4

- uses: actions/setup-go@v7
with:
go-version-file: 'go.mod'
cache: true

- uses: actions/setup-python@v5
with:
python-version: '3.12'
cache: 'pip'
cache-dependency-path: examples/langgraph_agent/requirements.txt

- run: go build -o sop-mcp-server ./cmd/sop-mcp-server

- run: pip install -r examples/langgraph_agent/requirements.txt

- name: Run the agent against the server
working-directory: examples/langgraph_agent
env:
JOLTRIN_MCP_SERVER: ${{ github.workspace }}/sop-mcp-server
run: python test_agent.py
13 changes: 13 additions & 0 deletions demo/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -626,6 +626,19 @@ <h2 class="text-2xl sm:text-3xl font-extrabold text-white tracking-tight leading
<p class="text-slate-400 mb-3">Any agent that supports MCP can use the barrier. This downloads a binary of about 6 MB, checks its checksum, and registers it with Claude Code, Codex, and the Gemini CLI, whichever are installed. No Go needed.</p>
<pre class="whitespace-pre-wrap break-all rounded-lg bg-dark-900 border border-dark-800 p-3 font-mono text-slate-200 leading-relaxed">curl -fsSL https://raw.githubusercontent.com/SharedCode/joltrin/master/scripts/install.sh | sh</pre>
<p class="text-slate-400 mt-3">Then ask it: "Use the joltrin tools to run <code>drop_prod_db</code> on workflow <code>db-maintenance</code> with trace id <code>t1</code>." The server refuses until <code>take_backup</code> and <code>validate_backup</code> have run in that trace, whatever the agent claims. <a href="https://github.com/SharedCode/joltrin/blob/master/docs/AGENT_BARRIER_TESTS.md" target="_blank" rel="noopener noreferrer" class="text-brand-400 hover:text-white underline underline-offset-2">Recorded runs with real agents</a>.</p>
<span class="text-white font-bold block text-sm mt-5 mb-1">Or drive it from LangGraph</span>
<p class="text-slate-400 mb-3">A <code>StateGraph</code> with an agent node, a <code>ToolNode</code> over the server's MCP tools, and a loop between them. The agent tries <code>drop_prod_db</code>, reads the block, runs the steps it names, and retries. With <code>ANTHROPIC_API_KEY</code> set, a Claude model makes the calls. Without it, a small scripted policy stands in so the example runs offline and in CI. The policy is not a language model.</p>
<pre class="whitespace-pre-wrap break-all rounded-lg bg-dark-900 border border-dark-800 p-3 font-mono text-slate-200 leading-relaxed">agent -&gt; execute_step drop_prod_db
barrier -&gt; BLOCKED missing backup_validated, run first: ['validate_backup']
agent -&gt; execute_step validate_backup
barrier -&gt; BLOCKED missing backup_taken, run first: ['take_backup']
agent -&gt; execute_step take_backup
barrier -&gt; ALLOWED
agent -&gt; execute_step validate_backup
barrier -&gt; ALLOWED
agent -&gt; execute_step drop_prod_db
barrier -&gt; ALLOWED</pre>
<p class="text-slate-400 mt-3">That output is from a run of <code>examples/langgraph_agent/agent.py</code> with the scripted policy. <a href="https://github.com/SharedCode/joltrin/tree/master/examples/langgraph_agent" target="_blank" rel="noopener noreferrer" class="text-brand-400 hover:text-white underline underline-offset-2">Source and setup</a>.</p>
</div>
</section>

Expand Down
2 changes: 1 addition & 1 deletion demo/tailwind.css

Large diffs are not rendered by default.

6 changes: 6 additions & 0 deletions examples/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,12 @@ Embeds Joltrin's own docs with `all-MiniLM-L6-v2`, stores the vectors in a Joltr
- **Key Feature**: `sop.ai` `upsert_batch` and `query`, with the model pinned to one Hub commit.
- **Check**: `PYTHONPATH=bindings/python python examples/hf_grounded_search/test_search.py` (needs the native library built for your machine)

### 7. LangGraph agent over MCP (`langgraph_agent`)
A LangGraph `StateGraph` that calls the verification barrier through `sop-mcp-server`, gets blocked on `drop_prod_db`, and recovers using the fields in the block. Python, with a pinned `requirements.txt`.
- **Key Feature**: `langchain-mcp-adapters` over stdio, one session for the whole run.
- **Models**: Claude through `langchain-anthropic` when `ANTHROPIC_API_KEY` is set, otherwise a scripted policy (not an LLM) so it runs offline.
- **Check**: `python examples/langgraph_agent/test_agent.py`

---

## ▶️ Running the Examples
Expand Down
152 changes: 152 additions & 0 deletions examples/langgraph_agent/agent.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,152 @@
"""A LangGraph agent that calls Joltrin's MCP server and recovers from a block.

The agent is told to drop a production database. It tries, the barrier refuses
because no backup was validated, and the agent reads the structured block
(blocked_by, missing_state, established_by_steps) and runs the steps it names
before trying again. The graph is a plain StateGraph: an agent node that calls
the model, a ToolNode that runs MCP tools, and a conditional edge between them.

Two models can sit in the agent node:

- ChatAnthropic, when ANTHROPIC_API_KEY is set. A real model decides.
- ScriptedModel, otherwise. It is a small rule-based policy, not an LLM. It
exists so the loop runs offline and in CI. It reads each tool result and
reacts to it, but it is not a language model and proves nothing about one.

Run it:

go build -o sop-mcp-server ./cmd/sop-mcp-server
pip install -r examples/langgraph_agent/requirements.txt
JOLTRIN_MCP_SERVER=./sop-mcp-server python examples/langgraph_agent/agent.py
"""
import asyncio
import json
import os
import sys
import uuid

from langchain_core.language_models.chat_models import BaseChatModel
from langchain_core.messages import AIMessage, HumanMessage, SystemMessage, ToolMessage
from langchain_core.outputs import ChatGeneration, ChatResult
from langchain_mcp_adapters.client import MultiServerMCPClient
from langchain_mcp_adapters.tools import load_mcp_tools
from langgraph.graph import END, START, MessagesState, StateGraph
from langgraph.prebuilt import ToolNode, tools_condition

WORKFLOW = "db-maintenance"
GOAL = "drop_prod_db"


def result_of(message: ToolMessage) -> dict:
"""The JSON object the MCP server returned for one tool call."""
content = message.content
if isinstance(content, list):
content = "".join(part.get("text", "") for part in content if isinstance(part, dict))
try:
return json.loads(content)
except (TypeError, ValueError):
return {}


class ScriptedModel(BaseChatModel):
"""Rule-based stand-in for a model: try the goal, then follow each block."""

@property
def _llm_type(self) -> str:
return "scripted"

def bind_tools(self, tools, **kwargs):
return self

def _generate(self, messages, stop=None, run_manager=None, **kwargs) -> ChatResult:
trace_id = next(m.content.split("trace_id=")[1].split()[0] for m in messages if isinstance(m, HumanMessage))
step_of = {}
for m in messages:
if isinstance(m, AIMessage):
for call in m.tool_calls:
step_of[call["id"]] = call["args"].get("step")
# Blocked steps still waiting to be retried, newest last.
pending, last = [], None
for m in messages:
if isinstance(m, ToolMessage):
step, res = step_of.get(m.tool_call_id), result_of(m)
last = (step, res)
if res.get("executed"):
pending = [s for s in pending if s != step]
elif step not in pending:
pending.append(step)
if last is None:
nxt = GOAL
elif not last[1].get("executed") and last[1].get("blocked", {}).get("established_by_steps"):
nxt = last[1]["blocked"]["established_by_steps"][0]
elif pending:
nxt = pending[-1]
else:
msg = AIMessage(content=f"Done. {GOAL} ran only after the steps the barrier asked for.")
return ChatResult(generations=[ChatGeneration(message=msg)])
call = {"name": "execute_step", "id": f"call_{uuid.uuid4().hex[:8]}",
"args": {"workflow": WORKFLOW, "trace_id": trace_id, "step": nxt}}
return ChatResult(generations=[ChatGeneration(message=AIMessage(content="", tool_calls=[call]))])


def pick_model() -> BaseChatModel:
if os.environ.get("ANTHROPIC_API_KEY"):
from langchain_anthropic import ChatAnthropic
return ChatAnthropic(model=os.environ.get("JOLTRIN_MODEL", "claude-sonnet-5-5"))
return ScriptedModel()


def build_graph(tools, model):
bound = model.bind_tools(tools)

async def agent(state: MessagesState):
return {"messages": [await bound.ainvoke(state["messages"])]}

graph = StateGraph(MessagesState)
graph.add_node("agent", agent)
graph.add_node("tools", ToolNode(tools))
graph.add_edge(START, "agent")
graph.add_conditional_edges("agent", tools_condition)
graph.add_edge("tools", "agent")
return graph.compile()


async def run(server: str, model: BaseChatModel | None = None) -> list[tuple[str, bool]]:
"""Run the agent against the server. Returns (step, executed) for each execute_step call."""
client = MultiServerMCPClient({"joltrin": {"command": server, "args": [], "transport": "stdio"}})
trace_id = f"agent-{uuid.uuid4().hex[:8]}"
start = [
SystemMessage("Call tools through the joltrin MCP server. If a step is blocked, read the block and run the steps it names first."),
HumanMessage(f"Free the disk by dropping the production database. workflow={WORKFLOW} trace_id={trace_id} "
f"Use read_sop first if you need the steps."),
]
steps, calls = [], {}
# One session for the whole run. Without it the adapter starts a new server
# process per tool call, and the server keeps each trace in memory, so a
# step that ran in one call would be forgotten by the next.
async with client.session("joltrin") as session:
graph = build_graph(await load_mcp_tools(session), model or pick_model())
async for update in graph.astream({"messages": start}, stream_mode="updates", config={"recursion_limit": 30}):
for node, out in update.items():
for m in out["messages"]:
if isinstance(m, AIMessage):
for call in m.tool_calls:
calls[call["id"]] = call["args"].get("step")
print(f"agent -> {call['name']} {call['args'].get('step', '')}".rstrip())
if m.content and not m.tool_calls:
print(f"agent -> {m.content}")
elif isinstance(m, ToolMessage) and calls.get(m.tool_call_id):
res = result_of(m)
steps.append((calls[m.tool_call_id], bool(res.get("executed"))))
if res.get("executed"):
print("barrier -> ALLOWED")
else:
b = res.get("blocked", {})
print(f"barrier -> BLOCKED missing {b.get('missing_state')}, run first: {b.get('established_by_steps')}")
return steps


if __name__ == "__main__":
server = os.environ.get("JOLTRIN_MCP_SERVER", "sop-mcp-server")
steps = asyncio.run(run(server))
sys.exit(0 if steps and steps[-1] == (GOAL, True) else 1)
5 changes: 5 additions & 0 deletions examples/langgraph_agent/requirements.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
langgraph==1.2.14
langchain-core==1.6.7
langchain-mcp-adapters==0.3.2
mcp==1.30.0
langchain-anthropic==1.7.5
15 changes: 15 additions & 0 deletions examples/langgraph_agent/test_agent.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
"""Offline check: the agent is blocked first, then recovers in the order the barrier names.

JOLTRIN_MCP_SERVER=./sop-mcp-server python examples/langgraph_agent/test_agent.py
"""
import asyncio
import os

from agent import GOAL, run

steps = asyncio.run(run(os.environ.get("JOLTRIN_MCP_SERVER", "sop-mcp-server")))

assert steps[0] == (GOAL, False), f"the goal should be blocked first, got {steps[0]}"
done = [s for s, ok in steps if ok]
assert done == ["take_backup", "validate_backup", GOAL], f"wrong order of executed steps: {done}"
print("ok:", steps)
9 changes: 9 additions & 0 deletions tests/homepage.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -104,6 +104,15 @@ test.describe('Homepage', () => {
await expect(section).toContainText('curl -fsSL https://raw.githubusercontent.com/SharedCode/joltrin/master/scripts/install.sh | sh');
});

test('shows how to drive the barrier from LangGraph and says the offline policy is not a model', async ({ page }) => {
await page.goto('/', { waitUntil: 'domcontentloaded' });
const section = page.locator('#test-your-agent');
await expect(section).toContainText('Or drive it from LangGraph');
await expect(section).toContainText('The policy is not a language model');
await expect(section).toContainText("BLOCKED missing backup_validated, run first: ['validate_backup']");
await expect(section.getByRole('link', { name: 'Source and setup' })).toHaveAttribute('href', /examples\/langgraph_agent$/);
});

test('three live experiences are linked right after the hero', async ({ page }) => {
await page.goto('/', { waitUntil: 'domcontentloaded' });
const strip = page.locator('#live-experiences');
Expand Down
Loading