UNPKG

@langchain/langgraph-sdk

Version:

Client library for interacting with the LangGraph API

647 lines (646 loc) • 23 kB
import { MessageTupleManager, toMessageDict } from "./messages.js"; import { getToolCallsWithResults } from "../utils/tools.js"; //#region src/ui/subagents.ts /** * Default tool names that indicate subagent invocation. * Can be customized via SubagentManager options. */ const DEFAULT_SUBAGENT_TOOL_NAMES = ["task"]; /** * Checks if a namespace indicates a subagent/subgraph message. * * Subagent namespaces contain a "tools:" segment indicating they * originate from a tool call that spawned a subgraph. * * @param namespace - The namespace array from stream events (or checkpoint_ns string) * @returns True if this is a subagent namespace */ function isSubagentNamespace(namespace) { if (!namespace) return false; if (typeof namespace === "string") return namespace.includes("tools:"); return namespace.some((s) => s.startsWith("tools:")); } /** * Extracts the tool call ID from a namespace path. * * Namespaces follow the pattern: ["tools:call_abc123", "model_request:xyz", ...] * This function extracts "call_abc123" from the first "tools:" segment. * * @param namespace - The namespace array from stream events * @returns The tool call ID, or undefined if not found */ function extractToolCallIdFromNamespace(namespace) { if (!namespace || namespace.length === 0) return void 0; for (const segment of namespace) if (segment.startsWith("tools:")) return segment.slice(6); } /** * Calculates the depth of a subagent based on its namespace. * Counts the number of "tools:" segments in the namespace. * * @param namespace - The namespace array * @returns The depth (0 for main agent, 1+ for subagents) */ function calculateDepthFromNamespace(namespace) { if (!namespace) return 0; return namespace.filter((s) => s.startsWith("tools:")).length; } /** * Extracts the parent tool call ID from a namespace. * * For nested subagents, the namespace looks like: * ["tools:parent_id", "tools:child_id", ...] * * @param namespace - The namespace array * @returns The parent tool call ID, or null if this is a top-level subagent */ function extractParentIdFromNamespace(namespace) { if (!namespace || namespace.length < 2) return null; const toolSegments = namespace.filter((s) => s.startsWith("tools:")); if (toolSegments.length < 2) return null; return toolSegments[toolSegments.length - 2]?.slice(6) ?? null; } /** * Manages subagent execution state. * * Tracks subagents from the moment they are invoked (AI message with tool calls) * through streaming to completion (tool message result). */ var SubagentManager = class { subagents = /* @__PURE__ */ new Map(); /** * Maps namespace IDs (pregel task IDs) to tool call IDs. * LangGraph subgraphs use internal pregel task IDs in their namespace, * which are different from the tool_call_id used to invoke them. */ namespaceToToolCallId = /* @__PURE__ */ new Map(); /** * Pending namespace matches that couldn't be resolved immediately. * These are retried when new tool calls are registered. */ pendingMatches = /* @__PURE__ */ new Map(); /** * Messages received before we can map a subgraph namespace to the public * subagent tool-call ID. Once the mapping is established, these messages are * replayed so early tool calls are not lost. */ pendingMessages = /* @__PURE__ */ new Map(); /** * Message managers for each subagent. * Uses the same MessageTupleManager as the main stream for proper * message chunk concatenation. */ messageManagers = /* @__PURE__ */ new Map(); subagentToolNames; onSubagentChange; toMessage; constructor(options) { this.subagentToolNames = new Set(options?.subagentToolNames ?? DEFAULT_SUBAGENT_TOOL_NAMES); this.onSubagentChange = options?.onSubagentChange; this.toMessage = options?.toMessage ?? toMessageDict; } /** * Get or create a MessageTupleManager for a subagent. */ getMessageManager(toolCallId) { let manager = this.messageManagers.get(toolCallId); if (!manager) { manager = new MessageTupleManager(); this.messageManagers.set(toolCallId, manager); } return manager; } /** * Get messages for a subagent with proper chunk concatenation. * This mirrors how the main stream handles messages. */ getMessagesForSubagent(toolCallId) { const manager = this.messageManagers.get(toolCallId); if (!manager) return []; const messages = []; for (const entry of Object.values(manager.chunks)) if (entry.chunk) messages.push(this.toMessage(entry.chunk)); return messages; } /** * Buffer a subagent message until we can resolve its namespace to a tool call. */ queuePendingMessage(namespaceId, serialized, metadata) { const pending = this.pendingMessages.get(namespaceId) ?? []; pending.push({ serialized, metadata }); this.pendingMessages.set(namespaceId, pending); } /** * Replay any buffered messages once a namespace has been mapped. */ flushPendingMessages(namespaceId) { const pending = this.pendingMessages.get(namespaceId); if (!pending?.length) return; this.pendingMessages.delete(namespaceId); pending.forEach(({ serialized, metadata }) => { this.addMessageToSubagent(namespaceId, serialized, metadata); }); } /** * Create a complete SubagentStream object with all derived properties. * This ensures consistency with UseStream interface. */ createSubagentStream(base) { const { messages } = base; const allToolCalls = getToolCallsWithResults(messages); return { ...base, isLoading: base.status === "running", toolCalls: allToolCalls, getToolCalls: (message) => { return allToolCalls.filter((tc) => tc.aiMessage.id === message.id); }, interrupt: void 0, interrupts: [], switchThread: () => {}, subagents: /* @__PURE__ */ new Map(), activeSubagents: [], getSubagent: () => void 0, getSubagentsByType: () => [], getSubagentsByMessage: () => [] }; } /** * Get the tool call ID for a given namespace ID. * Returns the namespace ID itself if no mapping exists. */ getToolCallIdFromNamespace(namespaceId) { return this.namespaceToToolCallId.get(namespaceId) ?? namespaceId; } /** * Try to match a subgraph to a pending subagent by description. * Creates a mapping from namespace ID to tool call ID if a match is found. * * Uses a multi-pass matching strategy: * 1. Exact description match * 2. Description contains/partial match * 3. Any unmapped pending subagent (fallback) * * @param namespaceId - The namespace ID (pregel task ID) from the subgraph * @param description - The description from the subgraph's initial message * @returns The matched tool call ID, or undefined if no match */ matchSubgraphToSubagent(namespaceId, description) { if (this.namespaceToToolCallId.has(namespaceId)) return this.namespaceToToolCallId.get(namespaceId); const mappedToolCallIds = new Set(this.namespaceToToolCallId.values()); const establishMapping = (toolCallId) => { this.namespaceToToolCallId.set(namespaceId, toolCallId); const subagent = this.subagents.get(toolCallId); if (subagent && subagent.status === "pending") { this.subagents.set(toolCallId, { ...subagent, status: "running", namespace: [namespaceId], startedAt: /* @__PURE__ */ new Date() }); this.onSubagentChange?.(); } this.flushPendingMessages(namespaceId); return toolCallId; }; for (const [toolCallId, subagent] of this.subagents) if ((subagent.status === "pending" || subagent.status === "running") && !mappedToolCallIds.has(toolCallId) && subagent.toolCall.args.description === description) return establishMapping(toolCallId); for (const [toolCallId, subagent] of this.subagents) if ((subagent.status === "pending" || subagent.status === "running") && !mappedToolCallIds.has(toolCallId)) { const subagentDesc = subagent.toolCall.args.description || ""; if (subagentDesc && description.includes(subagentDesc) || subagentDesc && subagentDesc.includes(description)) { if (description.length > subagentDesc.length) this.subagents.set(toolCallId, { ...subagent, toolCall: { ...subagent.toolCall, args: { ...subagent.toolCall.args, description } } }); return establishMapping(toolCallId); } } if (description) this.pendingMatches.set(namespaceId, description); } /** * Check if a tool call is a subagent invocation. */ isSubagentToolCall(toolName) { return this.subagentToolNames.has(toolName); } /** * Check if a subagent_type value is valid. * Valid types are proper identifiers like "weather-scout", "experience-curator". */ isValidSubagentType(type) { if (!type || typeof type !== "string") return false; if (type.length < 3) return false; if (!/^[a-zA-Z][a-zA-Z0-9_-]*$/.test(type)) return false; if (type.length > 50) return false; return true; } /** * Check if a subagent should be exposed through the public API. * * We expose subagents as soon as their parent AI message emits a complete * subagent tool call, which means pending subagents should be visible. * * This still filters out corrupted or partial streaming artifacts by * requiring a valid-looking `subagent_type`. */ isValidSubagent(subagent) { return this.isValidSubagentType(subagent.toolCall.args.subagent_type); } /** * Build a complete SubagentStream from internal state. * Adds messages and derived properties. */ buildExecution(base) { const streamingMessages = this.getMessagesForSubagent(base.id); const messages = streamingMessages.length > 0 ? streamingMessages : base.messages; return this.createSubagentStream({ ...base, messages }); } /** * Get all subagents as a Map. * Filters out incomplete/phantom subagents that lack subagent_type. */ getSubagents() { const result = /* @__PURE__ */ new Map(); for (const [id, subagent] of this.subagents) if (this.isValidSubagent(subagent)) result.set(id, this.buildExecution(subagent)); return result; } /** * Get all currently running subagents. * Filters out incomplete/phantom subagents. */ getActiveSubagents() { return [...this.subagents.values()].filter((s) => s.status === "running" && this.isValidSubagent(s)).map((s) => this.buildExecution(s)); } /** * Get a specific subagent by tool call ID. */ getSubagent(toolCallId) { const subagent = this.subagents.get(toolCallId); return subagent ? this.buildExecution(subagent) : void 0; } /** * Get all subagents of a specific type. */ getSubagentsByType(type) { return [...this.subagents.values()].filter((s) => s.toolCall.args.subagent_type === type).map((s) => this.buildExecution(s)); } /** * Get all subagents triggered by a specific AI message. * * @param messageId - The ID of the AI message. * @returns Array of subagent streams triggered by that message. */ getSubagentsByMessage(messageId) { return [...this.subagents.values()].filter((s) => s.aiMessageId === messageId && this.isValidSubagent(s)).map((s) => this.buildExecution(s)); } /** * Parse tool call args, handling both object and string formats. * During streaming, args might come as a string that needs parsing. */ parseArgs(args) { if (!args) return {}; if (typeof args === "string") try { return JSON.parse(args); } catch { return {}; } return args; } /** * Register new subagent(s) from AI message tool calls. * * Called when an AI message is received with tool calls. * Creates pending subagent entries for each subagent tool call. * * @param toolCalls - The tool calls from an AI message * @param aiMessageId - The ID of the AI message that triggered the tool calls */ registerFromToolCalls(toolCalls, aiMessageId) { let hasChanges = false; for (const toolCall of toolCalls) { if (!toolCall.id) continue; if (!this.isSubagentToolCall(toolCall.name)) continue; const parsedArgs = this.parseArgs(toolCall.args); const hasValidType = this.isValidSubagentType(parsedArgs.subagent_type); const existing = this.subagents.get(toolCall.id); if (existing) { const newType = parsedArgs.subagent_type || ""; const oldType = existing.toolCall.args.subagent_type || ""; const newDesc = parsedArgs.description || ""; const oldDesc = existing.toolCall.args.description || ""; const shouldUpdateType = this.isValidSubagentType(newType) && newType.length > oldType.length; const shouldUpdateDesc = newDesc.length > oldDesc.length; const shouldUpdateMessageId = aiMessageId != null && aiMessageId !== existing.aiMessageId; if (shouldUpdateType || shouldUpdateDesc || shouldUpdateMessageId) { this.subagents.set(toolCall.id, { ...existing, ...shouldUpdateMessageId ? { aiMessageId } : {}, toolCall: { ...existing.toolCall, args: { ...existing.toolCall.args, ...parsedArgs, description: shouldUpdateDesc ? newDesc : oldDesc, subagent_type: shouldUpdateType ? newType : oldType } } }); hasChanges = true; } continue; } if (!hasValidType) continue; const subagentToolCall = { id: toolCall.id, name: toolCall.name, args: { description: parsedArgs.description, subagent_type: parsedArgs.subagent_type, ...parsedArgs } }; const execution = { id: toolCall.id, toolCall: subagentToolCall, status: "pending", values: {}, result: null, error: null, namespace: [], messages: [], aiMessageId: aiMessageId ?? null, parentId: null, depth: 0, startedAt: null, completedAt: null }; this.subagents.set(toolCall.id, execution); this.getMessageManager(toolCall.id); this.flushPendingMessages(toolCall.id); hasChanges = true; } if (hasChanges) { this.retryPendingMatches(); this.onSubagentChange?.(); } } /** * Retry matching pending namespaces to newly registered tool calls. */ retryPendingMatches() { if (this.pendingMatches.size === 0) return; for (const [namespaceId, description] of this.pendingMatches) { if (this.namespaceToToolCallId.has(namespaceId)) { this.pendingMatches.delete(namespaceId); continue; } if (this.matchSubgraphToSubagent(namespaceId, description)) this.pendingMatches.delete(namespaceId); } } /** * Mark a subagent as running and update its namespace. * * Called when update events are received with a namespace indicating * which subagent is streaming. * * @param toolCallId - The tool call ID of the subagent * @param options - Additional update options */ markRunning(toolCallId, options) { const existing = this.subagents.get(toolCallId); if (!existing) return; const namespace = options?.namespace ?? existing.namespace; this.subagents.set(toolCallId, { ...existing, status: "running", namespace, parentId: existing.parentId ?? extractParentIdFromNamespace(namespace) ?? null, depth: existing.depth || calculateDepthFromNamespace(namespace), startedAt: existing.startedAt ?? /* @__PURE__ */ new Date() }); this.onSubagentChange?.(); } /** * Mark a subagent as running using a namespace ID. * Resolves the namespace ID to the actual tool call ID via the mapping. * * @param namespaceId - The namespace ID (pregel task ID) from the subgraph * @param namespace - The full namespace array */ markRunningFromNamespace(namespaceId, namespace) { const toolCallId = this.getToolCallIdFromNamespace(namespaceId); this.markRunning(toolCallId, { namespace }); } /** * Add a serialized message to a subagent from stream events. * * This method handles the raw serialized message data from SSE events. * Uses MessageTupleManager for proper chunk concatenation, matching * how the main stream handles messages. * * @param namespaceId - The namespace ID (pregel task ID) from the stream * @param serialized - The serialized message from the stream * @param metadata - Optional metadata from the stream event */ addMessageToSubagent(namespaceId, serialized, metadata) { if (serialized.type === "human" && typeof serialized.content === "string") this.matchSubgraphToSubagent(namespaceId, serialized.content); const toolCallId = this.getToolCallIdFromNamespace(namespaceId); const existing = this.subagents.get(toolCallId); if (!existing) { this.queuePendingMessage(namespaceId, serialized, metadata); return; } if (this.getMessageManager(toolCallId).add(serialized, metadata)) if (serialized.type === "ai") this.subagents.set(toolCallId, { ...existing, status: "running", startedAt: existing.startedAt ?? /* @__PURE__ */ new Date(), messages: this.getMessagesForSubagent(toolCallId) }); else this.subagents.set(toolCallId, { ...existing, messages: this.getMessagesForSubagent(toolCallId) }); this.onSubagentChange?.(); } /** * Update subagent values from a values stream event. * * Called when a values event is received from a subagent's namespace. * This populates the subagent's state values, making them accessible * via the `values` property. * * @param namespaceId - The namespace ID (pregel task ID) from the stream * @param values - The state values from the stream event */ updateSubagentValues(namespaceId, values) { const toolCallId = this.getToolCallIdFromNamespace(namespaceId); const existing = this.subagents.get(toolCallId); if (!existing) return; this.subagents.set(toolCallId, { ...existing, values, status: existing.status === "pending" ? "running" : existing.status, startedAt: existing.startedAt ?? /* @__PURE__ */ new Date() }); this.onSubagentChange?.(); } /** * Complete a subagent with a result. * * Called when a tool message is received for the subagent. * * @param toolCallId - The tool call ID of the subagent * @param result - The result content * @param status - The final status (complete or error) */ complete(toolCallId, result, status = "complete") { const existing = this.subagents.get(toolCallId); if (!existing) return; this.subagents.set(toolCallId, { ...existing, status, result: status === "complete" ? result : null, error: status === "error" ? result : null, completedAt: /* @__PURE__ */ new Date() }); this.onSubagentChange?.(); } /** * Clear all subagent state. */ clear() { this.subagents.clear(); this.namespaceToToolCallId.clear(); this.messageManagers.clear(); this.pendingMatches.clear(); this.pendingMessages.clear(); this.onSubagentChange?.(); } /** * Process a tool message to complete a subagent. * * @param toolCallId - The tool call ID from the tool message * @param content - The result content * @param status - Whether the tool execution was successful */ processToolMessage(toolCallId, content, status = "success") { if (!this.subagents.get(toolCallId)) return; this.complete(toolCallId, content, status === "success" ? "complete" : "error"); } /** * Reconstruct subagent state from historical messages. * * This method parses an array of messages (typically from thread history) * to identify subagent executions and their results. It's used to restore * subagent state after: * - Page refresh (when stream has already completed) * - Loading thread history * - Navigating between threads * * The reconstruction process: * 1. Find AI messages with tool calls matching subagent tool names * 2. Find corresponding tool messages with results * 3. Create SubagentStream entries with "complete" status * * Note: Internal subagent messages (their streaming conversation) are not * reconstructed since they are not persisted in the main thread state. * * @param messages - Array of messages from thread history * @param options - Optional configuration * @param options.skipIfPopulated - If true, skip reconstruction if subagents already exist */ reconstructFromMessages(messages, options) { if (options?.skipIfPopulated && this.subagents.size > 0) return; const toolResults = /* @__PURE__ */ new Map(); for (const message of messages) if (message.type === "tool" && "tool_call_id" in message) { const toolCallId = message.tool_call_id; const content = typeof message.content === "string" ? message.content : JSON.stringify(message.content); const status = "status" in message && message.status === "error" ? "error" : "success"; toolResults.set(toolCallId, { content, status }); } let hasChanges = false; for (const message of messages) { if (message.type !== "ai" || !("tool_calls" in message) || !Array.isArray(message.tool_calls)) continue; for (const toolCall of message.tool_calls) { if (!toolCall.id) continue; if (!this.isSubagentToolCall(toolCall.name)) continue; if (this.subagents.has(toolCall.id)) continue; const parsedArgs = this.parseArgs(toolCall.args); if (!this.isValidSubagentType(parsedArgs.subagent_type)) continue; const subagentToolCall = { id: toolCall.id, name: toolCall.name, args: { description: parsedArgs.description, subagent_type: parsedArgs.subagent_type, ...parsedArgs } }; const toolResult = toolResults.get(toolCall.id); const isComplete = !!toolResult; const status = isComplete ? toolResult.status === "error" ? "error" : "complete" : "running"; const execution = { id: toolCall.id, toolCall: subagentToolCall, status, values: {}, result: isComplete && status === "complete" ? toolResult.content : null, error: isComplete && status === "error" ? toolResult.content : null, namespace: [], messages: [], aiMessageId: message.id ?? null, parentId: null, depth: 0, startedAt: null, completedAt: isComplete ? /* @__PURE__ */ new Date() : null }; this.subagents.set(toolCall.id, execution); hasChanges = true; } } if (hasChanges) this.onSubagentChange?.(); } /** * Update a reconstructed subagent's messages and values from its subgraph checkpoint state. * * This is called after fetching the subgraph's history to restore the internal * conversation that was lost on page refresh. Only updates if messages are * currently empty (does not overwrite live streaming data). * * @param toolCallId - The tool call ID identifying the subagent * @param messages - Messages from the subgraph's latest checkpoint * @param values - Full state values from the subgraph's latest checkpoint * @returns true if the subagent was updated, false otherwise */ updateSubagentFromSubgraphState(toolCallId, messages, values) { const subagent = this.subagents.get(toolCallId); if (!subagent) return false; if (subagent.messages.length > 0) return false; if (messages.length === 0) return false; subagent.messages = messages; if (values != null) subagent.values = values; this.onSubagentChange?.(); return true; } /** * Check if any subagents are currently tracked. */ hasSubagents() { return this.subagents.size > 0; } }; //#endregion export { SubagentManager, calculateDepthFromNamespace, extractParentIdFromNamespace, extractToolCallIdFromNamespace, isSubagentNamespace }; //# sourceMappingURL=subagents.js.map