Skip to content

Commit 0b4282e

Browse files
bloveclaude
andcommitted
docs(langgraph): describe the lifecycle token, root registry, error kind, required interrupt, and awaitable transport
- lifecycle: `kind` replaces `classification` and holds the `AgentErrorKind`; the registry is root-provided and unregisters on destroy; `AGENT_LIFECYCLE` comes from `provideAgent()` and follows the last-ref-wins rule. - provide-agent: documents the `AGENT_LIFECYCLE` token and the dev-mode warning on an ambiguous ref-less inject. - testing and mock-stream-transport: every emit is awaited rather than chased with a macrotask flush; `flush()` is documented; `chat.interrupt()` drops the `?.` now that `LangGraphAgent` requires it. Both pages' spec fences were executed verbatim against the adapter and pass; the transport page's first fence was additionally missing the optimistic user message in its assertion. - agent-contract and introduction: the narrowed `interrupt`, and the corrected lifecycle/registry facts. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
1 parent c45ae59 commit 0b4282e

7 files changed

Lines changed: 116 additions & 82 deletions

File tree

apps/website/content/docs/langgraph/api/api-docs.json

Lines changed: 34 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -2,14 +2,14 @@
22
{
33
"name": "AgentLifecycleRegistry",
44
"kind": "class",
5-
"description": "Optional registry that collects per-instance agent lifecycles within\nan Angular injection context. External instrumentation packages\n(e.g. cockpit-telemetry) provide this token and read from it.\n\n`@threadplane/langgraph` does NOT provide this itself — the configured agent\ninstance writes to the registry only when an external consumer has provided it.",
5+
"description": "Application-wide registry of every live agent's AgentLifecycle.\n\nIt is `providedIn: 'root'`, so it always exists and every agent registers\ninto the same instance regardless of which injector built it — an agent\ncreated in a route or component injector is still visible from the root.\nExternal instrumentation packages read `lifecycles()` to observe every agent\nin the application without owning the provider graph.\n\nRegistration is scoped to the agent's lifetime: an agent unregisters when the\ninjector that created it is destroyed, so `lifecycles()` never accumulates\nlifecycles for agents that are gone.",
66
"params": [],
77
"examples": [],
88
"properties": [
99
{
1010
"name": "lifecycles",
1111
"type": "Signal<readonly AgentLifecycle[]>",
12-
"description": "Reactive list of registered lifecycles.",
12+
"description": "Reactive list of the lifecycles of every currently live agent.",
1313
"optional": false
1414
}
1515
],
@@ -26,6 +26,19 @@
2626
"optional": false
2727
}
2828
]
29+
},
30+
{
31+
"name": "unregister",
32+
"signature": "unregister(lifecycle: AgentLifecycle): void",
33+
"description": "Drop a lifecycle when its agent's injector is destroyed.",
34+
"params": [
35+
{
36+
"name": "lifecycle",
37+
"type": "AgentLifecycle",
38+
"description": "",
39+
"optional": false
40+
}
41+
]
2942
}
3043
]
3144
},
@@ -563,7 +576,7 @@
563576
{
564577
"name": "MockAgentTransport",
565578
"kind": "class",
566-
"description": "Test transport for deterministic agent testing without a real LangGraph server.\n\nScript event batches upfront, then emit them manually or step through them\nin your test specs. Supports error injection and close control.",
579+
"description": "Test transport for deterministic agent testing without a real LangGraph server.\n\nScript event batches upfront, then emit them manually or step through them\nin your test specs. Supports error injection and close control.\n\n`emit()`, `emitError()`, `close()` and `flush()` are awaitable: the returned\npromise settles once the adapter has consumed everything queued so far (and\none macrotask later, so throttled signal writes have landed), which removes\nthe hand-rolled `await new Promise(resolve => setTimeout(resolve, 0))` flush\nfrom specs.",
567580
"params": [
568581
{
569582
"name": "script",
@@ -573,7 +586,7 @@
573586
}
574587
],
575588
"examples": [
576-
"```typescript\nconst transport = new MockAgentTransport([\n [{ type: 'values', messages: [aiMsg('Hello')] }],\n [{ type: 'values', messages: [aiMsg('Done')] }],\n]);\n```"
589+
"```typescript\nconst transport = new MockAgentTransport([\n [{ type: 'values', messages: [aiMsg('Hello')] }],\n [{ type: 'values', messages: [aiMsg('Done')] }],\n]);\nawait transport.emit(transport.nextBatch());\n```"
577590
],
578591
"properties": [
579592
{
@@ -641,8 +654,8 @@
641654
},
642655
{
643656
"name": "close",
644-
"signature": "close(): void",
645-
"description": "Close the stream. Remaining queued events are drained before completion.",
657+
"signature": "close(): Promise<void>",
658+
"description": "Close the stream. Remaining queued events are drained before completion.\nResolves once the run has finished.",
646659
"params": []
647660
},
648661
{
@@ -684,8 +697,8 @@
684697
},
685698
{
686699
"name": "emit",
687-
"signature": "emit(events: StreamEvent[]): void",
688-
"description": "Manually emit events into the stream.",
700+
"signature": "emit(events: StreamEvent[]): Promise<void>",
701+
"description": "Manually emit events into the stream.\n\nAwait the returned promise: it resolves once the adapter has pulled this\nbatch out of the stream (or the run has ended), so signals are settled and\nassertions read live state rather than the value from before the emit.",
689702
"params": [
690703
{
691704
"name": "events",
@@ -697,8 +710,8 @@
697710
},
698711
{
699712
"name": "emitError",
700-
"signature": "emitError(err: Error): void",
701-
"description": "Inject an error into the stream.",
713+
"signature": "emitError(err: Error): Promise<void>",
714+
"description": "Inject an error into the stream. Resolves once the stream has thrown.",
702715
"params": [
703716
{
704717
"name": "err",
@@ -708,6 +721,12 @@
708721
}
709722
]
710723
},
724+
{
725+
"name": "flush",
726+
"signature": "flush(): Promise<void>",
727+
"description": "Resolve once everything emitted so far has been consumed, without emitting\nanything new. Useful after driving the agent by some other route (a\n`submit()`, a `switchThread()`) that has to reach the transport first.",
728+
"params": []
729+
},
711730
{
712731
"name": "getHistory",
713732
"signature": "getHistory(threadId: string, signal: AbortSignal): Promise<ThreadState<DefaultValues>[]>",
@@ -975,7 +994,7 @@
975994
{
976995
"name": "streamErrorAt",
977996
"type": "Signal<object | null>",
978-
"description": "Epoch ms + classification of the most recent stream error. Resets on switchThread().",
997+
"description": "Epoch ms + failure class of the most recent stream error. Resets on switchThread().\n\n`kind` is the AgentErrorKind of the normalized `AgentError`\n(`connection` | `auth` | `server` | `interrupted` | `aborted`) — the same\nvalue `agent.error()?.kind` carries. For a failure the runtime could not\nnormalize it falls back to the error's constructor name.",
979998
"optional": false
980999
},
9811000
{
@@ -1512,8 +1531,8 @@
15121531
{
15131532
"name": "interrupt",
15141533
"type": "Signal<AgentInterrupt | undefined>",
1515-
"description": "",
1516-
"optional": true
1534+
"description": "Current human-in-the-loop pause, or `undefined` when the run is not paused.\n\nNarrowed from the neutral `Agent` contract, where `interrupt` is optional\nbecause a runtime without human-in-the-loop support omits it. The LangGraph\nadapter always provides it, so `injectAgent().interrupt()` type-checks\ndirectly under `strictNullChecks` — no `?.()` needed.",
1535+
"optional": false
15171536
},
15181537
{
15191538
"name": "isLoading",
@@ -1946,7 +1965,7 @@
19461965
{
19471966
"name": "interrupt",
19481967
"type": "WritableSignal<AgentInterrupt | undefined>",
1949-
"description": "",
1968+
"description": "Current human-in-the-loop pause, or `undefined` when the run is not paused.\n\nNarrowed from the neutral `Agent` contract, where `interrupt` is optional\nbecause a runtime without human-in-the-loop support omits it. The LangGraph\nadapter always provides it, so `injectAgent().interrupt()` type-checks\ndirectly under `strictNullChecks` — no `?.()` needed.",
19501969
"optional": false
19511970
},
19521971
{
@@ -2499,7 +2518,7 @@
24992518
{
25002519
"name": "provideAgent",
25012520
"kind": "function",
2502-
"description": "Wire the LangGraph adapter into Angular's dependency injection.\n\nRegisters a singleton `LangGraphAgent` constructed from `config`. Retrieve it\nin any component with `injectAgent()`. Provide this at the application root\n(`app.config.ts`) for an app-wide agent.\n\nTo use a different agent in a component subtree, re-provide\n`provideAgent({...})` in that component's `providers: []` array —\nAngular's hierarchical DI scopes the singleton accordingly.\n\n**Static vs factory config.** Pass a plain `AgentConfig` object when the\nconfig is known up front. Pass a `() => AgentConfig` factory when the config\ndepends on runtime/DI state — the factory runs inside an Angular injection\ncontext, so it may call `inject()` to read services, route params, or\ncomponent-scoped signals.\n\n**Typed state via AgentRef.** Pass a typed ref as the first argument to flow\nthe state shape from `provideAgent` to `injectAgent` without repeating the\ngeneric at every call site.\n\n**Several agents at one injector level.** Each `provideAgent(ref, …)` call\nbuilds its own agent from its own config, so two (or more) refs may be\nprovided side by side in a single `providers` array and `injectAgent(refA)`\n/ `injectAgent(refB)` return distinct agents. The ref-less `injectAgent()`\nresolves a single shared token, which can only point at one of them: when\nmore than one ref is provided at the same level the **last** call wins.\nAlways inject by ref when an injector provides more than one agent.",
2521+
"description": "Wire the LangGraph adapter into Angular's dependency injection.\n\nRegisters a singleton `LangGraphAgent` constructed from `config`. Retrieve it\nin any component with `injectAgent()`. Provide this at the application root\n(`app.config.ts`) for an app-wide agent.\n\nTo use a different agent in a component subtree, re-provide\n`provideAgent({...})` in that component's `providers: []` array —\nAngular's hierarchical DI scopes the singleton accordingly.\n\n**Static vs factory config.** Pass a plain `AgentConfig` object when the\nconfig is known up front. Pass a `() => AgentConfig` factory when the config\ndepends on runtime/DI state — the factory runs inside an Angular injection\ncontext, so it may call `inject()` to read services, route params, or\ncomponent-scoped signals.\n\n**Typed state via AgentRef.** Pass a typed ref as the first argument to flow\nthe state shape from `provideAgent` to `injectAgent` without repeating the\ngeneric at every call site.\n\n**Several agents at one injector level.** Each `provideAgent(ref, …)` call\nbuilds its own agent from its own config, so two (or more) refs may be\nprovided side by side in a single `providers` array and `injectAgent(refA)`\n/ `injectAgent(refB)` return distinct agents. The ref-less `injectAgent()`\nresolves a single shared token, which can only point at one of them: when\nmore than one ref is provided at the same level the **last** call wins, and\nresolving it in dev mode logs a `console.warn` naming every competing ref.\nThe `AGENT_LIFECYCLE` token follows the same rule. Always inject by ref when\nan injector provides more than one agent.\n\n**Lifecycle token.** Every form also provides `AGENT_LIFECYCLE`, so\n`inject(AGENT_LIFECYCLE)` returns the same object as `injectAgent().lifecycle`\nwithout reaching for the agent itself.",
25032522
"signature": "provideAgent(ref: AgentRef<T>, configOrFactory: AgentConfig<T, BagTemplate> | () => AgentConfig<T>): Provider[]",
25042523
"params": [
25052524
{

apps/website/content/docs/langgraph/api/mock-stream-transport.mdx

Lines changed: 19 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -48,17 +48,20 @@ describe('ChatComponent', () => {
4848
fixture.detectChanges();
4949

5050
const stream = fixture.componentInstance.chat.submit({ message: 'Hello' });
51-
transport.emit([
51+
await transport.emit([
5252
{
5353
type: 'values',
5454
messages: [{ type: 'ai', content: 'Hi there' }],
5555
},
5656
]);
57-
transport.close();
57+
await transport.close();
5858
await stream;
5959
fixture.detectChanges();
6060

61+
// submit() appends the user message optimistically, so the streamed
62+
// assistant reply is the last entry, not the only one.
6163
expect(fixture.componentInstance.chat.messages()).toEqual([
64+
expect.objectContaining({ role: 'user', content: 'Hello' }),
6265
expect.objectContaining({ role: 'assistant', content: 'Hi there' }),
6366
]);
6467
expect(fixture.componentInstance.chat.status()).toBe('idle');
@@ -69,7 +72,7 @@ describe('ChatComponent', () => {
6972
fixture.detectChanges();
7073

7174
const stream = fixture.componentInstance.chat.submit({ message: 'Hello' });
72-
transport.emitError(new Error('not found'));
75+
await transport.emitError(new Error('not found'));
7376
await stream;
7477
fixture.detectChanges();
7578

@@ -85,9 +88,10 @@ describe('ChatComponent', () => {
8588
|--------|-------------|
8689
| `constructor(script?: StreamEvent[][])` | Optionally seeds scripted event batches for manual stepping (defaults to `[]`). |
8790
| `nextBatch()` | Returns the next scripted event batch. |
88-
| `emit(events)` | Pushes one or more `StreamEvent` objects into the active stream. |
89-
| `emitError(err)` | Makes the active stream reject with `err`. The runtime catches it, so `submit()` still resolves — assert on `status()` and `error()` instead of on a rejected promise. |
90-
| `close()` | Closes the active stream after queued events drain. |
91+
| `emit(events)` | Pushes one or more `StreamEvent` objects into the active stream. Returns a promise that resolves once the adapter has consumed the batch. |
92+
| `emitError(err)` | Makes the active stream reject with `err`. The runtime catches it, so `submit()` still resolves — assert on `status()` and `error()` instead of on a rejected promise. Returns a promise that resolves once the stream has thrown. |
93+
| `close()` | Closes the active stream after queued events drain. Returns a promise that resolves once the run has finished. |
94+
| `flush()` | Resolves once everything emitted so far has been consumed, without emitting anything new. |
9195
| `isStreaming()` | Returns whether a stream is currently active. |
9296

9397
Messages inside a `values` event are raw LangGraph messages, so their role comes from `type` (`'human'`, `'ai'`, `'tool'`, `'system'`), not from a `role` field. The projector reads `_getType()` or the raw `type` and falls back to `'ai'`; a `role` key is never read.
@@ -99,6 +103,15 @@ The transport also records calls in `streams`, `createdQueuedRuns`, `cancelledRu
99103
or `close()`. This makes stream state and payload assertions deterministic.
100104
</Callout>
101105

106+
<Callout type="warning" title="Await every emit">
107+
`emit()`, `emitError()`, `close()` and `flush()` are asynchronous. `stream()`
108+
is an async generator, so nothing has reached the signals at the moment
109+
`emit()` returns. Await the promise before asserting — a spec that reads
110+
signals synchronously after an emit reads stale state and passes or fails for
111+
the wrong reason. `flush()` covers the same wait when the agent was driven by
112+
something other than an emit.
113+
</Callout>
114+
102115
## What's Next
103116

104117
<CardGroup cols={3}>

apps/website/content/docs/langgraph/api/provide-agent.mdx

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -82,7 +82,7 @@ provideAgent({
8282
const chat = injectAgent();
8383
```
8484

85-
Several agents may also coexist at one injector level. Each `provideAgent(ref, …)` call builds its own agent from its own config, so `injectAgent(refA)` and `injectAgent(refB)` return distinct instances. The ref-less `injectAgent()` resolves a single shared token, which can only point at one of them — the last ref-form call at that level wins. Always inject by ref when an injector provides more than one agent.
85+
Several agents may also coexist at one injector level. Each `provideAgent(ref, …)` call builds its own agent from its own config, so `injectAgent(refA)` and `injectAgent(refB)` return distinct instances. The ref-less `injectAgent()` resolves a single shared token, which can only point at one of them — the last ref-form call at that level wins, and resolving it in development mode logs a warning naming every competing ref. Always inject by ref when an injector provides more than one agent.
8686

8787
```ts
8888
export const LIVE = createAgentRef<ChatState>('live');
@@ -95,6 +95,23 @@ providers: [
9595
// injectAgent(LIVE) !== injectAgent(REPLAY)
9696
```
9797

98+
<Callout type="warning" title="Dev-mode warning on an ambiguous ref-less inject">
99+
In development mode, resolving the ref-less token while several refs share the injector level logs a `console.warn` naming every competing ref and the one that won. It fires only on the ambiguous path: `injectAgent(LIVE)` and `injectAgent(REPLAY)` are unambiguous and stay silent. Production builds never log it.
100+
</Callout>
101+
102+
## The `AGENT_LIFECYCLE` token
103+
104+
Every form of `provideAgent()` also provides `AGENT_LIFECYCLE`, so instrumentation can read an agent's [lifecycle signals](/docs/langgraph/guides/lifecycle) without depending on the agent itself:
105+
106+
```ts
107+
import { inject } from '@angular/core';
108+
import { AGENT_LIFECYCLE } from '@threadplane/langgraph';
109+
110+
const lifecycle = inject(AGENT_LIFECYCLE); // === injectAgent().lifecycle
111+
```
112+
113+
The token follows the same last-one-wins rule as the ref-less `injectAgent()`: with several refs at one injector level it resolves the last ref's lifecycle. Read `injectAgent(ref).lifecycle` instead when that is the case.
114+
98115
## Transcript node filtering
99116

100117
LangGraph streams `messages-tuple` chunks for every LLM node in a run. If your graph has side-effect LLM nodes, such as a title generator or evaluator, set `transcriptNodeNames` so only your conversational node updates `messages()`.

apps/website/content/docs/langgraph/concepts/agent-contract.mdx

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -136,7 +136,9 @@ The UI lifecycle is intentionally boring — and boring is the goal.
136136
5. `stop()` aborts the active run when supported, and `retry()` clears `error()` and re-runs the last submitted input after a failure.
137137
6. `regenerate(index)` rolls back from an assistant message and reruns from the preceding user message.
138138

139-
LangGraph adds deeper lifecycle and history surfaces. `@threadplane/langgraph` exposes `injectAgent().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 `@threadplane/chat`.
139+
LangGraph adds deeper lifecycle and history surfaces. `@threadplane/langgraph` exposes `injectAgent().lifecycle`, provides the `AGENT_LIFECYCLE` token from every `provideAgent()` call, and ships a root-provided `AgentLifecycleRegistry` that collects the lifecycle of every live agent. Those are useful for telemetry, debugging, persistence, and time-travel UI. They are not required by `@threadplane/chat`. See [Agent lifecycle signals](/docs/langgraph/guides/lifecycle).
140+
141+
`LangGraphAgent` also narrows one optional member of the neutral contract: `interrupt` is required, because the LangGraph adapter always provides it. Code written against `LangGraphAgent` calls `agent.interrupt()` directly; code written against the neutral `Agent` still needs `interrupt?.()`.
140142

141143
## Testing And Mocks
142144

apps/website/content/docs/langgraph/getting-started/introduction.mdx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -40,7 +40,7 @@ chat.messages(); // Message[]
4040
chat.status(); // 'idle' | 'running' | 'error'
4141
chat.isLoading(); // boolean
4242
chat.error(); // AgentError | undefined
43-
chat.interrupt?.(); // AgentInterrupt | undefined
43+
chat.interrupt(); // AgentInterrupt | undefined
4444
chat.history(); // AgentCheckpoint[]
4545
chat.langGraphHistory(); // ThreadState[]
4646
```

0 commit comments

Comments
 (0)