Compare commits
10
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
efb54ad4cc | ||
|
|
8c110098ff | ||
|
|
d61cb8c9a1 | ||
|
|
a2ca59a27e | ||
|
|
4a85143a66 | ||
|
|
37faa92649 | ||
|
|
ec24f3e8e4 | ||
|
|
47a2337afe | ||
|
|
9b12943be4 | ||
|
|
9a3e7348c6 |
No files matched your search
@@ -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
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
itest-output/
|
||||||
@@ -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).
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
@@ -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/);
|
||||||
|
});
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
@@ -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}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
Generated
+5288
File diff suppressed because it is too large.
Load diff
@@ -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();
|
||||||
|
};
|
||||||
@@ -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);
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -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() },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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>;
|
||||||
@@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 });
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -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 },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
});
|
||||||
@@ -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];
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -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"],
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -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" }
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in new issue
Block a user