diff --git a/apps/website/content/docs/ag-ui/api/api-docs.json b/apps/website/content/docs/ag-ui/api/api-docs.json index 4d00cc02b..e34c99ddf 100644 --- a/apps/website/content/docs/ag-ui/api/api-docs.json +++ b/apps/website/content/docs/ag-ui/api/api-docs.json @@ -590,7 +590,7 @@ { "name": "provideAgent", "kind": "function", - "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.", + "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.", "signature": "provideAgent(ref: AgentRef, configOrFactory: AgentConfig | () => AgentConfig): Provider[]", "params": [ { diff --git a/apps/website/content/docs/langgraph/api/api-docs.json b/apps/website/content/docs/langgraph/api/api-docs.json index cabe07265..26947be55 100644 --- a/apps/website/content/docs/langgraph/api/api-docs.json +++ b/apps/website/content/docs/langgraph/api/api-docs.json @@ -2499,7 +2499,7 @@ { "name": "provideAgent", "kind": "function", - "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.", + "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.", "signature": "provideAgent(ref: AgentRef, configOrFactory: AgentConfig | () => AgentConfig): Provider[]", "params": [ { @@ -2520,6 +2520,7 @@ "description": "" }, "examples": [ + "```ts\nexport const LIVE = createAgentRef('live');\nexport const REPLAY = createAgentRef('replay');\nproviders: [\n provideAgent(LIVE, { assistantId: 'chat' }),\n provideAgent(REPLAY, { assistantId: 'chat', transport: replayTransport }),\n];\n// component: injectAgent(LIVE) !== injectAgent(REPLAY)\n```", "```ts\nproviders: [\n provideAgent(() => {\n const route = inject(ActivatedRoute);\n return { assistantId: 'chat', threadId: toSignal(route.paramMap) };\n }),\n];\n```", "```ts\nexport const TRIP = createAgentRef('trip');\n// app.config.ts:\nproviders: [provideAgent(TRIP, { assistantId: 'trip-graph' })]\n// component:\nconst agent = injectAgent(TRIP); // LangGraphAgent\n```" ] diff --git a/libs/ag-ui/src/lib/provide-agent.spec.ts b/libs/ag-ui/src/lib/provide-agent.spec.ts index be2215d87..99c657072 100644 --- a/libs/ag-ui/src/lib/provide-agent.spec.ts +++ b/libs/ag-ui/src/lib/provide-agent.spec.ts @@ -2,7 +2,9 @@ import { describe, it, expect, vi } from 'vitest'; import { Observable } from 'rxjs'; import type { BaseEvent } from '@ag-ui/client'; import type { RunAgentInput } from '@ag-ui/core'; +import { InjectionToken, inject } from '@angular/core'; import { TestBed } from '@angular/core/testing'; +import { createAgentRef } from '@threadplane/chat'; import { provideAgent, injectAgent, AGENT } from './provide-agent'; import { createRuntimeProtectedFetch, @@ -263,4 +265,83 @@ describe('provideAgent', () => { expect(agentProvider.provide).toBe(AGENT); expect(typeof agentProvider.useFactory).toBe('function'); }); + + describe('AgentRef isolation', () => { + it('gives each ref its own agent and its own config in ONE providers array', async () => { + const REF_A = createAgentRef>('ref-a'); + const REF_B = createAgentRef>('ref-b'); + const fetchMock = vi + .fn() + .mockResolvedValue(new Response('', { status: 500 })); + vi.stubGlobal('fetch', fetchMock); + const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => undefined); + TestBed.configureTestingModule({ + providers: [ + provideAgent(REF_A, { url: 'http://a.example/agent' }), + provideAgent(REF_B, { url: 'http://b.example/agent' }), + ], + }); + + const agentA = TestBed.runInInjectionContext(() => injectAgent(REF_A)); + const agentB = TestBed.runInInjectionContext(() => injectAgent(REF_B)); + + // Distinct instances... + expect(agentA).not.toBe(agentB); + // ...each wired to ITS OWN config, not to the last one registered. + await agentA.submit({ message: 'to-a' }); + await agentB.submit({ message: 'to-b' }); + const urls = fetchMock.mock.calls.map(([input]) => String(input)); + expect(urls).toEqual(['http://a.example/agent', 'http://b.example/agent']); + + errorSpy.mockRestore(); + TestBed.resetTestingModule(); + vi.unstubAllGlobals(); + }); + + it('keeps single-ref behaviour identical: injectAgent() resolves the same instance', () => { + const REF = createAgentRef>('single'); + TestBed.configureTestingModule({ + providers: [provideAgent(REF, { url: 'http://single.example/agent' })], + }); + + const byRef = TestBed.runInInjectionContext(() => injectAgent(REF)); + const bare = TestBed.runInInjectionContext(() => injectAgent()); + expect(bare).toBe(byRef); + expect(TestBed.inject(AGENT)).toBe(byRef); + TestBed.resetTestingModule(); + }); + + it('resolves a ref config factory lazily inside an injection context, exactly once', async () => { + const REF = createAgentRef>('lazy'); + const AGENT_URL = new InjectionToken('AGENT_URL'); + const fetchMock = vi.fn().mockResolvedValue(new Response('', { status: 500 })); + vi.stubGlobal('fetch', fetchMock); + const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => undefined); + let calls = 0; + TestBed.configureTestingModule({ + providers: [ + { provide: AGENT_URL, useValue: 'http://from-di.example/agent' }, + provideAgent(REF, () => { + calls += 1; + // Only legal if the factory runs in an injection context. + return { url: inject(AGENT_URL) }; + }), + ], + }); + // Not run at decoration time. + expect(calls).toBe(0); + + const agent = TestBed.runInInjectionContext(() => injectAgent(REF)); + expect(calls).toBe(1); + // The DI-read url reached the underlying HttpAgent. + await agent.submit({ message: 'hello' }); + expect(String(fetchMock.mock.calls[0][0])).toBe('http://from-di.example/agent'); + // The bare token aliases the ref token, so no second evaluation. + expect(TestBed.runInInjectionContext(() => injectAgent())).toBe(agent); + expect(calls).toBe(1); + errorSpy.mockRestore(); + TestBed.resetTestingModule(); + vi.unstubAllGlobals(); + }); + }); }); diff --git a/libs/ag-ui/src/lib/provide-agent.ts b/libs/ag-ui/src/lib/provide-agent.ts index 15a2d4a5f..ae6a659a2 100644 --- a/libs/ag-ui/src/lib/provide-agent.ts +++ b/libs/ag-ui/src/lib/provide-agent.ts @@ -73,6 +73,14 @@ function isAgentRef(x: unknown): x is AgentRef { * the state shape from `provideAgent` to `injectAgent` without repeating the * generic at every call site. * + * **Several agents at one injector level.** Each `provideAgent(ref, …)` call + * builds its own agent, so two (or more) refs may be provided side by side in a + * single `providers` array and `injectAgent(refA)` / `injectAgent(refB)` return + * distinct agents. The ref-less `injectAgent()` resolves a single shared token, + * which can only point at one of them: when more than one ref is provided at + * the same level the **last** call wins. Always inject by ref when an injector + * provides more than one agent. + * * @example Typed state via AgentRef * ```ts * interface TripState { day: number; places: string[]; } @@ -96,11 +104,18 @@ export function provideAgent>( ): Provider[] { const ref = isAgentRef(refOrConfig) ? refOrConfig : undefined; const configOrFactory = (ref ? maybeConfig : refOrConfig) as AgentConfig | (() => AgentConfig); - const providers: Provider[] = [ - { provide: AGENT, useFactory: () => buildAgUiAgent(configOrFactory) }, + if (!ref) { + return [{ provide: AGENT, useFactory: () => buildAgUiAgent(configOrFactory) }]; + } + // Ref form: the agent is built under this call's own ref token, so N refs can + // coexist in one `providers` array. The shared AGENT token aliases it — with a + // single ref that reproduces the old `useExisting` identity exactly (one + // instance, one config evaluation); with several refs AGENT can only mean one + // thing, so the last call wins. + return [ + { provide: ref.token, useFactory: () => buildAgUiAgent(configOrFactory) }, + { provide: AGENT, useExisting: ref.token }, ]; - if (ref) providers.push({ provide: ref.token, useExisting: AGENT }); - return providers; } /** diff --git a/libs/langgraph/src/lib/agent.provider.spec.ts b/libs/langgraph/src/lib/agent.provider.spec.ts index fed38d714..cac84b70e 100644 --- a/libs/langgraph/src/lib/agent.provider.spec.ts +++ b/libs/langgraph/src/lib/agent.provider.spec.ts @@ -1,6 +1,9 @@ import { describe, it, expect } from 'vitest'; +import { InjectionToken, inject } from '@angular/core'; import { TestBed } from '@angular/core/testing'; +import { createAgentRef } from '@threadplane/chat'; import { provideAgent, AGENT_CONFIG, AGENT } from './agent.provider'; +import { injectAgent } from './inject-agent'; import { MockAgentTransport } from './transport/mock-stream.transport'; describe('provideAgent', () => { @@ -85,4 +88,112 @@ describe('provideAgent', () => { // AGENT reads the already-resolved AGENT_CONFIG, so the factory ran once. expect(calls).toBe(1); }); + + describe('AgentRef isolation', () => { + interface StateA { which: string } + interface StateB { which: string } + + it('gives each ref its own agent and its own config in ONE providers array', async () => { + const REF_A = createAgentRef('ref-a'); + const REF_B = createAgentRef('ref-b'); + const transportA = new MockAgentTransport(); + const transportB = new MockAgentTransport(); + TestBed.configureTestingModule({ + providers: [ + provideAgent(REF_A, { + apiUrl: '', + assistantId: 'graph-a', + transport: transportA, + initialValues: { which: 'a' }, + }), + provideAgent(REF_B, { + apiUrl: '', + assistantId: 'graph-b', + transport: transportB, + initialValues: { which: 'b' }, + }), + ], + }); + + const agentA = TestBed.runInInjectionContext(() => injectAgent(REF_A)); + const agentB = TestBed.runInInjectionContext(() => injectAgent(REF_B)); + + // Distinct instances... + expect(agentA).not.toBe(agentB); + // ...each built from ITS OWN config, not the last one registered. + expect(agentA.value()).toEqual({ which: 'a' }); + expect(agentB.value()).toEqual({ which: 'b' }); + + // And each is wired to its own transport: a submit on A must not reach B. + void agentA.submit({ message: 'to-a' }); + await Promise.resolve(); + expect(transportA.streams.length).toBe(1); + expect(transportB.streams.length).toBe(0); + agentA.stop(); + }); + + it('keeps single-ref behaviour identical: injectAgent() resolves the same instance', () => { + const REF = createAgentRef('single'); + const transport = new MockAgentTransport(); + TestBed.configureTestingModule({ + providers: [ + provideAgent(REF, { + apiUrl: '', + assistantId: 'single-graph', + transport, + initialValues: { which: 'single' }, + }), + ], + }); + + const byRef = TestBed.runInInjectionContext(() => injectAgent(REF)); + const bare = TestBed.runInInjectionContext(() => injectAgent()); + expect(bare).toBe(byRef); + expect(TestBed.inject(AGENT)).toBe(byRef); + // The internal AGENT_CONFIG token still resolves for a ref-provided agent. + expect(TestBed.inject(AGENT_CONFIG).assistantId).toBe('single-graph'); + expect(TestBed.inject(AGENT_CONFIG).transport).toBe(transport); + }); + + it('resolves a ref config factory lazily inside an injection context, exactly once', () => { + const REF = createAgentRef('lazy'); + const ASSISTANT_ID = new InjectionToken('ASSISTANT_ID'); + let calls = 0; + TestBed.configureTestingModule({ + providers: [ + { provide: ASSISTANT_ID, useValue: 'from-di' }, + provideAgent(REF, () => { + calls += 1; + // Only legal if the factory runs in an injection context. + return { + apiUrl: '', + assistantId: inject(ASSISTANT_ID), + transport: new MockAgentTransport(), + initialValues: { which: 'lazy' }, + }; + }), + ], + }); + // Not run at decoration time. + expect(calls).toBe(0); + + const agent = TestBed.runInInjectionContext(() => injectAgent(REF)); + expect(agent.value()).toEqual({ which: 'lazy' }); + expect(TestBed.inject(AGENT_CONFIG).assistantId).toBe('from-di'); + // Ref agent and AGENT_CONFIG share one resolution of the factory. + expect(calls).toBe(1); + expect(TestBed.runInInjectionContext(() => injectAgent())).toBe(agent); + expect(calls).toBe(1); + }); + + it('throws the same assistantId error through a ref token', () => { + const REF = createAgentRef('no-assistant'); + TestBed.configureTestingModule({ + providers: [provideAgent(REF, { apiUrl: 'http://localhost' })], + }); + expect(() => + TestBed.runInInjectionContext(() => injectAgent(REF)), + ).toThrow(/`assistantId` is required to construct the AGENT singleton/); + }); + }); }); diff --git a/libs/langgraph/src/lib/agent.provider.ts b/libs/langgraph/src/lib/agent.provider.ts index f976cbb06..e68bcb47a 100644 --- a/libs/langgraph/src/lib/agent.provider.ts +++ b/libs/langgraph/src/lib/agent.provider.ts @@ -65,11 +65,12 @@ export const AGENT_CONFIG = new InjectionToken('AGENT_CONFIG'); */ export const AGENT = new InjectionToken('AGENT'); -/** @internal — shared factory that reads AGENT_CONFIG and constructs the singleton. */ -function agentFactory(): LangGraphAgent { - // useFactory runs in an injection context, so the legacy `agent()` - // factory's `inject(DestroyRef)` calls work. - const config = inject(AGENT_CONFIG) as AgentConfig; +/** + * @internal — builds a LangGraphAgent from an already-resolved config. + * Must be called from an injection context (the legacy `agent()` factory calls + * `inject(DestroyRef)`). + */ +function createAgentFromConfig(config: AgentConfig): LangGraphAgent { if (config.assistantId === undefined) { throw new Error( 'provideAgent: `assistantId` is required to construct the AGENT singleton.', @@ -91,6 +92,13 @@ function agentFactory(): LangGraphAgent { }); } +/** @internal — shared factory that reads AGENT_CONFIG and constructs the singleton. */ +function agentFactory(): LangGraphAgent { + // useFactory runs in an injection context, so the legacy `agent()` + // factory's `inject(DestroyRef)` calls work. + return createAgentFromConfig(inject(AGENT_CONFIG) as AgentConfig); +} + function isAgentRef(x: unknown): x is AgentRef { return typeof x === 'object' && x !== null && 'token' in x; } @@ -116,6 +124,24 @@ function isAgentRef(x: unknown): x is AgentRef { * the state shape from `provideAgent` to `injectAgent` without repeating the * generic at every call site. * + * **Several agents at one injector level.** Each `provideAgent(ref, …)` call + * builds its own agent from its own config, so two (or more) refs may be + * provided side by side in a single `providers` array and `injectAgent(refA)` + * / `injectAgent(refB)` return distinct agents. The ref-less `injectAgent()` + * resolves a single shared token, which can only point at one of them: when + * more than one ref is provided at the same level the **last** call wins. + * Always inject by ref when an injector provides more than one agent. + * + * @example Two agents in one providers array + * ```ts + * export const LIVE = createAgentRef('live'); + * export const REPLAY = createAgentRef('replay'); + * providers: [ + * provideAgent(LIVE, { assistantId: 'chat' }), + * provideAgent(REPLAY, { assistantId: 'chat', transport: replayTransport }), + * ]; + * // component: injectAgent(LIVE) !== injectAgent(REPLAY) + * ``` * @example Factory config reading route params * ```ts * providers: [ @@ -155,13 +181,30 @@ export function provideAgent>( const resolveConfig = (): AgentConfig => typeof configOrFactory === 'function' ? (configOrFactory as () => AgentConfig)() : configOrFactory; - const providers: Provider[] = [ - // AGENT_CONFIG resolves the config once (running the factory in an - // injection context if a factory was passed). AGENT reads the resolved - // config from here, so the factory is invoked exactly once. - { provide: AGENT_CONFIG, useFactory: resolveConfig }, - { provide: AGENT, useFactory: agentFactory }, + if (!ref) { + return [ + // AGENT_CONFIG resolves the config once (running the factory in an + // injection context if a factory was passed). AGENT reads the resolved + // config from here, so the factory is invoked exactly once. + { provide: AGENT_CONFIG, useFactory: resolveConfig }, + { provide: AGENT, useFactory: agentFactory }, + ]; + } + + // Ref form: this call gets its OWN config token and its OWN agent factory, so + // N refs can coexist in one `providers` array without colliding. The shared + // AGENT / AGENT_CONFIG tokens alias this call's pair — with a single ref that + // reproduces the old `useExisting` identity exactly (`injectAgent()` and + // `injectAgent(ref)` return the same instance, and the config factory still + // runs exactly once); with several refs the shared tokens can only mean one + // thing, so the last call wins. + const refConfig = new InjectionToken>( + `AGENT_CONFIG(${ref.token.toString()})`, + ); + return [ + { provide: refConfig, useFactory: resolveConfig }, + { provide: ref.token, useFactory: () => createAgentFromConfig(inject(refConfig)) }, + { provide: AGENT_CONFIG, useExisting: refConfig }, + { provide: AGENT, useExisting: ref.token }, ]; - if (ref) providers.push({ provide: ref.token, useExisting: AGENT }); - return providers; }