feat(vscode): create empty notebooks via notebook_edit

Add a create action to the notebook_edit tool so an agent can make a new empty
.ipynb on disk, then insert cells. The VS Code notebook API has no way to create
a notebook file at a path (openNotebookDocument only makes an unsaved untitled
notebook), so the bridge writes a minimal valid empty .ipynb and opens it.

Reuses the existing notebook_edit permission and native_notebook_tools
experiment; .ipynb only for now, parent directory must already exist.
This commit is contained in:
Mark IJbema
2026-06-26 13:25:08 +02:00
parent ebba5a6b85
commit 6bc57fe6f8
11 changed files with 276 additions and 38 deletions
+5
View File
@@ -0,0 +1,5 @@
---
"kilo-code": minor
---
Let the notebook tools create a new empty notebook.
@@ -1,7 +1,7 @@
import path from "node:path"
import * as vscode from "vscode"
import { normalizeOutputs, normalizeSource } from "./output"
import { NotebookError, resolveNotebookPath, type NotebookPathDeps } from "./path"
import { NotebookError, resolveNotebookCreatePath, resolveNotebookPath, type NotebookPathDeps } from "./path"
import { cellFingerprint, fingerprint, notebookState, sameCell, type NotebookState } from "./revision"
import {
NOTEBOOK_LIMITS,
@@ -21,6 +21,18 @@ const RETAINED_REVISIONS = 1_000
const revisions = new Map<string, NotebookState>()
const locks = new Map<string, Promise<void>>()
// Minimal valid empty Jupyter notebook accepted by the built-in ipynb serializer.
const EMPTY_IPYNB = JSON.stringify(
{
cells: [],
metadata: {},
nbformat: 4,
nbformat_minor: 5,
},
null,
1,
)
function revisionKey(target: string, revision: string): string {
return `${target}\0${revision}`
}
@@ -60,6 +72,7 @@ function defaults(): NotebookAdapterDeps {
return {
documents: () => vscode.workspace.notebookDocuments,
open: (uri) => Promise.resolve(vscode.workspace.openNotebookDocument(uri)),
write: (uri, content) => Promise.resolve(vscode.workspace.fs.writeFile(uri, content)),
apply: (edit) => Promise.resolve(vscode.workspace.applyEdit(edit)),
execute: (command, ...args) => Promise.resolve(vscode.commands.executeCommand(command, ...args)),
change: (listener) => vscode.workspace.onDidChangeNotebookDocument(listener),
@@ -167,12 +180,23 @@ export class NotebookAdapter {
}
async edit(request: NotebookEditRequest): Promise<NotebookEditResult> {
const edit = request.edit
if (edit.action === "create") {
return this.create(request)
}
const loaded = await this.document(request.directory, request.path)
return this.lock(loaded.target, async () => {
const before = this.remember(loaded.document, loaded.target)
this.revision(before, request.expectedRevision, loaded.path, request.index)
const expectedRevision = request.expectedRevision
if (expectedRevision === undefined) {
throw new NotebookError("invalid_cell", "An expected revision is required for this edit", {
path: loaded.path,
index: request.index,
})
}
this.revision(before, expectedRevision, loaded.path, request.index)
const count = loaded.document.cellCount
const max = request.edit.action === "insert" ? count : count - 1
const max = edit.action === "insert" ? count : count - 1
if (!Number.isInteger(request.index) || request.index < 0 || request.index > max) {
throw new NotebookError("invalid_cell", `Cell index ${request.index} is out of range`, {
path: loaded.path,
@@ -182,25 +206,25 @@ export class NotebookAdapter {
const expected = [...before.cells]
const edits = (() => {
if (request.edit.action === "delete") {
if (edit.action === "delete") {
expected.splice(request.index, 1)
return [this.deps.delete(request.index)]
}
const language = request.edit.language ?? (request.edit.kind === "code" ? "plaintext" : "markdown")
const language = edit.language ?? (edit.kind === "code" ? "plaintext" : "markdown")
const cell = this.deps.cell({
kind: request.edit.kind,
language: request.edit.language,
source: request.edit.source,
kind: edit.kind,
language: edit.language,
source: edit.source,
})
const value = fingerprint(request.edit.kind, language, request.edit.source)
if (request.edit.action === "insert") {
const value = fingerprint(edit.kind, language, edit.source)
if (edit.action === "insert") {
expected.splice(request.index, 0, value)
return [this.deps.insert(request.index, [cell])]
}
expected.splice(request.index, 1, value)
return [this.deps.replace(request.index, [cell])]
})()
this.revision(this.remember(loaded.document, loaded.target), request.expectedRevision, loaded.path, request.index)
this.revision(this.remember(loaded.document, loaded.target), expectedRevision, loaded.path, request.index)
if (!(await this.deps.apply(this.deps.edit(loaded.document.uri, edits)))) {
throw new NotebookError("unsupported", "VS Code rejected the notebook edit", {
path: loaded.path,
@@ -217,15 +241,37 @@ export class NotebookAdapter {
requestPath: request.path,
revision: after.revision,
index: request.index,
action: request.edit.action,
action: edit.action,
}
if (request.edit.action !== "delete" && request.index < loaded.document.cellCount) {
if (edit.action !== "delete" && request.index < loaded.document.cellCount) {
result.cell = this.cell(loaded.document.cellAt(request.index), request.index)
}
return result
})
}
private async create(request: NotebookEditRequest): Promise<NotebookEditResult> {
const resolved = await resolveNotebookCreatePath(request.directory, request.path, this.access, this.options.paths)
return this.lock(resolved.target, async () => {
await this.deps.write(this.deps.uri(resolved.target), new TextEncoder().encode(EMPTY_IPYNB))
const document = await this.deps.open(this.deps.uri(resolved.target))
if (document.isClosed) {
throw new NotebookError("closed", `Notebook ${JSON.stringify(resolved.relative)} is closed`, {
path: resolved.relative,
})
}
const state = this.remember(document, resolved.target)
return {
operation: "edit",
path: resolved.relative,
requestPath: request.path,
revision: state.revision,
index: 0,
action: "create",
}
})
}
async execute(request: NotebookExecuteRequest): Promise<NotebookExecuteResult> {
const loaded = await this.document(request.directory, request.path)
const state = this.remember(loaded.document, loaded.target)
@@ -264,7 +264,7 @@ export class NotebookBridge {
return adapter.edit({
path: request.path,
directory,
expectedRevision: request.expectedRevision,
...(request.expectedRevision !== undefined ? { expectedRevision: request.expectedRevision } : {}),
index: request.index,
edit: request.edit,
})
@@ -84,3 +84,53 @@ export async function resolveNotebookPath(
}
return { target, relative: path.relative(root, target).split(path.sep).join("/") }
}
// Resolve a path for a notebook that does not exist yet. The file itself is not
// realpathed (it must not exist); instead the parent directory is realpathed and
// must be contained in the request root and pass access checks.
export async function resolveNotebookCreatePath(
directory: string,
input: string,
access: NotebookAccess,
deps: NotebookPathDeps = defaults,
): Promise<NotebookPath> {
if (!input || input.length > 4_096 || input.includes("\0")) {
throw invalid(input, "the path is empty, too long, or malformed")
}
if (WINDOWS_ABSOLUTE.test(input) && !path.win32.isAbsolute(directory)) {
throw invalid(input, "the absolute path uses a different platform format")
}
if (!input.toLowerCase().endsWith(".ipynb")) {
throw invalid(input, "only .ipynb notebooks can be created")
}
const base = path.resolve(directory)
const root = await deps.realpath(base)
const candidate = path.resolve(base, input)
if (!path.isAbsolute(input) && !contained(base, candidate)) {
throw invalid(input, "it is outside the request directory")
}
const parent = await deps.realpath(path.dirname(candidate)).catch((error: unknown) => {
const detail = error instanceof Error ? error.message : String(error)
throw new NotebookError("not_found", `Cannot resolve the parent directory of ${JSON.stringify(input)}: ${detail}`, {
path: input,
})
})
if (!contained(root, parent)) {
throw invalid(input, "its parent directory resolves outside the request directory")
}
const target = path.join(parent, path.basename(candidate))
const existing = await deps.realpath(target).then(
() => true,
() => false,
)
if (existing) {
throw new NotebookError("already_exists", `Notebook ${JSON.stringify(input)} already exists`, { path: input })
}
if (!(await access.validateAccess(target))) {
throw invalid(input, "it is excluded by workspace access or ignore rules")
}
return { target, relative: path.relative(root, target).split(path.sep).join("/") }
}
@@ -57,7 +57,7 @@ export interface NotebookEditResult {
requestPath: string
revision: string
index: number
action: "insert" | "replace" | "delete"
action: "insert" | "replace" | "delete" | "create"
cell?: NotebookCell
}
@@ -82,6 +82,7 @@ export type NotebookEdit =
| ({ action: "insert" } & NotebookCellInput)
| ({ action: "replace" } & NotebookCellInput)
| { action: "delete" }
| { action: "create" }
export interface NotebookReadRequest {
path: string
@@ -92,7 +93,7 @@ export interface NotebookReadRequest {
export interface NotebookEditRequest {
path: string
directory: string
expectedRevision: string
expectedRevision?: string
index: number
edit: NotebookEdit
}
@@ -113,6 +114,7 @@ export interface NotebookAccess {
export interface NotebookAdapterDeps {
documents(): readonly vscode.NotebookDocument[]
open(uri: vscode.Uri): Promise<vscode.NotebookDocument>
write(uri: vscode.Uri, content: Uint8Array): Promise<void>
apply(edit: vscode.WorkspaceEdit): Promise<boolean>
execute(command: string, ...args: unknown[]): Promise<unknown>
change(listener: (event: vscode.NotebookDocumentChangeEvent) => void): vscode.Disposable
@@ -65,13 +65,23 @@ function notebook(cells: vscode.NotebookCell[], file = "/repo/book.ipynb"): vsco
function harness(document: vscode.NotebookDocument, cells: vscode.NotebookCell[]) {
const changes = new Set<(event: vscode.NotebookDocumentChangeEvent) => void>()
const closes = new Set<(document: vscode.NotebookDocument) => void>()
const calls = { open: 0, apply: 0, command: 0, commandArgs: [] as unknown[], edit: undefined as unknown }
const calls = {
open: 0,
apply: 0,
command: 0,
commandArgs: [] as unknown[],
edit: undefined as unknown,
write: [] as Array<{ uri: vscode.Uri; content: Uint8Array }>,
}
const deps: NotebookAdapterDeps = {
documents: () => [document],
open: async () => {
calls.open++
return document
},
write: async (uri, content) => {
calls.write.push({ uri, content })
},
apply: async (edit) => {
calls.apply++
const item = (
@@ -119,12 +129,12 @@ function harness(document: vscode.NotebookDocument, cells: vscode.NotebookCell[]
const paths = { realpath: async (value: string) => value }
const access = { validateAccess: mock(() => true) }
function adapter(items: ReturnType<typeof cell>[], file = "/repo/book.ipynb") {
function adapter(items: ReturnType<typeof cell>[], file = "/repo/book.ipynb", resolver = paths) {
const cells = items.map((item) => item.value)
const document = notebook(cells, file)
const ctx = harness(document, cells)
return {
adapter: new NotebookAdapter(access, { deps: ctx.deps, paths, timeout: 50 }),
adapter: new NotebookAdapter(access, { deps: ctx.deps, paths: resolver, timeout: 50 }),
document,
cells,
...ctx,
@@ -467,3 +477,64 @@ describe("notebook adapter", () => {
])
})
})
describe("notebook create", () => {
// The new file must not resolve (it does not exist), but its parent directory must.
const creating = {
realpath: async (value: string) => (value.endsWith("fresh.ipynb") ? Promise.reject(new Error("ENOENT")) : value),
}
it("writes a minimal empty .ipynb, opens it, and returns the initial revision", async () => {
const ctx = adapter([], "/repo/fresh.ipynb", creating)
const result = await ctx.adapter.edit({
directory: "/repo",
path: "fresh.ipynb",
index: 0,
edit: { action: "create" },
})
expect(result).toMatchObject({ operation: "edit", action: "create", path: "fresh.ipynb", index: 0 })
expect(result.revision).toContain("content:")
expect(ctx.calls.write).toHaveLength(1)
expect(ctx.calls.open).toBe(1)
const written = JSON.parse(new TextDecoder().decode(ctx.calls.write[0]!.content))
expect(written).toMatchObject({ cells: [], nbformat: 4 })
})
it("rejects creating a notebook that already exists", async () => {
const ctx = adapter([cell()], "/repo/book.ipynb")
await expect(
ctx.adapter.edit({ directory: "/repo", path: "book.ipynb", index: 0, edit: { action: "create" } }),
).rejects.toMatchObject({ code: "already_exists", path: "book.ipynb" })
expect(ctx.calls.write).toHaveLength(0)
})
it("rejects a missing parent directory with not_found", async () => {
const missing = {
realpath: async (value: string) => (value === "/repo" ? value : Promise.reject(new Error("ENOENT"))),
}
const ctx = adapter([], "/repo/missing/fresh.ipynb", missing)
await expect(
ctx.adapter.edit({ directory: "/repo", path: "missing/fresh.ipynb", index: 0, edit: { action: "create" } }),
).rejects.toMatchObject({ code: "not_found" })
expect(ctx.calls.write).toHaveLength(0)
})
it("rejects non-.ipynb create targets", async () => {
const ctx = adapter([], "/repo/notes.txt", creating)
await expect(
ctx.adapter.edit({ directory: "/repo", path: "notes.txt", index: 0, edit: { action: "create" } }),
).rejects.toMatchObject({ code: "invalid_path" })
expect(ctx.calls.write).toHaveLength(0)
})
it("rejects create targets excluded by access rules", async () => {
const guard = { validateAccess: mock(() => false) }
const document = notebook([], "/repo/fresh.ipynb")
const ctx = harness(document, [])
const core = new NotebookAdapter(guard, { deps: ctx.deps, paths: creating, timeout: 50 })
await expect(
core.edit({ directory: "/repo", path: "fresh.ipynb", index: 0, edit: { action: "create" } }),
).rejects.toMatchObject({ code: "invalid_path" })
expect(ctx.calls.write).toHaveLength(0)
})
})
@@ -72,12 +72,15 @@ const CellEdit = {
export const EditRequest = Schema.Struct({
...Base,
operation: Schema.Literal("edit"),
expectedRevision: Revision,
expectedRevision: Schema.optional(Revision).annotate({
description: "Required for insert, replace, and delete; omitted for create, which has no prior revision",
}),
index: Index,
edit: Schema.Union([
Schema.Struct({ action: Schema.Literal("insert"), ...CellEdit }),
Schema.Struct({ action: Schema.Literal("replace"), ...CellEdit }),
Schema.Struct({ action: Schema.Literal("delete") }),
Schema.Struct({ action: Schema.Literal("create") }),
]),
}).annotate({ identifier: "NotebookEditRequest" })
@@ -114,7 +117,7 @@ export const EditResult = Schema.Struct({
requestPath: Path,
revision: Revision,
index: Index,
action: Schema.Literals(["insert", "replace", "delete"]),
action: Schema.Literals(["insert", "replace", "delete", "create"]),
cell: Schema.optional(Cell),
}).annotate({ identifier: "NotebookEditResult" })
@@ -141,6 +144,7 @@ export const Result = Schema.Union([ReadResult, EditResult, ExecuteResult]).anno
export type Result = Schema.Schema.Type<typeof Result>
export const ErrorCode = Schema.Literals([
"already_exists",
"cancelled",
"closed",
"disconnected",
@@ -91,10 +91,15 @@ export const NotebookReadTool = Tool.define<
const EditParams = Schema.Struct({
path: Path,
expected_revision: Revision,
index: Index,
action: Schema.Literals(["insert", "replace", "delete"]).annotate({
description: "insert and replace require kind and source; delete ignores cell fields",
expected_revision: Schema.optional(Revision).annotate({
description: "Required for insert, replace, and delete. Omit for create, which has no prior revision.",
}),
index: Schema.optional(Index).annotate({
description: "Zero-based cell index. Required for insert, replace, and delete. Ignored for create.",
}),
action: Schema.Literals(["insert", "replace", "delete", "create"]).annotate({
description:
"insert and replace require kind and source; delete ignores cell fields; create makes a new empty .ipynb at path and ignores cell fields, index, and expected_revision",
}),
kind: Schema.optional(Schema.Literals(["code", "markdown"])).annotate({
description: "Cell kind. Required for insert and replace.",
@@ -105,12 +110,27 @@ const EditParams = Schema.Struct({
type EditInput = Schema.Schema.Type<typeof EditParams>
function cellEdit(params: EditInput) {
if (params.action === "delete") return Effect.succeed({ action: params.action } as const)
if (params.kind === undefined || params.source === undefined)
if (params.action === "create") return Effect.succeed({ action: params.action } as const)
if (params.action === "delete") {
if (params.expected_revision === undefined || params.index === undefined)
return Effect.die(
new Tool.InvalidArgumentsError({
tool: "notebook_edit",
detail: `the "delete" action requires both "expected_revision" and "index"`,
}),
)
return Effect.succeed({ action: params.action } as const)
}
if (
params.kind === undefined ||
params.source === undefined ||
params.expected_revision === undefined ||
params.index === undefined
)
return Effect.die(
new Tool.InvalidArgumentsError({
tool: "notebook_edit",
detail: `the "${params.action}" action requires both "kind" and "source"`,
detail: `the "${params.action}" action requires "kind", "source", "expected_revision", and "index"`,
}),
)
return Effect.succeed({
@@ -132,11 +152,12 @@ export const NotebookEditTool = Tool.define<
const notebook = yield* Notebook.Service
return {
description:
"Insert, replace, or delete one cell in a live VS Code notebook. Paths may be request-directory-relative or safe absolute paths. Pass the latest opaque revision from notebook_read or the previous successful edit unchanged. A stale_revision error requires a fresh read; never blindly retry an index-based edit. Leaves the document dirty.",
"Insert, replace, delete, or create. insert/replace/delete operate on one cell in a live VS Code notebook and require expected_revision and index. create makes a new empty .ipynb at path (the parent directory must exist) and returns its initial revision so you can then insert cells. Paths may be request-directory-relative or safe absolute paths. Pass the latest opaque revision from notebook_read or the previous successful edit unchanged. A stale_revision error requires a fresh read; never blindly retry an index-based edit. Leaves the document dirty.",
parameters: EditParams,
execute: (params, ctx) =>
Effect.gen(function* () {
const edit = yield* cellEdit(params)
const index = params.action === "create" ? 0 : params.index!
yield* ctx.ask({
permission: "notebook_edit",
patterns: [params.path],
@@ -144,7 +165,7 @@ export const NotebookEditTool = Tool.define<
metadata: {
path: params.path,
action: params.action,
index: params.index,
index,
expectedRevision: params.expected_revision,
},
})
@@ -153,8 +174,8 @@ export const NotebookEditTool = Tool.define<
operation: "edit",
sessionID: ctx.sessionID,
path: params.path,
expectedRevision: params.expected_revision,
index: params.index,
...(params.expected_revision !== undefined ? { expectedRevision: params.expected_revision } : {}),
index,
edit,
}),
ctx.abort,
@@ -162,7 +183,10 @@ export const NotebookEditTool = Tool.define<
if (result.operation !== "edit")
return yield* Effect.die(new Error("Notebook host returned the wrong result type"))
return {
title: `${result.action} notebook cell ${result.index}`,
title:
result.action === "create"
? `created notebook ${result.path}`
: `${result.action} notebook cell ${result.index}`,
output: render(result),
metadata: { path: result.path, revision: result.revision, index: result.index },
}
@@ -27,7 +27,7 @@ const notebook = Layer.mock(Notebook.Service, {
operation: "edit" as const,
path: input.path,
requestPath: input.path,
revision: "content:edit",
revision: input.edit.action === "create" ? "content:create" : "content:edit",
index: input.index,
action: input.edit.action,
})
@@ -135,6 +135,26 @@ describe("native notebook tools", () => {
}),
{ git: true },
)
it.instance(
"creates a new notebook through notebook_edit without a revision or cell fields",
() =>
Effect.gen(function* () {
calls.length = 0
const edit = yield* NotebookEditTool.pipe(Effect.flatMap(Tool.init))
const asks: Parameters<Tool.Context["ask"]>[0][] = []
const ctx = context(asks)
const result = yield* edit.execute({ path: "fresh.ipynb", action: "create" }, ctx)
expect(asks.map((item) => item.permission)).toEqual(["notebook_edit"])
expect(calls).toEqual([
{ operation: "edit", sessionID: ctx.sessionID, path: "fresh.ipynb", index: 0, edit: { action: "create" } },
])
expect(result.title).toContain("created notebook")
expect(result.metadata).toMatchObject({ path: "fresh.ipynb", revision: "content:create", index: 0 })
}),
{ git: true },
)
})
test("uses dedicated VS Code notebook permission defaults only when enabled", () => {
+6 -2
View File
@@ -212,7 +212,7 @@ export type NotebookEditRequest = {
/**
* Opaque notebook content revision; pass it back unchanged and do not parse or increment it
*/
expectedRevision: string
expectedRevision?: string
/**
* Zero-based cell index
*/
@@ -233,6 +233,9 @@ export type NotebookEditRequest = {
| {
action: "delete"
}
| {
action: "create"
}
}
export type NotebookExecuteRequest = {
@@ -2539,7 +2542,7 @@ export type NotebookEditResult = {
* Zero-based cell index
*/
index: number
action: "insert" | "replace" | "delete"
action: "insert" | "replace" | "delete" | "create"
cell?: NotebookCell
}
@@ -2564,6 +2567,7 @@ export type NotebookResult = NotebookReadResult | NotebookEditResult | NotebookE
export type NotebookFailure = {
code:
| "already_exists"
| "cancelled"
| "closed"
| "disconnected"
+14 -2
View File
@@ -17647,11 +17647,22 @@
},
"required": ["action"],
"additionalProperties": false
},
{
"type": "object",
"properties": {
"action": {
"type": "string",
"enum": ["create"]
}
},
"required": ["action"],
"additionalProperties": false
}
]
}
},
"required": ["id", "sessionID", "path", "operation", "expectedRevision", "index", "edit"],
"required": ["id", "sessionID", "path", "operation", "index", "edit"],
"additionalProperties": false
},
"NotebookExecuteRequest": {
@@ -24518,7 +24529,7 @@
},
"action": {
"type": "string",
"enum": ["insert", "replace", "delete"]
"enum": ["insert", "replace", "delete", "create"]
},
"cell": {
"$ref": "#/components/schemas/NotebookCell"
@@ -24592,6 +24603,7 @@
"code": {
"type": "string",
"enum": [
"already_exists",
"cancelled",
"closed",
"disconnected",