LangGraph Tool Execution: LangChain vs External Agent Harness β Trade Space
Status: ACTIVE (conversation observation) Agent: opencode/ext-agent (sandshrew) Timestamp UTC: 2026-05-11T18:55:00Z Claim: synthesis | 2026-05-11T18:50:00Z Session: MjF trade-off exploration β LangChain tools vs Pi/Hermes agents inside LangGraph nodes
Prior Context
- [[langgraph-node-anatomy]] β Full node anatomy catalog
- [[langgraph-game-surface]] β Game backend architecture
- Pearl Brain: research-sandshrew-20260511-180853 β Tool calls without LangChain
- This page: trade-off profile between LangChain tool integration and external agent harnesses
The Core Tension (As Observed)
Two paths for tool execution inside LangGraph nodes:
| Path | Pro Observed | Con Observed |
|---|---|---|
| LangChain tools (bind_tools, ToolNode, tool schemas) | Native integration with LangGraph's patterns. Tools are visible in state, checkpointable, interruptible. Documented, tested, familiar to LangGraph users. | Every tool must be declared upfront at compile time. Adaptive tool use at runtime requires anticipating every possible tool schema. If a situation emerges that needs a tool not in the schema, the node stalls. |
| External agent harness (Pi, Hermes, raw function calls) | Already has tool calls, multimodal abilities, adaptive reasoning. No pre-staging every tool schema β agent decides what to call at runtime. Lightweight (Bun runtime for Pi agents). | Internal tool calls are opaque to LangGraph state. LangGraph only sees the agent's final output, not intermediate tool calls. Wiring pattern may feel less "native" β node is a black-box wrapper around an external process. |
What LangGraph Actually Cares About
LangGraph is an orchestration runtime. It cares about three things: 1. State β what keys exist and how updates merge 2. Nodes β functions that receive state and return updates 3. Edges β how the next node is chosen
LangGraph does NOT care how a node produces its update. The official docs state: "You don't need to use LangChain to use LangGraph." Nodes are plain Python functions. Inside a node, the developer can call an LLM directly, invoke a LangChain tool, shell out to a Bun process, make an HTTP request to a Pi agent, or run pure Python logic. LangGraph is indifferent.
This means LangChain tools are NOT the native convention β they are ONE option among many. The node's internal implementation is LangGraph's blind spot by design.
Observed Boundary: What Granularity of State Tracking Is Needed?
The choice hinges on how much of the tool execution pipeline needs to be visible in LangGraph state:
| Need | LangChain Tools | External Harness |
|---|---|---|
| Track every tool call in state | Yes β ToolNode writes tool calls/results to state | No β internal calls invisible |
| Checkpoint mid-tool-execution | Yes β each tool call is a super-step | No β agent black box |
| Interrupt after a specific tool | Yes β interrupt() between tools | No β agent runs to completion |
| Adaptive tool selection at runtime | Only tools in schema | Full adaptivity β agent decides |
| Multimodal (image, audio) | Requires custom tool definitions | Built into Hermes-style harnesses |
| Lightweight runtime | Python only | Bun (Pi agents) or any |
| Pre-staging burden | High β every tool schema upfront | Low β agent handles its own tools |
Observed Patterns for Bridging
Pattern A: Black Box
def hex_node(state: State):
"""LangGraph owns routing + state. Agent handles everything inside."""
agent = get_agent("hermes")
result = agent.run(
task="Unit at hex_17 with enemies nearby. Decide and act.",
context={"state": state, "unit": state["unit"]}
)
return {"last_action": result.action, "unit": result.updated_state}
LangGraph asks "what should we do here?" The agent answers. LangGraph tracks the outcome, not the process. LangGraph state only sees: input β agent decision β output. Intermediate tool calls (the agent deciding to scout, then move, then attack) are invisible to LangGraph β they're the agent's internal reasoning loop.
Pattern B: Full Visibility
# Each tool is a LangChain tool, each call is a state checkpoint
graph.add_node("hex_node", ToolNode([
move_tool, attack_tool, scout_tool, use_item_tool
]))
LangGraph sees every tool invocation. State tracks: unit requested move, state updated with new position, next tool scout fires. Every step is checkpointable and interruptible. But every tool must be in the schema at compile time.
Pattern C: Hybrid β LangGraph Routes, Agent Decides
def hex_node(state: State, runtime: Runtime):
"""LangGraph provides situation. Agent decides which tools to use.
Agent reports back high-level outcomes. LangGraph tracks those."""
# LangGraph sends structured situation to agent
situation = {
"turn": state["turn"],
"unit": state["unit"],
"nearby": state["nearby_units"],
"terrain": state["terrain"][state["unit"]["position"]],
"objectives": state["objectives"],
"available_tools": ["move", "attack", "scout", "fortify"]
}
# Agent (Pi/Hermes) decides internally, uses its own tools
agent = runtime.context.get_agent("pi")
decision = agent.decide(situation)
# LangGraph tracks the decision at a high level
return {
"unit_action": decision.action, # "attack hex_18"
"unit_target": decision.target,
"action_log": decision.reasoning, # agent's internal reasoning, logged
"unit_state": decision.unit_update # health deltas, position, etc.
}
LangGraph owns the graph topology, state schema, routing, and checkpoints. The agent owns tool selection and execution. The interface is a structured handoff: "here's the situation and available tools" β "here's what I decided and what changed." LangGraph never needs to know whether the agent called scout before attack β it only needs the outcome.
This avoids both problems: - No pre-staging every tool in LangChain format - No fighting LangGraph's routing β routing remains LangGraph's job - Agent adaptivity is preserved β it can chain tools or invent new approaches
Observed: Runtime Separation Concern
Pi agents run on Bun (JavaScript). LangGraph runs on Python. If both run on the same Pi:
Same-machine options: - LangGraph Python node β subprocess call to Bun β Pi agent runs β returns JSON β LangGraph resumes - LangGraph Python node β HTTP call to local Pi agent server β JSON response - LangGraph threads and Pi agent as separate OS processes, state passed via JSON
Cross-machine options: - LangGraph on Pi, Pi agent HTTP server on Pi β simplest - LangGraph on Mac, Pi agent on Pi β network calls, need Tailscale or LAN
The subprocess pattern (Python calling Bun) is the tightest coupling but also the least overhead. The HTTP pattern is cleaner separation but adds a local server that must stay running.
Open Questions (Not Prescriptive)
- Is the tool visibility trade-off real in practice, or does Pattern C (high-level outcome tracking) cover enough ground for a game surface?
- How much does the BunβPython subprocess overhead actually cost on a Pi 4 per turn?
- Could Pi agents be configured to expose their internal tool calls as streamed events that LangGraph could optionally subscribe to, without making them mandatory state keys?
- If the game surface evolves to need mid-tool-execution interrupts (e.g., "scout found something β stop and let human decide"), does that force a move toward Pattern B?