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
4 changes: 4 additions & 0 deletions .github/workflows/interop.yml
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,10 @@ jobs:
- run: pnpm run typecheck
- name: Node — a Flue agent app on cellb
run: pnpm --dir ../flue-example test
- name: Node — the fake OpenAI-compatible model
run: pnpm --dir ../fake-openai test
- name: Node — pi and opencode agents on cellb, against the fake model
run: pnpm --dir ../agent-examples test
- name: Node — celld conformance and interop
run: pnpm run interop
- run: pnpm exec playwright install --with-deps chromium
Expand Down
79 changes: 79 additions & 0 deletions packages/agent-examples/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
# @statewalker/agent-examples

Coding-agent frameworks running in Durable Objects on cellb, each against a fake OpenAI-compatible
model (`@statewalker/fake-openai`), so the whole thing runs offline once the packages are installed:

| Example | Framework | What it shows |
|---|---|---|
| `pi/` | [pi](https://pi.dev)'s agent loop (`@earendil-works/pi-agent-core`, `@earendil-works/pi-ai`) | a prompt runs a tool call and its follow-up; the transcript persists in the object's storage |
| `opencode/` | opencode's workerd SDK (`@opencode-ai/sdk/workerd`) | celld's opencode example: the SDK boots on the object's SQLite, creates a session, and the model answers a prompt |
| `../flue-example` | Flue (`@flue/runtime`) | a whole Flue app, built for Cloudflare, on Node and in browsers |

The package is private: it is a test of cellb, not a library.

## Why it exists

celld ships `pi` and `opencode` examples, but they need the network: `pi` depends on an unreleased
preview build of the `agents` package and OpenRouter, `opencode` on a free model from opencode's own
registry. These versions take the model from an endpoint named in the project's vars, which the
tests point at the fake model, and they run on cellb as celld runs them.

## How to use it

```
pnpm install # at the repository root
pnpm test # in this directory: each example on cellb (Node), against the fake model
```

To run one against a real endpoint, set its vars (`PI_BASE_URL`, `PI_MODEL`, `PI_API_KEY`;
`OPENCODE_BASE_URL`, `OPENCODE_MODEL`, `OPENCODE_API_KEY`) in `wrangler.jsonc` or `.dev.vars`, and
start it with `celld dev pi` or load it with `loadNodeProject`. Against the fake model:

```sh
node ../fake-openai/src/cli.ts 8787 # the vars' defaults point here
```

## Examples

What the tests prove:

1. **pi**: `GET /?q=…` runs the agent: the model is offered the `where_am_i` tool and calls it, the
tool answers, and the second model call carries the result, which becomes the reply
(`The tool said: a Durable Object on cellb`). A second prompt continues the same transcript,
restored from storage.
2. **opencode**: `GET /` runs celld's probe (`model.default`, `provider.list`, `health.get`,
`session.create`, `session.get`, `session.prompt`, `session.wait`, `message.list`); every step
succeeds and the reply is the fake model's answer to `hello world`, from provider `endpoint`,
model `fake-model`.

## Internals

### Differences from celld's examples

- **pi** uses pi's published packages instead of the `PiHarness` of `agents` pull request 2197
(only on pkg.pr.new, which may expire, and unreachable from some networks). The object holds a
pi `Agent` with one tool; the transcript is stored under the `messages` key.
- **opencode** adds `config` to `OpenCodeWorkerd.create()`: a provider `endpoint`
(`@ai-sdk/openai-compatible` with `baseURL` from `OPENCODE_BASE_URL`) and `model:
"endpoint/<OPENCODE_MODEL>"`. Everything else is celld's code.

### What surprised us

- **The opencode SDK builds a Response from another** (`new Response(body, response)`). cellb's
`Response` lost the headers of such an init, so the SDK's client saw no `content-type` and failed
with `ClientError: UnsupportedContentType`; it now reads the init as Fetch does.
- **Packages with only a `main` field did not resolve** in cellb's Node bundles (esbuild's neutral
platform has no main fields): opencode's dependencies (`@smithy/*`) failed to build. Node bundles
now resolve as `celld deploy` does: conditions `workerd`, `worker`, `browser`, then `module` and
`main`.
- **The opencode bundle is large** (about 430,000 lines): its first load takes about half a minute.

### Dependencies

`@earendil-works/pi-agent-core` and `@earendil-works/pi-ai` (pi), `@opencode-ai/sdk` (pinned to the
build celld's example uses); `@statewalker/cellb`, `@statewalker/fake-openai` and vitest for the
tests.

## License

MIT; `opencode/index.js` is adapted from celld's example (Apache-2.0).
149 changes: 149 additions & 0 deletions packages/agent-examples/opencode/index.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,149 @@
// Adapted from celld's examples/opencode (Apache-2.0): the model comes from OPENCODE_BASE_URL.
import { DurableObject } from "cloudflare:workers";
import { OpenCodeWorkerd } from "@opencode-ai/sdk/workerd";

const PROMPT = "hello world";

// The model: an OpenAI-compatible endpoint as an opencode provider (celld's example takes a free
// model from opencode's own registry, which needs the network).
function endpointConfig(env) {
return {
provider: {
endpoint: {
npm: "@ai-sdk/openai-compatible",
name: "OpenAI-compatible endpoint",
options: { baseURL: env.OPENCODE_BASE_URL, apiKey: env.OPENCODE_API_KEY || "none" },
models: { [env.OPENCODE_MODEL]: { name: env.OPENCODE_MODEL } },
},
},
model: `endpoint/${env.OPENCODE_MODEL}`,
};
}

export class OpenCodeDO extends DurableObject {
async fetch(_request) {
const checks = [];
const check = async (name, fn) => {
const started = Date.now();
try {
const value = await fn();
checks.push({ name, ok: true, ms: Date.now() - started });
return value;
} catch (error) {
checks.push({
name,
ok: false,
ms: Date.now() - started,
error: serializeError(error),
});
throw error;
}
};

try {
const opencode = await OpenCodeWorkerd.create({
storage: this.ctx.storage,
config: endpointConfig(this.env),
});

// The SDK resolves the model from its own provider registry. Report the
// resolution instead of a model this example names: a request for an
// unregistered provider falls back without an error, so a hardcoded name
// in the response would claim a model that never ran.
const model = await check("model.default", () => opencode.model.default());
const providers = await check("provider.list", () => opencode.provider.list());

await check("health.get", () => opencode.health.get());
const session = await check("session.create", () => opencode.session.create());
await check("session.get", () => opencode.session.get({ sessionID: session.id }));

// `prompt` only enqueues the message and returns before the model runs.
// `wait` blocks until the run completes, so a probe that stops at
// `prompt` reports success even when no model answers.
await check("session.prompt", () =>
opencode.session.prompt({ sessionID: session.id, text: PROMPT }),
);
await check("session.wait", () => opencode.session.wait({ sessionID: session.id }));

const messages = await check("message.list", () =>
opencode.message.list({ sessionID: session.id }),
);

const reply = assistantText(messages);
if (!reply) {
return Response.json(
{
ok: false,
error: "the session completed without an assistant reply",
model: modelRef(model),
checks,
},
{ status: 502 },
);
}

return Response.json({
ok: true,
model: modelRef(model),
providers: (providers?.data ?? providers ?? []).map((entry) => entry.id),
prompt: PROMPT,
reply,
checks,
});
} catch (error) {
return Response.json(
{
ok: false,
error: serializeError(error),
checks,
},
{ status: 500 },
);
}
}
}

function modelRef(model) {
const data = model?.data ?? model;
return {
providerID: data?.providerID ?? null,
modelID: data?.modelID ?? null,
};
}

// An assistant reply is one entry of a `content` array that also carries
// reasoning and tool parts. Join the text parts only, because a reasoning part
// is present even when the model produced no answer.
function assistantText(messages) {
const rows = messages?.data ?? messages ?? [];
for (const message of rows) {
if (message?.type !== "assistant") continue;
const text = (message.content ?? [])
.filter((part) => part?.type === "text")
.map((part) => part.text)
.join("")
.trim();
if (text) return text;
}
return null;
}

function serializeError(value, seen = new Set()) {
if (value === null || typeof value !== "object") return value;
if (seen.has(value)) return "[circular]";
seen.add(value);
return {
name: value.name,
message: value.message,
stack: value.stack,
cause: serializeError(value.cause, seen),
...Object.fromEntries(Object.entries(value)),
};
}

export default {
fetch(request, env) {
const id = env.OPENCODE.idFromName("reporter-main-f4e19cd2");
return env.OPENCODE.get(id).fetch(request);
},
};
16 changes: 16 additions & 0 deletions packages/agent-examples/opencode/wrangler.jsonc
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
{
// opencode's workerd SDK in a Durable Object, with a model from any OpenAI-compatible endpoint
// (OPENCODE_BASE_URL; the tests point it at the fake model).
"name": "opencode-agent",
"main": "index.js",
"compatibility_date": "2026-08-30",
"compatibility_flags": ["nodejs_compat"],
"vars": {
"OPENCODE_BASE_URL": "http://127.0.0.1:8787/v1",
"OPENCODE_MODEL": "fake-model"
},
"durable_objects": {
"bindings": [{ "name": "OPENCODE", "class_name": "OpenCodeDO" }]
},
"migrations": [{ "tag": "v1", "new_sqlite_classes": ["OpenCodeDO"] }]
}
26 changes: 26 additions & 0 deletions packages/agent-examples/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
{
"name": "@statewalker/agent-examples",
"version": "0.0.1",
"description": "Agent frameworks (pi, opencode) in Durable Objects, run on cellb against a fake OpenAI-compatible model",
"type": "module",
"private": true,
"license": "MIT",
"scripts": {
"test": "vitest run",
"typecheck": "tsc --noEmit"
},
"dependencies": {
"@earendil-works/pi-agent-core": "^1.4.0",
"@earendil-works/pi-ai": "^1.4.0",
"@opencode-ai/sdk": "0.0.0-dev-18697"
},
"devDependencies": {
"@statewalker/cellb": "workspace:*",
"@statewalker/fake-openai": "workspace:*",
"@statewalker/webrun-files-mem": "^0.10.0",
"@types/node": "catalog:",
"esbuild": "0.28.2",
"typescript": "catalog:",
"vitest": "catalog:"
}
}
104 changes: 104 additions & 0 deletions packages/agent-examples/pi/index.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
// pi's agent loop in a Durable Object: each prompt runs the model and its tool calls, and the
// transcript is kept in the object's storage, so it survives a restart. Adapted from celld's
// `examples/pi`, which needs the unreleased PiHarness of the `agents` package; this one uses pi's
// own published packages.
import { DurableObject } from "cloudflare:workers";
import { Agent } from "@earendil-works/pi-agent-core";
import { createModels, createProvider, Type } from "@earendil-works/pi-ai";
import { openAICompletionsApi } from "@earendil-works/pi-ai/api/openai-completions.lazy";

function modelsFor(env) {
const models = createModels();
models.setProvider(
createProvider({
id: "endpoint",
// The OpenAI client needs some key; a local endpoint ignores it.
auth: {
apiKey: {
name: "endpoint",
resolve: async () => ({ auth: { apiKey: env.PI_API_KEY || "none" } }),
},
},
models: [
{
id: env.PI_MODEL,
name: env.PI_MODEL,
api: "openai-completions",
provider: "endpoint",
baseUrl: env.PI_BASE_URL,
reasoning: false,
input: ["text"],
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
contextWindow: 32000,
maxTokens: 1024,
},
],
api: openAICompletionsApi(),
}),
);
return models;
}

// A tool the model can call: what the object knows about where it runs.
const whereAmI = {
name: "where_am_i",
label: "Where am I",
description: "Tells where this agent runs",
parameters: Type.Object({}),
execute: async () => ({
content: [{ type: "text", text: "a Durable Object on cellb" }],
details: {},
}),
};

export class PiAgent extends DurableObject {
constructor(ctx, env) {
super(ctx, env);
const models = modelsFor(env);
this.agent = new Agent({
initialState: {
systemPrompt: "You are a helpful assistant. Use your tools.",
model: models.getModel("endpoint", env.PI_MODEL),
tools: [whereAmI],
},
streamFn: models.streamSimple.bind(models),
});
ctx.blockConcurrencyWhile(async () => {
const saved = await ctx.storage.get("messages");
if (saved) this.agent.state.messages = saved;
});
}

async fetch(request) {
const url = new URL(request.url);
if (url.pathname === "/messages") return Response.json(this.agent.state.messages);
await this.agent.prompt(url.searchParams.get("q") ?? "ping");
const messages = this.agent.state.messages;
await this.ctx.storage.put("messages", messages);
const last = messages.at(-1);
const reply = (last?.content ?? [])
.filter((part) => part.type === "text")
.map((part) => part.text)
.join("");
const failed = last?.stopReason === "error";
return Response.json(
{
reply,
error: failed ? last.errorMessage : undefined,
toolCalls: messages
.filter((m) => m.role === "assistant")
.flatMap((m) =>
m.content.filter((part) => part.type === "toolCall").map((part) => part.name),
),
messages: messages.length,
},
{ status: failed ? 502 : 200 },
);
}
}

export default {
fetch(request, env) {
return env.PI_AGENT.getByName("local-pi").fetch(request);
},
};
Loading
Loading