Skip to content

Commit e57ab2e

Browse files
authored
feat(agent-skills): install and update Deepnote agent skills on server start (#332)
* feat(agent-skills): install and update Deepnote agent skills on server start Ensures AI agents always have the latest Deepnote skill files by running deepnote-cli install-skills as a background task whenever a notebook server starts, once per environment per session. * chore: use Uri.joinPath() to build the deepnote binary path * chore: add test coverage * fix(deepnote): correct getAgentName return type and docstring to match behavior
1 parent 9105222 commit e57ab2e

6 files changed

Lines changed: 350 additions & 4 deletions
Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,99 @@
1+
import { inject, injectable } from 'inversify';
2+
import { env, Uri, workspace } from 'vscode';
3+
4+
import { IProcessServiceFactory } from '../../platform/common/process/types.node';
5+
import { logger } from '../../platform/logging';
6+
import { PythonEnvironment } from '../../platform/pythonEnvironments/info';
7+
8+
/**
9+
* Returns the Deepnote CLI `--agent` value for the current editor.
10+
* Defaults to 'github copilot' for unrecognized editors.
11+
*/
12+
function getAgentName(): string {
13+
const appName = env.appName.toLowerCase();
14+
15+
if (appName.includes('cursor')) {
16+
return 'cursor';
17+
}
18+
if (appName.includes('windsurf')) {
19+
return 'windsurf';
20+
}
21+
22+
// VS Code and unknown editors default to GitHub Copilot
23+
return 'github copilot';
24+
}
25+
26+
/**
27+
* Manages background installation of Deepnote agent skill files.
28+
*
29+
* After each environment's venv becomes ready (toolkit installed), this
30+
* service upgrades `deepnote-cli` and runs `deepnote install-skills`
31+
* once per session per environment, without blocking the server start.
32+
*/
33+
@injectable()
34+
export class DeepnoteAgentSkillsManager {
35+
private readonly processedEnvironments = new Set<string>();
36+
37+
constructor(@inject(IProcessServiceFactory) private readonly processServiceFactory: IProcessServiceFactory) {}
38+
39+
/**
40+
* Fire-and-forget: ensures the agent skill files are up-to-date for the
41+
* given environment. Safe to call repeatedly -- only the first call per
42+
* environment per session actually does work.
43+
*/
44+
public ensureSkillsUpdated(environmentId: string, venvInterpreter: PythonEnvironment): void {
45+
if (this.processedEnvironments.has(environmentId)) {
46+
return;
47+
}
48+
49+
this.processedEnvironments.add(environmentId);
50+
51+
this.updateSkillsInBackground(venvInterpreter).catch((err) =>
52+
logger.warn('Failed to install Deepnote agent skills', err)
53+
);
54+
}
55+
56+
private async updateSkillsInBackground(venvInterpreter: PythonEnvironment): Promise<void> {
57+
const agentName = getAgentName();
58+
if (!agentName) {
59+
return;
60+
}
61+
62+
const workspaceRoot = workspace.workspaceFolders?.[0]?.uri;
63+
if (!workspaceRoot) {
64+
logger.info('No workspace folder open, skipping agent skills installation');
65+
return;
66+
}
67+
68+
const processService = await this.processServiceFactory.create(undefined);
69+
const venvBinDir = Uri.joinPath(venvInterpreter.uri, '..');
70+
71+
// Upgrade deepnote-cli to latest (also installs it if missing in older venvs)
72+
logger.info('Upgrading deepnote-cli in venv...');
73+
const upgradeResult = await processService.exec(
74+
venvInterpreter.uri.fsPath,
75+
['-m', 'pip', 'install', '--upgrade', 'deepnote-cli'],
76+
{ throwOnStdErr: false }
77+
);
78+
79+
if (upgradeResult.stderr) {
80+
logger.info('deepnote-cli upgrade stderr:', upgradeResult.stderr);
81+
}
82+
83+
// Run install-skills using the venv's deepnote entry point
84+
const deepnoteBin = Uri.joinPath(venvBinDir, 'deepnote');
85+
logger.info(`Running deepnote install-skills --agent "${agentName}" in ${workspaceRoot.fsPath}`);
86+
87+
const installResult = await processService.exec(deepnoteBin.fsPath, ['install-skills', '--agent', agentName], {
88+
cwd: workspaceRoot.fsPath,
89+
throwOnStdErr: false
90+
});
91+
92+
if (installResult.stdout) {
93+
logger.info('install-skills output:', installResult.stdout);
94+
}
95+
if (installResult.stderr) {
96+
logger.warn('install-skills stderr:', installResult.stderr);
97+
}
98+
}
99+
}
Lines changed: 234 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,234 @@
1+
import { assert } from 'chai';
2+
import * as sinon from 'sinon';
3+
import { reset, when } from 'ts-mockito';
4+
import { Uri } from 'vscode';
5+
6+
import { IProcessService, IProcessServiceFactory } from '../../platform/common/process/types.node';
7+
import { PythonEnvironment } from '../../platform/pythonEnvironments/info';
8+
import { mockedVSCodeNamespaces, resetVSCodeMocks } from '../../test/vscode-mock';
9+
import { DeepnoteAgentSkillsManager } from './deepnoteAgentSkillsManager.node';
10+
11+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
12+
const getPrivateMethod = (obj: any, methodName: string) => {
13+
return obj[methodName].bind(obj);
14+
};
15+
16+
suite('DeepnoteAgentSkillsManager', () => {
17+
let manager: DeepnoteAgentSkillsManager;
18+
let execStub: sinon.SinonStub;
19+
20+
const workspaceFolder = { uri: Uri.file('/workspace/my-project') };
21+
22+
const testInterpreter: PythonEnvironment = {
23+
id: 'test-python-id',
24+
uri: Uri.file('/home/user/.venvs/test-venv/bin/python')
25+
} as PythonEnvironment;
26+
27+
function configureVSCodeMocks(appName: string, workspaceFolders?: any[]) {
28+
resetVSCodeMocks();
29+
reset(mockedVSCodeNamespaces.env);
30+
reset(mockedVSCodeNamespaces.workspace);
31+
32+
when(mockedVSCodeNamespaces.env.appName).thenReturn(appName);
33+
when(mockedVSCodeNamespaces.workspace.workspaceFolders).thenReturn(workspaceFolders as any);
34+
}
35+
36+
setup(() => {
37+
configureVSCodeMocks('Cursor', [workspaceFolder]);
38+
39+
execStub = sinon.stub().resolves({ stdout: '', stderr: '' });
40+
41+
const stubProcessService = { exec: execStub } as unknown as IProcessService;
42+
const stubFactory = {
43+
create: sinon.stub().resolves(stubProcessService)
44+
} as unknown as IProcessServiceFactory;
45+
46+
manager = new DeepnoteAgentSkillsManager(stubFactory);
47+
});
48+
49+
suite('updateSkillsInBackground', () => {
50+
test('should run pip upgrade then install-skills', async () => {
51+
const updateSkills: (interpreter: PythonEnvironment) => Promise<void> = getPrivateMethod(
52+
manager,
53+
'updateSkillsInBackground'
54+
);
55+
56+
await updateSkills(testInterpreter);
57+
58+
assert.strictEqual(execStub.callCount, 2);
59+
60+
const [executable, args] = execStub.firstCall.args;
61+
62+
assert.strictEqual(executable, testInterpreter.uri.fsPath);
63+
assert.deepStrictEqual(args, ['-m', 'pip', 'install', '--upgrade', 'deepnote-cli']);
64+
});
65+
66+
test('should call install-skills with correct agent and cwd', async () => {
67+
const updateSkills: (interpreter: PythonEnvironment) => Promise<void> = getPrivateMethod(
68+
manager,
69+
'updateSkillsInBackground'
70+
);
71+
72+
await updateSkills(testInterpreter);
73+
74+
const [executable, args, options] = execStub.secondCall.args;
75+
const expectedBin = Uri.joinPath(testInterpreter.uri, '..', 'deepnote').fsPath;
76+
77+
assert.strictEqual(executable, expectedBin);
78+
assert.deepStrictEqual(args, ['install-skills', '--agent', 'cursor']);
79+
assert.strictEqual(options.cwd, workspaceFolder.uri.fsPath);
80+
});
81+
});
82+
83+
suite('session-scoped deduplication', () => {
84+
test('should mark environment as processed after first call', () => {
85+
manager.ensureSkillsUpdated('env-1', testInterpreter);
86+
87+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
88+
const processed = (manager as any).processedEnvironments as Set<string>;
89+
90+
assert.isTrue(processed.has('env-1'));
91+
});
92+
93+
test('should track different environments separately', () => {
94+
manager.ensureSkillsUpdated('env-1', testInterpreter);
95+
manager.ensureSkillsUpdated('env-2', testInterpreter);
96+
97+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
98+
const processed = (manager as any).processedEnvironments as Set<string>;
99+
100+
assert.isTrue(processed.has('env-1'));
101+
assert.isTrue(processed.has('env-2'));
102+
assert.strictEqual(processed.size, 2);
103+
});
104+
105+
test('should not add duplicate entries for the same environment', () => {
106+
manager.ensureSkillsUpdated('env-1', testInterpreter);
107+
manager.ensureSkillsUpdated('env-1', testInterpreter);
108+
manager.ensureSkillsUpdated('env-1', testInterpreter);
109+
110+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
111+
const processed = (manager as any).processedEnvironments as Set<string>;
112+
113+
assert.strictEqual(processed.size, 1);
114+
});
115+
});
116+
117+
suite('editor detection', () => {
118+
test('should detect Cursor', async () => {
119+
configureVSCodeMocks('Cursor', [workspaceFolder]);
120+
manager = new DeepnoteAgentSkillsManager(
121+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
122+
(manager as any).processServiceFactory
123+
);
124+
125+
const updateSkills: (interpreter: PythonEnvironment) => Promise<void> = getPrivateMethod(
126+
manager,
127+
'updateSkillsInBackground'
128+
);
129+
130+
await updateSkills(testInterpreter);
131+
132+
assert.deepStrictEqual(execStub.secondCall.args[1], ['install-skills', '--agent', 'cursor']);
133+
});
134+
135+
test('should detect Windsurf', async () => {
136+
configureVSCodeMocks('Windsurf', [workspaceFolder]);
137+
manager = new DeepnoteAgentSkillsManager(
138+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
139+
(manager as any).processServiceFactory
140+
);
141+
142+
const updateSkills: (interpreter: PythonEnvironment) => Promise<void> = getPrivateMethod(
143+
manager,
144+
'updateSkillsInBackground'
145+
);
146+
147+
await updateSkills(testInterpreter);
148+
149+
assert.deepStrictEqual(execStub.secondCall.args[1], ['install-skills', '--agent', 'windsurf']);
150+
});
151+
152+
test('should default to github copilot for VS Code', async () => {
153+
configureVSCodeMocks('Visual Studio Code', [workspaceFolder]);
154+
manager = new DeepnoteAgentSkillsManager(
155+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
156+
(manager as any).processServiceFactory
157+
);
158+
159+
const updateSkills: (interpreter: PythonEnvironment) => Promise<void> = getPrivateMethod(
160+
manager,
161+
'updateSkillsInBackground'
162+
);
163+
164+
await updateSkills(testInterpreter);
165+
166+
assert.deepStrictEqual(execStub.secondCall.args[1], ['install-skills', '--agent', 'github copilot']);
167+
});
168+
169+
test('should default to github copilot for unknown editors', async () => {
170+
configureVSCodeMocks('SomeUnknownEditor', [workspaceFolder]);
171+
manager = new DeepnoteAgentSkillsManager(
172+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
173+
(manager as any).processServiceFactory
174+
);
175+
176+
const updateSkills: (interpreter: PythonEnvironment) => Promise<void> = getPrivateMethod(
177+
manager,
178+
'updateSkillsInBackground'
179+
);
180+
181+
await updateSkills(testInterpreter);
182+
183+
assert.deepStrictEqual(execStub.secondCall.args[1], ['install-skills', '--agent', 'github copilot']);
184+
});
185+
});
186+
187+
suite('edge cases', () => {
188+
test('should skip when no workspace folder is open', async () => {
189+
configureVSCodeMocks('Cursor', undefined);
190+
manager = new DeepnoteAgentSkillsManager(
191+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
192+
(manager as any).processServiceFactory
193+
);
194+
195+
const updateSkills: (interpreter: PythonEnvironment) => Promise<void> = getPrivateMethod(
196+
manager,
197+
'updateSkillsInBackground'
198+
);
199+
200+
await updateSkills(testInterpreter);
201+
202+
assert.strictEqual(execStub.callCount, 0);
203+
});
204+
205+
test('should skip when workspace folders array is empty', async () => {
206+
configureVSCodeMocks('Cursor', []);
207+
manager = new DeepnoteAgentSkillsManager(
208+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
209+
(manager as any).processServiceFactory
210+
);
211+
212+
const updateSkills: (interpreter: PythonEnvironment) => Promise<void> = getPrivateMethod(
213+
manager,
214+
'updateSkillsInBackground'
215+
);
216+
217+
await updateSkills(testInterpreter);
218+
219+
assert.strictEqual(execStub.callCount, 0);
220+
});
221+
222+
test('should swallow errors in ensureSkillsUpdated', () => {
223+
execStub.rejects(new Error('pip failure'));
224+
225+
// ensureSkillsUpdated is fire-and-forget -- it must not throw
226+
manager.ensureSkillsUpdated('env-error', testInterpreter);
227+
228+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
229+
const processed = (manager as any).processedEnvironments as Set<string>;
230+
231+
assert.isTrue(processed.has('env-error'));
232+
});
233+
});
234+
});

src/kernels/deepnote/deepnoteServerStarter.node.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ import { ISqlIntegrationEnvVarsProvider } from '../../platform/notebooks/deepnot
1818
import { PythonEnvironment } from '../../platform/pythonEnvironments/info';
1919
import * as path from '../../platform/vscode-path/path';
2020
import { DEEPNOTE_DEFAULT_PORT, DeepnoteServerInfo, IDeepnoteServerStarter, IDeepnoteToolkitInstaller } from './types';
21+
import { DeepnoteAgentSkillsManager } from './deepnoteAgentSkillsManager.node';
2122
import tcpPortUsed from 'tcp-port-used';
2223

2324
/**
@@ -68,6 +69,7 @@ export class DeepnoteServerStarter implements IDeepnoteServerStarter, IExtension
6869
constructor(
6970
@inject(IProcessServiceFactory) private readonly processServiceFactory: IProcessServiceFactory,
7071
@inject(IDeepnoteToolkitInstaller) private readonly toolkitInstaller: IDeepnoteToolkitInstaller,
72+
@inject(DeepnoteAgentSkillsManager) private readonly agentSkillsManager: DeepnoteAgentSkillsManager,
7173
@inject(IOutputChannel) @named(STANDARD_OUTPUT_CHANNEL) private readonly outputChannel: IOutputChannel,
7274
@inject(IHttpClient) private readonly httpClient: IHttpClient,
7375
@inject(IAsyncDisposableRegistry) asyncRegistry: IAsyncDisposableRegistry,
@@ -258,6 +260,8 @@ export class DeepnoteServerStarter implements IDeepnoteServerStarter, IExtension
258260
token
259261
);
260262

263+
this.agentSkillsManager.ensureSkillsUpdated(environmentId, venvInterpreter);
264+
261265
Cancellation.throwIfCanceled(token);
262266

263267
await this.toolkitInstaller.installAdditionalPackages(venvPath, additionalPackages, token);

src/kernels/deepnote/deepnoteServerStarter.unit.test.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import { assert } from 'chai';
22
import * as sinon from 'sinon';
33
import tcpPortUsed from 'tcp-port-used';
44
import { anything, instance, mock, when } from 'ts-mockito';
5+
import { DeepnoteAgentSkillsManager } from './deepnoteAgentSkillsManager.node';
56
import { DeepnoteServerStarter } from './deepnoteServerStarter.node';
67
import { IProcessServiceFactory } from '../../platform/common/process/types.node';
78
import { IAsyncDisposableRegistry, IHttpClient, IOutputChannel } from '../../platform/common/types';
@@ -21,6 +22,7 @@ suite('DeepnoteServerStarter - Port Allocation Integration Tests', () => {
2122
let serverStarter: DeepnoteServerStarter;
2223
let mockProcessServiceFactory: IProcessServiceFactory;
2324
let mockToolkitInstaller: IDeepnoteToolkitInstaller;
25+
let mockAgentSkillsManager: DeepnoteAgentSkillsManager;
2426
let mockOutputChannel: IOutputChannel;
2527
let mockHttpClient: IHttpClient;
2628
let mockAsyncRegistry: IAsyncDisposableRegistry;
@@ -36,6 +38,7 @@ suite('DeepnoteServerStarter - Port Allocation Integration Tests', () => {
3638
// Create mocks
3739
mockProcessServiceFactory = mock<IProcessServiceFactory>();
3840
mockToolkitInstaller = mock<IDeepnoteToolkitInstaller>();
41+
mockAgentSkillsManager = mock<DeepnoteAgentSkillsManager>();
3942
mockOutputChannel = mock<IOutputChannel>();
4043
mockHttpClient = mock<IHttpClient>();
4144
mockAsyncRegistry = mock<IAsyncDisposableRegistry>();
@@ -47,6 +50,7 @@ suite('DeepnoteServerStarter - Port Allocation Integration Tests', () => {
4750
serverStarter = new DeepnoteServerStarter(
4851
instance(mockProcessServiceFactory),
4952
instance(mockToolkitInstaller),
53+
instance(mockAgentSkillsManager),
5054
instance(mockOutputChannel),
5155
instance(mockHttpClient),
5256
instance(mockAsyncRegistry),

0 commit comments

Comments
 (0)