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
5 changes: 5 additions & 0 deletions .changeset/codemode-module-scheme.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@cloudflare/computer": patch
---

`CodemodeBackend` takes a `scheme`, such as `"agent"`, to serve host modules and the host's libraries as `agent:*` instead of `codemode:*`, and `createBrowserModule()` takes `specifier` and `toolsSpecifier` for the names its description gives. See [Module scheme](https://github.com/cloudflare/computer/blob/main/docs/17_isolate_javascript.md#module-scheme).
28 changes: 26 additions & 2 deletions docs/17_isolate_javascript.md
Original file line number Diff line number Diff line change
Expand Up @@ -362,7 +362,7 @@ Source modules live together in the module directory, not beside the caller's fi

### Host modules

A host module runs in the Durable Object, and each of its functions becomes a named export in the isolate. Host modules must use a simple `codemode:*` specifier. Nothing under `codemode:` is installed unless you configure it.
A host module runs in the Durable Object, and each of its functions becomes a named export in the isolate. Host modules must use a simple `codemode:*` specifier, or one under the backend's [module scheme](#module-scheme). Nothing under `codemode:` is installed unless you configure it.

Pass an object of functions:

Expand Down Expand Up @@ -429,6 +429,30 @@ The run's code never sees metadata: it isn't in `process.env` or the input, so c

Specifiers and the export names of an object are checked at construction. A factory's export names are checked when the backend connects and the factory runs. An object must export at least one function. A factory may build none, as `codemode:tools` does for an agent with no tools: the backend then leaves that module out of the connection, so only code that imports it fails, with a message saying the module exports nothing, and every other run still starts. Every export name must be a JavaScript identifier name other than `default` or `then`. A reserved word such as `delete` is allowed, and caller code renames it on import: `import { delete as remove } from "codemode:files"`. Importing a name the module does not export fails when the module graph links, before any code runs.

#### Module scheme

A host that wants its modules under a name of its own sets `scheme`, which replaces `codemode` in every specifier above. With `scheme: "agent"`, host modules and the libraries the host provides are `agent:git`, `agent:weather` and so on: code imports them by those names, the backend's description lists them so, and an import of an `agent:*` module that isn't configured fails before the Worker is created, as an unconfigured `codemode:*` one does by default.

```ts
new CodemodeBackend({
loader: env.LOADER,
scheme: "agent",
modules: {
"agent:git": createGitModule(),
"agent:web": createBrowserModule({
browser: this.browser,
specifier: "agent:web",
toolsSpecifier: "agent:tools",
}),
"agent:tools": createToolBindings(forPiTools(() => agent.tools)),
},
});
```

`codemode:*` is then an ordinary bare name: a host module can't use it, and code that imports one it doesn't have fails as it would for any other unknown module. A backend has one scheme. It must be a lowercase letter followed by lowercase letters, digits, or `-`, and can't be one the Workers runtime or URLs already give a meaning, such as `node`, `cloudflare`, `http`, or `file`. The default, `codemode`, behaves exactly as before.

The prebuilt modules work under any specifier. `createBrowserModule()` tells the model which specifier to import it from, and not to reach the browser tool through `codemode:tools`, so pass it `specifier` and `toolsSpecifier` when it's served under other names.

#### Codemode modules

`createCodemodeModule()` builds a host module that can return objects with methods, and give its functions shared context. Define the kinds of object the module returns, add context, then make the module:
Expand Down Expand Up @@ -759,7 +783,7 @@ The function may throw instead, when a run without that metadata shouldn't brows

Runs in one browser scope take turns, like `browserTool` calls, because they would otherwise see each other's navigation: pi and the AI SDK run a message's tool calls in parallel. A run takes its turn at its first browser call and keeps it until it ends, and runs that never call the module never wait. A run gives up after `lockTimeoutMs` (45 seconds by default) with an error saying why; keep it under the run's timeout and the backend's `maxHostCallMs`. A run that browsed and is waiting for a person keeps its browser scope while it waits: its session is closed, and other runs' first calls in that browser scope fail at once, saying the browser is held by a run that's waiting for a person. A run that took its turn while one ahead of it paused fails the same way. The module remembers which runs browsed in memory, so after the Durable Object restarts, a waiting run holds nothing until it resumes. Once that run resumes, it takes its turn again only at its first new browser call, since replay answers its earlier calls from the journal.

Don't expose `browserTool` to code through `codemode:tools` alongside this module. A run calling it would wait for its own turn until the host call timed out. The module's description says so, and also tells the model to save screenshots to a file with `node:fs` and open them with `read`, to take about eight per run (each host call returns at most 1 MiB, and a run's calls share 8 MiB each way), and to work on several pages inside one run rather than across runs started together. The backend adds that description to its own, which the `exec` tool shows. An app that writes `exec`'s description itself must include it, from `backend.description`.
Don't expose `browserTool` to code through `codemode:tools` alongside this module. A run calling it would wait for its own turn until the host call timed out. The module's description says so, naming `toolsSpecifier` (`codemode:tools` by default) as it names `specifier` (`codemode:browser` by default) in its import line, and also tells the model to save screenshots to a file with `node:fs` and open them with `read`, to take about eight per run (each host call returns at most 1 MiB, and a run's calls share 8 MiB each way), and to work on several pages inside one run rather than across runs started together. The backend adds that description to its own, which the `exec` tool shows. An app that writes `exec`'s description itself must include it, from `backend.description`.

The module is experimental and may change in any release.

Expand Down
200 changes: 199 additions & 1 deletion packages/computer/src/backends/codemode/codemode.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,9 @@ import { Database, initializeSchema, WorkspaceFilesystem } from "@cloudflare/dof
import { SQLiteTestStorage } from "@cloudflare/dofs/testing";
import { describe, expect, it, vi } from "vitest";

import { createGitModule } from "../../modules/git";
import { createCodemodeModule, type WorkspaceModuleScopeEnd } from "../../runtime/codemode-module";
import type { WorkspaceRuntimeLoader } from "../../runtime/types";
import type { WorkspaceModule, WorkspaceRuntimeLoader } from "../../runtime/types";
import { Workspace } from "../../workspace";
import { runtimeCode, CodemodeBackend } from "./codemode";
import { ExecutionJournal, fingerprint, loadResume, type ResumeStart, saveResume } from "./journal";
Expand Down Expand Up @@ -2050,6 +2051,203 @@ describe("CodemodeBackend", () => {
`import { value } from "base"; import { run } from "./codemode:echo"; export { value, run };`,
);
});

describe("with a scheme", () => {
async function workspaceWith(
load: WorkspaceRuntimeLoader["load"],
modules: Record<string, WorkspaceModule>,
) {
const workspace = new Workspace({
storage: new SQLiteTestStorage(),
backends: [new CodemodeBackend({ loader: { load }, scheme: "agent", modules })],
});
await workspace.fs.mkdir("/workspace", { recursive: true });
return workspace;
}

// A loader whose run calls each host function in turn, as code that
// imported them would, then completes.
function callingLoader(names: readonly string[]) {
return vi.fn<WorkspaceRuntimeLoader["load"]>(
(_code: { modules: Record<string, string | { js?: string }> }) => ({
getEntrypoint() {
return {
async evaluate(
_input: unknown,
host: {
call(name: string, args: unknown[]): Promise<unknown>;
assertResult(value: unknown): Promise<void>;
attachOutput(readable: ReadableStream<Uint8Array>): Promise<void>;
},
) {
for (const name of names) await host.call(name, []);
await evaluateResult(host, null);
},
};
},
}),
);
}

it("serves objects of functions, factories and codemode modules under its scheme", async () => {
const called: string[] = [];
const load = callingLoader([
"host/agent:echo.run",
"host/agent:repo.recent",
"host/agent:jobs.start",
]);
const workspace = await workspaceWith(load, {
"agent:echo": { run: async () => void called.push("echo") },
"agent:repo": () => ({ recent: async () => void called.push("repo") }),
"agent:jobs": createCodemodeModule().module({
start: async () => void called.push("jobs"),
}),
});

const execution = await workspace.runtime.exec(
`import { run } from "agent:echo"; import { recent } from "agent:repo"; import { start } from "agent:jobs"; export default [run, recent, start].length;`,
);
await expect(execution.result()).resolves.toMatchObject({ status: "completed" });

expect(called).toEqual(["echo", "repo", "jobs"]);
const modules = load.mock.calls[0]?.[0].modules ?? {};
expect(Object.keys(modules).filter((name) => name.startsWith("__modules__/"))).toEqual([
"__modules__/agent:echo",
"__modules__/agent:repo",
"__modules__/agent:jobs",
]);
expect(source(modules["workspace/__workspace_entry__.js"])).toBe(
`import { run } from "../__modules__/agent:echo"; import { recent } from "../__modules__/agent:repo"; import { start } from "../__modules__/agent:jobs"; export default [run, recent, start].length;`,
);
});

it("describes a prebuilt factory and the host's libraries under its scheme", () => {
const backend = new CodemodeBackend({
loader: throwingLoader("must not load"),
scheme: "agent",
modules: {
"agent:git": createGitModule(),
"agent:lib": "export const x = 1;",
// Under another scheme, codemode:* is an ordinary bare name.
"codemode:lib": "export const x = 1;",
},
});

expect(backend.description).toContain(
"- `agent:git`: The workspace's Git repository tools: `status({ dir })`",
);
expect(backend.description).toContain("- `agent:lib`: a library the host provides.");
expect(backend.description).toContain("- `codemode:lib`: a bundled library.");
});

it("serves source under its scheme, which can import the host's modules by name", async () => {
const load = completingLoader();
const workspace = await workspaceWith(load, {
"agent:facade": `import { run } from "agent:echo"; import { value } from "agent:base"; export const echo = { run, value };`,
"agent:base": "export const value = 1;",
"agent:echo": { run: async () => null },
});

const execution = await workspace.runtime.exec(
`import { echo } from "agent:facade"; export default typeof echo.run;`,
);
await expect(execution.result()).resolves.toMatchObject({ status: "completed" });

const modules = load.mock.calls[0]?.[0].modules ?? {};
expect(source(modules["__modules__/agent:facade"])).toBe(
`import { run } from "./agent:echo"; import { value } from "./agent:base"; export const echo = { run, value };`,
);
});

it.each([
[
"code that imports an unconfigured module under its scheme",
{},
`import "agent:missing"; export default 1;`,
`Module "agent:missing" is not configured. Add it to the backend's modules option.`,
],
[
"code that imports a codemode:* module",
{},
`import { status } from "codemode:git"; export default status;`,
`Module "codemode:git" is not configured for the codemode backend.`,
],
[
"source that imports an unconfigured module under its scheme",
{ lib: `import "agent:missing";` },
`import "lib"; export default 1;`,
`Configured module "lib" imports "agent:missing", which is not configured.`,
],
])("rejects %s", async (_label, modules, code, message) => {
const load = vi.fn<WorkspaceRuntimeLoader["load"]>();
const workspace = await workspaceWith(load, modules);

await expect(workspace.runtime.exec(code)).rejects.toThrow(message);
expect(load).not.toHaveBeenCalled();
});

it.each([
[
"a host module under codemode:*",
{ "codemode:git": { run: async () => null } },
'Host module "codemode:git" must use a simple agent:* name, such as "agent:git".',
],
[
"source under its scheme with a path",
{ "agent:lib/x": "export const x = 1;" },
'Module "agent:lib/x" must use a simple agent:* name, such as "agent:git".',
],
])("rejects %s at construction", (_label, modules, message) => {
expect(
() =>
new CodemodeBackend({
loader: throwingLoader("must not load"),
scheme: "agent",
modules,
}),
).toThrow(message);
});

it("keeps codemode:* by default, and refuses another scheme's host modules", () => {
expect(
() =>
new CodemodeBackend({
loader: throwingLoader("must not load"),
modules: { "agent:git": { run: async () => null } },
}),
).toThrow(
'Host module "agent:git" must use a simple codemode:* name, such as "codemode:git".',
);
const backend = new CodemodeBackend({
loader: throwingLoader("must not load"),
scheme: "codemode",
modules: { "codemode:weather": { forecast: () => null } },
});
expect(backend.description).toBe(
new CodemodeBackend({
loader: throwingLoader("must not load"),
modules: { "codemode:weather": { forecast: () => null } },
}).description,
);
});

it.each(["", "Agent", "1agent", "agent:", "my_agent", "node", "cloudflare", "https", "file"])(
"refuses the scheme %j",
(scheme) => {
expect(
() => new CodemodeBackend({ loader: throwingLoader("must not load"), scheme }),
).toThrow(/CodemodeBackend scheme/);
},
);

it("accepts lowercase schemes with digits and dashes", () => {
for (const scheme of ["agent", "my-agent", "agent2"]) {
expect(
() => new CodemodeBackend({ loader: throwingLoader("must not load"), scheme }),
).not.toThrow();
}
});
});
});
});

Expand Down
28 changes: 26 additions & 2 deletions packages/computer/src/backends/codemode/codemode.ts
Original file line number Diff line number Diff line change
Expand Up @@ -55,7 +55,9 @@ import {
} from "./journal.js";
import {
assertHostModuleExports,
assertScheme,
buildModuleGraph,
DEFAULT_SCHEME,
hasNodeModules,
internalModuleSources,
NODE_MODULES_DESCRIPTION,
Expand All @@ -77,7 +79,8 @@ export interface CodemodeBackendOptions {
* one, is a host module: it runs in the Durable Object under a `codemode:*`
* specifier, and each function becomes a named export. Source may use a
* simple `codemode:*` specifier too, for a library the host provides, such as
* one that wraps host modules in objects.
* one that wraps host modules in objects. `scheme` replaces `codemode`
* in these specifiers.
*
* ```ts
* modules: {
Expand All @@ -93,6 +96,21 @@ export interface CodemodeBackendOptions {
* connecting throws when a host module's export names are not allowed.
*/
modules?: Record<string, WorkspaceModule>;
/**
* The scheme of host module specifiers, and of the libraries the host
* provides, without its colon. Defaults to `"codemode"`, for
* `codemode:git`. With `"agent"`, host modules are `agent:git` and
* `agent:weather`, code imports them by those names, and the backend's
* description lists them so.
*
* A specifier under any other scheme, `codemode:` included, is then an
* ordinary bare name: a host module can't use it, and an import of one
* that isn't configured fails like any other unknown module. The scheme
* must be a lowercase letter followed by lowercase letters, digits, or
* `-`, and not one the Workers runtime or URLs already use, such as
* `node`, `cloudflare`, `http` or `file`.
*/
scheme?: string;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 New scheme has no consumer example

Adding scheme changes the public backend API, but no runnable example uses it. The repository requires examples to accompany public API changes, leaving custom module names unexercised in a consumer.

Learn more

The repository's agent guidelines require updating examples when a public API changes. scheme is a new public backend option, and createBrowserModule also gains public naming options. The existing codemode example still uses the default backend with no modules; none of the runnable consumers demonstrate the new configuration.

Example: A user following the codemode example sees no working configuration for scheme: "agent" or for importing agent:git from code, despite the new public option.

Recommended fix: Add a runnable consumer example that configures a custom scheme, registers a matching host module, and imports it; include browser specifiers if the consumer uses the browser module.

Devin Review


Was this helpful? React with 👍 or 👎 to provide feedback.

defaultTimeoutMs?: number;
maxTimeoutMs?: number;
/**
Expand Down Expand Up @@ -214,6 +232,7 @@ type ResolvedCodemodeBackendOptions = Required<
CodemodeBackendOptions,
| "root"
| "access"
| "scheme"
| "defaultTimeoutMs"
| "maxTimeoutMs"
| "maxCpuMs"
Expand Down Expand Up @@ -374,6 +393,8 @@ export class CodemodeBackend implements WorkspaceModuleBackend {
if ((options.maxCapabilityBytes ?? 1024 * 1024) < 256) {
throw new Error("CodemodeBackend maxCapabilityBytes must be at least 256 bytes.");
}
const scheme = options.scheme ?? DEFAULT_SCHEME;
assertScheme(scheme);
const compatibilityDate = options.compatibilityDate ?? "2026-05-23";
if (!/^\d{4}-\d{2}-\d{2}$/.test(compatibilityDate)) {
throw new Error("CodemodeBackend compatibilityDate must use YYYY-MM-DD.");
Expand All @@ -392,7 +413,8 @@ export class CodemodeBackend implements WorkspaceModuleBackend {
this.#options = {
...backendOptions,
egress: resolvedEgress,
modules: parseModules(options.modules ?? {}),
modules: parseModules(options.modules ?? {}, scheme),
scheme,
root: options.root ?? WORKSPACE_ROOT,
access: options.access ?? "read-write",
defaultTimeoutMs,
Expand Down Expand Up @@ -976,6 +998,7 @@ class CodemodeBackendHandle implements WorkspaceModuleBackendHandle {
this.#sourceModules ??= prepareSourceModules(
this.#options.modules.source,
new Set([...this.#options.modules.host.keys(), ...this.#options.modules.codemode.keys()]),
this.#options.scheme,
);
return this.#sourceModules;
}
Expand Down Expand Up @@ -1034,6 +1057,7 @@ class CodemodeBackendHandle implements WorkspaceModuleBackendHandle {
nodeModules: hasNodeModules(this.#options.compatibilityFlags),
maxSourceBytes: this.#options.maxSourceBytes,
maxCapabilityBytes: this.#options.maxCapabilityBytes,
scheme: this.#options.scheme,
});
if (this.#closed) {
throw runtimeError("ECLOSED", "CodemodeBackend closed during module loading");
Expand Down
Loading
Loading