Skip to content

Commit 971581c

Browse files
bloveclaude
andauthored
fix(langgraph, ag-ui): give each AgentRef its own agent instead of aliasing one token (#1000)
Two provideAgent(ref, ...) calls in one providers array silently returned the same agent, built from the last config. AGENT and AGENT_CONFIG are module-scope tokens, so a second call re-registers both (last wins) and every ref.token was useExisting: AGENT, so all refs aliased that single winner. No error, no warning. Both adapters had the identical shape; both are fixed. Each ref now gets a per-call private config token and its own factory. The alias direction is load-bearing: the ref path aliases AGENT to ref.token rather than providing a second independent factory, which is what keeps single-ref behaviour byte-identical — one instance, one config-factory evaluation. With several refs the bare injectAgent() necessarily stays last-wins, and the doc comment now says so. Includes the api-docs regeneration for the changed doc comments. Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
1 parent d932a48 commit 971581c

6 files changed

Lines changed: 270 additions & 19 deletions

File tree

apps/website/content/docs/ag-ui/api/api-docs.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -590,7 +590,7 @@
590590
{
591591
"name": "provideAgent",
592592
"kind": "function",
593-
"description": "Provides an Agent instance wired through HttpAgent and toAgent.\nConstructs an HttpAgent from config and wraps it in the runtime-neutral\nAgent contract via toAgent(). Returns a provider array suitable for\nbootstrapApplication or TestBed.configureTestingModule().\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 or route params.\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.",
593+
"description": "Provides an Agent instance wired through HttpAgent and toAgent.\nConstructs an HttpAgent from config and wraps it in the runtime-neutral\nAgent contract via toAgent(). Returns a provider array suitable for\nbootstrapApplication or TestBed.configureTestingModule().\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 or route params.\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, so two (or more) refs may be provided side by side in a\nsingle `providers` array and `injectAgent(refA)` / `injectAgent(refB)` return\ndistinct agents. The ref-less `injectAgent()` resolves a single shared token,\nwhich can only point at one of them: when more than one ref is provided at\nthe same level the **last** call wins. Always inject by ref when an injector\nprovides more than one agent.",
594594
"signature": "provideAgent(ref: AgentRef<T>, configOrFactory: AgentConfig | () => AgentConfig): Provider[]",
595595
"params": [
596596
{

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

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2499,7 +2499,7 @@
24992499
{
25002500
"name": "provideAgent",
25012501
"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.",
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.",
25032503
"signature": "provideAgent(ref: AgentRef<T>, configOrFactory: AgentConfig<T, BagTemplate> | () => AgentConfig<T>): Provider[]",
25042504
"params": [
25052505
{
@@ -2520,6 +2520,7 @@
25202520
"description": ""
25212521
},
25222522
"examples": [
2523+
"```ts\nexport const LIVE = createAgentRef<ChatState>('live');\nexport const REPLAY = createAgentRef<ChatState>('replay');\nproviders: [\n provideAgent(LIVE, { assistantId: 'chat' }),\n provideAgent(REPLAY, { assistantId: 'chat', transport: replayTransport }),\n];\n// component: injectAgent(LIVE) !== injectAgent(REPLAY)\n```",
25232524
"```ts\nproviders: [\n provideAgent(() => {\n const route = inject(ActivatedRoute);\n return { assistantId: 'chat', threadId: toSignal(route.paramMap) };\n }),\n];\n```",
25242525
"```ts\nexport const TRIP = createAgentRef<TripState>('trip');\n// app.config.ts:\nproviders: [provideAgent(TRIP, { assistantId: 'trip-graph' })]\n// component:\nconst agent = injectAgent(TRIP); // LangGraphAgent<TripState>\n```"
25252526
]

libs/ag-ui/src/lib/provide-agent.spec.ts

Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,9 @@ import { describe, it, expect, vi } from 'vitest';
22
import { Observable } from 'rxjs';
33
import type { BaseEvent } from '@ag-ui/client';
44
import type { RunAgentInput } from '@ag-ui/core';
5+
import { InjectionToken, inject } from '@angular/core';
56
import { TestBed } from '@angular/core/testing';
7+
import { createAgentRef } from '@threadplane/chat';
68
import { provideAgent, injectAgent, AGENT } from './provide-agent';
79
import {
810
createRuntimeProtectedFetch,
@@ -263,4 +265,83 @@ describe('provideAgent', () => {
263265
expect(agentProvider.provide).toBe(AGENT);
264266
expect(typeof agentProvider.useFactory).toBe('function');
265267
});
268+
269+
describe('AgentRef isolation', () => {
270+
it('gives each ref its own agent and its own config in ONE providers array', async () => {
271+
const REF_A = createAgentRef<Record<string, unknown>>('ref-a');
272+
const REF_B = createAgentRef<Record<string, unknown>>('ref-b');
273+
const fetchMock = vi
274+
.fn()
275+
.mockResolvedValue(new Response('', { status: 500 }));
276+
vi.stubGlobal('fetch', fetchMock);
277+
const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => undefined);
278+
TestBed.configureTestingModule({
279+
providers: [
280+
provideAgent(REF_A, { url: 'http://a.example/agent' }),
281+
provideAgent(REF_B, { url: 'http://b.example/agent' }),
282+
],
283+
});
284+
285+
const agentA = TestBed.runInInjectionContext(() => injectAgent(REF_A));
286+
const agentB = TestBed.runInInjectionContext(() => injectAgent(REF_B));
287+
288+
// Distinct instances...
289+
expect(agentA).not.toBe(agentB);
290+
// ...each wired to ITS OWN config, not to the last one registered.
291+
await agentA.submit({ message: 'to-a' });
292+
await agentB.submit({ message: 'to-b' });
293+
const urls = fetchMock.mock.calls.map(([input]) => String(input));
294+
expect(urls).toEqual(['http://a.example/agent', 'http://b.example/agent']);
295+
296+
errorSpy.mockRestore();
297+
TestBed.resetTestingModule();
298+
vi.unstubAllGlobals();
299+
});
300+
301+
it('keeps single-ref behaviour identical: injectAgent() resolves the same instance', () => {
302+
const REF = createAgentRef<Record<string, unknown>>('single');
303+
TestBed.configureTestingModule({
304+
providers: [provideAgent(REF, { url: 'http://single.example/agent' })],
305+
});
306+
307+
const byRef = TestBed.runInInjectionContext(() => injectAgent(REF));
308+
const bare = TestBed.runInInjectionContext(() => injectAgent());
309+
expect(bare).toBe(byRef);
310+
expect(TestBed.inject(AGENT)).toBe(byRef);
311+
TestBed.resetTestingModule();
312+
});
313+
314+
it('resolves a ref config factory lazily inside an injection context, exactly once', async () => {
315+
const REF = createAgentRef<Record<string, unknown>>('lazy');
316+
const AGENT_URL = new InjectionToken<string>('AGENT_URL');
317+
const fetchMock = vi.fn().mockResolvedValue(new Response('', { status: 500 }));
318+
vi.stubGlobal('fetch', fetchMock);
319+
const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => undefined);
320+
let calls = 0;
321+
TestBed.configureTestingModule({
322+
providers: [
323+
{ provide: AGENT_URL, useValue: 'http://from-di.example/agent' },
324+
provideAgent(REF, () => {
325+
calls += 1;
326+
// Only legal if the factory runs in an injection context.
327+
return { url: inject(AGENT_URL) };
328+
}),
329+
],
330+
});
331+
// Not run at decoration time.
332+
expect(calls).toBe(0);
333+
334+
const agent = TestBed.runInInjectionContext(() => injectAgent(REF));
335+
expect(calls).toBe(1);
336+
// The DI-read url reached the underlying HttpAgent.
337+
await agent.submit({ message: 'hello' });
338+
expect(String(fetchMock.mock.calls[0][0])).toBe('http://from-di.example/agent');
339+
// The bare token aliases the ref token, so no second evaluation.
340+
expect(TestBed.runInInjectionContext(() => injectAgent())).toBe(agent);
341+
expect(calls).toBe(1);
342+
errorSpy.mockRestore();
343+
TestBed.resetTestingModule();
344+
vi.unstubAllGlobals();
345+
});
346+
});
266347
});

libs/ag-ui/src/lib/provide-agent.ts

Lines changed: 19 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -73,6 +73,14 @@ function isAgentRef<T>(x: unknown): x is AgentRef<T> {
7373
* the state shape from `provideAgent` to `injectAgent` without repeating the
7474
* generic at every call site.
7575
*
76+
* **Several agents at one injector level.** Each `provideAgent(ref, …)` call
77+
* builds its own agent, so two (or more) refs may be provided side by side in a
78+
* single `providers` array and `injectAgent(refA)` / `injectAgent(refB)` return
79+
* distinct agents. The ref-less `injectAgent()` resolves a single shared token,
80+
* which can only point at one of them: when more than one ref is provided at
81+
* the same level the **last** call wins. Always inject by ref when an injector
82+
* provides more than one agent.
83+
*
7684
* @example Typed state via AgentRef
7785
* ```ts
7886
* interface TripState { day: number; places: string[]; }
@@ -96,11 +104,18 @@ export function provideAgent<T = Record<string, unknown>>(
96104
): Provider[] {
97105
const ref = isAgentRef<T>(refOrConfig) ? refOrConfig : undefined;
98106
const configOrFactory = (ref ? maybeConfig : refOrConfig) as AgentConfig | (() => AgentConfig);
99-
const providers: Provider[] = [
100-
{ provide: AGENT, useFactory: () => buildAgUiAgent(configOrFactory) },
107+
if (!ref) {
108+
return [{ provide: AGENT, useFactory: () => buildAgUiAgent(configOrFactory) }];
109+
}
110+
// Ref form: the agent is built under this call's own ref token, so N refs can
111+
// coexist in one `providers` array. The shared AGENT token aliases it — with a
112+
// single ref that reproduces the old `useExisting` identity exactly (one
113+
// instance, one config evaluation); with several refs AGENT can only mean one
114+
// thing, so the last call wins.
115+
return [
116+
{ provide: ref.token, useFactory: () => buildAgUiAgent(configOrFactory) },
117+
{ provide: AGENT, useExisting: ref.token },
101118
];
102-
if (ref) providers.push({ provide: ref.token, useExisting: AGENT });
103-
return providers;
104119
}
105120

106121
/**

libs/langgraph/src/lib/agent.provider.spec.ts

Lines changed: 111 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,9 @@
11
import { describe, it, expect } from 'vitest';
2+
import { InjectionToken, inject } from '@angular/core';
23
import { TestBed } from '@angular/core/testing';
4+
import { createAgentRef } from '@threadplane/chat';
35
import { provideAgent, AGENT_CONFIG, AGENT } from './agent.provider';
6+
import { injectAgent } from './inject-agent';
47
import { MockAgentTransport } from './transport/mock-stream.transport';
58

69
describe('provideAgent', () => {
@@ -85,4 +88,112 @@ describe('provideAgent', () => {
8588
// AGENT reads the already-resolved AGENT_CONFIG, so the factory ran once.
8689
expect(calls).toBe(1);
8790
});
91+
92+
describe('AgentRef isolation', () => {
93+
interface StateA { which: string }
94+
interface StateB { which: string }
95+
96+
it('gives each ref its own agent and its own config in ONE providers array', async () => {
97+
const REF_A = createAgentRef<StateA>('ref-a');
98+
const REF_B = createAgentRef<StateB>('ref-b');
99+
const transportA = new MockAgentTransport();
100+
const transportB = new MockAgentTransport();
101+
TestBed.configureTestingModule({
102+
providers: [
103+
provideAgent(REF_A, {
104+
apiUrl: '',
105+
assistantId: 'graph-a',
106+
transport: transportA,
107+
initialValues: { which: 'a' },
108+
}),
109+
provideAgent(REF_B, {
110+
apiUrl: '',
111+
assistantId: 'graph-b',
112+
transport: transportB,
113+
initialValues: { which: 'b' },
114+
}),
115+
],
116+
});
117+
118+
const agentA = TestBed.runInInjectionContext(() => injectAgent(REF_A));
119+
const agentB = TestBed.runInInjectionContext(() => injectAgent(REF_B));
120+
121+
// Distinct instances...
122+
expect(agentA).not.toBe(agentB);
123+
// ...each built from ITS OWN config, not the last one registered.
124+
expect(agentA.value()).toEqual({ which: 'a' });
125+
expect(agentB.value()).toEqual({ which: 'b' });
126+
127+
// And each is wired to its own transport: a submit on A must not reach B.
128+
void agentA.submit({ message: 'to-a' });
129+
await Promise.resolve();
130+
expect(transportA.streams.length).toBe(1);
131+
expect(transportB.streams.length).toBe(0);
132+
agentA.stop();
133+
});
134+
135+
it('keeps single-ref behaviour identical: injectAgent() resolves the same instance', () => {
136+
const REF = createAgentRef<StateA>('single');
137+
const transport = new MockAgentTransport();
138+
TestBed.configureTestingModule({
139+
providers: [
140+
provideAgent(REF, {
141+
apiUrl: '',
142+
assistantId: 'single-graph',
143+
transport,
144+
initialValues: { which: 'single' },
145+
}),
146+
],
147+
});
148+
149+
const byRef = TestBed.runInInjectionContext(() => injectAgent(REF));
150+
const bare = TestBed.runInInjectionContext(() => injectAgent());
151+
expect(bare).toBe(byRef);
152+
expect(TestBed.inject(AGENT)).toBe(byRef);
153+
// The internal AGENT_CONFIG token still resolves for a ref-provided agent.
154+
expect(TestBed.inject(AGENT_CONFIG).assistantId).toBe('single-graph');
155+
expect(TestBed.inject(AGENT_CONFIG).transport).toBe(transport);
156+
});
157+
158+
it('resolves a ref config factory lazily inside an injection context, exactly once', () => {
159+
const REF = createAgentRef<StateA>('lazy');
160+
const ASSISTANT_ID = new InjectionToken<string>('ASSISTANT_ID');
161+
let calls = 0;
162+
TestBed.configureTestingModule({
163+
providers: [
164+
{ provide: ASSISTANT_ID, useValue: 'from-di' },
165+
provideAgent(REF, () => {
166+
calls += 1;
167+
// Only legal if the factory runs in an injection context.
168+
return {
169+
apiUrl: '',
170+
assistantId: inject(ASSISTANT_ID),
171+
transport: new MockAgentTransport(),
172+
initialValues: { which: 'lazy' },
173+
};
174+
}),
175+
],
176+
});
177+
// Not run at decoration time.
178+
expect(calls).toBe(0);
179+
180+
const agent = TestBed.runInInjectionContext(() => injectAgent(REF));
181+
expect(agent.value()).toEqual({ which: 'lazy' });
182+
expect(TestBed.inject(AGENT_CONFIG).assistantId).toBe('from-di');
183+
// Ref agent and AGENT_CONFIG share one resolution of the factory.
184+
expect(calls).toBe(1);
185+
expect(TestBed.runInInjectionContext(() => injectAgent())).toBe(agent);
186+
expect(calls).toBe(1);
187+
});
188+
189+
it('throws the same assistantId error through a ref token', () => {
190+
const REF = createAgentRef<StateA>('no-assistant');
191+
TestBed.configureTestingModule({
192+
providers: [provideAgent(REF, { apiUrl: 'http://localhost' })],
193+
});
194+
expect(() =>
195+
TestBed.runInInjectionContext(() => injectAgent(REF)),
196+
).toThrow(/`assistantId` is required to construct the AGENT singleton/);
197+
});
198+
});
88199
});

0 commit comments

Comments
 (0)