From 2e11cccde218870165af098eba4b4cdf2d2f82fe Mon Sep 17 00:00:00 2001 From: Brian Love Date: Mon, 18 May 2026 13:16:30 -0700 Subject: [PATCH] docs: correct agent langgraph docs drift --- .../content/docs/agent/api/api-docs.json | 16 ++--- .../agent/concepts/agent-architecture.mdx | 11 ++-- .../docs/agent/concepts/agent-contract.mdx | 2 +- .../docs/agent/concepts/langgraph-basics.mdx | 12 +++- .../docs/agent/concepts/state-management.mdx | 9 ++- .../agent/getting-started/installation.mdx | 11 +++- .../agent/getting-started/introduction.mdx | 2 +- .../content/docs/agent/guides/deployment.mdx | 62 ++++++++----------- .../content/docs/agent/guides/interrupts.mdx | 52 ++++++++++------ .../content/docs/agent/guides/lifecycle.mdx | 56 +++++++++++------ .../content/docs/agent/guides/streaming.mdx | 41 ++++++++---- .../content/docs/agent/guides/subgraphs.mdx | 38 +++++------- libs/langgraph/src/lib/lifecycle.ts | 16 ++--- 13 files changed, 190 insertions(+), 138 deletions(-) diff --git a/apps/website/content/docs/agent/api/api-docs.json b/apps/website/content/docs/agent/api/api-docs.json index 52fe354be..6280daabb 100644 --- a/apps/website/content/docs/agent/api/api-docs.json +++ b/apps/website/content/docs/agent/api/api-docs.json @@ -595,49 +595,49 @@ { "name": "interruptReceivedAt", "type": "Signal", - "description": "Epoch ms of the first interrupt$ non-null in this stream. Resets on clearThread.", + "description": "Epoch ms of the first interrupt$ non-null in this stream. Resets on switchThread().", "optional": false }, { "name": "interruptResolvedAt", "type": "Signal", - "description": "Epoch ms of the most recent submit({ interrupt }) call. Resets on clearThread.", + "description": "Epoch ms of the most recent submit({ resume }) call. Resets on switchThread().", "optional": false }, { "name": "streamErrorAt", "type": "Signal", - "description": "Epoch ms + classification of the most recent stream error. Resets on clearThread.", + "description": "Epoch ms + classification of the most recent stream error. Resets on switchThread().", "optional": false }, { "name": "streamStartedAt", "type": "Signal", - "description": "Epoch ms of the first stream chunk arrival. Resets on clearThread.", + "description": "Epoch ms of the first stream chunk arrival. Resets on switchThread().", "optional": false }, { "name": "threadCreatedAt", "type": "Signal", - "description": "Epoch ms when the agent's \"create new thread\" branch fired. Resets on clearThread.", + "description": "Epoch ms when the agent's \"create new thread\" branch fired. Resets on switchThread().", "optional": false }, { "name": "threadPersistedAt", "type": "Signal", - "description": "Epoch ms when an existing thread was restored from server (proves persistence). Resets on clearThread.", + "description": "Epoch ms when an existing thread was restored from server (proves persistence). Resets on switchThread().", "optional": false }, { "name": "toolCallCompletedAt", "type": "Signal", - "description": "Epoch ms of the first tool call result transition. Resets on clearThread.", + "description": "Epoch ms of the first tool call result transition. Resets on switchThread().", "optional": false }, { "name": "toolCallStartedAt", "type": "Signal", - "description": "Epoch ms of the first tool call append. Resets on clearThread.", + "description": "Epoch ms of the first tool call append. Resets on switchThread().", "optional": false } ], diff --git a/apps/website/content/docs/agent/concepts/agent-architecture.mdx b/apps/website/content/docs/agent/concepts/agent-architecture.mdx index 70bb547b7..37e5a1746 100644 --- a/apps/website/content/docs/agent/concepts/agent-architecture.mdx +++ b/apps/website/content/docs/agent/concepts/agent-architecture.mdx @@ -466,7 +466,7 @@ export class MultiAgentComponent { -The `subagentToolNames` option tells agent() which tool calls spawn subagents. The default Deep Agents tool name is `task`; set this option when your graph uses custom delegation tool names. +The `subagentToolNames` option tells `agent()` which tool calls spawn subagents. The default Deep Agents tool name is `task`; set this option when your graph uses custom delegation tool names. Ordinary LangGraph subgraph nodes stream through the parent signals, but they do not appear in `subagents()` unless they are represented by matching delegation tool calls. ## Error Handling and Recovery @@ -638,14 +638,17 @@ graph = builder.compile() **Use when:** The agent takes high-stakes actions (sending emails, modifying data, making purchases) that need human approval. ```python -from langgraph.types import Interrupt +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}) + approval = interrupt({"action": plan.content, "requires_approval": True}) + return {"pending_action": plan.content, "approval": approval} def execute_action(state: AgentState) -> dict: # Only runs after human approves + if not state.get("approval", {}).get("approved"): + return {"messages": [{"role": "assistant", "content": "Action cancelled."}]} return perform_action(state["pending_action"]) ``` @@ -663,7 +666,7 @@ builder.add_node("analyst", analyst_subgraph) builder.add_conditional_edges("supervisor", route_to_agent) ``` -**Angular signals used:** `messages()`, `subagents()`, `toolCalls()`, `status()` +**Angular signals used:** `messages()`, `toolCalls()`, `status()`; `subagents()` only when delegation happens through tracked tool calls ### Decision Matrix diff --git a/apps/website/content/docs/agent/concepts/agent-contract.mdx b/apps/website/content/docs/agent/concepts/agent-contract.mdx index f1745269f..dedb54dd1 100644 --- a/apps/website/content/docs/agent/concepts/agent-contract.mdx +++ b/apps/website/content/docs/agent/concepts/agent-contract.mdx @@ -133,7 +133,7 @@ The UI lifecycle is intentionally boring. 5. `stop()` aborts the active run when supported. 6. `regenerate(index)` rolls back from an assistant message and reruns from the preceding user message. -LangGraph adds deeper lifecycle and history surfaces. `@ngaf/langgraph` exports `AGENT_LIFECYCLE`, `AgentLifecycle`, and `AgentLifecycleRegistry`. Those are useful for telemetry, debugging, persistence, and time-travel UI. They are not required by `@ngaf/chat`. +LangGraph adds deeper lifecycle and history surfaces. `@ngaf/langgraph` exposes `agent().lifecycle` and exports `AgentLifecycle`, `AgentLifecycleRegistry`, and the low-level `AGENT_LIFECYCLE` token. Those are useful for telemetry, debugging, persistence, and time-travel UI. They are not required by `@ngaf/chat`. ## Testing And Mocks diff --git a/apps/website/content/docs/agent/concepts/langgraph-basics.mdx b/apps/website/content/docs/agent/concepts/langgraph-basics.mdx index 1fbef14a1..37d829cc2 100644 --- a/apps/website/content/docs/agent/concepts/langgraph-basics.mdx +++ b/apps/website/content/docs/agent/concepts/langgraph-basics.mdx @@ -176,19 +176,25 @@ const completedTools = computed(() => agent.toolCalls()); The agent proposes an action and pauses. Your Angular UI shows an approval dialog. The user decides, and the agent resumes. ```python -from langgraph.types import Interrupt +from langgraph.types import interrupt def propose_action(state: State) -> dict: action = llm.invoke(state["messages"]) # Pause execution — Angular will show approval UI - raise Interrupt(value={ + approval = interrupt({ "action": "send_email", "to": "client@example.com", "body": action.content, }) + return { + "pending_action": action.content, + "approval": approval, + } def execute_action(state: State) -> dict: # Only runs after human approves + if not state.get("approval", {}).get("approved"): + return {"messages": [{"role": "assistant", "content": "Email cancelled."}]} send_email(state["pending_action"]) return {"messages": [{"role": "assistant", "content": "Email sent."}]} ``` @@ -227,7 +233,7 @@ builder.add_node("analyst", analyst_subgraph) builder.add_conditional_edges("supervisor", lambda s: s["next_agent"]) ``` -**Angular connection:** Track delegated work through dedicated subagent signals: +**Angular connection:** Track delegated work through dedicated subagent signals when delegation is exposed as tool calls: ```typescript const orchestrator = agent({ assistantId: 'orchestrator', diff --git a/apps/website/content/docs/agent/concepts/state-management.mdx b/apps/website/content/docs/agent/concepts/state-management.mdx index 49a9fe807..f913becd8 100644 --- a/apps/website/content/docs/agent/concepts/state-management.mdx +++ b/apps/website/content/docs/agent/concepts/state-management.mdx @@ -209,14 +209,17 @@ The agent doesn't wait until it's finished to send state updates. It streams par ### 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. +`agent()` requests the stream modes it needs to populate its public signals by default. Use `values` when you only need state snapshots, and `messages-tuple` when you only need individual message tokens. ```typescript const agent = agent({ assistantId: 'project_agent', - // Default: values mode — full state after each node - // streamMode: 'messages' — token-by-token for text fields }); + +agent.submit( + { message: 'Update the project plan.' }, + { streamMode: ['messages-tuple'] }, +); ``` ### Signals Update Mid-Stream diff --git a/apps/website/content/docs/agent/getting-started/installation.mdx b/apps/website/content/docs/agent/getting-started/installation.mdx index 414893512..1bc09d14a 100644 --- a/apps/website/content/docs/agent/getting-started/installation.mdx +++ b/apps/website/content/docs/agent/getting-started/installation.mdx @@ -39,7 +39,7 @@ npm install @ngaf/langgraph @ngaf/chat ## Configure the provider -Add `provideAgent()` to your application configuration. This sets global defaults for all agent instances. +Add `provideAgent()` to your application configuration. This sets global defaults for all agent instances. It is recommended when most agents share the same LangGraph URL, but it is not required if each `agent()` call passes its own `apiUrl`. ```typescript // app.config.ts @@ -102,10 +102,15 @@ Create a minimal component to verify the setup works. `agent()` must be called i ```typescript // In a component field initializer (injection context) -const test = agent({ assistantId: 'chat_agent' }); +const test = agent({ + apiUrl: 'http://localhost:2024', + assistantId: 'chat_agent', +}); console.log(test.status()); // 'idle' - setup is correct ``` +If you already configured `provideAgent({ apiUrl })`, you can omit `apiUrl` from individual agents. + ## Troubleshooting @@ -116,7 +121,7 @@ console.log(test.status()); // 'idle' - setup is correct **Connection refused** -- If you see `ERR_CONNECTION_REFUSED`, verify your LangGraph server is running and that the `apiUrl` matches the correct host and port. Run `langgraph dev` and confirm the server starts at the expected address (default `http://localhost:2024`). -**"NullInjectorError: No provider for AgentConfig"** -- You forgot to add `provideAgent()` to your `appConfig` providers array. See the [Configure the provider](#configure-the-provider) section above. +**Missing or incorrect API URL** -- `provideAgent()` is optional, but the runtime still needs an API URL from either `provideAgent({ apiUrl })` or `agent({ apiUrl })`. If the URL is missing or points at the wrong origin, the SDK request will fail when you submit the first run. diff --git a/apps/website/content/docs/agent/getting-started/introduction.mdx b/apps/website/content/docs/agent/getting-started/introduction.mdx index 2632852e4..56eeffd1a 100644 --- a/apps/website/content/docs/agent/getting-started/introduction.mdx +++ b/apps/website/content/docs/agent/getting-started/introduction.mdx @@ -323,7 +323,7 @@ Your Angular app is a stateless client. All agent state — threads, checkpoints Deterministic testing with MockAgentTransport - Subscribe to per-agent lifecycle signals via AGENT_LIFECYCLE + Read per-agent lifecycle signals for debugging and telemetry Deep dive into how Signals power agent diff --git a/apps/website/content/docs/agent/guides/deployment.mdx b/apps/website/content/docs/agent/guides/deployment.mdx index 6f3cbeabf..6e37d7483 100644 --- a/apps/website/content/docs/agent/guides/deployment.mdx +++ b/apps/website/content/docs/agent/guides/deployment.mdx @@ -87,7 +87,6 @@ Angular uses file-based environment replacement at build time rather than `proce export const environment = { production: false, langgraphUrl: 'http://localhost:2024', - langsmithApiKey: '', // not needed locally }; ``` @@ -97,8 +96,7 @@ export const environment = { ```typescript export const environment = { production: true, - langgraphUrl: 'https://my-agent-abc123.langgraph.app', - langsmithApiKey: 'lsv2_pt_xxxxxxxx', + langgraphUrl: '/api/langgraph', }; ``` @@ -124,36 +122,23 @@ Angular CLI replaces `environment.ts` with `environment.prod.ts` during `ng buil ## Authentication -### API key for LangGraph Platform +### Keep LangGraph credentials server-side -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. +Do not ship LangSmith or LangGraph API keys in an Angular bundle. The default `FetchStreamTransport` uses the LangGraph SDK client directly, not Angular `HttpClient`, so Angular HTTP interceptors do not attach headers to `agent()` requests. -```typescript -import { HttpInterceptorFn } from '@angular/common/http'; -import { environment } from '../environments/environment'; +For production, put a same-origin backend route, edge function, or API gateway in front of LangGraph. The browser calls your relative URL, and that server-side layer adds the deployment credentials. -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); +```typescript +// environment.prod.ts +export const environment = { + production: true, + langgraphUrl: '/api/langgraph', }; ``` -Register the interceptor in your application config: - ```typescript -import { provideHttpClient, withInterceptors } from '@angular/common/http'; -import { langGraphAuthInterceptor } from './auth.interceptor'; - export const appConfig: ApplicationConfig = { providers: [ - provideHttpClient(withInterceptors([langGraphAuthInterceptor])), provideAgent({ apiUrl: environment.langgraphUrl, }), @@ -161,13 +146,13 @@ export const appConfig: ApplicationConfig = { }; ``` - -Add `environment.prod.ts` to `.gitignore`. In CI, generate it from environment variables or inject secrets at build time. + +Environment files that are bundled into Angular are public. Store LangGraph deployment credentials in your server-side proxy, hosting provider secret store, or CI/CD environment, not in `environment.prod.ts`. ### 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. +If your app has its own user authentication, authenticate the same-origin proxy with cookies or your usual app session. The proxy can forward user identity to LangGraph through safe, server-controlled metadata or headers. ## CORS configuration @@ -185,7 +170,7 @@ In `langgraph.json`, add an `http` section: "cors": { "allow_origins": ["https://your-angular-app.com"], "allow_methods": ["GET", "POST", "PUT", "DELETE", "OPTIONS"], - "allow_headers": ["Content-Type", "x-api-key", "Authorization"], + "allow_headers": ["Content-Type", "Authorization"], "allow_credentials": true } } @@ -202,6 +187,7 @@ Production apps need graceful error handling. Build a reactive error boundary us ```typescript import { ChangeDetectionStrategy, Component, computed } from '@angular/core'; +import { HttpErrorResponse } from '@angular/common/http'; import { agent } from '@ngaf/langgraph'; @Component({ @@ -254,7 +240,7 @@ export async function retrySubmit( ): Promise { for (let attempt = 0; attempt < maxAttempts; attempt++) { try { - chat.submit(input); + await chat.submit(input); return; } catch { if (attempt === maxAttempts - 1) throw new Error('Max retries exceeded'); @@ -269,9 +255,16 @@ export async function retrySubmit( Use `joinStream()` to reconnect to a running agent execution after a network interruption, page refresh, or navigation event. ```typescript -// Store the run ID when starting a stream -const runId = this.chat.runId(); -localStorage.setItem('activeRunId', runId); +// Store the run ID when LangGraph creates the run +await this.chat.submit( + { message: input }, + { + streamResumable: true, + onRunCreated: ({ run_id }) => { + localStorage.setItem('activeRunId', run_id); + }, + }, +); // After reconnecting, resume from where the stream left off const savedRunId = localStorage.getItem('activeRunId'); @@ -323,7 +316,6 @@ jobs: export const environment = { production: true, langgraphUrl: '${{ secrets.LANGGRAPH_URL }}', - langsmithApiKey: '${{ secrets.LANGSMITH_API_KEY }}', }; EOF - run: npx ng build --configuration production @@ -371,10 +363,10 @@ effect(() => { -Point `provideAgent({ apiUrl })` to your LangGraph Cloud deployment URL via `environment.prod.ts`. +Point `provideAgent({ apiUrl })` to your same-origin LangGraph proxy, or to a direct LangGraph URL only when that deployment is intentionally public. -Add an HTTP interceptor to attach `x-api-key` headers to all LangGraph requests. +Route browser traffic through a server-side proxy or gateway that adds LangGraph credentials outside the Angular bundle. Add your Angular app's origin to the `allow_origins` list in `langgraph.json`. diff --git a/apps/website/content/docs/agent/guides/interrupts.mdx b/apps/website/content/docs/agent/guides/interrupts.mdx index 33ab68507..34434c221 100644 --- a/apps/website/content/docs/agent/guides/interrupts.mdx +++ b/apps/website/content/docs/agent/guides/interrupts.mdx @@ -15,7 +15,7 @@ Before diving into code, understand the five-stage lifecycle that every interrup 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. +The agent node calls `interrupt({...})`, which freezes the graph. The interrupt payload is persisted in the checkpoint and streamed to the client. agent() updates the `interrupt()` signal. Your Angular template detects the change through OnPush change detection and renders an approval dialog with the interrupt payload. @@ -28,16 +28,16 @@ LangGraph resumes the graph from the interrupted checkpoint. The next node recei -## Python: Raising an Interrupt +## Python: Pausing With 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. +An interrupt is created inside any graph node by calling `interrupt({...})`. The value can be any JSON-serializable object — it becomes the payload your Angular component displays. When the UI resumes the run, the resume payload becomes the return value of the `interrupt()` call. ```python from langgraph.graph import END, START, StateGraph -from langgraph.types import Interrupt, Command +from langgraph.types import interrupt from langchain_openai import ChatOpenAI from typing_extensions import TypedDict, Annotated from operator import add @@ -69,12 +69,13 @@ def plan_action(state: State) -> dict: def request_approval(state: State) -> dict: """Pause the graph and ask the human for approval.""" action = state["proposed_action"] - raise Interrupt(value={ + approval = interrupt({ "action": action["action"], "target": action["target"], "description": action["description"], "risk_level": action.get("risk_level", "medium"), }) + return {"approval_result": approval} def execute_action(state: State) -> dict: """Run the approved action or explain the rejection.""" @@ -126,7 +127,7 @@ graph = builder.compile() -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. +Place the `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. When a run resumes, LangGraph re-executes the node containing `interrupt()`, so any side effects before that call must be idempotent. ## Angular: Building an Approval Component @@ -284,7 +285,7 @@ Some workflows require multiple approvals in sequence. For example, an agent tha ```python from langgraph.graph import END, START, StateGraph -from langgraph.types import Interrupt +from langgraph.types import interrupt from typing_extensions import TypedDict, Annotated from operator import add @@ -293,6 +294,7 @@ class DeployState(TypedDict): plan: list[dict] current_step: int completed_steps: list[str] + approval_result: dict def create_plan(state: DeployState) -> dict: """Generate a multi-step deployment plan.""" @@ -307,16 +309,24 @@ 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={ + decision = interrupt({ "step_number": step_index + 1, "total_steps": len(state["plan"]), "step": step["step"], "description": step["description"], "completed": state.get("completed_steps", []), }) + return {"approval_result": decision} def execute_step(state: DeployState) -> dict: """Execute the approved step and advance.""" + decision = state.get("approval_result", {}) + if not decision.get("approved"): + return { + "current_step": len(state["plan"]), + "messages": [{"role": "assistant", "content": "Deployment aborted."}], + } + step = state["plan"][state["current_step"]] # ... perform the actual deployment step ... return { @@ -375,6 +385,7 @@ export class DeployApprovalComponent { plan: { step: string; description: string }[]; current_step: number; completed_steps: string[]; + approval_result: { approved: boolean; reason?: string }; }>({ assistantId: 'deploy_agent', }); @@ -449,9 +460,9 @@ export class DeployApprovalComponent { ## Typed Interrupt Payloads with BagTemplate -By default, `interrupt()` returns an untyped object. The BagTemplate generic parameter on agent() lets you define the exact shape of your interrupt payloads, giving you full TypeScript safety throughout your component. +By default, `interrupt()` returns an untyped value. The `BagTemplate` generic parameter on `agent()` 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 agent 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. +`BagTemplate` is an SDK type with an `InterruptType` slot. When you specify that slot, the raw `langGraphInterrupts()` signal is typed. The normalized runtime-neutral `interrupt()` signal still exposes `value` as `unknown`, so cast that payload at the UI boundary if you use the neutral signal. ```typescript import { agent, BagTemplate } from '@ngaf/langgraph'; @@ -465,21 +476,22 @@ interface DeployApproval { completed: string[]; } -// Pass the interrupt type via BagTemplate -const agent = agent< - DeployState, - BagTemplate<{ interrupt: DeployApproval }> ->({ +type DeployBag = BagTemplate & { + InterruptType: DeployApproval; +}; + +const agent = agent({ assistantId: 'deploy_agent', }); -// Now interrupt() is typed — no casting needed -const step = agent.interrupt(); -// ^? Signal<{ value: DeployApproval } | null> +const raw = agent.langGraphInterrupts(); +// ^? Interrupt[] + +const step = agent.interrupt()?.value as DeployApproval | undefined; // TypeScript catches errors at compile time -const num = step?.value.step_number; // number — correct -const bad = step?.value.nonexistent; // Error — property doesn't exist +const num = step?.step_number; // number — correct +const bad = step?.nonexistent; // Error — property doesn't exist ``` diff --git a/apps/website/content/docs/agent/guides/lifecycle.mdx b/apps/website/content/docs/agent/guides/lifecycle.mdx index ee29d61d8..36e5d3502 100644 --- a/apps/website/content/docs/agent/guides/lifecycle.mdx +++ b/apps/website/content/docs/agent/guides/lifecycle.mdx @@ -1,6 +1,6 @@ # Agent Lifecycle Signals -The `@ngaf/langgraph` library exposes per-agent lifecycle signals via the `AGENT_LIFECYCLE` injection token. These are timestamps and classifications derived from the existing stream — useful for debugging, custom dashboards, or telemetry integrations. +The `@ngaf/langgraph` library exposes per-agent lifecycle signals on every `LangGraphAgent` returned by `agent()`. These are timestamps and classifications derived from the existing stream — useful for debugging, custom dashboards, or telemetry integrations. ## Interface @@ -8,21 +8,21 @@ The `@ngaf/langgraph` library exposes per-agent lifecycle signals via the `AGENT import { InjectionToken, Signal } from '@angular/core'; export interface AgentLifecycle { - /** Epoch ms of the first stream chunk arrival. Resets on clearThread. */ + /** Epoch ms of the first stream chunk arrival. Resets on switchThread(). */ readonly streamStartedAt: Signal; - /** Epoch ms + classification of the most recent stream error. Resets on clearThread. */ + /** Epoch ms + classification of the most recent stream error. Resets on switchThread(). */ readonly streamErrorAt: Signal<{ at: number; classification: string } | null>; - /** Epoch ms of the first interrupt$ non-null in this stream. Resets on clearThread. */ + /** Epoch ms of the first interrupt$ non-null in this stream. Resets on switchThread(). */ readonly interruptReceivedAt: Signal; - /** Epoch ms of the most recent submit({ interrupt }) call. Resets on clearThread. */ + /** Epoch ms of the most recent submit({ resume }) call. Resets on switchThread(). */ readonly interruptResolvedAt: Signal; - /** Epoch ms when the agent's "create new thread" branch fired. Resets on clearThread. */ + /** Epoch ms when the agent's "create new thread" branch fired. Resets on switchThread(). */ readonly threadCreatedAt: Signal; - /** Epoch ms when an existing thread was restored from server (proves persistence). Resets on clearThread. */ + /** Epoch ms when an existing thread was restored from server (proves persistence). Resets on switchThread(). */ readonly threadPersistedAt: Signal; - /** Epoch ms of the first tool call append. Resets on clearThread. */ + /** Epoch ms of the first tool call append. Resets on switchThread(). */ readonly toolCallStartedAt: Signal; - /** Epoch ms of the first tool call result transition. Resets on clearThread. */ + /** Epoch ms of the first tool call result transition. Resets on switchThread(). */ readonly toolCallCompletedAt: Signal; } @@ -31,11 +31,11 @@ export const AGENT_LIFECYCLE = new InjectionToken('AGENT_LIFECYC ## Derivation -Five of the eight signals derive directly from existing stream subjects on the agent (`status$`, `error$`, `interrupt$`, `toolCalls$`, `history$`): +Five of the eight signals derive directly from existing stream subjects on the agent (`values$`, `messages$`, `error$`, `interrupt$`, `toolCalls$`, `history$`): | Signal | Source | |--------|--------| -| `streamStartedAt` | first `status$` transition into a running state | +| `streamStartedAt` | first non-empty `values$` or `messages$` emission | | `streamErrorAt` | `error$` emission, classified | | `interruptReceivedAt` | first non-null `interrupt$` value | | `toolCallStartedAt` | first tool-call append in `toolCalls$` | @@ -45,23 +45,25 @@ Three signals require explicit hook points that the agent already invokes: | Signal | Hook | |--------|------| -| `interruptResolvedAt` | `submit({ interrupt })` | +| `interruptResolvedAt` | `submit({ resume })` | | `threadCreatedAt` | the agent's "create new thread" branch | | `threadPersistedAt` | restore-from-server path | ## Subscribing ```typescript -import { Component, inject, effect } from '@angular/core'; -import { AGENT_LIFECYCLE } from '@ngaf/langgraph'; +import { Component, effect } from '@angular/core'; +import { agent } from '@ngaf/langgraph'; @Component({ /* ... */ }) export class MyComponent { - private lifecycle = inject(AGENT_LIFECYCLE); + private chat = agent({ + assistantId: 'chat_agent', + }); constructor() { effect(() => { - const err = this.lifecycle.streamErrorAt(); + const err = this.chat.lifecycle.streamErrorAt(); if (err) { console.log('Stream error at', err.at, 'classification:', err.classification); } @@ -70,10 +72,28 @@ export class MyComponent { } ``` +For app-wide instrumentation, provide `AgentLifecycleRegistry` and read the lifecycles registered by agents created in that injection context: + +```typescript +import { ApplicationConfig, inject } from '@angular/core'; +import { AgentLifecycleRegistry } from '@ngaf/langgraph'; + +export const appConfig: ApplicationConfig = { + providers: [AgentLifecycleRegistry], +}; +``` + +```typescript +const registry = inject(AgentLifecycleRegistry); +const lifecycles = registry.lifecycles(); +``` + +The exported `AGENT_LIFECYCLE` token is a low-level token for custom integrations. `agent()` does not automatically provide a different token instance for each agent. + ## Reset semantics -All eight signals reset on `switchThread()` (and on `clearThread()`). This keeps lifecycle observations scoped to the current thread. +All eight signals reset on `switchThread()`. This keeps lifecycle observations scoped to the current thread. ## Privacy -These signals contain no message content, no model output, no PII. They are timestamps, counts, and short classification strings only. The trust contract at [libs/telemetry/README.md](https://github.com/cacheplane/angular-agent-framework/blob/main/libs/telemetry/README.md) applies: **no app telemetry by default.** Subscribing to `AGENT_LIFECYCLE` in your code does not fire any telemetry; what you do with the signal values is your choice. +These signals contain no message content, no model output, no PII. They are timestamps, counts, and short classification strings only. The trust contract at [libs/telemetry/README.md](https://github.com/cacheplane/angular-agent-framework/blob/main/libs/telemetry/README.md) applies: **no app telemetry by default.** Reading lifecycle signals or providing `AgentLifecycleRegistry` does not fire any telemetry; what you do with the signal values is your choice. diff --git a/apps/website/content/docs/agent/guides/streaming.mdx b/apps/website/content/docs/agent/guides/streaming.mdx index 883159944..823406891 100644 --- a/apps/website/content/docs/agent/guides/streaming.mdx +++ b/apps/website/content/docs/agent/guides/streaming.mdx @@ -30,7 +30,8 @@ builder.add_edge("call_model", END) graph = builder.compile() -# Stream modes control what SSE chunks contain: +# Stream modes control what SSE chunks contain. LangGraph Platform accepts +# one mode or a list of modes, depending on the API you call: # "values" — full state snapshot after each node async for chunk in graph.astream( @@ -117,32 +118,42 @@ The connection was interrupted or the agent returned an error. Inspect `error()` ## Stream modes -LangGraph supports three stream modes. Pass `streamMode` to control what each SSE chunk contains. +By default, `agent()` asks LangGraph Platform for the stream modes it needs to populate its public signals: `values`, `messages-tuple`, `updates`, and `custom`. It also enables `streamSubgraphs` so namespaced subgraph events can reach the client. + +Override `streamMode` per run when you need a narrower stream. `streamMode` is a submit option, not an `agent()` option. - + ```typescript // Receives the full agent state after every node execution. -// Best for message-based chat interfaces. +// Best when you only need state snapshots. const chat = agent({ assistantId: 'chat_agent', - streamMode: 'values', }); +await chat.submit( + { message: 'Summarize this thread.' }, + { streamMode: ['values'] }, +); + // chat.messages() always contains the complete message list ``` - + ```typescript // Streams individual message tokens as they are generated. // Best for token-by-token rendering with lowest perceived latency. const chat = agent({ assistantId: 'chat_agent', - streamMode: 'messages', }); + +await chat.submit( + { message: 'Draft a reply.' }, + { streamMode: ['messages-tuple'] }, +); ``` @@ -153,15 +164,19 @@ const chat = agent({ // Best for advanced observability or custom progress indicators. const chat = agent({ assistantId: 'chat_agent', - streamMode: 'events', }); + +await chat.submit( + { message: 'Trace this run.' }, + { streamMode: ['events'] }, +); ``` -Use `values` for most chat UIs — it gives you a consistent, complete state snapshot. Switch to `messages` only when you need sub-token latency or are rendering a live typing cursor. +Use the default modes for most chat UIs. They keep `messages()`, `state()`, `toolCalls()`, `customEvents()`, and subagent streams populated from the same run. Narrow the mode list only when you know which signals your UI will read. ## Error handling @@ -215,7 +230,7 @@ export class ChatComponent { ## Throttle configuration -By default Agent emits a signal update for every incoming SSE chunk. On fast connections this can trigger hundreds of renders per second. Use the `throttle` option to coalesce updates. +By default Agent coalesces state-like signal updates every 16 ms. That is close to a 60 fps render cadence and prevents fast SSE streams from triggering hundreds of state renders per second. ```typescript const chat = agent({ @@ -225,12 +240,12 @@ const chat = agent({ }); ``` -The value is in milliseconds. A `throttle` of `0` (default) disables batching and passes every chunk through immediately. Good starting values: +The value is in milliseconds. Pass `false` or `0` to disable batching and forward state updates immediately. Token message updates are not throttled, so live markdown and typing indicators still receive every token emission. | Use case | Recommended throttle | |---|---| -| Token-by-token typing effect | 0 ms (disabled) | -| Standard chat bubble | 50 ms | +| Token-by-token typing effect | default 16 ms | +| Standard chat bubble | default 16 ms or 50 ms | | Background summarisation | 150 ms | diff --git a/apps/website/content/docs/agent/guides/subgraphs.mdx b/apps/website/content/docs/agent/guides/subgraphs.mdx index 649cc8a6c..4eb095c55 100644 --- a/apps/website/content/docs/agent/guides/subgraphs.mdx +++ b/apps/website/content/docs/agent/guides/subgraphs.mdx @@ -1,9 +1,9 @@ # Subgraphs -Subgraphs let you compose complex agents from smaller, focused units. agent() tracks subagent execution through dedicated signals, giving you visibility into delegated work. +Subgraphs let you compose complex agents from smaller, focused units. `agent()` streams their output through the same message, state, tool-call, and custom-event signals as the parent graph. -LangGraph calls them subgraphs (modular graph composition). Deep Agents calls them subagents (task delegation). agent() supports both patterns through the same API. +LangGraph subgraphs are graph nodes. Deep Agents-style subagents are delegated tool calls. `agent()` requests subgraph streams by default, but the `subagents()` signal is populated only for tool calls whose names match `subagentToolNames` and whose args include a `subagent_type`. ## How subgraph composition works @@ -85,15 +85,10 @@ import { agent } from '@ngaf/langgraph'; export class OrchestratorComponent { readonly orchestrator = agent({ assistantId: 'orchestrator', - subagentToolNames: ['research', 'analyze'], }); - readonly running = computed(() => - [...this.orchestrator.subagents().values()].filter((subagent) => - subagent.status() === 'pending' || subagent.status() === 'running' - ) - ); - readonly runningCount = computed(() => this.running().length); + readonly messages = computed(() => this.orchestrator.messages()); + readonly isRunning = computed(() => this.orchestrator.isLoading()); send(text: string) { this.orchestrator.submit({ message: text }); @@ -104,14 +99,14 @@ export class OrchestratorComponent { -## Tracking subagent execution +## Tracking delegated subagent execution -The `subagents()` signal contains a Map of active subagent streams. Use it to inspect the full set of delegated tasks and their current state. +The `subagents()` signal contains a Map of active delegated subagent streams. Use it when your graph delegates through tool calls, such as Deep Agents' default `task` tool or your own delegation tools. Plain subgraph nodes do not appear in this map. ```typescript const orchestrator = agent({ assistantId: 'orchestrator', - subagentToolNames: ['research', 'analyze', 'summarize'], + subagentToolNames: ['task', 'delegate_to_researcher'], }); // All subagent streams (active and completed) @@ -127,7 +122,9 @@ const runningCount = computed(() => running().length); // Lookup helpers for common UI paths const specific = computed(() => orchestrator.getSubagent('research-tool-call-id')); -const researchers = computed(() => orchestrator.getSubagentsByType('researcher')); +const researchers = computed(() => + orchestrator.getSubagentsByType('researcher') +); // React to count changes effect(() => { @@ -163,7 +160,7 @@ The orchestrator pattern delegates specialised work to subagents and merges thei ```typescript const pipeline = agent({ assistantId: 'pipeline-orchestrator', - subagentToolNames: ['fetch-data', 'transform', 'validate', 'publish'], + subagentToolNames: ['task'], filterSubagentMessages: true, }); @@ -219,7 +216,7 @@ export class SubagentProgressComponent { } @if (entry[1].status() === 'error') { -

{{ entry[1].error()?.message }}

+

Subagent failed

} } @@ -235,7 +232,7 @@ By default, subagent messages appear in the parent's `messages()` signal. Filter const orchestrator = agent({ assistantId: 'orchestrator', filterSubagentMessages: true, // Hide subagent messages from parent - subagentToolNames: ['research', 'analyze'], + subagentToolNames: ['task'], }); // Parent messages only (no subagent chatter) @@ -243,21 +240,20 @@ const parentMessages = computed(() => orchestrator.messages()); ``` -Set `subagentToolNames` to the tool names that spawn subagents. agent() uses this to identify which tool calls create subagent streams. +Set `subagentToolNames` to the tool names that spawn subagents. `agent()` uses this to identify tool calls that create subagent streams. Those tool calls must include a `subagent_type` argument for type-based lookup helpers such as `getSubagentsByType()`. ## Error handling per subagent -Each subagent exposes its own `error()` signal so failures are isolated — one subagent failing does not stop the others. +Each subagent exposes its own `status()` signal. A failure changes that subagent's status to `'error'` without necessarily stopping sibling delegates. ```typescript const agents = orchestrator.subagents(); for (const [id, agent] of agents) { effect(() => { - const err = agent.error(); - if (err) { - console.error(`Subagent ${id} failed:`, err.message); + if (agent.status() === 'error') { + console.error(`Subagent ${id} failed`); // Retry, surface to user, or fall back gracefully } }); diff --git a/libs/langgraph/src/lib/lifecycle.ts b/libs/langgraph/src/lib/lifecycle.ts index 0b5a6c1a7..37728d77e 100644 --- a/libs/langgraph/src/lib/lifecycle.ts +++ b/libs/langgraph/src/lib/lifecycle.ts @@ -2,21 +2,21 @@ import { InjectionToken, Signal } from '@angular/core'; export interface AgentLifecycle { - /** Epoch ms of the first stream chunk arrival. Resets on clearThread. */ + /** Epoch ms of the first stream chunk arrival. Resets on switchThread(). */ readonly streamStartedAt: Signal; - /** Epoch ms + classification of the most recent stream error. Resets on clearThread. */ + /** Epoch ms + classification of the most recent stream error. Resets on switchThread(). */ readonly streamErrorAt: Signal<{ at: number; classification: string } | null>; - /** Epoch ms of the first interrupt$ non-null in this stream. Resets on clearThread. */ + /** Epoch ms of the first interrupt$ non-null in this stream. Resets on switchThread(). */ readonly interruptReceivedAt: Signal; - /** Epoch ms of the most recent submit({ interrupt }) call. Resets on clearThread. */ + /** Epoch ms of the most recent submit({ resume }) call. Resets on switchThread(). */ readonly interruptResolvedAt: Signal; - /** Epoch ms when the agent's "create new thread" branch fired. Resets on clearThread. */ + /** Epoch ms when the agent's "create new thread" branch fired. Resets on switchThread(). */ readonly threadCreatedAt: Signal; - /** Epoch ms when an existing thread was restored from server (proves persistence). Resets on clearThread. */ + /** Epoch ms when an existing thread was restored from server (proves persistence). Resets on switchThread(). */ readonly threadPersistedAt: Signal; - /** Epoch ms of the first tool call append. Resets on clearThread. */ + /** Epoch ms of the first tool call append. Resets on switchThread(). */ readonly toolCallStartedAt: Signal; - /** Epoch ms of the first tool call result transition. Resets on clearThread. */ + /** Epoch ms of the first tool call result transition. Resets on switchThread(). */ readonly toolCallCompletedAt: Signal; }