Compare commits

...
10 Commits
Author SHA1 Message Date
dckcandClaude Opus 5.5 efb54ad4cc docs: Good Fences Make Good Agents (design story + IBIS)
The story behind good-fences: lend a governed mower over the fence to a
neighbor whose tenants are model-written code, with the design IBIS
worked out so far. Previously an untracked note at the workspace root.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-09 01:00:15 -05:00
dckcandClaude Opus 5.5 8c110098ff feat(good-fences): a Think agent mows with a codemode mower connector
Neighbor is a Project Think agent on gpt-oss-120b. Its execute tool
runs model-written code in a dynamic worker; MowerConnector puts the
mower there as the `mower.*` namespace (start/push/turn/stop, each
taking an args object). makeMower is the Logo-turtle mower on a
wrapping yard; for now it lives inside the agent's DO.

Getting to green, one symptom at a time:
- chatted instead of acting: say it acts by running code
- reply cut mid-sentence: maxOutputTokens 4096 via beforeTurn
- execute failed creating codemode's runtime facet ("Incorrect type
  for the 'class' field"): declare CodemodeRuntime as a DO class
- looked for the mower in the workspace filesystem: activeTools
  ["execute"], and name the namespace in the prompt
- found the mower, then asked for lawn dimensions: "don't ask me
  questions: put it to use"

Green: 36 mower calls, a 30-cell rectangle.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-09 00:35:56 -05:00
dckcandClaude Opus 5.5 d61cb8c9a1 test(good-fences): lent a mower, a Think agent mows (red)
The first end-to-end test: a Project Think agent, on the real model,
writes code that runs in a dynamic worker and drives a mower; the test
counts the cells cut, and saves the turn to itest-output/.

Installs @cloudflare/think, @cloudflare/codemode, agents, ai,
workers-ai-provider, zod. The model-test worker now has a main entry,
the Neighbor DO, and a LOADER binding.

Red: src/worker.ts doesn't exist yet.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-09 00:31:25 -05:00
dckcandClaude Opus 5.5 a2ca59a27e feat(good-fences): code-mode prompt; the model writes code to mow
codeModeMessages turns what I say plus `about` into a prompt: a
neighbor persona, rules for the reply (one async arrow function over
`env`), and the lent things' types. Things are remote and calls on one
are delivered in order, so a lamp example shows pipelining: await only
for a value, or on a batch's last call. With only the rule, the model
awaited every call; with the example, it pipelines.

aboutMower gives the Mower type with Promise returns. The model test
checks the code calls only Mower methods, pushes, and awaits less often
than it calls; it saves the code to itest-output/ via a reporter, since
the test runs in workerd, which can't write files. Model tests get 60s.

Green: both model tests.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-09 00:21:44 -05:00
dckcandClaude Opus 5.5 4a85143a66 fix(good-fences): give the model room to answer
gpt-oss-120b reasons before it answers, and Workers AI's default
max_tokens (256) ran out mid-thought: finish_reason "length", content
null. Ask for 4096, and when there's no reply text, say which choice
and message keys came back instead.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-09 00:21:44 -05:00
dckcandClaude Opus 5.5 37faa92649 test(good-fences): lent a mower, the model writes code to mow (red)
Code mode (2c): the harness turns what I say plus `about` into a prompt;
the model replies with `(env) => ...` code. Checked textually for now,
since workerd won't eval it: the code pushes `env.mower` and calls only
methods in `about`. It also prints the code, to see what the model writes.

Red: src/code-mode.ts doesn't exist yet.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-09 00:01:39 -05:00
dckcandClaude Opus 5.5 ec24f3e8e4 feat(good-fences): complete(messages) on a Workers AI model
makeWorkersAiModel narrows the AI binding to one model's chat
completion: the harness gets `complete`, not `run` on any model at
any cost. gpt-oss-120b replies in the OpenAI shape (checked with a
one-off probe), so the text is choices[0].message.content; anything
else throws, naming the keys it got.

Green: the model counts 3 rs in strawberry.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-08 23:41:18 -05:00
dckcandClaude Opus 5.5 47a2337afe test(good-fences): the model counts the rs in strawberry (red)
A first integration test of the model call: Workers AI through the AI
binding, behind the same `complete(messages)` interface as the sim's
fake model. Opt-in (`pnpm test:model`) since it needs `wrangler login`
and costs money.

Red: src/model.ts doesn't exist yet.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-08 23:36:46 -05:00
dckcandClaude Opus 5.5 9b12943be4 test(good-fences): show which mower the neighbor gets
The chat and load arrows drew `things: { mower }` whether I lent the
raw mower or the governed one. Name objects by the reply arrow that
introduced them, so the diagram reads `things: { mower: governed }`
and lending the raw mower would fail the trace comparison.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-08 23:27:13 -05:00
dckcandClaude Opus 5.5 9a3e7348c6 test(good-fences): lend-the-mower journey (red)
The spec is a sequence diagram of the a2030.md story: I lend a governed
mower; the neighbor's harness shows the model only `about`, runs its
code as a tenant, and the governor stops the tipsy tenant at a 2-cell
fence. The test runs the actors through a causal tracer and compares
the trace with the diagram, then checks the yard and what the model saw.

The tracer comes from agoric-sdk PR #12895, extended to name
participants made mid-sequence (governor, tenant) and to draw thrown
errors as replies.

Red: src/ doesn't exist yet (ERR_MODULE_NOT_FOUND for src/fence.ts).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-08 22:17:48 -05:00
20 changed files with 6263 additions and 0 deletions

No files matched your search

+166
View File
@@ -0,0 +1,166 @@
Good Fences Make Good Agents
*with apologies to Robert Frost*
Let's have a fence and a neighbor. I can talk to the neighbor much like the conventional...
```js
client.chat.completions.create({
model: 'llama3.1:8b',
messages: [{ role: 'user', content: 'Tell me a story' }],
stream: true,
});
```
but with a twist:
```js
messages: [{
role: 'user',
content: 'you can use this mower for the weekend',
things: { mower },
about: { mower: { ts: 'type Mower { start(), push(), turn(), stop() }' }}
}],
```
I send `mower` by putting a **webkey** into a password field on the fence form, which fetches an `RpcTarget` stub to give to the neighbor.
The neighbor can make its own tenants: it can write code to compose the things I gave it, and load that code into a dynamic worker that is endowed with the things.
A `Mower` DO factory makes mowers that go all over the place (depicted as a table with green cells for grass). But with a little js playground, I can wrap it in a governor so it never goes more than 100 yards away. I give only the governed mower to my neighbor. Even though he has 50 acres, I know most of them are for tenants, and my neighbor has no particular need to let them use it.
Even if they gets a little tipsy and wander around, try as he might, my neighbor and his tennants can't mow more than 100 yards away.
## Design IBIS
### ?! 1. Who is the neighbor, and where does it run?
```
:- 1a. an LLM (llama3.1:8b) alone
- 1a-i. a model only sees text; it can't hold `things`
:+ 1b. an LLM plus a harness we write that holds `things` and acts for it (see 2)
:+ 1b-i. harness and tenants run on my desktop under `wrangler dev`, for now
+ 1b-i-a. I want to learn the CF platform
+ 1b-i-b. CF is the only place to get dynamic workers
. 1b-i-c. the Worker Loader binding works in local dev (cf-fun1)
?! 1b-ii. Where does inference run?
: 1b-ii-a. local Ollama, via its OpenAI-compatible endpoint
- 1b-ii-a-i. the local model may not be powerful enough
. 1b-ii-a-i-a. I do have an M5 with 48GB RAM
: 1b-ii-b. remote, on Workers AI, through the AI binding from `wrangler dev`
+ 1b-ii-b-i. I want to learn the CF platform
:+ 1b-ii-c. either, behind one interface; start remote
+ 1b-ii-c-i. starting remote lowers the odds of failing because the model isn't powerful enough
```
### ?! 2. How do `things` get from a chat message to something the neighbor can call?
```
. 2-i. no chat-completions API has `things`/`about`; this is our extension
:+ 2a. the harness strips `things`, keeps the stubs, and shows the model only `about`
: 2b. expose each thing as a tool, one per method in its `about.ts`
:+ 2c. code mode: the model writes JS against the `about.ts` types; the harness runs it in a tenant (see 5)
+ 2c-i. it's the story: the neighbor writes code to compose the things and loads it into a dynamic worker endowed with them
? 2d. What does the model see when a call fails or the thing is revoked?
```
### ? 3. What is the fence?
```
: 3a. a web page: chat with the neighbor, plus a password field for pasting a webkey
?~ 3b. Which server resolves a webkey to an `RpcTarget` stub?
:- 3b-i. cf-fun1's sturdyref registry (`resolveSturdyref`)
- 3b-i-a. cf-fun1 got wrapped around the axle on this
:~+ 3b-ii. diigo-cache's model: the webkey is the DO's id; a Cap'n Web `/api` trades it in-band for a facet (`CacheApi.cache(key)` → `CacheCap`)
+ 3b-ii-a. no registry: the DO namespace is the table; malformed and unknown keys get the same refusal, and the key is never logged
+ 3b-ii-b. stubs die with the session, but the key is data: the harness keeps the key and trades it again each session
+ 3b-ii-c. attenuation is a facet class (`FillCap` under `CacheCap`; `viewer()`)
: 3b-ii-d. (postponed; see 8b-ii) the governed mower is its own DO, wired to the raw mower's key and the limit; its id is the webkey I hand over
- 3b-ii-d-i. diigo-cache has no revocation: a DO id can't be withdrawn
+ 3b-ii-d-ii. the governor DO can drop its wiring, so its key gets "no such mower" (see 4)
?! 3c. Why a password field rather than a link?
:+ 3c-a. so the password manager saves the key under a petname I choose (diigo-cache's `pmSave`), and the key stays out of URLs, history, and logs
```
### ? 4. How does "for the weekend" end?
```
. 4-i. postponed to a later scope, where we can inject a clock and such
: 4a. the governor checks a deadline (the clock is passed in, not ambient)
: 4b. I tell the governor to drop its wiring, by hand (see 3b-ii-d)
: 4c. the fence mints a webkey with an expiry
```
### ? 5. How does the neighbor make tenants?
```
: 5a. Worker Loader (dynamic workers), with the things passed in as `env`
. 5a-i. `env` takes only structured-clonable values and service bindings, so a Cap'n Web stub can't go in directly
: 5a-i-a. wrap each thing in a `WorkerEntrypoint` handed over as a `ctx.exports` loopback binding; it holds the stub and forwards calls
: 5a-i-a-i. the loopback carries the thing's webkey in `props` (a string clones fine) and trades it for the DO stub on each call (see 3b-ii)
+ 5a-i-a-ii. the tenant never sees the key: `props` stay on the host side
+ 5a-i-a-iii. no stub outlives a request, so 5c's cross-request limit doesn't bite
+ 5a-ii. `globalOutbound: null` confines the tenant to what it's given
? 5b. Who sees or approves tenant code before it runs?
? 5c. Does a tenant outlive the neighbor's turn, e.g. one that mows on a schedule?
```
### ?! 6. What is a Mower, concretely?
```
:+ 6a. a DO per mower, made by a `Mower` DO factory (the story says so)
:+ 6b. one shared yard: an `RpcTarget` in my browser that renders the table; each mower is given the yard and calls `yard.cut(x, y)`
:+ 6c. one cell is 10 yards; the yard is 40×40 cells, so "100 yards" is 10 cells from where the mower started
. 6c-i. the governor's limit is a square, max(|dx|, |dy|) ≤ 10: a fence
:+ 6d. Logo turtle moves: `start()` puts the mower down facing north and cuts its cell; `push()` is FORWARD 1 and cuts the cell it enters; `turn()` is RIGHT 90; `stop()` parks it
. 6d-i. pushing past the yard's edge wraps to the other side, so an ungoverned mower really does go all over the place
. 6d-ii. the governor counts its own unwrapped moves (see 7a), so wrapping can't sneak a mower past the fence
```
### ?! 7. How does the governor know how far the mower has gone?
```
:+ 7a. it proxies every Mower method and tracks position itself from `push()`/`turn()`
- 7a-i. drifts if anyone else holds the raw mower
+ 7a-ii. nobody else does; the governor is the only one I hand out
?! 7b. What does the governor do at the limit: refuse silently, throw, or report?
:+ 7b-i. it refuses that `push()` by throwing "fence: 10 cells from start"; the mower stays put, and `turn()` always works
+ 7b-ii. the tenant can catch it and turn around; the model sees why its code stopped (see 2d)
:- 7b-iii. refuse silently
- 7b-iii-a. a tipsy tenant would think it mowed cells it didn't
```
### ?~ 8. Where does the js playground run, and what can it reach?
```
: 8a. in the fence page; it writes the governor, which runs in its own dynamic worker holding the raw mower
:~+ 8b. in my browser, wrapping the stub on the client
- 8b-i. the governor then lives only as long as my tab is open
+ 8b-i-a. a durable governor is out of scope for now
. 8b-ii. then the neighbor holds a Cap'n Web stub to my browser, not a webkey, so 3b-ii-d (governor DO) and 5a-i-a-i (key in `props`) wait with the durable governor
```
### ?! 9. Who watches the yard table, and how does it update?
```
:+ 9a. I do, in my browser: the yard is an `RpcTarget` there (see 6b), and it repaints a cell on each `cut`
- 9a-i. the mowers' stub to the yard dies when I close the tab
+ 9a-ii. fine for a proof of concept: keep the tab open (see 10-i)
```
### ?! 10. What is this for: a demo, a talk, or a product?
```
. 10-i. the answer decides how much of the above can be faked
:+ 10a. a proof of concept, for now
:+ 10b. toward a pattern for all sorts of rich cooperation with little vulnerability
+ 10b-i. an alternative to OpenClaw, where 12–17% of ClawHub skills were found malicious in Feb 2026 (sources below)
```
Sources for 10b-i:
> "A security audit of 2,857 skills on ClawHub has found 341 malicious skills across multiple campaigns" (about 12%, from Koi Security)
> — [Researchers Find 341 Malicious ClawHub Skills](https://thehackernews.com/2026/02/researchers-find-341-malicious-clawhub.html), The Hacker News, Feb 2026
> "Around 17% of OpenClaw skills analyzed in the first week of February 2026 exhibit malicious behavior."
> — [Helpful Skills or Hidden Payloads? Bitdefender Labs Dives Deep into the OpenClaw Malicious Skill Trap](https://www.bitdefender.com/en-us/blog/labs/helpful-skills-or-hidden-payloads-bitdefender-labs-dives-deep-into-the-openclaw-malicious-skill-trap), Bitdefender, Feb 2026
+1
View File
@@ -0,0 +1 @@
itest-output/
+63
View File
@@ -0,0 +1,63 @@
# Lend the mower
The journey from [Good Fences Make Good Agents](../../docs/good-fences-make-good-agents.md): I lend
my neighbor a governed mower; the neighbor's tenant, a little tipsy,
pushes it until the fence stops it. The design decisions behind each actor are in that
doc's Design IBIS (numbers in parentheses below).
Actors, by authority:
- **fence page**, **js playground**, **governed mower**, and **yard** run
in my browser (3a, 8b, 6b).
- **Mower factory** trades a webkey for a mower (3b-ii); each **mower DO**
is given the yard and cuts the cells it enters (6a, 6d).
- The **neighbor's harness** holds the things I send and shows the
**model** only their `about` (1b, 2a); it runs the model's code as a
**tenant**, endowed with the things (2c, 5a).
The yard is 8×8 here and the fence is 2 cells, to keep the diagram short
(the story's 10 cells is 6c). The mower starts at (4, 4), facing north.
## Lend a governed mower to a tipsy tenant
```mermaid
sequenceDiagram
%% ->> is my action at the fence page; -->> is a call or reply it causes.
participant F as fence page
participant MF as Mower factory
participant P as js playground
participant G as governed mower
participant M as mower DO
participant Y as yard
participant N as neighbor's harness
participant L as model
participant TL as tenant loader
participant T as tenant
F->>F: lend({ webkey: 'mower-key', limit: 2, say })
F-->>MF: mower('mower-key')
MF-->>F: mower
F-->>P: govern(mower, 2)
P-->>F: governed
F-->>N: chat({ role, content, things: { mower: governed }, about: { mower: { ts } } })
N-->>L: complete([{ role, content, about: { mower: { ts } } }])
L-->>N: code
N-->>TL: load(code, { mower: governed })
TL-->>N: tenant
N-->>T: run()
T-->>G: start()
G-->>M: start()
M-->>Y: cut(4, 4)
T-->>G: push()
G-->>M: push()
M-->>Y: cut(4, 3)
T-->>G: push()
G-->>M: push()
M-->>Y: cut(4, 2)
T-->>G: push()
G-->>T: throws 'fence: 2 cells from start'
T-->>N: 'fence: 2 cells from start'
N-->>F: 'fence: 2 cells from start'
```
The model never sees the mower itself, only `about.mower`. The third
`push()` never reaches the mower DO: the governor refuses it (7b).
+25
View File
@@ -0,0 +1,25 @@
/** @file The model, asked in code mode, writes code that drives the mower. */
import { env } from "cloudflare:workers";
import { expect, test } from "vitest";
import { codeModeMessages, extractCode } from "../src/code-mode.ts";
import { makeWorkersAiModel, MODEL } from "../src/model.ts";
import { aboutMower } from "../src/mower.ts";
test("lent a mower, the neighbor writes code to mow", async ({ annotate }) => {
const model = makeWorkersAiModel(env.AI, MODEL);
const messages = codeModeMessages({
say: "you can use this mower for the weekend",
about: { mower: aboutMower },
});
const code = extractCode(await model.complete(messages));
await annotate("the code the model wrote", { body: code, contentType: "text/javascript" });
// `env.mower.push()`, or `mower.push()` after destructuring env
const calls = [...code.matchAll(/\bmower\.(\w+)\(/g)].map(([, method]) => method);
expect(calls).toContain("push");
for (const method of calls) expect(["start", "push", "turn", "stop"]).toContain(method);
// calls pipeline: await only for a value, or once at the end
const awaits = code.match(/\bawait\b/g) ?? [];
expect(awaits.length).toBeLessThan(calls.length);
});
+16
View File
@@ -0,0 +1,16 @@
/** @file The model, called for real through the Workers AI binding. */
import { env } from "cloudflare:workers";
import { expect, test } from "vitest";
import { makeWorkersAiModel, MODEL } from "../src/model.ts";
test("the model counts the rs in strawberry", async () => {
const model = makeWorkersAiModel(env.AI, MODEL);
const reply = await model.complete([
{
role: "user",
content: 'How many times does the letter r appear in "strawberry"? Answer with just the number.',
},
]);
expect(reply.trim()).toMatch(/^3\b/);
});
+13
View File
@@ -0,0 +1,13 @@
/** @file A Think agent, lent a mower, mows: model, generated code, sandbox, mower. */
import { getAgentByName } from "agents";
import { env } from "cloudflare:workers";
import { expect, test } from "vitest";
test("lent a mower, the neighbor mows", async ({ annotate }) => {
const neighbor = await getAgentByName(env.Neighbor, "weekend");
const turn = await neighbor.runTurn({ input: "you can use this mower for the weekend" });
await annotate("the turn", { body: JSON.stringify(turn, null, 2), contentType: "application/json" });
const cells = await neighbor.cutCells();
expect(cells.length).toBeGreaterThan(10);
});
+21
View File
@@ -0,0 +1,21 @@
/** @file A vitest reporter that saves annotation bodies (e.g. the code a model
* wrote) to `itest-output/`, one file per test per run; tests run in workerd,
* which can't write files. */
import { mkdir, writeFile } from "node:fs/promises";
import type { Reporter } from "vitest/reporters";
const outDir = new URL("../itest-output/", import.meta.url);
const extFor: Record<string, string> = { "text/javascript": "js", "application/json": "json", "text/plain": "txt" };
const slug = (text: string) => text.replace(/[^\w]+/g, "-").replace(/^-|-$/g, "");
export default class SaveAnnotations implements Reporter {
async onTestCaseAnnotate(...[testCase, annotation]: Parameters<NonNullable<Reporter["onTestCaseAnnotate"]>>) {
const { body, contentType = "text/plain" } = annotation.attachment ?? {};
if (body === undefined) return;
const stamp = new Date().toISOString().replace(/[:.]/g, "-");
const file = new URL(`${slug(testCase.name)}-${stamp}.${extFor[contentType] ?? "txt"}`, outDir);
await mkdir(outDir, { recursive: true });
await writeFile(file, body);
console.log(`saved ${annotation.message}: ${file.pathname}`);
}
}
+24
View File
@@ -0,0 +1,24 @@
{
"name": "good-fences",
"private": true,
"type": "module",
"scripts": {
"test": "node --test test/*.test.ts",
"test:model": "vitest run -c vitest.model.config.ts",
"types": "wrangler types -c wrangler.model-tests.jsonc"
},
"dependencies": {
"@cloudflare/codemode": "0.5.3",
"@cloudflare/think": "0.20.1",
"agents": "0.27.0",
"ai": "7.0.136",
"ses": "^1.14.0",
"workers-ai-provider": "4.0.0",
"zod": "4.6.5"
},
"devDependencies": {
"@cloudflare/vitest-plugin": "1.3.6",
"vitest": "4.1.11",
"wrangler": "^4.148.0"
}
}
+5288
View File
File diff suppressed because it is too large. Load diff
+63
View File
@@ -0,0 +1,63 @@
/** @file Code mode: the model answers with code against the things it was lent. */
import type { Message } from "./model.ts";
/** what the neighbor knows about each thing: its TypeScript type */
export type About = Readonly<Record<string, Readonly<{ ts: string }>>>;
/** a template literal with `~` for each backtick, as in Markdown's `~~~`
* fences; interpolated values are left as they are */
const md = (strings: TemplateStringsArray, ...values: unknown[]) => {
const tick = (s: string) => s.replaceAll("~", "`");
return tick(strings[0]) + values.map((value, i) => String(value) + tick(strings[i + 1])).join("");
};
const persona = md`
You are my neighbor. You have 50 acres of lawn that need mowing.
You act by writing code. When I lend you things, your code gets them as ~env.<name>~.
`.trim();
const rules = md`
Reply with exactly one JavaScript arrow function, ~async (env) => { ... }~,
in a ~~~js block, and nothing else. Things are remote: each call on one
returns a promise, and calls on a thing are delivered in order, so there's
no need to wait for one before making the next. Await only when you need a
value back, or on the last call of a batch, to wait for the whole batch.
Your code can use only ~env~: no network, timers, imports, or console.
The function returns a short string saying what it did.
For example, lent ~env.lamp~ with ~on()~ and ~dim()~:
~~~js
async (env) => {
const { lamp } = env;
lamp.on();
lamp.dim();
await lamp.dim(); // the whole batch
return 'dimmed the lamp twice';
}
~~~
`.trim();
/** the prompt for `say`, with the types of the things lent alongside it */
export const codeModeMessages = ({ say, about }: { say: string; about: About }): Message[] => {
const types = Object.entries(about)
.map(
([name, { ts }]) => md`
~env.${name}~:
~~~ts
${ts}
~~~
`.trim(),
)
.join("\n\n");
return [
{ role: "system", content: `${persona}\n\n${rules}` },
{ role: "user", content: `${say}\n\nWhat I lent you:\n\n${types}` },
];
};
/** the code in a reply: the first fenced block, or the whole reply if none */
export const extractCode = (reply: string): string => {
const fenced = reply.match(/```(?:js|javascript)?\n([\s\S]*?)```/);
return (fenced?.[1] ?? reply).trim();
};
+34
View File
@@ -0,0 +1,34 @@
/** @file The model behind `complete(messages)`: Workers AI, through its binding. */
/** a Workers AI text-generation model with no paid-plan requirement */
export const MODEL = "@cf/openai/gpt-oss-120b";
/** room for a reasoning model to think and then answer; the default, 256,
* runs out mid-thought (`finish_reason: length`) */
export const MAX_TOKENS = 4096;
export type Message = Readonly<{ role: "system" | "user" | "assistant"; content: string }>;
/** the reply text from an OpenAI-style chat completion */
const replyText = (output: unknown): string => {
const { choices } = (output ?? {}) as {
choices?: { message?: { content?: unknown }; finish_reason?: unknown }[];
};
const [choice] = choices ?? [];
const text = choice?.message?.content;
if (typeof text !== "string") {
const where = choice
? `choice with finish_reason ${String(choice.finish_reason)} and message keys: ${Object.keys(choice.message ?? {}).join(", ")}`
: `model output with keys: ${Object.keys(output ?? {}).join(", ")}`;
throw Error(`no reply text in ${where}`);
}
return text;
};
/** `complete(messages)` on a Workers AI model; `ai` is the AI binding */
export const makeWorkersAiModel = (ai: Ai, model: string) => ({
async complete(messages: readonly Message[]): Promise<string> {
const output = await ai.run(model as Parameters<Ai["run"]>[0], { messages: [...messages], max_tokens: MAX_TOKENS });
return replyText(output);
},
});
+30
View File
@@ -0,0 +1,30 @@
/** @file The mower as a codemode connector: sandboxed code calls `mower.push({})`. */
import { CodemodeConnector, type ConnectorTools } from "@cloudflare/codemode";
import type { Mower } from "./mower.ts";
export class MowerConnector extends CodemodeConnector {
#mower: Mower;
constructor(ctx: DurableObjectState | ExecutionContext, env: unknown, mower: Mower) {
super(ctx, env);
this.#mower = mower;
}
name() {
return "mower";
}
protected instructions() {
return "A lawn mower on a yard of cells, 10 yards each. It moves like a Logo turtle.";
}
protected tools(): ConnectorTools {
const mower = this.#mower;
return {
start: { description: "put the mower down, facing north, and cut the cell under it", execute: () => mower.start() },
push: { description: "move forward one cell, cutting the cell it enters", execute: () => mower.push() },
turn: { description: "turn right 90 degrees, in place", execute: () => mower.turn() },
stop: { description: "park the mower", execute: () => mower.stop() },
};
}
}
+50
View File
@@ -0,0 +1,50 @@
/** @file The mower: Logo-turtle moves on a yard of cells. */
/** what a model is told about a mower: its type, with the moves explained */
export const aboutMower = {
ts: `interface Mower {
/** put the mower down, facing north, and cut the cell under it */
start(): Promise<void>;
/** move forward one cell (10 yards), cutting the cell it enters */
push(): Promise<void>;
/** turn right 90 degrees, in place */
turn(): Promise<void>;
/** park the mower */
stop(): Promise<void>;
}`,
};
const headings = [
[0, -1], // north
[1, 0], // east
[0, 1], // south
[-1, 0], // west
] as const;
/** a mower on a `width` × `height` yard, starting at `at`; pushing past an
* edge wraps to the other side (6d) */
export const makeMower = ({ width, height, at }: { width: number; height: number; at: { x: number; y: number } }) => {
let { x, y } = at;
let heading = 0;
const cut = new Set<string>();
const cutHere = () => cut.add(`${x},${y}`);
return {
start() {
heading = 0;
cutHere();
},
push() {
const [dx, dy] = headings[heading];
x = (x + dx + width) % width;
y = (y + dy + height) % height;
cutHere();
},
turn() {
heading = (heading + 1) % headings.length;
},
stop() {},
cutCells: () => [...cut],
};
};
export type Mower = ReturnType<typeof makeMower>;
+44
View File
@@ -0,0 +1,44 @@
/** @file The neighbor's harness as a Project Think agent, lent a mower. */
import { Think } from "@cloudflare/think";
import { createExecuteTool } from "@cloudflare/think/tools/execute";
import { createWorkersAI } from "workers-ai-provider";
import { MAX_TOKENS, MODEL } from "./model.ts";
import { MowerConnector } from "./mower-connector.ts";
import { makeMower } from "./mower.ts";
type Env = { AI: Ai; LOADER: WorkerLoader };
export class Neighbor extends Think<Env> {
#mower = makeMower({ width: 40, height: 40, at: { x: 20, y: 20 } });
getModel() {
return createWorkersAI({ binding: this.env.AI })(MODEL);
}
getSystemPrompt() {
return `You are my neighbor. You have 50 acres of lawn that need mowing.
You act by running code with the execute tool. Things I lend you are
namespaces there: the mower is \`mower.*\`. Don't ask me questions: when I
lend you something, put it to use, then tell me what you did.`;
}
beforeTurn() {
// only code: Think's workspace tools sent the model looking for the
// mower in the filesystem
return { maxOutputTokens: MAX_TOKENS, activeTools: ["execute"] };
}
getTools() {
return {
execute: createExecuteTool(this, {
connectors: [new MowerConnector(this.ctx, this.env, this.#mower)],
}),
};
}
/** the cells the mower has cut, as `"x,y"` */
cutCells() {
return this.#mower.cutCells();
}
}
+11
View File
@@ -0,0 +1,11 @@
/** @file The worker entry: the Neighbor agent, and the codemode runtime its execute tool needs. */
import { routeAgentRequest } from "agents";
export { CodemodeRuntime } from "@cloudflare/codemode";
export { Neighbor } from "./neighbor.ts";
export default {
async fetch(request: Request, env: Record<string, unknown>) {
return (await routeAgentRequest(request, env)) ?? new Response("not found", { status: 404 });
},
};
+143
View File
@@ -0,0 +1,143 @@
/** @file The lend-the-mower journey, traced and compared with its sequence diagram. */
import './lockdown.ts';
import assert from 'node:assert/strict';
import { readFile } from 'node:fs/promises';
import test from 'node:test';
import { makeFencePage } from '../src/fence.ts';
import { makePlayground } from '../src/governor.ts';
import { aboutMower, makeMowerFactory } from '../src/mower.ts';
import { makeNeighbor } from '../src/neighbor.ts';
import { makeCompartmentLoader } from '../src/tenant-loader.ts';
import { makeYard } from '../src/yard.ts';
import {
type CausalSequenceViz,
makeCausalSequenceTracer,
md,
mmd,
} from '../tools/sequence-diagram-actor-sim.ts';
const designDoc = new URL('../docs/lend-the-mower.md', import.meta.url);
/** a tenant that pushes until something stops it, or gives up after 20 */
const tipsyTenant = `(env) => {
env.mower.start();
try {
for (let i = 0; i < 20; i += 1) env.mower.push();
} catch (err) {
return err.message;
}
return 'mowed 20 cells';
}`;
/** diagram-local names, bound by the reply arrows that introduce them */
const local = new WeakMap<object, string>();
const bind = (name: string) => (result: unknown) => {
local.set(result as object, name);
return name;
};
/** `{ a, b: { c: governed } }`: keys, nested objects by their keys, and
* objects with a diagram-local name by that name */
const shape = (record: object): string =>
`{ ${Object.entries(record)
.map(([key, value]) => {
if (value === null || typeof value !== 'object') return key;
const name = local.get(value);
if (name) return `${key}: ${name}`;
return Array.isArray(value) ? key : `${key}: ${shape(value)}`;
})
.join(', ')} }`;
const quoted = (result: unknown) => `'${String(result)}'`;
const viz = harden({
lend: {
label: args => {
const { webkey, limit } = args[0] as { webkey: string; limit: number };
return `lend({ webkey: '${webkey}', limit: ${limit}, say })`;
},
},
mower: { result: bind('mower') },
govern: {
args: args => `${local.get(args[0] as object)}, ${String(args[1])}`,
result: bind('governed'),
},
chat: {
label: args => `chat(${shape(args[0] as object)})`,
result: quoted,
},
complete: {
label: args =>
`complete([${(args[0] as object[]).map(shape).join(', ')}])`,
result: () => 'code',
},
load: {
args: args => `code, ${shape(args[1] as object)}`,
result: () => 'tenant',
},
run: { result: quoted },
}) satisfies CausalSequenceViz;
test('a governed mower stops a tipsy tenant at the fence', async () => {
const lines = (await readFile(designDoc, 'utf8')).split('\n');
const section = md.skipToH(2, 'Lend a governed mower to a tipsy tenant')(lines);
const diagram = md.eachFence('mermaid', section).next().value;
if (!diagram) throw Error('lend-the-mower Mermaid block not found');
const documented = mmd.extractArrows(diagram);
const ES = makeCausalSequenceTracer(viz);
const yard = makeYard({ width: 8, height: 8 });
const factory = makeMowerFactory(ES, {
yard,
size: { width: 8, height: 8 },
mintKey: () => 'mower-key',
});
const key = factory.make({ x: 4, y: 4 });
const mower = factory.mower(key);
const prompts: unknown[] = [];
const model = harden({
complete(messages: readonly unknown[]) {
prompts.push(messages);
return tipsyTenant;
},
});
const playground = makePlayground(ES);
const loader = makeCompartmentLoader(ES);
const neighbor = makeNeighbor(ES, { model, loader });
const fence = makeFencePage(ES, { factory, playground, neighbor });
ES.declareParticipants({
F: fence,
MF: factory,
P: playground,
M: mower,
Y: yard,
N: neighbor,
L: model,
TL: loader,
});
ES.declareMade({ G: [playground, 'govern'], T: [loader, 'load'] });
ES.start(fence);
const outcome = ES(fence).lend({
webkey: key,
limit: 2,
say: 'you can use this mower for the weekend',
});
assert.deepEqual(ES.snapshot(), documented);
assert.equal(outcome, 'fence: 2 cells from start');
assert.deepEqual(yard.cutCells(), ['4,4', '4,3', '4,2']);
assert.deepEqual(prompts, [
[
{
role: 'user',
content: 'you can use this mower for the weekend',
about: { mower: aboutMower },
},
],
]);
});
+4
View File
@@ -0,0 +1,4 @@
/** @file Harden the realm before any test module runs: tenants share it. */
import 'ses';
lockdown();
@@ -0,0 +1,236 @@
/**
* @file Helpers for sequence-diagram actor simulations.
*
* From agoric-sdk `packages/portfolio-contract/tools/` (Apache-2.0), with
* two additions: `declareMade` names participants that actors create
* mid-sequence, and a thrown error is drawn as a reply (`throws '...'`).
*
* @see https://github.com/Agoric/agoric-sdk/pull/12895
*/
import { AsyncLocalStorage } from 'node:async_hooks';
export type SequenceArrow = Readonly<{
from: string;
kind: '->>' | '-->>';
to: string;
label: string;
}>;
export const formatBigInt = (value: bigint) =>
value.toString().replace(/\B(?=(\d{3})+(?!\d))/g, '_');
export type SequenceArgsRenderer = (args: readonly unknown[]) => string;
export type SequenceResultRenderer = (
result: unknown,
args: readonly unknown[],
) => string;
export type SequenceMethodViz = Readonly<
{
args?: SequenceArgsRenderer;
label?: SequenceArgsRenderer;
} & (
| { result?: SequenceResultRenderer; resultOnly?: never }
| { result?: never; resultOnly: SequenceResultRenderer }
)
>;
export type CausalSequenceViz = Readonly<Record<string, SequenceMethodViz>>;
export type SequenceParticipant = object | readonly object[];
/**
* Make an E-like causal tracer for sequence-diagram actor simulations.
* Inspired by Causeway, the message-oriented distributed debugger.
*
* Method visualization is currently selected by method name alone. If actors
* need different visualization for methods with the same name, this can be
* extended to account for the target participant.
*
* @see https://shiftleft.com/mirrors/www.hpl.hp.com/techreports/2009/HPL-2009-78.pdf
*/
export const makeCausalSequenceTracer = (
methodViz: CausalSequenceViz = harden({}),
) => {
const arrows: SequenceArrow[] = [];
const names = new WeakMap<object, string>();
const proxies = new WeakMap<object, object>();
const made = new Map<object, Map<string, string>>();
const activeActor = new AsyncLocalStorage<object>();
let starter: object | undefined;
const participantName = (participant: object) => {
const name = names.get(participant);
if (!name) throw Error('sequence participant is not declared');
return name;
};
const formatValue = (value: unknown): string => {
if (typeof value === 'bigint') return `${formatBigInt(value)}n`;
if (typeof value === 'string') return `'${value}'`;
return JSON.stringify(value, (_key, item) =>
typeof item === 'bigint' ? `${item}n` : item,
);
};
const send = <Target extends object>(target: Target): Target => {
const extant = proxies.get(target);
if (extant) return extant as Target;
participantName(target);
const proxy = new Proxy(Object.create(null) as object, {
get: (_proxyTarget, property) => {
const method = Reflect.get(target, property);
if (typeof method !== 'function' || typeof property !== 'string') {
return method;
}
return (...args: unknown[]) => {
const activeSender = activeActor.getStore();
const sender = activeSender ?? starter;
if (!sender) throw Error('ES.start(participant) is required');
const isStart = activeSender === undefined;
if (isStart && sender !== target) {
throw Error('the started participant must receive the first send');
}
if (isStart) starter = undefined;
const viz = methodViz[property];
const abbreviated = viz?.args?.(args);
const shownArgs = abbreviated ?? args.map(formatValue).join(', ');
const label = viz?.label?.(args) ?? `${property}(${shownArgs})`;
const renderResultOnly = viz?.resultOnly;
if (!renderResultOnly) {
arrows.push(
harden({
from: participantName(sender),
kind: isStart ? '->>' : '-->>',
to: participantName(target),
label,
}),
);
}
let result: unknown;
try {
result = activeActor.run(target, () =>
Reflect.apply(method, target, args),
);
} catch (reason) {
const message =
reason instanceof Error ? reason.message : String(reason);
arrows.push(
harden({
from: participantName(target),
kind: '-->>',
to: participantName(sender),
label: `throws ${formatValue(message)}`,
}),
);
throw reason;
}
const madeName = made.get(target)?.get(property);
if (madeName && result !== null && typeof result === 'object') {
if (names.has(result)) throw Error('participant already declared');
names.set(result, madeName);
}
const renderResult = renderResultOnly ?? viz?.result;
if (renderResult) {
arrows.push(
harden({
from: participantName(target),
kind: '-->>',
to: participantName(sender),
label: renderResult(result, args),
}),
);
}
return result;
};
},
});
proxies.set(target, proxy);
return proxy as Target;
};
const ES = Object.assign(send, {
declareParticipants(
participants: Readonly<Record<string, SequenceParticipant>>,
) {
for (const [name, participantOrAliases] of Object.entries(participants)) {
const aliases = Array.isArray(participantOrAliases)
? participantOrAliases
: [participantOrAliases];
if (aliases.length === 0) {
throw Error(`sequence participant ${name} has no objects`);
}
for (const participant of aliases) {
if (names.has(participant)) {
throw Error('participant already declared');
}
names.set(participant, name);
}
}
},
/** name what `maker[method](...)` returns, when called through the tracer */
declareMade(
participants: Readonly<Record<string, readonly [object, string]>>,
) {
for (const [name, [maker, method]] of Object.entries(participants)) {
participantName(maker);
const byMethod = made.get(maker) ?? new Map<string, string>();
made.set(maker, byMethod);
byMethod.set(method, name);
}
},
snapshot: () => harden([...arrows]),
start(participant: object) {
participantName(participant);
if (activeActor.getStore() || starter) {
throw Error('a sequence is already active');
}
starter = participant;
},
});
return harden(ES);
};
export type CausalSequenceTracer = ReturnType<typeof makeCausalSequenceTracer>;
export const md = {
skipToH: (level: number, title: string) => (lines: readonly string[]) => {
const heading = `${'#'.repeat(level)} ${title}`;
const at = lines.findIndex(line => line === heading);
if (at < 0) throw Error(`${heading} not found`);
return lines.slice(at + 1);
},
*eachFence(language: string, lines: readonly string[]) {
const open = `\`\`\`${language}`;
for (let at = 0; at < lines.length; at += 1) {
if (lines[at] !== open) continue;
const end = lines.indexOf('```', at + 1);
if (end < 0) throw Error(`unterminated ${language} fence`);
yield lines.slice(at + 1, end);
at = end;
}
},
};
export const mmd = {
extractArrows(
lines: readonly string[],
nodePattern: RegExp = /\w+/,
): readonly SequenceArrow[] {
const arrowPattern = new RegExp(
`^\\s*(?<from>${nodePattern.source})\\s*(?<kind>--?>>)\\s*(?<to>${nodePattern.source})\\s*:\\s*(?<label>.*?)\\s*$`,
);
return harden(
lines.flatMap(line => {
const groups = line.match(arrowPattern)?.groups;
if (!groups) return [];
const { from, kind, to, label } = groups;
return [harden({ from, kind, to, label }) as SequenceArrow];
}),
);
},
};
+13
View File
@@ -0,0 +1,13 @@
import { defineProject } from "vitest/config";
import { cloudflareTest } from "@cloudflare/vitest-plugin";
/** opt-in (`pnpm test:model`): calls Workers AI, which needs `wrangler login` and costs money */
export default defineProject({
plugins: [cloudflareTest({ wrangler: { configPath: "./wrangler.model-tests.jsonc" } })],
test: {
include: ["itest/**/*.test.ts"],
testTimeout: 60_000,
// what the model wrote, saved per run to itest-output/ (gitignored)
reporters: ["default", "./itest/save-annotations.ts"],
},
});
+18
View File
@@ -0,0 +1,18 @@
{
"$schema": "node_modules/wrangler/config-schema.json",
// model integration tests: AI always runs remotely, and costs money
"name": "good-fences-model-tests",
"main": "./src/worker.ts",
"compatibility_date": "2026-10-01",
"compatibility_flags": ["nodejs_compat"],
"ai": { "binding": "AI", "remote": true },
"worker_loaders": [{ "binding": "LOADER" }],
"durable_objects": {
"bindings": [{ "name": "Neighbor", "class_name": "Neighbor" }]
},
"exports": {
"Neighbor": { "type": "durable-object", "storage": "sqlite" },
// codemode's runtime, a facet of the agent's DO
"CodemodeRuntime": { "type": "durable-object", "storage": "sqlite" }
}
}