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
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@

---

`agent()` is the Angular equivalent of LangGraph's React `useStream()` hook — a full-parity implementation built on Angular Signals and the Angular Resource API. It gives enterprise Angular teams the same production-grade streaming primitives available to React developers on LangChain, without compromises or workarounds. Drop it into any Angular 20+ component, point it at your LangGraph Platform endpoint, and get reactive, signal-driven access to streaming state, messages, tool calls, interrupts, and thread history.
`agent()` is the Angular equivalent of LangGraph's React `useStream()` hook, built on Angular Signals and the Angular Resource API. It gives enterprise Angular teams production-grade streaming primitives for LangChain. Drop it into any Angular 20+ component, point it at your LangGraph Platform endpoint, and get reactive, signal-driven access to streaming state, messages, tool calls, interrupts, and thread history.

---

Expand Down Expand Up @@ -94,7 +94,7 @@ That's it. `chat.messages()` is an Angular Signal. Bind it directly in your temp
| Tool call progress | `toolProgress()` | `toolProgress` |
| Tool calls with results | `toolCalls()` | `toolCalls` |
| Branch / history | `branch()` / `history()` | `branch` / `history` |
| Subagent streaming | `subagents()` / `activeSubagents()` | `subagents` / `activeSubagents` |
| Subagent streaming | Planned next | `subagents` / `activeSubagents` |
| Reactive thread switching | `Signal<string \| null>` input | prop |
| Submit | `submit(values, opts?)` | `submit(values, opts?)` |
| Stop | `stop()` | `stop()` |
Expand Down
39 changes: 20 additions & 19 deletions apps/website/content/docs/agent/concepts/agent-architecture.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -417,40 +417,37 @@ interface OrchestratorState {
<app-chat [messages]="messages()" />

<aside class="agent-panel">
<h3>Active Agents</h3>
@for (sub of activeWorkers(); track sub.name) {
<h3>Active Delegated Work</h3>
@for (tool of activeTools(); track tool.toolCallId ?? tool.name) {
<app-agent-card
[name]="sub.name"
[status]="sub.status"
[messages]="sub.messages" />
[name]="tool.name"
[status]="tool.state"
[input]="tool.input" />
}
</aside>

<footer class="all-agents">
<h3>All Subagents</h3>
@for (entry of allSubagents(); track entry[0]) {
<app-subagent-summary
[name]="entry[0]"
[state]="entry[1]" />
<h3>Completed Tool Results</h3>
@for (tool of completedTools(); track tool.id) {
<app-tool-summary
[name]="tool.name"
[result]="tool.result" />
}
</footer>
`,
})
export class MultiAgentComponent {
orchestrator = agent<OrchestratorState>({
assistantId: 'orchestrator',
subagentToolNames: ['researcher', 'analyst', 'writer'],
});

messages = this.orchestrator.messages;

// Currently running subagents with live status
activeWorkers = computed(() => this.orchestrator.activeSubagents());
// Currently running delegated work with live status
activeTools = computed(() => this.orchestrator.toolProgress());

// Full map of all subagents (active + completed)
allSubagents = computed(() =>
Array.from(this.orchestrator.subagents().entries())
);
// Completed tool calls with results
completedTools = computed(() => this.orchestrator.toolCalls());

send(text: string) {
this.orchestrator.submit({
Expand All @@ -463,8 +460,12 @@ export class MultiAgentComponent {
</Tab>
</Tabs>

<Callout type="warning" title="Subagent signal status">
Tool calls, tool progress, and tool results stream today. Dedicated `subagents()` and `activeSubagents()` tracking is planned for the next implementation phase; use `toolCalls()` and `toolProgress()` for current delegated-work visibility.
</Callout>

<Callout type="tip" title="subagentToolNames is the key">
The `subagentToolNames` option tells agent() which graph nodes are subagents. Without it, subagent execution looks like regular tool calls. With it, `activeSubagents()` and `subagents()` provide dedicated tracking with isolated message histories.
The `subagentToolNames` option will tell agent() which graph nodes are subagents. Until dedicated tracking lands, subagent execution appears through the regular tool-call and tool-progress signals.
</Callout>

## Error Handling and Recovery
Expand Down Expand Up @@ -661,7 +662,7 @@ builder.add_node("analyst", analyst_subgraph)
builder.add_conditional_edges("supervisor", route_to_agent)
```

**Angular signals used:** `messages()`, `subagents()`, `activeSubagents()`, `toolCalls()`, `status()`
**Angular signals used today:** `messages()`, `toolCalls()`, `toolProgress()`, `status()`; dedicated `subagents()` / `activeSubagents()` tracking is planned next.

### Decision Matrix

Expand Down
13 changes: 5 additions & 8 deletions apps/website/content/docs/agent/concepts/langgraph-basics.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -227,16 +227,14 @@ builder.add_node("analyst", analyst_subgraph)
builder.add_conditional_edges("supervisor", lambda s: s["next_agent"])
```

**Angular connection:** Track each sub-agent independently:
**Angular connection:** Dedicated subagent tracking is planned for the next implementation phase. Today, track delegated work through tool progress and tool results:
```typescript
const orchestrator = agent<OrchestratorState>({
assistantId: 'orchestrator',
subagentToolNames: ['researcher', 'analyst', 'writer'],
});

// See all active sub-agents
const workers = computed(() => orchestrator.activeSubagents());
const workerCount = computed(() => workers().length);
const activeTools = computed(() => orchestrator.toolProgress());
const completedTools = computed(() => orchestrator.toolCalls());
```

### Pattern 4: Persistent Conversations
Expand Down Expand Up @@ -322,10 +320,9 @@ agent.interrupt() // Signal<Interrupt> — agent is paused
agent.history() // Signal<ThreadState[]> — checkpoint timeline
agent.branch() // Signal<string> — time-travel branch

// Multi-agent
agent.subagents() // Signal<Map> — delegated agents
agent.activeSubagents() // Signal<SubagentRef[]> — running workers
agent.toolCalls() // Signal<ToolCallWithResult[]> — tool results
agent.toolProgress() // Signal<ToolProgress[]> — active tool execution
// Dedicated subagent signals are planned next.
```

</Tab>
Expand Down
4 changes: 4 additions & 0 deletions apps/website/content/docs/agent/guides/subgraphs.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,10 @@ Subgraphs let you compose complex agents from smaller, focused units. agent() tr
LangGraph calls them subgraphs (modular graph composition). Deep Agents calls them subagents (task delegation). agent() supports both patterns through the same API.
</Callout>

<Callout type="warning" title="Implementation status">
Tool calls, tool progress, and tool results stream today. The `subagents()` / `activeSubagents()` examples below describe the planned Phase 2 API; until that lands, use `toolCalls()` and `toolProgress()` for visibility into delegated work.
</Callout>

## How subgraph composition works

Subgraph composition starts on the agent side. Each subgraph is a fully compiled `StateGraph` that can be added as a node in a parent graph.
Expand Down
2 changes: 1 addition & 1 deletion apps/website/src/components/docs/mdx/FeatureChips.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ const CHIPS: ChipData[] = [
{ icon: '💾', title: 'Persistence', signal: 'threadId', href: '/docs/guides/persistence', gradient: 'linear-gradient(135deg, rgba(16,185,129,0.06), rgba(52,199,89,0.08))', border: 'rgba(16,185,129,0.1)' },
{ icon: '✋', title: 'Interrupts', signal: 'chat.interrupt()', href: '/docs/guides/interrupts', gradient: 'linear-gradient(135deg, rgba(232,147,12,0.06), rgba(245,180,60,0.08))', border: 'rgba(232,147,12,0.1)' },
{ icon: '⏪', title: 'Time Travel', signal: 'chat.history()', href: '/docs/guides/time-travel', gradient: 'linear-gradient(135deg, rgba(221,0,49,0.05), rgba(255,100,130,0.07))', border: 'rgba(221,0,49,0.08)' },
{ icon: '🔀', title: 'Subagents', signal: 'chat.subagents()', href: '/docs/guides/subgraphs', gradient: 'linear-gradient(135deg, rgba(0,64,144,0.05), rgba(0,100,180,0.07))', border: 'rgba(0,64,144,0.08)' },
{ icon: '🔀', title: 'Subagents', signal: 'Phase 2', href: '/docs/guides/subgraphs', gradient: 'linear-gradient(135deg, rgba(0,64,144,0.05), rgba(0,100,180,0.07))', border: 'rgba(0,64,144,0.08)' },
{ icon: '🔧', title: 'Tool Calls', signal: 'chat.toolCalls()', href: '/docs/guides/streaming', gradient: 'linear-gradient(135deg, rgba(100,80,200,0.05), rgba(120,100,210,0.07))', border: 'rgba(100,80,200,0.08)' },
{ icon: '🧪', title: 'Testing', signal: 'MockTransport', href: '/docs/guides/testing', gradient: 'linear-gradient(135deg, rgba(16,185,129,0.05), rgba(40,200,140,0.07))', border: 'rgba(16,185,129,0.08)' },
];
Expand Down
5 changes: 2 additions & 3 deletions apps/website/src/components/landing/PositioningStrip.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -17,11 +17,10 @@ const CARDS: Card[] = [
},
{
eyebrow: 'Streaming',
headline: 'Full-parity LangGraph streaming.',
headline: 'LangGraph streaming for Angular.',
body: (
<>
<code style={{ fontFamily: 'var(--font-mono)' }}>agent()</code> ships everything React&apos;s{' '}
<code style={{ fontFamily: 'var(--font-mono)' }}>useStream()</code> does — interrupt, subagents, branch and history, tool progress — plus{' '}
<code style={{ fontFamily: 'var(--font-mono)' }}>agent()</code> ships LangGraph streaming for interrupts, branch and history, tool progress, and tool results — plus{' '}
<code style={{ fontFamily: 'var(--font-mono)' }}>error()</code>,{' '}
<code style={{ fontFamily: 'var(--font-mono)' }}>status()</code>, and{' '}
<code style={{ fontFamily: 'var(--font-mono)' }}>reload()</code>.
Expand Down
35 changes: 10 additions & 25 deletions docs/limitations.md
Original file line number Diff line number Diff line change
Expand Up @@ -79,32 +79,17 @@ automatically on `submit()` calls.

---

### Limitation: `getMessagesMetadata()` and `getToolCalls()` always return empty
### Limitation: subagent tracking is deferred

**Feature:** `getMessagesMetadata(msg, idx?)` / `getToolCalls(msg)`
**Feature:** `subagents()` / `activeSubagents()` / `filterSubagentMessages` /
`subagentToolNames`

**React behavior:** `useStream()` derives per-message metadata (run ID, feedback
keys, tool results) from an internal StreamManager message registry populated via
the `onMessagesMetadata` callback.
**React behavior:** `useStream()` can track Deep Agent subagent execution by
combining subgraph stream events with tool-call registration.

**Angular behavior:** v1 returns `undefined` / `[]` unconditionally. The
`StreamManager` callback integration is deferred.
**Angular behavior:** Tool calls, tool progress, message metadata, and
per-message tool results are implemented. Subagent-specific stream routing is
deferred to the next implementation phase.

**Workaround:** None in v1. Tool call results are available via `toolCalls()`.

---

### Limitation: `toolProgress()` and `toolCalls()` signals always return empty

**Feature:** `toolProgress()` / `toolCalls()` reactive signals

**React behavior:** `useStream()` populates these from `tool_progress` and
`tool_calls` LangGraph SSE event types via StreamManager's internal dispatcher.

**Angular behavior:** v1 leaves these unhandled in `processEvent` because the
LangGraph SDK's `ToolProgressEvent` and `ToolCallEvent` shapes need to be
confirmed against the published SDK types before implementation. Both signals
return `[]` unconditionally.

**Workaround:** None in v1. Subscribe to raw stream events via a custom transport
if tool progress visibility is required.
**Workaround:** Use `toolCalls()` and `toolProgress()` for tool-level visibility
until dedicated subagent tracking lands.
99 changes: 97 additions & 2 deletions libs/langgraph/src/lib/agent.fn.spec.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,10 @@
import { describe, it, expect, beforeEach } from 'vitest';
import { TestBed } from '@angular/core/testing';
import { signal } from '@angular/core';
import { HumanMessage, AIMessage } from '@langchain/core/messages';
import type { AIMessage as CoreAIMessage } from '@langchain/core/messages';
import { agent } from './agent.fn';
import { MockAgentTransport } from './transport/mock-stream.transport';
import { ResourceStatus } from './agent.types';
import type { StreamEvent } from './agent.types';

function withInjectionContext<T>(fn: () => T): T {
let result!: T;
Expand Down Expand Up @@ -235,6 +235,101 @@ describe('agent', () => {
expect(Array.isArray(ref.langGraphToolCalls())).toBe(true);
});

it('toolProgress() reflects tools stream lifecycle events', async () => {
const transport = new MockAgentTransport();
const ref = withInjectionContext(() =>
agent({ apiUrl: '', assistantId: 'a', transport, throttle: false })
);

ref.submit({ message: 'hello' });
transport.emit([{
type: 'tools',
data: { event: 'on_tool_start', toolCallId: 'call-1', name: 'search', input: { q: 'angular' } },
} satisfies StreamEvent]);
transport.close();
await new Promise(r => setTimeout(r, 20));

expect(ref.toolProgress()).toEqual([
{
toolCallId: 'call-1',
name: 'search',
state: 'starting',
input: { q: 'angular' },
},
]);
});

it('toolCalls() and getToolCalls() expose tool results derived from messages', async () => {
const transport = new MockAgentTransport();
const ref = withInjectionContext(() =>
agent({ apiUrl: '', assistantId: 'a', transport, throttle: false })
);

ref.submit({ message: 'hello' });
transport.emit([{
type: 'messages',
messages: [
{
id: 'ai-1',
type: 'ai',
content: '',
tool_calls: [{ id: 'call-1', name: 'search', args: { q: 'angular' } }],
},
{
id: 'tool-1',
type: 'tool',
tool_call_id: 'call-1',
content: 'result',
status: 'success',
},
],
} satisfies StreamEvent]);
transport.close();
await new Promise(r => setTimeout(r, 20));

expect(ref.langGraphToolCalls()).toHaveLength(1);
expect(ref.toolCalls()).toEqual([
{
id: 'call-1',
name: 'search',
args: { q: 'angular' },
status: 'complete',
result: 'result',
error: undefined,
},
]);
expect(ref.getToolCalls(ref.langGraphMessages()[0] as CoreAIMessage)).toHaveLength(1);
});

it('getMessagesMetadata() returns stream metadata captured from message tuples', async () => {
const transport = new MockAgentTransport();
const ref = withInjectionContext(() =>
agent({ apiUrl: '', assistantId: 'a', transport, throttle: false })
);

ref.submit({ message: 'hello' });
transport.emit([{
type: 'messages',
messages: [{ id: 'ai-1', type: 'ai', content: 'hello' }],
messageMetadata: { langgraph_node: 'model', run_id: 'run-1' },
} satisfies StreamEvent]);
transport.close();
await new Promise(r => setTimeout(r, 20));

const aiMessage = ref.langGraphMessages().find(
msg => (msg as unknown as Record<string, unknown>)['id'] === 'ai-1',
);
if (!aiMessage) throw new Error('Expected streamed AI message');

expect(ref.getMessagesMetadata(aiMessage, 0)).toEqual({
messageId: 'ai-1',
firstSeenState: undefined,
branch: undefined,
branchOptions: undefined,
streamMetadata: { langgraph_node: 'model', run_id: 'run-1' },
});
});

it('events$ is an Observable-like with .subscribe', () => {
const transport = new MockAgentTransport();
const ref = withInjectionContext(() =>
Expand Down
21 changes: 16 additions & 5 deletions libs/langgraph/src/lib/agent.fn.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ import type {
Subagent,
ToolCall,
ToolCallStatus,
ContentBlock,
AgentSubmitInput,
AgentSubmitOptions,
} from '@ngaf/chat';
Expand All @@ -37,6 +38,7 @@ import {
ResourceStatus,
} from './agent.types';
import type { ThreadState, ToolProgress } from '@langchain/langgraph-sdk';
import type { MessageMetadata } from '@langchain/langgraph-sdk/ui';
import { createStreamManagerBridge } from './internals/stream-manager.bridge';

/**
Expand Down Expand Up @@ -96,6 +98,7 @@ export function agent<
const isThreadLoading$ = new BehaviorSubject<boolean>(false);
const toolProgress$ = new BehaviorSubject<ToolProgress[]>([]);
const toolCalls$ = new BehaviorSubject<ToolCallWithResult[]>([]);
const messageMetadata$ = new BehaviorSubject<Map<string, MessageMetadata<Record<string, unknown>>>>(new Map());
const subagents$ = new BehaviorSubject<Map<string, SubagentStreamRef>>(new Map());
const custom$ = new BehaviorSubject<CustomStreamEvent[]>([]);
const hasValue$ = new BehaviorSubject<boolean>(false);
Expand All @@ -115,7 +118,7 @@ export function agent<
const subjects: StreamSubjects<T, InferBag<T, Bag>> = {
status$, values$, messages$, error$,
interrupt$, interrupts$, branch$, history$,
isThreadLoading$, toolProgress$, toolCalls$, subagents$, custom$,
isThreadLoading$, toolProgress$, toolCalls$, messageMetadata$, subagents$, custom$,
};

// threadId$ — resolved before bridge creation (injection context required for toObservable)
Expand Down Expand Up @@ -238,9 +241,17 @@ export function agent<
manager.switchThread(id);
},
joinStream: (id, last) => manager.joinStream(id, last),
// V1 deferred: requires StreamManager's internal message registry
getMessagesMetadata: (_msg, _idx) => undefined,
getToolCalls: (_msg) => [],
getMessagesMetadata: (msg, idx) => {
const id = (msg as unknown as Record<string, unknown>)['id'];
const key = id != null ? String(id) : idx != null ? String(idx) : undefined;
return key ? messageMetadata$.value.get(key) : undefined;
},
getToolCalls: (msg) => {
const id = (msg as unknown as Record<string, unknown>)['id'];
return id == null
? []
: toolCalls$.value.filter(tc => (tc.aiMessage as unknown as Record<string, unknown>)['id'] === id);
},
};
}

Expand Down Expand Up @@ -348,7 +359,7 @@ function buildSubmitPayload(input: AgentSubmitInput): unknown {
if (input.message !== undefined) {
const content = typeof input.message === 'string'
? input.message
: input.message.map((b: any) => (b.type === 'text' ? b.text : JSON.stringify(b))).join('');
: input.message.map((b: ContentBlock) => (b.type === 'text' ? b.text : JSON.stringify(b))).join('');
return { messages: [{ role: 'human', content }], ...(input.state ?? {}) };
}
return input.state ?? {};
Expand Down
Loading
Loading