Skip to content

Commit 3ccb61c

Browse files
danielmillerpclaude
andcommitted
feat(cli): add Temporal + LangGraph agent template and example
Adds a `temporal-langgraph` init template (registered in the Temporal submenu of `agentex init`) plus a runnable tutorial example, filling the gap left by the existing `temporal-openai-agents` / `temporal-pydantic-ai` templates — LangGraph previously only had `default-langgraph` and `sync-langgraph`. Uses the official Temporal LangGraph plugin (`temporalio.contrib.langgraph`, shipped in `temporalio[langgraph]>=1.27.0`): each LangGraph node runs as a durable Temporal activity (the LLM/`agent` node) or inline in the workflow (the `tools` node). Temporal is the runtime; LangGraph is the agent framework. Verified the plugin's behavior locally (Temporal dev server + real LLM) and encoded the working shape into the template: - agent node `execute_in="activity"`, tools node `execute_in="workflow"` (tool-node-as-activity is broken in the experimental plugin — AIMessage doesn't survive the activity boundary); - async router + async tools (sync callables hit run_in_executor, which Temporal's workflow loop forbids); - AgentexWorker's UnsandboxedWorkflowRunner makes langchain imports safe. Showcases nodes-as-activities, human-in-the-loop via LangGraph `interrupt` resumed by a Temporal `provide_approval` signal + `Command(resume=...)`, multi-turn memory on the workflow, graph-visualization queries (mermaid/ascii), and tracing. Example: examples/.../10_temporal/130_langgraph (full agent + tests). Tests: - tests/lib/cli/test_init_templates.py — renders every init template and asserts valid, substituted Python (catches .j2 regressions), with focused coverage of the new template (nodes-as-activities, HIL, queries, deps). - example tests/test_graph_temporal.py — hermetic graph + HIL coverage with a stub model, plus a live end-to-end run through the real Temporal plugin (skipped without LITELLM_API_KEY); tests/test_agent.py — live integration. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent a66d239 commit 3ccb61c

34 files changed

Lines changed: 3036 additions & 0 deletions
Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
# Python
2+
__pycache__/
3+
*.py[cod]
4+
*$py.class
5+
*.so
6+
.Python
7+
build/
8+
develop-eggs/
9+
dist/
10+
downloads/
11+
eggs/
12+
.eggs/
13+
lib/
14+
lib64/
15+
parts/
16+
sdist/
17+
var/
18+
wheels/
19+
*.egg-info/
20+
.installed.cfg
21+
*.egg
22+
23+
# Environments
24+
.env**
25+
.venv
26+
env/
27+
venv/
28+
ENV/
29+
env.bak/
30+
venv.bak/
31+
32+
# IDE
33+
.idea/
34+
.vscode/
35+
*.swp
36+
*.swo
37+
38+
# Git
39+
.git
40+
.gitignore
41+
42+
# Misc
43+
.DS_Store
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
# at130-langgraph - Environment Variables
2+
# Copy this file to .env and fill in the values
3+
4+
# API key for your LLM provider
5+
LITELLM_API_KEY=
6+
7+
# LLM base URL (optional - override to use a different provider)
8+
# OPENAI_BASE_URL=
9+
10+
# SGP Configuration (optional - for tracing)
11+
# SGP_API_KEY=
12+
# SGP_ACCOUNT_ID=
13+
# SGP_CLIENT_BASE_URL=
Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
# syntax=docker/dockerfile:1.3
2+
FROM python:3.12-slim
3+
COPY --from=ghcr.io/astral-sh/uv:0.6.4 /uv /uvx /bin/
4+
5+
# Install system dependencies
6+
RUN apt-get update && apt-get install -y \
7+
htop \
8+
vim \
9+
curl \
10+
tar \
11+
python3-dev \
12+
postgresql-client \
13+
build-essential \
14+
libpq-dev \
15+
gcc \
16+
cmake \
17+
netcat-openbsd \
18+
nodejs \
19+
npm \
20+
&& apt-get clean \
21+
&& rm -rf /var/lib/apt/lists/**
22+
23+
# Install tctl (Temporal CLI)
24+
RUN curl -L https://github.com/temporalio/tctl/releases/download/v1.18.1/tctl_1.18.1_linux_arm64.tar.gz -o /tmp/tctl.tar.gz && \
25+
tar -xzf /tmp/tctl.tar.gz -C /usr/local/bin && \
26+
chmod +x /usr/local/bin/tctl && \
27+
rm /tmp/tctl.tar.gz
28+
29+
ENV UV_COMPILE_BYTECODE=1
30+
ENV UV_LINK_MODE=copy
31+
ENV UV_HTTP_TIMEOUT=1000
32+
33+
WORKDIR /app/at130_langgraph
34+
35+
# Copy dependency files for layer caching
36+
COPY at130_langgraph/pyproject.toml at130_langgraph/uv.lock ./
37+
38+
# Install dependencies (without project itself, for layer caching)
39+
RUN --mount=type=cache,target=/root/.cache/uv \
40+
uv sync --locked --no-install-project --no-dev
41+
42+
# Copy the project code
43+
COPY at130_langgraph/project ./project
44+
45+
# Install the project
46+
RUN --mount=type=cache,target=/root/.cache/uv \
47+
uv sync --locked --no-dev
48+
49+
ENV PATH="/app/at130_langgraph/.venv/bin:$PATH"
50+
51+
# Run the ACP server using uvicorn
52+
CMD ["uvicorn", "project.acp:acp", "--host", "0.0.0.0", "--port", "8000"]
53+
54+
# When we deploy the worker, we will replace the CMD with the following
55+
# CMD ["python", "-m", "run_worker"]
Lines changed: 121 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,121 @@
1+
# at130-langgraph — AgentEx Temporal + LangGraph
2+
3+
A starter template for building AI agents with AgentEx, [LangGraph](https://langchain-ai.github.io/langgraph/),
4+
and Temporal — where **Temporal is the runtime and LangGraph is the agent framework**.
5+
6+
It uses the official [`temporalio.contrib.langgraph`](https://docs.temporal.io/develop/python/integrations/langgraph)
7+
plugin: each LangGraph node runs either as a durable **Temporal activity** or
8+
inline in the **workflow**, configured per node with `execute_in`. You get
9+
per-node durability, automatic retries, and full visibility in the Temporal UI
10+
— without LangGraph's own runtime or an external checkpoint database.
11+
12+
> The Temporal LangGraph plugin is currently **experimental**; its API may change.
13+
14+
## What's in the box
15+
16+
- **Nodes as activities** — the LLM (`agent`) node runs as a retried, observable
17+
Temporal activity; the `tools` node runs in the workflow (see below).
18+
- **Human-in-the-loop** — approval-gated tools raise a LangGraph `interrupt`;
19+
the workflow pauses on a Temporal signal (`provide_approval`) until a human
20+
approves or rejects, then resumes.
21+
- **Live introspection via Temporal queries**`get_status`,
22+
`get_pending_approval`, `get_graph_state`, and `get_graph_mermaid` /
23+
`get_graph_ascii` to render the agent graph while it runs.
24+
- **Multi-turn memory** — the running message list is kept on the workflow
25+
instance, durable for free.
26+
- **Tracing/observability** — a per-turn span shipped to SGP/AgentEx.
27+
28+
## The agent graph
29+
30+
```
31+
START --> agent --> (tool calls?) --> tools --> agent
32+
--> (no tool calls?) --> END
33+
```
34+
35+
`project/graph.py` defines this graph. The `agent` node is marked
36+
`execute_in="activity"`; the `tools` node is `execute_in="workflow"`. Query
37+
`get_graph_mermaid` at runtime to see it rendered.
38+
39+
### Why the tools node runs in the workflow
40+
41+
The `tools` node runs inline in the workflow (not as an activity) for two
42+
reasons: the `AIMessage` with tool calls stays intact without crossing an
43+
activity boundary, and LangGraph `interrupt` (used for human approval) must run
44+
where the workflow can pause on a Temporal signal. For long-running or heavily
45+
side-effecting tools, move that work into its own `execute_in="activity"` node.
46+
The router and tools are `async` so LangGraph awaits them directly (sync
47+
callables are offloaded via `run_in_executor`, which Temporal workflows forbid).
48+
49+
## Project structure
50+
51+
```
52+
at130_langgraph/
53+
├── project/
54+
│ ├── __init__.py
55+
│ ├── acp.py # Thin async ACP server; registers the LangGraphPlugin
56+
│ ├── workflow.py # Temporal runtime: runs the graph, HIL, queries, memory
57+
│ ├── graph.py # LangGraph graph; nodes tagged execute_in activity/workflow
58+
│ ├── tools.py # Async tool definitions + approval set
59+
│ └── run_worker.py # Temporal worker; registers the LangGraphPlugin
60+
├── Dockerfile
61+
├── manifest.yaml
62+
├── environments.yaml
63+
├── dev.ipynb
64+
└── pyproject.toml
65+
```
66+
67+
## Running the agent
68+
69+
```bash
70+
agentex uv sync
71+
source .venv/bin/activate
72+
73+
# Start the agent (ACP server + Temporal worker)
74+
agentex agents run --manifest manifest.yaml
75+
```
76+
77+
The agent starts on port 8000. Open the Temporal UI at http://localhost:8080 to
78+
watch workflows and activities execute. Use `dev.ipynb` to create a task and
79+
send messages.
80+
81+
## Human-in-the-loop
82+
83+
Tools listed in `TOOLS_REQUIRING_APPROVAL` (in `project/tools.py`) raise a
84+
LangGraph `interrupt` before they run. The workflow surfaces the pending call
85+
(queryable via `get_pending_approval`) and waits — durably, for as long as it
86+
takes — for a `provide_approval` signal carrying the decision:
87+
88+
```python
89+
# decision: {"approved": true, "approver": "daniel", "reason": "looks good"}
90+
```
91+
92+
If rejected, the rejection is fed back to the model so it can adjust.
93+
94+
## Adding tools
95+
96+
1. Define an **async** `@tool` function in `project/tools.py` and add it to `TOOLS`.
97+
2. (Optional) add its name to `TOOLS_REQUIRING_APPROVAL` to gate it behind
98+
human approval.
99+
100+
The model is bound with `TOOLS` and the tool node looks them up by name, so no
101+
other wiring is needed.
102+
103+
## Configuration
104+
105+
Tune the model in `project/graph.py` (`MODEL_NAME`) and the system prompt
106+
(`SYSTEM_PROMPT`). Per-node activity timeouts and retry policies live in the
107+
node `metadata` in `build_graph()`.
108+
109+
## Environment variables
110+
111+
Create a `.env` file (see `.env.example`):
112+
113+
```bash
114+
LITELLM_API_KEY=your-litellm-key # copied to OPENAI_API_KEY automatically
115+
# OPENAI_BASE_URL= # optional: point at a different provider
116+
# SGP_API_KEY= # optional: tracing
117+
# SGP_ACCOUNT_ID= # optional: tracing
118+
# SGP_CLIENT_BASE_URL= # optional: tracing
119+
```
120+
121+
Happy building with Temporal + LangGraph!
Lines changed: 126 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,126 @@
1+
{
2+
"cells": [
3+
{
4+
"cell_type": "code",
5+
"execution_count": null,
6+
"id": "36834357",
7+
"metadata": {},
8+
"outputs": [],
9+
"source": [
10+
"from agentex import Agentex\n",
11+
"\n",
12+
"client = Agentex(base_url=\"http://localhost:5003\")"
13+
]
14+
},
15+
{
16+
"cell_type": "code",
17+
"execution_count": null,
18+
"id": "d1c309d6",
19+
"metadata": {},
20+
"outputs": [],
21+
"source": [
22+
"AGENT_NAME = \"at130-langgraph\""
23+
]
24+
},
25+
{
26+
"cell_type": "code",
27+
"execution_count": null,
28+
"id": "9f6e6ef0",
29+
"metadata": {},
30+
"outputs": [],
31+
"source": [
32+
"# (REQUIRED) Create a new task. For Async agents, you must create a task for messages to be associated with.\n",
33+
"import uuid\n",
34+
"\n",
35+
"rpc_response = client.agents.create_task(\n",
36+
" agent_name=AGENT_NAME,\n",
37+
" params={\n",
38+
" \"name\": f\"{str(uuid.uuid4())[:8]}-task\",\n",
39+
" \"params\": {}\n",
40+
" }\n",
41+
")\n",
42+
"\n",
43+
"task = rpc_response.result\n",
44+
"print(task)"
45+
]
46+
},
47+
{
48+
"cell_type": "code",
49+
"execution_count": null,
50+
"id": "b03b0d37",
51+
"metadata": {},
52+
"outputs": [],
53+
"source": [
54+
"# Send an event to the agent\n",
55+
"\n",
56+
"# The response is expected to be a list of TaskMessage objects, which is a union of the following types:\n",
57+
"# - TextContent: A message with just text content \n",
58+
"# - DataContent: A message with JSON-serializable data content\n",
59+
"# - ToolRequestContent: A message with a tool request, which contains a JSON-serializable request to call a tool\n",
60+
"# - ToolResponseContent: A message with a tool response, which contains response object from a tool call in its content\n",
61+
"\n",
62+
"# When processing the message/send response, if you are expecting more than TextContent, such as DataContent, ToolRequestContent, or ToolResponseContent, you can process them as well\n",
63+
"\n",
64+
"rpc_response = client.agents.send_event(\n",
65+
" agent_name=AGENT_NAME,\n",
66+
" params={\n",
67+
" \"content\": {\"type\": \"text\", \"author\": \"user\", \"content\": \"Hello what can you do?\"},\n",
68+
" \"task_id\": task.id,\n",
69+
" }\n",
70+
")\n",
71+
"\n",
72+
"event = rpc_response.result\n",
73+
"print(event)"
74+
]
75+
},
76+
{
77+
"cell_type": "code",
78+
"execution_count": null,
79+
"id": "a6927cc0",
80+
"metadata": {},
81+
"outputs": [],
82+
"source": [
83+
"# Subscribe to the async task messages produced by the agent\n",
84+
"from agentex.lib.utils.dev_tools import subscribe_to_async_task_messages\n",
85+
"\n",
86+
"task_messages = subscribe_to_async_task_messages(\n",
87+
" client=client,\n",
88+
" task=task, \n",
89+
" only_after_timestamp=event.created_at, \n",
90+
" print_messages=True,\n",
91+
" rich_print=True,\n",
92+
" timeout=5,\n",
93+
")"
94+
]
95+
},
96+
{
97+
"cell_type": "code",
98+
"execution_count": null,
99+
"id": "4864e354",
100+
"metadata": {},
101+
"outputs": [],
102+
"source": []
103+
}
104+
],
105+
"metadata": {
106+
"kernelspec": {
107+
"display_name": ".venv",
108+
"language": "python",
109+
"name": "python3"
110+
},
111+
"language_info": {
112+
"codemirror_mode": {
113+
"name": "ipython",
114+
"version": 3
115+
},
116+
"file_extension": ".py",
117+
"mimetype": "text/x-python",
118+
"name": "python",
119+
"nbconvert_exporter": "python",
120+
"pygments_lexer": "ipython3",
121+
"version": "3.12.9"
122+
}
123+
},
124+
"nbformat": 4,
125+
"nbformat_minor": 5
126+
}

0 commit comments

Comments
 (0)