@langchain/langgraph-sdk
Version:
Client library for interacting with the LangGraph API
334 lines (333 loc) • 12.8 kB
JavaScript
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