UNPKG

@langchain/langgraph-sdk

Version:

Client library for interacting with the LangGraph API

334 lines (333 loc) • 12.8 kB
import { normalizeAIMessageToolCalls } from "../message-coercion.js"; import { StreamStore } from "../store.js"; import { isConcreteToolNamespace, isRootNamespace, isToolNamespaceSegment, namespaceKey } from "../namespace.js"; //#region src/stream/discovery/subagents.ts /** Stable empty map — reused on {@link SubagentDiscovery.reset}. */ const EMPTY_SUBAGENT_MAP = /* @__PURE__ */ new Map(); var SubagentDiscovery = class { store = new StreamStore(/* @__PURE__ */ new Map()); #map = /* @__PURE__ */ new Map(); #taskIdByObservedNamespace = /* @__PURE__ */ new Map(); #observedOwnNamespaces = /* @__PURE__ */ new Set(); #toolCallIdByTaskInput = /* @__PURE__ */ new Map(); /** Feed a single root event. Non-discovery events are ignored. */ push(event) { if (event.method === "tools") this.#onToolEvent(event); else if (event.method === "values") this.#onValuesEvent(event); } /** * Seed discovery from a checkpoint's root `messages` array (as * returned by `client.threads.getState().values.messages`) so deep * agent cards render on thread refresh without waiting for SSE * replay. * * Drives the existing root `values` path via a synthetic event so it * reuses task discovery + completion marking with no new parsing * logic. Root namespace `[]` keeps namespaces at the default * `tools:<toolCallId>`; the always-on root pump (and {@link * applyExecutionNamespace}) promote them to the execution namespace * later. Idempotent by construction: re-driving root values for * already-discovered tasks is a no-op (the FIFO `taskInput` queue is * only populated on first discovery), so no `snapshot.size` guard is * needed. */ seedFromCheckpointMessages(messages) { if (!Array.isArray(messages) || messages.length === 0) return; this.push({ type: "event", method: "values", params: { namespace: [], timestamp: Date.now(), data: { messages } } }); } /** * Promote a discovered subagent to its execution namespace, derived * from checkpoint history (see `namespace-from-history`). * * Routes through the same guarded promotion machinery the live SSE * replay uses ({@link #recordTaskNamespaceCandidate} + the * `#observedOwnNamespaces` no-demote rule) so a getHistory-derived * namespace and an SSE-derived one cannot disagree. A no-op when the * subagent is unknown or already sits on the target namespace. * * @param toolCallId - Parent `task` tool-call id (the subagent's discovery key). * @param executionSegment - Single execution namespace segment, e.g. `tools:<uuid>`. */ applyExecutionNamespace(toolCallId, executionSegment) { if (typeof executionSegment !== "string" || executionSegment.length === 0) return; const entry = this.#map.get(toolCallId); if (entry == null) return; const namespace = [executionSegment]; const namespaceKeyed = namespaceKey(namespace); const ownNamespaceKey = `tools:${toolCallId}`; this.#recordTaskNamespaceCandidate(toolCallId, namespace); if (namespaceKeyed !== ownNamespaceKey && this.#observedOwnNamespaces.has(toolCallId)) return; if (namespaceKey(entry.namespace) === namespaceKeyed) return; entry.namespace = namespace; this.#commit(); } /** Current snapshot map. */ get snapshot() { return this.store.getSnapshot(); } /** * Drop all discovery state. Called on thread rebind / dispose so a * new thread's subagents cannot bleed into the previous UI. */ reset() { this.#map.clear(); this.#taskIdByObservedNamespace.clear(); this.#observedOwnNamespaces.clear(); this.#toolCallIdByTaskInput.clear(); this.store.setValue(EMPTY_SUBAGENT_MAP); } discoverFromMessage(message, namespace) { let changed = false; for (const toolCall of getTaskToolCalls(message)) changed = this.#upsertTaskToolCall(toolCall.id, toolCall.input, namespace) || changed; if (changed) this.#commit(); } #commit() { this.store.setValue(new Map([...this.#map.values()].map((entry) => [entry.id, toSnapshot(entry)]))); } #onToolEvent(event) { const data = event.params.data; const toolCallId = data.tool_call_id; const toolName = data.tool_name; if (data.event === "tool-started" && toolName === "task") { const input = parseTaskInput(data.input); if (toolCallId == null) return; this.#upsertTaskToolCall(toolCallId, input, event.params.namespace); this.#commit(); return; } if (toolCallId == null) return; const entry = this.#map.get(toolCallId); if (entry == null) return; if (data.event === "tool-finished") { entry.status = "complete"; entry.output = data.output; entry.completedAt = /* @__PURE__ */ new Date(); this.#commit(); return; } if (data.event === "tool-error") { entry.status = "error"; entry.error = data.message ?? "Subagent failed"; entry.completedAt = /* @__PURE__ */ new Date(); this.#commit(); } } #onValuesEvent(event) { const data = event.params.data; if (data == null || typeof data !== "object" || Array.isArray(data)) return; const messages = data.messages; if (!Array.isArray(messages)) return; this.#bindNamespaceByTaskInput(event.params.namespace, messages); let changed = this.#recordObservedWorkNamespace(event.params.namespace); for (const message of messages) { for (const toolCall of getTaskToolCalls(message)) changed = this.#upsertTaskToolCall(toolCall.id, toolCall.input, event.params.namespace) || changed; const toolCallId = getToolMessageCallId(message); if (toolCallId == null) continue; const existing = this.#map.get(toolCallId); if (existing == null) continue; existing.status = "complete"; existing.output = message; existing.completedAt = /* @__PURE__ */ new Date(); changed = true; } if (changed) this.#commit(); } #upsertTaskToolCall(toolCallId, input, eventNamespace) { const namespace = taskWorkNamespace(toolCallId, eventNamespace); const existing = this.#map.get(toolCallId); if (existing != null) { let changed = false; this.#recordTaskNamespaceCandidate(toolCallId, eventNamespace); const nextName = input.subagent_type ?? existing.name; const nextTaskInput = input.description ?? existing.taskInput; if (existing.name !== nextName) { existing.name = nextName; changed = true; } if (existing.taskInput !== nextTaskInput) { existing.taskInput = nextTaskInput; changed = true; } const namespaceKeyed = namespaceKey(existing.namespace); const ownNamespaceKey = `tools:${toolCallId}`; const nextNamespaceKey = namespaceKey(namespace); if (isConcreteToolNamespace(eventNamespace) || namespaceKeyed === ownNamespaceKey) { if (namespaceKeyed === ownNamespaceKey && nextNamespaceKey !== ownNamespaceKey && this.#observedOwnNamespaces.has(toolCallId)) return changed; if (namespaceKeyed !== nextNamespaceKey) { existing.namespace = namespace; changed = true; } } if (existing.status !== "complete" && existing.status !== "error") { if (existing.status !== "running") { existing.status = "running"; changed = true; } } return changed; } const { parentId, depth } = lineageFromNamespace(eventNamespace); this.#recordTaskNamespaceCandidate(toolCallId, eventNamespace); if (input.description != null) { const queue = this.#toolCallIdByTaskInput.get(input.description) ?? []; queue.push(toolCallId); this.#toolCallIdByTaskInput.set(input.description, queue); } this.#map.set(toolCallId, { id: toolCallId, name: input.subagent_type ?? "unknown", namespace, parentId, depth, status: "running", taskInput: input.description, output: void 0, error: void 0, startedAt: /* @__PURE__ */ new Date(), completedAt: null }); return true; } #recordObservedWorkNamespace(namespace) { if (!isConcreteToolNamespace(namespace)) return false; const last = namespace.at(-1); if (last == null) return false; const namespaceKeyed = namespaceKey(namespace); const toolCallId = this.#taskIdByObservedNamespace.get(namespaceKeyed) ?? last.slice(6); const existing = this.#map.get(toolCallId); if (existing == null) return false; if (namespaceKeyed === `tools:${toolCallId}`) this.#observedOwnNamespaces.add(toolCallId); else if (this.#observedOwnNamespaces.has(toolCallId) || !this.#taskIdByObservedNamespace.has(namespaceKeyed) && !shouldPromoteToObservedNamespace(existing)) return false; if (namespaceKey(existing.namespace) === namespaceKeyed) return false; existing.namespace = [...namespace]; return true; } #recordTaskNamespaceCandidate(toolCallId, namespace) { if (!isConcreteToolNamespace(namespace)) return; this.#taskIdByObservedNamespace.set(namespaceKey(namespace), toolCallId); } /** * Bind a `tools:<id>` namespace to a registered subagent by looking * up the first HumanMessage content in the `taskInput` index. */ #bindNamespaceByTaskInput(namespace, messages) { if (!isConcreteToolNamespace(namespace)) return; const namespaceKeyed = namespaceKey(namespace); if (this.#taskIdByObservedNamespace.has(namespaceKeyed)) return; const text = getFirstHumanMessageText(messages); if (text == null) return; const toolCallId = this.#toolCallIdByTaskInput.get(text)?.shift(); if (toolCallId == null) return; this.#taskIdByObservedNamespace.set(namespaceKeyed, toolCallId); } }; function getFirstHumanMessageText(messages) { for (const message of messages) { if (message == null || typeof message !== "object") continue; const record = message; if ((record.type ?? record.role ?? record.kwargs?.type ?? record.lc_kwargs?.type) !== "human") continue; const content = record.content ?? record.kwargs?.content ?? record.lc_kwargs?.content; return typeof content === "string" && content.length > 0 ? content : null; } return null; } function shouldPromoteToObservedNamespace(entry) { return entry.name === "fanout-worker" || /^Worker worker-\d+/i.test(entry.taskInput ?? ""); } function taskWorkNamespace(toolCallId, eventNamespace) { const last = eventNamespace.at(-1); if (last != null && isToolNamespaceSegment(last)) return [...eventNamespace]; return [`tools:${toolCallId}`]; } function toSnapshot(entry) { return { id: entry.id, name: entry.name, namespace: entry.namespace, parentId: entry.parentId, depth: entry.depth, status: entry.status, taskInput: entry.taskInput, output: entry.output, error: entry.error, startedAt: entry.startedAt, completedAt: entry.completedAt }; } function parseTaskInput(raw) { if (raw == null) return {}; if (typeof raw === "string") try { const parsed = JSON.parse(raw); return { description: typeof parsed.description === "string" ? parsed.description : void 0, subagent_type: typeof parsed.subagent_type === "string" ? parsed.subagent_type : void 0 }; } catch { return {}; } if (typeof raw === "object" && !Array.isArray(raw)) { const obj = raw; return { description: typeof obj.description === "string" ? obj.description : void 0, subagent_type: typeof obj.subagent_type === "string" ? obj.subagent_type : void 0 }; } return {}; } function getTaskToolCalls(message) { if (message == null || typeof message !== "object" || Array.isArray(message)) return []; const record = normalizeAIMessageToolCalls(message); const toolCalls = record.tool_calls ?? record.kwargs?.tool_calls ?? record.lc_kwargs?.tool_calls; if (!Array.isArray(toolCalls)) return []; const result = []; for (const toolCall of toolCalls) { if (toolCall == null || typeof toolCall !== "object" || Array.isArray(toolCall)) continue; const record = toolCall; if (typeof record.id !== "string" || record.name !== "task") continue; result.push({ id: record.id, input: parseTaskInput(record.args) }); } return result; } function getToolMessageCallId(message) { if (message == null || typeof message !== "object" || Array.isArray(message)) return; const record = message; const id = record.tool_call_id ?? record.kwargs?.tool_call_id ?? record.lc_kwargs?.tool_call_id; return typeof id === "string" && id.length > 0 ? id : void 0; } /** * Derive (parentId, depth) from a namespace like * `["subagents:abc:def"]`. Namespaces form a rooted tree; the last * `:` segment of the deepest namespace element is the current node's * call-id and the one before it is the parent. */ function lineageFromNamespace(namespace) { if (isRootNamespace(namespace)) return { parentId: null, depth: 1 }; const last = namespace[namespace.length - 1]; if (last == null) return { parentId: null, depth: 1 }; const trimmed = last.split(":").filter((part) => part.length > 0).slice(1); const depth = Math.max(1, trimmed.length); return { parentId: (trimmed.length >= 2 ? trimmed[trimmed.length - 2] : null) ?? null, depth }; } //#endregion export { SubagentDiscovery }; //# sourceMappingURL=subagents.js.map