diff --git a/README.md b/README.md index 323f6969..bb0b1d99 100644 --- a/README.md +++ b/README.md @@ -67,6 +67,10 @@ To see the pieces working together, start with the examples: through `env.LOADER`. No container. - [`examples/think`](examples/think) — an agent that uses the workspace as its working directory. +- [`examples/tutorial`](examples/tutorial) — the smallest version of + that idea, built up step by step: one endpoint, one file, an agent + that writes markdown on the host and runs `pandoc` on it in the + container. ## Repository layout diff --git a/biome.jsonc b/biome.jsonc index df09b7e1..57ac5e4c 100644 --- a/biome.jsonc +++ b/biome.jsonc @@ -1,5 +1,5 @@ { - "$schema": "https://biomejs.dev/schemas/2.4.16/schema.json", + "$schema": "https://biomejs.dev/schemas/2.5.6/schema.json", "vcs": { "enabled": true, "clientKind": "git", "useIgnoreFile": true }, "files": { "includes": [ diff --git a/examples/tutorial/.env.example b/examples/tutorial/.env.example new file mode 100644 index 00000000..f3de44b7 --- /dev/null +++ b/examples/tutorial/.env.example @@ -0,0 +1,12 @@ +# Copy to .env for local development, or set these as secrets in +# production with `wrangler secret put `. The generated +# worker-configuration.d.ts picks the names up from this file: +# wrangler types --env-file=.env.example +# +# The R2 binding alone can't mint a presigned URL, so the assets +# client needs R2 S3 credentials. Create an R2 API token scoped to +# the bucket and fill in the values below. + +R2_ACCESS_KEY_ID= +R2_SECRET_ACCESS_KEY= +CLOUDFLARE_ACCOUNT_ID= diff --git a/examples/tutorial/.gitignore b/examples/tutorial/.gitignore new file mode 100644 index 00000000..b0f702d3 --- /dev/null +++ b/examples/tutorial/.gitignore @@ -0,0 +1,3 @@ +.wrangler/ +.env +.dev.vars* diff --git a/examples/tutorial/Dockerfile b/examples/tutorial/Dockerfile new file mode 100644 index 00000000..6d676c1a --- /dev/null +++ b/examples/tutorial/Dockerfile @@ -0,0 +1,42 @@ +# Container image for the recipe card tutorial. Same wiring as +# examples/think — pull the computerd binary out of the public GHCR image, +# drop it into a slim debian, and run it as PID 1. The :VERSION tag is +# rewritten in lockstep with the rest of the monorepo by +# script/set-versions.mjs. +# +# computerd mounts the workspace at MOUNT_POINT, so a file the host writes +# through the Workspace is on disk for pandoc to read, and the PDF +# pandoc writes syncs back to the durable object. + +FROM ghcr.io/cloudflare/computer-computerd-linux-x64:0.1.0-alpha.1 AS computerd + +FROM debian:stable-slim + +ARG PANDOC_VERSION=3.10 +ARG TYPST_VERSION=0.15.1 + +# pandoc converts the agent's markdown; typst is the PDF engine, a +# single 30 MB binary instead of the several hundred megabytes a LaTeX +# install would cost. curl is only the download tool for those two. +RUN apt-get update \ + && apt-get install -y --no-install-recommends \ + fuse3 libfuse2t64 ca-certificates curl xz-utils \ + && curl -fsSL -o /tmp/pandoc.deb \ + "https://github.com/jgm/pandoc/releases/download/${PANDOC_VERSION}/pandoc-${PANDOC_VERSION}-1-amd64.deb" \ + && dpkg -i /tmp/pandoc.deb \ + && curl -fsSL "https://github.com/typst/typst/releases/download/v${TYPST_VERSION}/typst-x86_64-unknown-linux-musl.tar.xz" \ + | tar -xJ -C /tmp \ + && mv /tmp/typst-x86_64-unknown-linux-musl/typst /usr/local/bin/typst \ + && rm -rf /tmp/pandoc.deb /tmp/typst-x86_64-unknown-linux-musl /var/lib/apt/lists/* + +COPY --from=computerd /usr/local/bin/computerd /usr/local/bin/computerd + +ENV PORT=8080 +ENV MOUNT_POINT=/workspace +# FUSE_MOUNT=auto uses the real kernel FUSE backend when /dev/fuse is +# reachable (Cloudflare Containers) and falls back to the userspace +# shim otherwise (wrangler dev). One image works in both places. +ENV FUSE_MOUNT=auto +EXPOSE 8080 + +ENTRYPOINT ["/usr/local/bin/computerd"] diff --git a/examples/tutorial/README.md b/examples/tutorial/README.md new file mode 100644 index 00000000..79f08e76 --- /dev/null +++ b/examples/tutorial/README.md @@ -0,0 +1,358 @@ +# Tutorial: a PDF recipe card agent + +> [!IMPORTANT] +> **PREVIEW ONLY** This package is provided as a preview for feedback only. +> APIs are unstable and the design is subject to change. +> +> Suitable for experiments, exploration and prototypes. It is NOT suitable +> for production use at this time. + +A worker with one endpoint. You post a dish, an agent finds a matching +recipe on [openstove.org](https://openstove.org/recipes), writes a +markdown recipe card, converts it to a PDF with `pandoc`, and answers +with a link to the PDF. + +``` +POST /prompt ──► RecipeAgent + │ fetch_url https://openstove.org/... (host) + │ write /workspace/card.md (host) + │ bash pandoc card.md -o card.pdf (container) + ▼ + R2 ──► signed link, good for a day +``` + +Both halves of that pipeline touch one filesystem, which is the point +of the workspace. The `write` tool runs on the host, in the durable +object, and writes through the `Workspace` into durable object storage. +The container sees the same file on its FUSE mount at `/workspace`, so +`pandoc` reads it as an ordinary file. The PDF `pandoc` writes syncs +back the other way when the `bash` call finishes, so the finished file +can be published straight from the workspace. + +The finished code is [one file](src/index.ts). The rest of this page +builds it from an empty directory. + +## 1. Create the worker project + +```sh +npm create cloudflare@latest -- computer-tutorial --type=hello-world \ + --lang=ts --no-git --no-deploy -y +cd computer-tutorial +``` + +Replace the generated `wrangler.jsonc` with the bindings this project +needs: Workers AI for the model, a durable object with a container +attached to it, and an R2 bucket for the finished PDFs. + +```jsonc +{ + "name": "computer-tutorial", + "main": "src/index.ts", + "compatibility_date": "2026-05-26", + "compatibility_flags": ["nodejs_compat"], + + "ai": { "binding": "AI" }, + + "containers": [ + { "class_name": "RecipeAgent", "image": "./Dockerfile", "max_instances": 5 } + ], + + "durable_objects": { + "bindings": [{ "name": "RecipeAgent", "class_name": "RecipeAgent" }] + }, + + "r2_buckets": [{ "binding": "CARDS", "bucket_name": "recipe-cards" }], + "vars": { "CARDS_BUCKET_NAME": "recipe-cards" }, + + "migrations": [{ "tag": "v1", "new_sqlite_classes": ["RecipeAgent"] }] +} +``` + +Two details matter here. The `containers` entry names the same class as +the durable object binding — one container instance per durable object, +which is how the agent gets a private Linux box. And the workspace +keeps its files in SQLite-backed durable object storage, so the class +has to be in `new_sqlite_classes`. + +Create the bucket now; `wrangler` won't do it for you: + +```sh +wrangler r2 bucket create recipe-cards +``` + +## 2. Install the dependencies + +```sh +npm install @cloudflare/computer @cloudflare/think agents ai zod +``` + +- `@cloudflare/computer` is the filesystem, the container backend, and + the assets client that publishes to R2. +- `@cloudflare/think` provides the agent loop and its file, shell, and + fetch tools. `agents` provides `getAgentByName` for reaching an + instance by name; `ai` and `zod` satisfy Think's peer dependencies. + +## 3. Create the Dockerfile + +The container is an ordinary Linux image with one requirement: `computerd`, +the workspace daemon, has to be PID 1. It mounts the workspace at +`MOUNT_POINT` and syncs it with the durable object. Everything else in +the image is yours — here, `pandoc` and a PDF engine for it. + +```dockerfile +FROM ghcr.io/cloudflare/computer-computerd-linux-x64:VERSION AS computerd + +FROM debian:stable-slim + +RUN apt-get update \ + && apt-get install -y --no-install-recommends \ + fuse3 libfuse2t64 ca-certificates curl xz-utils \ + && ...install pandoc and typst... + +COPY --from=computerd /usr/local/bin/computerd /usr/local/bin/computerd + +ENV PORT=8080 +ENV MOUNT_POINT=/workspace +ENV FUSE_MOUNT=auto +EXPOSE 8080 + +ENTRYPOINT ["/usr/local/bin/computerd"] +``` + +See [`Dockerfile`](Dockerfile) for the full version, including the +pinned `pandoc` and `typst` downloads. `typst` is the PDF engine +because it is a single 30 MB binary; a LaTeX install would cost several +hundred megabytes of image. + +`FUSE_MOUNT=auto` uses the kernel FUSE backend when `/dev/fuse` is +reachable, which it is on Cloudflare Containers, and falls back to a +userspace shim otherwise, which is what `wrangler dev` gets. One image +works in both places. + +## 4. Give Think a Computer workspace + +The durable object owns a `CloudflareContainerBackend`, which is the +container the workspace is mounted in. The `Workspace` instance enables +Computer's Think-compatible methods so Think's built-in tools use the +same filesystem as the container. + +```ts +import { + CloudflareContainerBackend, + withWorkspaceContainer, +} from "@cloudflare/computer/backends/container"; +import { Think } from "@cloudflare/think"; +import { + type DurableObjectStorageLike, + type ThinkWorkspaceCompatibility, + Workspace, +} from "@cloudflare/computer"; + +class RecipeBase extends Think {} + +export class RecipeAgent extends withWorkspaceContainer(RecipeBase) { + readonly #backend = new CloudflareContainerBackend({ + container: () => this, + workspace: { binding: "RecipeAgent", id: this.ctx.id.toString() }, + }); + + override workspace = new Workspace({ + storage: this.ctx.storage as unknown as DurableObjectStorageLike, + backends: [this.#backend], + useThink: true, + }) as Workspace & ThinkWorkspaceCompatibility; + + override async fetch(request: Request): Promise { + return new URL(request.url).pathname === "/ws" + ? this.#backend.handleFetch(request) + : super.fetch(request); + } +} +``` + +`withWorkspaceContainer` mixes the container lifecycle into Think, so +the durable object can start and stop its own container. The +`workspace: { binding, id }` pair is how the container finds its way +home: it dials the named binding at that id, which is why `fetch` has to +hand `/ws` to the backend before the base class sees it. + +## 5. Hook the workspace up to the agent + +Think already has file and shell tools. The Computer workspace makes +those tools use the same filesystem as the container: +`write` calls Computer's host-side filesystem, while `bash` calls the +container shell. Think's fetch tool gets an allowlist of one host, so +the agent can read openstove.org and nothing else. + +```ts +override maxSteps = 10; +override fetchTools = { + allowlist: ["https://openstove.org/**"], + followRedirects: "none" as const, + maxModelChars: 64_000, +}; + +override getModel() { + return "@cf/zai-org/glm-5.2"; +} + +override getSystemPrompt() { + return [ + "You turn a cooking request into a one-page PDF recipe card.", + "", + "1. Find the recipe. `fetch_url` https://openstove.org/sitemap-0.xml lists every recipe page.", + " Pick the closest match and fetch it; each page carries the whole", + ' recipe in a