From 415854ef2f8c4618668058dd4b1a6a7cc6e746a1 Mon Sep 17 00:00:00 2001 From: Doug Parker Date: Thu, 19 Mar 2026 09:24:58 -0700 Subject: [PATCH 1/4] refactor(core): define tool interfaces for AI agents These serve as the type definitions for interacting with the `chrome-devtools-mcp` AI runtime debugging functionality. Eventually this will hopefully be upstreamed to some more authoritative location, but for now this will do. --- .../core/src/debug/ai/tool_definitions.ts | 43 +++++++++++++++++++ 1 file changed, 43 insertions(+) create mode 100644 packages/core/src/debug/ai/tool_definitions.ts diff --git a/packages/core/src/debug/ai/tool_definitions.ts b/packages/core/src/debug/ai/tool_definitions.ts new file mode 100644 index 00000000000..e29f316e10f --- /dev/null +++ b/packages/core/src/debug/ai/tool_definitions.ts @@ -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 { + /** 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; + + /** Function that executes the tool. */ + execute: (args: T) => R | Promise; +} + +/** + * 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; +} From 9bf09027d8ba1c7eac7ffcddcbd7145997ef9c56 Mon Sep 17 00:00:00 2001 From: Doug Parker Date: Thu, 19 Mar 2026 09:25:10 -0700 Subject: [PATCH 2/4] refactor(core): implement Angular signal graph tool This provides an `angular:signal_graph` in-page tool which exposes the signal graph from the component rendered for a particular DOM element. It leverages the algorithm defined for Angular DevTools, which essentially means it takes the effects registered on the components injector and walks transitive dependencies to find all signals referenced by the component in an effect or the template. --- packages/core/src/debug/ai/signal_graph.ts | 81 +++++++++++++++++++ .../core/test/debug/ai/signal_graph_spec.ts | 80 ++++++++++++++++++ 2 files changed, 161 insertions(+) create mode 100644 packages/core/src/debug/ai/signal_graph.ts create mode 100644 packages/core/test/debug/ai/signal_graph_spec.ts diff --git a/packages/core/src/debug/ai/signal_graph.ts b/packages/core/src/debug/ai/signal_graph.ts new file mode 100644 index 00000000000..a033549c328 --- /dev/null +++ b/packages/core/src/debug/ai/signal_graph.ts @@ -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 & { + nodes: Array>; +}; + +/** + * 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, + }; + }, +}; diff --git a/packages/core/test/debug/ai/signal_graph_spec.ts b/packages/core/test/debug/ai/signal_graph_spec.ts new file mode 100644 index 00000000000..4b01de3017a --- /dev/null +++ b/packages/core/test/debug/ai/signal_graph_spec.ts @@ -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: '
Signals test: {{ double() }}
', + 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/); + }); +}); From 059c706e7a9ff2cdb1b247b16f03f47616fde7c6 Mon Sep 17 00:00:00 2001 From: Doug Parker Date: Thu, 19 Mar 2026 09:25:16 -0700 Subject: [PATCH 3/4] refactor(core): add `registerAiTools` function This will centrally manage all the AI tools supported by Angular out of the box. --- packages/core/src/debug/ai/index.ts | 9 +++++ packages/core/src/debug/ai/registration.ts | 36 +++++++++++++++++ .../core/test/debug/ai/registration_spec.ts | 39 +++++++++++++++++++ 3 files changed, 84 insertions(+) create mode 100644 packages/core/src/debug/ai/index.ts create mode 100644 packages/core/src/debug/ai/registration.ts create mode 100644 packages/core/test/debug/ai/registration_spec.ts diff --git a/packages/core/src/debug/ai/index.ts b/packages/core/src/debug/ai/index.ts new file mode 100644 index 00000000000..50b6fa26df2 --- /dev/null +++ b/packages/core/src/debug/ai/index.ts @@ -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'; diff --git a/packages/core/src/debug/ai/registration.ts b/packages/core/src/debug/ai/registration.ts new file mode 100644 index 00000000000..2314c636d74 --- /dev/null +++ b/packages/core/src/debug/ai/registration.ts @@ -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); + }; +} diff --git a/packages/core/test/debug/ai/registration_spec.ts b/packages/core/test/debug/ai/registration_spec.ts new file mode 100644 index 00000000000..63432c34c7a --- /dev/null +++ b/packages/core/test/debug/ai/registration_spec.ts @@ -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(); + }); + }); +}); From 52e2f79852b4839ae5bce445f7ef08adb2f5829f Mon Sep 17 00:00:00 2001 From: Doug Parker Date: Wed, 1 Apr 2026 17:15:04 -0700 Subject: [PATCH 4/4] feat(core): register AI runtime debugging tools This registers AI runtime debugging tools during platform creation and unregisters them when the platform is destroyed. This roughly matches existing usage of global utils with respect to timing. It is limited to dev mode only because these tools are exclusively for debugging Angular's internals and not something production users would leverage. --- packages/core/src/platform/platform.ts | 13 +++++++++++++ .../test/browser/bootstrap_spec.ts | 14 ++++++++++++++ 2 files changed, 27 insertions(+) diff --git a/packages/core/src/platform/platform.ts b/packages/core/src/platform/platform.ts index e6dd381ddb4..a77fb7af347 100644 --- a/packages/core/src/platform/platform.ts +++ b/packages/core/src/platform/platform.ts @@ -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. @@ -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. @@ -151,6 +156,11 @@ export function getPlatform(): PlatformRef | null { * @publicApi */ export function destroyPlatform(): void { + if (typeof ngDevMode !== 'undefined' && ngDevMode) { + _unregisterAiTools?.(); + _unregisterAiTools = null; + } + getPlatform()?.destroy(); } @@ -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); diff --git a/packages/platform-browser/test/browser/bootstrap_spec.ts b/packages/platform-browser/test/browser/bootstrap_spec.ts index 3af1e1070c7..0f80f470536 100644 --- a/packages/platform-browser/test/browser/bootstrap_spec.ts +++ b/packages/platform-browser/test/browser/bootstrap_spec.ts @@ -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[] = [];