From 43c0ba18fc16407dbc3ca896d2ea1620269b0e46 Mon Sep 17 00:00:00 2001 From: Brian Love Date: Sat, 4 Apr 2026 16:19:24 -0700 Subject: [PATCH 01/12] docs: add comprehensive docs overhaul master plan (15 tasks, 3 phases) --- .../2026-04-04-docs-comprehensive-overhaul.md | 234 ++++++++++++++++++ 1 file changed, 234 insertions(+) create mode 100644 docs/superpowers/plans/2026-04-04-docs-comprehensive-overhaul.md diff --git a/docs/superpowers/plans/2026-04-04-docs-comprehensive-overhaul.md b/docs/superpowers/plans/2026-04-04-docs-comprehensive-overhaul.md new file mode 100644 index 000000000..409188dc1 --- /dev/null +++ b/docs/superpowers/plans/2026-04-04-docs-comprehensive-overhaul.md @@ -0,0 +1,234 @@ +# Comprehensive Docs Overhaul — Master Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Bring all 18 docs pages to gold standard quality — every page shows both Python agent code AND Angular streamResource code, uses correct MDX syntax, has 200+ lines of rich content, and tells the product story. + +**Architecture:** Each task rewrites one MDX file. The gold standard is `introduction.mdx` (337 lines) and `langgraph-basics.mdx` (384 lines). Every page should pair Python LangGraph patterns with Angular streamResource consumption, use correct Tab label syntax, include Callouts, Steps, and CardGroup navigation. + +**Tech Stack:** MDX with custom components (Callout, Steps, Tabs/Tab with label prop, CardGroup/Card, FeatureChips, ArchFlowDiagram) + +--- + +## Phase 0: Critical Fixes (do first, affects all pages) + +### Task 0: Fix Cross-Cutting Issues + +**Files:** Multiple + +- [ ] **Step 1: Fix import path inconsistency** + +Search all MDX and TSX files for `@ngxp/stream-resource` and `@stream-resource/angular`. Replace ALL with `@cacheplane/stream-resource`. + +Run: `grep -rn "@ngxp/stream-resource\|@stream-resource/angular" apps/website/content/docs-v2/ apps/website/src/` + +Replace all occurrences with `@cacheplane/stream-resource`. + +- [ ] **Step 2: Fix API method inconsistency** + +Search for `.stream(` in docs (should be `.submit(`). Search for `status() === 'streaming'` (should be `status() === 'loading'`). + +- [ ] **Step 3: Fix broken links** + +Search for `/docs-v2/` (should be `/docs/`). Search for `/docs/guides/branching` and `/docs/guides/error-handling` (don't exist — remove or replace). + +- [ ] **Step 4: Fix unclosed code fence in state-management.mdx** + +Line ~60 has an unclosed TypeScript code fence that swallows the rest of the page. + +- [ ] **Step 5: Fix .tsx file extensions** + +Search for `.tsx` in Tab labels (should be `.ts` — this is Angular, not React). + +- [ ] **Step 6: Commit** + +```bash +git add -A +git commit -m "fix(website): resolve import paths, API naming, broken links, code fence" +``` + +--- + +## Phase 1: Rewrite THIN Pages (highest impact) + +Each page below needs to be expanded to 200+ lines with Python + Angular code pairs. + +### Task 1: Rewrite `concepts/angular-signals.mdx` (76 → 250+ lines) + +Current: Surface-level primer. No Python code. No streaming lifecycle explanation. + +New content needed: +- How `toSignal()` converts BehaviorSubjects internally +- Streaming lifecycle: idle → loading → streaming tokens → resolved +- `computed()` for derived AI state (message count, last message, tool progress) +- `effect()` for side effects (analytics, logging, error reporting) +- A complete component example showing all signal patterns +- Performance: why Signals + OnPush is efficient for high-frequency streaming +- Python agent code showing what produces the streaming events that Signals consume + +### Task 2: Rewrite `concepts/agent-architecture.mdx` (70 → 250+ lines) + +Current: 5-bullet overview, single code snippet, 3-line pattern list. + +New content needed: +- Full ReAct agent pattern with Python code + Angular streamResource code +- Tool calling: Python `@tool` decorator → Angular `toolCalls()` signal +- Multi-agent: Python supervisor graph → Angular `subagents()` signal +- Error handling and recovery patterns +- Planning phase: how LLMs decide actions +- Checkpointing: how `history()` and `branch()` expose decisions + +### Task 3: Rewrite `concepts/state-management.mdx` (83 → 200+ lines) + +Current: Has syntax error (unclosed code fence). No Python code. ASCII diagram. + +New content needed: +- Fix unclosed code fence +- Python TypedDict with reducers → TypeScript interface mapping +- How `Annotated[list, add]` works and why messages accumulate +- State updates during streaming (partial values) +- Checkpoint model: persistence, restore, branching +- Tabs showing Python state definition + Angular consumption +- Replace ASCII diagram with Steps component + +### Task 4: Rewrite `guides/memory.mdx` (83 → 200+ lines) + +Current: Thinnest guide. No Tabs, no Python, no template code. + +New content needed: +- Python: agent state with memory fields, LangGraph Store API +- Short-term (thread-scoped) vs long-term (cross-thread) memory +- Semantic memory with vector search +- Tabs: TypeScript component + Angular template for memory-aware UI +- How memory updates surface through `value()` signal + +### Task 5: Rewrite `guides/interrupts.mdx` (96 → 200+ lines) + +Current: No Python code. Dangling reference to BagTemplate. Tab syntax wrong. + +New content needed: +- Python: `raise Interrupt(value={...})` in agent node +- Python: graph structure with approval node +- Full approval component: TypeScript + Template in Tabs +- Multi-step approval pattern +- Typed interrupt payloads with BagTemplate (explain the reference) +- Steps component for interrupt lifecycle +- Fix Tab syntax to use `label` prop + +### Task 6: Rewrite `guides/persistence.mdx` (107 → 200+ lines) + +Current: No Python code. Tab syntax wrong. + +New content needed: +- Python: checkpointer setup (MemorySaver, PostgresSaver) +- Python: thread_id in graph invocation +- Full thread-list component: TypeScript + Template +- Thread switching UI pattern +- Fix Tab syntax to use `label` prop + +### Task 7: Rewrite `guides/testing.mdx` (124 → 200+ lines) + +Current: No Tabs, no Python, no template code. + +New content needed: +- Python: how to test the agent side +- Tabs: spec file + component file pairs +- Testing subagent interactions +- Testing interrupts and thread switching +- Integration testing with real LangGraph dev server +- Steps for test setup workflow + +### Task 8: Rewrite `guides/deployment.mdx` (108 → 200+ lines) + +Current: Tab syntax wrong. Introduction page has better deployment content. + +New content needed: +- Python: LangGraph Cloud deployment (langgraph.json, CLI) +- LangSmith deployment walkthrough +- Authentication / API key configuration +- CORS configuration for SSE +- CI/CD pipeline example +- Monitoring and health checks +- Fix Tab syntax to use `label` prop + +--- + +## Phase 2: Polish CLOSE Pages + +### Task 9: Polish `guides/streaming.mdx` (206 lines — fix issues) + +Fix: +- Import path: `@stream-resource/angular` → `@cacheplane/stream-resource` +- `.stream()` → `.submit()` +- `'streaming'` status → `'loading'` +- Add Python agent showing `stream_mode` configuration +- Add `ChangeDetectionStrategy.OnPush` to component + +### Task 10: Polish `guides/time-travel.mdx` (175 lines — fix issues) + +Fix: +- `.tsx` extension in Tab label → `.ts` +- Remove broken link to `/docs-v2/guides/branching` +- Add Python checkpointer setup code +- Expand to 200+ lines + +### Task 11: Polish `guides/subgraphs.mdx` (199 lines — fix issues) + +Fix: +- `.tsx` extension in Tab label → `.ts` +- Remove broken link to `/docs-v2/guides/error-handling` +- Add Python subgraph composition code + +### Task 12: Polish `getting-started/quickstart.mdx` (131 lines) + +Fix: +- Tab syntax: `items={[...]}` → `` +- Replace plain `##` numbered headings with `/` +- Add `ChangeDetectionStrategy.OnPush` +- Add error display (`chat.error()`) to template +- Add agent setup context or link + +### Task 13: Polish `getting-started/installation.mdx` (103 lines) + +Fix: +- Tab syntax: `items={[...]}` → `` +- Fix `process.env` error → use Angular `environment.ts` +- Fix verify example (needs injection context) +- Add troubleshooting section +- Expand "Next steps" to 4+ cards + +--- + +## Phase 3: Expand API Pages + +### Task 14: Expand 4 API Reference Pages + +Fix import path `@ngxp/stream-resource` → `@cacheplane/stream-resource` in all 4. +Add "What's Next" CardGroup to all 4. +Expand intros with more context about when/why to use each. + +--- + +## Execution Strategy + +**Phase 0** (Task 0): Do first — fixes affect all pages. Single commit. +**Phase 1** (Tasks 1-8): Highest impact. 8 full rewrites. Dispatch as parallel subagents. +**Phase 2** (Tasks 9-13): Polish passes. 5 targeted fixes. Dispatch as parallel subagents. +**Phase 3** (Task 14): API pages. Single task. + +Total: 15 tasks, ~14 files rewritten. + +## Quality Checklist (apply to every page) + +- [ ] 200+ lines of content +- [ ] Python LangGraph code showing the agent/server pattern +- [ ] Angular streamResource code showing the frontend consumption +- [ ] Both paired together to tell the product story +- [ ] All imports use `@cacheplane/stream-resource` +- [ ] All Tab components use `` syntax +- [ ] `ChangeDetectionStrategy.OnPush` in component examples +- [ ] At least 2 Callouts (tip, info, or warning) +- [ ] "What's Next" CardGroup with 4+ cards +- [ ] No broken links +- [ ] Correct API method names (`.submit()`, not `.stream()`) +- [ ] Correct status values (`'loading'`, not `'streaming'`) From 8dbff4eea7664460463598833f022fcbc5f3fb0b Mon Sep 17 00:00:00 2001 From: Brian Love Date: Sat, 4 Apr 2026 16:25:22 -0700 Subject: [PATCH 02/12] fix(website): resolve import paths, broken links, code fence, .tsx extensions --- .../docs-v2/api/fetch-stream-transport.mdx | 2 +- .../docs-v2/api/mock-stream-transport.mdx | 2 +- .../docs-v2/api/provide-stream-resource.mdx | 2 +- .../content/docs-v2/api/stream-resource.mdx | 2 +- .../docs-v2/concepts/state-management.mdx | 1 + .../content/docs-v2/guides/streaming.mdx | 20 +++++++++---------- .../content/docs-v2/guides/subgraphs.mdx | 11 ++++------ .../content/docs-v2/guides/time-travel.mdx | 11 ++++------ 8 files changed, 23 insertions(+), 28 deletions(-) diff --git a/apps/website/content/docs-v2/api/fetch-stream-transport.mdx b/apps/website/content/docs-v2/api/fetch-stream-transport.mdx index afb74b5f2..be313baed 100644 --- a/apps/website/content/docs-v2/api/fetch-stream-transport.mdx +++ b/apps/website/content/docs-v2/api/fetch-stream-transport.mdx @@ -6,7 +6,7 @@ You rarely need to interact with `FetchStreamTransport` directly — simply prov ```ts import { inject } from '@angular/core'; -import { streamResource, FetchStreamTransport } from '@ngxp/stream-resource'; +import { streamResource, FetchStreamTransport } from '@cacheplane/stream-resource'; // Override transport for a single resource const events = streamResource({ diff --git a/apps/website/content/docs-v2/api/mock-stream-transport.mdx b/apps/website/content/docs-v2/api/mock-stream-transport.mdx index fbf014cd6..d9ebd13c8 100644 --- a/apps/website/content/docs-v2/api/mock-stream-transport.mdx +++ b/apps/website/content/docs-v2/api/mock-stream-transport.mdx @@ -7,7 +7,7 @@ import { TestBed } from '@angular/core/testing'; import { provideStreamResource, MockStreamTransport, -} from '@ngxp/stream-resource'; +} from '@cacheplane/stream-resource'; beforeEach(() => { TestBed.configureTestingModule({ diff --git a/apps/website/content/docs-v2/api/provide-stream-resource.mdx b/apps/website/content/docs-v2/api/provide-stream-resource.mdx index 6e618bdb1..4863cf489 100644 --- a/apps/website/content/docs-v2/api/provide-stream-resource.mdx +++ b/apps/website/content/docs-v2/api/provide-stream-resource.mdx @@ -7,7 +7,7 @@ import { bootstrapApplication } from '@angular/platform-browser'; import { provideStreamResource, FetchStreamTransport, -} from '@ngxp/stream-resource'; +} from '@cacheplane/stream-resource'; import { AppComponent } from './app/app.component'; bootstrapApplication(AppComponent, { diff --git a/apps/website/content/docs-v2/api/stream-resource.mdx b/apps/website/content/docs-v2/api/stream-resource.mdx index 719efab1f..e383d3164 100644 --- a/apps/website/content/docs-v2/api/stream-resource.mdx +++ b/apps/website/content/docs-v2/api/stream-resource.mdx @@ -3,7 +3,7 @@ `streamResource` is the core primitive of the library. It creates a reactive resource that opens a server-sent event stream, tracks loading and error states, and exposes the latest emitted value — all within Angular's signal-based reactivity model. ```ts -import { streamResource } from '@ngxp/stream-resource'; +import { streamResource } from '@cacheplane/stream-resource'; // Inside a component or service with injection context const repo = streamResource({ diff --git a/apps/website/content/docs-v2/concepts/state-management.mdx b/apps/website/content/docs-v2/concepts/state-management.mdx index 6c1c9e94a..9953d7d58 100644 --- a/apps/website/content/docs-v2/concepts/state-management.mdx +++ b/apps/website/content/docs-v2/concepts/state-management.mdx @@ -66,6 +66,7 @@ Every state update from the agent creates a new signal value. Angular's change d const hasErrors = computed(() => agent.value().analysis.issues.length > 0 ); +``` ## What's Next diff --git a/apps/website/content/docs-v2/guides/streaming.mdx b/apps/website/content/docs-v2/guides/streaming.mdx index b3dc03962..0a9df886a 100644 --- a/apps/website/content/docs-v2/guides/streaming.mdx +++ b/apps/website/content/docs-v2/guides/streaming.mdx @@ -15,7 +15,7 @@ Create a `streamResource` in your component, pass it a message, and bind to the ```typescript import { Component, computed } from '@angular/core'; -import { streamResource } from '@stream-resource/angular'; +import { streamResource } from '@cacheplane/stream-resource'; import { BaseMessage } from '@langchain/core/messages'; @Component({ selector: 'app-chat', templateUrl: './chat.component.html' }) @@ -24,10 +24,10 @@ export class ChatComponent { assistantId: 'chat_agent', }); - readonly isStreaming = computed(() => this.chat.status() === 'streaming'); + readonly isStreaming = computed(() => this.chat.status() === 'loading'); send(text: string) { - this.chat.stream({ messages: [{ role: 'user', content: text }] }); + this.chat.submit({ messages: [{ role: 'user', content: text }] }); } } ``` @@ -126,7 +126,7 @@ If the SSE connection drops or the agent throws, `status()` transitions to `'err ```typescript import { Component, computed, effect } from '@angular/core'; -import { streamResource } from '@stream-resource/angular'; +import { streamResource } from '@cacheplane/stream-resource'; import { BaseMessage } from '@langchain/core/messages'; @Component({ selector: 'app-chat', templateUrl: './chat.component.html' }) @@ -139,7 +139,7 @@ export class ChatComponent { retry() { // Re-stream using the same thread so context is preserved - this.chat.stream(); + this.chat.submit(); } } ``` @@ -184,22 +184,22 @@ The value is in milliseconds. A `throttle` of `0` (default) disables batching an | Background summarisation | 150 ms | -Each call to `chat.stream()` opens a new SSE connection. Connections are automatically closed when the agent run completes or when the Angular component is destroyed — you do not need to manage the lifecycle manually. +Each call to `chat.submit()` opens a new SSE connection. Connections are automatically closed when the agent run completes or when the Angular component is destroyed — you do not need to manage the lifecycle manually. ## What's Next - + Resume conversations across page reloads using thread IDs and checkpointers. - + Pause agent execution mid-stream to collect human input before continuing. - + Unit-test components that use streamResource with the built-in test harness. - + Full option reference for streamResource(), including all configuration keys. diff --git a/apps/website/content/docs-v2/guides/subgraphs.mdx b/apps/website/content/docs-v2/guides/subgraphs.mdx index 2663160e1..5519b47be 100644 --- a/apps/website/content/docs-v2/guides/subgraphs.mdx +++ b/apps/website/content/docs-v2/guides/subgraphs.mdx @@ -76,7 +76,7 @@ const pipelineStatus = computed(() => { Render live progress for each subagent using the signals above. - + ```typescript import { computed } from '@angular/core'; @@ -183,16 +183,13 @@ Use **subagents** when tasks are independent and can run in parallel, when each ## What's Next - + Understand how streamResource() surfaces tokens, status, and errors in real time. - + Write unit and integration tests for orchestrator graphs and subagent interactions. - + Full reference for streamResource() options, signals, and subagent configuration. - - Patterns for retries, fallbacks, and surfacing errors from deeply nested agents. - diff --git a/apps/website/content/docs-v2/guides/time-travel.mdx b/apps/website/content/docs-v2/guides/time-travel.mdx index 37501fbb9..743025e54 100644 --- a/apps/website/content/docs-v2/guides/time-travel.mdx +++ b/apps/website/content/docs-v2/guides/time-travel.mdx @@ -80,7 +80,7 @@ Expose checkpoint history directly in your component to let users scrub through ```typescript import { Component, inject, computed } from '@angular/core'; -import { streamResource } from '@stream-resource/angular'; +import { streamResource } from '@cacheplane/stream-resource'; import { AgentService } from './agent.service'; @Component({ @@ -159,16 +159,13 @@ Time travel is most useful during development. Inspect why an agent chose a part ## What's Next - + Configure thread storage so checkpoints survive page reloads and are available across sessions. - + Understand how streamResource() surfaces incremental updates and how history integrates with live streaming state. - + Full reference for streamResource() options, signals, and the submit() API including checkpoint parameters. - - Deep dive into branch management, merging strategies, and presenting multi-branch UIs to end users. - From 43aae7ef4f99b7d16d8d418d3bf55c609e315075 Mon Sep 17 00:00:00 2001 From: Brian Love Date: Sat, 4 Apr 2026 16:25:55 -0700 Subject: [PATCH 03/12] fix(website): convert all Tabs to label prop syntax --- .../content/docs-v2/getting-started/installation.mdx | 6 +++--- apps/website/content/docs-v2/getting-started/quickstart.mdx | 6 +++--- apps/website/content/docs-v2/guides/deployment.mdx | 6 +++--- apps/website/content/docs-v2/guides/interrupts.mdx | 6 +++--- apps/website/content/docs-v2/guides/persistence.mdx | 6 +++--- 5 files changed, 15 insertions(+), 15 deletions(-) diff --git a/apps/website/content/docs-v2/getting-started/installation.mdx b/apps/website/content/docs-v2/getting-started/installation.mdx index f06f1f942..8200f7f67 100644 --- a/apps/website/content/docs-v2/getting-started/installation.mdx +++ b/apps/website/content/docs-v2/getting-started/installation.mdx @@ -48,8 +48,8 @@ Any option passed to `streamResource()` directly overrides the global provider c ## Environment setup - - + + For local development, run a LangGraph server: @@ -61,7 +61,7 @@ langgraph dev ``` - + For production, point to your LangGraph Cloud deployment: diff --git a/apps/website/content/docs-v2/getting-started/quickstart.mdx b/apps/website/content/docs-v2/getting-started/quickstart.mdx index 96f8fcff6..de1aee2fb 100644 --- a/apps/website/content/docs-v2/getting-started/quickstart.mdx +++ b/apps/website/content/docs-v2/getting-started/quickstart.mdx @@ -33,8 +33,8 @@ export const appConfig: ApplicationConfig = { Use `streamResource()` in a component field initializer. Every property on the returned ref is an Angular Signal. - - + + ```typescript // chat.component.ts @@ -67,7 +67,7 @@ export class ChatComponent { ``` - + ```html diff --git a/apps/website/content/docs-v2/guides/deployment.mdx b/apps/website/content/docs-v2/guides/deployment.mdx index 91abe0c21..ad47a7a43 100644 --- a/apps/website/content/docs-v2/guides/deployment.mdx +++ b/apps/website/content/docs-v2/guides/deployment.mdx @@ -6,8 +6,8 @@ Configure streamResource() for production with LangGraph Cloud, environment-base Point `apiUrl` to your LangGraph Cloud deployment. - - + + ```typescript // app.config.ts @@ -24,7 +24,7 @@ export const environment = { ``` - + ```typescript // app.config.ts diff --git a/apps/website/content/docs-v2/guides/interrupts.mdx b/apps/website/content/docs-v2/guides/interrupts.mdx index 8063b852f..ec35897a4 100644 --- a/apps/website/content/docs-v2/guides/interrupts.mdx +++ b/apps/website/content/docs-v2/guides/interrupts.mdx @@ -10,8 +10,8 @@ Use interrupts for human approval, late-binding decisions, or any step where the When an agent interrupts, the `interrupt()` signal contains the interrupt data. - - + + ```typescript // approval.component.ts @@ -30,7 +30,7 @@ pendingApproval = computed(() => agent.interrupt()); ``` - + ```html diff --git a/apps/website/content/docs-v2/guides/persistence.mdx b/apps/website/content/docs-v2/guides/persistence.mdx index 2eb55eca9..aff7da2b0 100644 --- a/apps/website/content/docs-v2/guides/persistence.mdx +++ b/apps/website/content/docs-v2/guides/persistence.mdx @@ -10,8 +10,8 @@ LangGraph checkpoints state at every super-step. streamResource() connects to th Save the thread ID to localStorage so conversations survive page refreshes. - - + + ```typescript // chat.component.ts @@ -23,7 +23,7 @@ const chat = streamResource<{ messages: BaseMessage[] }>({ ``` - + ```html From 949829d9ee2b4bcd8e39546fd886d6da5b3fb036 Mon Sep 17 00:00:00 2001 From: Brian Love Date: Sat, 4 Apr 2026 16:27:35 -0700 Subject: [PATCH 04/12] docs(website): rewrite Angular Signals concept with streaming lifecycle and Python code --- .../docs-v2/concepts/angular-signals.mdx | 542 +++++++++++++++++- 1 file changed, 513 insertions(+), 29 deletions(-) diff --git a/apps/website/content/docs-v2/concepts/angular-signals.mdx b/apps/website/content/docs-v2/concepts/angular-signals.mdx index 5fb2a5887..dccc83db2 100644 --- a/apps/website/content/docs-v2/concepts/angular-signals.mdx +++ b/apps/website/content/docs-v2/concepts/angular-signals.mdx @@ -1,75 +1,559 @@ # Angular Signals -streamResource() is built on Angular Signals — the reactive primitive introduced in Angular 16+. Every property on a StreamResourceRef is a Signal, making it work seamlessly with OnPush change detection, computed values, and effect callbacks. +Angular Signals are the reactive primitive that powers streamResource(). If you're coming from a Python AI/agent background and wondering how Angular handles real-time streaming data, this page is your guide. Every property on a StreamResourceRef is a Signal, which means your templates update automatically as tokens arrive — no manual subscriptions, no async pipes, no RxJS boilerplate. -## Signals primer + +Think of Signals like a Python property with built-in change notification. When the value changes, every consumer — templates, computed values, effects — re-evaluates automatically. If you've used Pydantic models with validators that react to field changes, Signals are the Angular equivalent but deeply integrated into the rendering engine. + + +## What Are Angular Signals? -A Signal is a reactive value container. When a Signal's value changes, Angular automatically re-renders any template that reads it. +A Signal is a reactive value container introduced in Angular 16+. You create one, read it by calling it like a function, and Angular tracks which templates and computations depend on it. ```typescript -// streamResource returns Signals, not Observables -const chat = streamResource({ assistantId: 'agent' }); +import { signal, computed } from '@angular/core'; + +// Create a writable signal +const count = signal(0); + +// Read the current value — call it like a function +console.log(count()); // 0 + +// Update the value +count.set(1); +count.update(prev => prev + 1); -chat.messages() // Signal — call to read -chat.status() // Signal -chat.error() // Signal -chat.isLoading() // Signal (computed) +// Derive new values with computed() +const doubled = computed(() => count() * 2); +console.log(doubled()); // 4 ``` -## Computed values +The key insight: Angular knows which Signals a template reads. When those Signals change, Angular re-renders only the affected parts of the DOM. No diffing the entire tree, no zone.js overhead. -Use `computed()` to derive new Signals from streamResource signals. +## How streamResource Uses Signals Internally + +Under the hood, streamResource() receives Server-Sent Events (SSE) over HTTP and feeds them into RxJS BehaviorSubjects. It then converts those BehaviorSubjects into Angular Signals using `toSignal()`. This is the bridge between the async streaming world and Angular's synchronous reactivity model. + + + ```typescript -const lastMessage = computed(() => - chat.messages().at(-1)?.content ?? '' -); +// Simplified view of what streamResource does internally: + +// 1. SSE events arrive as an observable stream +const messages$ = new BehaviorSubject([]); +const status$ = new BehaviorSubject('idle'); + +// 2. Each SSE chunk updates the BehaviorSubject +transport.onChunk(chunk => { + messages$.next([...messages$.getValue(), chunk.message]); +}); + +// 3. BehaviorSubjects become Signals via toSignal() +const messages = toSignal(messages$, { initialValue: [] }); +const status = toSignal(status$, { initialValue: 'idle' }); + +// 4. Your component reads pure Signals — no RxJS knowledge needed +``` + + + + +```typescript +import { streamResource } from '@cacheplane/stream-resource'; + +// You never touch BehaviorSubjects or toSignal() yourself. +// streamResource() hands you clean Signals: +const chat = streamResource({ + assistantId: 'chat_agent', +}); + +chat.messages(); // Signal +chat.status(); // Signal +chat.error(); // Signal +chat.isLoading(); // Signal +chat.value(); // Signal +``` + + + + + +The BehaviorSubject-to-Signal conversion means you get the best of both worlds: RxJS handles the async SSE transport (reconnection, backpressure, error recovery), while Signals handle the synchronous UI reactivity (change detection, template binding, computed derivations). You only interact with the Signal side. + + +## The Streaming Lifecycle as Signals + +Every streamResource() instance moves through a lifecycle: **idle**, **loading**, tokens arriving, then **resolved** (or **error**). The `status()` Signal reflects each transition in real time. + + + +The resource has been created but no request has been submitted yet. All Signals hold their initial values. + +```typescript +const chat = streamResource({ + assistantId: 'chat_agent', +}); + +console.log(chat.status()); // 'idle' +console.log(chat.messages()); // [] +console.log(chat.isLoading()); // false +``` + + + +After calling `submit()`, the status transitions to `'loading'`. The SSE connection is open and the agent is processing. + +```typescript +chat.submit({ messages: [{ role: 'user', content: 'Explain quantum computing' }] }); + +console.log(chat.status()); // 'loading' +console.log(chat.isLoading()); // true +console.log(chat.messages()); // [] (no tokens yet) +``` + -const messageCount = computed(() => - chat.messages().length + +As the agent generates tokens, the `messages()` Signal updates with each chunk. The status remains `'loading'` throughout. + +```typescript +// After first few tokens arrive: +console.log(chat.status()); // 'loading' (still streaming) +console.log(chat.messages()); // [AIMessageChunk("Quantum computing uses...")] + +// After more tokens: +console.log(chat.messages()); // [AIMessageChunk("Quantum computing uses qubits...")] +// The message content grows as tokens stream in +``` + + + +The agent has finished. All tokens have arrived. The status transitions to `'resolved'`. + +```typescript +console.log(chat.status()); // 'resolved' +console.log(chat.isLoading()); // false +console.log(chat.messages()); // [AIMessage("Quantum computing uses qubits to...")] +``` + + + +If the agent fails or the connection drops, the status transitions to `'error'` and the `error()` Signal contains the failure details. + +```typescript +console.log(chat.status()); // 'error' +console.log(chat.error()); // HttpErrorResponse { status: 500, ... } +console.log(chat.isLoading()); // false +``` + + + +## Composing Derived State with computed() + +`computed()` lets you derive new Signals from streamResource Signals. These derived Signals update automatically whenever their dependencies change — during streaming, that means every time a new token arrives. + +```typescript +import { computed } from '@angular/core'; +import { streamResource } from '@cacheplane/stream-resource'; + +const chat = streamResource({ + assistantId: 'chat_agent', +}); + +// Count all messages in the conversation +const messageCount = computed(() => chat.messages().length); + +// Get the last message (useful for showing the latest response) +const lastMessage = computed(() => chat.messages().at(-1)); + +// Extract just the assistant's messages +const assistantMessages = computed(() => + chat.messages().filter(m => m._getType() === 'ai') ); -const isIdle = computed(() => - chat.status() === 'idle' +// Track which tools the agent is actively calling +const activeTools = computed(() => + chat.messages() + .filter(m => m._getType() === 'ai') + .flatMap(m => m.tool_calls ?? []) + .filter(tc => !tc.result) ); + +// Build a user-facing error message +const errorDisplay = computed(() => { + const err = chat.error(); + if (!err) return null; + if (err instanceof HttpErrorResponse) { + return err.status === 429 + ? 'Rate limited. Please wait a moment.' + : `Server error (${err.status})`; + } + return 'An unexpected error occurred.'; +}); + +// Combine multiple signals into a single view model +const viewModel = computed(() => ({ + messages: chat.messages(), + isStreaming: chat.isLoading(), + canSend: chat.status() !== 'loading', + messageCount: messageCount(), + error: errorDisplay(), +})); +``` + + +A `computed()` only re-evaluates when one of its dependencies actually changes, and it caches the result. If `chat.messages()` emits the same reference, downstream computeds skip their work entirely. This matters for high-frequency streaming where tokens arrive rapidly. + + +## Side Effects with effect() + +Use `effect()` when a Signal change should trigger work that lives outside the template — logging, analytics, scrolling, persisting state. Effects run in the injection context and are automatically cleaned up when the component is destroyed. + +```typescript +import { effect } from '@angular/core'; + +// Log errors for observability +effect(() => { + const err = chat.error(); + if (err) { + console.error('[StreamResource] Agent error:', err); + this.analytics.track('agent_error', { error: err }); + } +}); + +// Auto-scroll to bottom when new messages arrive +effect(() => { + const msgs = chat.messages(); + if (msgs.length > 0) { + // Schedule after Angular renders the new message + setTimeout(() => { + this.chatContainer.nativeElement.scrollTo({ + top: this.chatContainer.nativeElement.scrollHeight, + behavior: 'smooth', + }); + }); + } +}); + +// Track streaming duration for performance monitoring +effect(() => { + const status = chat.status(); + if (status === 'loading') { + this.streamStart = performance.now(); + } + if (status === 'resolved' && this.streamStart) { + const duration = performance.now() - this.streamStart; + this.analytics.track('stream_duration_ms', { duration }); + this.streamStart = null; + } +}); +``` + + +Writing to a Signal inside an `effect()` can create infinite loops. If you need to transform one Signal into another, use `computed()` instead. Reserve `effect()` for side effects that leave the reactive graph — DOM manipulation, logging, analytics, network calls. + + +## Template Patterns + +Angular's new control flow syntax (`@if`, `@for`, `@switch`) works naturally with Signals. Here's a complete chat template that handles every lifecycle state. + +```typescript +import { ChangeDetectionStrategy, Component, computed, effect, ElementRef, ViewChild } from '@angular/core'; +import { streamResource } from '@cacheplane/stream-resource'; + +@Component({ + selector: 'app-chat', + changeDetection: ChangeDetectionStrategy.OnPush, + template: ` + + @switch (chat.status()) { + @case ('loading') { +
+ Agent is responding... +
+ } + @case ('error') { +
+ {{ errorDisplay() }} + +
+ } + } + + +
+ @for (message of chat.messages(); track $index) { + @switch (message._getType()) { + @case ('human') { +
+ {{ message.content }} +
+ } + @case ('ai') { +
+ {{ message.content }} + + + @for (tool of message.tool_calls ?? []; track tool.id) { +
+ Called: {{ tool.name }} +
+ } +
+ } + @case ('tool') { +
+ {{ message.name }}: {{ message.content }} +
+ } + } + } @empty { +
+ Send a message to start the conversation. +
+ } +
+ + +
+ + +
+ `, +}) +export class ChatComponent { + @ViewChild('chatContainer') chatContainer!: ElementRef; + + chat = streamResource({ + assistantId: 'chat_agent', + }); + + errorDisplay = computed(() => { + const err = this.chat.error(); + if (!err) return ''; + return err instanceof HttpErrorResponse + ? `Error ${err.status}: ${err.statusText}` + : 'Connection lost. Please retry.'; + }); + + scrollEffect = effect(() => { + const msgs = this.chat.messages(); + if (msgs.length) { + setTimeout(() => + this.chatContainer?.nativeElement.scrollTo({ + top: this.chatContainer.nativeElement.scrollHeight, + behavior: 'smooth', + }) + ); + } + }); + + send(event: Event) { + event.preventDefault(); + const input = (event.target as HTMLFormElement).querySelector('input')!; + const content = input.value.trim(); + if (!content) return; + + this.chat.submit({ + messages: [{ role: 'user', content }], + }); + input.value = ''; + } + + retry() { + this.chat.submit({ + messages: [{ role: 'user', content: 'Please try again.' }], + }); + } +} +``` + +## OnPush Change Detection + +Every component using streamResource() should use `ChangeDetectionStrategy.OnPush`. Here's why it works and why it's efficient. + +With the default change detection strategy, Angular checks every component in the tree on every browser event — clicks, timers, HTTP responses. For a streaming agent emitting dozens of tokens per second, that means hundreds of unnecessary checks across your entire app. + +With OnPush, Angular only checks a component when: + +1. An `@Input()` reference changes +2. An event fires inside the component's template +3. A **Signal** that the template reads changes + +Since streamResource() exposes Signals, condition 3 handles everything. When a new token arrives and `messages()` updates, Angular marks only the components reading that Signal for check — not the entire tree. + +```typescript +@Component({ + // Always use OnPush with streamResource + changeDetection: ChangeDetectionStrategy.OnPush, + template: ` +

{{ chat.messages().length }} messages

+ @if (chat.isLoading()) { + + } + `, +}) +export class ChatComponent { + chat = streamResource({ assistantId: 'chat_agent' }); +} ``` -## OnPush change detection + +With older Observable-based patterns, you had to call `ChangeDetectorRef.markForCheck()` or use the `async` pipe to trigger OnPush updates. Signals do this automatically. When a Signal's value changes, Angular's internal notification system marks the component dirty — zero manual intervention. + + +## Python Agent to Angular Signals + +The real power of streamResource() is how it pairs a Python LangGraph agent with Angular Signals. The agent defines the logic; Signals surface the results in real time. + + + + +```python +from langgraph.graph import END, START, MessagesState, StateGraph +from langchain_openai import ChatOpenAI +from langchain_core.tools import tool + +llm = ChatOpenAI(model="gpt-5-mini", streaming=True) + +@tool +def search_knowledge_base(query: str) -> str: + """Search internal documentation for relevant information.""" + results = vector_store.similarity_search(query, k=3) + return "\n".join(r.page_content for r in results) + +tools = [search_knowledge_base] + +def call_model(state: MessagesState) -> dict: + response = llm.bind_tools(tools).invoke(state["messages"]) + return {"messages": [response]} + +def should_continue(state: MessagesState) -> str: + last_msg = state["messages"][-1] + if last_msg.tool_calls: + return "tools" + return END -Because Signals trigger change detection automatically, streamResource works perfectly with `ChangeDetectionStrategy.OnPush`. +# Build the graph +builder = StateGraph(MessagesState) +builder.add_node("model", call_model) +builder.add_node("tools", ToolNode(tools)) +builder.add_edge(START, "model") +builder.add_conditional_edges("model", should_continue) +builder.add_edge("tools", "model") + +graph = builder.compile() +``` + + + ```typescript +import { ChangeDetectionStrategy, Component, computed, effect } from '@angular/core'; +import { streamResource } from '@cacheplane/stream-resource'; + @Component({ + selector: 'app-chat', changeDetection: ChangeDetectionStrategy.OnPush, template: ` @for (msg of chat.messages(); track $index) { -

{{ msg.content }}

+ @switch (msg._getType()) { + @case ('human') { +
{{ msg.content }}
+ } + @case ('ai') { +
+ {{ msg.content }} + @for (tc of msg.tool_calls ?? []; track tc.id) { + {{ tc.name }} + } +
+ } + @case ('tool') { +
{{ msg.name }}: {{ msg.content }}
+ } + } + } + + @if (chat.isLoading()) { +
Agent is thinking...
} `, }) export class ChatComponent { - chat = streamResource({ assistantId: 'agent' }); + chat = streamResource({ + assistantId: 'chat_agent', + }); + + // Derived state from the Python agent's output + toolsUsed = computed(() => + this.chat.messages() + .filter(m => m._getType() === 'tool') + .map(m => m.name) + ); + + hasError = computed(() => this.chat.status() === 'error'); + + sendMessage(content: string) { + this.chat.submit({ + messages: [{ role: 'user', content }], + }); + } } ``` -## No RxJS required +
+
-Unlike traditional Angular HTTP patterns, streamResource doesn't use Observables. There are no subscriptions to manage, no async pipes needed, and no memory leak risks. +When the Python agent calls `search_knowledge_base`, the tool call streams to Angular as a message. When the tool returns, the result streams as another message. The agent's final response streams token by token. Every one of these events updates the `messages()` Signal, and your template re-renders the new content automatically. - -Signals are simpler for UI state. They synchronously read the latest value, compose with computed(), and integrate with Angular's template syntax. streamResource handles the async SSE connection internally and surfaces results as Signals. +## Performance: Signals vs Alternatives + +High-frequency token streaming puts unique pressure on a frontend framework. Here's why Signals with OnPush outperform the alternatives. + +| Approach | Token update cost | Memory overhead | Cleanup required | +|---|---|---|---| +| **Signals + OnPush** | Marks only reading components | None beyond Signal | Automatic | +| Observable + async pipe | Creates/destroys subscriptions per `@if` block | Subscription objects | Pipe handles it | +| Observable + manual subscribe | Full component check if you forget `markForCheck()` | Subscription tracking | Manual unsubscribe | +| Default change detection | Checks entire component tree | None | None | + +For a typical chat UI receiving 30-50 tokens per second: + +- **Signals + OnPush**: Only the message list component and its direct ancestors are checked. The sidebar, header, settings panel — all skipped. +- **Default strategy**: Every component in the tree is checked 30-50 times per second, even components with no streaming data. +- **Observable + async pipe**: Works correctly but creates and destroys subscriptions each time an `@if` or `@for` block re-evaluates, adding GC pressure during rapid streaming. + + +Signals use referential equality (`===`) by default. streamResource() creates new array references for `messages()` only when the array actually changes (a new token arrives). Between updates, reading `messages()` returns the same reference and skips downstream recomputation. For custom equality, pass an `equal` function when creating a `computed()`. ## What's Next - Understand how LangGraph agent state flows into Angular Signals. + How LangGraph agent state flows into Angular Signals and how to structure complex state. - See Signals in action with token-by-token streaming responses. + Configure stream modes, handle token-by-token rendering, and manage concurrent streams. + + + Understand the Python agent patterns that produce the events Signals consume. - Full reference for every Signal exposed by streamResource. + Full reference for every Signal, method, and option on StreamResourceRef. + + + Build human-in-the-loop approval flows that pause and resume the agent. + + + Deep dive into change detection optimization for streaming applications. From b12cba8007e2b35b0e01612e05296cc3cf6d8f15 Mon Sep 17 00:00:00 2001 From: Brian Love Date: Sat, 4 Apr 2026 16:27:48 -0700 Subject: [PATCH 05/12] docs(website): rewrite State Management with Python reducers and TypeScript mapping --- .../docs-v2/concepts/state-management.mdx | 535 ++++++++++++++++-- 1 file changed, 496 insertions(+), 39 deletions(-) diff --git a/apps/website/content/docs-v2/concepts/state-management.mdx b/apps/website/content/docs-v2/concepts/state-management.mdx index 9953d7d58..68397a602 100644 --- a/apps/website/content/docs-v2/concepts/state-management.mdx +++ b/apps/website/content/docs-v2/concepts/state-management.mdx @@ -1,84 +1,541 @@ # State Management -How state flows through streamResource() — from LangGraph's server-side state machine to Angular Signals in your templates. +How agent state flows from LangGraph's server-side state machine into Angular Signals — and why the separation between server state and UI state makes your app simpler, not more complex. -## State lives on the server + +LangGraph Platform owns the state. Angular owns the view. `streamResource()` is the read-only bridge between them. You never manually sync, serialize, or manage agent state in your Angular code. + + +## State Lives on the Server + +In a traditional Angular app, state lives in an NgRx store or a signals-based service. In a LangGraph app, **the agent's state lives on the server** — in LangGraph Platform's checkpoint store. Your Angular app is a stateless view layer that reads state through signals as the agent streams it back. + +This inversion is intentional. Agent state can span multiple LLM calls, tool executions, and human-in-the-loop interrupts. It needs to survive browser refreshes, reconnections, and even server deployments. A server-side checkpoint store handles all of that automatically. Your Angular app just calls `.submit()` and reads signals. + + + +Your Angular component calls `agent.submit({ messages: [userMsg] })`. No state is stored in the component. + + +`@cacheplane/stream-resource` forwards the input to `FetchStreamTransport`, which opens an HTTP POST and SSE connection to LangGraph Platform. + + +The agent runs its nodes — calling the LLM, invoking tools, checking conditions — and streams SSE events back with incremental state updates. + + +Incoming SSE chunks are parsed and pushed into BehaviorSubjects — one per signal type. + + +BehaviorSubjects are converted to Angular Signals via `toSignal()`. Every update triggers Angular's change detection automatically. + + +Components using `OnPush` re-render only when signal values change. No manual `detectChanges()`, no zone triggers, no subscriptions to manage. + + + +## Python State Design + +On the Python side, your agent's state is a `TypedDict`. The fields you define here are exactly what `streamResource()` exposes in TypeScript. Getting the Python state design right is the most important architectural decision in your agent. + +### The TypedDict Pattern + +Every LangGraph state is a `TypedDict`. Fields can be plain values or annotated with reducers that control how updates are merged. -Unlike traditional Angular state management (NgRx, signals stores), agent state lives on the LangGraph Platform. Your Angular app is a stateless view layer. + + +```python +from typing_extensions import TypedDict + +class ChatState(TypedDict): + messages: list # Will be replaced on each update + session_id: str # Single value, replaced on update + turn_count: int # Single value, replaced on update ``` -LangGraph Platform (source of truth) - ↓ SSE stream -FetchStreamTransport (transport layer) - ↓ events -streamResource() (signal conversion) - ↓ Signals -Angular templates (reactive rendering) + + + + +```python +from typing_extensions import TypedDict, Annotated +from operator import add + +class AgentState(TypedDict): + # Annotated[list, add] means: append new items, don't replace + messages: Annotated[list, add] + tool_results: Annotated[list, add] + + # Plain fields: each update replaces the previous value + status: str + current_plan: list[str] ``` -## The state shape + + + +```python +from langgraph.graph import MessagesState + +# MessagesState is a built-in TypedDict that pre-wires +# messages: Annotated[list[AnyMessage], add_messages] +# add_messages handles deduplication, type coercion, and ordering + +class ProjectState(MessagesState): + # Extend with your own fields + files: Annotated[list[str], add] # Accumulates file paths + analysis: dict # Latest analysis result + progress: int # 0–100 progress value +``` + + + + +### Reducers: How State Merges + +When a node returns `{"messages": [new_msg]}`, LangGraph doesn't replace the messages list — it **calls the reducer** to merge the update. This is what `Annotated[list, add]` means: use Python's `operator.add` to concatenate lists. + +```python +from typing_extensions import TypedDict, Annotated +from operator import add + +class ResearchState(TypedDict): + # Each node can append to these — they accumulate across the run + messages: Annotated[list, add] + sources: Annotated[list[str], add] + findings: Annotated[list[str], add] + + # These are replaced (last write wins) + query: str + model: str + confidence: float + +def researcher_node(state: ResearchState) -> dict: + result = llm.invoke(state["messages"]) + new_sources = extract_sources(result.content) + + # Returns partial state — only fields being updated + # LangGraph merges this into the existing state + return { + "messages": [result], # Appended via reducer + "sources": new_sources, # Appended via reducer + "confidence": 0.87, # Replaced + } +``` + + +Nodes return only the fields they change. LangGraph merges partial updates into the full state object. This is why you can have 10 nodes each updating different fields without conflicts. + + +## TypeScript Interface Mapping + +The TypeScript interface you pass to `streamResource()` is your contract with the Python state. Every Python state field maps to a TypeScript property. The types don't need to match exactly — they just need to be compatible with the JSON that LangGraph streams back. + + + + +```python +from typing_extensions import TypedDict, Annotated +from operator import add +from langgraph.graph import MessagesState + +class ProjectState(MessagesState): + # From MessagesState: messages: Annotated[list[AnyMessage], add_messages] + files: Annotated[list[str], add] + analysis: dict[str, any] | None + progress: int + plan: Annotated[list[str], add] + error: str | None +``` -Your state type defines what the agent manages. The `value()` signal exposes the full state object. + + ```typescript +import { BaseMessage } from '@langchain/core/messages'; + interface ProjectState { + // Maps from MessagesState.messages messages: BaseMessage[]; + + // Maps from Python fields (reducers are transparent — you see the final list) files: string[]; - analysis: { score: number; issues: string[] }; + analysis: { score: number; issues: string[]; summary: string } | null; + progress: number; + plan: string[]; + error: string | null; } const agent = streamResource({ assistantId: 'project_agent', }); - -// Access any state field as a reactive value -const files = computed(() => agent.value().files); -const score = computed(() => agent.value().analysis.score); ``` -## Thread state vs application state + + - -Thread state (managed by LangGraph) and application state (managed by Angular) are separate concerns. Don't try to sync them — read thread state from signals, manage UI state with Angular signals. - +| Python type | TypeScript type | +|-------------|-----------------| +| `str` | `string` | +| `int` / `float` | `number` | +| `bool` | `boolean` | +| `list[str]` | `string[]` | +| `dict[str, any]` | `Record` | +| `TypedDict` | `interface` or `type` | +| `str \| None` | `string \| null` | +| `list[AnyMessage]` | `BaseMessage[]` | +| `Annotated[list, add]` | Same as the list type — reducer is invisible | + + + + +Once you define the interface, every field is accessible via `agent.value()`: ```typescript -// Thread state — from the agent -const messages = agent.messages(); // Read-only signal -const agentStatus = agent.status(); // Read-only signal +// Full typed state object +const state = agent.value(); // Signal + +// Computed values from nested fields +const score = computed(() => agent.value().analysis?.score ?? 0); +const fileCount = computed(() => agent.value().files.length); +const isDone = computed(() => agent.value().progress === 100); + +// Direct messages access (shortcut for agent.value().messages) +const messages = agent.messages(); // Signal +``` -// Application state — your Angular code -const sidebarOpen = signal(true); // Your UI state -const selectedTab = signal('chat'); // Your UI state +## State Updates During Streaming + +The agent doesn't wait until it's finished to send state updates. It streams partial state updates as each node completes. Your Angular signals update incrementally throughout the run. + +### How Partial Updates Arrive + +LangGraph streams in `values` mode by default — each SSE event contains the full state snapshot after a node completes. In `messages` mode, you get individual message tokens as they're generated. + +```typescript +const agent = streamResource({ + assistantId: 'project_agent', + // Default: values mode — full state after each node + // streamMode: 'messages' — token-by-token for text fields +}); ``` -## State updates are immutable +### Signals Update Mid-Stream -Every state update from the agent creates a new signal value. Angular's change detection picks this up automatically. +Because every state update is a new signal value, your templates reflect the agent's progress in real time — without polling, without timers, without manual state management. ```typescript -// This works with OnPush because the Signal reference changes -@for (msg of agent.messages(); track $index) { -

{{ msg.content }}

+@Component({ + changeDetection: ChangeDetectionStrategy.OnPush, + template: ` + +

Files processed: {{ agent.value().files.length }}

+ + + + + + @for (step of agent.value().plan; track step) { +
  • {{ step }}
  • + } + + + @for (msg of agent.messages(); track $index) { + + } + ` +}) +export class ProjectComponent { + readonly agent = streamResource({ + assistantId: 'project_agent', + }); } +``` + +### Immutability and OnPush -// Computed values re-evaluate when dependencies change +Every signal update produces a new object reference. Angular's `OnPush` change detection compares references — when a signal emits a new value, the component re-renders. You never need to clone objects or call `markForCheck()` manually. + +```typescript +// Safe: computed() re-evaluates when agent.value() changes const hasErrors = computed(() => - agent.value().analysis.issues.length > 0 + (agent.value().analysis?.issues ?? []).length > 0 ); + +// Safe: @for tracks by identity, not index, for stable DOM +// track $index is fine for messages since they always append +@for (msg of agent.messages(); track $index) { + +} + +// Safe: null-coalescing handles state fields not yet populated +const score = computed(() => agent.value().analysis?.score ?? 0); +``` + + +`streamResource()` uses `toSignal()` internally with `requireSync: false`. Signals always have a value — even before the first stream update. You never need to handle `undefined` explicitly for the signal itself, though individual state fields may be `null` until the agent populates them. + + +## Thread State vs Application State + +There are two kinds of state in a LangGraph Angular app, and keeping them separate makes your code much easier to reason about. + +**Thread state** is owned by LangGraph Platform. You read it through `streamResource()` signals. You never write to it directly — you only send new input via `.submit()`. + +**Application state** is owned by your Angular component or service. It's UI-only: sidebar visibility, active tab, selected message, form input values. It has nothing to do with the agent. + +```typescript +@Component({ + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class ChatComponent { + // --- Thread state (from agent, read-only) --- + readonly agent = streamResource({ + assistantId: 'chat_agent', + }); + + // Convenience computed values from thread state + readonly messages = this.agent.messages; // Signal + readonly isLoading = this.agent.isLoading; // Signal + readonly interrupted = this.agent.interrupt; // Signal + + // --- Application state (your Angular signals) --- + readonly sidebarOpen = signal(true); + readonly activeTab = signal<'chat' | 'history' | 'settings'>('chat'); + readonly inputText = signal(''); + readonly selectedMessageId = signal(null); + + // --- Actions --- + send() { + const text = this.inputText(); + if (!text.trim()) return; + this.agent.submit({ messages: [{ role: 'user', content: text }] }); + this.inputText.set(''); // UI state — clear the input + } + + approve() { + this.agent.submit(null, { resume: { approved: true } }); + } +} +``` + + +A common mistake is copying `agent.messages()` into a local signal to "control" it. This creates stale state bugs and defeats the purpose of the reactive signal model. Read thread state directly from `agent.*` signals and derive what you need with `computed()`. + + +## The Checkpoint Model + +LangGraph Platform persists state at every node boundary using a checkpoint store. Each checkpoint is an immutable snapshot of the full state at a point in time. + +``` +Thread: "user_123_session" +│ +├── Checkpoint 1 ← After call_model: { messages: [HumanMessage, AIMessage] } +├── Checkpoint 2 ← After tool_node: { messages: [..., ToolMessage] } +├── Checkpoint 3 ← After call_model: { messages: [..., AIMessage("Here's what I found...")] } +└── (current) +``` + +### What This Means for Your Angular App + +**Resumable threads** — If the user refreshes the page or closes the browser, the thread is still there. Pass the same `threadId` and `streamResource()` will restore the full conversation history automatically. + +**Time travel** — You can fork a thread at any checkpoint and replay it with different input. This powers the time-travel debugging guides. + +**Interrupt persistence** — When the agent raises an `Interrupt`, the checkpoint captures everything. The agent can be resumed hours or days later. + +```typescript +const agent = streamResource({ + assistantId: 'chat_agent', + + // Same threadId = restored conversation history + threadId: signal(this.route.snapshot.params['threadId']), + + // New threadId auto-created for new conversations + onThreadId: (id) => this.router.navigate(['/chat', id]), +}); + +// Read checkpoint history for time-travel UI +const history = agent.history(); // Signal +const branch = agent.branch(); // Signal — active branch ID +``` + +For full checkpoint and time-travel patterns, see the [Persistence guide](/docs/guides/persistence) and [Time Travel guide](/docs/guides/time-travel). + +## Custom State Fields + +`messages` is just one field. Real agents carry rich state: structured plans, tool results, progress indicators, metadata, and more. Every custom field you define in Python is available in your TypeScript interface. + + + + +```python +from typing_extensions import TypedDict, Annotated +from operator import add +from langgraph.graph import MessagesState +from langchain_openai import ChatOpenAI + +llm = ChatOpenAI(model="gpt-5-mini") + +class ResearchState(MessagesState): + # Accumulating lists — each node can append + plan: Annotated[list[str], add] + sources: Annotated[list[str], add] + findings: Annotated[list[str], add] + + # Scalar progress + progress: int # 0–100 + + # Structured results + report: dict | None # Final report when complete + + # Agent metadata + query: str + model_used: str + +def planner_node(state: ResearchState) -> dict: + steps = llm.invoke([ + {"role": "system", "content": "Break this query into research steps."}, + *state["messages"] + ]) + plan_items = steps.content.split("\n") + return { + "plan": plan_items, # Appended via reducer + "progress": 10, + "model_used": "gpt-5-mini", + } + +def researcher_node(state: ResearchState) -> dict: + # Runs once per plan step in a loop + for step in state["plan"]: + result = search(step) + yield { + "findings": [result], # Each iteration appends + "progress": state["progress"] + (80 // len(state["plan"])), + } +``` + + + + +```typescript +import { BaseMessage } from '@langchain/core/messages'; +import { streamResource } from '@cacheplane/stream-resource'; + +interface ResearchState { + messages: BaseMessage[]; + plan: string[]; + sources: string[]; + findings: string[]; + progress: number; + report: { + title: string; + summary: string; + sections: { heading: string; content: string }[]; + } | null; + query: string; + model_used: string; +} + +// In your component: +readonly agent = streamResource({ + assistantId: 'research_agent', +}); +``` + + + + +```typescript +@Component({ + changeDetection: ChangeDetectionStrategy.OnPush, + template: ` + +
    +
    +
    +

    {{ agent.value().progress }}% complete

    + + +
      + @for (step of agent.value().plan; track step) { +
    1. {{ step }}
    2. + } +
    + + +
      + @for (finding of agent.value().findings; track finding) { +
    • {{ finding }}
    • + } +
    + + + @if (agent.value().report; as report) { +
    +

    {{ report.title }}

    +

    {{ report.summary }}

    + @for (section of report.sections; track section.heading) { +
    +

    {{ section.heading }}

    +

    {{ section.content }}

    +
    + } +
    + } + ` +}) +export class ResearchComponent { + readonly agent = streamResource({ + assistantId: 'research_agent', + }); + + startResearch(query: string) { + this.agent.submit({ + messages: [{ role: 'user', content: query }], + }); + } +} +``` + +
    +
    + +### Derived State with computed() + +You rarely need to consume `agent.value()` raw in your template. Use `computed()` to derive clean, focused values: + +```typescript +readonly agent = streamResource({ + assistantId: 'research_agent', +}); + +// Derived signals — recalculate only when their dependencies change +readonly progress = computed(() => this.agent.value().progress); +readonly isPlanning = computed(() => this.agent.value().plan.length === 0 && this.agent.isLoading()); +readonly sourceCount = computed(() => this.agent.value().sources.length); +readonly hasReport = computed(() => this.agent.value().report !== null); +readonly reportTitle = computed(() => this.agent.value().report?.title ?? ''); ``` ## What's Next - Learn how streamResource uses Signals for reactive rendering. + How streamResource() uses Angular Signals for zero-subscription reactive rendering. + + + Configure stream modes — values, messages, events — for different use cases. - Persist thread state so users can resume conversations later. + Thread-based conversation persistence and checkpoint configuration. + + + Fork threads at any checkpoint and replay with different input. - - Preserve context across sessions with LangGraph's memory store. + + Human-in-the-loop approval flows and how interrupt state surfaces in Angular. + + + Nodes, edges, and the graph execution model behind the state machine. -``` From 7327bd3c14ae87bf8e1082d9c74c185dd70c994e Mon Sep 17 00:00:00 2001 From: Brian Love Date: Sat, 4 Apr 2026 16:27:49 -0700 Subject: [PATCH 06/12] docs(website): rewrite Memory guide with Python Store API and Angular patterns --- .../website/content/docs-v2/guides/memory.mdx | 412 ++++++++++++++++-- 1 file changed, 372 insertions(+), 40 deletions(-) diff --git a/apps/website/content/docs-v2/guides/memory.mdx b/apps/website/content/docs-v2/guides/memory.mdx index f2da7b9d5..ca507fdcc 100644 --- a/apps/website/content/docs-v2/guides/memory.mdx +++ b/apps/website/content/docs-v2/guides/memory.mdx @@ -1,82 +1,414 @@ # Memory -Memory in LangGraph preserves useful context that later steps can read back. streamResource() exposes memory through the messages and state signals, with thread persistence providing cross-session continuity. +Memory gives your LangGraph agent the ability to recall past interactions, user preferences, and learned facts. There are two distinct kinds: short-term memory scoped to a single thread (conversation), and long-term memory that persists across threads using the LangGraph Store API. streamResource() surfaces both through Angular Signals so your components stay reactive without manual state wiring. -Short-term memory lives within a thread (conversation history). Long-term memory persists across threads via LangGraph's memory store. +Short-term memory lives within a thread — it is the conversation history plus any custom state fields your agent accumulates during a run. Long-term memory lives in the LangGraph Store and survives across threads, users, and sessions. Think of short-term as "what happened in this conversation" and long-term as "what the agent knows about this user." -## Short-term memory (thread-scoped) +## Agent State with Custom Memory Fields -Every message in a thread is automatically preserved. When you reconnect with the same `threadId`, the full conversation history is restored. +Every LangGraph agent has a state schema. You control what the agent remembers by adding fields to that schema. Messages accumulate automatically, but you can define any additional fields the agent should track. -```typescript -const chat = streamResource<{ messages: BaseMessage[] }>({ - assistantId: 'memory_agent', - threadId: signal(userId()), // User-specific thread -}); + + -// Messages accumulate across the conversation -const messageCount = computed(() => chat.messages().length); +```python +from typing_extensions import TypedDict, Annotated +from operator import add +from langgraph.graph import END, START, StateGraph +from langchain_openai import ChatOpenAI -// Resume where you left off on next visit -// threadId persists, so history is restored -``` +llm = ChatOpenAI(model="gpt-5-mini") + +class State(TypedDict): + messages: Annotated[list, add] + user_preferences: dict # Accumulated user preferences + conversation_summary: str # Rolling summary of past context + mentioned_topics: list[str] # Topics the user has brought up + +def call_model(state: State) -> dict: + system = "You are a helpful assistant." + if state.get("conversation_summary"): + system += f"\n\nPrevious context: {state['conversation_summary']}" + if state.get("user_preferences"): + system += f"\n\nUser preferences: {state['user_preferences']}" -## Accessing agent state as memory + response = llm.invoke([ + {"role": "system", "content": system}, + *state["messages"] + ]) + return {"messages": [response]} -The `value()` signal contains the full agent state, which can include custom memory fields. +def update_memory(state: State) -> dict: + """Extract preferences and topics from the latest exchange.""" + extraction = llm.invoke([ + {"role": "system", "content": ( + "Extract any user preferences and topics from " + "this conversation. Return JSON with keys: " + "preferences (dict), topics (list[str]), summary (str)." + )}, + *state["messages"][-4:] # Last two exchanges + ]) + parsed = parse_json(extraction.content) + return { + "user_preferences": { + **state.get("user_preferences", {}), + **parsed.get("preferences", {}), + }, + "mentioned_topics": parsed.get("topics", []), + "conversation_summary": parsed.get("summary", ""), + } + +builder = StateGraph(State) +builder.add_node("model", call_model) +builder.add_node("update_memory", update_memory) +builder.add_edge(START, "model") +builder.add_edge("model", "update_memory") +builder.add_edge("update_memory", END) + +graph = builder.compile() +``` + + + ```typescript +import { Component, computed, ChangeDetectionStrategy } from '@angular/core'; +import { streamResource, BaseMessage } from '@cacheplane/stream-resource'; + interface AgentState { messages: BaseMessage[]; - userPreferences: { theme: string; language: string }; - projectContext: { name: string; files: string[] }; + user_preferences: Record; + conversation_summary: string; + mentioned_topics: string[]; } -const agent = streamResource({ - assistantId: 'context_agent', - threadId: signal(projectId()), -}); +@Component({ + selector: 'app-memory-chat', + templateUrl: './memory.component.html', + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class MemoryChatComponent { + agent = streamResource({ + assistantId: 'memory_agent', + threadId: signal(localStorage.getItem('memory-thread')), + onThreadId: (id) => localStorage.setItem('memory-thread', id), + }); + + // Reactive memory signals derived from agent state + preferences = computed(() => this.agent.value()?.user_preferences ?? {}); + summary = computed(() => this.agent.value()?.conversation_summary ?? ''); + topics = computed(() => this.agent.value()?.mentioned_topics ?? []); + messages = computed(() => this.agent.messages()); -// Read memory fields from agent state -const prefs = computed(() => agent.value().userPreferences); -const context = computed(() => agent.value().projectContext); + send(input: string) { + this.agent.submit({ + messages: [{ role: 'user', content: input }], + }); + } +} ``` -## Cross-session memory + + + +```html +
    + @for (msg of messages(); track msg) { +
    {{ msg.content }}
    + } + + @if (agent.isLoading()) { +
    Agent is thinking...
    + } +
    + + + +``` + +
    +
    + + +When `update_memory` returns `user_preferences`, the dict is merged into the existing state. For list fields using the `Annotated[list, add]` reducer, new items are appended. Design your state schema with these merge semantics in mind. + + +## Short-Term Memory (Thread-Scoped) + +Short-term memory is the simplest form: the conversation history and any accumulated state fields within a single thread. Every message, tool call, and state update is automatically checkpointed. When a user reconnects with the same `threadId`, the full history is restored. -Thread persistence enables memory that spans sessions. The agent decides what to store in its state. +```python +from langgraph.checkpoint.postgres import PostgresSaver + +checkpointer = PostgresSaver.from_connection_string(DATABASE_URL) +graph = builder.compile(checkpointer=checkpointer) + +# Every invocation within the same thread accumulates state +result = graph.invoke( + {"messages": [{"role": "user", "content": "I prefer dark mode"}]}, + config={"configurable": {"thread_id": "user_42_session"}} +) + +# Later invocation — same thread, memory intact +result = graph.invoke( + {"messages": [{"role": "user", "content": "What theme do I like?"}]}, + config={"configurable": {"thread_id": "user_42_session"}} +) +# Agent responds: "You mentioned you prefer dark mode." +``` + +On the Angular side, thread-scoped memory requires no extra code. The `threadId` signal handles it: ```typescript -// User returns days later — same threadId resumes context -const agent = streamResource({ +const chat = streamResource({ assistantId: 'memory_agent', - threadId: signal(localStorage.getItem('agent-thread')), - onThreadId: (id) => localStorage.setItem('agent-thread', id), + threadId: signal(userId()), // Same user = same thread = same memory }); -// Agent recalls past decisions, preferences, and context -// No explicit memory management needed on the Angular side +// chat.messages() restores full history on reconnect +// chat.value() restores all custom state fields +``` + +## Long-Term Memory (Cross-Thread) with the Store API + +Short-term memory disappears when you start a new thread. For knowledge that should persist across conversations — user preferences, learned facts, project context — use the LangGraph Store API. The Store is a key-value layer that any node can read from and write to, independent of the current thread. + + + + +```python +from langgraph.graph import END, START, StateGraph, MessagesState +from langgraph.store.base import BaseStore +from langchain_openai import ChatOpenAI + +llm = ChatOpenAI(model="gpt-5-mini") + +def recall_memories(state: MessagesState, *, store: BaseStore, config) -> dict: + """Load long-term memories for this user before responding.""" + user_id = config["configurable"]["user_id"] + + # Fetch all memories in this user's namespace + memories = store.search(("memories", user_id)) + memory_text = "\n".join( + f"- {m.value['content']}" for m in memories + ) + + system = ( + "You are a helpful assistant with long-term memory.\n\n" + f"What you remember about this user:\n{memory_text}" + ) + response = llm.invoke([ + {"role": "system", "content": system}, + *state["messages"] + ]) + return {"messages": [response]} + +def save_memories(state: MessagesState, *, store: BaseStore, config) -> dict: + """Extract and persist new facts to the Store.""" + user_id = config["configurable"]["user_id"] + + extraction = llm.invoke([ + {"role": "system", "content": ( + "Extract new facts about the user from the latest " + "exchange. Return a JSON list of strings. " + "Return [] if nothing new." + )}, + *state["messages"][-4:] + ]) + facts = parse_json(extraction.content) + + for fact in facts: + store.put( + ("memories", user_id), + key=str(uuid4()), + value={"content": fact}, + ) + + return {} + +builder = StateGraph(MessagesState) +builder.add_node("recall", recall_memories) +builder.add_node("save", save_memories) +builder.add_edge(START, "recall") +builder.add_edge("recall", "save") +builder.add_edge("save", END) + +graph = builder.compile() +``` + + + + +```typescript +import { Component, computed, signal, ChangeDetectionStrategy } from '@angular/core'; +import { streamResource, BaseMessage } from '@cacheplane/stream-resource'; + +@Component({ + selector: 'app-longterm-chat', + templateUrl: './memory.component.html', + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class LongTermChatComponent { + // Each conversation gets a new thread, but the agent + // remembers the user across all of them via the Store. + agent = streamResource<{ messages: BaseMessage[] }>({ + assistantId: 'memory_agent', + config: { configurable: { user_id: 'user_42' } }, + }); + + messages = computed(() => this.agent.messages()); + + send(input: string) { + this.agent.submit({ + messages: [{ role: 'user', content: input }], + }); + } +} +``` + + + + +```html +
    + @for (msg of messages(); track msg) { +
    {{ msg.content }}
    + } + + @if (agent.isLoading()) { +
    Thinking...
    + } + +
    + + +
    +
    +``` + +
    +
    + + +The checkpointer saves thread state (short-term memory). The Store saves cross-thread knowledge (long-term memory). They serve different purposes and you will typically use both. The checkpointer is configured at compile time; the Store is injected into nodes that declare a `store` parameter. + + +## Semantic Memory with Vector Search + +For agents that accumulate hundreds or thousands of memories, keyword matching is not enough. The Store API supports semantic search with embeddings, so your agent can retrieve the most relevant memories for any given context. + +```python +from langchain_openai import OpenAIEmbeddings +from langgraph.store.base import BaseStore + +def recall_relevant(state: MessagesState, *, store: BaseStore, config) -> dict: + """Retrieve memories semantically related to the current question.""" + user_id = config["configurable"]["user_id"] + query = state["messages"][-1].content + + # Vector search — returns memories ranked by cosine similarity + results = store.search( + ("memories", user_id), + query=query, + limit=5, + ) + + memory_text = "\n".join( + f"- [{r.score:.2f}] {r.value['content']}" for r in results + ) + + response = llm.invoke([ + {"role": "system", "content": ( + "Relevant memories (similarity score in brackets):\n" + f"{memory_text}\n\n" + "Use these memories to personalize your response." + )}, + *state["messages"] + ]) + return {"messages": [response]} +``` + +The `store.search()` call accepts a `query` string and returns results ranked by vector similarity. You control how many results to retrieve with the `limit` parameter. Each result includes a `score` field (0 to 1) indicating how relevant the memory is to the query. + + +Semantic search requires an embedding model configured on the Store. LangGraph Platform handles this configuration in `langgraph.json`. When running locally, pass the embeddings provider when constructing your Store instance. + + +## Surfacing Memory in Angular with value() + +The `value()` signal is the primary way memory surfaces in your Angular components. It contains the full agent state object, including all custom memory fields. Because it is a Signal, your template re-renders automatically through OnPush change detection whenever the agent state changes. + +```typescript +// The value() signal contains everything the agent knows +const state = agent.value(); + +// Access specific memory fields +const prefs = state?.user_preferences; +const summary = state?.conversation_summary; +const topics = state?.mentioned_topics; + +// Compose derived signals for template binding +const hasMemory = computed(() => { + const val = agent.value(); + return val?.conversation_summary || val?.mentioned_topics?.length; +}); ``` - -The agent controls what gets stored in memory. streamResource() just surfaces the current state. Design your agent's state schema to include the fields you want to persist. +For long-term memory stored in the Store, the agent must explicitly include retrieved memories in its response or state output. The Store lives server-side; your Angular app only sees what the agent puts into the thread state. + +## Memory Best Practices + + +Every field in your state schema is persisted by the checkpointer. Only include fields the agent actively uses. Avoid dumping raw LLM outputs into state — extract structured data instead. + + + +Thread state grows with every message and state update. For long-running conversations, consider summarizing older messages into a `conversation_summary` field and trimming the message list. This keeps checkpoints small and LLM context windows manageable. + + + +Use hierarchical namespaces like `("memories", user_id)` or `("project", project_id, "notes")` to keep long-term memories organized. This also makes cleanup straightforward — delete an entire namespace when a user requests data removal. ## What's Next - Save thread IDs and resume conversations across sessions. + Configure checkpointers and thread storage for production deployments. Replay and branch agent runs from any past checkpoint. - - Understand how agent state flows into Angular Signals. + + Pause for human input before the agent acts on its memory. - - Test memory and state behavior with MockStreamTransport. + + How agent state flows from LangGraph into Angular Signals. From 8fe1d26e869d3a46ed6fbf9824f6cb04c209e54c Mon Sep 17 00:00:00 2001 From: Brian Love Date: Sat, 4 Apr 2026 16:28:03 -0700 Subject: [PATCH 07/12] docs(website): rewrite Agent Architecture with full Python patterns and Angular mapping --- .../docs-v2/concepts/agent-architecture.mdx | 684 +++++++++++++++++- 1 file changed, 658 insertions(+), 26 deletions(-) diff --git a/apps/website/content/docs-v2/concepts/agent-architecture.mdx b/apps/website/content/docs-v2/concepts/agent-architecture.mdx index 32a334de6..1f6e0d95c 100644 --- a/apps/website/content/docs-v2/concepts/agent-architecture.mdx +++ b/apps/website/content/docs-v2/concepts/agent-architecture.mdx @@ -1,69 +1,701 @@ # Agent Architecture -How AI agents work — the planning, execution, and tool-calling lifecycle that streamResource() connects your Angular app to. +How AI agents work — the planning, execution, and tool-calling lifecycle that streamResource() connects your Angular app to. This page shows you the Python patterns that power modern agents and exactly how each pattern surfaces in Angular through `@cacheplane/stream-resource`. -## The agent loop + +Every section below shows the Python backend code first, then the Angular frontend code that consumes it. You need both halves to build a production agent application — LangGraph handles the intelligence, streamResource() handles the reactivity. + + +## The Agent Loop -An AI agent follows a cycle: +Every agent follows a five-phase cycle. Understanding this cycle is critical because each phase maps to a specific streamResource() signal in your Angular app. - -The user sends a message via `submit()`. streamResource() posts it to LangGraph Platform. + +The user sends a message. On the Angular side, `submit()` posts input to LangGraph Platform. On the Python side, the message lands in the graph's `messages` state key. + +```python +class AgentState(TypedDict): + messages: Annotated[list, add] + plan: list[str] + tool_results: dict +``` + -The LLM decides what to do next — respond directly, call a tool, or delegate to a subagent. +The LLM examines the full message history plus any accumulated state. It decides what to do next — respond directly, call one or more tools, or delegate to a subagent. + +```python +def plan(state: AgentState, config: RunnableConfig) -> dict: + system = """You are a research assistant. Given the conversation, + decide whether to respond directly, search for information, + or analyze data. Use tools when the user needs factual answers.""" + + response = llm.bind_tools(tools).invoke([ + {"role": "system", "content": system}, + *state["messages"], + ]) + return {"messages": [response]} +``` + -Tools run (database queries, API calls, code execution). Results feed back into state. +If the LLM decided to call tools, LangGraph routes to the tool node. Tools run — database queries, API calls, code execution — and their results feed back into state as `ToolMessage` entries. + +```python +from langgraph.prebuilt import ToolNode + +tool_node = ToolNode(tools) +# LangGraph automatically calls each tool the LLM requested +# and appends ToolMessage results to state["messages"] +``` + -The agent streams its response token-by-token. streamResource() updates the `messages()` signal in real-time. +After tools finish (or if no tools were needed), the agent streams its final response token by token. streamResource() updates the `messages()` signal in real time so your Angular template re-renders incrementally. + +```typescript +// Angular side — messages update as tokens arrive +@if (agent.isLoading()) { + +} +@for (msg of agent.messages(); track msg.id) { + +} +``` + -State is checkpointed. The agent may loop back to Plan, or finish. +LangGraph checkpoints the full state — messages, tool results, plan, everything. The agent may loop back to Plan (if tools returned data that needs further reasoning) or finish. The checkpoint is what enables time-travel debugging via `history()`. + +```python +from langgraph.checkpoint.postgres import PostgresSaver + +checkpointer = PostgresSaver.from_connection_string(DATABASE_URL) +graph = builder.compile(checkpointer=checkpointer) +``` + -## Tool calling +## ReAct Pattern + +ReAct (Reason + Act) is the most common agent pattern. The agent reasons about the user's question, decides to call a tool, observes the result, and loops until it has enough information to answer. + + + + +```python +from langgraph.graph import END, START, StateGraph +from langgraph.prebuilt import ToolNode +from langchain_openai import ChatOpenAI +from langchain_core.tools import tool +from typing_extensions import TypedDict, Annotated +from operator import add + +# --- State --- +class AgentState(TypedDict): + messages: Annotated[list, add] + +# --- Tools --- +@tool +def search_docs(query: str) -> str: + """Search the knowledge base for relevant documents.""" + results = vector_store.similarity_search(query, k=3) + return "\n\n".join(doc.page_content for doc in results) + +@tool +def query_database(sql: str) -> str: + """Run a read-only SQL query against the analytics database.""" + rows = db.execute(text(sql)).fetchall() + return json.dumps([dict(r) for r in rows]) + +@tool +def get_weather(city: str) -> str: + """Get current weather for a city.""" + resp = httpx.get(f"https://api.weather.com/v1/{city}") + return resp.json()["summary"] + +tools = [search_docs, query_database, get_weather] + +# --- LLM with tools bound --- +llm = ChatOpenAI(model="gpt-5-mini") + +def call_model(state: AgentState) -> dict: + response = llm.bind_tools(tools).invoke(state["messages"]) + return {"messages": [response]} + +# --- Routing --- +def should_continue(state: AgentState) -> str: + last_message = state["messages"][-1] + if last_message.tool_calls: + return "tools" + return END + +# --- Graph --- +builder = StateGraph(AgentState) +builder.add_node("model", call_model) +builder.add_node("tools", ToolNode(tools)) + +builder.add_edge(START, "model") +builder.add_conditional_edges("model", should_continue) +builder.add_edge("tools", "model") # After tools, reason again + +graph = builder.compile() +``` + + + + +```typescript +import { ChangeDetectionStrategy, Component, computed } from '@angular/core'; +import { streamResource } from '@cacheplane/stream-resource'; + +interface AgentState { + messages: BaseMessage[]; +} + +@Component({ + selector: 'app-react-agent', + changeDetection: ChangeDetectionStrategy.OnPush, + template: ` + @for (msg of messages(); track msg.id) { + + } + + @if (activeTools().length) { + + } + + @for (result of completedTools(); track result.id) { + + } + `, +}) +export class ReactAgentComponent { + agent = streamResource({ + assistantId: 'react_agent', + }); + + messages = this.agent.messages; -Agents extend their capabilities through tools. streamResource() tracks tool execution: + // Tools currently executing (spinner, progress bar) + activeTools = computed(() => this.agent.toolProgress()); + + // Tools that finished with results (expandable cards) + completedTools = computed(() => this.agent.toolCalls()); + + send(text: string) { + this.agent.submit({ + messages: [{ role: 'human', content: text }], + }); + } +} +``` + + + + +The key insight: `should_continue` is the decision point. If the LLM's response contains `tool_calls`, the graph routes to the `tools` node. If not, it ends. After tools execute, the graph loops back to `model` so the LLM can reason about the tool results. This loop continues until the LLM responds without requesting any tools. + +## Tool Calling Deep Dive + +Tools are how agents interact with the outside world. Understanding both the Python definition and the Angular consumption is essential. + +### Defining Tools in Python + +Every tool is a Python function decorated with `@tool`. LangGraph converts the function signature and docstring into the JSON schema that the LLM uses to decide when and how to call it: + +```python +from langchain_core.tools import tool +from pydantic import BaseModel, Field + +# Simple tool — args inferred from function signature +@tool +def calculate(expression: str) -> str: + """Evaluate a mathematical expression and return the result.""" + return str(eval(expression)) # Use a sandbox in production + +# Structured tool — explicit schema with validation +class EmailInput(BaseModel): + to: str = Field(description="Recipient email address") + subject: str = Field(description="Email subject line") + body: str = Field(description="Email body content") + +@tool(args_schema=EmailInput) +def send_email(to: str, subject: str, body: str) -> str: + """Send an email to the specified recipient.""" + mail_service.send(to=to, subject=subject, body=body) + return f"Email sent to {to}" +``` + + +The LLM reads the docstring to decide when to call a tool. A vague docstring like "does stuff" means the LLM will not know when to use it. Be specific: what the tool does, what it returns, when to use it. + + +### How Tools Surface in Angular + +When the agent calls a tool, streamResource() exposes the execution lifecycle through two signals: + + + ```typescript +// toolProgress() — tools currently executing +// Updates in real time as tools start and complete + const agent = streamResource({ - assistantId: 'research_agent', + assistantId: 'react_agent', }); -// Currently executing tools -const tools = computed(() => agent.toolProgress()); +// Each entry has: name, args, status +const activeTools = computed(() => agent.toolProgress()); + +// Template usage +@Component({ + changeDetection: ChangeDetectionStrategy.OnPush, + template: ` + @for (tool of activeTools(); track tool.id) { +
    + + Running {{ tool.name }}... +
    {{ tool.args | json }}
    +
    + } + `, +}) +export class ToolProgressComponent { + activeTools = computed(() => this.agent.toolProgress()); +} +``` + +
    + + +```typescript +// toolCalls() — completed tool calls with results +// Available after each tool finishes -// Completed tool calls with results const completedTools = computed(() => agent.toolCalls()); + +// Each entry has: name, args, result, duration +@Component({ + changeDetection: ChangeDetectionStrategy.OnPush, + template: ` + @for (call of completedTools(); track call.id) { +
    + + {{ call.name }} + {{ call.duration }}ms + +
    +

    Input

    +
    {{ call.args | json }}
    +
    +
    +

    Output

    +
    {{ call.result }}
    +
    +
    + } + `, +}) +export class ToolResultsComponent { + completedTools = computed(() => this.agent.toolCalls()); +} ``` -## Multi-agent patterns +
    +
    + +### Tool Execution Flow + +The full lifecycle from Python tool definition to Angular UI update: + + + +The model returns an `AIMessage` with a `tool_calls` array. Each entry specifies the tool name and arguments. + + +The `should_continue` conditional edge detects `tool_calls` and routes to the `tools` node. + + +`ToolNode` calls the Python function. The result is wrapped in a `ToolMessage` and appended to state. + + +LangGraph Platform streams the tool call and result as SSE events to the Angular client. + + +`toolProgress()` updates during execution. `toolCalls()` updates when the tool completes. Both trigger OnPush change detection. + + + +## Multi-Agent Architecture + +When a single agent with tools is not enough, you can compose multiple agents into a supervisor-worker architecture. A supervisor agent receives the user's request, decides which specialist to delegate to, and synthesizes the final answer. + + + + +```python +from langgraph.graph import END, START, StateGraph +from langchain_openai import ChatOpenAI +from typing import Literal +from typing_extensions import TypedDict, Annotated +from operator import add + +class OrchestratorState(TypedDict): + messages: Annotated[list, add] + next_agent: str + research_output: str + analysis_output: str + +llm = ChatOpenAI(model="gpt-5-mini") + +# --- Supervisor --- +def supervisor(state: OrchestratorState) -> dict: + response = llm.bind_tools([route_tool]).invoke([ + {"role": "system", "content": """You are a supervisor. + Route to 'researcher' for fact-finding, + 'analyst' for data analysis, + 'writer' for drafting content, + or 'finish' if the task is complete."""}, + *state["messages"], + ]) + destination = response.tool_calls[0]["args"]["agent"] + return {"next_agent": destination, "messages": [response]} + +# --- Specialist subagents (each is its own compiled graph) --- +researcher_graph = build_researcher_agent() +analyst_graph = build_analyst_agent() +writer_graph = build_writer_agent() -Complex tasks use multiple agents working together: +# --- Routing --- +def route_to_agent(state: OrchestratorState) -> str: + return state["next_agent"] -- **Orchestrator** — one agent delegates to specialized subagents -- **Pipeline** — agents process sequentially, each refining the output -- **Debate** — agents review each other's work +# --- Orchestrator graph --- +builder = StateGraph(OrchestratorState) +builder.add_node("supervisor", supervisor) +builder.add_node("researcher", researcher_graph) +builder.add_node("analyst", analyst_graph) +builder.add_node("writer", writer_graph) -streamResource() supports these patterns through the `subagents()` and `activeSubagents()` signals. +builder.add_edge(START, "supervisor") +builder.add_conditional_edges("supervisor", route_to_agent, { + "researcher": "researcher", + "analyst": "analyst", + "writer": "writer", + "finish": END, +}) +# After each specialist, return to supervisor +builder.add_edge("researcher", "supervisor") +builder.add_edge("analyst", "supervisor") +builder.add_edge("writer", "supervisor") + +graph = builder.compile() +``` + + + + +```typescript +import { ChangeDetectionStrategy, Component, computed } from '@angular/core'; +import { streamResource } from '@cacheplane/stream-resource'; + +interface OrchestratorState { + messages: BaseMessage[]; + next_agent: string; + research_output: string; + analysis_output: string; +} + +@Component({ + selector: 'app-multi-agent', + changeDetection: ChangeDetectionStrategy.OnPush, + template: ` + + + + +
    +

    All Subagents

    + @for (entry of allSubagents(); track entry[0]) { + + } +
    + `, +}) +export class MultiAgentComponent { + orchestrator = streamResource({ + assistantId: 'orchestrator', + subagentToolNames: ['researcher', 'analyst', 'writer'], + }); + + messages = this.orchestrator.messages; + + // Currently running subagents with live status + activeWorkers = computed(() => this.orchestrator.activeSubagents()); + + // Full map of all subagents (active + completed) + allSubagents = computed(() => + Array.from(this.orchestrator.subagents().entries()) + ); + + send(text: string) { + this.orchestrator.submit({ + messages: [{ role: 'human', content: text }], + }); + } +} +``` + +
    +
    + + +The `subagentToolNames` option tells streamResource() 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. + + +## Error Handling and Recovery + +Agents fail. Tools throw exceptions, APIs time out, LLMs hallucinate invalid tool arguments. A robust architecture handles all of these gracefully. + +### Python-Side Error Handling + +```python +from langchain_core.tools import tool, ToolException + +@tool(handle_tool_error=True) +def query_database(sql: str) -> str: + """Run a read-only SQL query against the analytics database.""" + if "DROP" in sql.upper() or "DELETE" in sql.upper(): + raise ToolException("Destructive queries are not allowed.") + try: + rows = db.execute(text(sql)).fetchall() + return json.dumps([dict(r) for r in rows]) + except Exception as e: + raise ToolException(f"Query failed: {str(e)}") +``` + +When `handle_tool_error=True` is set, LangGraph catches `ToolException` and feeds the error message back to the LLM as a `ToolMessage`. The LLM sees the error and can retry with corrected arguments or explain the failure to the user. + +### How Errors Surface in Angular + +```typescript +const agent = streamResource({ + assistantId: 'react_agent', +}); + +// The error() signal captures both transport and agent errors +const error = computed(() => agent.error()); + +// In your template +@Component({ + changeDetection: ChangeDetectionStrategy.OnPush, + template: ` + @if (error()) { + + } + `, +}) +export class AgentComponent { + error = computed(() => this.agent.error()); + + retry() { + // Re-submit the last message to retry + this.agent.submit(this.lastInput); + } +} +``` + +### Error Recovery Strategies + +| Error type | Python behavior | Angular signal | +|---|---|---| +| Tool throws `ToolException` | Error fed back to LLM, agent retries | `toolCalls()` shows error in result | +| Tool throws unexpected error | LangGraph catches it, marks tool as failed | `error()` fires with details | +| LLM returns invalid tool args | ToolNode validation fails, error fed to LLM | `toolProgress()` shows failed status | +| Transport error (network) | N/A | `error()` fires, `status()` becomes `'error'` | +| Agent exceeds recursion limit | Graph raises `GraphRecursionError` | `error()` fires with recursion message | + + +LangGraph defaults to 25 recursion steps. If your agent loops between `model` and `tools` more than 25 times, it stops with a `GraphRecursionError`. Increase the limit in production with `graph.compile(recursion_limit=50)` or redesign the agent to converge faster. + + +## Checkpointing and Debugging + +Every time a node completes, LangGraph saves a checkpoint — a full snapshot of the agent's state at that moment. streamResource() exposes this checkpoint timeline to Angular, giving you time-travel debugging for free. + +### How Checkpoints Work + +```python +from langgraph.checkpoint.postgres import PostgresSaver + +checkpointer = PostgresSaver.from_connection_string(DATABASE_URL) +graph = builder.compile(checkpointer=checkpointer) + +# Every node execution creates a checkpoint: +# checkpoint_1: after "model" (LLM decided to call search_docs) +# checkpoint_2: after "tools" (search_docs returned results) +# checkpoint_3: after "model" (LLM responded with final answer) +``` + +### Exposing Checkpoints in Angular + +```typescript +const agent = streamResource({ + assistantId: 'react_agent', + threadId: signal('thread_abc123'), +}); + +// Full checkpoint timeline — every state snapshot +const timeline = computed(() => agent.history()); + +// Current branch (for time-travel) +const branch = computed(() => agent.branch()); +``` + +### Building a Debug Timeline + +```typescript +@Component({ + selector: 'app-debug-timeline', + changeDetection: ChangeDetectionStrategy.OnPush, + template: ` +
    + @for (checkpoint of history(); track checkpoint.id) { + + } +
    + +
    +

    State at checkpoint

    +
    {{ selectedState() | json }}
    +
    + `, +}) +export class DebugTimelineComponent { + history = computed(() => this.agent.history()); + currentCheckpoint = signal(null); + + selectedState = computed(() => { + const id = this.currentCheckpoint(); + return this.history().find(c => c.id === id)?.state; + }); + + timeTravel(checkpointId: string) { + this.currentCheckpoint.set(checkpointId); + this.agent.submit(null, { checkpoint: checkpointId }); + } +} +``` + + +When you submit from a previous checkpoint, LangGraph creates a new branch from that point. The original timeline is preserved. The `branch()` signal tells you which branch is currently active. See the [Time Travel guide](/docs/guides/time-travel) for the full walkthrough. + + +## Choosing an Architecture + +Not every application needs a multi-agent swarm. Here is a decision guide for picking the right level of complexity. + +### Single Agent with Tools + +**Use when:** Most applications. The user has a conversation, the agent calls tools as needed, and responds. + +```python +# Simple, powerful, covers 80% of use cases +builder = StateGraph(AgentState) +builder.add_node("model", call_model) +builder.add_node("tools", ToolNode(tools)) +builder.add_edge(START, "model") +builder.add_conditional_edges("model", should_continue) +builder.add_edge("tools", "model") +graph = builder.compile() +``` + +**Angular signals used:** `messages()`, `toolCalls()`, `toolProgress()`, `status()` + +### Single Agent with Human-in-the-Loop + +**Use when:** The agent takes high-stakes actions (sending emails, modifying data, making purchases) that need human approval. + +```python +from langgraph.types import Interrupt + +def propose_action(state: AgentState) -> dict: + plan = llm.invoke(state["messages"]) + raise Interrupt(value={"action": plan.content, "requires_approval": True}) + +def execute_action(state: AgentState) -> dict: + # Only runs after human approves + return perform_action(state["pending_action"]) +``` + +**Angular signals used:** `messages()`, `interrupt()`, `status()` plus `submit(null, { resume })` to approve + +### Multi-Agent Supervisor + +**Use when:** The task naturally decomposes into specialist roles (researcher, analyst, writer), and each specialist needs its own tools, prompts, and reasoning chain. + +```python +builder = StateGraph(OrchestratorState) +builder.add_node("supervisor", supervisor) +builder.add_node("researcher", researcher_subgraph) +builder.add_node("analyst", analyst_subgraph) +builder.add_conditional_edges("supervisor", route_to_agent) +``` + +**Angular signals used:** `messages()`, `subagents()`, `activeSubagents()`, `toolCalls()`, `status()` + +### Decision Matrix + +| Factor | Single agent | Single + approval | Multi-agent | +|---|---|---|---| +| Tool count | 1-10 | 1-10 | 10+ across specialists | +| Task complexity | Single domain | Single domain, high stakes | Cross-domain | +| Latency budget | Low | Medium (human wait) | Higher (multiple LLM calls) | +| State isolation | Shared | Shared + interrupt | Isolated per subagent | +| Angular complexity | Low | Medium | Higher | -Most applications only need a single agent with tools. Add subagents when you need true task delegation with isolated state. +Begin with a single agent and tools. Add human-in-the-loop when you need approval flows. Graduate to multi-agent only when a single agent's context window cannot hold all the tools and instructions it needs. ## What's Next - Learn the graph, node, and edge model that agents are built on. + Learn the graph, node, and edge primitives that agents are built on. - - Compose agents into multi-agent pipelines using subgraphs. + + Stream token-by-token responses with multiple stream modes. - Pause agent execution and wait for human approval mid-run. + Build human-in-the-loop approval flows that pause and resume agents. + + + Compose multi-agent systems with orchestrators and specialist workers. + + + Debug agents by stepping through checkpoint history and branching. + + + How Signals power the reactive model behind streamResource(). From 251ee541782f72736a86d095fde1339a9bc16c92 Mon Sep 17 00:00:00 2001 From: Brian Love Date: Sat, 4 Apr 2026 16:28:15 -0700 Subject: [PATCH 08/12] docs(website): rewrite Deployment guide with full LangGraph Cloud + Angular deployment --- .../content/docs-v2/guides/deployment.mdx | 416 ++++++++++++++++-- 1 file changed, 369 insertions(+), 47 deletions(-) diff --git a/apps/website/content/docs-v2/guides/deployment.mdx b/apps/website/content/docs-v2/guides/deployment.mdx index ad47a7a43..5d737cbfe 100644 --- a/apps/website/content/docs-v2/guides/deployment.mdx +++ b/apps/website/content/docs-v2/guides/deployment.mdx @@ -1,91 +1,407 @@ # Deployment -Configure streamResource() for production with LangGraph Cloud, environment-based URLs, and error handling patterns. +Deploy your LangGraph agent to the cloud and ship your Angular frontend to production with environment-based configuration, authentication, error handling, and observability. -## Production configuration +## Python: LangGraph Cloud deployment -Point `apiUrl` to your LangGraph Cloud deployment. +Your agent code needs a `langgraph.json` manifest at the project root. This file tells LangGraph Cloud how to build and serve your agent. - - +```json title="langgraph.json" +{ + "dependencies": ["."], + "graphs": { + "chat_agent": "./agent/graph.py:graph" + }, + "env": ".env" +} +``` -```typescript -// app.config.ts -provideStreamResource({ - apiUrl: environment.langgraphUrl, -}) +The `graphs` key maps an assistant ID (used by `streamResource()` on the Angular side) to the Python module path and graph variable. The `env` key points to a file with secrets like `OPENAI_API_KEY` that will be injected at runtime. + +### Agent entry point + +```python title="agent/graph.py" +from langchain_openai import ChatOpenAI +from langgraph.graph import StateGraph, MessagesState + +llm = ChatOpenAI(model="gpt-5-mini") + +def call_model(state: MessagesState): + return {"messages": [llm.invoke(state["messages"])]} + +graph = StateGraph(MessagesState) +graph.add_node("model", call_model) +graph.set_entry_point("model") +graph = graph.compile() ``` -```typescript -// environment.prod.ts +### Push and deploy + +```bash +# Initialize and push to GitHub +git init && git add . && git commit -m "initial agent" +gh repo create my-agent --public --source=. --push + +# Deploy via CLI (alternative to the LangSmith UI) +pip install langgraph-cli +langgraph deploy --project my-agent +``` + +The CLI watches your repository and builds a container image on LangGraph Cloud. First deployments take roughly 10-15 minutes. Subsequent pushes to the default branch trigger automatic redeployments. + +## LangSmith deployment walkthrough + +The LangSmith UI provides a visual deployment flow if you prefer not to use the CLI. + + + + +Navigate to [smith.langchain.com](https://smith.langchain.com) and click **Deployments** in the left sidebar, then **+ New Deployment**. + + + + +Authorize LangSmith to access your GitHub account. Select the repository containing your `langgraph.json`. LangSmith auto-detects the manifest and shows the graphs it found. + + + + +Add secrets like `OPENAI_API_KEY` in the deployment settings. These are encrypted at rest and injected into your container at runtime. You can also set `LANGCHAIN_TRACING_V2=true` here to enable automatic tracing. + + + + +Click **Deploy**. Once the build succeeds, you will see a deployment URL like `https://my-agent-abc123.langgraph.app`. Copy this URL for your Angular environment configuration. + + + + +## Angular: environment configuration + +Angular uses file-based environment replacement at build time rather than `process.env`. Create separate environment files for development and production. + + + + +```typescript title="src/environments/environment.ts" export const environment = { - langgraphUrl: 'https://your-project.langgraph.app', + production: false, + langgraphUrl: 'http://localhost:2024', + langsmithApiKey: '', // not needed locally }; ``` - + -```typescript -// app.config.ts -provideStreamResource({ - apiUrl: 'https://your-project.langgraph.app', -}) +```typescript title="src/environments/environment.prod.ts" +export const environment = { + production: true, + langgraphUrl: 'https://my-agent-abc123.langgraph.app', + langsmithApiKey: 'lsv2_pt_xxxxxxxx', +}; ``` +Wire the environment into `provideStreamResource()`: + +```typescript title="app.config.ts" +import { provideStreamResource } from '@cacheplane/stream-resource'; +import { environment } from '../environments/environment'; + +export const appConfig: ApplicationConfig = { + providers: [ + provideStreamResource({ + apiUrl: environment.langgraphUrl, + }), + ], +}; +``` + +Angular CLI replaces `environment.ts` with `environment.prod.ts` during `ng build --configuration production` automatically via the `fileReplacements` array in `angular.json`. + +## Authentication + +### API key for LangGraph Platform + +LangGraph Cloud deployments require an API key on every request. The recommended approach is an Angular HTTP interceptor that attaches the key as a header. + +```typescript title="auth.interceptor.ts" +import { HttpInterceptorFn } from '@angular/common/http'; +import { environment } from '../environments/environment'; + +export const langGraphAuthInterceptor: HttpInterceptorFn = (req, next) => { + if (req.url.startsWith(environment.langgraphUrl)) { + const cloned = req.clone({ + setHeaders: { + 'x-api-key': environment.langsmithApiKey, + }, + }); + return next(cloned); + } + return next(req); +}; +``` + +Register the interceptor in your application config: + +```typescript title="app.config.ts" +import { provideHttpClient, withInterceptors } from '@angular/common/http'; +import { langGraphAuthInterceptor } from './auth.interceptor'; + +export const appConfig: ApplicationConfig = { + providers: [ + provideHttpClient(withInterceptors([langGraphAuthInterceptor])), + provideStreamResource({ + apiUrl: environment.langgraphUrl, + }), + ], +}; +``` + + +Add `environment.prod.ts` to `.gitignore`. In CI, generate it from environment variables or inject secrets at build time. + + +### User-level authentication + +If your app has its own user authentication (JWT, session cookies), you can add a second interceptor or extend the one above to forward identity headers that your agent can use for per-user scoping. + +## CORS configuration + +When your Angular frontend and LangGraph backend are on different origins, you must configure CORS on the LangGraph side. + +In `langgraph.json`, add an `http` section: + +```json title="langgraph.json" +{ + "dependencies": ["."], + "graphs": { + "chat_agent": "./agent/graph.py:graph" + }, + "http": { + "cors": { + "allow_origins": ["https://your-angular-app.com"], + "allow_methods": ["GET", "POST", "PUT", "DELETE", "OPTIONS"], + "allow_headers": ["Content-Type", "x-api-key", "Authorization"], + "allow_credentials": true + } + } +} +``` + + +During local development with `langgraph dev`, CORS is permissive by default. You only need explicit CORS configuration for production deployments. + + ## Error boundaries -Handle errors gracefully in production. +Production apps need graceful error handling. Build a reactive error boundary using `streamResource()` signals. -```typescript -const chat = streamResource({ - assistantId: 'chat_agent', -}); +```typescript title="chat.component.ts" +import { ChangeDetectionStrategy, Component, computed } from '@angular/core'; +import { streamResource } from '@cacheplane/stream-resource'; -// Reactive error display -hasError = computed(() => chat.status() === 'error'); -errorMessage = computed(() => { - const err = chat.error(); - return err instanceof Error ? err.message : 'Something went wrong'; -}); +@Component({ + selector: 'app-chat', + changeDetection: ChangeDetectionStrategy.OnPush, + template: ` + @if (hasError()) { +
    +

    {{ errorMessage() }}

    + +
    + } + `, +}) +export class ChatComponent { + chat = streamResource({ + assistantId: 'chat_agent', + }); + + hasError = computed(() => this.chat.status() === 'error'); + + errorMessage = computed(() => { + const err = this.chat.error(); + if (err instanceof HttpErrorResponse) { + switch (err.status) { + case 401: return 'Authentication failed. Please check your API key.'; + case 429: return 'Rate limit exceeded. Please wait a moment.'; + case 503: return 'Agent is starting up. Please try again shortly.'; + default: return 'Something went wrong. Please try again.'; + } + } + return err instanceof Error ? err.message : 'An unexpected error occurred.'; + }); -// Retry after error -retry() { - chat.reload(); + retry(): void { + this.chat.reload(); + } } ``` -## Recovering interrupted streams +### Retry with exponential backoff -Use `joinStream()` to reconnect to a running stream after a network interruption. +For automated retries (network blips, transient 5xx errors), wrap `.submit()` with a backoff utility: + +```typescript title="retry-submit.ts" +export async function retrySubmit( + chat: ReturnType, + input: Record, + maxAttempts = 3, +): Promise { + for (let attempt = 0; attempt < maxAttempts; attempt++) { + try { + chat.submit(input); + return; + } catch { + if (attempt === maxAttempts - 1) throw new Error('Max retries exceeded'); + await new Promise(r => setTimeout(r, 1000 * 2 ** attempt)); + } + } +} +``` + +## Stream recovery + +Use `joinStream()` to reconnect to a running agent execution after a network interruption, page refresh, or navigation event. ```typescript -// If you know the run ID (e.g., from a status endpoint) -await chat.joinStream(runId, lastEventId); -// Resumes streaming from where it left off +// Store the run ID when starting a stream +const runId = this.chat.runId(); +localStorage.setItem('activeRunId', runId); + +// After reconnecting, resume from where the stream left off +const savedRunId = localStorage.getItem('activeRunId'); +if (savedRunId) { + await this.chat.joinStream(savedRunId, lastEventId); +} ``` +`joinStream()` replays any events the client missed, then switches to live streaming. This works because all state lives on the LangGraph Platform, and the SSE endpoint supports event ID-based resumption. + -streamResource() is a stateless client. All state lives on the LangGraph Platform. This means your Angular app can be deployed anywhere (CDN, edge, SSR) without state management concerns. +`streamResource()` is a stateless client. All state lives on the LangGraph Platform. This means your Angular app can be deployed anywhere (CDN, edge, SSR) without state management concerns. Scale your frontend independently of your agent infrastructure. -## Checklist +## CI/CD pipeline + +A typical pipeline deploys the Python agent and Angular frontend in parallel since they are independent artifacts. + +```yaml title=".github/workflows/deploy.yml" +name: Deploy +on: + push: + branches: [main] + +jobs: + deploy-agent: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: '3.12' + - run: pip install langgraph-cli + - run: langgraph deploy --project my-agent + env: + LANGSMITH_API_KEY: ${{ secrets.LANGSMITH_API_KEY }} + + deploy-angular: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: '22' + - run: npm ci + - name: Generate production environment + run: | + cat > src/environments/environment.prod.ts << 'EOF' + export const environment = { + production: true, + langgraphUrl: '${{ secrets.LANGGRAPH_URL }}', + langsmithApiKey: '${{ secrets.LANGSMITH_API_KEY }}', + }; + EOF + - run: npx ng build --configuration production + - name: Deploy to hosting + run: | + # Replace with your hosting provider's CLI + # e.g., npx vercel deploy --prod dist/my-app/browser + echo "Deploy dist/ to your hosting platform" +``` + +## Monitoring + +### LangSmith observability + +When `LANGCHAIN_TRACING_V2=true` is set in your agent environment, every run is automatically traced in LangSmith. No code changes are needed. + +Key metrics to track in production: + +| Metric | Where to find it | Why it matters | +|--------|-------------------|----------------| +| End-to-end latency | LangSmith Runs tab | Directly affects user-perceived responsiveness | +| Error rate | LangSmith Runs tab, filter by error | Spike detection for broken tools or provider outages | +| Token usage | LangSmith per-run detail | Cost control and budget alerting | +| Time to first token | Angular performance monitoring | Stream startup latency visible to users | +| Thread count | LangGraph Platform dashboard | Capacity planning | + +### Client-side monitoring + +Track stream health from your Angular app: + +```typescript +const status = this.chat.status(); // 'idle' | 'streaming' | 'error' +const isStreaming = this.chat.isStreaming(); + +// Log stream lifecycle for your APM tool +effect(() => { + const s = this.chat.status(); + if (s === 'error') { + this.analytics.trackError('stream_error', this.chat.error()); + } +}); +``` + +## Deployment checklist -Point to your LangGraph Cloud deployment URL. +Point `provideStreamResource({ apiUrl })` to your LangGraph Cloud deployment URL via `environment.prod.ts`. + + +Add an HTTP interceptor to attach `x-api-key` headers to all LangGraph requests. + + +Add your Angular app's origin to the `allow_origins` list in `langgraph.json`. - -Show user-friendly error messages and retry buttons. + +Show user-friendly error messages for 401, 429, 503, and network failures. Provide retry buttons. + + +Store `runId` and use `joinStream()` to reconnect after network interruptions. -Store threadId in localStorage or a backend so users can resume conversations. +Store `threadId` in `localStorage` or a backend so users can resume conversations across sessions. -Set `throttle` option if token-by-token updates are too frequent for your UI. +Set the `throttle` option if token-by-token updates are too frequent for your UI rendering. + + +Set `LANGCHAIN_TRACING_V2=true` in your agent environment for production observability. + + +Add `environment.prod.ts` to `.gitignore`. Generate it from CI secrets at build time. + + +Automate agent and Angular deployments on push to your main branch. + + +Confirm LangSmith traces are arriving and set up alerts for error rate spikes and latency regressions. @@ -93,15 +409,21 @@ Set `throttle` option if token-by-token updates are too frequent for your UI. - Test agent interactions deterministically before deploying. + Test agent interactions deterministically before deploying to production. Store thread IDs so users can resume conversations across sessions. - Tune streaming options like throttle for production performance. + Tune streaming options like throttle and stream modes for production performance. + + + Understand the agent patterns your deployment will serve. Full reference for provideStreamResource configuration options. + + Deep dive into error recovery patterns beyond basic error boundaries. + From 055654506a1f8dc4dc40d97128cb465d827974b2 Mon Sep 17 00:00:00 2001 From: Brian Love Date: Sat, 4 Apr 2026 16:28:45 -0700 Subject: [PATCH 09/12] docs(website): rewrite Persistence guide with Python checkpointers and thread UI --- .../content/docs-v2/guides/persistence.mdx | 311 ++++++++++++++++-- 1 file changed, 278 insertions(+), 33 deletions(-) diff --git a/apps/website/content/docs-v2/guides/persistence.mdx b/apps/website/content/docs-v2/guides/persistence.mdx index aff7da2b0..73b62cc72 100644 --- a/apps/website/content/docs-v2/guides/persistence.mdx +++ b/apps/website/content/docs-v2/guides/persistence.mdx @@ -1,47 +1,260 @@ # Persistence -Thread persistence keeps conversations alive across page refreshes, browser restarts, and session changes. streamResource() manages thread state through the `threadId` signal and `onThreadId` callback. +Thread persistence keeps conversations alive across page refreshes, browser restarts, and server deployments. This guide covers configuring checkpointers on the Python side and wiring up thread management in your Angular components with streamResource(). -LangGraph checkpoints state at every super-step. streamResource() connects to these checkpoints via thread IDs, letting you resume exactly where you left off. +LangGraph checkpoints agent state at every super-step. Each checkpoint is keyed by a thread ID. streamResource() connects to these checkpoints automatically, so your users resume exactly where they left off — even if your server restarted between sessions. -## Basic thread persistence +## Python: Checkpointer Setup -Save the thread ID to localStorage so conversations survive page refreshes. +Every LangGraph agent needs a checkpointer to persist state between invocations. The checkpointer you choose depends on your environment. - + + +```python +from langgraph.checkpoint.memory import MemorySaver +from langgraph.graph import START, END, MessagesState, StateGraph +from langchain_openai import ChatOpenAI + +llm = ChatOpenAI(model="gpt-5-mini") + +def call_model(state: MessagesState) -> dict: + return {"messages": [llm.invoke(state["messages"])]} + +builder = StateGraph(MessagesState) +builder.add_node("model", call_model) +builder.add_edge(START, "model") +builder.add_edge("model", END) + +# MemorySaver stores checkpoints in-process memory +# Fast for development — lost when the process restarts +graph = builder.compile(checkpointer=MemorySaver()) +``` + + + + +```python +from langgraph.checkpoint.sqlite.aio import AsyncSqliteSaver +from langgraph.graph import START, END, MessagesState, StateGraph + +# Persists to a local file — survives restarts, zero infrastructure +async with AsyncSqliteSaver.from_conn_string("checkpoints.db") as checkpointer: + builder = StateGraph(MessagesState) + builder.add_node("model", call_model) + builder.add_edge(START, "model") + builder.add_edge("model", END) + + graph = builder.compile(checkpointer=checkpointer) +``` + + + + +```python +from langgraph.checkpoint.postgres.aio import AsyncPostgresSaver +from langgraph.graph import START, END, MessagesState, StateGraph + +DATABASE_URL = "postgresql://user:pass@localhost:5432/myapp" + +async with AsyncPostgresSaver.from_conn_string(DATABASE_URL) as checkpointer: + # Run migrations once on startup + await checkpointer.setup() + + builder = StateGraph(MessagesState) + builder.add_node("model", call_model) + builder.add_edge(START, "model") + builder.add_edge("model", END) + + graph = builder.compile(checkpointer=checkpointer) +``` + + + + + +MemorySaver is for development only — all state vanishes when the process exits. For anything users depend on, use PostgresSaver. SqliteSaver is a middle ground for prototypes and single-server deployments where you need persistence without a database. + + +## Python: Thread IDs in Graph Invocation + +The thread ID is how LangGraph associates a conversation with its checkpoint history. Pass it in the `configurable` dict every time you invoke the graph: + +```python +# First message creates the thread +result = graph.invoke( + {"messages": [{"role": "user", "content": "What is LangGraph?"}]}, + config={"configurable": {"thread_id": "user_123"}} +) + +# Second message continues the same conversation +result = graph.invoke( + {"messages": [{"role": "user", "content": "How does it handle state?"}]}, + config={"configurable": {"thread_id": "user_123"}} +) +# The agent sees both messages — the full history is restored from the checkpoint +``` + + +Use stable, user-scoped identifiers for thread IDs. A common pattern is `f"{user_id}_{session_id}"` — this prevents cross-user data leaks and lets one user have multiple conversations. + + +## Angular: Basic Thread Persistence + +Save the thread ID to localStorage so conversations survive page refreshes. streamResource() handles thread creation and restoration automatically. + + + ```typescript -// chat.component.ts -const chat = streamResource<{ messages: BaseMessage[] }>({ - assistantId: 'chat_agent', - threadId: signal(localStorage.getItem('threadId')), - onThreadId: (id) => localStorage.setItem('threadId', id), -}); +import { ChangeDetectionStrategy, Component } from '@angular/core'; +import { signal } from '@angular/core'; +import { streamResource } from '@cacheplane/stream-resource'; + +@Component({ + selector: 'app-chat', + templateUrl: './chat.component.html', + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class ChatComponent { + chat = streamResource<{ messages: BaseMessage[] }>({ + assistantId: 'chat_agent', + // Restore thread from localStorage on mount + threadId: signal(localStorage.getItem('threadId')), + // Persist thread ID whenever a new thread is created + onThreadId: (id) => localStorage.setItem('threadId', id), + }); + + send(text: string) { + this.chat.submit({ messages: [{ role: 'user', content: text }] }); + } +} ``` - + ```html - - + @for (msg of chat.messages(); track $index) { -

    {{ msg.content }}

    +
    +

    {{ msg.content }}

    +
    +} + +@if (chat.isLoading()) { +
    Agent is thinking...
    } ```
    -## Reactive thread switching +## Angular: Thread-List Component + +A real chat application needs a sidebar showing all conversations. Here is a full thread-list component that manages multiple threads alongside your chat resource. -Pass a Signal as `threadId` to reactively switch between conversations. + + + +```typescript +import { ChangeDetectionStrategy, Component, signal, computed } from '@angular/core'; +import { streamResource } from '@cacheplane/stream-resource'; + +interface Thread { + id: string; + title: string; + updatedAt: Date; +} + +@Component({ + selector: 'app-thread-list', + templateUrl: './thread-list.component.html', + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class ThreadListComponent { + threads = signal(this.loadThreads()); + activeThreadId = signal(null); + + chat = streamResource<{ messages: BaseMessage[] }>({ + assistantId: 'chat_agent', + threadId: this.activeThreadId, + onThreadId: (id) => { + this.activeThreadId.set(id); + this.addThread(id, 'New conversation'); + }, + }); + + activeThread = computed(() => + this.threads().find((t) => t.id === this.activeThreadId()) + ); + + selectThread(id: string) { + this.activeThreadId.set(id); + } + + newConversation() { + this.chat.switchThread(null); + // A new thread ID is assigned on the next submit + } + + private addThread(id: string, title: string) { + this.threads.update((list) => [ + { id, title, updatedAt: new Date() }, + ...list.filter((t) => t.id !== id), + ]); + this.saveThreads(); + } + + private loadThreads(): Thread[] { + return JSON.parse(localStorage.getItem('threads') ?? '[]'); + } + + private saveThreads() { + localStorage.setItem('threads', JSON.stringify(this.threads())); + } +} +``` + + + + +```html + + +
    + @if (chat.isThreadLoading()) { +
    Loading conversation...
    + } @else { + @for (msg of chat.messages(); track $index) { +
    {{ msg.content }}
    + } + } +
    +``` + +
    +
    + +## Reactive Thread Switching + +When you pass a Signal as `threadId`, streamResource() reacts to every change. Set the signal and the conversation switches automatically — no imperative calls needed. ```typescript -// conversation-list.component.ts activeThreadId = signal(null); chat = streamResource<{ messages: BaseMessage[] }>({ @@ -50,57 +263,89 @@ chat = streamResource<{ messages: BaseMessage[] }>({ onThreadId: (id) => this.activeThreadId.set(id), }); -// Switch to a different conversation +// Clicking a thread in the sidebar triggers a reactive switch selectThread(id: string) { this.activeThreadId.set(id); - // streamResource automatically loads the new thread's state + // streamResource detects the signal change, fetches the thread's + // checkpoint from the server, and updates all derived signals } ``` -Use the `isThreadLoading()` signal to show a loading indicator while thread state is being fetched from the server. +Use the `isThreadLoading()` signal to show a skeleton UI while streamResource() fetches checkpoint state from the server. This avoids a flash of empty content when switching threads. -## Manual thread switching +## Manual Thread Switching -Use `switchThread()` for imperative thread changes that also reset derived state. +Use `switchThread()` for imperative thread changes. This is useful when you want to explicitly control when the switch happens — for example, after an animation completes or a modal closes. ```typescript -// Reset and start a new conversation +// Start a fresh conversation (null = new thread on next submit) newConversation() { this.chat.switchThread(null); - // Creates a new thread on next submit } -// Switch to a specific thread +// Jump to a specific thread loadConversation(threadId: string) { this.chat.switchThread(threadId); } + +// Fork a conversation — create a new thread from current state +forkConversation() { + this.chat.switchThread(null); + this.chat.submit({ + messages: this.chat.messages(), + }); +} ``` -## Checkpoint recovery +## Checkpoint Recovery -When a connection drops, streamResource() can rejoin an in-progress run. +When a connection drops mid-stream, `joinStream()` reconnects to an in-progress run without restarting the agent. This prevents duplicate work and lost tokens. ```typescript -// Rejoin a running stream +// Rejoin a running stream after a network interruption await chat.joinStream(runId, lastEventId); -// Picks up from where the connection was lost +// Picks up from the last event — no duplicate agent execution ``` + +In most cases streamResource() handles reconnection internally. Use `joinStream()` directly only when you need explicit control — for example, when restoring a run ID from a URL parameter after a full page reload. + + +## Thread Lifecycle + + + +streamResource() reads the `threadId` signal. If it contains a value, the existing thread's checkpoint is fetched from the server. + + +If `threadId` is null, streamResource() creates a new thread via the LangGraph API and fires `onThreadId` with the new ID. + + +Each super-step is checkpointed server-side. The `messages()` signal updates in real time as events arrive. + + +Setting the `threadId` signal (or calling `switchThread()`) loads the target thread's latest checkpoint. All signals update to reflect the restored state. + + +`joinStream()` reconnects to the in-progress run. The agent does not restart — streaming resumes from the last received event. + + + ## What's Next - Pause agent execution and wait for human input with interrupt signals. + Pause agent execution and wait for human approval before continuing. - Preserve context across sessions using LangGraph's memory store. + Preserve long-term context across sessions with LangGraph's memory store. Stream token-by-token responses and tool progress in real time. - Test agent interactions deterministically with MockStreamTransport. + Test thread persistence and switching deterministically with MockStreamTransport. From d73adefa4d01d464f4f73ad6ca2d049abdf02006 Mon Sep 17 00:00:00 2001 From: Brian Love Date: Sat, 4 Apr 2026 16:28:48 -0700 Subject: [PATCH 10/12] docs(website): rewrite Testing guide with comprehensive mock patterns --- .../content/docs-v2/guides/testing.mdx | 500 ++++++++++++++++-- 1 file changed, 454 insertions(+), 46 deletions(-) diff --git a/apps/website/content/docs-v2/guides/testing.mdx b/apps/website/content/docs-v2/guides/testing.mdx index 0d0f7f80f..453bd54ce 100644 --- a/apps/website/content/docs-v2/guides/testing.mdx +++ b/apps/website/content/docs-v2/guides/testing.mdx @@ -1,19 +1,55 @@ # Testing -MockStreamTransport lets you test agent interactions deterministically without a running LangGraph server. Script exact event sequences and step through them in your Angular test specs. +MockStreamTransport lets you test agent interactions deterministically without a running LangGraph server. Script exact event sequences, step through streaming lifecycles, and verify every signal transition in your Angular test specs. -MockStreamTransport eliminates network dependencies, timing issues, and server state. Every test run produces identical results. +MockStreamTransport eliminates network dependencies, timing issues, and server state. Every test run produces identical results. Your CI pipeline stays green. -## Basic test setup +## Python: Testing the Agent -Create a MockStreamTransport with scripted events and pass it to streamResource. +Before testing the Angular side, make sure your agent logic is correct. LangGraph agents are plain Python functions — test them directly with pytest. + +```python +import pytest +from langchain_core.messages import HumanMessage +from my_agent.agent import graph + +@pytest.mark.asyncio +async def test_agent_responds(): + result = await graph.ainvoke( + {"messages": [HumanMessage(content="Hello")]}, + config={"configurable": {"thread_id": "test_1"}}, + ) + assert len(result["messages"]) >= 2 + assert result["messages"][-1].type == "ai" + +@pytest.mark.asyncio +async def test_agent_uses_tools(): + result = await graph.ainvoke( + {"messages": [HumanMessage(content="Search for LangGraph docs")]}, + config={"configurable": {"thread_id": "test_2"}}, + ) + # Verify the agent called the search tool + tool_messages = [m for m in result["messages"] if m.type == "tool"] + assert len(tool_messages) > 0 +``` + + +With MemorySaver and a mocked LLM, agent tests run in milliseconds. Use `langchain_core.language_models.FakeListChatModel` to remove the LLM dependency entirely. + + +## MockStreamTransport: Basic Setup + +On the Angular side, MockStreamTransport replaces the real HTTP transport. Create it inside `TestBed.runInInjectionContext` so streamResource() has access to Angular's dependency injection. + + + ```typescript import { TestBed } from '@angular/core/testing'; -import { MockStreamTransport } from '@cacheplane/stream-resource'; -import type { StreamEvent } from '@cacheplane/stream-resource'; +import { MockStreamTransport, streamResource } from '@cacheplane/stream-resource'; +import type { BaseMessage } from '@cacheplane/stream-resource'; describe('ChatComponent', () => { it('should display agent messages', () => { @@ -25,9 +61,12 @@ describe('ChatComponent', () => { transport, }); - // Emit a values event + // Emit a values event — simulates the agent responding transport.emit([ - { type: 'values', messages: [{ role: 'assistant', content: 'Hello!' }] }, + { + type: 'values', + messages: [{ role: 'assistant', content: 'Hello!' }], + }, ]); expect(chat.messages().length).toBe(1); @@ -37,72 +76,441 @@ describe('ChatComponent', () => { }); ``` -## Scripting event sequences + + + +```typescript +import { ChangeDetectionStrategy, Component } from '@angular/core'; +import { streamResource } from '@cacheplane/stream-resource'; + +@Component({ + selector: 'app-chat', + templateUrl: './chat.component.html', + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class ChatComponent { + chat = streamResource<{ messages: BaseMessage[] }>({ + assistantId: 'chat_agent', + }); + + send(text: string) { + this.chat.submit({ messages: [{ role: 'user', content: text }] }); + } +} +``` + + + + +## Scripted Event Sequences -Pass event batches to the constructor for sequential playback. +Pass event batches to the constructor for sequential playback. Each call to `nextBatch()` advances one step — giving you frame-by-frame control over what the component sees. ```typescript const transport = new MockStreamTransport([ - // Batch 1: Initial response + // Batch 1: Agent starts thinking [{ type: 'values', messages: [{ role: 'assistant', content: 'Analyzing...' }] }], - // Batch 2: Final response - [{ type: 'values', messages: [{ role: 'assistant', content: 'Done!' }] }], + // Batch 2: Agent finishes + [{ type: 'values', messages: [{ role: 'assistant', content: 'Here is your answer.' }] }], ]); -// Advance through batches -const batch1 = transport.nextBatch(); // First batch -const batch2 = transport.nextBatch(); // Second batch +TestBed.runInInjectionContext(() => { + const chat = streamResource<{ messages: BaseMessage[] }>({ + assistantId: 'test_agent', + transport, + }); + + chat.submit({ messages: [{ role: 'user', content: 'Explain signals' }] }); + + // Step through each batch + transport.nextBatch(); + expect(chat.messages()[0].content).toBe('Analyzing...'); + + transport.nextBatch(); + expect(chat.messages()[0].content).toBe('Here is your answer.'); +}); ``` -## Testing interrupts +## Testing the Streaming Lifecycle + +The most common test pattern verifies the full submit-to-resolved lifecycle: submit triggers loading, values arrive, and the status settles to resolved. -Script an interrupt event to test human-in-the-loop flows. + + ```typescript -it('should handle interrupts', () => { - const transport = new MockStreamTransport(); +import { TestBed } from '@angular/core/testing'; +import { MockStreamTransport, streamResource } from '@cacheplane/stream-resource'; + +describe('streaming lifecycle', () => { + it('should transition through loading → values → resolved', () => { + const transport = new MockStreamTransport([ + [{ type: 'values', messages: [{ role: 'assistant', content: 'Thinking...' }] }], + [{ type: 'values', messages: [{ role: 'assistant', content: 'Done!' }] }], + ]); - TestBed.runInInjectionContext(() => { - const agent = streamResource({ - assistantId: 'approval_agent', - transport, + TestBed.runInInjectionContext(() => { + const chat = streamResource<{ messages: BaseMessage[] }>({ + assistantId: 'test_agent', + transport, + }); + + // Initial state + expect(chat.status()).toBe('idle'); + expect(chat.messages()).toEqual([]); + + // Submit triggers loading + chat.submit({ messages: [{ role: 'user', content: 'Hello' }] }); + expect(chat.status()).toBe('loading'); + expect(chat.isLoading()).toBe(true); + + // First batch — partial response + transport.nextBatch(); + expect(chat.messages()[0].content).toBe('Thinking...'); + expect(chat.status()).toBe('loading'); + + // Second batch — final response + transport.nextBatch(); + expect(chat.messages()[0].content).toBe('Done!'); + + // Stream completes + transport.complete(); + expect(chat.status()).toBe('resolved'); + expect(chat.isLoading()).toBe(false); }); + }); +}); +``` - // Emit an interrupt - transport.emit([ - { type: 'interrupt', value: { action: 'delete', risk: 'high' } }, - ]); + + + +```typescript +import { ChangeDetectionStrategy, Component } from '@angular/core'; +import { streamResource } from '@cacheplane/stream-resource'; - expect(agent.interrupt()).toBeDefined(); - expect(agent.interrupt()?.value.risk).toBe('high'); +@Component({ + selector: 'app-chat', + template: ` + @if (chat.isLoading()) { +
    Thinking...
    + } + @for (msg of chat.messages(); track $index) { +
    {{ msg.content }}
    + } + `, + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class ChatComponent { + chat = streamResource<{ messages: BaseMessage[] }>({ + assistantId: 'chat_agent', + }); + + send(text: string) { + this.chat.submit({ messages: [{ role: 'user', content: text }] }); + } +} +``` + +
    +
    + +## Testing Interrupts + +Script an interrupt event to test human-in-the-loop flows. Verify the interrupt signal surfaces the payload, then resume and confirm the agent continues. + + + + +```typescript +import { TestBed } from '@angular/core/testing'; +import { MockStreamTransport, streamResource } from '@cacheplane/stream-resource'; + +describe('interrupt handling', () => { + it('should surface interrupt and resume on approval', () => { + const transport = new MockStreamTransport(); + + TestBed.runInInjectionContext(() => { + const agent = streamResource<{ messages: BaseMessage[] }>({ + assistantId: 'approval_agent', + transport, + }); + + // Agent hits an interrupt + transport.emit([ + { + type: 'interrupt', + value: { action: 'delete_account', risk: 'high' }, + }, + ]); + + // Verify interrupt signal + expect(agent.interrupt()).toBeDefined(); + expect(agent.interrupt()?.value.action).toBe('delete_account'); + expect(agent.interrupt()?.value.risk).toBe('high'); + + // User approves — resume the agent + agent.submit(null, { resume: { approved: true } }); + + // Agent continues after approval + transport.emit([ + { + type: 'values', + messages: [{ role: 'assistant', content: 'Account deleted.' }], + }, + ]); + + expect(agent.interrupt()).toBeNull(); + expect(agent.messages()[0].content).toBe('Account deleted.'); + }); }); }); ``` -## Testing errors + + + +```typescript +import { ChangeDetectionStrategy, Component } from '@angular/core'; +import { streamResource } from '@cacheplane/stream-resource'; + +@Component({ + selector: 'app-approval', + template: ` + @if (agent.interrupt(); as interrupt) { +
    +

    Action: {{ interrupt.value.action }}

    +

    Risk: {{ interrupt.value.risk }}

    + + +
    + } + `, + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class ApprovalComponent { + agent = streamResource<{ messages: BaseMessage[] }>({ + assistantId: 'approval_agent', + }); + + approve() { + this.agent.submit(null, { resume: { approved: true } }); + } + + reject() { + this.agent.submit(null, { resume: { approved: false } }); + } +} +``` + +
    +
    + +## Testing Errors + +Inject errors with `emitError()` to verify your component handles failures gracefully. -Inject errors to test error handling. + + ```typescript -it('should surface errors', () => { - const transport = new MockStreamTransport(); +import { TestBed } from '@angular/core/testing'; +import { MockStreamTransport, streamResource } from '@cacheplane/stream-resource'; + +describe('error handling', () => { + it('should surface errors and set error status', () => { + const transport = new MockStreamTransport(); + + TestBed.runInInjectionContext(() => { + const chat = streamResource<{ messages: BaseMessage[] }>({ + assistantId: 'test_agent', + transport, + }); + + chat.submit({ messages: [{ role: 'user', content: 'Hello' }] }); - TestBed.runInInjectionContext(() => { - const chat = streamResource({ - assistantId: 'test_agent', - transport, + // Simulate a connection failure + transport.emitError(new Error('Connection lost')); + + expect(chat.error()).toBeDefined(); + expect(chat.error()?.message).toBe('Connection lost'); + expect(chat.status()).toBe('error'); + expect(chat.isLoading()).toBe(false); }); + }); + + it('should recover from errors on retry', () => { + const transport = new MockStreamTransport(); + + TestBed.runInInjectionContext(() => { + const chat = streamResource<{ messages: BaseMessage[] }>({ + assistantId: 'test_agent', + transport, + }); - transport.emitError(new Error('Connection lost')); + // First attempt fails + chat.submit({ messages: [{ role: 'user', content: 'Hello' }] }); + transport.emitError(new Error('Timeout')); + expect(chat.status()).toBe('error'); - expect(chat.error()).toBeDefined(); - expect(chat.status()).toBe('error'); + // Retry succeeds + chat.submit({ messages: [{ role: 'user', content: 'Hello' }] }); + transport.emit([ + { + type: 'values', + messages: [{ role: 'assistant', content: 'Sorry for the delay!' }], + }, + ]); + + expect(chat.status()).not.toBe('error'); + expect(chat.messages()[0].content).toBe('Sorry for the delay!'); + }); }); }); ``` - -streamResource() must be called within an Angular injection context. In tests, wrap calls in `TestBed.runInInjectionContext()`. + + + +```typescript +import { ChangeDetectionStrategy, Component } from '@angular/core'; +import { streamResource } from '@cacheplane/stream-resource'; + +@Component({ + selector: 'app-chat', + template: ` + @if (chat.error(); as err) { +
    +

    {{ err.message }}

    + +
    + } + `, + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class ChatComponent { + chat = streamResource<{ messages: BaseMessage[] }>({ + assistantId: 'chat_agent', + }); + private lastMessage = ''; + + send(text: string) { + this.lastMessage = text; + this.chat.submit({ messages: [{ role: 'user', content: text }] }); + } + + retry() { + this.send(this.lastMessage); + } +} +``` + +
    +
    + +## Testing Thread Switching + +Verify that switching threads loads the correct conversation state and clears the previous thread's messages. + +```typescript +describe('thread switching', () => { + it('should load new thread state on switch', () => { + const transport = new MockStreamTransport(); + + TestBed.runInInjectionContext(() => { + const threadId = signal('thread_A'); + + const chat = streamResource<{ messages: BaseMessage[] }>({ + assistantId: 'test_agent', + threadId, + transport, + }); + + // Thread A has messages + transport.emit([ + { + type: 'values', + messages: [{ role: 'assistant', content: 'Thread A response' }], + }, + ]); + expect(chat.messages()[0].content).toBe('Thread A response'); + + // Switch to thread B + chat.switchThread('thread_B'); + + // Thread B loads its own state + transport.emit([ + { + type: 'values', + messages: [{ role: 'assistant', content: 'Thread B response' }], + }, + ]); + expect(chat.messages()[0].content).toBe('Thread B response'); + }); + }); + + it('should create a new thread when switching to null', () => { + const transport = new MockStreamTransport(); + + TestBed.runInInjectionContext(() => { + const chat = streamResource<{ messages: BaseMessage[] }>({ + assistantId: 'test_agent', + transport, + }); + + // Start a conversation + transport.emit([ + { + type: 'values', + messages: [{ role: 'assistant', content: 'Hello' }], + }, + ]); + + // Switch to new thread + chat.switchThread(null); + expect(chat.messages()).toEqual([]); + }); + }); +}); +``` + +## Test Setup Workflow + + + +Make sure `@cacheplane/stream-resource` is available in your test environment. MockStreamTransport ships with the main package — no extra install needed. + + +Instantiate `MockStreamTransport` with optional pre-scripted batches for sequential playback, or leave it empty for imperative `emit()` calls. + + +Call `TestBed.runInInjectionContext(() => { ... })` so streamResource() can access Angular's injector for signal creation and cleanup. + + +Pass the transport to streamResource() via the `transport` option. All other options (assistantId, threadId, onThreadId) work identically to production code. + + +Use `transport.emit()` for ad-hoc events, `transport.nextBatch()` for pre-scripted sequences, or `transport.emitError()` for failure scenarios. + + +Read signals like `chat.messages()`, `chat.status()`, `chat.interrupt()`, and `chat.error()` to verify your component reacts correctly. + + + +## Integration Testing + +For end-to-end confidence, run tests against a real LangGraph dev server. The LangGraph CLI starts a local server that your tests can hit directly. + +```bash +# Start the dev server +langgraph dev --config langgraph.json + +# Run Angular tests against it (no MockStreamTransport needed) +ng test --watch=false +``` + + +Integration tests hit a real server and (potentially) a real LLM. Reserve them for CI pipelines or pre-release smoke tests. Use MockStreamTransport for the vast majority of your test suite — it runs in milliseconds with zero external dependencies. ## What's Next @@ -112,10 +520,10 @@ streamResource() must be called within an Angular injection context. In tests, w Understand the SSE event model your tests simulate. - Test human-in-the-loop approval flows with scripted interrupt events. + Build human-in-the-loop approval flows tested with scripted interrupt events. - - Configure streamResource() for production LangGraph Cloud. + + Thread persistence patterns that pair with thread-switching tests. Full reference for MockStreamTransport options and methods. From 23c2a47ecf4575283aac2839db73b47c5fedf993 Mon Sep 17 00:00:00 2001 From: Brian Love Date: Sat, 4 Apr 2026 16:29:17 -0700 Subject: [PATCH 11/12] docs(website): rewrite Interrupts guide with Python interrupt code and approval component --- .../content/docs-v2/guides/interrupts.mdx | 544 ++++++++++++++++-- 1 file changed, 505 insertions(+), 39 deletions(-) diff --git a/apps/website/content/docs-v2/guides/interrupts.mdx b/apps/website/content/docs-v2/guides/interrupts.mdx index ec35897a4..bb3bb7a2a 100644 --- a/apps/website/content/docs-v2/guides/interrupts.mdx +++ b/apps/website/content/docs-v2/guides/interrupts.mdx @@ -1,95 +1,561 @@ # Interrupts -Interrupts let your LangGraph agent pause execution and wait for human input. streamResource() surfaces interrupts as Angular Signals, making it easy to build approval flows, confirmation dialogs, and human-in-the-loop experiences. +Interrupts let your LangGraph agent pause mid-execution and hand control to a human. The agent proposes an action, the graph freezes, your Angular UI shows an approval dialog, the user decides, and the agent resumes with the human's decision. streamResource() surfaces interrupts as Angular Signals, so building approval flows, confirmation dialogs, and multi-step review experiences requires no manual event wiring. -Use interrupts for human approval, late-binding decisions, or any step where the agent needs external input before continuing. +Use interrupts when an agent action is irreversible (sending an email, placing an order, deleting data), when the agent needs a human decision it cannot make on its own, or when compliance requires explicit approval before execution. -## Basic interrupt handling +## The Interrupt Lifecycle -When an agent interrupts, the `interrupt()` signal contains the interrupt data. +Before diving into code, understand the five-stage lifecycle that every interrupt follows: + + + +The agent reasons about the user's request and determines an action that requires human approval. It builds a structured payload describing what it wants to do. + + +The agent node calls `raise Interrupt(value={...})`, which freezes the graph. The interrupt payload is persisted in the checkpoint and streamed to the client. + + +streamResource() updates the `interrupt()` signal. Your Angular template detects the change through OnPush change detection and renders an approval dialog with the interrupt payload. + + +The user reviews the proposed action and clicks Approve or Reject. Your component calls `agent.submit()` with a resume payload containing the decision. + + +LangGraph resumes the graph from the interrupted checkpoint. The next node receives the human's decision and either executes or aborts the action. + + + +## Python: Raising an Interrupt + +An interrupt is raised inside any graph node by calling `raise Interrupt(value={...})`. The value can be any JSON-serializable object — it becomes the payload your Angular component displays. - + + +```python +from langgraph.graph import END, START, StateGraph +from langgraph.types import Interrupt, Command +from langchain_openai import ChatOpenAI +from typing_extensions import TypedDict, Annotated +from operator import add + +llm = ChatOpenAI(model="gpt-5-mini") + +class State(TypedDict): + messages: Annotated[list, add] + proposed_action: dict + approval_result: dict + +def plan_action(state: State) -> dict: + """Agent analyzes the request and proposes an action.""" + response = llm.invoke([ + {"role": "system", "content": ( + "Analyze the user's request. If it requires sending " + "an email, modifying data, or any irreversible action, " + "return a JSON action plan with keys: action, target, " + "description, risk_level." + )}, + *state["messages"] + ]) + action = parse_json(response.content) + return { + "proposed_action": action, + "messages": [response], + } + +def request_approval(state: State) -> dict: + """Pause the graph and ask the human for approval.""" + action = state["proposed_action"] + raise Interrupt(value={ + "action": action["action"], + "target": action["target"], + "description": action["description"], + "risk_level": action.get("risk_level", "medium"), + }) + +def execute_action(state: State) -> dict: + """Run the approved action or explain the rejection.""" + result = state.get("approval_result", {}) + if result.get("approved"): + # Execute the real action + outcome = perform_action(state["proposed_action"]) + return { + "messages": [{"role": "assistant", "content": ( + f"Done. {outcome}" + )}] + } + else: + reason = result.get("reason", "No reason given") + return { + "messages": [{"role": "assistant", "content": ( + f"Action cancelled. Reason: {reason}" + )}] + } + +# Build the graph: plan → approve → execute +builder = StateGraph(State) +builder.add_node("plan", plan_action) +builder.add_node("approve", request_approval) +builder.add_node("execute", execute_action) +builder.add_edge(START, "plan") +builder.add_edge("plan", "approve") +builder.add_edge("approve", "execute") +builder.add_edge("execute", END) + +graph = builder.compile() +``` + + + + +```json +{ + "dependencies": ["."], + "graphs": { + "approval_agent": "./src/approval_agent/agent.py:graph" + }, + "env": ".env", + "python_version": "3.12" +} +``` + + + + + +Place the `raise Interrupt()` call in its own dedicated node. This gives you a clean three-node pattern (plan, approve, execute) where the interrupt sits between reasoning and action. If you raise an interrupt inside a node that also does work, the work before the interrupt runs twice on resume. + + +## Angular: Building an Approval Component + +When the agent raises an interrupt, streamResource() populates the `interrupt()` signal with the interrupt payload. Your component reads this signal to render a dialog and calls `submit()` to resume. + + + ```typescript -// approval.component.ts +import { + Component, + computed, + signal, + ChangeDetectionStrategy, +} from '@angular/core'; +import { streamResource, BaseMessage } from '@cacheplane/stream-resource'; + interface ApprovalPayload { action: string; + target: string; description: string; - risk: 'low' | 'medium' | 'high'; + risk_level: 'low' | 'medium' | 'high'; } -const agent = streamResource({ - assistantId: 'approval_agent', -}); +interface AgentState { + messages: BaseMessage[]; + proposed_action: ApprovalPayload; + approval_result: { approved: boolean; reason?: string }; +} + +@Component({ + selector: 'app-approval', + templateUrl: './approval.component.html', + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class ApprovalComponent { + agent = streamResource({ + assistantId: 'approval_agent', + }); + + messages = computed(() => this.agent.messages()); + pendingApproval = computed(() => this.agent.interrupt()); + isLoading = computed(() => this.agent.isLoading()); + + rejectionReason = signal(''); + + riskClass = computed(() => { + const interrupt = this.pendingApproval(); + if (!interrupt) return ''; + const level = interrupt.value?.risk_level ?? 'medium'; + return `risk-${level}`; + }); + + send(input: string) { + this.agent.submit({ + messages: [{ role: 'user', content: input }], + }); + } -// Check for pending interrupts -pendingApproval = computed(() => agent.interrupt()); + approve() { + this.agent.submit(null, { + resume: { approved: true }, + }); + } + + reject() { + this.agent.submit(null, { + resume: { + approved: false, + reason: this.rejectionReason() || 'User rejected', + }, + }); + this.rejectionReason.set(''); + } +} ``` - + ```html - + +
    + @for (msg of messages(); track msg) { +
    {{ msg.content }}
    + } + + @if (isLoading()) { +
    Agent is working...
    + } +
    + + @if (pendingApproval(); as approval) { -
    -

    Agent needs approval

    -

    {{ approval.value.description }}

    -

    Risk level: {{ approval.value.risk }}

    - - +
    +

    Agent Needs Approval

    + +
    +
    Action
    +
    {{ approval.value.action }}
    + +
    Target
    +
    {{ approval.value.target }}
    + +
    Description
    +
    {{ approval.value.description }}
    + +
    Risk Level
    +
    + + {{ approval.value.risk_level | titlecase }} + +
    +
    + +
    + + +
    + +
    + + +
    } + + +@if (!pendingApproval()) { +
    + + +
    +} ``` -## Resuming from an interrupt +## Multi-Step Approval Pattern + +Some workflows require multiple approvals in sequence. For example, an agent that plans a multi-step deployment might need approval at each stage. Each node in the graph can raise its own interrupt. + + + + +```python +from langgraph.graph import END, START, StateGraph +from langgraph.types import Interrupt +from typing_extensions import TypedDict, Annotated +from operator import add + +class DeployState(TypedDict): + messages: Annotated[list, add] + plan: list[dict] + current_step: int + completed_steps: list[str] + +def create_plan(state: DeployState) -> dict: + """Generate a multi-step deployment plan.""" + plan = [ + {"step": "backup", "description": "Back up current database"}, + {"step": "migrate", "description": "Run schema migrations"}, + {"step": "deploy", "description": "Deploy new application version"}, + ] + return {"plan": plan, "current_step": 0} + +def approve_step(state: DeployState) -> dict: + """Interrupt for each step that needs approval.""" + step_index = state["current_step"] + step = state["plan"][step_index] + raise Interrupt(value={ + "step_number": step_index + 1, + "total_steps": len(state["plan"]), + "step": step["step"], + "description": step["description"], + "completed": state.get("completed_steps", []), + }) -Call `submit()` with the resume payload to continue execution. +def execute_step(state: DeployState) -> dict: + """Execute the approved step and advance.""" + step = state["plan"][state["current_step"]] + # ... perform the actual deployment step ... + return { + "completed_steps": [step["step"]], + "current_step": state["current_step"] + 1, + "messages": [{"role": "assistant", "content": ( + f"Completed: {step['description']}" + )}], + } + +def should_continue(state: DeployState) -> str: + if state["current_step"] < len(state["plan"]): + return "approve_step" + return END + +builder = StateGraph(DeployState) +builder.add_node("create_plan", create_plan) +builder.add_node("approve_step", approve_step) +builder.add_node("execute_step", execute_step) +builder.add_edge(START, "create_plan") +builder.add_edge("create_plan", "approve_step") +builder.add_edge("approve_step", "execute_step") +builder.add_conditional_edges("execute_step", should_continue) + +graph = builder.compile() +``` + + + ```typescript -approve() { - this.agent.submit(null, { resume: { approved: true } }); +import { + Component, + computed, + ChangeDetectionStrategy, +} from '@angular/core'; +import { streamResource, BaseMessage } from '@cacheplane/stream-resource'; + +interface StepApproval { + step_number: number; + total_steps: number; + step: string; + description: string; + completed: string[]; +} + +@Component({ + selector: 'app-deploy-approval', + templateUrl: './approval.component.html', + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class DeployApprovalComponent { + agent = streamResource<{ + messages: BaseMessage[]; + plan: { step: string; description: string }[]; + current_step: number; + completed_steps: string[]; + }>({ + assistantId: 'deploy_agent', + }); + + currentStep = computed(() => { + const interrupt = this.agent.interrupt(); + return interrupt?.value as StepApproval | null; + }); + + progress = computed(() => { + const step = this.currentStep(); + if (!step) return 0; + return (step.completed.length / step.total_steps) * 100; + }); + + allInterrupts = computed(() => this.agent.interrupts()); + + approveStep() { + this.agent.submit(null, { resume: { approved: true } }); + } + + abortDeploy() { + this.agent.submit(null, { + resume: { approved: false, reason: 'Deployment aborted by user' }, + }); + } +} +``` + + + + +```html +@if (currentStep(); as step) { +
    +

    Step {{ step.step_number }} of {{ step.total_steps }}

    + + +
    +
    +
    + + + @if (step.completed.length) { +
      + @for (done of step.completed; track done) { +
    • {{ done }}
    • + } +
    + } + + +
    + {{ step.step }} +

    {{ step.description }}

    +
    + +
    + + +
    +
    } +``` + +
    +
    + +## Typed Interrupt Payloads with BagTemplate + +By default, `interrupt()` returns an untyped object. The BagTemplate generic parameter on streamResource() lets you define the exact shape of your interrupt payloads, giving you full TypeScript safety throughout your component. + +BagTemplate is a type parameter on the streamResource configuration that maps signal names to their types. When you specify an interrupt type through BagTemplate, the `interrupt()` signal returns a properly typed object instead of `unknown`. This means your template expressions, computed signals, and event handlers all benefit from compile-time checking. -reject() { - this.agent.submit(null, { resume: { approved: false, reason: 'User rejected' } }); +```typescript +import { streamResource, BagTemplate } from '@cacheplane/stream-resource'; + +// Define the exact shape of your interrupt payload +interface DeployApproval { + step_number: number; + total_steps: number; + step: string; + description: string; + completed: string[]; } + +// Pass the interrupt type via BagTemplate +const agent = streamResource< + DeployState, + BagTemplate<{ interrupt: DeployApproval }> +>({ + assistantId: 'deploy_agent', +}); + +// Now interrupt() is typed — no casting needed +const step = agent.interrupt(); +// ^? Signal<{ value: DeployApproval } | null> + +// TypeScript catches errors at compile time +const num = step?.value.step_number; // number — correct +const bad = step?.value.nonexistent; // Error — property doesn't exist ``` -## Multiple interrupts + +Define your interrupt payload interfaces alongside your Python state schema. This creates a contract between your agent and your UI. When the Python payload shape changes, the TypeScript interface should change too. Consider generating types from a shared schema to keep them in sync. + + +## Timeout Handling + +Interrupts pause graph execution indefinitely by default — the agent waits until a human responds. In production, you often need to handle cases where no one responds within a reasonable time. There are two strategies for managing interrupt timeouts. + +**Server-side timeout with a background task:** Schedule a background job that checks for stale interrupts and resumes them with a default decision. + +```python +async def check_stale_interrupts(): + """Periodic task to auto-reject stale interrupts.""" + threads = await client.threads.search( + status="interrupted", + metadata={"interrupt_type": "approval"}, + ) + for thread in threads: + created = thread.updated_at + if (now() - created).total_seconds() > 3600: # 1 hour timeout + await client.runs.create( + thread["thread_id"], + assistant_id="approval_agent", + input=None, + command={"resume": { + "approved": False, + "reason": "Auto-rejected: approval timeout", + }}, + ) +``` -The `interrupts()` signal tracks all interrupts received during a run, not just the current one. +**Client-side timeout in Angular:** Use a timer in your component to auto-reject if the user does not act. ```typescript -// Track interrupt history -allInterrupts = computed(() => agent.interrupts()); -latestInterrupt = computed(() => agent.interrupt()); -interruptCount = computed(() => agent.interrupts().length); +import { effect } from '@angular/core'; +import { timer } from 'rxjs'; + +// Watch for interrupts and start a timeout +effect(() => { + const interrupt = this.agent.interrupt(); + if (interrupt) { + const sub = timer(5 * 60 * 1000).subscribe(() => { + // Auto-reject after 5 minutes of inaction + this.agent.submit(null, { + resume: { approved: false, reason: 'Approval timeout' }, + }); + }); + // Clean up if user responds before timeout + return () => sub.unsubscribe(); + } +}); ``` - -Use the BagTemplate generic parameter to type your interrupt payloads for full TypeScript safety. + +Avoid running both server-side and client-side timeouts simultaneously. If both fire, the second resume call will fail because the graph already moved past the interrupt. Choose server-side timeouts for reliability (works even if the browser closes) or client-side timeouts for immediacy. + + + +Because interrupts are checkpointed, the user can close their browser, come back hours later, and still approve or reject the pending action. The graph state is frozen in the checkpoint store, not in browser memory. ## What's Next + + Give your agent short-term and long-term memory with the Store API. + - Resume conversations across page refreshes with thread persistence. + Configure checkpointers that keep interrupt state across deployments. - Stream token-by-token responses and tool progress in real time. + Stream token-by-token responses alongside interrupt events. Script interrupt events deterministically with MockStreamTransport. - - Full reference for streamResource options and returned signals. - From 3589aa362ef77f7140b3347f5f0a45f25687846b Mon Sep 17 00:00:00 2001 From: Brian Love Date: Sat, 4 Apr 2026 16:37:37 -0700 Subject: [PATCH 12/12] =?UTF-8?q?fix(website):=20fix=20Callout=20type=3D'w?= =?UTF-8?q?arn'=20=E2=86=92=20type=3D'warning'=20+=20strip=20code=20fence?= =?UTF-8?q?=20titles?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../docs-v2/concepts/agent-architecture.mdx | 2 +- .../docs-v2/concepts/angular-signals.mdx | 2 +- .../content/docs-v2/guides/deployment.mdx | 24 +++++++++---------- apps/website/next-env.d.ts | 2 +- 4 files changed, 15 insertions(+), 15 deletions(-) diff --git a/apps/website/content/docs-v2/concepts/agent-architecture.mdx b/apps/website/content/docs-v2/concepts/agent-architecture.mdx index 1f6e0d95c..2571643ed 100644 --- a/apps/website/content/docs-v2/concepts/agent-architecture.mdx +++ b/apps/website/content/docs-v2/concepts/agent-architecture.mdx @@ -529,7 +529,7 @@ export class AgentComponent { | Transport error (network) | N/A | `error()` fires, `status()` becomes `'error'` | | Agent exceeds recursion limit | Graph raises `GraphRecursionError` | `error()` fires with recursion message | - + LangGraph defaults to 25 recursion steps. If your agent loops between `model` and `tools` more than 25 times, it stops with a `GraphRecursionError`. Increase the limit in production with `graph.compile(recursion_limit=50)` or redesign the agent to converge faster. diff --git a/apps/website/content/docs-v2/concepts/angular-signals.mdx b/apps/website/content/docs-v2/concepts/angular-signals.mdx index dccc83db2..d476ee891 100644 --- a/apps/website/content/docs-v2/concepts/angular-signals.mdx +++ b/apps/website/content/docs-v2/concepts/angular-signals.mdx @@ -249,7 +249,7 @@ effect(() => { }); ``` - + Writing to a Signal inside an `effect()` can create infinite loops. If you need to transform one Signal into another, use `computed()` instead. Reserve `effect()` for side effects that leave the reactive graph — DOM manipulation, logging, analytics, network calls. diff --git a/apps/website/content/docs-v2/guides/deployment.mdx b/apps/website/content/docs-v2/guides/deployment.mdx index 5d737cbfe..79e0faea7 100644 --- a/apps/website/content/docs-v2/guides/deployment.mdx +++ b/apps/website/content/docs-v2/guides/deployment.mdx @@ -6,7 +6,7 @@ Deploy your LangGraph agent to the cloud and ship your Angular frontend to produ Your agent code needs a `langgraph.json` manifest at the project root. This file tells LangGraph Cloud how to build and serve your agent. -```json title="langgraph.json" +```json { "dependencies": ["."], "graphs": { @@ -20,7 +20,7 @@ The `graphs` key maps an assistant ID (used by `streamResource()` on the Angular ### Agent entry point -```python title="agent/graph.py" +```python from langchain_openai import ChatOpenAI from langgraph.graph import StateGraph, MessagesState @@ -83,7 +83,7 @@ Angular uses file-based environment replacement at build time rather than `proce -```typescript title="src/environments/environment.ts" +```typescript export const environment = { production: false, langgraphUrl: 'http://localhost:2024', @@ -94,7 +94,7 @@ export const environment = { -```typescript title="src/environments/environment.prod.ts" +```typescript export const environment = { production: true, langgraphUrl: 'https://my-agent-abc123.langgraph.app', @@ -107,7 +107,7 @@ export const environment = { Wire the environment into `provideStreamResource()`: -```typescript title="app.config.ts" +```typescript import { provideStreamResource } from '@cacheplane/stream-resource'; import { environment } from '../environments/environment'; @@ -128,7 +128,7 @@ Angular CLI replaces `environment.ts` with `environment.prod.ts` during `ng buil LangGraph Cloud deployments require an API key on every request. The recommended approach is an Angular HTTP interceptor that attaches the key as a header. -```typescript title="auth.interceptor.ts" +```typescript import { HttpInterceptorFn } from '@angular/common/http'; import { environment } from '../environments/environment'; @@ -147,7 +147,7 @@ export const langGraphAuthInterceptor: HttpInterceptorFn = (req, next) => { Register the interceptor in your application config: -```typescript title="app.config.ts" +```typescript import { provideHttpClient, withInterceptors } from '@angular/common/http'; import { langGraphAuthInterceptor } from './auth.interceptor'; @@ -161,7 +161,7 @@ export const appConfig: ApplicationConfig = { }; ``` - + Add `environment.prod.ts` to `.gitignore`. In CI, generate it from environment variables or inject secrets at build time. @@ -175,7 +175,7 @@ When your Angular frontend and LangGraph backend are on different origins, you m In `langgraph.json`, add an `http` section: -```json title="langgraph.json" +```json { "dependencies": ["."], "graphs": { @@ -200,7 +200,7 @@ During local development with `langgraph dev`, CORS is permissive by default. Yo Production apps need graceful error handling. Build a reactive error boundary using `streamResource()` signals. -```typescript title="chat.component.ts" +```typescript import { ChangeDetectionStrategy, Component, computed } from '@angular/core'; import { streamResource } from '@cacheplane/stream-resource'; @@ -246,7 +246,7 @@ export class ChatComponent { For automated retries (network blips, transient 5xx errors), wrap `.submit()` with a backoff utility: -```typescript title="retry-submit.ts" +```typescript export async function retrySubmit( chat: ReturnType, input: Record, @@ -290,7 +290,7 @@ if (savedRunId) { A typical pipeline deploys the Python agent and Angular frontend in parallel since they are independent artifacts. -```yaml title=".github/workflows/deploy.yml" +```yaml name: Deploy on: push: diff --git a/apps/website/next-env.d.ts b/apps/website/next-env.d.ts index c4b7818fb..fdbfe5258 100644 --- a/apps/website/next-env.d.ts +++ b/apps/website/next-env.d.ts @@ -1,6 +1,6 @@ /// /// -import "./.next/dev/types/routes.d.ts"; +import "./../../dist/apps/website/.next/types/routes.d.ts"; // NOTE: This file should not be edited // see https://nextjs.org/docs/app/api-reference/config/typescript for more information.