From cfeb6f46d7eec4cf394a4379ed42dd08b8b292a0 Mon Sep 17 00:00:00 2001 From: Christian Bromann Date: Tue, 26 May 2026 14:53:53 -0700 Subject: [PATCH 01/22] chore(frontend): update frontend pattern docs --- src/oss/deepagents/frontend/overview.mdx | 14 +- src/oss/deepagents/frontend/sandbox.mdx | 96 +++-- .../frontend/subagent-streaming.mdx | 352 +++++++++-------- src/oss/deepagents/frontend/todo-list.mdx | 14 +- src/oss/langchain/frontend/branching-chat.mdx | 362 +++++------------- src/oss/langchain/frontend/generative-ui.mdx | 30 +- .../langchain/frontend/human-in-the-loop.mdx | 233 ++++------- .../frontend/integrations/ai-elements.mdx | 27 +- .../frontend/integrations/assistant-ui.mdx | 18 +- .../frontend/integrations/openui.mdx | 10 +- .../frontend/integrations/overview.mdx | 2 +- src/oss/langchain/frontend/join-rejoin.mdx | 301 +++++++++------ .../langchain/frontend/markdown-messages.mdx | 24 +- src/oss/langchain/frontend/message-queues.mdx | 121 +++--- src/oss/langchain/frontend/overview.mdx | 10 +- .../langchain/frontend/reasoning-tokens.mdx | 32 +- .../langchain/frontend/structured-output.mdx | 232 +++++------ src/oss/langchain/frontend/time-travel.mdx | 126 ++++-- src/oss/langchain/frontend/tool-calling.mdx | 114 +++--- .../langgraph/frontend/graph-execution.mdx | 319 ++++++--------- src/oss/langgraph/frontend/overview.md | 52 ++- 21 files changed, 1171 insertions(+), 1318 deletions(-) diff --git a/src/oss/deepagents/frontend/overview.mdx b/src/oss/deepagents/frontend/overview.mdx index 88778b8c51..99a418cb57 100644 --- a/src/oss/deepagents/frontend/overview.mdx +++ b/src/oss/deepagents/frontend/overview.mdx @@ -7,7 +7,7 @@ Build frontends that visualize deep agent workflows in real time. These patterns ## Architecture -Deep Agents use a coordinator-worker architecture. The main agent plans tasks and delegates to specialized subagents, each running in isolation. On the frontend, `useStream` surfaces both the coordinator's messages and each subagent's streaming state. +Deep Agents use a coordinator-worker architecture. The main agent plans tasks and delegates to specialized subagents, each running in isolation. On the frontend, the v1 stream handle surfaces coordinator messages on the root stream and exposes subagent discovery snapshots for scoped subagent views. ```mermaid %%{ @@ -20,11 +20,15 @@ Deep Agents use a coordinator-worker architecture. The main agent plans tasks an }%% graph LR FRONTEND["useStream()"] + SELECTORS["selector helpers"] BACKEND["createDeepAgent()"] SUB1["Subagent A"] SUB2["Subagent B"] BACKEND --"stream"--> FRONTEND + FRONTEND --"scope by subagent"--> SELECTORS + SELECTORS --> SUB1 + SELECTORS --> SUB2 FRONTEND --"submit"--> BACKEND BACKEND --"delegate"--> SUB1 BACKEND --"delegate"--> SUB2 @@ -34,7 +38,7 @@ graph LR classDef blueHighlight fill:#E5F4FF,stroke:#006DDD,color:#030710; classDef greenHighlight fill:#F6FFDB,stroke:#6E8900,color:#2E3900; classDef purpleHighlight fill:#EBD0F0,stroke:#885270,color:#441E33; - class FRONTEND blueHighlight; + class FRONTEND,SELECTORS blueHighlight; class BACKEND greenHighlight; class SUB1,SUB2 purpleHighlight; ``` @@ -66,7 +70,7 @@ import { createDeepAgent } from "deepagents"; const agent = createDeepAgent({ tools: [getWeather], - system: "You are a helpful assistant", + systemPrompt: "You are a helpful assistant", subagents: [ { name: "researcher", @@ -78,7 +82,7 @@ const agent = createDeepAgent({ ::: -On the frontend, connect with `useStream` the same way as with `createAgent`. Deep agent patterns use additional `useStream` features like `stream.subagents`, `stream.values.todos`, and `filterSubagentMessages` to render subagent-specific UIs. +On the frontend, connect with `useStream` the same way as with `createAgent`. Deep agent patterns use `stream.subagents`, selector helpers such as `useMessages(stream, subagent)`, and custom state values like `stream.values.todos` to render subagent-specific UIs. ```ts import { useStream } from "@langchain/react"; @@ -91,7 +95,7 @@ function App() { // Deep agent state beyond messages const todos = stream.values?.todos; - const subagents = stream.subagents; + const subagents = [...stream.subagents.values()]; } ``` diff --git a/src/oss/deepagents/frontend/sandbox.mdx b/src/oss/deepagents/frontend/sandbox.mdx index 71f819d91b..90f994ceb1 100644 --- a/src/oss/deepagents/frontend/sandbox.mdx +++ b/src/oss/deepagents/frontend/sandbox.mdx @@ -55,7 +55,7 @@ graph LR SANDBOX["Sandbox"] UI --"useStream()"--> AGENT - UI --"/api/sandbox/:threadId/*"--> API + UI --"/sandbox/:threadId/*"--> API AGENT --"read/write/execute"--> SANDBOX API --"ls / read"--> SANDBOX @@ -96,7 +96,7 @@ sequenceDiagram FE->>LG: POST /threads LG-->>FE: threadId - FE->>HTTP: GET /api/sandbox/:threadId/tree + FE->>HTTP: GET /sandbox/:threadId/tree HTTP->>LG: threads.get(threadId) → metadata.sandbox_id alt No sandbox yet HTTP->>SB: LangSmithSandbox.create() @@ -192,7 +192,7 @@ import urllib.request session_id = str(uuid.uuid4()) query = urllib.parse.urlencode({"sessionId": session_id}) -urllib.request.urlopen(f"http://localhost:2024/api/sandbox/tree?{query}") +urllib.request.urlopen(f"http://localhost:2024/sandbox/{session_id}/tree?filePath=/app") ``` ::: @@ -201,7 +201,7 @@ urllib.request.urlopen(f"http://localhost:2024/api/sandbox/tree?{query}") ```ts const sessionId = crypto.randomUUID(); -fetch(`/api/sandbox/tree?sessionId=${sessionId}`); +fetch(`/sandbox/${sessionId}/tree?filePath=/app`); ``` ::: @@ -251,13 +251,14 @@ commands. No tool configuration needed. ### Resolve a sandbox per thread Instead of creating a sandbox at module level (which would be shared across -all threads and may expire), resolve the sandbox per-thread at runtime. The sandbox reads `thread_id` from the LangGraph config via `getConfig()`: +all threads and may expire), resolve the sandbox per-thread at runtime. The +backend factory receives the LangGraph runtime and reads `thread_id` from +`runtime.configurable`: :::js ```ts -import { createDeepAgent, LangSmithSandbox } from "deepagents"; -import { getConfig } from "@langchain/langgraph"; +import { createDeepAgent, type BackendRuntime } from "deepagents"; async function getOrCreateSandboxForThread(threadId: string): Promise { // Check thread metadata for existing sandbox_id @@ -279,19 +280,15 @@ async function getOrCreateSandboxForThread(threadId: string): Promise { - const config = getConfig(); - const threadId = config.configurable?.thread_id; - if (!threadId) throw new Error("No thread_id — agent must run on a thread"); - return getOrCreateSandboxForThread(threadId); - }, -}); +async function sandboxBackendFactory(runtime: BackendRuntime) { + const threadId = runtime.configurable?.thread_id; + if (!threadId) throw new Error("No thread_id — agent must run on a thread"); + return getOrCreateSandboxForThread(threadId); +} export const agent = createDeepAgent({ model: "google_genai:gemini-3.5-flash", - backend: sandbox, + backend: sandboxBackendFactory, systemPrompt: "You are an expert developer working on a project in /app.", }); ``` @@ -398,7 +395,7 @@ import { getOrCreateSandboxForThread } from "./utils.js"; export const app = new Hono(); -app.get("/api/sandbox/:threadId/tree", async (c) => { +app.get("/sandbox/:threadId/tree", async (c) => { const threadId = c.req.param("threadId"); const rootPath = c.req.query("filePath") || "/app"; @@ -424,7 +421,7 @@ app.get("/api/sandbox/:threadId/tree", async (c) => { return c.json({ path: rootPath, entries, sandboxId: sandbox.id }); }); -app.get("/api/sandbox/:threadId/file", async (c) => { +app.get("/sandbox/:threadId/file", async (c) => { const threadId = c.req.param("threadId"); const filePath = c.req.query("filePath"); if (!filePath) return c.json({ error: "filePath is required" }, 400); @@ -450,14 +447,14 @@ from utils import get_or_create_sandbox_for_thread app = FastAPI() -@app.get("/api/sandbox/{thread_id}/tree") +@app.get("/sandbox/{thread_id}/tree") async def list_tree( thread_id: str = Path(...), - path: str = Query("/app"), + filePath: str = Query("/app"), ): sandbox = await get_or_create_sandbox_for_thread(thread_id) result = await sandbox.aexecute( - f"find {path} -printf '%y\\t%s\\t%p\\n' 2>/dev/null | sort" + f"find {filePath} -printf '%y\\t%s\\t%p\\n' 2>/dev/null | sort" ) entries = [] for line in result.output.strip().split("\n"): @@ -470,16 +467,16 @@ async def list_tree( "path": full_path, "size": int(size_str), }) - return {"path": path, "entries": entries, "sandbox_id": sandbox.id} + return {"path": filePath, "entries": entries, "sandboxId": sandbox.id} -@app.get("/api/sandbox/{thread_id}/file") +@app.get("/sandbox/{thread_id}/file") async def read_file( thread_id: str = Path(...), - path: str = Query(...), + filePath: str = Query(...), ): sandbox = await get_or_create_sandbox_for_thread(thread_id) - results = await sandbox.adownload_files([path]) - return {"path": path, "content": results[0].content.decode()} + results = await sandbox.adownload_files([filePath]) + return {"path": filePath, "content": results[0].content.decode()} ``` ::: @@ -507,7 +504,7 @@ the LangGraph platform to serve your custom routes alongside the default ones: { "node_version": "22", "graphs": { - "coding_agent": "./src/agents/my-agent.ts:agent" + "deep_agent_ide": "./src/agents/my-agent.ts:agent" }, "env": ".env", "http": { @@ -523,7 +520,7 @@ the LangGraph platform to serve your custom routes alongside the default ones: ```json { "graphs": { - "coding_agent": "./src/agents/my_agent.py:agent" + "deep_agent_ide": "./src/agents/my_agent.py:agent" }, "env": ".env", "http": { @@ -570,7 +567,7 @@ function IDEPreview() { const stream = useStream({ apiUrl: AGENT_URL, - assistantId: "coding_agent", + assistantId: "deep_agent_ide", threadId, onThreadId: updateThreadId, }); @@ -592,7 +589,6 @@ fresh thread (and sandbox): ```tsx function handleNewThread() { - stream.switchThread(null); updateThreadId(null); } ``` @@ -608,7 +604,7 @@ const AGENT_URL = "http://localhost:2024"; async function fetchTree(threadId: string): Promise { const res = await fetch( - `${AGENT_URL}/api/sandbox/${encodeURIComponent(threadId)}/tree?filePath=/app`, + `${AGENT_URL}/sandbox/${encodeURIComponent(threadId)}/tree?filePath=/app`, ); const data = await res.json(); return data.entries.filter((e: FileEntry) => !e.path.includes("node_modules")); @@ -616,7 +612,7 @@ async function fetchTree(threadId: string): Promise { async function fetchFile(threadId: string, path: string): Promise { const res = await fetch( - `${AGENT_URL}/api/sandbox/${encodeURIComponent(threadId)}/file?filePath=${encodeURIComponent(path)}`, + `${AGENT_URL}/sandbox/${encodeURIComponent(threadId)}/file?filePath=${encodeURIComponent(path)}`, ); const data = await res.json(); return data.content ?? null; @@ -641,7 +637,7 @@ const FILE_MUTATING_TOOLS = new Set(["write_file", "edit_file", "execute"]); export function IDEPreview() { const stream = useStream({ apiUrl: AGENT_URL, - assistantId: "coding_agent", + assistantId: "deep_agent_ide", }); const processedIds = useRef(new Set()); @@ -669,9 +665,9 @@ export function IDEPreview() { processedIds.current.add(id); if (call.name === "write_file" || call.name === "edit_file") { - refreshSingleFile(call.args.path); + refreshSingleFile(call.args.path ?? call.args.file_path); } else if (call.name === "execute") { - refreshAllFiles(); + refreshTreeAndFiles(); } } }, [stream.messages]); @@ -689,7 +685,7 @@ const processedIds = new Set(); const stream = useStream({ apiUrl: AGENT_URL, - assistantId: "coding_agent", + assistantId: "deep_agent_ide", }); watch( @@ -716,16 +712,16 @@ watch( processedIds.add(id); if (call.name === "write_file" || call.name === "edit_file") { - refreshSingleFile(call.args.path); + refreshSingleFile(call.args.path ?? call.args.file_path); } else if (call.name === "execute") { - refreshAllFiles(); + refreshTreeAndFiles(); } } }, { deep: true }, ); -```` +``` ```svelte Svelte ``` @@ -118,26 +149,25 @@ const stream = useStream({ const AGENT_URL = "http://localhost:2024"; - const { messages, getSubagentsByMessage, submit } = useStream({ + const stream = useStream({ apiUrl: AGENT_URL, assistantId: "deep_agent_subagent_cards", - filterSubagentMessages: true, });
- {#each $messages as msg (msg.id)} - + {#each stream.messages as msg (msg.id)} + + {/each} + {#each [...stream.subagents.values()] as subagent (subagent.id)} + {/each}
``` ```ts Angular -import { Component } from "@angular/core"; -import { useStream } from "@langchain/angular"; +import { Component, computed } from "@angular/core"; +import { injectStream } from "@langchain/angular"; const AGENT_URL = "http://localhost:2024"; @@ -145,33 +175,34 @@ const AGENT_URL = "http://localhost:2024"; selector: "app-deep-agent-chat", template: ` @for (msg of stream.messages(); track msg.id) { - + + } + @for (subagent of subagents(); track subagent.id) { + } `, }) export class DeepAgentChatComponent { - stream = useStream({ + stream = injectStream({ apiUrl: AGENT_URL, assistantId: "deep_agent_subagent_cards", - filterSubagentMessages: true, }); + + subagents = computed(() => [...this.stream.subagents().values()]); } ``` -## Submitting with subgraph streaming +## Submitting messages -When submitting a message, enable subgraph streaming and set an appropriate -recursion limit. Deep agent workflows often involve multiple layers of nested -subagraphs, so a higher recursion limit prevents premature termination: +Submit messages through the root stream. Deep agent workflows often involve +multiple layers of nested subgraphs, so set an appropriate recursion limit if +your agent can delegate deeply: ```ts stream.submit( { messages: [{ type: "human", content: text }] }, - { streamSubgraphs: true } + { config: { recursion_limit: 100 } } ); ``` @@ -181,87 +212,71 @@ most multi-expert setups. You can override this via `config.recursion_limit` if needed. -## The SubagentStreamInterface +## The SubagentDiscoverySnapshot -Each subagent exposes a `SubagentStreamInterface` with metadata about the -subagent's task, status, and timing: +Each subagent discovery snapshot carries identity and lifecycle metadata: ```ts -interface SubagentStreamInterface { +interface SubagentDiscoverySnapshot { id: string; + name: string; + namespace: readonly string[]; + parentId: string | null; + depth: number; status: "pending" | "running" | "complete" | "error"; - messages: BaseMessage[]; - result: string | undefined; - toolCall: { - id: string; - name: string; - args: { - description: string; - subagent_type: string; - [key: string]: unknown; - }; - }; - startedAt: number | undefined; - completedAt: number | undefined; + taskInput?: string; + output?: string; + error?: unknown; + startedAt?: number; + completedAt?: number; } ``` | Property | Description | | --- | --- | | `id` | Unique identifier for this subagent instance | +| `name` | The specialist name, such as `"researcher"` or `"analyst"` | +| `namespace` | Namespace used by selector helpers to scope messages and tool calls | +| `parentId` | Parent subagent ID when the specialist was spawned by another subagent | +| `depth` | Nesting depth in the subagent tree | | `status` | Lifecycle state: `pending` → `running` → `complete` or `error` | -| `messages` | The subagent's own message stream, updated in real time | -| `result` | The final output text, available only when `status` is `"complete"` | -| `toolCall` | The tool call that spawned this subagent, including task metadata | -| `toolCall.args.description` | The task description the coordinator assigned to this subagent | -| `toolCall.args.subagent_type` | The type or name of the specialist (e.g., `"researcher"`, `"analyst"`) | -| `startedAt` | Timestamp when the subagent began executing | -| `completedAt` | Timestamp when the subagent finished | - -## Linking subagents to messages - -The `getSubagentsByMessage` method returns the subagents spawned by a specific -AI message. This lets you render subagent cards directly beneath the -coordinator message that triggered them: - -```ts -const turnSubagents = stream.getSubagentsByMessage(msg.id); -``` - -This returns an array of `SubagentStreamInterface` objects. If the message -didn't spawn any subagents, it returns an empty array. +| `taskInput` | Optional task description used to spawn the subagent | +| `output` | Final output captured on the snapshot, when available | +| `error` | Error payload if the subagent failed | +| `startedAt` / `completedAt` | Optional timestamps for duration and ordering | ## Building the SubagentCard -Each subagent card shows the specialist's name, task description, streaming -content or final result, and timing information: +Each subagent card shows the specialist's name, status, streaming content, and +tool calls. Use selector hooks to subscribe to the subagent namespace: ```tsx -import { AIMessage } from "@langchain/core/messages"; +import { useState } from "react"; +import { AIMessage } from "langchain"; +import { + useMessages, + useToolCalls, + type AnyStream, + type SubagentDiscoverySnapshot, +} from "@langchain/react"; function SubagentCard({ + stream, subagent, }: { - subagent: SubagentStreamInterface; + stream: AnyStream; + subagent: SubagentDiscoverySnapshot; }) { const [expanded, setExpanded] = useState(true); + const messages = useMessages(stream, subagent); + const toolCalls = useToolCalls(stream, subagent); - const title = - subagent.toolCall?.args?.subagent_type ?? `Agent ${subagent.id}`; - const description = subagent.toolCall?.args?.description ?? ""; - - const lastAIMessage = subagent.messages + const lastAIMessage = messages .filter(AIMessage.isInstance) .at(-1); const displayContent = - subagent.status === "complete" - ? subagent.result - : typeof lastAIMessage?.content === "string" - ? lastAIMessage.content - : ""; - - const elapsed = getElapsedTime(subagent.startedAt, subagent.completedAt); + lastAIMessage?.text ?? subagent.output ?? ""; return (
@@ -272,14 +287,13 @@ function SubagentCard({
-

{title}

-

{description}

+

{subagent.name}

+

+ {toolCalls.length} tool call{toolCalls.length === 1 ? "" : "s"} +

- {elapsed && ( - {elapsed} - )}
@@ -297,17 +311,6 @@ function SubagentCard({
); } - -function getElapsedTime( - startedAt: number | undefined, - completedAt: number | undefined -): string | null { - if (!startedAt) return null; - const end = completedAt ?? Date.now(); - const seconds = Math.round((end - startedAt) / 1000); - if (seconds < 60) return `${seconds}s`; - return `${Math.floor(seconds / 60)}m ${seconds % 60}s`; -} ``` ## Status icons and badges @@ -315,7 +318,7 @@ function getElapsedTime( Consistent visual indicators help users parse subagent status at a glance: ```tsx -function StatusIcon({ status }: { status: SubagentStreamInterface["status"] }) { +function StatusIcon({ status }: { status: SubagentDiscoverySnapshot["status"] }) { switch (status) { case "pending": return ; @@ -328,7 +331,7 @@ function StatusIcon({ status }: { status: SubagentStreamInterface["status"] }) { } } -function StatusBadge({ status }: { status: SubagentStreamInterface["status"] }) { +function StatusBadge({ status }: { status: SubagentDiscoverySnapshot["status"] }) { const styles = { pending: "bg-gray-100 text-gray-600", running: "bg-blue-100 text-blue-700", @@ -352,7 +355,7 @@ Show a progress bar and counter so users know how many subagents have finished: function SubagentProgress({ subagents, }: { - subagents: SubagentStreamInterface[]; + subagents: SubagentDiscoverySnapshot[]; }) { const completed = subagents.filter((s) => s.status === "complete").length; const total = subagents.length; @@ -379,37 +382,37 @@ function SubagentProgress({ ## Rendering messages with subagent cards -The key layout pattern is to render each coordinator message, and if that message -spawned subagents, render their cards immediately below it: +The key layout pattern is to render coordinator messages from the root stream +and attach subagent cards to the AI message whose tool call spawned them: ```tsx -function MessageWithSubagents({ - message, - subagents, -}: { - message: BaseMessage; - subagents: SubagentStreamInterface[]; -}) { - if (message.type === "human") { - return ; - } +function DeepAgentLayout({ stream }: { stream: AnyStream }) { + const subagents = [...stream.subagents.values()]; + const subagentsByCallId = new Map(subagents.map((s) => [s.id, s])); return (
- {message.content && ( -
- {message.content} -
- )} - - {subagents.length > 0 && ( -
- - {subagents.map((subagent) => ( - - ))} -
- )} + {stream.messages.map((message) => { + const turnSubagents = AIMessage.isInstance(message) + ? (message.tool_calls ?? []) + .map((tc) => subagentsByCallId.get(tc.id ?? "")) + .filter((s): s is SubagentDiscoverySnapshot => !!s) + : []; + + return ( +
+ + {turnSubagents.length > 0 && ( +
+ + {turnSubagents.map((subagent) => ( + + ))} +
+ )} +
+ ); + })}
); } @@ -425,7 +428,7 @@ function SynthesisIndicator({ subagents, isLoading, }: { - subagents: SubagentStreamInterface[]; + subagents: SubagentDiscoverySnapshot[]; isLoading: boolean; }) { const allComplete = @@ -450,13 +453,6 @@ workflows. A clear "Synthesizing results..." indicator prevents users from thinking the agent has stalled. -## Debug unfiltered output - -During development, you can temporarily set `filterSubagentMessages: false` to -see the raw, interleaved output from all subagents in the main message stream. -This is useful for verifying that subagent tokens are flowing correctly, but -should not be used in production UIs. - ## Use cases Deep agent subagent cards are the right choice when your agent workflow @@ -473,8 +469,7 @@ involves: ## Accessing the full subagents map -Beyond per-message lookup, you can access all subagents at once through -`stream.subagents`: +Access all subagents through `stream.subagents`: ```ts const allSubagents = [...stream.subagents.values()]; @@ -483,22 +478,19 @@ const completed = allSubagents.filter((s) => s.status === "complete"); const errors = allSubagents.filter((s) => s.status === "error"); ``` -This is useful for building global progress indicators or dashboards that -summarize all subagent activity regardless of which coordinator message spawned -them. +This is useful for building progress indicators or dashboards that summarize all +subagent activity. ## Best practices -- **Always set `filterSubagentMessages: true`**. Unfiltered streams produce an - unreadable interleaving of coordinator and subagent tokens. -- **Show task descriptions**. The `toolCall.args.description` field tells users - exactly what each subagent was asked to do. Always display this prominently. +- **Mount selectors only where needed**. Scoped messages and tool calls stream + when a card calls `useMessages(stream, subagent)` or `useToolCalls(stream, subagent)`. +- **Show specialist names**. `subagent.name` tells users which worker is active. - **Use collapsible cards**. In workflows with 5+ subagents, auto-collapse completed cards so users can focus on active work. -- **Display timing data**. Showing how long each subagent took helps users - understand performance characteristics and identify bottlenecks. -- **Set an appropriate recursion limit**. Deep agent workflows with nested - subgraphs need higher limits than the default 25. Start with 100. +- **Override recursion only when needed**. Deep Agents sets a high default + recursion limit; pass `config.recursion_limit` only for unusually deep custom + workflows. - **Handle errors per subagent**. One subagent failing shouldn't crash the entire UI. Show the error in that subagent's card while others continue running. diff --git a/src/oss/deepagents/frontend/todo-list.mdx b/src/oss/deepagents/frontend/todo-list.mdx index af0792affe..6667f1f551 100644 --- a/src/oss/deepagents/frontend/todo-list.mdx +++ b/src/oss/deepagents/frontend/todo-list.mdx @@ -42,10 +42,10 @@ read the `todos` from `stream.values`. Define a TypeScript interface matching your agent's state schema and pass it as a type parameter to `useStream` for type-safe access to state values, including custom state keys like `todos`. In the examples below, replace `typeof myAgent` with your interface name: ```ts -import type { BaseMessage } from "@langchain/core/messages"; +import type { BaseMessage } from "langchain"; interface TodoItem { - title: string; + content: string; status: "pending" | "in_progress" | "completed"; description?: string; } @@ -126,17 +126,17 @@ const todos = computed(() => stream.values.value?.todos ?? []); const AGENT_URL = "http://localhost:2024"; - const { messages, values, submit } = useStream({ + const stream = useStream({ apiUrl: AGENT_URL, assistantId: "deep_agent_todo_list", }); - $: todos = $values?.todos ?? []; + const todos = $derived(stream.values?.todos ?? []);
- {#each $messages as msg (msg.id)} + {#each stream.messages as msg (msg.id)} {/each}
@@ -144,7 +144,7 @@ const todos = computed(() => stream.values.value?.todos ?? []); ```ts Angular import { Component, computed } from "@angular/core"; -import { useStream } from "@langchain/angular"; +import { injectStream } from "@langchain/angular"; const AGENT_URL = "http://localhost:2024"; @@ -160,7 +160,7 @@ const AGENT_URL = "http://localhost:2024"; `, }) export class TodoAgentComponent { - stream = useStream({ + stream = injectStream({ apiUrl: AGENT_URL, assistantId: "deep_agent_todo_list", }); diff --git a/src/oss/langchain/frontend/branching-chat.mdx b/src/oss/langchain/frontend/branching-chat.mdx index 1de775715f..db72590a80 100644 --- a/src/oss/langchain/frontend/branching-chat.mdx +++ b/src/oss/langchain/frontend/branching-chat.mdx @@ -1,13 +1,13 @@ --- title: Branching chat -description: Edit messages, regenerate responses, and navigate conversation branches +description: Edit messages and regenerate responses by forking from checkpoints --- Conversations with AI agents are rarely linear. You may want to rephrase a -question, regenerate a response you didn't like, or explore a completely -different conversational path without losing your previous work. Branching chat -brings version-control semantics to your chat UI. Every edit creates a new -branch, and you can freely navigate between them. +question, regenerate a response you didn't like, or explore a different +conversational path without losing the checkpoint history. Branching chat uses +LangGraph checkpoints as fork points: every edit or regeneration submits a new +run from the selected message's parent checkpoint. import { PatternEmbed } from "/snippets/pattern-embed.jsx" @@ -19,24 +19,22 @@ import RequiresLanggraphServer from '/snippets/oss/requires-langgraph-server.mdx ## What is branching chat? -Branching chat treats a conversation as a tree rather than a list. Each message -is a node, and editing a message or regenerating a response creates a **fork** -from that point. The original path is preserved as a sibling branch, so users -can switch back and forth between different conversation trajectories. +Branching chat treats a conversation as a checkpointed timeline rather than a +flat list. Each message has metadata that points to the checkpoint before that +message was created. Editing a message or regenerating a response submits a new +run from that checkpoint. Key capabilities: -- **Edit any user message:** rewrite a previous prompt and re-run the agent - from that point -- **Regenerate any AI response:** ask the agent to produce a different answer - for the same input -- **Navigate branches:** switch between different versions of the conversation - using per-message branch controls +- **Edit any user message:** rewrite a previous prompt and re-run the agent from that point +- **Regenerate any AI response:** ask the agent to produce a different answer for the same input +- **Inspect history:** use the LangGraph client to load checkpoints when you need a branch timeline -## Set up useStream with history +## Set up stream metadata -To enable branching, pass `fetchStateHistory: true` so that `useStream` -retrieves checkpoint metadata needed for branch operations. +Use the root stream for messages, then read per-message checkpoint metadata in +the component that renders each message. The metadata includes the parent +checkpoint ID to fork from. :::python @@ -71,25 +69,14 @@ const AGENT_URL = "http://localhost:2024"; export function Chat() { const stream = useStream({ apiUrl: AGENT_URL, - assistantId: "branching_chat", - fetchStateHistory: true, + assistantId: "simple_agent", }); return (
- {stream.messages.map((msg) => { - const metadata = stream.getMessagesMetadata(msg); - return ( - handleEdit(stream, msg, metadata, text)} - onRegenerate={() => handleRegenerate(stream, metadata)} - onBranchSwitch={(id) => stream.setBranch(id)} - /> - ); - })} + {stream.messages.map((msg) => ( + + ))}
); } @@ -103,38 +90,17 @@ const AGENT_URL = "http://localhost:2024"; const stream = useStream({ apiUrl: AGENT_URL, - assistantId: "branching_chat", - fetchStateHistory: true, + assistantId: "simple_agent", }); - -function handleEdit(msg: any, metadata: any, text: string) { - const checkpoint = metadata.firstSeenState?.parent_checkpoint; - if (!checkpoint) return; - - stream.submit( - { messages: [{ ...msg, content: text }] }, - { checkpoint } - ); -} - -function handleRegenerate(metadata: any) { - const checkpoint = metadata.firstSeenState?.parent_checkpoint; - if (!checkpoint) return; - - stream.submit(undefined, { checkpoint }); -} @@ -146,39 +112,17 @@ function handleRegenerate(metadata: any) { const AGENT_URL = "http://localhost:2024"; - const { messages, getMessagesMetadata, setBranch, submit } = useStream({ + const stream = useStream({ apiUrl: AGENT_URL, - assistantId: "branching_chat", - fetchStateHistory: true, + assistantId: "simple_agent", }); - - function handleEdit(msg: any, metadata: any, text: string) { - const checkpoint = metadata.firstSeenState?.parent_checkpoint; - if (!checkpoint) return; - - submit( - { messages: [{ ...msg, content: text }] }, - { checkpoint } - ); - } - - function handleRegenerate(metadata: any) { - const checkpoint = metadata.firstSeenState?.parent_checkpoint; - if (!checkpoint) return; - - submit(undefined, { checkpoint }); - }
- {#each $messages as msg (msg.id)} - {@const metadata = getMessagesMetadata(msg)} + {#each stream.messages as msg (msg.id)} handleEdit(msg, metadata, text)} - onRegenerate={() => handleRegenerate(metadata)} - onBranchSwitch={(id) => setBranch(id)} + {stream} /> {/each}
@@ -186,7 +130,7 @@ function handleRegenerate(metadata: any) { ```ts Angular import { Component } from "@angular/core"; -import { useStream } from "@langchain/angular"; +import { injectStream } from "@langchain/angular"; const AGENT_URL = "http://localhost:2024"; @@ -196,122 +140,88 @@ const AGENT_URL = "http://localhost:2024"; @for (msg of stream.messages(); track msg.id) { } `, }) export class ChatComponent { - stream = useStream({ + stream = injectStream({ apiUrl: AGENT_URL, - assistantId: "branching_chat", - fetchStateHistory: true, + assistantId: "simple_agent", }); - - handleEdit(msg: any, metadata: any, text: string) { - const checkpoint = metadata.firstSeenState?.parent_checkpoint; - if (!checkpoint) return; - - this.stream.submit( - { messages: [{ ...msg, content: text }] }, - { checkpoint } - ); - } - - handleRegenerate(metadata: any) { - const checkpoint = metadata.firstSeenState?.parent_checkpoint; - if (!checkpoint) return; - - this.stream.submit(undefined, { checkpoint }); - } } ``` ## Understand message metadata -The `getMessagesMetadata(msg)` function returns branch information for each -message: +The `useMessageMetadata(stream, messageId)` helper returns checkpoint metadata +for one message: ```ts interface MessageMetadata { - branch: string; - branchOptions: string[]; - firstSeenState: { - parent_checkpoint: Checkpoint | null; - }; + parentCheckpointId?: string; } ``` | Property | Description | | --- | --- | -| `branch` | The branch ID of this specific message version | -| `branchOptions` | Array of all branch IDs available for this message position | -| `firstSeenState.parent_checkpoint` | The checkpoint just before this message. Use it as the fork point for edits and regenerations | - -When a message has only one version, `branchOptions` contains a single entry. -After an edit or regeneration, new branch IDs are added to `branchOptions`, -and you can navigate between them. +| `parentCheckpointId` | The checkpoint just before this message. Use it as the fork point for edits and regenerations | ## Edit a message -To edit a user message and create a new branch: +To edit a user message and fork the conversation: -1. Get the `parent_checkpoint` from the message's metadata -2. Submit the edited message with that checkpoint -3. The agent re-runs from that point, creating a new branch +1. Get `parentCheckpointId` from the message's metadata +2. Submit the edited message with `forkFrom: { checkpointId }` +3. The agent re-runs from that point ```ts function handleEdit( stream: ReturnType, originalMsg: HumanMessage, - metadata: MessageMetadata, + metadata: MessageMetadata | undefined, newText: string ) { - const checkpoint = metadata.firstSeenState?.parent_checkpoint; - if (!checkpoint) return; + if (!metadata?.parentCheckpointId) return; stream.submit( { - messages: [{ ...originalMsg, content: newText }], + messages: [{ type: "human", content: newText }], }, - { checkpoint } + { forkFrom: { checkpointId: metadata.parentCheckpointId } } ); } ``` After the edit: -- The message's `branchOptions` gains a new entry -- The view switches to the new branch automatically - The agent re-runs from the fork point with the updated message -- The original version is preserved and accessible via the branch switcher +- The original path remains available in the thread history ## Regenerate a response To regenerate an AI response without changing the input: 1. Get the `parent_checkpoint` from the AI message's metadata -2. Submit with `undefined` input and the parent checkpoint -3. The agent produces a fresh response, creating a new branch +2. Submit with empty input and `forkFrom: { checkpointId }` +3. The agent produces a fresh response from that point ```ts function handleRegenerate( stream: ReturnType, - metadata: MessageMetadata + metadata: MessageMetadata | undefined ) { - const checkpoint = metadata.firstSeenState?.parent_checkpoint; - if (!checkpoint) return; + if (!metadata?.parentCheckpointId) return; - stream.submit(undefined, { checkpoint }); + stream.submit(undefined, { + forkFrom: { checkpointId: metadata.parentCheckpointId }, + }); } ``` -Each regeneration creates a new branch for the AI message at that position. -Users can then use the branch switcher to compare different responses. +Each regeneration creates a new path for the AI message at that position. Regeneration is useful for non-deterministic agents. Since LLM outputs vary @@ -319,68 +229,37 @@ with temperature, regenerating the same prompt often produces meaningfully different responses. -## Build a branch switcher +## Show fork controls -When a message has multiple branches, show a compact inline control with the -current version index and navigation arrows: +Render edit controls on user messages and regenerate controls on AI messages. +Both controls use the same message metadata: ```tsx -function BranchSwitcher({ +function ForkControls({ metadata, - onSwitch, + onEdit, + onRegenerate, }: { metadata: MessageMetadata; - onSwitch: (branchId: string) => void; + onEdit: () => void; + onRegenerate: () => void; }) { - const { branch, branchOptions } = metadata; - - if (branchOptions.length <= 1) return null; - - const currentIndex = branchOptions.indexOf(branch); - const hasPrev = currentIndex > 0; - const hasNext = currentIndex < branchOptions.length - 1; + if (!metadata.parentCheckpointId) return null; return ( -
- - - {currentIndex + 1}/{branchOptions.length} - - +
+ +
); } ``` -When the user clicks a branch arrow, call `stream.setBranch(branchId)` to -switch the conversation view to that branch. This is instant since all branch -data is already loaded via `fetchStateHistory: true`. - - -Switching branches affects not only the target message but also all subsequent -messages. If you switch to a different version of message 3, messages 4, 5, 6, -etc. will also update to reflect the conversation that followed that version. - - ## How branching works under the hood LangGraph persists every state transition as a **checkpoint**. When you submit -with a `checkpoint` parameter, the backend forks from that point instead of -appending to the current conversation. The result is a tree structure: +with `forkFrom`, the backend starts a new execution path from that point instead +of appending to the current conversation. The result is a tree structure: ``` User: "What is React?" @@ -394,31 +273,29 @@ User: "Tell me about JSX" (edited from branch A) └─ AI: "JSX is a syntax extension..." ``` -Each branch is an independent path through the conversation tree. Switching -branches updates the displayed messages but does not delete any data. All -branches persist in the checkpoint store. +Each path is persisted in the checkpoint store. Use +`stream.client.threads.getHistory(threadId)` when you want to build a separate +timeline view across checkpoints. ## Complete message component -Here is a full component that combines message display, editing, regeneration, -and branch switching: +Here is a full component that combines message display, editing, and +regeneration: ```tsx function MessageWithBranching({ message, - metadata, stream, }: { message: BaseMessage; - metadata: MessageMetadata; stream: ReturnType; }) { const [isEditing, setIsEditing] = useState(false); - const [editText, setEditText] = useState(message.content as string); + const [editText, setEditText] = useState(message.text); + const metadata = useMessageMetadata(stream, message.id); const isHuman = message._getType() === "human"; const isAI = message._getType() === "ai"; - const hasBranches = metadata.branchOptions.length > 1; return (
@@ -431,7 +308,7 @@ function MessageWithBranching({ setIsEditing(false); }} onCancel={() => { - setEditText(message.content as string); + setEditText(message.text); setIsEditing(false); }} /> @@ -445,7 +322,7 @@ function MessageWithBranching({ : "inline-block rounded-lg bg-gray-100 px-4 py-2" } > - {message.content as string} + {message.text}
@@ -470,12 +347,6 @@ function MessageWithBranching({ )} - {hasBranches && ( - stream.setBranch(id)} - /> - )} )} @@ -521,91 +392,56 @@ function EditForm({ } ``` -## Combine with optimistic updates +## Refresh history after forks -Combine branching with optimistic updates for a seamless editing experience. When the user saves an edit, optimistically show the updated message before the server responds: +If you render a checkpoint timeline, refresh it after an edit or regeneration +settles: ```ts -function handleEditOptimistic( - stream: ReturnType, - originalMsg: HumanMessage, - metadata: MessageMetadata, - newText: string -) { - const checkpoint = metadata.firstSeenState?.parent_checkpoint; - if (!checkpoint) return; - - const updatedMsg = { ...originalMsg, content: newText }; - - stream.submit( - { messages: [updatedMsg] }, - { - checkpoint, - optimisticValues: (prev) => { - if (!prev?.messages) return { messages: [updatedMsg] }; - - const idx = prev.messages.findIndex((m) => m.id === originalMsg.id); - if (idx === -1) return prev; - - return { - ...prev, - messages: [...prev.messages.slice(0, idx), updatedMsg], - }; - }, - } - ); +async function refreshHistory(stream: ReturnType) { + if (!stream.threadId) return []; + return stream.client.threads.getHistory(stream.threadId, { limit: 100 }); } ``` -## Add keyboard navigation +## Add keyboard shortcuts -For power users, add keyboard shortcuts to navigate branches: +Keyboard shortcuts are useful for power users. Bind them to the same edit and +regenerate handlers you expose in the message controls: ```tsx useEffect(() => { function handleKeyDown(e: KeyboardEvent) { - if (!focusedMessageMetadata) return; - - const { branch, branchOptions } = focusedMessageMetadata; - const idx = branchOptions.indexOf(branch); - - if (e.altKey && e.key === "ArrowLeft" && idx > 0) { - stream.setBranch(branchOptions[idx - 1]); + if (!focusedMessage) return; + if (e.altKey && e.key === "e") { + startEditing(focusedMessage); } - if (e.altKey && e.key === "ArrowRight" && idx < branchOptions.length - 1) { - stream.setBranch(branchOptions[idx + 1]); + if (e.altKey && e.key === "r") { + regenerate(focusedMessage); } } window.addEventListener("keydown", handleKeyDown); return () => window.removeEventListener("keydown", handleKeyDown); -}, [focusedMessageMetadata, stream]); +}, [focusedMessage, stream]); ``` -`Alt + ←` / `Alt + →` is a natural mapping for branch navigation since it -mirrors browser back/forward navigation. +Keep shortcuts secondary to visible controls so the pattern remains discoverable. ## Best practices -- **Always enable `fetchStateHistory`**: without it, `getMessagesMetadata` - cannot return branch information. -- **Only show the branch switcher when there are multiple branches**: a - `1/1` indicator adds clutter without value. -- **Show branch controls on hover**: branch navigation arrows and edit buttons - should appear on hover to keep the UI clean. -- **Keep the branch switcher compact**: it sits inline with message controls - and should not dominate the UI. -- **Preserve scroll position**: when switching branches, try to keep the - viewport anchored to the message that changed. -- **Indicate the active branch**: use subtle visual cues (e.g., a colored dot - or branch label) so users know which branch they're viewing. +- **Read metadata near the message**: call `useMessageMetadata` in the component + that renders the message controls. +- **Show fork controls on hover**: edit and regenerate buttons should appear on + hover to keep the UI clean. +- **Refresh history on demand**: call `client.threads.getHistory()` only when + rendering a timeline or after a fork settles. - **Disable controls while streaming**: don't allow edits or regeneration while the agent is actively streaming a response. Check `stream.isLoading` before enabling these actions. - **Preserve edit text on cancel**: if the user starts editing, then cancels, reset the textarea to the original message content. -- **Test with deep branch trees**: users who edit and regenerate frequently - can create many branches. Ensure the branch switcher and data handling - remain performant. +- **Test with deep checkpoint trees**: users who edit and regenerate frequently + can create many paths. Ensure timeline rendering remains performant. diff --git a/src/oss/langchain/frontend/generative-ui.mdx b/src/oss/langchain/frontend/generative-ui.mdx index 8aa779d5f4..7108967222 100644 --- a/src/oss/langchain/frontend/generative-ui.mdx +++ b/src/oss/langchain/frontend/generative-ui.mdx @@ -46,6 +46,13 @@ const catalog = defineCatalog(schema, { padding: z.enum(["sm", "md", "lg"]).optional(), }), }, + Stack: { + description: "Layout children vertically or horizontally with consistent spacing", + props: z.object({ + direction: z.enum(["vertical", "horizontal"]).optional(), + gap: z.enum(["sm", "md", "lg"]).optional(), + }), + }, TextInput: { description: "A text input field with optional label and placeholder", props: z.object({ @@ -91,6 +98,11 @@ const { registry } = defineRegistry(catalog, { {children} ), + Stack: ({ props, children }) => ( +
+ {children} +
+ ), TextInput: ({ props }) => (
{props.label && } @@ -118,6 +130,8 @@ const { registry } = defineRegistry(catalog, { props.title ? h("h2", null, props.title) : null, children, ]), + Stack: ({ props, children }) => + h("div", { class: `stack stack-${props.direction ?? "vertical"} gap-${props.gap ?? "md"}` }, children), TextInput: ({ props }) => h("div", null, [ props.label ? h("label", null, props.label) : null, @@ -142,7 +156,7 @@ with your agent's assistant ID, then extract the spec from the AI message's ```tsx React import { useStream } from "@langchain/react"; -import { AIMessage } from "@langchain/core/messages"; +import { AIMessage } from "langchain"; function GenerativeUI() { const stream = useStream({ @@ -160,7 +174,7 @@ function GenerativeUI() { ```vue Vue ``` ```ts Angular import { Component } from "@angular/core"; -import { useStream } from "@langchain/angular"; -import { AIMessage } from "@langchain/core/messages"; +import { injectStream } from "@langchain/angular"; +import { AIMessage } from "langchain"; @Component({ selector: "app-generative-ui", template: `...`, }) export class GenerativeUIComponent { - stream = useStream({ + stream = injectStream({ apiUrl: "http://localhost:2024", assistantId: "generative_ui", }); diff --git a/src/oss/langchain/frontend/human-in-the-loop.mdx b/src/oss/langchain/frontend/human-in-the-loop.mdx index 3acf133ece..be6668394f 100644 --- a/src/oss/langchain/frontend/human-in-the-loop.mdx +++ b/src/oss/langchain/frontend/human-in-the-loop.mdx @@ -121,30 +121,30 @@ function handleRespond(response: HITLResponse) { const AGENT_URL = "http://localhost:2024"; - const { messages, interrupt, submit } = useStream({ + const stream = useStream({ apiUrl: AGENT_URL, assistantId: "human_in_the_loop", }); function handleRespond(response: HITLResponse) { - submit(null, { command: { resume: response } }); + stream.submit(null, { command: { resume: response } }); }
- {#each $messages as msg (msg.id)} + {#each stream.messages as msg (msg.id)} {/each} - {#if $interrupt} - + {#if stream.interrupt} + {/if}
``` ```ts Angular import { Component } from "@angular/core"; -import { useStream } from "@langchain/angular"; +import { injectStream } from "@langchain/angular"; import type { HITLResponse } from "langchain"; const AGENT_URL = "http://localhost:2024"; @@ -164,7 +164,7 @@ const AGENT_URL = "http://localhost:2024"; `, }) export class ChatComponent { - stream = useStream({ + stream = injectStream({ apiUrl: AGENT_URL, assistantId: "human_in_the_loop", }); @@ -188,7 +188,7 @@ interface HITLRequest { } interface ActionRequest { - action: string; + name: string; args: Record; description?: string; } @@ -201,7 +201,7 @@ interface ReviewConfig { | Property | Description | | --- | --- | | `actionRequests` | Array of pending actions the agent wants to perform | -| `actionRequests[].action` | The action name (e.g. `"send_email"`, `"delete_record"`) | +| `actionRequests[].name` | The action name (e.g. `"send_email"`, `"delete_record"`) | | `actionRequests[].args` | Structured arguments for the action | | `actionRequests[].description` | Optional human-readable description of what the action does | | `reviewConfigs` | Per-action configuration controlling which decisions are allowed | @@ -217,7 +217,7 @@ The user confirms the action should proceed as-is: ```ts const response: HITLResponse = { - decision: "approve", + decisions: [{ type: "approve" }], }; stream.submit(null, { command: { resume: response } }); @@ -229,8 +229,12 @@ The user denies the action with an optional reason: ```ts const response: HITLResponse = { - decision: "reject", - reason: "The email tone is too aggressive. Please revise.", + decisions: [ + { + type: "reject", + message: "The email tone is too aggressive. Please revise.", + }, + ], }; stream.submit(null, { command: { resume: response } }); @@ -248,12 +252,19 @@ The user modifies the action's arguments before approving: ```ts const response: HITLResponse = { - decision: "edit", - args: { - ...originalArgs, - subject: "Updated subject line", - body: "Revised email body with softer language.", - }, + decisions: [ + { + type: "edit", + editedAction: { + name: actionRequest.name, + args: { + ...actionRequest.args, + subject: "Updated subject line", + body: "Revised email body with softer language.", + }, + }, + }, + ], }; stream.submit(null, { command: { resume: response } }); @@ -265,8 +276,7 @@ The user provides a direct reply for "ask user" style tools. The `message` becom ```ts const response: HITLResponse = { - decision: "respond", - message: "Blue.", + decisions: [{ type: "respond", message: "Blue." }], }; stream.submit(null, { command: { resume: response } }); @@ -278,140 +288,42 @@ Use `respond` when the tool is intentionally a placeholder for human input — f ## Building the ApprovalCard -Here is a full approval card component that handles all four decision types: +Here is the decision wiring used by the approval cards. The UI can split each +action into its own card, but the resume payload is a single `HITLResponse` +with one decision per pending action: ```tsx -function ApprovalCard({ - interrupt, - onRespond, -}: { - interrupt: { value: HITLRequest }; - onRespond: (response: HITLResponse) => void; -}) { - const request = interrupt.value; - const [editedArgs, setEditedArgs] = useState( - request.actionRequests[0]?.args ?? {} - ); - const [rejectReason, setRejectReason] = useState(""); - const [respondMessage, setRespondMessage] = useState(""); - const [mode, setMode] = useState<"review" | "edit" | "reject" | "respond">("review"); - - const action = request.actionRequests[0]; - const config = request.reviewConfigs[0]; - - if (!action || !config) return null; - - return ( -
-

Action Review Required

-

- {action.description ?? `The agent wants to perform: ${action.action}`} -

- -
-
{JSON.stringify(action.args, null, 2)}
-
- - {mode === "review" && ( -
- {config.allowedDecisions.includes("approve") && ( - - )} - {config.allowedDecisions.includes("reject") && ( - - )} - {config.allowedDecisions.includes("edit") && ( - - )} - {config.allowedDecisions.includes("respond") && ( - - )} -
- )} - - {mode === "reject" && ( -
-