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
152 changes: 147 additions & 5 deletions bun.lock

Large diffs are not rendered by default.

34 changes: 28 additions & 6 deletions examples/alchemy-cloudflare/README.md
Original file line number Diff line number Diff line change
@@ -1,14 +1,19 @@
# alchemy-cloudflare

A minimal fold chat host on Cloudflare, deployed with [Alchemy v2](https://alchemy.run/cloudflare/). Inference runs on OpenAI (`gpt-5.6-terra`, medium reasoning) through fold's `openaiModel`. Each fold session is one Durable Object, named by its `SessionId`.
A minimal fold chat host on Cloudflare, deployed with [Alchemy v2](https://alchemy.run/cloudflare/). Inference runs on OpenAI (`gpt-5.6-terra`, medium reasoning) through fold's `openaiModel`. Each fold session is one Durable Object, named by its `SessionId`, with a workspace of cloned repos in a second one of the same name.

- `src/ChatSession.ts` — the Durable Object. It opens the fold log in its SQLite on activation; the first message starts the session, later activations resume it.
- `src/ChatSession.ts` — the Durable Object. It opens the fold log in its SQLite on activation; the first message clones its repos and starts the session, later activations resume it.
- `src/DurableObjectEventLog.ts` — the service that opens fold's `EventLogService` over the object's SQLite.
- `src/Keepalive.ts` — a 30s alarm heartbeat that keeps the object alive while a turn runs.
- `src/SessionExpiry.ts` — deletes an idle session and its workspace; see below.
- `src/ChatSessions.ts` — the service the routes require; its `layer` reaches each session's Durable Object.
- `src/Workspace.ts` — the service ChatSession clones repos and reaches files through; its `layer` reaches the session's Computer.
- `src/BashTool.ts` — the agent's `bash` tool, running commands in the workspace's shell.
- `src/WorkspaceFileSystem.ts` — Effect's `FileSystem` over the Computer's file methods, so fold-agent's file tools and skill loader run on the workspace unchanged.
- `src/computer/` — the Computer Worker: a plain Durable Object holding a [`@cloudflare/computer`](https://github.com/cloudflare/computer) workspace (a filesystem in its SQLite, with git and a shell) per session. The shell is just-bash in a Worker that a Worker Loader starts, reading and writing the same filesystem. A separate Worker, because `@cloudflare/computer` needs the plain `cloudflare:workers` class, and an Effect Worker's bundle keeps only its own exports.
- `src/Api.ts` — the `HttpRouter` routes, encoding responses with fold's log schemas.
- `src/Worker.ts` — serves the routes, with `ChatSessions` as RPC to the Durable Objects.
- `alchemy.run.ts` — the stack.
- `alchemy.run.ts` — the stack: the Computer Worker, then the chat Worker, which binds it by script name.

## Run

Expand All @@ -20,16 +25,33 @@ bun run deploy # prints the Worker url
```

```sh
ID=sess_... # any new SessionId; the first message starts that session
curl -X POST "$URL/sessions/$ID/messages" -d '{"text":"hi"}' # the agent-finished entry
ID=sess_... # any new SessionId; the first message clones its repos and starts that session
curl -X POST "$URL/sessions/$ID/messages" -d '{"text":"hi","repos":[{"url":"https://github.com/humanlayer/fold"}]}' # the agent-finished entry
curl -X POST "$URL/sessions/$ID/messages" -d '{"text":"hi"}' # later messages' repos are ignored
curl -X POST "$URL/sessions/$ID/messages" -d '{"text":"stop","whenRunning":"interrupt"}' # queue (default) | steer | interrupt
curl "$URL/sessions/$ID" # all log entries
curl "$URL/sessions/$ID/log" # the fold event log
```

A session with no messages for 14 days (`IDLE_TIME` in `src/SessionExpiry.ts`) deletes its workspace and its log; its id then starts a new session. The workspace also has its own deadline a day later, so it gets deleted even if its session never does it.

`bun alchemy tail` streams both Workers' logs. Each clone logs `workspace.prepare`, each file call from the agent's tools logs `workspace.file` with its method, path and result, and each shell command logs `workspace.exec` with its exit code.

Each repo is `{ "url": "https://...", "name"?: string, "ref"?: string }`: it clones into `/workspace/<name>` (`name` defaults to the URL's last path segment) at `ref` (the default branch when absent), shallow. A repo that fails to clone makes the first message a 422 and starts nothing; the next message clones again from scratch. The log's `session_started` entry records each repo's commit.

The agent gets fold-agent's file tools on the workspace: `read`, plus `write` and `edit` for Claude models or `apply_patch` for GPT models (fold picks by model). Relative paths resolve against `/workspace`. It also gets `bash`, which runs in a lightweight shell: the common text commands (`ls`, `cat`, `grep`, `find`, `sed`, `awk`, `jq`, ...), pipes and redirects, and git. The shell and the file tools see the same files. Skills load from each repo's `.claude/skills` and `.agents/skills` when the session opens. The system prompt lists the repos.

`bun run test` checks the routes against an in-memory `ChatSessions` built on fold's in-memory log.

## Limits

- A turn cut off by a crash or deploy continues when the object next wakes, through a synthetic "continue" user message (at most 3 per cut-off turn). A subagent cut off with it is left for the root model to resume by id.

- The shell is not a full Linux machine: no package managers, no node or python, no compilers, and no network. It can't install, build, or run tests.

- The file tools read and write the workspace, not the repos' remotes: changes stay in the session's workspace. `read` can't process images in a Worker (its image library needs Node), so an image comes back as a short note, not the picture.

- The Computer Worker's script name is fixed (`src/computer/Contract.ts`), so an account holds one deploy of this stack.

- Clones go over HTTPS, public repos only. They run in the Durable Object through `isomorphic-git`, which fetches every file at the tip, so large repos clone slowly.

- Replies arrive whole. `FoldSession.events` can stream deltas once a route forwards them.
3 changes: 3 additions & 0 deletions examples/alchemy-cloudflare/alchemy.run.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,12 +2,15 @@ import * as Alchemy from 'alchemy'
import * as Cloudflare from 'alchemy/Cloudflare'
import { Effect } from 'effect'

import { ComputerWorker } from './src/computer/ComputerWorker'
import Worker from './src/Worker'

export default Alchemy.Stack(
'FoldAlchemyCloudflare',
{ providers: Cloudflare.providers(), state: Cloudflare.state() },
Effect.gen(function* () {
// First: ChatWorker binds the Computer Durable Object by this Worker's script name.
yield* ComputerWorker
const worker = yield* Worker
return { url: worker.url }
}),
Expand Down
4 changes: 4 additions & 0 deletions examples/alchemy-cloudflare/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -13,13 +13,17 @@
"test:watch": "bun vitest"
},
"dependencies": {
"@cloudflare/computer": "catalog:",
"@humanlayer/fold-agent": "workspace:*",
"@humanlayer/fold-core": "workspace:*",
"@platformatic/vfs": "catalog:",
"@effect/platform-bun": "catalog:",
"@effect/platform-node": "catalog:",
"alchemy": "catalog:",
"effect": "catalog:"
},
"devDependencies": {
"@cloudflare/workers-types": "catalog:",
"@effect/vitest": "catalog:",
"@humanlayer/fold-vitest-config": "workspace:*",
"typescript": "catalog:",
Expand Down
27 changes: 15 additions & 12 deletions examples/alchemy-cloudflare/src/Api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,21 +2,19 @@
* The chat HTTP routes over {@link ChatSessions}, encoding responses with fold's log schemas.
*
* - `POST /sessions/:sessionId/messages` with `{ "text": string, "whenRunning"?: "queue" | "steer" |
* "interrupt" }` delivers one user message, starting the session if the id is new, and returns the
* `agent-finished` entry of the run that consumed it. `whenRunning` defaults to `queue`.
* - `GET /sessions/:sessionId` returns the session's log entries.
* "interrupt", "repos"?: [{ "url": string, "name"?: string, "ref"?: string }] }` delivers one user
* message and returns the `agent-finished` entry of the run that consumed it. `whenRunning` defaults to
* `queue`. A message to a new id clones its `repos` into `/workspace/<name>` and starts the session;
* if a repo fails to clone the response is a 422 and nothing starts.
* - `GET /sessions/:sessionId/log` returns the session's fold event log.
*/
import { AgentFinishedLogEntry, LogEntry, SessionId } from '@humanlayer/fold-core'
import { Effect, Schema } from 'effect'
import { HttpRouter, HttpServerRequest, HttpServerResponse } from 'effect/unstable/http'

import { ChatSessions, WhenRunning } from './ChatSessions'
import { ChatSessions, Message } from './ChatSessions'

const SessionParams = Schema.Struct({ sessionId: SessionId })
const MessageBody = Schema.Struct({
text: Schema.String,
whenRunning: WhenRunning.pipe(Schema.withDecodingDefaultKey(Effect.succeed('queue'))),
})
const entriesJson = HttpServerResponse.schemaJson(Schema.Array(LogEntry))
const finishedJson = HttpServerResponse.schemaJson(AgentFinishedLogEntry)

Expand All @@ -28,7 +26,7 @@ export const ChatRoutes = HttpRouter.use((router) =>

yield* router.add(
'GET',
'/sessions/:sessionId',
'/sessions/:sessionId/log',
Effect.gen(function* () {
const { sessionId } = yield* HttpRouter.schemaPathParams(SessionParams)
return yield* entriesJson(yield* sessions.entries(sessionId))
Expand All @@ -40,9 +38,14 @@ export const ChatRoutes = HttpRouter.use((router) =>
'/sessions/:sessionId/messages',
Effect.gen(function* () {
const { sessionId } = yield* HttpRouter.schemaPathParams(SessionParams)
const { text, whenRunning } = yield* HttpServerRequest.schemaBodyJson(MessageBody)
return yield* finishedJson(yield* sessions.send(sessionId, text, whenRunning))
}).pipe(Effect.catchTag('SchemaError', badRequest)),
const message = yield* HttpServerRequest.schemaBodyJson(Message)
return yield* finishedJson(yield* sessions.send(sessionId, message))
}).pipe(
Effect.catchTags({
SchemaError: badRequest,
RepoCloneError: ({ message }) => Effect.succeed(HttpServerResponse.text(message, { status: 422 })),
}),
),
)
}),
)
114 changes: 114 additions & 0 deletions examples/alchemy-cloudflare/src/BashTool.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,114 @@
/**
* The agent's `bash` tool, running commands on the session's workspace in one of two places: the shell
* (just-bash, fast, with the common text commands and git) or the container (Linux, with package managers
* and language runtimes, slower to start). fold-agent's bash tool starts real processes, which a Worker
* cannot; this one keeps its parameters and its output handling - stdout and stderr trimmed to the last
* 2000 lines or 50KB, and a failure carrying the output when the command fails - and adds `backend`.
*/
import {
defaultMaxBytes,
defaultMaxLines,
defineTool,
formatSize,
ToolResultFailure,
ToolResultText,
truncateTail,
type FoldTool,
} from '@humanlayer/fold-core'
import { Effect, Schema } from 'effect'

import { type CommandInput, type CommandOutput, WORKSPACE_ROOT } from './computer/Contract'
import type { ShellError } from './Workspace'

const DEFAULT_TIMEOUT_MS = 120_000
const MAX_TIMEOUT_MS = 600_000

const BashParameters = Schema.Struct({
command: Schema.String.annotate({ description: 'Bash command to execute' }),
backend: Schema.optionalKey(Schema.Literals(['shell', 'container'])).annotate({
description:
'Where to run the command: "shell" (default; fast, text commands and git only) or "container" ' +
'(full Linux: installs, builds, tests)',
}),
timeout_ms: Schema.optionalKey(Schema.Number).annotate({
description: `Timeout in milliseconds (default ${DEFAULT_TIMEOUT_MS}, maximum ${MAX_TIMEOUT_MS})`,
}),
workdir: Schema.optionalKey(Schema.String).annotate({
description: `Working directory for the command, absolute or relative to ${WORKSPACE_ROOT}. Use this instead of cd.`,
}),
description: Schema.optionalKey(Schema.String).annotate({
description: 'Short (5-10 word) description of what this command does',
}),
})

const DESCRIPTION =
`Run a bash command in the session's workspace, where the repo(s) are cloned under ${WORKSPACE_ROOT}. ` +
'Returns stdout then stderr, keeping the last ' +
`${defaultMaxLines} lines or ${formatSize(defaultMaxBytes)}. Commands start in ${WORKSPACE_ROOT} unless ` +
'you pass workdir.\n\n' +
'Commands run in one of two places, which see the same files:\n' +
'- backend "shell" (the default): a lightweight shell (just-bash), not a full Linux machine. It has the ' +
'common text commands - ls, cat, head, tail, grep, find, sed, awk, sort, uniq, wc, diff, cut, tr, xargs, ' +
'jq and more - pipes, redirects, and git (clone, status, diff, log, add, commit, branch, checkout). It has ' +
'no package managers, no node or python, no compilers, and no network access. It answers at once. Use it ' +
'to explore and search the repos: list directories, grep for code, and inspect git history.\n' +
'- backend "container": a Debian Linux container where commands run as root in a bash login shell, with ' +
'internet access, git, node 22 with npm, bun, ' +
'python3, ripgrep, fd and tmux; install anything else with apt-get or a package manager. Use it to install ' +
'dependencies, build, and run tests or scripts. It can take up to 30 seconds to start if it was stopped. ' +
'Programs and files outside the workspace last only until it stops; files under the workspace are kept, ' +
"except those a repo's .gitignore lists (node_modules, build output, caches): they stay in the container " +
'only, so the shell and the file tools cannot see them, and they are gone once the container stops, so ' +
'reinstall or rebuild after a restart. Look at them with container commands. A gitignored file you made ' +
'with the file tools, such as .env, does not receive changes made in the container; change it with the ' +
'file tools.\n\n' +
'Try the shell first, and switch to the container when a command is not found or needs a real machine.'

/** The command's output as the model sees it: stdout, then stderr, trimmed from the front. */
const formatOutput = ({ stdout, stderr }: CommandOutput) => {
const text = stdout.length > 0 && stderr.length > 0 ? `${stdout.replace(/\n?$/, '\n')}${stderr}` : stdout + stderr
const truncation = truncateTail(text)
if (!truncation.truncated) return text
return `[Showing the last ${truncation.outputLines} of ${truncation.totalLines} lines]\n${truncation.content}`
}

/** Build the bash tool over one session's shell. */
export const bashTool = (run: (input: CommandInput) => Effect.Effect<CommandOutput, ShellError>): FoldTool =>
defineTool({
name: 'bash',
description: DESCRIPTION,
parameters: BashParameters,
success: ToolResultText,
failure: ToolResultFailure,
handler: (params) =>
Effect.gen(function* () {
const timeoutMs = params.timeout_ms ?? DEFAULT_TIMEOUT_MS
if (!Number.isFinite(timeoutMs) || timeoutMs <= 0 || timeoutMs > MAX_TIMEOUT_MS) {
return yield* Effect.fail(
`Invalid timeout_ms: must be between 1 and ${MAX_TIMEOUT_MS} milliseconds`,
)
}
const cwd =
params.workdir === undefined
? WORKSPACE_ROOT
: params.workdir.startsWith('/')
? params.workdir
: `${WORKSPACE_ROOT}/${params.workdir}`

const output = yield* run({
backend: params.backend ?? 'shell',
command: params.command,
cwd,
timeoutMs,
}).pipe(Effect.mapError((error) => `The shell could not run the command: ${error.message}`))
const text = formatOutput(output).replace(/\n+$/, '')

if (output.status === 'cancelled') {
return yield* Effect.fail(`${text}\n\nCommand timed out after ${timeoutMs} milliseconds`)
}
if (output.exitCode !== 0) {
return yield* Effect.fail(`${text}\n\nCommand exited with code ${output.exitCode}`)
}
return ToolResultText.make({ text: text.length === 0 ? '(no output)' : text })
}).pipe(Effect.mapError((text) => ToolResultFailure.make({ text }))),
})
Loading
Loading