Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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<string \| null>` input | prop |
| Submit | `submit(values, opts?)` | `submit(values, opts?)` |
| Stop | `stop()` | `stop()` |
Expand Down
36 changes: 36 additions & 0 deletions apps/website/content/docs/agent/api/api-docs.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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",
Expand Down
15 changes: 12 additions & 3 deletions apps/website/content/docs/agent/guides/subgraphs.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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`);
Expand All @@ -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
Expand Down
15 changes: 0 additions & 15 deletions docs/limitations.md
Original file line number Diff line number Diff line change
Expand Up @@ -65,18 +65,3 @@ SDK and depends on internal tree-diffing utilities not exported from
(Signal<ThreadState[]>) 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.
56 changes: 56 additions & 0 deletions libs/langgraph/src/lib/agent.fn.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(() =>
Expand Down
21 changes: 20 additions & 1 deletion libs/langgraph/src/lib/agent.fn.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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),
Expand Down Expand Up @@ -365,6 +375,15 @@ function toSubagent(sa: SubagentStreamRef): Subagent {
};
}

function getToolCallIds(msg: CoreAIMessage): string[] {
const raw = msg as unknown as Record<string, unknown>;
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) {
Expand Down
9 changes: 9 additions & 0 deletions libs/langgraph/src/lib/agent.types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -241,6 +241,15 @@ export interface LangGraphAgent<T = unknown, ResolvedBag extends BagTemplate = B
/** Filtered list of subagents with status 'running'. */
activeSubagents: Signal<SubagentStreamRef[]>;

/** 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<CustomStreamEvent[]>;
Expand Down
16 changes: 16 additions & 0 deletions libs/langgraph/src/lib/testing/mock-langgraph-agent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, unknown>)['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<string, unknown>)['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
Expand Down
Loading