Skip to content
Open
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
9 changes: 9 additions & 0 deletions packages/core/src/debug/ai/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
/**
* @license
* Copyright Google LLC All Rights Reserved.
*
* Use of this source code is governed by an MIT-style license that can be
* found in the LICENSE file at https://angular.dev/license
*/

export {registerAiTools} from './registration';
36 changes: 36 additions & 0 deletions packages/core/src/debug/ai/registration.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
/**
* @license
* Copyright Google LLC All Rights Reserved.
*
* Use of this source code is governed by an MIT-style license that can be
* found in the LICENSE file at https://angular.dev/license
*/

import {signalGraphTool} from './signal_graph';
import {DevtoolsToolDiscoveryEvent} from './tool_definitions';

/**
* Registers Angular AI tools with Chrome DevTools.
*
* This function listens for the `devtoolstooldiscovery` event and responds with
* the available Angular-specific tools.
*
* @returns A callback function to unregister the tools.
*/
export function registerAiTools(): () => void {
// No-op in non-browser environments.
if (typeof window === 'undefined') return () => {};

function listener(inputEvent: Event): void {
const event = inputEvent as DevtoolsToolDiscoveryEvent;
event.respondWith({
name: 'Angular',
tools: [signalGraphTool],
});
}

window.addEventListener('devtoolstooldiscovery', listener);
return () => {
window.removeEventListener('devtoolstooldiscovery', listener);
};
}
81 changes: 81 additions & 0 deletions packages/core/src/debug/ai/signal_graph.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
/**
* @license
* Copyright Google LLC All Rights Reserved.
*
* Use of this source code is governed by an MIT-style license that can be
* found in the LICENSE file at https://angular.dev/license
*/

import {NullInjector} from '../../di/null_injector';
import {getInjector} from '../../render3/util/discovery_utils';
import {DebugSignalGraph, getSignalGraph} from '../../render3/util/signal_debug';
import {ToolDefinition} from './tool_definitions';

// Omit `debuggableFn` and `id` from returned signal graph to AI agent.
type AiSignalGraph = Omit<DebugSignalGraph, 'nodes'> & {
nodes: Array<Omit<DebugSignalGraph['nodes'][number], 'debuggableFn' | 'id'>>;
};

/**
* Tool that exposes Angular's signal dependency graph to AI agents.
*/
export const signalGraphTool: ToolDefinition<{target: HTMLElement}, AiSignalGraph> = {
name: 'angular:signal_graph',
// tslint:disable-next-line:no-toplevel-property-access
description: `
Exposes the Angular signal dependency graph for a given DOM element.

This tool extracts the reactive dependency graph (signals, computeds, and effects) that
are transitive dependencies of the effects of that element. It will include signals
authored in other components/services and depended upon by the target component, but
will *not* include signals only used in descendant components effects.

Params:
- \`target\`: The element to get the signal graph for. Must be the host element of an
Angular component.

Returns:
- \`nodes\`: An array of reactive nodes discovered in the context. Each node contains:
- \`kind\`: The type of reactive node ('signal', 'computed', 'effect', or 'template'
for component template effects).
- \`value\`: The current evaluated value of the node (if applicable).
- \`label\`: The symbol name of the associated signal if available (ex.
\`const foo = signal(0);\` has \`label: 'foo'\`).
- \`epoch\`: The internal version number of the node's value.
- \`edges\`: An array of dependency links representing which nodes read from which other
nodes.
- \`consumer\`: The index in the \`nodes\` array of the node that depends on the value.
- \`producer\`: The index in the \`nodes\` array of the node that provides the value.

Example: An edge with \`{consumer: 2, producer: 0}\` means that \`nodes[2]\` (e.g. an
\`effect\`) reads the value of \`nodes[0]\` (e.g. a \`signal\`).
`.trim(),
inputSchema: {
type: 'object',
properties: {
target: {
type: 'object',
description: 'The element to get the signal graph for.',
'x-mcp-type': 'HTMLElement',
},
},
required: ['target'],
},
execute: async ({target}: {target: HTMLElement}) => {
if (!(target instanceof HTMLElement)) {
throw new Error('Invalid input: "target" must be an HTMLElement.');
}

const injector = getInjector(target);
if (injector instanceof NullInjector) {
throw new Error('Invalid input: "target" is not the host element of an Angular component.');
}

const graph = getSignalGraph(injector);
return {
// Filter out unneeded data.
nodes: graph.nodes.map(({id, debuggableFn, ...node}) => node),
edges: graph.edges,
};
},
};
43 changes: 43 additions & 0 deletions packages/core/src/debug/ai/tool_definitions.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
/**
* @license
* Copyright Google LLC All Rights Reserved.
*
* Use of this source code is governed by an MIT-style license that can be
* found in the LICENSE file at https://angular.dev/license
*/

/**
* Definition of an AI tool that can be exposed to Chrome DevTools.
*/
export interface ToolDefinition<T = any, R = unknown> {
/** Name of the tool, should be namespaced (e.g., 'angular:di_graph'). */
name: string;

/** Human-readable description of what the tool does. */
description: string;

/** JSON schema for the tool's input arguments. */
inputSchema: Record<string, unknown>;

/** Function that executes the tool. */
execute: (args: T) => R | Promise<R>;
}

/**
* A group of related AI tools.
*/
export interface ToolGroup {
/** Name of the tool group. */
name: string;

/** List of tools in this group. */
tools: ToolDefinition[];
}

/**
* Event dispatched by Chrome DevTools to discover tools in the page.
*/
export interface DevtoolsToolDiscoveryEvent extends CustomEvent {
/** Callback to register tools with DevTools. */
respondWith(toolGroup: ToolGroup): void;
}
13 changes: 13 additions & 0 deletions packages/core/src/platform/platform.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,8 +24,10 @@ import {RuntimeError, RuntimeErrorCode} from '../errors';

import {PlatformRef} from './platform_ref';
import {PLATFORM_DESTROY_LISTENERS} from './platform_destroy_listeners';
import {registerAiTools} from '../debug/ai';

let _platformInjector: Injector | null = null;
let _unregisterAiTools: (() => void) | null = null;

/**
* Creates a platform.
Expand All @@ -43,6 +45,9 @@ export function createPlatform(injector: Injector): PlatformRef {

publishDefaultGlobalUtils();
publishSignalConfiguration();
if (typeof ngDevMode !== 'undefined' && ngDevMode) {
_unregisterAiTools = registerAiTools();
}

// During SSR, using this setting and using an injector from the global can cause the
// injector to be used for a different requjest due to concurrency.
Expand Down Expand Up @@ -151,6 +156,11 @@ export function getPlatform(): PlatformRef | null {
* @publicApi
*/
export function destroyPlatform(): void {
if (typeof ngDevMode !== 'undefined' && ngDevMode) {
_unregisterAiTools?.();
_unregisterAiTools = null;
}

getPlatform()?.destroy();
}

Expand All @@ -165,6 +175,9 @@ export function createOrReusePlatformInjector(providers: StaticProvider[] = []):
if (_platformInjector) return _platformInjector;

publishDefaultGlobalUtils();
if (typeof ngDevMode !== 'undefined' && ngDevMode) {
_unregisterAiTools = registerAiTools();
}

// Otherwise, setup a new platform injector and run platform initializers.
const injector = createPlatformInjector(providers);
Expand Down
39 changes: 39 additions & 0 deletions packages/core/test/debug/ai/registration_spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
/**
* @license
* Copyright Google LLC All Rights Reserved.
*
* Use of this source code is governed by an MIT-style license that can be
* found in the LICENSE file at https://angular.dev/license
*/

import {signalGraphTool} from '../../../src/debug/ai/signal_graph';
import {registerAiTools} from '../../../src/debug/ai';
import {DevtoolsToolDiscoveryEvent} from '../../../src/debug/ai/tool_definitions';

describe('registration', () => {
describe('registerAiTools', () => {
it('should register the tools', () => {
const unregister = registerAiTools();
try {
// Verify Angular responds to the event.
const event = new CustomEvent('devtoolstooldiscovery') as DevtoolsToolDiscoveryEvent;
event.respondWith = jasmine.createSpy('respondWith');
window.dispatchEvent(event);
expect(event.respondWith).toHaveBeenCalledOnceWith({
name: 'Angular',

// Just check one tool.
tools: jasmine.arrayContaining([signalGraphTool]),
});
} finally {
unregister();
}

// After unregistering, Angular should not react to the event.
const event = new CustomEvent('devtoolstooldiscovery') as DevtoolsToolDiscoveryEvent;
event.respondWith = jasmine.createSpy('respondWith');
window.dispatchEvent(event);
expect(event.respondWith).not.toHaveBeenCalled();
});
});
});
80 changes: 80 additions & 0 deletions packages/core/test/debug/ai/signal_graph_spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
/**
* @license
* Copyright Google LLC All Rights Reserved.
*
* Use of this source code is governed by an MIT-style license that can be
* found in the LICENSE file at https://angular.dev/license
*/

import {Component, computed, effect, signal} from '@angular/core';
import {TestBed} from '@angular/core/testing';
import {signalGraphTool} from '../../../src/debug/ai/signal_graph';
import {setupFrameworkInjectorProfiler} from '../../../src/render3/debug/framework_injector_profiler';

describe('signalGraphTool', () => {
beforeEach(() => {
setupFrameworkInjectorProfiler();
});

it('should discover signal graph from targeted element', async () => {
@Component({
selector: 'signal-graph-test-root',
template: '<div>Signals test: {{ double() }}</div>',
standalone: true,
})
class SignalGraphTestComponent {
private readonly count = signal(1, {debugName: 'count'});
protected readonly double = computed(() => this.count() * 2, {debugName: 'double'});

constructor() {
effect(
() => {
this.double();
},
{debugName: 'consumer'},
);
}
}

const fixture = TestBed.createComponent(SignalGraphTestComponent);
await fixture.whenStable();

const rootElement = fixture.nativeElement;

const result = await signalGraphTool.execute({target: rootElement});

expect(result.nodes).toEqual(
jasmine.arrayWithExactContents([
jasmine.objectContaining({kind: 'signal', label: 'count', value: 1}),
jasmine.objectContaining({kind: 'computed', label: 'double', value: 2}),
jasmine.objectContaining({kind: 'effect', label: 'consumer'}),
jasmine.objectContaining({kind: 'template'}),
]),
);

const countIndex = result.nodes.findIndex((n) => n.label === 'count');
const doubleIndex = result.nodes.findIndex((n) => n.label === 'double');
const consumerIndex = result.nodes.findIndex((n) => n.label === 'consumer');
const templateIndex = result.nodes.findIndex((n) => n.kind === 'template');

expect(result.edges).toEqual(
jasmine.arrayWithExactContents([
{consumer: consumerIndex, producer: doubleIndex},
{consumer: doubleIndex, producer: countIndex},
{consumer: templateIndex, producer: doubleIndex},
]),
);
});

it('should throw an error if target is not an HTMLElement', async () => {
await expectAsync(
signalGraphTool.execute({target: {} as unknown as HTMLElement}),
).toBeRejectedWithError(/must be an HTMLElement/);
});

it('should throw an error if target is not an Angular component', async () => {
await expectAsync(
signalGraphTool.execute({target: document.createElement('div')}),
).toBeRejectedWithError(/not the host element of an Angular component/);
});
});
14 changes: 14 additions & 0 deletions packages/platform-browser/test/browser/bootstrap_spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -817,6 +817,20 @@ describe('bootstrap factory method', () => {
);
});

it('should register AI tools', async () => {
const evt = new Event('devtoolstooldiscovery') as any;
evt.respondWith = jasmine.createSpy<() => void>('respondWith');
window.dispatchEvent(evt);
expect(evt.respondWith).not.toHaveBeenCalled();

await bootstrap(HelloRootCmp);
window.dispatchEvent(evt);
expect(evt.respondWith).toHaveBeenCalledOnceWith({
name: 'Angular',
tools: jasmine.any(Array),
});
});

describe('change detection', () => {
const log: string[] = [];

Expand Down
Loading