cloudflare / cloudflare/agents

Skill scripts served from R2 cannot run: both documented paths fail

Open
#2,068 3 comments 0 reactions 1 assignee Claimed by @aron-cf View on GitHub
Dominant language
TypeScript
Stars
5.6k
Forks
711
Avg merge
1d 20h
Merged PRs (30d)
53

Description

**Packages:** `agents@0.20.1` (via `@cloudflare/think@0.15.1`)

Agent Skills documents two ways to give a dynamically-sourced skill an executable
script. Neither works. A skill loaded through `skills.r2()` can only run a script
that ignores both instructions.

## The documented paths

**1. Ship TypeScript.** The Think README states:

> TypeScript scripts are compiled with `@cloudflare/worker-bundler`.

There is no such compilation at runtime. `@cloudflare/worker-bundler` is not a
dependency of `agents` and appears nowhere in `dist/`. A `.ts` entry resolves to
runtime `typescript`, so `prepareJavaScriptSource()` falls past its
`runtime === "javascript"` branch and throws:

> Skill script "…" must be compiled to a self-contained JavaScript module before
> it can run.

**2. Compile before upload.** The error above, and `src/skills/compile.ts`'s own
module docstring, both direct you to the same fix:

> skills served from R2 or other dynamic sources should be compiled with
> `compileSkillScript` before upload

Following that instruction produces a *worse* failure than ignoring it.
`compileSkillScript` emits esbuild's default-export form:

```js
function run(input, ctx) { /* … */ }
export { run as default };
```

`prepareJavaScriptSource()` routes a compiled bundle to `rewriteBundledSource()`,
which strips the export **and rebinds the default** (`const __skillRun = run;`).
That branch is gated on `resource.precompiled === true`.

**`skills.r2()` never sets `precompiled`.** It derives every resource descriptor
from the object path alone — `resourceKind()`, `resourceEncoding()`,
`resourceMimeType()`, plus `size`. `listAllObjects()` calls
`bucket.list({ prefix, cursor })` without `include: ["customMetadata"]`, so
there is no channel for the flag to arrive on either.

So the compiled bundle misses the `precompiled` branch and takes the single-file
raw branch instead. `scriptModule()` then applies:

```js
stripStrayExports(source.replace(/^\s*export\s+default\s+/m, "const __skillRun = "))
```

The regex does not match `export { run as default }`, so nothing is rebound, and
`stripStrayExports()` deletes the export line. Executed against a real Worker
Loader, the compiled bundle fails with:

> Skill script failed: Skill script default export must be a function (input, ctx).

The script *does* have a default export. The runner cannot see it in the form its
own compiler emits, and reports the opposite.

## The loop

Executed end to end against a live `worker_loaders` binding:

| Script served from R2 | Result |
| --- | --- |
| `.ts`, `export default` | `must be compiled to a self-contained JavaScript module … e.g. with compileSkillScript` |
| `.js` compiled by `compileSkillScript` | `Skill script default export must be a function (input, ctx).` |

The first error instructs you to produce the second.

## What this costs

`compileSkillScript` is the bundler — turning several modules into one
self-contained file is the whole reason it exists. So while the symptom is "a
compiled bundle will not run", the consequence is broader: **a dynamically-sourced
JavaScript or TypeScript skill script cannot span more than one file.** The only
shape that runs is a single `.js` that imports nothing, so any skill whose logic
does not fit comfortably in one module has no path at all.

The asymmetry is the sharp part. A multi-file *Python* skill from the same R2
source runs today, sibling imports and all, because `runPythonScript()` never
touches this code path and materializes every resource onto the filesystem. So the
runtime with the weaker story upstream is the only one that can be structured, and
a team that wants modules is pushed to Python by a bug rather than by a decision.

## Reproduction

The descriptor drop reproduces directly (vitest, workers pool):

```ts
import { r2 } from "agents/skills";

const files = new Map([
["skills/demo/SKILL.md", "---\nname: demo\ndescription: demo skill\n---\nbody"],
// exactly what compileSkillScript emits
["skills/demo/scripts/run.js",
"function run(i,c){return{ok:true}}\nexport { run as default };"],
]);

const bucket = {
async list({ prefix }) {
return {
objects: [...files.keys()].filter(k => k.startsWith(prefix))
.map(key => ({ key, size: files.get(key).length })),
truncated: false,
};
},
async get(key) {
const body = files.get(key);
return body === undefined ? null : { text: async () => body };
},
};

const content = await r2(bucket, { prefix: "skills/" }).load("demo");
const script = content.resources.find(r => r.path.endsWith("run.js"));

script.kind; // "script"
script.precompiled; // undefined <-- the compiled bundle is not recognised
```

## Expected

Either `skills.r2()` propagates `precompiled` for compiled scripts — via
`customMetadata` on the R2 object, or by treating `compileSkillScript` output as
recognisable — or `scriptModule()` handles the `export { x as default }` form the
way `rewriteBundledSource()` does, so the binding survives on both branches.

Also worth correcting: the README's `@cloudflare/worker-bundler` sentence
describes runtime compilation that does not exist, which is what leads a reader
to upload TypeScript in the first place.

## Notes

- Every result above was executed against a live `worker_loaders` binding, not
inferred. A plain single-file `.js` using `export default` runs fine from the
same source, which is the only JavaScript shape that currently works.
- Python is unaffected. `runPythonScript()` bypasses this path entirely and
materializes every resource onto the Pyodide filesystem, so a multi-file Python
skill served from R2 runs today — verified, including a sibling module import
resolved from the documented `/skill` mount point.

Contributor guide

Open the contributing guide

Assessment

This issue has not been assessed yet.

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.