Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion apps/website/content/docs/ag-ui/api/api-docs.json
Original file line number Diff line number Diff line change
Expand Up @@ -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<T>, configOrFactory: AgentConfig | () => AgentConfig): Provider[]",
"params": [
{
Expand Down
3 changes: 2 additions & 1 deletion apps/website/content/docs/langgraph/api/api-docs.json
Original file line number Diff line number Diff line change
Expand Up @@ -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<T>, configOrFactory: AgentConfig<T, BagTemplate> | () => AgentConfig<T>): Provider[]",
"params": [
{
Expand All @@ -2520,6 +2520,7 @@
"description": ""
},
"examples": [
"```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```",
"```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<TripState>('trip');\n// app.config.ts:\nproviders: [provideAgent(TRIP, { assistantId: 'trip-graph' })]\n// component:\nconst agent = injectAgent(TRIP); // LangGraphAgent<TripState>\n```"
]
Expand Down
81 changes: 81 additions & 0 deletions libs/ag-ui/src/lib/provide-agent.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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<Record<string, unknown>>('ref-a');
const REF_B = createAgentRef<Record<string, unknown>>('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<Record<string, unknown>>('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<Record<string, unknown>>('lazy');
const AGENT_URL = new InjectionToken<string>('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();
});
});
});
23 changes: 19 additions & 4 deletions libs/ag-ui/src/lib/provide-agent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,14 @@ function isAgentRef<T>(x: unknown): x is AgentRef<T> {
* 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[]; }
Expand All @@ -96,11 +104,18 @@ export function provideAgent<T = Record<string, unknown>>(
): Provider[] {
const ref = isAgentRef<T>(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;
}

/**
Expand Down
111 changes: 111 additions & 0 deletions libs/langgraph/src/lib/agent.provider.spec.ts
Original file line number Diff line number Diff line change
@@ -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', () => {
Expand Down Expand Up @@ -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<StateA>('ref-a');
const REF_B = createAgentRef<StateB>('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<StateA>('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<StateA>('lazy');
const ASSISTANT_ID = new InjectionToken<string>('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<StateA>('no-assistant');
TestBed.configureTestingModule({
providers: [provideAgent(REF, { apiUrl: 'http://localhost' })],
});
expect(() =>
TestBed.runInInjectionContext(() => injectAgent(REF)),
).toThrow(/`assistantId` is required to construct the AGENT singleton/);
});
});
});
Loading
Loading