From c7f8fa6923eaebbc0353b00b73ef53e24da325db Mon Sep 17 00:00:00 2001 From: Brian Love Date: Fri, 1 May 2026 20:23:37 -0700 Subject: [PATCH] Add LangGraph subagent lookup helpers --- README.md | 2 +- .../content/docs/agent/api/api-docs.json | 36 ++++++++++++ .../content/docs/agent/guides/subgraphs.mdx | 15 ++++- docs/limitations.md | 15 ----- libs/langgraph/src/lib/agent.fn.spec.ts | 56 +++++++++++++++++++ libs/langgraph/src/lib/agent.fn.ts | 21 ++++++- libs/langgraph/src/lib/agent.types.ts | 9 +++ .../src/lib/testing/mock-langgraph-agent.ts | 16 ++++++ 8 files changed, 150 insertions(+), 20 deletions(-) diff --git a/README.md b/README.md index 9f12e449f..7481d5acf 100644 --- a/README.md +++ b/README.md @@ -95,7 +95,7 @@ That's it. `chat.messages()` is an Angular Signal. Bind it directly in your temp | Tool calls with results | `toolCalls()` | `toolCalls` | | Branch / history | `branch()` / `history()` | `branch` / `history` | | Pending run queue | `queue()` | `queue` | -| Subagent streaming | `subagents()` / `activeSubagents()` | `subagents` / `activeSubagents` | +| Subagent streaming and lookup helpers | `subagents()` / `activeSubagents()` / `getSubagent()` | `subagents` / `activeSubagents` / helper methods | | Reactive thread switching | `Signal` input | prop | | Submit | `submit(values, opts?)` | `submit(values, opts?)` | | Stop | `stop()` | `stop()` | diff --git a/apps/website/content/docs/agent/api/api-docs.json b/apps/website/content/docs/agent/api/api-docs.json index 5d51691b3..c1bdbdb44 100644 --- a/apps/website/content/docs/agent/api/api-docs.json +++ b/apps/website/content/docs/agent/api/api-docs.json @@ -655,6 +655,24 @@ "description": "Get metadata for a specific message by index.", "optional": false }, + { + "name": "getSubagent", + "type": "object", + "description": "Get a subagent stream by the tool call ID that spawned it.", + "optional": false + }, + { + "name": "getSubagentsByMessage", + "type": "object", + "description": "Get subagent streams spawned by the tool calls on a specific AI message.", + "optional": false + }, + { + "name": "getSubagentsByType", + "type": "object", + "description": "Get subagent streams by their configured subagent type/name.", + "optional": false + }, { "name": "getToolCalls", "type": "object", @@ -863,6 +881,24 @@ "description": "Get metadata for a specific message by index.", "optional": false }, + { + "name": "getSubagent", + "type": "object", + "description": "Get a subagent stream by the tool call ID that spawned it.", + "optional": false + }, + { + "name": "getSubagentsByMessage", + "type": "object", + "description": "Get subagent streams spawned by the tool calls on a specific AI message.", + "optional": false + }, + { + "name": "getSubagentsByType", + "type": "object", + "description": "Get subagent streams by their configured subagent type/name.", + "optional": false + }, { "name": "getToolCalls", "type": "object", diff --git a/apps/website/content/docs/agent/guides/subgraphs.mdx b/apps/website/content/docs/agent/guides/subgraphs.mdx index f8618be7e..9ff503eb5 100644 --- a/apps/website/content/docs/agent/guides/subgraphs.mdx +++ b/apps/website/content/docs/agent/guides/subgraphs.mdx @@ -119,6 +119,10 @@ const subagents = computed(() => orchestrator.subagents()); const running = computed(() => orchestrator.activeSubagents()); const runningCount = computed(() => running().length); +// Lookup helpers for common UI paths +const specific = computed(() => orchestrator.getSubagent('research-tool-call-id')); +const researchers = computed(() => orchestrator.getSubagentsByType('researcher')); + // React to count changes effect(() => { console.log(`${runningCount()} subagents currently running`); @@ -127,18 +131,23 @@ effect(() => { ## Subagent stream details -Each `SubagentStreamRef` exposes its own reactive signals — status, messages, and errors — so you can surface granular progress in your UI. +Each `SubagentStreamRef` exposes its own reactive signals — status, messages, and state — so you can surface granular progress in your UI. ```typescript // Access a specific subagent by its tool call ID const researchAgent = computed(() => - orchestrator.subagents().get('research-tool-call-id') + orchestrator.getSubagent('research-tool-call-id') ); +// Or get the subagents spawned by a specific AI message with tool calls +const messageAgents = computed(() => { + const message = selectedAiMessage(); + return message ? orchestrator.getSubagentsByMessage(message) : []; +}); + // Track its progress const researchStatus = computed(() => researchAgent()?.status()); const researchMessages = computed(() => researchAgent()?.messages() ?? []); -const researchError = computed(() => researchAgent()?.error()); ``` ## Orchestrator pattern diff --git a/docs/limitations.md b/docs/limitations.md index 96db98c93..c3948253c 100644 --- a/docs/limitations.md +++ b/docs/limitations.md @@ -65,18 +65,3 @@ SDK and depends on internal tree-diffing utilities not exported from (Signal) to reconstruct branch relationships manually. --- - -## 5. Subagent Helper Methods - -**Feature:** `getSubagent()` / `getSubagentsByType()` / -`getSubagentsByMessage()` - -**React behavior:** `useStream()` exposes helper methods for looking up -subagent streams by tool call ID, subagent type, or triggering message. - -**Angular behavior:** `subagents()` and `activeSubagents()` are implemented. -Use the `subagents()` map directly for lookups. Helper methods can be added -later if Angular consumers need parity beyond the signal surface. - -**Workaround:** Read from `subagents().get(toolCallId)` or filter -`[...subagents().values()]` in a computed signal. diff --git a/libs/langgraph/src/lib/agent.fn.spec.ts b/libs/langgraph/src/lib/agent.fn.spec.ts index a1b987d8c..b57af23f7 100644 --- a/libs/langgraph/src/lib/agent.fn.spec.ts +++ b/libs/langgraph/src/lib/agent.fn.spec.ts @@ -404,6 +404,62 @@ describe('agent', () => { expect(ref.subagents().get('call-1')?.status()).toBe('complete'); }); + it('exposes helper methods for looking up subagent streams', async () => { + const transport = new MockAgentTransport(); + const ref = withInjectionContext(() => + agent({ + apiUrl: '', + assistantId: 'a', + transport, + throttle: false, + subagentToolNames: ['task'], + }) + ); + + ref.submit({ message: 'hello' }); + const triggeringMessage = { + id: 'ai-helpers', + type: 'ai', + content: '', + tool_calls: [ + { + id: 'call-research', + name: 'task', + args: { subagent_type: 'researcher', description: 'Research Angular signals' }, + }, + { + id: 'call-review', + name: 'task', + args: { subagent_type: 'reviewer', description: 'Review the notes' }, + }, + ], + } as unknown as CoreAIMessage; + transport.emit([{ + type: 'messages', + messages: [triggeringMessage], + } satisfies StreamEvent]); + transport.emit([{ + type: 'messages|tools:call-research' as StreamEvent['type'], + namespace: ['tools:call-research'], + messages: [{ id: 'sub-ai-research', type: 'ai', content: 'Research note' }], + } satisfies StreamEvent]); + transport.emit([{ + type: 'messages|tools:call-review' as StreamEvent['type'], + namespace: ['tools:call-review'], + messages: [{ id: 'sub-ai-review', type: 'ai', content: 'Review note' }], + } satisfies StreamEvent]); + + await new Promise(r => setTimeout(r, 20)); + + expect(ref.getSubagent('call-research')?.name).toBe('researcher'); + expect(ref.getSubagentsByType('reviewer').map(sa => sa.toolCallId)).toEqual(['call-review']); + expect(ref.getSubagentsByMessage(triggeringMessage).map(sa => sa.toolCallId)).toEqual([ + 'call-research', + 'call-review', + ]); + expect(ref.getSubagent('missing')).toBeUndefined(); + }); + it('events$ is an Observable-like with .subscribe', () => { const transport = new MockAgentTransport(); const ref = withInjectionContext(() => diff --git a/libs/langgraph/src/lib/agent.fn.ts b/libs/langgraph/src/lib/agent.fn.ts index 6e5b32325..e8c4379c3 100644 --- a/libs/langgraph/src/lib/agent.fn.ts +++ b/libs/langgraph/src/lib/agent.fn.ts @@ -11,7 +11,7 @@ import { } from 'rxjs'; import { takeUntil } from 'rxjs/operators'; import type { Observable } from 'rxjs'; -import type { BaseMessage } from '@langchain/core/messages'; +import type { BaseMessage, AIMessage as CoreAIMessage } from '@langchain/core/messages'; import type { Interrupt, ToolCallWithResult } from '@langchain/langgraph-sdk'; import type { BagTemplate, InferBag } from '@langchain/langgraph-sdk'; import type { @@ -242,6 +242,16 @@ export function agent< toolProgress: toolProgSig, queue: queueSig, activeSubagents, + getSubagent: (toolCallId) => subagentsSig().get(toolCallId), + getSubagentsByType: (type) => + [...subagentsSig().values()].filter(sa => sa.name === type), + getSubagentsByMessage: (msg) => { + const ids = getToolCallIds(msg); + const subagents = subagentsSig(); + return ids + .map(id => subagents.get(id)) + .filter((subagent): subagent is SubagentStreamRef => subagent != null); + }, customEvents: customSig, branch: branchSig, setBranch: (b) => branch$.next(b), @@ -365,6 +375,15 @@ function toSubagent(sa: SubagentStreamRef): Subagent { }; } +function getToolCallIds(msg: CoreAIMessage): string[] { + const raw = msg as unknown as Record; + const toolCalls = raw['tool_calls']; + if (!Array.isArray(toolCalls)) return []; + return toolCalls + .map(toolCall => isRecord(toolCall) && typeof toolCall['id'] === 'string' ? toolCall['id'] : undefined) + .filter((id): id is string => id != null); +} + function buildSubmitPayload(input: AgentSubmitInput): unknown { if (input.resume !== undefined) return { __resume__: input.resume }; if (input.message !== undefined) { diff --git a/libs/langgraph/src/lib/agent.types.ts b/libs/langgraph/src/lib/agent.types.ts index 9c1592128..fa5c288d0 100644 --- a/libs/langgraph/src/lib/agent.types.ts +++ b/libs/langgraph/src/lib/agent.types.ts @@ -241,6 +241,15 @@ export interface LangGraphAgent; + /** Get a subagent stream by the tool call ID that spawned it. */ + getSubagent: (toolCallId: string) => SubagentStreamRef | undefined; + + /** Get subagent streams by their configured subagent type/name. */ + getSubagentsByType: (type: string) => SubagentStreamRef[]; + + /** Get subagent streams spawned by the tool calls on a specific AI message. */ + getSubagentsByMessage: (msg: CoreAIMessage) => SubagentStreamRef[]; + /** Raw custom events stream (signal of array). The runtime-neutral * `events$` Observable is derived from this. */ customEvents: Signal; diff --git a/libs/langgraph/src/lib/testing/mock-langgraph-agent.ts b/libs/langgraph/src/lib/testing/mock-langgraph-agent.ts index e7e426969..17a7fed02 100644 --- a/libs/langgraph/src/lib/testing/mock-langgraph-agent.ts +++ b/libs/langgraph/src/lib/testing/mock-langgraph-agent.ts @@ -129,6 +129,22 @@ export function mockLangGraphAgent( toolProgress: toolProgress$, queue: queue$, activeSubagents: activeSubagents$, + getSubagent: (toolCallId: string) => + activeSubagents$().find(subagent => subagent.toolCallId === toolCallId), + getSubagentsByType: (type: string) => + activeSubagents$().filter(subagent => subagent.name === type), + getSubagentsByMessage: (msg: CoreAIMessage) => { + const toolCalls = (msg as unknown as Record)['tool_calls']; + if (!Array.isArray(toolCalls)) return []; + const ids = toolCalls + .map(toolCall => { + if (toolCall == null || typeof toolCall !== 'object' || Array.isArray(toolCall)) return undefined; + const id = (toolCall as Record)['id']; + return typeof id === 'string' ? id : undefined; + }) + .filter((id): id is string => id != null); + return activeSubagents$().filter(subagent => ids.includes(subagent.toolCallId)); + }, customEvents: customEvents$, branch: branch$, // eslint-disable-next-line @typescript-eslint/no-empty-function