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
14 changes: 8 additions & 6 deletions .claude/skills/effect-program-design/REFERENCE.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,20 +17,19 @@ conventions from another repository.

Fold has two intentionally different interfaces:

1. A host-facing descriptor interface, where consumers describe an agent, model, toolset, hooks, and event-log
backend without learning Fold's internal runtime graph.
1. A host-facing descriptor interface for agents, models, tools, and hooks. Hosts select EventLog backends
through ordinary Layer composition; Session.open hides the internal runtime graph.
2. An internal Effect interface, where `Context.Service` tags and `Layer` implementations express capabilities,
resource requirements, and replaceable test seams.

Keep the conversion in one direction at the facade/provisioning seam:

```text
host descriptors -> provisioning -> Effect services/layers -> running session
host agent descriptors + ambient EventLog -> Session.open/provisioning -> running session
```

Do not expose layer construction to a host just because the implementation needs a provider client, tool runtime, or
event log. Conversely, do not turn a capability that varies per runtime into a global singleton or a closure-captured
ambient dependency.
Do not expose internal provider-client or tool-runtime wiring. EventLog backend selection is deliberately
layer-native at the host boundary. Do not turn a runtime-varying capability into a global singleton.

## A small service seam

Expand Down Expand Up @@ -100,3 +99,6 @@ it.effect('records an entry through the public service', () =>

No `vi.mock`, `vi.spyOn`, module patching, or sleep-based timing. If the behavior cannot be tested by providing a
layer, move the seam rather than patching the module under test.

EventLog resources belong to the host's acquisition scope, while internal session graphs use session-fresh memo
maps. Session.open adopts existing identity or initializes an empty log; it never rebuilds a supplied backend.
18 changes: 12 additions & 6 deletions .claude/skills/effect-program-design/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,11 +17,13 @@ API, read `~/projects/effect`, not Effect v3 documentation or examples.

## Fold's architecture

- **Public Fold APIs are descriptor-facing.** Hosts use `defineAgent`, model descriptors, tool descriptors, and
event-log descriptors without learning `Layer`, `Toolkit`, or runtime wiring. See `README.md` and
`packages/fold-core/src/Api/Provisioning.ts`.
- **Agent APIs are descriptor-facing; event logs are layer-native.** Hosts use `defineAgent`, model and tool
descriptors. `Session.open({ agent })` initializes or resumes the ambient `EventLog`; absence means a fresh
isolated memory log. Custom logs are selected at the host composition root with `Layer`, never a session option.
See `README.md` and `packages/fold-core/src/Api/OpenSession.ts`.
- **Provisioning owns lowering.** The provisioner turns descriptors into the required services and layers once per
runtime/session. Do not make callers build or pass Fold's internal clients, layers, toolsets, or runtime services.
runtime/session. Do not make callers build Fold's internal clients, toolsets, or runtime services. Host-owned
EventLog layers are the deliberate exception: supply them through the Effect environment.
- **Services are internal capabilities.** Use `Context.Service` and `Layer` where a capability varies by runtime,
implementation, or test seam. One implementation is enough when the seam is valuable for real tests.
- **Event schemas are durable contracts.** Model persisted and wire-visible log entries with `Schema.TaggedStruct` and
Expand Down Expand Up @@ -62,8 +64,12 @@ API, read `~/projects/effect`, not Effect v3 documentation or examples.
only when that separation improves locality.
- Dependencies normally remain ambient in `R`. Yield them where the operation needs them rather than forwarding them
through public signatures.
- Build runtime-specific layer graphs at the provisioner/facade seam. Fold's agent provisioner owns memo-map and
scope semantics; callers do not recreate that graph.
- Build internal runtime graphs at the provisioner/facade seam with fresh memo maps. Hosts own their EventLog
acquisition scope and cardinality: build once per session, share with that session's consumers, and keep it alive
for every handle/RPC use. Never put unrelated sessions under one shared EventLog. Session.open alone may use
Effect.serviceOption (documented lint exception); the internal graph requires EventLog directly.
- Persisted-log acquisition and operation failures are infrastructure defects by Fold policy. Use Layer.orDie
at host acquisition and Effect.orDie at session operations; do not silently fall back to an empty log.
- Acquire resources in a scope and make background work supervised/owned. Bound concurrency for unbounded fan-out and
keep external calls outside authoritative transactions.
- Use `Effect.fn` or `Effect.withSpan` for meaningful public or I/O operation boundaries when observability is
Expand Down
46 changes: 35 additions & 11 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -25,38 +25,41 @@ foldcode auth --help # configure and manage authentication

## Isomorphic agents with `fold-core`

The ergonomic API is descriptor-based: define an agent, then run it against a session backend. No Effect `Layer`, `Toolkit`, or runtime wiring is exposed.
Define an agent with model and tool descriptors, then open a session. Custom event-log backends are supplied through normal Effect layer composition. Provider and tool-runtime wiring stays internal.

```ts
import { anthropicModel, defineAgent, startSession } from '@humanlayer/fold-core'
import { anthropicModel, defineAgent, Session } from '@humanlayer/fold-core'
import { Config, Effect } from 'effect'

export const ask = (prompt: string) =>
Effect.gen(function* () {
const apiKey = yield* Config.redacted('ANTHROPIC_API_KEY')
const apiKey = yield* Config.Redacted('ANTHROPIC_API_KEY')
const agent = defineAgent({
name: 'assistant',
model: anthropicModel({ model: 'claude-sonnet-4-6', apiKey }),
systemPrompt: 'Be concise and helpful.',
})
const session = yield* startSession({ agent }) // in-memory by default
const session = yield* Session.open({ agent }) // in-memory by default
return yield* session.send(prompt)
}).pipe(Effect.scoped)
```

`defineTool`, model descriptors, and event-log descriptors use the same data-first API, so hosts can supply browser, worker, or filesystem implementations without changing the agent definition.
`defineTool` and model descriptors keep agent configuration portable. `Session.open` initializes an empty log or resumes the session recorded in an existing one. Without a supplied `EventLog`, each call creates a fresh, isolated in-memory log.

## Custom coding agents with `fold-agent`

Compose `fold-agent`'s platform tools and JSONL backend with the same `fold-core` API. `codingTools({ cwd })` installs the complete tool union; `fold-core` selects the advertised tools from the active model on every request: Claude gets `write`/`edit`, while GPT/Codex gets `apply_patch`. Switching models reselects the tools automatically.

```ts
import { codingTools, jsonlEventLog } from '@humanlayer/fold-agent'
import { anthropicModel, defineAgent, startSession } from '@humanlayer/fold-core'
import { Config, Effect } from 'effect'
import { codingTools, layerCodingToolServices, layerJsonl } from '@humanlayer/fold-agent'
import { anthropicModel, defineAgent, Session } from '@humanlayer/fold-core'
import { Config, Effect, Layer } from 'effect'

const hostServices = layerCodingToolServices({ outputDirectory: '.fold/tool-output' })
const eventLog = layerJsonl('.fold/review.jsonl').pipe(Layer.orDie)

const program = Effect.gen(function* () {
const apiKey = yield* Config.redacted('ANTHROPIC_API_KEY')
const apiKey = yield* Config.Redacted('ANTHROPIC_API_KEY')
const cwd = '.'
const agent = defineAgent({
name: 'reviewer',
Expand All @@ -65,7 +68,28 @@ const program = Effect.gen(function* () {
tools: codingTools({ cwd }),
autoCompact: { enabled: true },
})
const session = yield* startSession({ agent, cwd, log: jsonlEventLog('.fold/review.jsonl') })
const session = yield* Session.open({ agent, cwd })
return yield* session.send('Find the highest-risk code in this project.')
}).pipe(Effect.scoped)
}).pipe(Effect.provide(eventLog.pipe(Layer.provideMerge(hostServices))), Effect.scoped)
```

The supplied backend represents one session, with at most one active handle. Reuse its service within
that session, not as an application-wide default for unrelated sessions. Layer memoization shares
instances by reference; Fold creates default memory logs and internal session graphs with fresh memo
maps, but deliberately adopts the exact host-supplied log. A fresh layer pointing at the same file is
still the same persistent log, not an isolated session.

Keep session construction **and use** within the owning scope. Long-lived hosts can build the backend
once into their session/object scope with `Layer.buildWithMemoMap` and provide the resulting context
for later calls. Storage acquisition errors are treated as defects using `Layer.orDie`.

Existing log identity is authoritative. A conflicting `sessionId`, a nonempty log without valid session
history, or multiple session starts fails rather than silently initializing a new session. `cwd` and
`meta` apply only to initialization; resumed model/prompt changes are recorded as configuration epochs.

### API migration

This is a breaking API change: replace `startSession` / `resumeSession` with `Session.open` and remove
`log` from the options. `FoldEventLog`, `memoryEventLog`, `eventLogSource`, and `jsonlEventLog` have been
removed. Supply `layerInMemoryEventLog`, `layerJsonl`, `layerJsonlWithIds`, or `layerJsonlNode` through the
Effect environment instead. `prepareSessionLog` now returns only the session id and path.
8 changes: 7 additions & 1 deletion examples/alchemy-cloudflare/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
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 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/DurableObjectEventLog.ts` — an `EventLog`-producing layer 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.
Expand Down Expand Up @@ -55,3 +55,9 @@ The agent gets fold-agent's file tools on the workspace: `read`, plus `write` an
- 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.

`ChatSession` builds the SQLite layer once per object activation into the explicit object-lifetime scope,
inside Alchemy's returned runtime Effect (planning has no live SQLite handle). The same EventLog instance
serves emptiness checks, `Session.open({ agent })`, and the `entries()` RPC. Workspace preparation still
branches on an empty versus existing log; Fold itself automatically initializes or resumes it. The backend
is not rebuilt for each RPC and does not end with the constructor/request scope.
30 changes: 14 additions & 16 deletions examples/alchemy-cloudflare/src/ChatSession.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,17 +19,16 @@
import { skillsFromDisk } from '@humanlayer/fold-agent/skills'
import { fileTools, Photon } from '@humanlayer/fold-agent/tools/files'
import {
EventLog,
SessionId,
type AgentId,
type FoldSession,
type LogEntry,
type UserMessageLogEntry,
defineAgent,
eventLogSource,
openaiModel,
resumeSession,
Session,
skillTool,
startSession,
} from '@humanlayer/fold-core'
import * as Cloudflare from 'alchemy/Cloudflare'
import {
Expand All @@ -52,7 +51,7 @@ import { FetchHttpClient } from 'effect/http'
import { bashTool } from './BashTool'
import type { Message, WhenRunning } from './ChatSessions'
import { WORKSPACE_ROOT } from './computer/Contract'
import { DurableObjectEventLog } from './DurableObjectEventLog'
import { layerDurableObjectEventLog } from './DurableObjectEventLog'
import { Keepalive } from './Keepalive'
import { SessionExpiry } from './SessionExpiry'
import { type Repo, Workspace } from './Workspace'
Expand Down Expand Up @@ -149,17 +148,20 @@ export default class ChatSession extends Cloudflare.DurableObject<ChatSession>()
const state = yield* Cloudflare.DurableObjectState
// Read from the deploy environment and bound to the Worker as a secret. Missing, the deploy fails.
const apiKey = yield* Config.Redacted('OPENAI_API_KEY').pipe(Effect.orDie)
const eventLogs = yield* DurableObjectEventLog
const keepalive = yield* Keepalive
const expiry = yield* SessionExpiry
const workspace = yield* Workspace

// Alchemy runs this outer Effect once per class and the returned Effect once per object instance.
// Alchemy also evaluates the outer Effect during planning, without a live SQLite handle.
// Storage acquisition stays in the returned runtime-only Effect.
// oxlint-disable-next-line effecttsgo/return-effect-in-gen
return Effect.gen(function* () {
const sessionId = yield* Schema.decodeUnknownEffect(SessionId)(state.id.name)
const eventLog = yield* eventLogs.open
const log = eventLogSource(Effect.succeed(eventLog))
// This scope and backend outlive construction and every individual RPC call.
const scope = yield* Scope.make()
const memoMap = yield* Layer.makeMemoMap
const logContext = yield* Layer.buildWithMemoMap(layerDurableObjectEventLog, memoMap, scope)
const eventLog = yield* EventLog.pipe(Effect.provideContext(logContext))
const isEmpty = Option.isNone(yield* Stream.runHead(eventLog.entries()))

const fileSystem = workspace.fileSystem(sessionId)
Expand All @@ -176,19 +178,14 @@ export default class ChatSession extends Cloudflare.DurableObject<ChatSession>()
],
})

// Never closed: the session lives as long as the object stays in memory, past every call's own
// scope. Opened on the first send, so reading an unknown id writes nothing.
const scope = yield* Scope.make()

// A new session clones its repos, starts its container in the background, then starts; a cut-off
// start leaves the log empty, so the next send clones them again from scratch.
const start = (repos: ReadonlyArray<Repo>) =>
Effect.gen(function* () {
const cloned = yield* workspace.prepare(sessionId, repos)
yield* Effect.forkIn(workspace.startContainer(sessionId), scope)
return yield* startSession({
return yield* Session.open({
agent: agentFor(cloned.map((repo) => repo.name)),
log,
sessionId,
cwd: WORKSPACE_ROOT,
meta: { repos: cloned },
Expand All @@ -204,7 +201,7 @@ export default class ChatSession extends Cloudflare.DurableObject<ChatSession>()
Effect.orDie,
),
)
return yield* resumeSession({ agent: agentFor(repoNames), log })
return yield* Session.open({ agent: agentFor(repoNames), sessionId })
})

// Opened once, by whichever send or restart nudge comes first; `repos` only matter to a new session.
Expand All @@ -217,6 +214,7 @@ export default class ChatSession extends Cloudflare.DurableObject<ChatSession>()
(isEmpty ? start(repos) : resume).pipe(
// fold hands these to the file tools, the skill loader, and the model.
Effect.provideService(FileSystem.FileSystem, fileSystem),
Effect.provideContext(logContext),
Effect.provide(Layer.mergeAll(Path.layer, FetchHttpClient.layer, Photon.layer)),
Scope.provide(scope),
Effect.map((session) => [session, Option.some(session)] as const),
Expand Down Expand Up @@ -259,5 +257,5 @@ export default class ChatSession extends Cloudflare.DurableObject<ChatSession>()
),
}
}).pipe(Effect.orDie)
}).pipe(Effect.provide([DurableObjectEventLog.layer, Keepalive.layer, SessionExpiry.layer, Workspace.layer])),
}).pipe(Effect.provide([Keepalive.layer, SessionExpiry.layer, Workspace.layer])),

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

event log? we should be using the DO event log shouldn't web - where did it go @humanlayer-channels-app?

) {}
Loading
Loading