From 1559b782407458591594d269d7222d8e254cc842 Mon Sep 17 00:00:00 2001 From: Brian Love Date: Sat, 5 Sep 2026 23:42:20 -0700 Subject: [PATCH 01/26] docs(chat): the twelve chat pages leave the pending list Co-Authored-By: Claude Fable 5.1 --- apps/website/src/lib/docs-example-code.spec.ts | 12 ------------ 1 file changed, 12 deletions(-) diff --git a/apps/website/src/lib/docs-example-code.spec.ts b/apps/website/src/lib/docs-example-code.spec.ts index ae4b128a0..b8f88bfc2 100644 --- a/apps/website/src/lib/docs-example-code.spec.ts +++ b/apps/website/src/lib/docs-example-code.spec.ts @@ -21,18 +21,6 @@ import { * comments on any docs page, or it counts as an include. */ const PENDING_PAGES = new Set([ - '/docs/chat/a2ui/overview', - '/docs/chat/components/chat-debug', - '/docs/chat/components/chat-input', - '/docs/chat/components/chat-interrupt-panel', - '/docs/chat/components/chat-subagent-card', - '/docs/chat/components/chat-tool-calls', - '/docs/chat/components/chat-trace', - '/docs/chat/concepts/message-model', - '/docs/chat/guides/client-tools', - '/docs/chat/guides/generative-ui', - '/docs/chat/guides/theming', - '/docs/chat/guides/thread-routing', ]); const findWorkspaceRoot = (): string => { From f25b4371908404048b1cb22aa400f96ab04fafe2 Mon Sep 17 00:00:00 2001 From: Brian Love Date: Sat, 5 Sep 2026 23:47:07 -0700 Subject: [PATCH 02/26] docs(chat): chat-interrupt-panel teaches through the running example Co-Authored-By: Claude Fable 5.1 --- .../chat/components/chat-interrupt-panel.mdx | 252 ++++++++++-------- .../angular/src/app/interrupts.component.ts | 4 + cockpit/chat/interrupts/python/docs/guide.md | 96 ------- cockpit/chat/interrupts/python/src/graph.py | 4 + 4 files changed, 145 insertions(+), 211 deletions(-) delete mode 100644 cockpit/chat/interrupts/python/docs/guide.md diff --git a/apps/website/content/docs/chat/components/chat-interrupt-panel.mdx b/apps/website/content/docs/chat/components/chat-interrupt-panel.mdx index 9520c1292..468ba43e7 100644 --- a/apps/website/content/docs/chat/components/chat-interrupt-panel.mdx +++ b/apps/website/content/docs/chat/components/chat-interrupt-panel.mdx @@ -1,173 +1,195 @@ +--- +description: How the interrupts example pauses a flight booking at interrupt() and resumes it from the panel's Accept and Ignore buttons, plus the component API +--- + # ChatInterruptPanelComponent -`ChatInterruptPanelComponent` is a composition that renders a styled interrupt card when the LangGraph agent pauses for human input. It displays the interrupt payload and provides action buttons for the user to accept, edit, respond to, or ignore the interrupt. +`ChatInterruptPanelComponent` is a composition that renders an inline card whenever the agent is waiting on a human decision. It reads the pending interrupt off the agent, prints the payload, and offers four buttons: Accept, Edit, Respond, and Ignore. It emits which one was clicked and nothing else, so the resume payload stays yours to choose. The running example is a flight booking that pauses before it completes, and this page walks the four files behind it. **Selector:** `chat-interrupt-panel` -**Import:** +## What the demo does + +The Run tab shows the prebuilt `` composition beside a sidebar holding the panel and the agent status. Two welcome suggestions set up the same pause on two different paths. "Book a flight (you confirm)" asks the agent to book UA123, and "Book a flight (you cancel)" asks it to book AA404. + +Either request reaches the `book_flight` tool, which stops mid-call. The panel appears in the sidebar with the booking payload and the four buttons. + +Accept resumes the run with `confirm`, the tool returns a booking confirmation, and the agent relays it in the transcript. Ignore resumes with `cancel`, and the tool returns "Booking cancelled." instead. Edit and Respond render, because the panel always renders all four, but this example leaves them unhandled: a single-decision booking has nothing to edit. + +## How it is built + +Four files carry the feature: a Python tool that pauses, the graph that runs it, an application config that registers the agent, and an Angular component that places the panel and maps its buttons onto resume payloads. Open the Code tab to read them in place. + +### The tool that pauses + +`interrupt()` freezes the run and streams its argument to the client, and that argument is exactly what the panel renders. Here the tool looks the flight up first, builds a one-line summary, and interrupts with a dictionary carrying the summary and the flight record. When the client resumes, the same `interrupt()` call returns the resume value, so the decision is read as an ordinary return value on the next line. + + + +The tool accepts any resume value that starts with `confirm` and treats everything else as a cancellation, which is why the client can answer with a bare string. + + +LangGraph resumes by re-executing the interrupting call, and `interrupt()` returns the resume value rather than pausing a second time. The flight lookup above therefore runs twice. Keep that stretch free of side effects: reading data is safe, charging a card there is not. + + +### The graph around it + +The graph is an ordinary agent and tool loop. `book_flight` is registered next to the read-only aviation tools, and nothing in the wiring is interrupt-specific, because the pause happens inside the tool rather than in the topology. + + + +`compile()` is called with no checkpointer here, because this graph is served by the LangGraph API server, which supplies persistence itself. + +### The agent provider + +`provideAgent()` registers the agent once for the whole application, and `provideChat({})` registers the configuration the `` composition reads, here left at its defaults. The example passes a factory because it resolves its connection details at runtime from the host that serves the demo. + + + +Your own application does not need the factory. Pass the values directly: ```typescript -import { ChatInterruptPanelComponent } from '@threadplane/chat'; -import type { InterruptAction } from '@threadplane/chat'; +provideAgent({ + apiUrl: 'https://your-deployment.langgraph.app', + assistantId: 'chat-interrupts', +}); ``` -## How It Works +`assistantId` must match the graph name in `langgraph.json`. -LangGraph agents can pause execution using interrupts -- checkpoints where the agent waits for human input before proceeding. The `ChatInterruptPanelComponent` reads the interrupt state from an `Agent` and renders a warning card with action buttons. +### The panel in the sidebar -When no interrupt is active, the component renders nothing. +The panel takes one input, the agent, and renders nothing at all while no interrupt is pending, so it is safe to leave mounted permanently. This demo puts it in the sidebar next to the agent status rather than over the transcript. -## Basic Usage + -```html - -``` +The status line beside it reads `agent.status()`, which is `idle`, `running`, or `error`. There is no interrupted status: a pending interrupt is a separate signal, and the panel is what reads it. -```typescript -import type { InterruptAction } from '@threadplane/chat'; +### Resuming with the decision -handleInterrupt(action: InterruptAction) { - switch (action) { - case 'accept': - this.chatRef.submit({ resume: true }); - break; - case 'edit': - this.openEditor(); - break; - case 'respond': - this.focusInput(); - break; - case 'ignore': - // Do nothing, dismiss the panel - break; - } -} -``` +The panel emits an `InterruptAction`, and the component turns the two actions it cares about into resume payloads. The graph expects a string, so `accept` submits `'confirm'` and `ignore` submits `'cancel'`. - -`chat-interrupt-panel` only emits which button the user clicked — it never calls `agent.submit()` itself. You own resumption. `openEditor()` and `focusInput()` above are your own helpers (collect an edited proposal or a typed reply); once you have the value, send it back with `agent.submit({ resume })`. The shape of `resume` is whatever your graph's interrupt handler expects. + -```typescript -// After the user edits the agent's proposal: -async resumeWithEdit(edited: Record) { - await this.chatRef.submit({ resume: { action: 'edit', data: edited } }); -} +`submit({ resume })` continues the paused run instead of starting a new one, and whatever you put in `resume` is exactly what `interrupt()` returns on the server. -// After the user types a free-text reply: -async resumeWithResponse(text: string) { - await this.chatRef.submit({ resume: { action: 'respond', text } }); -} -``` + +`chat-interrupt-panel` emits the action and stops there. Leaving an action unhandled, as this example leaves Edit and Respond, means the run stays paused and the panel stays on screen. Handle every button you are willing to show, or wrap the panel in your own template so the unused ones never appear. -## API +## Import + +```typescript +import { ChatInterruptPanelComponent } from '@threadplane/chat'; +import type { InterruptAction } from '@threadplane/chat'; +``` + +The component is standalone, so add it to a component's `imports` array. -### Inputs +## Inputs | Input | Type | Default | Description | |-------|------|---------|-------------| -| `agent` | `Agent` | **Required** | The agent providing interrupt state | +| `agent` | `Agent` | **Required** | The agent whose pending interrupt the panel renders. | -### Outputs +## Outputs | Output | Type | Description | |--------|------|-------------| -| `action` | `InterruptAction` | Emits when the user clicks an action button | +| `action` | `InterruptAction` | Emits the action the user selected. The panel takes no other step. | -## InterruptAction Type +## The InterruptAction union ```typescript type InterruptAction = 'accept' | 'edit' | 'respond' | 'ignore'; ``` -| Action | Button Label | Typical Use | -|--------|-------------|-------------| -| `'accept'` | Accept | Approve the agent's proposed action and resume execution | -| `'edit'` | Edit | Open an editor to modify the agent's proposal before resuming | -| `'respond'` | Respond | Send a text response back to the agent | -| `'ignore'` | Ignore | Dismiss the interrupt without taking action | +| Action | Button | Typical use | +|--------|--------|-------------| +| `'accept'` | Accept | Approve the proposed action and resume the run. | +| `'edit'` | Edit | Open your own editor, then resume with the edited value. | +| `'respond'` | Respond | Collect free text, then resume with it. | +| `'ignore'` | Ignore | Reject or dismiss. The run stays paused until you resume it. | -## Interrupt Payload Display +All four buttons always render. The union is the whole of the component's contract with your code. -The component extracts the interrupt payload and displays it as text: +## How the payload is rendered -- If the interrupt value is a `string`, it is displayed directly -- If the interrupt value is an object, it is serialized with `JSON.stringify()` +The panel derives one string from the interrupt value and prints it, preserving line breaks: -## Styling +- An object with a string `reason` field renders that field alone. +- A string value renders directly. +- Anything else is rendered as indented JSON. -The panel uses the chat theme's warning variables: +The example takes the third path, since its payload is a dictionary of `type`, `summary`, and `flight` with no `reason` field, so the card shows the raw booking record. -| Variable | Applied To | + +Interrupt with a `reason` string alongside your structured fields and the panel shows the sentence rather than the JSON dump, with no change on the Angular side. + + +## Theme variables + +The panel reads these from the chat theme: + +| Variable | Applied to | |----------|-----------| -| `--tplane-chat-warning-bg` | Panel background | -| `--tplane-chat-warning-text` | Header and message text | -| `--tplane-chat-separator` | Panel border | -| `--tplane-chat-radius-card` | Panel border radius | -| `--tplane-chat-surface-alt` | Action button backgrounds | -| `--tplane-chat-text` | Action button text | -| `--tplane-chat-text-muted` | Ignore button text | +| `--tplane-chat-surface` | Card background | +| `--tplane-chat-separator` | Card border and the Edit and Respond button borders | +| `--tplane-chat-radius-card` | Card corner radius | +| `--tplane-chat-warning-text` | The "Agent paused" eyebrow and its dot | +| `--tplane-chat-text` | Payload text and the Edit and Respond button labels | +| `--tplane-chat-primary` | Accept button background | +| `--tplane-chat-on-primary` | Accept button label | +| `--tplane-chat-radius-button` | Button corner radius | +| `--tplane-chat-text-muted` | Ignore button label | + +The card carries `role="alert"`, so a screen reader announces the pause when it appears. -## Primitive Alternative: ChatInterruptComponent +## The lower level primitive -If you need full control over the interrupt UI, use the lower-level `ChatInterruptComponent` primitive instead. It provides content projection via an `ng-template`: +When the four buttons are the wrong set, `ChatInterruptComponent` gives you the pause detection without the chrome. It renders its own heading and projects an `ng-template` whose implicit value is the pending interrupt, so the body and every control are yours. ```html - + -
-

Agent paused: {{ interrupt.value }}

- -
+

{{ summaryOf(interrupt) }}

+
``` -The primitive also exports a `getInterrupt()` helper function: +`value` is typed `unknown`, so narrow it in the component rather than in the template: ```typescript -import { getInterrupt } from '@threadplane/chat'; - -const interrupt = getInterrupt(chatRef); // Interrupt | undefined +summaryOf(interrupt: AgentInterrupt): string { + const value = interrupt.value as { summary?: string }; + return value.summary ?? ''; +} ``` -## Full Example +The same module exports the reader both components use: ```typescript -import { Component, signal, ChangeDetectionStrategy } from '@angular/core'; -import { injectAgent, provideAgent } from '@threadplane/langgraph'; -import { ChatComponent, ChatInterruptPanelComponent } from '@threadplane/chat'; -import type { InterruptAction } from '@threadplane/chat'; +import { getInterrupt } from '@threadplane/chat'; -@Component({ - selector: 'app-interrupt-demo', - standalone: true, - imports: [ChatComponent, ChatInterruptPanelComponent], - providers: [provideAgent({ assistantId: 'interrupt_agent', threadId: signal(null) })], - changeDetection: ChangeDetectionStrategy.OnPush, - template: ` -
-
- -
- - -
- `, -}) -export class InterruptDemoComponent { - chatRef = injectAgent(); - - onInterruptAction(action: InterruptAction) { - if (action === 'accept') { - this.chatRef.submit({ resume: true }); - } - } -} +const pending = getInterrupt(agent); // AgentInterrupt | undefined ``` + +An `AgentInterrupt` carries `id`, the opaque `value`, and `resumable`, which is `true` when the runtime supports `submit({ resume })`. + +## What's Next + + + + The interrupt lifecycle, typed payloads, and timeout strategies. + + + The same panel gating writes behind an approval. + + + The composition the panel sits beside. + + + Where the variables above come from. + + diff --git a/cockpit/chat/interrupts/angular/src/app/interrupts.component.ts b/cockpit/chat/interrupts/angular/src/app/interrupts.component.ts index 67d656ecf..c7739cf05 100644 --- a/cockpit/chat/interrupts/angular/src/app/interrupts.component.ts +++ b/cockpit/chat/interrupts/angular/src/app/interrupts.component.ts @@ -60,6 +60,7 @@ const SUGGESTIONS = [ }
+

Interrupt Panel

@@ -68,6 +69,7 @@ const SUGGESTIONS = [

{{ streamStatus() }}

+ `, styles: [` @@ -105,6 +107,7 @@ export class InterruptsComponent { protected readonly streamStatus = computed(() => this.agent.status()); protected readonly suggestions = SUGGESTIONS; + // #region resume protected onInterruptAction(action: InterruptAction): void { if (action === 'accept') { this.agent.submit({ resume: 'confirm' }); @@ -113,6 +116,7 @@ export class InterruptsComponent { } // 'edit' and 'respond' are intentionally unhandled for the booking flow. } + // #endregion protected send(text: string): void { void this.agent.submit({ message: text }); diff --git a/cockpit/chat/interrupts/python/docs/guide.md b/cockpit/chat/interrupts/python/docs/guide.md deleted file mode 100644 index d6a0c0de0..000000000 --- a/cockpit/chat/interrupts/python/docs/guide.md +++ /dev/null @@ -1,96 +0,0 @@ -# Chat Interrupts with @threadplane/chat - - -Implement human-in-the-loop approval gates using LangGraph interrupts -and ChatInterruptPanelComponent. The graph pauses execution and presents -an approval UI before proceeding. - - - -Add interrupt handling to your chat interface using `ChatInterruptPanelComponent` -from `@threadplane/chat`. Detect when the stream enters an interrupted state -and render approval/rejection controls. - - - - - -Configure `provideAgent()` in your app config, then call `injectAgent()` in your -component - the agent automatically detects interrupt states from the LangGraph -backend: - -```typescript -// app.config.ts -import { provideAgent } from '@threadplane/langgraph'; - -export const appConfig: ApplicationConfig = { - providers: [ - provideAgent({ - apiUrl: environment.langGraphApiUrl, - assistantId: environment.streamingAssistantId, - }), - ], -}; -``` - -```typescript -// app.component.ts -import { injectAgent } from '@threadplane/langgraph'; - -protected readonly stream = injectAgent(); -``` - - - - -Check the stream status for interrupt events. The agent ref exposes -interrupt data when the graph pauses: - -```typescript -protected readonly isInterrupted = computed( - () => this.stream.status() === 'interrupted' -); -``` - - - - -Use `ChatInterruptPanelComponent` to display the approval UI: - -```html - -``` - -The panel shows the interrupt payload, draft content, and action buttons. - - - - -The interrupt panel emits `approve` and `reject` events. Handle them -to resume or cancel the graph execution: - -```html - -``` - - - - -After approval, resume the graph to continue from the interrupt point: - -```typescript -onApprove() { - this.stream.resume({ action: 'approve' }); -} -``` - - - - - -Interrupts are ideal for sensitive actions like sending emails, making -purchases, or modifying data where human oversight is required. - diff --git a/cockpit/chat/interrupts/python/src/graph.py b/cockpit/chat/interrupts/python/src/graph.py index 2eeadc250..92a0d38fe 100644 --- a/cockpit/chat/interrupts/python/src/graph.py +++ b/cockpit/chat/interrupts/python/src/graph.py @@ -75,6 +75,7 @@ async def generate_title(state: MessagesState, config) -> dict: return {} +# region book-flight @tool async def book_flight(flight_number: str) -> str: """Book a flight by flight number. Pauses for human confirmation. @@ -113,8 +114,10 @@ async def book_flight(flight_number: str) -> str: f"(departs {flight['depart_local']})." ) return "Booking cancelled." +# endregion +# region graph def build_interrupts_graph(): """Agent ↔ ToolNode loop with aviation read tools + book_flight (interrupt).""" tools = [book_flight, find_routes, lookup_flight, get_airport_info] @@ -141,6 +144,7 @@ def should_continue(state: MessagesState) -> str: graph.add_edge("tools", "agent") graph.add_edge("generate_title", END) return graph.compile() +# endregion graph = build_interrupts_graph() From e49c251d0942bc5476499675777e1e408bb16b42 Mon Sep 17 00:00:00 2001 From: Brian Love Date: Sat, 5 Sep 2026 23:47:57 -0700 Subject: [PATCH 03/26] docs(chat): chat-debug teaches through the running example Co-Authored-By: Claude Fable 5.1 --- .../docs/chat/components/chat-debug.mdx | 157 ++++++++++-------- cockpit/chat/debug/python/docs/guide.md | 63 ------- cockpit/chat/debug/python/src/graph.py | 5 + 3 files changed, 96 insertions(+), 129 deletions(-) delete mode 100644 cockpit/chat/debug/python/docs/guide.md diff --git a/apps/website/content/docs/chat/components/chat-debug.mdx b/apps/website/content/docs/chat/components/chat-debug.mdx index a51e57189..7d815937d 100644 --- a/apps/website/content/docs/chat/components/chat-debug.mdx +++ b/apps/website/content/docs/chat/components/chat-debug.mdx @@ -1,6 +1,10 @@ +--- +description: How the debug example mounts the chat-debug panel against a multi-node LangGraph agent, plus the panel inputs, outputs, sidenav integration and bundle gate. +--- + # ChatDebugComponent -`ChatDebugComponent` provides a docked development panel for inspecting an `Agent` or `AgentWithHistory`. It ships from the debug-only secondary entry point so applications can keep debug implementation code out of production bundles unless they explicitly opt in. +`ChatDebugComponent` is a docked development panel for inspecting an agent. It reads the agent's checkpoint history and current state and renders them in a floating panel, and it ships from a debug-only secondary entry point so applications can keep the implementation out of production bundles unless they opt in. The running example mounts the panel on its own, which is the clearest way to see what the panel contributes and what it does not. **Selector:** `chat-debug` @@ -10,97 +14,118 @@ import { ChatDebugComponent } from '@threadplane/chat/debug'; ``` -## Basic Usage +## What the demo does -```typescript -import { Component, ChangeDetectionStrategy, signal } from '@angular/core'; -import { injectAgent, provideAgent } from '@threadplane/langgraph'; -import { ChatDebugComponent } from '@threadplane/chat/debug'; +The Run tab mounts one component: `` bound to a LangGraph agent. The panel keeps itself out of the layout — its host renders `display: contents`, and the launcher and the panel are both fixed-position — so the page shows nothing but a small round status pill in the top-right corner. Click the pill and the docked Chat Devtools panel opens, with a Timeline tab listing the agent's checkpoints and a State tab pretty-printing the agent's current state under a Copy button. + +The demo mounts no chat composition beside the panel, so there is no composer on the page and the Timeline tab shows its empty state: "No checkpoints yet. Send a message to populate the timeline." Everything else is live. The dock buttons in the header move the panel between the left, bottom and right edges, and the choice is persisted, as is the open state and the selected tab. + +## How it is built + +Three files carry the example: the graph that produces the checkpoints, the provider that points Angular at it, and the component that mounts the panel. Open the Code tab to read them in place. + +### A graph with several nodes per turn + +The backend is deliberately multi-step, because one node per turn produces one checkpoint and very little to look at. `generate` answers with the model, `process` appends a synthetic message derived from the answer, and `summarize` asks the model for a one-sentence summary of the conversation so far. The system prompt read by `generate` frames the assistant as an aviation helper working over a mock dataset of ten United States airports and four airlines. + + + +Each node returns a partial state update, and each of those updates becomes a checkpoint on the thread. + +### Wiring the nodes into a linear pipeline + +The nodes are registered on a `StateGraph` over `MessagesState` and chained: `generate` to `process` to `summarize` to `generate_title`, then to the end. `generate_title` is a background node that summarizes the first user message into a thread title; it returns an empty update, so it changes the message list not at all while still adding a step to the run. + + + + +The last line calls `compile()` with no checkpointer. The checkpoints the Timeline tab reads come from the LangGraph API server, which persists thread state for every graph it serves. Compiling a checkpointer in as well is an error when the graph is served that way — see [Persistence](/docs/langgraph/guides/persistence). + + +### The agent provider + +`provideAgent()` registers the agent once for the whole application. The example resolves its connection details at runtime from the host that serves the demo, which is why its factory reads them rather than hard-coding them, and it sits alongside `provideChat({})`, which supplies the chat library's own providers. -@Component({ - selector: 'app-debug-page', - standalone: true, - imports: [ChatDebugComponent], - providers: [ - provideAgent({ - apiUrl: '/api/langgraph', - assistantId: 'chat', - threadId: signal(localStorage.getItem('threadId')), - onThreadId: (id) => localStorage.setItem('threadId', id), - }), - ], - changeDetection: ChangeDetectionStrategy.OnPush, - template: ` - - `, -}) -export class DebugPageComponent { - protected readonly replayCheckpointId = signal(null); - protected readonly forkSourceCheckpointId = signal(null); - - protected readonly chat = injectAgent(); - - replay(checkpointId: string) { - this.replayCheckpointId.set(checkpointId); - // Route this into your app's replay workflow. - } - - fork(checkpointId: string) { - this.forkSourceCheckpointId.set(checkpointId); - // Route this into your app's thread fork workflow. - } -} + + +Your own application passes the two values directly instead: + +```typescript +provideAgent({ + apiUrl: 'https://your-deployment.langgraph.app', + assistantId: 'debug', +}), +provideChat({}), ``` +### Mounting the panel + +The component is a template and one field. `injectAgent()` returns the agent registered above, and it is handed straight to `[agent]`. Nothing else is bound, so the panel runs on its defaults: docked right, closed on first load, with the floating launcher visible. + + + +The agent returned by `injectAgent()` exposes a `history()` signal as well as `state()`, which is what makes the Timeline tab appear; an agent without `history()` gets the State tab alone. + ## Inputs | Input | Type | Default | Description | | --- | --- | --- | --- | -| `agent` | `Agent \| AgentWithHistory` | **Required** | Agent state. Agents with `history()` also enable the Timeline tab. | -| `dock` | `'right' \| 'bottom' \| 'left'` | `'right'` | Initial dock position. | -| `defaultOpen` | `boolean` | `false` | Initial open state when no persisted state exists. | +| `agent` | `DebugAgent \| DebugAgentWithHistory \| null` | `null` | Agent to inspect. Nothing renders while this is `null`. An agent that also exposes `history()` enables the Timeline tab. | +| `dock` | `'right' \| 'bottom' \| 'left'` | `'right'` | Initial dock position, used when no persisted position exists. | +| `defaultOpen` | `boolean` | `false` | Initial open state, used when no persisted state exists. | | `launcher` | `'floating' \| 'none'` | `'floating'` | Shows the built-in floating launcher, or hides it when another surface opens the panel. | -| `storageKey` | `string` | `'chat-debug'` | Local storage key prefix for persisted open/dock/tab state. | +| `storageKey` | `string` | `'chat-debug'` | Local storage key prefix for the persisted open, dock and tab state. | + +`DebugAgent` is a structural type: signals for `messages`, `status`, `isLoading`, `error`, `toolCalls` and `state`. `DebugAgentWithHistory` adds `history`. The agents returned by `injectAgent()` satisfy the second. ## Outputs | Output | Type | Description | | --- | --- | --- | -| `replayRequested` | `string` | Emits a checkpoint id when the built-in Timeline tab requests replay. | -| `forkRequested` | `string` | Emits a checkpoint id when the built-in Timeline tab requests fork. | +| `replayRequested` | `string` | Emits a checkpoint id when Replay is pressed on a selected checkpoint in the Timeline tab. | +| `forkRequested` | `string` | Emits a checkpoint id when Fork is pressed on a selected checkpoint in the Timeline tab. | | `openChange` | `boolean` | Emits when the panel opens or closes. | | `dockChange` | `'right' \| 'bottom' \| 'left'` | Emits when the dock position changes. | -`replayRequested` and `forkRequested` are integration hooks. The debug panel does not mutate the agent by itself; the host app decides whether a checkpoint opens a replay view, starts a forked thread, or maps to a backend-specific time travel operation. - -## Sidenav Integration - -`ChatSidenavComponent` can own the launcher for apps that already use the sidenav footer. Pass the same agent and keep `[debug]` enabled: +`replayRequested` and `forkRequested` are integration hooks, and the example demonstrates the panel without them. The panel never mutates the agent by itself: selecting a checkpoint reveals a Replay button, a Fork button and a before-and-after diff of that step's state, and the host application decides whether an emitted id opens a replay view, starts a forked thread, or maps to a backend-specific time travel operation. ```html - ``` -The footer button is labelled in expanded and drawer modes. In collapsed mode it uses the status dot only. The dot pulses while `agent.status()` is `running`. +## Sidenav integration + +`ChatSidenavComponent` can own the launcher for applications that already use the sidenav footer. Pass the same agent and leave `[debug]` at its default of `true`: + +```html + +``` + +The footer button appears only when an agent is bound and the debug entry point is included in the build. It is labeled "Devtools" in expanded and drawer modes; in collapsed mode it is the status dot alone, and the dot pulses while `agent.status()` is `running`. Pressing it lazily imports `ChatDebugComponent`, mounts it with `launcher="none"` and the storage key `chat-sidenav-debug`, and opens it. The sidenav does not re-expose `replayRequested` or `forkRequested`, so mount `` yourself when you need those hooks. -## Production Bundles +## Production bundles -The debug implementation lives under `@threadplane/chat/debug`; the main `@threadplane/chat` entry point no longer exports it. In the canonical Angular demo, normal production builds set `THREADPLANE_CHAT_DEBUG=false` and externalize `@threadplane/chat/debug`, so the debug implementation is absent from the emitted bundle. The `production-debug` build opts back in with `THREADPLANE_CHAT_DEBUG=true`. +The implementation lives under `@threadplane/chat/debug`; the main `@threadplane/chat` entry point does not export it. Importing it yourself, as the example does, always puts it in the bundle. The sidenav's lazy import is different: it is gated on an internal flag that is true whenever `ngDevMode` is true, and otherwise only when a `THREADPLANE_CHAT_DEBUG` compile-time constant is defined as `true`. The canonical Angular demo defines that constant per build configuration — `false` for `production`, and `true` for a separate `production-debug` configuration. -Keep debug controls for your app outside the debug panel. The debug panel intentionally exposes a small fixed surface so consumers do not expect demo-specific controls to appear in their own applications. +Keep debug controls of your own outside the panel. The panel intentionally exposes a small fixed surface, so consumers do not expect application-specific controls to appear in it. -## See also +## What's Next -- [Time travel](/docs/langgraph/guides/time-travel) — what the `replayRequested` / `forkRequested` checkpoint hooks plug into, and how to wire replay and fork against the agent's history. -- [``](/docs/chat/concepts/primitives-vs-compositions#compositions) — the layout composition that can own the debug launcher (see Sidenav Integration above). + + + What the replay and fork checkpoint hooks plug into, and how to wire them against the agent's history. + + + Where the checkpoints come from, and why this graph compiles without a checkpointer. + + + The layout composition that can own the debug launcher. + + + An in-conversation view of graph execution, for surfaces your users see. + + diff --git a/cockpit/chat/debug/python/docs/guide.md b/cockpit/chat/debug/python/docs/guide.md deleted file mode 100644 index a6ab89c22..000000000 --- a/cockpit/chat/debug/python/docs/guide.md +++ /dev/null @@ -1,63 +0,0 @@ -# Chat Debug with @threadplane/chat - - -Inspect conversation state, diffs, and graph execution using the -ChatDebugComponent. Provides a full debug panel with timeline, -state inspector, and diff viewer for development. - - - -Add a debug panel to your chat interface using `ChatDebugComponent` -from `@threadplane/chat/debug`. This replaces `ChatComponent` and provides -full development inspection capabilities. - - - - - -Use `ChatDebugComponent` instead of `ChatComponent` for the full -debug experience: - -```typescript -import { ChatDebugComponent } from '@threadplane/chat/debug'; -``` - - - - -Place the debug component in your template: - -```html - -``` - -This renders the full debug panel with timeline, state inspector, -and diff viewer. - - - - -The debug panel shows the current graph state at each checkpoint. -Click on any checkpoint to see the full state object and how it -changed from the previous step. - - - - -The diff viewer highlights what changed between consecutive -checkpoints, making it easy to understand how each node modifies -the conversation state. - - - - -The debug panel provides controls for stepping through execution, -replaying from checkpoints, and inspecting intermediate values. - - - - - -ChatDebugComponent is designed for development only. Use ChatComponent -in production for a polished end-user experience. - diff --git a/cockpit/chat/debug/python/src/graph.py b/cockpit/chat/debug/python/src/graph.py index 87ec56880..82e866945 100644 --- a/cockpit/chat/debug/python/src/graph.py +++ b/cockpit/chat/debug/python/src/graph.py @@ -73,6 +73,7 @@ def build_debug_graph(): """ llm = ChatOpenAI(model="gpt-5-mini", streaming=True) + # region pipeline-nodes async def generate(state: MessagesState) -> dict: system_prompt = (PROMPTS_DIR / "debug.md").read_text() messages = [SystemMessage(content=system_prompt)] + state["messages"] @@ -94,6 +95,9 @@ async def summarize(state: MessagesState) -> dict: response = await llm.ainvoke(messages) return {"messages": [response]} + # endregion + + # region graph-wiring graph = StateGraph(MessagesState) graph.add_node("generate", generate) graph.add_node("process", process) @@ -106,6 +110,7 @@ async def summarize(state: MessagesState) -> dict: graph.add_edge("generate_title", END) return graph.compile() + # endregion graph = build_debug_graph() From 107ef2d64c1c6e1aac6baee06aa84f156a51f2c4 Mon Sep 17 00:00:00 2001 From: Brian Love Date: Sat, 5 Sep 2026 23:48:20 -0700 Subject: [PATCH 04/26] docs(chat): chat-subagent-card teaches through the running example Co-Authored-By: Claude Fable 5.1 --- .../chat/components/chat-subagent-card.mdx | 204 +++++++++++------- .../angular/src/app/subagents.component.ts | 4 + cockpit/chat/subagents/python/docs/guide.md | 87 -------- cockpit/chat/subagents/python/src/graph.py | 8 + 4 files changed, 140 insertions(+), 163 deletions(-) delete mode 100644 cockpit/chat/subagents/python/docs/guide.md diff --git a/apps/website/content/docs/chat/components/chat-subagent-card.mdx b/apps/website/content/docs/chat/components/chat-subagent-card.mdx index 99f57deaf..1621199b6 100644 --- a/apps/website/content/docs/chat/components/chat-subagent-card.mdx +++ b/apps/website/content/docs/chat/components/chat-subagent-card.mdx @@ -1,8 +1,10 @@ -# ChatSubagentCardComponent +--- +description: How the subagents example dispatches task calls to real subgraphs, and the Subagent contract the card reads to render each dispatch inline +--- -`ChatSubagentCardComponent` is a composition that renders an expandable card for a subagent stream. It displays the subagent's name (or tool call ID), current status (with a color-coded badge) and message count, and expands to show the subagent's **full transcript** — every message rendered as streaming markdown, alongside any reasoning and the subagent's own tool-call cards. +# ChatSubagentCardComponent -The card is built on the [``](/docs/chat/components/chat-trace) primitive, so it auto-expands while the subagent is `running` and collapses once it reaches `complete` (a user toggle always wins). +`ChatSubagentCardComponent` renders one delegated subagent as an expandable card: its name, the tool call that spawned it, a status pill, a message count, and — once expanded — the subagent's own transcript. The running example is a trip planner whose orchestrator delegates research, booking, and itinerary work to a child graph, and this page walks the files that make each dispatch show up as a card. **Selector:** `chat-subagent-card` @@ -12,7 +14,73 @@ The card is built on the [``](/docs/chat/components/chat-trace) prim import { ChatSubagentCardComponent } from '@threadplane/chat'; ``` -## Basic Usage +## What the demo does + +The Run tab shows the prebuilt `` composition beside a sidebar that lists the pipeline: orchestrator, then a research subagent, a booking subagent, and an itinerary subagent. One welcome suggestion, "Plan a trip from LAX to JFK", starts the run. + +Send it and the orchestrator calls one `task` tool three times, in that order. Each call renders in the transcript as a subagent card rather than as a generic tool-call chip. A card opens while its subagent is running, streams that subagent's answer inside itself, and collapses to a one-line summary when the dispatch completes. The cards stay in the transcript afterwards, so the finished run reads as three collapsed dispatches followed by the orchestrator's own summary of the trip. + +## How it is built + +Four things carry the feature: a child graph the orchestrator can invoke, a `task` tool that invokes it, one line of adapter configuration that says `task` means delegation, and a component that renders the standard `` composition. Open the Code tab to read them in place. + +### The subagent subgraph + +Each specialist is the same compiled child graph, parameterized by a `subagent_type` that selects its system prompt. The child keeps its own state schema, so the task description arrives as a field rather than as a chat message. + + + +Invoking that compiled graph from inside a tool is what makes LangGraph nest its run under a `tools:` namespace, and everything the card shows comes from that namespaced stream. + +### The task tool + +The tool is an ordinary LangChain tool. It takes the specialist to dispatch and a plain-English description of the work, invokes the child graph, and returns the child's final text to the orchestrator. + + + +`subagent_type` is a `Literal`, which matters twice: it constrains what the model may dispatch, and it becomes the card's label. + +### Binding the child stream to its tool call + +Nothing on the wire links the child's `tools:` namespace to the parent's `call_*` tool-call id — the uuid is a checkpoint id assigned independently. Inside the tool body both halves are known, so the example emits them together as one custom event. + + + +`@threadplane/langgraph` consumes that event as protocol chatter rather than forwarding it as application data, and replays any chunks that streamed before the binding arrived. + + +Without the announcement the adapter falls back to attributing the first unmapped pending or running child, which is correct only while one dispatch is outstanding at a time. Announcing the binding is what makes parallel fan-out safe. The canonical helper is `threadplane.middleware.langgraph.announce_subagent`; the example inlines it because each example stands alone. + + +### The orchestrator + +The parent is a plain tool-calling loop: a model bound to the single `task` tool, a `ToolNode` that runs it, and a conditional edge back to the model until no tool calls remain. + + + +The system prompt tells the orchestrator to dispatch research, then booking, then itinerary, which is why the demo produces three cards in a stable order. + +### Telling the adapter that task means delegation + +`provideAgent()` registers the agent once for the whole application, and `provideChat({})` registers the chat configuration at its defaults. The one subagent-specific line is `subagentToolNames`. + + + +A tool call is registered as a subagent only when its name is in `subagentToolNames` and its arguments carry a plausible `subagent_type`; that argument then becomes `Subagent.name`. The default is already `['task']`, so this example states explicitly what it would otherwise inherit. + +### Where the card appears + +The component is small on purpose. It renders ``, a welcome suggestion, and a static sidebar note — and nothing about subagents at all. + + + +Subagent cards appear because ``, inside the `` composition, cross-references each tool call against `agent.subagents()`: a call that spawned a subagent renders as a standalone `` instead of a tool-call card, and never groups with adjacent calls. + + + +## Basic usage + +Rendering a card directly needs only the subagent: ```html @@ -26,120 +94,104 @@ import { ChatSubagentCardComponent } from '@threadplane/chat'; |-------|------|---------|-------------| | `subagent` | `Subagent` | **Required** | The subagent to display | +The component exposes no outputs. + ## Subagent -The `Subagent` type comes from `@threadplane/chat`. It provides reactive state for a subagent: +The `Subagent` type comes from `@threadplane/chat` and is what every adapter maps its own subagent state onto: | Property | Type | Description | |----------|------|-------------| -| `toolCallId` | `string` | The ID linking this subagent to its parent tool call | -| `name` | `string \| undefined` | Optional human-readable name. The card's "Subagent" label uses it when present | -| `status()` | `Signal<'pending' \| 'running' \| 'complete' \| 'error'>` | Current execution status | -| `messages()` | `Signal` | Messages produced by the subagent | -| `toolCalls()` | `Signal \| undefined` | The subagent's own tool calls (name/args/result), referenced by each message's `toolCallIds`. Optional — adapters that don't surface subagent tool calls omit it, and the card defaults to `[]` | -| `state()` | `Signal>` | Arbitrary subagent state exposed by the runtime | +| `toolCallId` | `string` | The tool call that spawned this subagent | +| `name` | `string \| undefined` | Optional human-readable name. The card falls back to the literal `Subagent` when it is absent | +| `status` | `Signal<'pending' \| 'running' \| 'complete' \| 'error'>` | Current execution status | +| `messages` | `Signal` | Messages produced by the subagent | +| `toolCalls` | `Signal \| undefined` | The subagent's own tool calls, referenced by each message's `toolCallIds`. Optional — adapters that do not surface subagent tool calls omit it, and the card defaults to `[]` | +| `state` | `Signal>` | Arbitrary subagent state exposed by the runtime | + +In the running example `name` is the `subagent_type` argument (`research`, `booking`, `itinerary`), `state` carries the child's state values plus the tool result once it lands, and `toolCalls` is absent, because the LangGraph adapter does not surface a child's own tool calls. -## Card Behavior + +The adapter registers a subagent as `pending` the moment the orchestrator's tool call arrives, and hides `pending` entries from `agent.subagents()`. The entry turns `running` when its child stream is bound, and `complete` (or `error`) when the tool result message lands. + + +## What the card renders ### Header -The card header (the `` toggle button) shows: -- A chevron that reflects the expanded state -- The subagent `name` if present, otherwise the literal `"Subagent"`, with the `toolCallId` in monospace -- A color-coded status pill -- The message count (e.g., "3 message(s)") +The header is the `` toggle button. It shows a chevron reflecting the expanded state, the `name` (or the literal `Subagent`), the `toolCallId` in a monospace face, a color-coded status pill, and the message count as "N message(s)". -### Auto-expand and collapse +### Expansion -Expansion is driven by ``: the card auto-expands while `status()` is `running` and collapses when it settles to `complete`. Clicking the header toggles it manually, and a manual toggle overrides the automatic behavior. +Expansion is delegated to [``](/docs/chat/components/chat-trace): the card expands automatically while the status is `running` or `error`, and otherwise stays collapsed. Clicking the header sets a manual override that wins from then on. Re-entering `running` or `error` from another state clears that override, so a card that is dispatched again opens again. ### Transcript -When expanded, the card renders the subagent's **entire message list** (not just the latest). For each message it shows, in order: +When expanded, the card renders the subagent's entire message list, not just the latest message. For each message, in order: + - Any `reasoning` text, as a muted italic line -- The message `content`, rendered through `` (streaming markdown) -- A [``](/docs/chat/components/chat-tool-call-card) for each tool call referenced by the message's `toolCallIds` +- The message content, rendered through `` so it streams as markdown +- A [``](/docs/chat/components/chat-tool-call-card) for each tool call the message references and the `toolCalls` signal resolves -### Status Pill Colors +### Status pill colors -The status pill is styled via a `data-status` attribute and CSS selectors (the exported `statusColor()` helper is retained for backward compatibility). Colors map to chat theme variables: +The pill is styled through a `data-status` attribute and CSS selectors. The exported `statusColor()` helper returns the equivalent inline style string and is retained for existing consumers. -| Status | Background | Text Color | +| Status | Background | Text color | |--------|-----------|------------| | `pending` | `--tplane-chat-surface-alt` | `--tplane-chat-text-muted` | | `running` | `--tplane-chat-warning-bg` | `--tplane-chat-warning-text` | | `complete` | (none) | `--tplane-chat-success` | | `error` | `--tplane-chat-error-bg` | `--tplane-chat-error-text` | -## Using with ChatSubagentsComponent +## Cards outside the transcript -The `ChatSubagentsComponent` primitive iterates over active subagent streams from an `Agent`. Combine it with `ChatSubagentCardComponent`: +The example needs no explicit markup because the `` composition places each card inline. To render cards somewhere else — a sidebar tray, for instance — use the `ChatSubagentsComponent` primitive, which iterates the agent's subagents and drops the ones that already reached `complete` or `error`: ```html - + ``` -## Full Example - -```typescript -import { Component, signal, ChangeDetectionStrategy } from '@angular/core'; -import { injectAgent, provideAgent } from '@threadplane/langgraph'; -import { - ChatComponent, - ChatSubagentsComponent, - ChatSubagentCardComponent, -} from '@threadplane/chat'; - -@Component({ - selector: 'app-subagent-demo', - standalone: true, - imports: [ChatComponent, ChatSubagentsComponent, ChatSubagentCardComponent], - providers: [provideAgent({ assistantId: 'multi_agent', threadId: signal(null) })], - changeDetection: ChangeDetectionStrategy.OnPush, - template: ` -
-
- -
- -
-

Active Subagents

-
- - - - - -
-
-
- `, -}) -export class SubagentDemoComponent { - chatRef = injectAgent(); -} -``` +The template is optional. Without one, `` renders a `` for each active subagent itself. ## Styling -The card uses the following CSS custom properties: +The card reads these CSS custom properties: -| Variable | Applied To | +| Variable | Applied to | |----------|-----------| -| `--tplane-chat-text` | Subagent name, message content | -| `--tplane-chat-text-muted` | Tool call ID, message count, reasoning line | -| `--tplane-chat-font-mono` | Tool call ID | +| `--tplane-chat-text` | Subagent name | +| `--tplane-chat-text-muted` | Tool call id, message count, reasoning line, `pending` pill text | +| `--tplane-chat-font-mono` | Tool call id | | `--tplane-chat-separator` | Divider between successive transcript messages | +| `--tplane-chat-surface-alt` | `pending` pill background | | `--tplane-chat-warning-bg` / `--tplane-chat-warning-text` | `running` status pill | | `--tplane-chat-success` | `complete` status pill | | `--tplane-chat-error-bg` / `--tplane-chat-error-text` | `error` status pill | -The outer card chrome (background, border, radius) comes from the wrapping [``](/docs/chat/components/chat-trace). +The outer card chrome — background, border, radius — comes from the wrapping [``](/docs/chat/components/chat-trace). + +## Accessibility + +The header is a real `