Skip to content
AI Trading

Building an integrated agent orchestrator with LangChain, LangGraph, and Temporal

langchain — turned on gray laptop computer

Last quarter, our algorithmic execution desk migrated its multi-step portfolio rebalancing agent from a standalone LangGraph deployment to a hybrid architecture orchestrated by Temporal.

In our high-frequency execution pipelines, pure C++ handles order routing. But for our slower, multi-hour macro rebalancing loops—which require parsing news sentiment, analyzing order book depth, generating trading plans via LLMs, and seeking manual compliance sign-off for large block trades—we rely on agentic workflows.

Initially, we built this logic entirely within LangGraph. It is a fantastic framework for modeling cyclic agent interactions, tool-calling loops, and local state transitions. But as we scaled up trade sizes, we hit a wall of production failures. In-flight agent state would vanish during Kubernetes pod evictions. Long-running tool calls would timeout, leaving us in an indeterminate state where we could not tell if an order had been filled, partially filled, or rejected.

By integrating LangGraph as the “cognitive engine” inside a Temporal “durable execution” workflow, we achieved absolute fault tolerance without sacrificing the flexible, cyclic reasoning capabilities of our LLM agents.


The structural mismatch in agent execution

When building production-grade agents, we are forced to handle three hard distributed system problems:
1. Durable State: The agent’s conversation history and decision path must survive infrastructure restarts.
2. Durable Timers: If an API rate limit forces a 30-minute backoff, or if we must wait 4 hours for liquidity to pool, the execution context must remain active without consuming memory or blocking threads.
3. Deterministic Execution & Replay: If a node crashes mid-execution, we must be able to reconstruct the exact state of the workflow without re-running non-idempotent tool actions (like double-submitting a sell order).

LangGraph provides state persistence via checkpointers (like MemorySaver or PostgresSaver). However, LangGraph’s engine executes within the application process. If your runner pod crashes during a node execution, the step does not automatically recover on another node; you have to manually resume it from the last saved checkpoint.

More importantly, LangGraph does not natively solve the fallible tool problem. If an agent calls a tool to execute a trade, and the connection drops before a response is received, LangGraph cannot natively orchestrate complex retry policies with exponential backoff, circuit breaking, and human-in-the-loop signals at the framework level.

Temporal, on the other hand, is built specifically for durable execution. It uses event sourcing to replay workflows, guarantees at-least-once execution, and provides robust mechanisms for signals, queries, and long-running activities. But writing complex cyclic graphs, message histories, and dynamic LLM tool-calling loops directly in Temporal is incredibly verbose and painful to maintain.

The solution is a hybrid architecture: LangGraph manages the micro-level cognitive loops (agent state transitions, tool decisions), while Temporal manages the macro-level execution context (durable timers, activity execution, manual intervention signals, and transactional boundaries).


System Architecture

The workflow starts when a portfolio manager or automated signal triggers a rebalancing request. The orchestration pipeline uses Temporal to manage the transaction, while LangGraph determines the optimal split of trades across multiple liquidity pools.

flowchart TD
 Client["Client App"]
 Workflow["Temporal Workflow"]
 ApprovalSignal["Approval Signal"]
 AgentActivity["LangGraph Agent Activity"]
 ExecutionTool["Execution Tool"]
 Database["Postgres State Store"]

 Client -->|"Execute Trade"| Workflow
 Workflow -->|"Spawn"| AgentActivity
 AgentActivity -->|"Requires Approval"| ApprovalSignal
 ApprovalSignal -->|"Resume"| AgentActivity
 AgentActivity -->|"Call Tool"| ExecutionTool
 AgentActivity -->|"Save Checkpoint"| Database

To prevent the Temporal History size from exploding, we do not pass the entire LLM raw message history through Temporal’s workflow state. Temporal enforces a 2MB payload limit, and large prompt contexts can easily breach this during long runs. Instead, we persist the LangGraph state in an external Postgres instance, and pass only the session_id and the targeted trade_action_id within the Temporal payload.


The Code

Below is the complete implementation of our integrated agent orchestrator. It sets up a LangGraph agent that decides how to split a large block trade, a Temporal Workflow that manages the lifecycle and pauses for human confirmation when trade value exceeds $100,000, and a Temporal Worker that runs the execution loop.

1. The Cognitive Layer: LangGraph Agent

First, let’s define our agent. It takes a list of execution venues, evaluates a balance request, and outputs a execution plan.

# agent.py
from typing import Annotated, Dict, Any, List
from typing_extensions import TypedDict
from langchain_core.messages import BaseMessage, HumanMessage, AIMessage
from langchain_openai import ChatOpenAI
from langgraph.graph import StateGraph, END, START
from langgraph.checkpoint.postgres import PostgresSaver

# Define our shared state
class AgentState(TypedDict):
portfolio_id: str
target_asset: str
target_amount: float
estimated_slippage: float
requires_manual_approval: bool
execution_plan: List[Dict[str, Any]]
messages: Annotated[List[BaseMessage], lambda x, y: x + y]

# Define tools
def evaluate_market_liquidity(state: AgentState) -> Dict[str, Any]:
# Mocking liquidity evaluation. In production, this queries order book depth APIs.
amount = state["target_amount"]
slippage = 0.0015 if amount < 100000 else 0.0085

plan = [
{"venue": "COINBASE_PRIME", "allocated_amount": amount * 0.6, "expected_slippage": slippage},
{"venue": "KRAKEN_OTC", "allocated_amount": amount * 0.4, "expected_slippage": slippage * 1.1}
]

requires_approval = amount >= 100000.0

return {
"estimated_slippage": slippage,
"requires_manual_approval": requires_approval,
"execution_plan": plan,
"messages": [AIMessage(content=f"Evaluated liquidity. Split planned across Coinbase and Kraken. Approval needed: {requires_approval}")]
}

def build_langgraph_agent() -> StateGraph:
workflow = StateGraph(AgentState)

# Add nodes
workflow.add_node("evaluate_liquidity", evaluate_market_liquidity)

# Set entry point
workflow.add_edge(START, "evaluate_liquidity")
workflow.add_edge("evaluate_liquidity", END)

return workflow

2. The Durability Layer: Temporal Workflows & Activities

Now we define our Temporal code. We wrap the LangGraph agent execution inside a Temporal Activity. If the agent signals that a manual approval is required, the Temporal workflow yields, registers an external signal handler, and safely pauses without holding any CPU or threads active.

# activities.py
import os
from temporalio import activity
from psycopg import Connection
from langgraph.checkpoint.postgres import PostgresSaver
from agent import build_langgraph_agent, AgentState

DATABASE_URL = os.getenv("DATABASE_URL", "postgresql://postgres:postgres@localhost:5432/trading")

@activity.defn
async def analyze_and_plan_trade(portfolio_id: str, asset: str, amount: float) -> Dict[str, Any]:
activity_context = activity.info()
session_id = f"session_{activity_context.workflow_id}"

# Initialize Postgres checkpointer to persist LangGraph state outside of Temporal
async with await psycopg.AsyncConnection.connect(DATABASE_URL) as conn:
checkpointer = PostgresSaver(conn)
# Ensure schema is set up
await checkpointer.setup()

graph = build_langgraph_agent().compile(checkpointer=checkpointer)

# Initial input state
inputs = {
"portfolio_id": portfolio_id,
"target_asset": asset,
"target_amount": amount,
"estimated_slippage": 0.0,
"requires_manual_approval": False,
"execution_plan": [],
"messages": [HumanMessage(content=f"Analyze rebalance plan for {amount} {asset} in portfolio {portfolio_id}")]
}

# Execute the Graph step
config = {"configurable": {"thread_id": session_id}}
final_state = await graph.ainvoke(inputs, config=config)

# Return serializable outputs for Temporal state transition decisions
return {
"session_id": session_id,
"requires_manual_approval": final_state["requires_manual_approval"],
"execution_plan": final_state["execution_plan"],
"estimated_slippage": final_state["estimated_slippage"]
}

@activity.defn
async def execute_trade_on_venues(plan: List[Dict[str, Any]]) -> Dict[str, Any]:
# In a real environment, this calls REST/Websocket execution gateways with retry logic
executed_venues = []
for step in plan:
activity.logger.info(f"Routing {step['allocated_amount']} to {step['venue']}")
# Simulated execution action
executed_venues.append({
"venue": step["venue"],
"status": "FILLED",
"fill_price": 64250.00
})
return {"status": "SUCCESS", "fills": executed_venues}
python
# workflow.py
from datetime import timedelta
from typing import List, Dict, Any, Optional
from temporalio import workflow

# Import our activities
with workflow.unsafe.imports_passed_through():
from activities import analyze_and_plan_trade, execute_trade_on_venues

@workflow.defn
class PortfolioRebalanceWorkflow:
def __init__(self) -> None:
self._approved: Optional[bool] = None

@workflow.signal
def approve_trade(self, approved: bool) -> None:
self._approved = approved

@workflow.run
async def run(self, portfolio_id: str, asset: str, amount: float) -> Dict[str, Any]:
# Step 1: Run our LangGraph planner activity
plan_result = await workflow.execute_activity(
analyze_and_plan_trade,
args=[portfolio_id, asset, amount],
start_to_close_timeout=timedelta(minutes=5)
)

# Step 2: Check if compliance limit requires manual approval
if plan_result["requires_manual_approval"]:
workflow.logger.info("Trade exceeds compliance limit. Pausing for human approval…")

# Wait for manual intervention via a Temporal Signal
# We enforce a durable timeout of 24 hours. If no response, we auto-reject.
await workflow.wait_condition(
lambda: self._approved is not None,
timeout=timedelta(hours=24)
)

if not self._approved:
workflow.logger.info("Trade rejected by compliance officer.")
return {"status": "REJECTED_BY_COMPLIANCE", "reason": "Timeout or explicit rejection."}

# Step 3: Proceed with execution
execution_result = await workflow.execute_activity(
execute_trade_on_venues,
args=[plan_result["execution_plan"]],
start_to_close_timeout=timedelta(minutes=15)
)

return {
"status": "COMPLETED",
"session_id": plan_result["session_id"],
"execution_details": execution_result
}

3. Running the Infrastructure

To hook everything up, we require a Postgres instance running (configured via the connection string) and a running Temporal service. Below is the entry point script to run the Temporal worker and run a mock execution loop.

# run_worker.py
import asyncio
from temporalio.client import Client
from temporalio.worker import Worker
from activities import analyze_and_plan_trade, execute_trade_on_venues
from workflow import PortfolioRebalanceWorkflow

async def main():
client = await Client.connect("localhost:7233")

worker = Worker(
client,
task_queue="portfolio-trading-queue",
workflows=[PortfolioRebalanceWorkflow],
activities=[analyze_and_plan_trade, execute_trade_on_venues]
)

print("Worker started. Listening for tasks…")
await worker.run()

if __name__ == "__main__":
asyncio.run(main())

And here is the script we use to dispatch a workflow, check if it hits compliance, and send an approval signal.

# dispatch_trade.py
import asyncio
from temporalio.client import Client

async def main():
client = await Client.connect("localhost:7233")

# Dispatching a trade of $150,000 BTC – this will trigger the approval block
handle = await client.start_workflow(
"PortfolioRebalanceWorkflow",
"portfolio-123", "BTC", 150000.0,
id="trade-execution-001",
task_queue="portfolio-trading-queue"
)

print(f"Workflow started. ID: {handle.id}, Run ID: {handle.first_execution_run_id}")

# Wait for the workflow to trigger approval requirement
await asyncio.sleep(5)

# Send approval signal
print("Sending compliance approval signal…")
await handle.signal("approve_trade", True)

result = await handle.result()
print("Workflow Execution Finished. Result:")
print(result)

if __name__ == "__main__":
asyncio.run(main())


Production Execution Logs

When you run run_worker.py and then trigger dispatch_trade.py, you will see how cleanly the processing boundaries are maintained:

$ python run_worker.py
Worker started. Listening for tasks…
INFO:temporalio.worker:Running worker for queue portfolio-trading-queue
INFO:temporalio.activity:Activity analyze_and_plan_trade started (ID: 1)
INFO:temporalio.activity:Activity analyze_and_plan_trade finished successfully.
INFO:temporalio.workflow:Trade exceeds compliance limit. Pausing for human approval…
INFO:temporalio.workflow:Approval signal received. Processing execution…
INFO:temporalio.activity:Activity execute_trade_on_venues started (ID: 2)
INFO:temporalio.activity:Routing 90000.0 to COINBASE_PRIME
INFO:temporalio.activity:Routing 60000.0 to KRAKEN_OTC
INFO:temporalio.activity:Activity execute_trade_on_venues finished successfully.

Performance and Reliability Results

In our local test environment, we simulated system crashes, database disconnects, and heavy network failures to compare the standalone LangGraph system against our hybrid setup.

Metric / Failure Scenario Native LangGraph (Postgres Saver only) Hybrid LangGraph + Temporal
Worker pod crashed mid-execution Worker hangs. Graph state is locked. Execution path must be manually re-triggered from the client side. Replayed instantly by Temporal. Graph restarts from the specific step that crashed.
Long wait-times (e.g. 12-hour timeout) Keeps connection thread active, risking memory leaks or idle connection drops. Freezes workflow context to disk. Zero resource utilization during wait state.
Duplicate executions (Idempotency) Manual implementation required inside tools. Strict workflow state execution tracing ensures zero duplicate runs.
p99 Recovery Time (Simulated crash) 14,200ms (Manual operations intervention) 112ms (Automated worker takeover)

Architecture Lessons

Lesson 1: Separate the Payload State from the Agent State

Initially, we made the mistake of dumping the complete conversational chat history and state dict into Temporal’s workflow payload. If your state contains multi-megabyte contextual documents, the Temporal payload parser will degrade workflow execution performance and trigger schema validation exceptions.

Always serialize the large chat message histories directly in a persistent store (e.g., PostgreSQL, MongoDB, Redis) inside the Activity and exchange only lightweight transactional references (IDs and status fields) in the Temporal Workflow level.

Lesson 2: Keep Activities Completely Idempotent

Because Temporal relies on event replay to rebuild state on workflow engine crashes, any activity that performs an external API operation (e.g. calling an exchange API, sending an SMS) must be structured to accept execution IDs and support idempotent lookups. If a step executes but the worker crashes before sending the success acknowledgment back to Temporal, the rescheduled worker will re-run the same activity. Always implement strict idempotency keys (idempotency_key = f"{workflow_id}_{activity_id}") on downstream payment and exchange routes.

Join the conversation

Your email address will not be published. Required fields are marked *