diff --git a/packages/fold-agent/src/Bin/ManagedBinaries.ts b/packages/fold-agent/src/Bin/ManagedBinaries.ts index 81a0394..1ae9132 100644 --- a/packages/fold-agent/src/Bin/ManagedBinaries.ts +++ b/packages/fold-agent/src/Bin/ManagedBinaries.ts @@ -179,323 +179,334 @@ const readHostEnvironment: Effect.Effect = Effect.gen(function* } }) -const makeManagedBinaries = (registry: ReadonlyArray) => - Effect.gen(function* () { - const fs = yield* FileSystem.FileSystem - const spawner = yield* ChildProcessSpawner.ChildProcessSpawner - const http = yield* HttpClient.HttpClient - const environment = yield* readHostEnvironment - - /** Fetch one release asset's bytes, with a 30s timeout. */ - const downloadAsset = (url: string): Effect.Effect => - http.get(url).pipe( - Effect.flatMap(HttpClientResponse.filterStatusOk), - Effect.flatMap((response) => response.arrayBuffer), - Effect.map((buffer) => new Uint8Array(buffer)), - Effect.catchTag('HttpClientError', (error) => - Effect.fail(new BinaryDownloadError({ message: `GET ${url}: ${error.message}` })), - ), - Effect.timeoutOrElse({ - duration: Duration.millis(downloadTimeoutMillis), - orElse: () => - Effect.fail( - new BinaryDownloadError({ - message: `GET ${url} timed out after ${downloadTimeoutMillis}ms`, - }), +/** The per-process managed-binary resolver's interface. */ +export type ManagedBinariesService = { + readonly ensure: (options: EnsureManagedBinariesOptions) => Effect.Effect> +} + +/** + * The per-process managed-binary resolver. `ensure` never fails; see the file header for the + * resolution ladder. + */ +export class ManagedBinaries extends Context.Service()( + 'fold-agent/Bin/ManagedBinaries', + { + make: (registry: ReadonlyArray) => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem + const spawner = yield* ChildProcessSpawner.ChildProcessSpawner + const http = yield* HttpClient.HttpClient + const environment = yield* readHostEnvironment + + /** Fetch one release asset's bytes, with a 30s timeout. */ + const downloadAsset = (url: string): Effect.Effect => + http.get(url).pipe( + Effect.flatMap(HttpClientResponse.filterStatusOk), + Effect.flatMap((response) => response.arrayBuffer), + Effect.map((buffer) => new Uint8Array(buffer)), + Effect.catchTag('HttpClientError', (error) => + Effect.fail(new BinaryDownloadError({ message: `GET ${url}: ${error.message}` })), ), - }), - ) - - /** Run one helper command to completion; a non-zero exit fails with its stderr. */ - const exec = (command: string, args: ReadonlyArray): Effect.Effect => { - const label = `${command} ${args.join(' ')}` - return Effect.gen(function* () { - const handle = yield* spawner.spawn(ChildProcess.make(command, args)) - const [stdout, stderr, exitCode] = yield* Effect.all( - [ - handle.stdout.pipe(Stream.decodeText(), Stream.mkString), - handle.stderr.pipe(Stream.decodeText(), Stream.mkString), - handle.exitCode, - ], - { concurrency: 'unbounded' }, - ) - if (exitCode !== ChildProcessSpawner.ExitCode(0)) { - const trimmed = stderr.trim() - return yield* new BinaryExecError({ - message: `${label}: exited with code ${exitCode}${trimmed === '' ? '' : ` (${trimmed.slice(0, 400)})`}`, - }) - } - return stdout - }).pipe( - Effect.scoped, - Effect.catchTag('PlatformError', (error) => - Effect.fail(new BinaryExecError({ message: `${label}: ${error.message}` })), - ), - Effect.timeoutOrElse({ - duration: Duration.millis(execTimeoutMillis), - orElse: () => - Effect.fail( - new BinaryExecError({ message: `${label}: timed out after ${execTimeoutMillis}ms` }), + Effect.timeoutOrElse({ + duration: Duration.millis(downloadTimeoutMillis), + orElse: () => + Effect.fail( + new BinaryDownloadError({ + message: `GET ${url} timed out after ${downloadTimeoutMillis}ms`, + }), + ), + }), + ) + + /** Run one helper command to completion; a non-zero exit fails with its stderr. */ + const exec = (command: string, args: ReadonlyArray): Effect.Effect => { + const label = `${command} ${args.join(' ')}` + return Effect.gen(function* () { + const handle = yield* spawner.spawn(ChildProcess.make(command, args)) + const [stdout, stderr, exitCode] = yield* Effect.all( + [ + handle.stdout.pipe(Stream.decodeText(), Stream.mkString), + handle.stderr.pipe(Stream.decodeText(), Stream.mkString), + handle.exitCode, + ], + { concurrency: 'unbounded' }, + ) + if (exitCode !== ChildProcessSpawner.ExitCode(0)) { + const trimmed = stderr.trim() + return yield* new BinaryExecError({ + message: `${label}: exited with code ${exitCode}${trimmed === '' ? '' : ` (${trimmed.slice(0, 400)})`}`, + }) + } + return stdout + }).pipe( + Effect.scoped, + Effect.catchTag('PlatformError', (error) => + Effect.fail(new BinaryExecError({ message: `${label}: ${error.message}` })), ), - }), - ) - } - - /** - * Whether one candidate path is an executable regular file. Off Windows, any execute bit - * counts (the FileSystem service has no X_OK check); on Windows, PATHEXT already decides. - */ - const isExecutableFile = (path: string, platform: string): Effect.Effect => - fs.stat(path).pipe( - Effect.map((info) => info.type === 'File' && (platform === 'win32' || (info.mode & 0o111) !== 0)), - Effect.catchTag('PlatformError', () => Effect.succeed(false)), - ) - - /** - * Locate one command on PATH. A manual scan beats shelling out to `command -v`, which also - * matches shell builtins and functions. - */ - const which = (context: ResolveContext, name: string): Effect.Effect => - Effect.gen(function* () { - for (const directory of context.pathEntries) { - for (const extension of context.executableExtensions) { - const candidate = join(directory, `${name}${extension}`) - if (yield* isExecutableFile(candidate, context.platform)) return candidate - } + Effect.timeoutOrElse({ + duration: Duration.millis(execTimeoutMillis), + orElse: () => + Effect.fail( + new BinaryExecError({ + message: `${label}: timed out after ${execTimeoutMillis}ms`, + }), + ), + }), + ) } - return null - }) - - /** - * Whether a resolved system binary satisfies the definition's version floor. Unparseable output - * or a failing `--version` counts as NOT satisfying it: falling through to the managed install is - * safe (the managed copy shadows nothing - `/bin` is PREPENDED to PATH) and self-healing. - */ - const satisfiesMinVersion = (binaryPath: string, minVersion: string): Effect.Effect => - Effect.gen(function* () { - const floor = parseBinaryVersion(minVersion) - if (floor === null) return true - - const output = yield* exec(binaryPath, ['--version']).pipe(Effect.option) - if (Option.isNone(output)) return false - const version = parseBinaryVersion(output.value) - - return version !== null && versionAtLeast(version, floor) - }) - - /** - * Extract one archive into a directory. tar.gz goes straight to the system `tar`; zip tries - * `unzip` first and falls back to `tar xf` (bsdtar on macOS and Windows System32 reads zip - * archives; GNU tar does not, hence the unzip-first order). - */ - const extractArchive = ( - asset: ManagedBinaryAsset, - archivePath: string, - extractDir: string, - ): Effect.Effect => { - if (asset.archive === 'tar.gz') { - return exec('tar', ['xzf', archivePath, '-C', extractDir]).pipe(Effect.asVoid) - } - - return exec('unzip', ['-q', '-o', archivePath, '-d', extractDir]).pipe( - Effect.catchTag('BinaryExecError', (unzipError) => - exec('tar', ['xf', archivePath, '-C', extractDir]).pipe( - Effect.mapError( - (tarError) => - new BinaryExecError({ message: `${unzipError.message}; ${tarError.message}` }), + + /** + * Whether one candidate path is an executable regular file. Off Windows, any execute bit + * counts (the FileSystem service has no X_OK check); on Windows, PATHEXT already decides. + */ + const isExecutableFile = (path: string, platform: string): Effect.Effect => + fs.stat(path).pipe( + Effect.map( + (info) => info.type === 'File' && (platform === 'win32' || (info.mode & 0o111) !== 0), ), - ), - ), - Effect.asVoid, - ) - } - - /** - * Download, verify, extract, and install one binary. Verification happens on the in-memory - * bytes BEFORE anything is written, so a bad digest never leaves a file behind; the rename out - * of a temp directory under `/bin` keeps the final write atomic on one filesystem. - */ - const installFromAsset = ( - context: ResolveContext, - definition: ManagedBinaryDefinition, - asset: ManagedBinaryAsset, - installPath: string, - ) => - Effect.gen(function* () { - const bytes = yield* downloadAsset(asset.url) + Effect.catchTag('PlatformError', () => Effect.succeed(false)), + ) + + /** + * Locate one command on PATH. A manual scan beats shelling out to `command -v`, which also + * matches shell builtins and functions. + */ + const which = (context: ResolveContext, name: string): Effect.Effect => + Effect.gen(function* () { + for (const directory of context.pathEntries) { + for (const extension of context.executableExtensions) { + const candidate = join(directory, `${name}${extension}`) + if (yield* isExecutableFile(candidate, context.platform)) return candidate + } + } + return null + }) - if (asset.sha256 !== null) { - const digest = sha256Hex(bytes) - if (digest !== asset.sha256) { - return yield* new BinaryInstallError({ - message: `sha256 mismatch for ${asset.url}: expected ${asset.sha256}, downloaded ${digest}`, - }) + /** + * Whether a resolved system binary satisfies the definition's version floor. Unparseable output + * or a failing `--version` counts as NOT satisfying it: falling through to the managed install is + * safe (the managed copy shadows nothing - `/bin` is PREPENDED to PATH) and self-healing. + */ + const satisfiesMinVersion = (binaryPath: string, minVersion: string): Effect.Effect => + Effect.gen(function* () { + const floor = parseBinaryVersion(minVersion) + if (floor === null) return true + + const output = yield* exec(binaryPath, ['--version']).pipe(Effect.option) + if (Option.isNone(output)) return false + const version = parseBinaryVersion(output.value) + + return version !== null && versionAtLeast(version, floor) + }) + + /** + * Extract one archive into a directory. tar.gz goes straight to the system `tar`; zip tries + * `unzip` first and falls back to `tar xf` (bsdtar on macOS and Windows System32 reads zip + * archives; GNU tar does not, hence the unzip-first order). + */ + const extractArchive = ( + asset: ManagedBinaryAsset, + archivePath: string, + extractDir: string, + ): Effect.Effect => { + if (asset.archive === 'tar.gz') { + return exec('tar', ['xzf', archivePath, '-C', extractDir]).pipe(Effect.asVoid) } + + return exec('unzip', ['-q', '-o', archivePath, '-d', extractDir]).pipe( + Effect.catchTag('BinaryExecError', (unzipError) => + exec('tar', ['xf', archivePath, '-C', extractDir]).pipe( + Effect.mapError( + (tarError) => + new BinaryExecError({ message: `${unzipError.message}; ${tarError.message}` }), + ), + ), + ), + Effect.asVoid, + ) } - const binDir = managedBinDir(context.foldHome) - yield* fs.makeDirectory(binDir, { recursive: true }) - yield* Effect.gen(function* () { - const extractDir = yield* fs.makeTempDirectoryScoped({ - directory: binDir, - prefix: `.tmp-${definition.name}-`, + /** + * Download, verify, extract, and install one binary. Verification happens on the in-memory + * bytes BEFORE anything is written, so a bad digest never leaves a file behind; the rename out + * of a temp directory under `/bin` keeps the final write atomic on one filesystem. + */ + const installFromAsset = ( + context: ResolveContext, + definition: ManagedBinaryDefinition, + asset: ManagedBinaryAsset, + installPath: string, + ) => + Effect.gen(function* () { + const bytes = yield* downloadAsset(asset.url) + + if (asset.sha256 !== null) { + const digest = sha256Hex(bytes) + if (digest !== asset.sha256) { + return yield* new BinaryInstallError({ + message: `sha256 mismatch for ${asset.url}: expected ${asset.sha256}, downloaded ${digest}`, + }) + } + } + + const binDir = managedBinDir(context.foldHome) + yield* fs.makeDirectory(binDir, { recursive: true }) + yield* Effect.gen(function* () { + const extractDir = yield* fs.makeTempDirectoryScoped({ + directory: binDir, + prefix: `.tmp-${definition.name}-`, + }) + const archivePath = join(extractDir, assetFileName(asset.url)) + yield* fs.writeFile(archivePath, bytes) + yield* extractArchive(asset, archivePath, extractDir) + + const extractedPath = join(extractDir, asset.pathInArchive) + const present = yield* fs.exists(extractedPath).pipe(Effect.orElseSucceed(() => false)) + if (!present) { + return yield* new BinaryInstallError({ + message: `${assetFileName(asset.url)} did not contain ${asset.pathInArchive}`, + }) + } + + yield* fs.rename(extractedPath, installPath) + yield* fs.chmod(installPath, 0o755) + }).pipe(Effect.scoped) }) - const archivePath = join(extractDir, assetFileName(asset.url)) - yield* fs.writeFile(archivePath, bytes) - yield* extractArchive(asset, archivePath, extractDir) - - const extractedPath = join(extractDir, asset.pathInArchive) - const present = yield* fs.exists(extractedPath).pipe(Effect.orElseSucceed(() => false)) - if (!present) { - return yield* new BinaryInstallError({ - message: `${assetFileName(asset.url)} did not contain ${asset.pathInArchive}`, - }) - } - yield* fs.rename(extractedPath, installPath) - yield* fs.chmod(installPath, 0o755) - }).pipe(Effect.scoped) - }) + /** Resolve one binary through the system -> managed -> download ladder. Failures propagate typed. */ + const resolveOne = (context: ResolveContext, definition: ManagedBinaryDefinition) => + Effect.gen(function* () { + const resolveSystem = Effect.gen(function* () { + for (const systemName of definition.systemNames) { + const found = yield* which(context, systemName) + if (found === null) continue + if (definition.minVersion !== null) { + const usable = yield* satisfiesMinVersion(found, definition.minVersion) + if (!usable) continue + } + + return { + name: definition.name, + resolution: 'system' as const, + path: found, + detail: `system binary "${systemName}" on PATH`, + } + } + + return null + }) - /** Resolve one binary through the system -> managed -> download ladder. Failures propagate typed. */ - const resolveOne = (context: ResolveContext, definition: ManagedBinaryDefinition) => - Effect.gen(function* () { - const resolveSystem = Effect.gen(function* () { - for (const systemName of definition.systemNames) { - const found = yield* which(context, systemName) - if (found === null) continue - if (definition.minVersion !== null) { - const usable = yield* satisfiesMinVersion(found, definition.minVersion) - if (!usable) continue + if (!context.requireManagedInstall) { + const system = yield* resolveSystem + if (system !== null) return system } - return { - name: definition.name, - resolution: 'system' as const, - path: found, - detail: `system binary "${systemName}" on PATH`, + const binDir = managedBinDir(context.foldHome) + const installPath = join(binDir, installedFileName(definition, context.platform)) + const installed = yield* fs.exists(installPath).pipe(Effect.orElseSucceed(() => false)) + if (installed) { + return { + name: definition.name, + resolution: 'managed' as const, + path: installPath, + detail: `already installed in ${binDir}`, + } } - } - return null - }) + if (context.requireManagedInstall) { + const system = yield* resolveSystem + if (system !== null && context.disableDownloads) return system + } - if (!context.requireManagedInstall) { - const system = yield* resolveSystem - if (system !== null) return system - } + if (context.disableDownloads) { + return { + name: definition.name, + resolution: 'unavailable' as const, + path: null, + detail: `binary downloads disabled; not found on PATH or in ${binDir}`, + } + } - const binDir = managedBinDir(context.foldHome) - const installPath = join(binDir, installedFileName(definition, context.platform)) - const installed = yield* fs.exists(installPath).pipe(Effect.orElseSucceed(() => false)) - if (installed) { - return { - name: definition.name, - resolution: 'managed' as const, - path: installPath, - detail: `already installed in ${binDir}`, - } - } + const asset = definition.assetFor(context.platform, context.arch) + if (asset === null) { + return { + name: definition.name, + resolution: 'unavailable' as const, + path: null, + detail: `no pinned ${definition.name} asset for ${context.platform}-${context.arch}`, + } + } - if (context.requireManagedInstall) { - const system = yield* resolveSystem - if (system !== null && context.disableDownloads) return system - } + yield* installFromAsset(context, definition, asset, installPath) - if (context.disableDownloads) { - return { - name: definition.name, - resolution: 'unavailable' as const, - path: null, - detail: `binary downloads disabled; not found on PATH or in ${binDir}`, - } - } - - const asset = definition.assetFor(context.platform, context.arch) - if (asset === null) { - return { - name: definition.name, - resolution: 'unavailable' as const, - path: null, - detail: `no pinned ${definition.name} asset for ${context.platform}-${context.arch}`, - } - } + return { + name: definition.name, + resolution: 'installed-now' as const, + path: installPath, + detail: `downloaded ${definition.name} ${definition.version} from ${definition.repo}`, + } + }) - yield* installFromAsset(context, definition, asset, installPath) + /** + * One binary's resolution, degraded to `unavailable` on ANY failure or defect. This is the + * best-effort boundary, so it deliberately catches the whole cause and keeps the message. + */ + const resolveOneNeverFailing = ( + context: ResolveContext, + definition: ManagedBinaryDefinition, + ): Effect.Effect => + resolveOne(context, definition).pipe( + Effect.catchCause((cause) => + // Debug level: this runs in the background under the TUI, where console warnings would + // corrupt the screen. `fold bin status|install` print each status's detail instead. + Effect.logDebug(`could not resolve managed binary ${definition.name}`, cause).pipe( + Effect.as({ + name: definition.name, + resolution: 'unavailable' as const, + path: null, + detail: Cause.prettyErrors(cause)[0]?.message ?? 'unknown failure', + }), + ), + ), + ) + + /** One full pass over the registry, every binary resolved concurrently. */ + const resolveAll = (key: EnsureKey): Effect.Effect> => + Effect.gen(function* () { + const context: ResolveContext = { + ...environment, + ...key, + disableDownloads: key.disableDownloads || environment.downloadsKillSwitch, + } + return yield* Effect.forEach( + registry, + (definition) => resolveOneNeverFailing(context, definition), + { + concurrency: Math.max(registry.length, 1), + }, + ) + }) - return { - name: definition.name, - resolution: 'installed-now' as const, - path: installPath, - detail: `downloaded ${definition.name} ${definition.version} from ${definition.repo}`, - } - }) - - /** - * One binary's resolution, degraded to `unavailable` on ANY failure or defect. This is the - * best-effort boundary, so it deliberately catches the whole cause and keeps the message. - */ - const resolveOneNeverFailing = ( - context: ResolveContext, - definition: ManagedBinaryDefinition, - ): Effect.Effect => - resolveOne(context, definition).pipe( - Effect.catchCause((cause) => - // Debug level: this runs in the background under the TUI, where console warnings would - // corrupt the screen. `fold bin status|install` print each status's detail instead. - Effect.logDebug(`could not resolve managed binary ${definition.name}`, cause).pipe( - Effect.as({ - name: definition.name, - resolution: 'unavailable' as const, - path: null, - detail: Cause.prettyErrors(cause)[0]?.message ?? 'unknown failure', - }), - ), - ), - ) + // Keys are (foldHome, mode) pairs: a handful per process. + const passes = yield* Cache.make>({ + capacity: 64, + lookup: resolveAll, + }) - /** One full pass over the registry, every binary resolved concurrently. */ - const resolveAll = (key: EnsureKey): Effect.Effect> => - Effect.gen(function* () { - const context: ResolveContext = { - ...environment, - ...key, - disableDownloads: key.disableDownloads || environment.downloadsKillSwitch, - } - return yield* Effect.forEach(registry, (definition) => resolveOneNeverFailing(context, definition), { - concurrency: Math.max(registry.length, 1), + const ensure = Effect.fn('ManagedBinaries.ensure')(function* (options: EnsureManagedBinariesOptions) { + const key: EnsureKey = { + foldHome: options.foldHome, + disableDownloads: options.disableDownloads === true, + requireManagedInstall: options.requireManagedInstall === true, + } + const resolved = options.memoize === false ? yield* resolveAll(key) : yield* Cache.get(passes, key) + return resolved }) - }) - - // Keys are (foldHome, mode) pairs: a handful per process. - const passes = yield* Cache.make>({ - capacity: 64, - lookup: resolveAll, - }) - - const ensure = Effect.fn('ManagedBinaries.ensure')(function* (options: EnsureManagedBinariesOptions) { - const key: EnsureKey = { - foldHome: options.foldHome, - disableDownloads: options.disableDownloads === true, - requireManagedInstall: options.requireManagedInstall === true, - } - const resolved = options.memoize === false ? yield* resolveAll(key) : yield* Cache.get(passes, key) - return resolved - }) - - return ManagedBinaries.of({ ensure }) - }) -/** - * The per-process managed-binary resolver. `ensure` never fails; see the file header for the - * resolution ladder. - */ -export class ManagedBinaries extends Context.Service< - ManagedBinaries, - { - readonly ensure: (options: EnsureManagedBinariesOptions) => Effect.Effect> - } ->()('fold-agent/Bin/ManagedBinaries') { + return { ensure } + }), + }, +) { /** A resolver over a given registry (tests pass a small one). */ static readonly layerWith = ( registry: ReadonlyArray, @@ -503,7 +514,7 @@ export class ManagedBinaries extends Context.Service< ManagedBinaries, never, FileSystem.FileSystem | ChildProcessSpawner.ChildProcessSpawner | HttpClient.HttpClient - > => Layer.effect(ManagedBinaries, makeManagedBinaries(registry)) + > => Layer.effect(this, this.make(registry)) /** The resolver over fold's pinned {@link managedBinaryRegistry}. */ static readonly layer = ManagedBinaries.layerWith(managedBinaryRegistry) diff --git a/packages/fold-agent/src/EventLog/JsonlLayer.ts b/packages/fold-agent/src/EventLog/JsonlLayer.ts index c599cf2..085b972 100644 --- a/packages/fold-agent/src/EventLog/JsonlLayer.ts +++ b/packages/fold-agent/src/EventLog/JsonlLayer.ts @@ -107,21 +107,16 @@ const encodeJsonlLine = (entry: LogEntry): Effect.Effect, - EventLogCorruptEntryError | EventLogUnsupportedVersionError | EventLogUnavailableError + EventLogCorruptEntryError | EventLogUnsupportedVersionError | EventLogUnavailableError, + FileSystem.FileSystem > => Effect.gen(function* () { - yield* fs - .makeDirectory(dirname(filePath), { recursive: true }) - .pipe( - Effect.mapError((cause) => - unavailableError('entries', `Unable to create EventLog directory for ${filePath}`, false, cause), - ), - ) + const fs = yield* FileSystem.FileSystem const exists = yield* fs .exists(filePath) .pipe( @@ -182,7 +177,19 @@ export const layerJsonlWithIds = ( Effect.gen(function* () { const fs = yield* FileSystem.FileSystem const ids = yield* Ids - const initialEntries = yield* loadEntries(fs, filePath) + yield* fs + .makeDirectory(dirname(filePath), { recursive: true }) + .pipe( + Effect.mapError((cause) => + unavailableError( + 'entries', + `Unable to create EventLog directory for ${filePath}`, + false, + cause, + ), + ), + ) + const initialEntries = yield* readJsonlEntries(filePath) const entriesRef = yield* Ref.make>(initialEntries) const pubsub = yield* PubSub.unbounded() const appendLock = yield* Semaphore.make(1) diff --git a/packages/fold-agent/src/Mode/Launch.ts b/packages/fold-agent/src/Mode/Launch.ts index 50f0f45..5f3dd24 100644 --- a/packages/fold-agent/src/Mode/Launch.ts +++ b/packages/fold-agent/src/Mode/Launch.ts @@ -64,20 +64,9 @@ import { import { rolesForDirectProviderSelection } from '../Config/ModelSelections' import { layerJsonl } from '../EventLog/JsonlLayer' import { memoryPromptBlock } from '../Memory/AgentFiles' -import { - layerOutputStore, - sweepToolOutput, - toolOutputSessionDirFor, - type OutputStore, -} from '../OutputStore/OutputStore' -import { - latestSessionLog, - prepareSessionLog, - refreshSessionSummaryIndex, - sessionLogById, - type SessionLogRef, -} from '../Session/SessionLayout' -import { generateSessionTitle } from '../Session/TitleGenerator' +import { sweepToolOutput, toolOutputSessionDirFor, OutputStore } from '../OutputStore/OutputStore' +import { latestSessionLog, prepareSessionLog, sessionLogById, type SessionLogRef } from '../Session/SessionLayout' +import { SessionTitleGenerator } from '../Session/TitleGenerator' import type { Photon } from '../Tools/Image/Photon' import { compactionArchiveAccessFor } from './CompactionArchiveAccess' import { defaultCodingMode, type FoldMode } from './Mode' @@ -421,7 +410,7 @@ const buildAgentDefinition = ( /** The session's own output store, under `/tool-output/`. */ const outputStoreFor = (sessionId: SessionId, options: LaunchSessionOptions) => - layerOutputStore({ + OutputStore.layer({ directory: toolOutputSessionDirFor( options.foldHome === undefined ? { sessionId } : { sessionId, foldHome: options.foldHome }, ), @@ -472,14 +461,9 @@ export const switchSessionMode = ( }) }) -const withGeneratedTitles = ( - session: FoldSession, - model: FoldModel, - options: { readonly cwd: string; readonly foldHome?: string }, -): Effect.Effect, never, FileSystem.FileSystem | HttpClient.HttpClient> => +const withGeneratedTitles = (session: FoldSession): Effect.Effect, never, SessionTitleGenerator> => Effect.gen(function* () { - // Titles are generated after later sends, which run without these services, so take them now. - const titleServices = yield* Effect.context() + const titles = yield* SessionTitleGenerator return yield* Semaphore.make(1).pipe( Effect.map((titleLock) => ({ ...session, @@ -502,24 +486,18 @@ const withGeneratedTitles = ( ) const generatedTurns = lastTitle?.rootUserTurns ?? 0 if (rootUsers.length <= generatedTurns) return Effect.void - return generateSessionTitle(entries, session.rootAgentId, model).pipe( - Effect.provideContext(titleServices), + return titles.generate(entries, session.rootAgentId).pipe( Effect.flatMap((title) => { const generatedThroughSeq = entries.at(-1)?.seq const rootUserTurns = rootUsers.length - const setTitle = session.setTitle( + // The title append changes the log, so the next summary listing + // rebuilds this session's index record from it. + return session.setTitle( title, generatedThroughSeq === undefined ? { rootUserTurns } : { rootUserTurns, generatedThroughSeq }, ) - return setTitle.pipe( - Effect.andThen( - refreshSessionSummaryIndex(session.sessionId, options).pipe( - Effect.provideContext(titleServices), - ), - ), - ) }), ) }), @@ -587,25 +565,31 @@ export const launchSession = ( yield* sweepToolOutput(opts.foldHome === undefined ? {} : { foldHome: opts.foldHome }) const agent = yield* buildAgentDefinition(opts, mode, models, cwd, config) - // The output store lives as long as the session, in the caller's scope. + // The session's own services live as long as the session, in the caller's scope. const sessionServices = yield* Layer.build( - Layer.mergeAll(outputStoreFor(prepared.sessionId, opts), layerJsonl(prepared.path).pipe(Layer.orDie)), + Layer.mergeAll( + outputStoreFor(prepared.sessionId, opts), + layerJsonl(prepared.path).pipe(Layer.orDie), + SessionTitleGenerator.layer(models.fast), + ), ) - const session = yield* Session.open({ - agent, - cwd, - sessionId: prepared.sessionId, - meta: { - mode: mode.name, - rpi: opts.rpi === true || mode.rpiByDefault === true, - profile: opts.profile ?? 'default', - }, - profiles: sessionProfilesFor(models), - catalog, - compactionArchiveAccess: compactionArchiveAccessFor({ logPath: prepared.path, modeName: mode.name }), - ...Struct.pick(opts, ['steering']), + return yield* Effect.gen(function* () { + const session = yield* Session.open({ + agent, + cwd, + sessionId: prepared.sessionId, + meta: { + mode: mode.name, + rpi: opts.rpi === true || mode.rpiByDefault === true, + profile: opts.profile ?? 'default', + }, + profiles: sessionProfilesFor(models), + catalog, + compactionArchiveAccess: compactionArchiveAccessFor({ logPath: prepared.path, modeName: mode.name }), + ...Struct.pick(opts, ['steering']), + }) + return yield* withGeneratedTitles(session) }).pipe(Effect.provideContext(sessionServices)) - return yield* withGeneratedTitles(session, models.fast, { cwd, ...Struct.pick(opts, ['foldHome']) }) }) const resumeFromLog = ( @@ -638,19 +622,25 @@ const resumeFromLog = ( yield* sweepToolOutput(options.foldHome === undefined ? {} : { foldHome: options.foldHome }) const agent = yield* buildAgentDefinition(options, mode, models, cwd, config) - // The output store lives as long as the session, in the caller's scope. + // The session's own services live as long as the session, in the caller's scope. const sessionServices = yield* Layer.build( - Layer.mergeAll(outputStoreFor(log.sessionId, options), layerJsonl(log.path).pipe(Layer.orDie)), + Layer.mergeAll( + outputStoreFor(log.sessionId, options), + layerJsonl(log.path).pipe(Layer.orDie), + SessionTitleGenerator.layer(models.fast), + ), ) - const session = yield* Session.open({ - agent, - sessionId: log.sessionId, - profiles: sessionProfilesFor(models), - catalog, - compactionArchiveAccess: compactionArchiveAccessFor({ logPath: log.path, modeName: mode.name }), - ...Struct.pick(options, ['steering']), + return yield* Effect.gen(function* () { + const session = yield* Session.open({ + agent, + sessionId: log.sessionId, + profiles: sessionProfilesFor(models), + catalog, + compactionArchiveAccess: compactionArchiveAccessFor({ logPath: log.path, modeName: mode.name }), + ...Struct.pick(options, ['steering']), + }) + return yield* withGeneratedTitles(session) }).pipe(Effect.provideContext(sessionServices)) - return yield* withGeneratedTitles(session, models.fast, { cwd, ...Struct.pick(options, ['foldHome']) }) }) /** diff --git a/packages/fold-agent/src/OutputStore/OutputStore.ts b/packages/fold-agent/src/OutputStore/OutputStore.ts index fd4389f..ff3f97b 100644 --- a/packages/fold-agent/src/OutputStore/OutputStore.ts +++ b/packages/fold-agent/src/OutputStore/OutputStore.ts @@ -50,9 +50,6 @@ export type OutputStoreService = { readonly read: (ref: OutputStoreRef, options?: OutputStoreReadOptions) => Effect.Effect } -/** Where tools store their full output. The host provides it; the bash tool asks for it. */ -export class OutputStore extends Context.Service()('fold-agent/OutputStore') {} - /** Root directory for all stored tool output. */ export const toolOutputRootFor = (options?: { readonly foldHome?: string }): string => join(options?.foldHome ?? defaultFoldHome(), 'tool-output') @@ -96,11 +93,10 @@ const lineSlice = (content: string, options?: OutputStoreReadOptions): string => } /** A file-backed OutputStore writing one file per tool call into `directory`. */ -export const layerOutputStore = (options: { - readonly directory: string -}): Layer.Layer => - Layer.effect( - OutputStore, +/** Where tools store their full output. The host provides it; the bash tool asks for it. */ +export class OutputStore extends Context.Service()('fold-agent/OutputStore', { + /** A file-backed OutputStore writing one file per tool call into `directory`. */ + make: (options: { readonly directory: string }) => Effect.gen(function* () { const fs = yield* FileSystem.FileSystem const directory = options.directory @@ -147,7 +143,10 @@ export const layerOutputStore = (options: { return { directory, refFor, prepare, append, read } }), - ) +}) { + /** A file-backed OutputStore writing into `options.directory`. */ + static readonly layer = (options: { readonly directory: string }) => Layer.effect(this, this.make(options)) +} /** * Best-effort retention sweep over every session's stored output under `/tool-output`: files diff --git a/packages/fold-agent/src/Session/SessionLayout.ts b/packages/fold-agent/src/Session/SessionLayout.ts index c54ce8b..de5df47 100644 --- a/packages/fold-agent/src/Session/SessionLayout.ts +++ b/packages/fold-agent/src/Session/SessionLayout.ts @@ -10,7 +10,7 @@ import { homedir } from 'node:os' import { join } from 'node:path' -import { EventLog, SessionId, encodedContentText, makeSessionId, usageInputTotal } from '@humanlayer/fold-core' +import { SessionId, encodedContentText, makeSessionId, usageInputTotal } from '@humanlayer/fold-core' import type { ActiveModel, AgentFinishedLogEntry, @@ -23,9 +23,9 @@ import type { SessionTitleLogEntry, UserMessageLogEntry, } from '@humanlayer/fold-core' -import { Predicate, Clock, Effect, Exit, FileSystem, Layer, Match, Option, Schema, Stream, Struct } from 'effect' +import { Predicate, Clock, Effect, Exit, FileSystem, Match, Option, Schema, Struct } from 'effect' -import { layerJsonl } from '../EventLog/JsonlLayer' +import { readJsonlEntries } from '../EventLog/JsonlLayer' import { toolOutputSessionDirFor } from '../OutputStore/OutputStore' /** Options shared by the layout helpers. */ @@ -305,12 +305,8 @@ const sessionSummary = (ref: SessionLogRef, entries: ReadonlyArray): S } const loadSessionSummary = (ref: SessionLogRef): Effect.Effect => - Effect.gen(function* () { - const eventLog = yield* EventLog - const entries = yield* Stream.runCollect(eventLog.entries()) - return sessionSummary(ref, Array.from(entries)) - }).pipe( - Effect.provide(layerJsonl(ref.path).pipe(Layer.orDie)), + readJsonlEntries(ref.path).pipe( + Effect.map((entries) => sessionSummary(ref, entries)), Effect.exit, Effect.map((exit) => (Exit.isSuccess(exit) ? exit.value : null)), ) @@ -417,28 +413,3 @@ export const sessionLogById = ( size: Number(info.size), } }) - -/** Rebuild and append one authoritative summary after session metadata changes. */ -export const refreshSessionSummaryIndex = ( - sessionId: SessionId, - options?: SessionLayoutOptions, -): Effect.Effect => - sessionLogById(sessionId, options).pipe( - Effect.flatMap((ref) => { - if (ref === null) return Effect.void - return loadSessionSummary(ref).pipe( - Effect.flatMap((summary) => - summary === null - ? Effect.void - : appendSessionIndexRecord( - SummaryIndexRecord.make({ - sourceMtimeMs: ref.mtimeMs, - sourceSize: ref.size ?? 0, - summary, - }), - options, - ), - ), - ) - }), - ) diff --git a/packages/fold-agent/src/Session/TitleGenerator.ts b/packages/fold-agent/src/Session/TitleGenerator.ts index fad5935..743fd8a 100644 --- a/packages/fold-agent/src/Session/TitleGenerator.ts +++ b/packages/fold-agent/src/Session/TitleGenerator.ts @@ -1,7 +1,6 @@ import type { AssistantMessageLogEntry, LogEntry, FoldModel, UserMessageLogEntry } from '@humanlayer/fold-core' -import { encodedContentText, languageModelLayerFor } from '@humanlayer/fold-core' -import { Predicate, Effect, Schema, type Scope } from 'effect' -import { LanguageModel } from 'effect/ai' +import { encodedContentText } from '@humanlayer/fold-core' +import { Context, Predicate, Effect, Layer, Schema, type Scope } from 'effect' const TitleResult = Schema.Struct({ title: Schema.String }) const MAX_TRANSCRIPT_CHARS = 12_000 @@ -42,24 +41,43 @@ export const titleTranscript = (entries: ReadonlyArray, rootAgentId: s .join('\n') .slice(0, MAX_TRANSCRIPT_CHARS) -/** One unlogged structured generation call. Callers persist only the resulting session_title event. */ -export const generateSessionTitle = ( - entries: ReadonlyArray, - rootAgentId: string, - model: FoldModel, -): Effect.Effect> => { - const fallback = fallbackSessionTitle(entries, rootAgentId) - const transcript = titleTranscript(entries, rootAgentId) - if (transcript.length === 0) return Effect.succeed(fallback) +/** Generates session titles over one LanguageModel built once per session. */ +export type SessionTitleGeneratorService = { + /** One unlogged structured generation call. Callers persist only the resulting session_title event. */ + readonly generate: (entries: ReadonlyArray, rootAgentId: string) => Effect.Effect +} + +/** One session's title generator. The host provides it per session; title generation asks for it. */ +export class SessionTitleGenerator extends Context.Service()( + 'fold-agent/Session/SessionTitleGenerator', + { + /** Build `model`'s LanguageModel once; every title then reuses it. */ + make: (model: FoldModel): Effect.Effect => + Effect.gen(function* () { + const languageModel = yield* model.make + + return { + generate: (entries, rootAgentId) => { + const fallback = fallbackSessionTitle(entries, rootAgentId) + const transcript = titleTranscript(entries, rootAgentId) + if (transcript.length === 0) return Effect.succeed(fallback) - return LanguageModel.generateObject({ - schema: TitleResult, - prompt: - 'Create a concise title of at most six words for this coding session. Return only the structured title.\n\n' + - transcript, - }).pipe( - Effect.provide(languageModelLayerFor(model)), - Effect.map((response) => normalizeSessionTitle(response.value.title) || fallback), - Effect.catchCause(() => Effect.succeed(fallback)), - ) + return languageModel + .generateObject({ + schema: TitleResult, + prompt: + 'Create a concise title of at most six words for this coding session. Return only the structured title.\n\n' + + transcript, + }) + .pipe( + Effect.map((response) => normalizeSessionTitle(response.value.title) || fallback), + Effect.catchCause(() => Effect.succeed(fallback)), + ) + }, + } + }), + }, +) { + /** Titles generated by `model`, whose LanguageModel is built once in the layer's scope. */ + static readonly layer = (model: FoldModel) => Layer.effect(this, this.make(model)) } diff --git a/packages/fold-agent/src/Tools/CodingTools.ts b/packages/fold-agent/src/Tools/CodingTools.ts index 29c807f..2ca4f50 100644 --- a/packages/fold-agent/src/Tools/CodingTools.ts +++ b/packages/fold-agent/src/Tools/CodingTools.ts @@ -10,7 +10,7 @@ import { type FileSystem, Layer, type Path } from 'effect' import { FetchHttpClient, type HttpClient } from 'effect/http' import type { ChildProcessSpawner } from 'effect/process' -import { layerOutputStore, type OutputStore } from '../OutputStore/OutputStore' +import { OutputStore } from '../OutputStore/OutputStore' import { bashTool, type BashToolOptions } from './BashTool' import { fileTools } from './FileTools' import { Photon } from './Image/Photon' @@ -54,5 +54,5 @@ export const layerCodingToolServices = (options: { NodeServices.layer, FetchHttpClient.layer, Photon.layer, - layerOutputStore({ directory: options.outputDirectory }).pipe(Layer.provide(NodeServices.layer)), + OutputStore.layer({ directory: options.outputDirectory }).pipe(Layer.provide(NodeServices.layer)), ) diff --git a/packages/fold-agent/src/Tools/Image/Photon.ts b/packages/fold-agent/src/Tools/Image/Photon.ts index b4693b3..baf577e 100644 --- a/packages/fold-agent/src/Tools/Image/Photon.ts +++ b/packages/fold-agent/src/Tools/Image/Photon.ts @@ -95,22 +95,22 @@ const unavailable = (error: PhotonError): PhotonService => ({ encodeJpeg: () => Effect.fail(error), }) -export class Photon extends Context.Service()('fold-agent/Tools/Image/Photon') { - /** Load photon once; a load failure yields a service whose operations fail with `unavailable`. */ - static readonly layer = Layer.effect( - Photon, - Effect.tryPromise({ - try: () => import('@silvia-odwyer/photon-node'), - catch: (cause) => new PhotonError({ reason: 'unavailable', cause }), - }).pipe( - Effect.map(fromModule), - Effect.catchTag('PhotonError', (error) => - Effect.logWarning('photon image library unavailable; images will be omitted', error.cause).pipe( - Effect.as(unavailable(error)), - ), +export class Photon extends Context.Service()('fold-agent/Tools/Image/Photon', { + /** Load photon; a load failure yields a service whose operations fail with `unavailable`. */ + make: Effect.tryPromise({ + try: () => import('@silvia-odwyer/photon-node'), + catch: (cause) => new PhotonError({ reason: 'unavailable', cause }), + }).pipe( + Effect.map(fromModule), + Effect.catchTag('PhotonError', (error) => + Effect.logWarning('photon image library unavailable; images will be omitted', error.cause).pipe( + Effect.as(unavailable(error)), ), ), - ) + ), +}) { + /** Load photon once per layer build. */ + static readonly layer = Layer.effect(this, this.make) /** A photon that failed to load with `cause`: every operation fails with reason `unavailable`. */ static readonly layerUnavailable = (cause: unknown): Layer.Layer => diff --git a/packages/fold-agent/test/OutputStore/OutputStore.vi.test.ts b/packages/fold-agent/test/OutputStore/OutputStore.vi.test.ts index 04f29a9..9133f80 100644 --- a/packages/fold-agent/test/OutputStore/OutputStore.vi.test.ts +++ b/packages/fold-agent/test/OutputStore/OutputStore.vi.test.ts @@ -6,7 +6,6 @@ import { SessionId, ToolCallId } from '@humanlayer/fold-core' import { Effect } from 'effect' import { - layerOutputStore, OutputStore, sweepToolOutput, toolOutputPathFor, @@ -30,7 +29,9 @@ it.effect('stores tool output at a deterministic session/tool-call path', () => expect(second.path).toBe(expectedPath) expect(yield* store.read(first)).toBe('one\ntwo\nthree') expect(yield* store.read(first, { offset: 2, limit: 1 })).toBe('two') - }).pipe(Effect.provide(layerOutputStore({ directory: toolOutputSessionDirFor({ sessionId, foldHome: root }) }))) + }).pipe( + Effect.provide(OutputStore.layer({ directory: toolOutputSessionDirFor({ sessionId, foldHome: root }) })), + ) }).pipe(Effect.provide(NodeFileSystem.layer)), ) @@ -43,7 +44,9 @@ it.live('sweeps old stored output files best-effort', () => const ref = yield* Effect.gen(function* () { const store = yield* OutputStore return yield* store.append(toolCallId, 'old output') - }).pipe(Effect.provide(layerOutputStore({ directory: toolOutputSessionDirFor({ sessionId, foldHome: root }) }))) + }).pipe( + Effect.provide(OutputStore.layer({ directory: toolOutputSessionDirFor({ sessionId, foldHome: root }) })), + ) const old = new Date(0) utimesSync(ref.path, old, old) expect(existsSync(ref.path)).toBe(true) diff --git a/packages/fold-agent/test/Session/TitleGenerator.vi.test.ts b/packages/fold-agent/test/Session/TitleGenerator.vi.test.ts index d9555b2..6d6db39 100644 --- a/packages/fold-agent/test/Session/TitleGenerator.vi.test.ts +++ b/packages/fold-agent/test/Session/TitleGenerator.vi.test.ts @@ -5,7 +5,7 @@ import { LanguageModel, type Response } from 'effect/ai' import { fallbackSessionTitle, - generateSessionTitle, + SessionTitleGenerator, normalizeSessionTitle, titleTranscript, } from '../../src/Session/TitleGenerator' @@ -88,14 +88,22 @@ describe('session title generation inputs', () => { { type: 'text', text: '{"title":"Harden Concurrent Session Titles"}' }, ]), ) - expect(yield* generateSessionTitle(entries, root, model)).toBe('Harden Concurrent Session Titles') + expect( + yield* SessionTitleGenerator.use((titles) => titles.generate(entries, root)).pipe( + Effect.provide(SessionTitleGenerator.layer(model)), + ), + ).toBe('Harden Concurrent Session Titles') }), ) it.effect('falls back nonfatally when the structured model call fails', () => Effect.gen(function* () { const model = modelWith(() => Effect.die(new Error('provider unavailable'))) - expect(yield* generateSessionTitle(entries, root, model)).toBe('Fix the session title cache') + expect( + yield* SessionTitleGenerator.use((titles) => titles.generate(entries, root)).pipe( + Effect.provide(SessionTitleGenerator.layer(model)), + ), + ).toBe('Fix the session title cache') }), ) }) diff --git a/packages/fold-agent/test/TestHelpers.ts b/packages/fold-agent/test/TestHelpers.ts index 655a93f..aea2186 100644 --- a/packages/fold-agent/test/TestHelpers.ts +++ b/packages/fold-agent/test/TestHelpers.ts @@ -28,7 +28,7 @@ import { Effect, FileSystem, Layer, type Path, PlatformError, Ref, Schema, type import { HttpClient, HttpClientError } from 'effect/http' import type { ChildProcessSpawner } from 'effect/process' -import { layerOutputStore, type OutputStore } from '../src/OutputStore/OutputStore' +import { OutputStore } from '../src/OutputStore/OutputStore' import { Photon } from '../src/Tools/Image/Photon' /** A scoped temp directory on the real filesystem, removed when the scope closes. */ @@ -86,7 +86,7 @@ export const makeAmbientServices: Effect.Effect< toolCallId: ToolCallId.make('tool_call_aaaaaaaaaaaaaaaaaaaaaaaa'), }), Layer.succeed(InterruptNote, { set: (text) => Ref.set(note, text) }), - layerOutputStore({ directory: outputDirectory }).pipe(Layer.provide(NodeFileSystem.layer)), + OutputStore.layer({ directory: outputDirectory }).pipe(Layer.provide(NodeFileSystem.layer)), Photon.layer, ), emitted: Ref.get(events), diff --git a/packages/fold-core/src/AgentEvents/AgentEventsLayer.ts b/packages/fold-core/src/AgentEvents/AgentEventsLayer.ts deleted file mode 100644 index cb767f3..0000000 --- a/packages/fold-core/src/AgentEvents/AgentEventsLayer.ts +++ /dev/null @@ -1,52 +0,0 @@ -/** - * This file implements the live AgentEvents layer over an unbounded PubSub, plus the ToolEventSink bridge - * that republishes ToolRuntime progress as AgentEvents deltas. Publishing is live-only: a subscriber sees - * only events published while its subscription is active, which is correct because deltas are ephemeral. - * Durable log rows reach Session.events through the EventLog subscribe merge, not through this layer. - */ -import { Effect, Layer, PubSub, Stream } from 'effect' - -import { ToolEventSink, type ToolEventSinkService } from '../ToolRuntime/ToolContextServices' -import { AgentEvents, type AgentEventsService, type FoldEvent } from './AgentEventsService' - -/** Live AgentEvents layer backed by an unbounded, live-only PubSub of ephemeral events. */ -export const liveAgentEventsLayer: Layer.Layer = Layer.effect( - AgentEvents, - Effect.gen(function* () { - const pubsub = yield* PubSub.unbounded() - - const service: AgentEventsService = { - publish: Effect.fn('fold.agent_events.publish')((event) => - PubSub.publish(pubsub, event).pipe(Effect.asVoid), - ), - subscribe: Stream.fromPubSub(pubsub), - } - - return service - }), -) - -/** - * Provide ToolEventSink by republishing each ToolRuntime progress event as an AgentEvents `tool-progress` - * delta. This is the seam that lifts ephemeral tool progress into the merged session event stream, keeping - * ToolRuntime unaware of AgentEvents. - */ -export const toolEventSinkLayerFromAgentEvents: Layer.Layer = Layer.effect( - ToolEventSink, - Effect.gen(function* () { - const agentEvents = yield* AgentEvents - - const service: ToolEventSinkService = { - emit: (event) => - agentEvents.publish({ - kind: 'delta', - agentId: event.agentId, - parentAgentId: event.parentAgentId, - toolCallId: event.toolCallId, - part: { type: 'tool-progress', toolName: event.toolName, payload: event.payload }, - }), - } - - return service - }), -) diff --git a/packages/fold-core/src/AgentEvents/AgentEventsService.ts b/packages/fold-core/src/AgentEvents/AgentEventsService.ts index 0db942d..5548bf6 100644 --- a/packages/fold-core/src/AgentEvents/AgentEventsService.ts +++ b/packages/fold-core/src/AgentEvents/AgentEventsService.ts @@ -3,8 +3,12 @@ * carries the merged `FoldEvent` shape that Session exposes: durable `log` rows (folded in by * Session.events from the EventLog) and ephemeral `delta` fragments (streaming text/reasoning and tool * progress) that are published live and never persisted. AgentEvents itself only holds the delta side. + * + * The live AgentEvents service runs over an unbounded PubSub. Publishing is live-only: a subscriber sees + * only events published while its subscription is active, which is correct because deltas are ephemeral. + * Durable log rows reach Session.events through the EventLog subscribe merge, not through this service. */ -import { Context, type Effect, type Schema, type Stream } from 'effect' +import { Context, Effect, type Schema, Stream, Layer, PubSub } from 'effect' import type { LogEntry } from '../EventLog/Schemas' import type { AgentId, ToolCallId } from '../Ids' @@ -38,5 +42,21 @@ export type AgentEventsService = { readonly subscribe: Stream.Stream } -/** Ephemeral event spine. One provided instance represents one session, mirroring EventLog. */ -export class AgentEvents extends Context.Service()('fold/AgentEvents') {} +/** AgentEvents service tag. */ +export class AgentEvents extends Context.Service()('fold/AgentEvents', { + /** Backed by an unbounded, live-only PubSub of ephemeral events. */ + make: Effect.gen(function* () { + const pubsub = yield* PubSub.unbounded() + + const service: AgentEventsService = { + publish: Effect.fn('fold.agent_events.publish')((event) => + PubSub.publish(pubsub, event).pipe(Effect.asVoid), + ), + subscribe: Stream.fromPubSub(pubsub), + } + + return service + }), +}) { + static readonly layer = Layer.effect(this, this.make) +} diff --git a/packages/fold-core/src/AgentRuntime/AgentRuntimeLayer.ts b/packages/fold-core/src/AgentRuntime/AgentRuntimeLayer.ts deleted file mode 100644 index 961f4f1..0000000 --- a/packages/fold-core/src/AgentRuntime/AgentRuntimeLayer.ts +++ /dev/null @@ -1,678 +0,0 @@ -/** - * This file implements the live AgentRuntime layer - the imperative model loop for one agent. Each run - * appends the user message, then loops turns: drain steering, run the top-of-turn auto-compaction - * check (D11), project messages from the EventLog, build the prompt, apply preRequest hooks, call the - * LanguageModel, persist the assistant message with fold tool-call ids (stashing provider ids in part - * options), settle tool calls through ToolRuntime, and consult onComplete hooks before writing the - * terminal agent-finished entry. Provider context-overflow failures take the reactive compaction path - * (compact and restart the turn, once per run) before becoming durable error outcomes. While the model streams, each - * text/reasoning delta is republished live through AgentEvents so UIs can render output as it arrives; - * those deltas are ephemeral and never persisted. Model provider failures become durable error + - * agent-finished entries, never service failures. - */ -import { Array as Arr, Data, Effect, Layer, Predicate, Ref, Result, Schema, Stream } from 'effect' -import { LanguageModel, Prompt, type Response, type Toolkit } from 'effect/ai' - -import { AgentEvents } from '../AgentEvents/AgentEventsService' -import { CompactionArchiveAccess } from '../Compaction/CompactionArchiveAccess' -import { isContextOverflowError } from '../Compaction/CompactionEngine' -import { Compaction, type CompactionTrigger } from '../Compaction/CompactionService' -import { EventLog } from '../EventLog/EventLogService' -import { - LogEntryInputs, - type ActiveModel, - type AgentFinishedLogEntry, - type AgentFinishedOutcome, - type CompactionLogEntry, - type LogEntry, - type LogEntryInput, -} from '../EventLog/Schemas' -import { usageFromResponseUsage } from '../EventLog/Usage' -import { HookRunner } from '../HookRunner/HookRunnerService' -import { Ids, type AgentId, type ToolCallId } from '../Ids' -import { ModelRequestSettings } from '../Model/ModelRequestSettings' -import { buildPrompt, providerToolCallIdKey, foldPartOptionsKey } from '../Model/RequestBuilder' -import { messagesForAgent, runtimeForAgent } from '../Projection/Projection' -import { SessionControls } from '../Session/SessionControls' -import { - initialDoomLoopState, - observeDoomLoop, - StopConditions, - type DoomLoopState, - type ToolCallFingerprintInput, -} from '../StopConditions/StopConditions' -import { SystemPrompt } from '../SystemPrompt/SystemPromptService' -import { StopController, type StopControllerService } from '../ToolRuntime/ToolContextServices' -import { ToolRuntime } from '../ToolRuntime/ToolRuntimeService' -import { ToolsetResolver } from '../ToolRuntime/ToolsetResolverService' -import { type AnyTool, Toolset } from '../ToolRuntime/ToolsetService' -import { - AgentRuntime, - type AgentRuntimeService, - type CompactAgentInput, - type RunAgentInput, - type StartAgentInput, - type SwitchModelInput, -} from './AgentRuntimeService' - -const encodeSystemMessage = Schema.encodeUnknownSync(Prompt.SystemMessage) - -const anthropicEphemeralCacheControl = { type: 'ephemeral' } as const - -const leadingSystemMessageFor = (content: string, cacheBreakpoint: boolean): Prompt.SystemMessage => { - const options = cacheBreakpoint ? { anthropic: { cacheControl: anthropicEphemeralCacheControl } } : undefined - return Prompt.systemMessage({ content, options }) -} -const encodeUserMessage = Schema.encodeUnknownSync(Prompt.UserMessage) -const encodeAssistantMessage = Schema.encodeUnknownSync(Prompt.AssistantMessage) - -/** Result of one private model/tool turn. */ -type TurnResult = Data.TaggedEnum<{ finished: { readonly entry: AgentFinishedLogEntry }; continue: {} }> -const TurnResult = Data.taggedEnum() - -type CompactionEnvelope = Pick - -/** Concatenate the text parts of an assistant message, or null when it produced no text. */ -const assistantResultText = (message: Prompt.AssistantMessage): string | null => { - const text = message.content.flatMap((part) => (part.type === 'text' ? [part.text] : [])).join('') - - return text.length > 0 ? text : null -} - -/** - * The tool as handed to `streamText`, which accepts any params while it reads the model's reply. - * - * With tool resolution disabled, `streamText` decodes each tool call's params against - * `tool.setParameters(Schema.toEncoded(tool.parametersSchema))`, so one bad call fails the whole reply and - * ends the run. Fold decodes params when it runs the tool (`toolkit.handle`), which turns a bad call into a - * failed tool result the model can fix. Only `setParameters` changes: providers build the tool's JSON - * schema from the unchanged `parametersSchema`, so the model sees the same schema. - */ -const acceptAnyParamsInReply = (tool: AnyTool): AnyTool => - Object.assign(Object.create(Object.getPrototypeOf(tool)), tool, { - setParameters: () => tool.setParameters(Schema.Unknown), - }) - -/** Live AgentRuntime layer wiring the EventLog, hooks, model, and tool settlement into the loop. */ -export const liveAgentRuntimeLayer: Layer.Layer< - AgentRuntime, - never, - | EventLog - | Ids - | HookRunner - | Toolset - | ToolsetResolver - | SystemPrompt - | ModelRequestSettings - | ToolRuntime - | LanguageModel.LanguageModel - | AgentEvents - | SessionControls -> = Layer.effect( - AgentRuntime, - Effect.gen(function* () { - const eventLog = yield* EventLog - const ids = yield* Ids - const hooks = yield* HookRunner - const toolset = yield* Toolset - const toolsetResolver = yield* ToolsetResolver - const systemPrompt = yield* SystemPrompt - const modelRequestSettings = yield* ModelRequestSettings - const toolRuntime = yield* ToolRuntime - const languageModel = yield* LanguageModel.LanguageModel - const agentEvents = yield* AgentEvents - const sessionControls = yield* SessionControls - // Defaulted reference (D11): resolves the provisioned live policy, or the disabled no-op. The - // provisioning builds it over this runtime's own LanguageModel and the session ModelCatalog. - const compaction = yield* Compaction - // Defaulted reference: host-specific post-compaction archive/log access guidance. - const compactionArchiveAccess = yield* CompactionArchiveAccess - const stopConditions = yield* StopConditions - - const appendToEventLog = (input: LogEntryInput): Effect.Effect => - eventLog.append(input).pipe(Effect.orDie) - - const collectEntries: Effect.Effect> = Stream.runCollect(eventLog.entries()).pipe( - Effect.orDie, - Effect.map((entries): ReadonlyArray => entries), - ) - - const appendUserMessage = (input: RunAgentInput, text: string): Effect.Effect => - ids.makeMessageId.pipe( - Effect.flatMap((messageId) => - appendToEventLog( - LogEntryInputs['user-message']({ - agentId: input.agentId, - parentAgentId: input.parentAgentId, - toolCallId: input.toolCallId, - messageId, - message: encodeUserMessage(Prompt.userMessage({ content: [Prompt.textPart({ text })] })), - }), - ), - ), - Effect.asVoid, - ) - - const appendFinished = ( - input: RunAgentInput, - outcome: AgentFinishedOutcome, - resultText: string | null, - reason: string | null, - ): Effect.Effect => - Effect.gen(function* () { - const entry = yield* appendToEventLog( - LogEntryInputs['agent-finished']({ - agentId: input.agentId, - parentAgentId: input.parentAgentId, - toolCallId: input.toolCallId, - outcome, - resultText, - reason, - }), - ) - - if (Predicate.isTagged(entry, 'agent-finished')) return entry - - // Invariant! - return yield* Effect.die(new Error(`EventLog returned ${entry._tag} while appending agent-finished`)) - }) - - /** Rewrite provider tool-call ids to freshly minted fold ids, stashing the provider id per part. */ - const rewriteAssistantToolCallIds = ( - message: Prompt.AssistantMessage, - ): Effect.Effect => - Effect.gen(function* () { - const content: Array = [] - - for (const part of message.content) { - if (part.type !== 'tool-call') { - content.push(part) - continue - } - - const foldId = yield* ids.makeToolCallId - content.push( - Prompt.toolCallPart({ - id: foldId, - name: part.name, - params: part.params, - providerExecuted: part.providerExecuted, - options: { ...part.options, [foldPartOptionsKey]: { [providerToolCallIdKey]: part.id } }, - }), - ) - } - - return Prompt.assistantMessage({ content, options: message.options }) - }) - - /** - * Run one compaction against the agent's current projection (D11): plan through the Compaction - * service - the summarization call runs on this runtime's own LanguageModel, so every agent - * (root or subagent) summarizes with its own model - and append the durable `compaction` entry - * under this run's envelope. A summarization failure never kills the run: it lands as a durable - * `error` note and the turn proceeds uncompacted. Returns the written compaction entry, if any. - */ - const performCompaction = ( - input: CompactionEnvelope, - entries: ReadonlyArray, - model: ActiveModel | null, - trigger: CompactionTrigger, - additionalInstructions: string | null = null, - ): Effect.Effect => - Effect.gen(function* () { - const planned = yield* compaction - .plan({ - agentId: input.agentId, - entries, - model, - trigger, - additionalInstructions, - }) - .pipe(Effect.result) - - if (Result.isFailure(planned)) { - yield* appendToEventLog( - LogEntryInputs['error']({ - agentId: input.agentId, - parentAgentId: input.parentAgentId, - toolCallId: input.toolCallId, - errorType: 'compaction', - message: planned.failure.message, - details: { trigger }, - }), - ) - return null - } - - if (planned.success === null) return null - const postCompactionInstructions = yield* compactionArchiveAccess.instructions({ - agentId: input.agentId, - parentAgentId: input.parentAgentId, - trigger, - }) - - const compactionEntry = { - agentId: input.agentId, - parentAgentId: input.parentAgentId, - toolCallId: input.toolCallId, - compactionId: yield* ids.makeCompactionId, - prompt: planned.success.prompt, - summary: planned.success.summary, - replacesThroughSeq: planned.success.replacesThroughSeq, - tokensBefore: planned.success.tokensBefore, - } - const entry = yield* appendToEventLog( - LogEntryInputs['compaction']( - postCompactionInstructions === null - ? compactionEntry - : { ...compactionEntry, postCompactionInstructions }, - ), - ) - - if (Predicate.isTagged(entry, 'compaction')) return entry - return yield* Effect.die(new Error(`EventLog returned ${entry._tag} while appending compaction`)) - }) - - /** Settle one assistant message's tool calls, then decide whether the run stops or continues. */ - const settleToolCalls = ( - input: RunAgentInput, - persistedAssistant: Prompt.AssistantMessage, - toolCalls: ReadonlyArray, - doomLoopRef: Ref.Ref, - ): Effect.Effect => - Effect.gen(function* () { - const doomLoop = observeDoomLoop(stopConditions, yield* Ref.get(doomLoopRef), toolCalls) - yield* Ref.set(doomLoopRef, doomLoop.state) - - const settlement = yield* toolRuntime.settle({ - agentId: input.agentId, - parentAgentId: input.parentAgentId, - assistantMessage: persistedAssistant, - }) - - if (settlement.stopRequested) { - const entry = yield* appendFinished(input, 'stopped', null, 'a tool or hook requested a stop') - return TurnResult.finished({ entry }) - } - - // D9: a session-wide stop lets the in-flight batch finish and its results land (above), - // then ends the run here - no further LLM call. - const sessionStopAfterBatch = yield* sessionControls.sessionStopReason - if (sessionStopAfterBatch !== null) { - const entry = yield* appendFinished(input, 'stopped', null, sessionStopAfterBatch) - return TurnResult.finished({ entry }) - } - - if (doomLoop.reason !== null) { - const entry = yield* appendFinished(input, 'stopped', null, doomLoop.reason) - return TurnResult.finished({ entry }) - } - - return TurnResult.continue() - }) - - /** Run one model turn: build the request, call the model, persist, and settle tool calls. */ - const runTurn = ( - input: RunAgentInput, - stopController: StopControllerService, - stopRef: Ref.Ref, - overflowRecoveryRef: Ref.Ref, - doomLoopRef: Ref.Ref, - ): Effect.Effect => - Effect.gen(function* () { - // Drain queued steering before this turn's model call (D8): each steered message becomes an - // ordinary user-message right here, so the log records it exactly where the model saw it. - const steered = yield* sessionControls.drainSteering(input.agentId) - for (const text of steered) { - yield* appendUserMessage(input, text) - } - - const entriesBeforeCompaction = yield* collectEntries - const runtimeState = runtimeForAgent(entriesBeforeCompaction, input.agentId) - - // Top-of-turn compaction check (D11): compare the last post-compaction API-reported usage - // against the model's usable budget, and compact BEFORE building this turn's request so - // the projection the model sees is the compacted one. Runs identically for root agents - // and subagents - each against its own projection and its own model's limits (D21). - const compacted = (yield* compaction.shouldCompact({ - agentId: input.agentId, - entries: entriesBeforeCompaction, - model: runtimeState.activeModel, - })) - ? yield* performCompaction(input, entriesBeforeCompaction, runtimeState.activeModel, 'threshold') - : false - - const entries = compacted ? yield* collectEntries : entriesBeforeCompaction - const projected = messagesForAgent(entries, input.agentId) - const prompt = yield* buildPrompt(projected).pipe(Effect.orDie) - - // Hook typed failures are resolved at authorship (D16); an escaped HookExecutionError is a - // defect here. preRequest/onComplete defect policy is owned by the in-flight defect work. - const preRequestDecision = yield* hooks - .preRequest({ agentId: input.agentId, parentAgentId: input.parentAgentId, prompt }) - .pipe(Effect.provideService(StopController, stopController), Effect.orDie) - - const requestPrompt = Predicate.isTagged(preRequestDecision, 'changed') - ? preRequestDecision.prompt - : prompt - - // Both stop tracks bind before the model call: this run's own StopController (a preRequest - // hook requested it) and the session-wide signal (D9 - external Session.stop reaches every - // agent's loop, so the whole tree stops at its batch boundaries). - const stopReasonAfterPreRequest = - (yield* Ref.get(stopRef)) ?? (yield* sessionControls.sessionStopReason) - if (stopReasonAfterPreRequest !== null) { - const entry = yield* appendFinished(input, 'stopped', null, stopReasonAfterPreRequest) - return TurnResult.finished({ entry }) - } - - // Advertise only the epoch's active toolset (agent_started/tools-change fold): the installed - // toolkit is filtered to the projected active names, so a tools-change entry binds the very - // next request. Handlers stay untouched - settlement still executes against the full Toolset. - const withHandler = yield* toolset.withHandler - const activeToolEntries = Object.entries(withHandler.tools) - .filter(([name]) => runtimeState.activeTools.includes(name)) - .map(([name, tool]) => [name, acceptAnyParamsInReply(tool)] as const) - const toolkit: Toolkit.WithHandler> = { - tools: Object.fromEntries(activeToolEntries), - handle: withHandler.handle, - } - - // Tap the live model stream: republish each streamed text/reasoning delta as an ephemeral AgentEvents - // delta so UIs can render output as it arrives. All other part types publish nothing, and deltas never - // enter the durable log. A failing stream fails before any part, so failure turns publish no deltas. - // The whole collection runs under the active model request settings, so the provider reads the - // projected reasoning configuration when it builds the request (thinking-change binds next turn). - const modelParts = yield* Stream.runCollect( - languageModel.streamText({ prompt: requestPrompt, toolkit, disableToolCallResolution: true }).pipe( - Stream.tap((part) => - part.type === 'text-delta' || part.type === 'reasoning-delta' - ? agentEvents.publish({ - kind: 'delta', - agentId: input.agentId, - parentAgentId: input.parentAgentId, - toolCallId: input.toolCallId, - part: - part.type === 'text-delta' - ? { type: 'text-delta', id: part.id, delta: part.delta } - : { type: 'reasoning-delta', id: part.id, delta: part.delta }, - }) - : Effect.void, - ), - ), - ).pipe( - modelRequestSettings.wrap({ - model: runtimeState.activeModel, - reasoningLevel: runtimeState.reasoningLevel, - promptCacheKey: runtimeState.promptCacheKey, - }), - Effect.result, - ) - - if (Result.isFailure(modelParts)) { - const message = modelParts.failure.message - - // Reactive overflow path (D11): when the provider says the request exceeded the context - // window, compact and restart the turn - once per run. A recovered attempt writes no - // error entry (transient, like a within-provider retry); a second overflow, or nothing - // safely compactable, falls through to the durable error outcome below. - if ( - compaction.enabled && - isContextOverflowError(message) && - !(yield* Ref.get(overflowRecoveryRef)) - ) { - yield* Ref.set(overflowRecoveryRef, true) - const recovered = yield* performCompaction( - input, - yield* collectEntries, - runtimeState.activeModel, - 'overflow', - ) - if (recovered) return TurnResult.continue() - } - - yield* appendToEventLog( - LogEntryInputs['error']({ - agentId: input.agentId, - parentAgentId: input.parentAgentId, - toolCallId: input.toolCallId, - errorType: 'model', - message, - details: {}, - }), - ) - - const entry = yield* appendFinished(input, 'error', null, message) - return TurnResult.finished({ entry }) - } - - const parts: ReadonlyArray = modelParts.success - const finishPart = parts.findLast((part): part is Response.FinishPart => part.type === 'finish') - const responseMessages = Prompt.fromResponseParts(parts) - const assistantMessage = responseMessages.content.find( - (message): message is Prompt.AssistantMessage => message.role === 'assistant', - ) - - if (assistantMessage === undefined) { - const entry = yield* appendFinished(input, 'completed', null, null) - return TurnResult.finished({ entry }) - } - - const persistedAssistant = yield* rewriteAssistantToolCallIds(assistantMessage) - - yield* appendToEventLog( - LogEntryInputs['assistant-message']({ - agentId: input.agentId, - parentAgentId: input.parentAgentId, - toolCallId: input.toolCallId, - messageId: yield* ids.makeMessageId, - message: encodeAssistantMessage(persistedAssistant), - finish: - finishPart === undefined - ? null - : { reason: finishPart.reason, usage: usageFromResponseUsage(finishPart.usage) }, - }), - ) - - const toolCalls = persistedAssistant.content.flatMap((part) => - part.type === 'tool-call' ? [{ name: part.name, params: part.params }] : [], - ) - const hasToolCalls = Arr.isArrayNonEmpty(toolCalls) - - if (hasToolCalls) { - return yield* settleToolCalls(input, persistedAssistant, toolCalls, doomLoopRef) - } - - yield* Ref.set(doomLoopRef, initialDoomLoopState) - - const resultText = assistantResultText(persistedAssistant) - - const onCompleteDecision = yield* hooks - .onComplete({ agentId: input.agentId, parentAgentId: input.parentAgentId, resultText }) - .pipe(Effect.provideService(StopController, stopController), Effect.orDie) - - const stopReasonAfterComplete = yield* Ref.get(stopRef) - if (stopReasonAfterComplete !== null) { - const entry = yield* appendFinished(input, 'stopped', resultText, stopReasonAfterComplete) - return TurnResult.finished({ entry }) - } - - if (Predicate.isTagged(onCompleteDecision, 'continueWith')) { - yield* appendUserMessage(input, onCompleteDecision.text) - return TurnResult.continue() - } - - // D8: follow-ups queued while this agent was running drain exactly where the run would - // complete naturally - each becomes an ordinary user-message and the run continues. - const followUps = yield* sessionControls.drainFollowUps(input.agentId) - if (Arr.isReadonlyArrayNonEmpty(followUps)) { - for (const text of followUps) { - yield* appendUserMessage(input, text) - } - return TurnResult.continue() - } - - const entry = yield* appendFinished(input, 'completed', resultText, null) - return TurnResult.finished({ entry }) - }) - - /** Shared epoch fields: whose log rows these are, and which model's leading prompt to compose. */ - type LeadingSystemMessageInput = { - readonly agentId: AgentId - readonly parentAgentId: AgentId | null - readonly toolCallId: ToolCallId | null - readonly model: ActiveModel - readonly systemPrompt: ReadonlyArray | null - } - - /** Compose and append one epoch's leading system-message block set (agent start and model switch). */ - const appendLeadingSystemMessage = (input: LeadingSystemMessageInput): Effect.Effect => - Effect.gen(function* () { - const agentBlocks = input.systemPrompt ?? [] - - const blocks = yield* systemPrompt.compose({ model: input.model, agentBlocks }) - - if (Arr.isReadonlyArrayNonEmpty(blocks)) { - yield* appendToEventLog( - LogEntryInputs['system-message']({ - agentId: input.agentId, - parentAgentId: input.parentAgentId, - toolCallId: input.toolCallId, - messageId: yield* ids.makeMessageId, - messages: Arr.map(blocks, (content, index) => - encodeSystemMessage(leadingSystemMessageFor(content, index === blocks.length - 1)), - ), - placement: 'leading', - }), - ) - } - }) - - const start: AgentRuntimeService['start'] = Effect.fn('fold.agent_runtime.start')((input: StartAgentInput) => - Effect.gen(function* () { - // Epoch-open choreography (D17): resolve the family toolset and compose the leading system - // prompt block set once for the starting model; both are recorded durably. - const resolvedToolset = yield* toolsetResolver.resolve({ model: input.model }) - - const agentStarted = { - agentId: input.agentId, - parentAgentId: input.parentAgentId, - toolCallId: input.toolCallId, - mode: input.mode, - model: input.model, - tools: resolvedToolset.names, - skill: input.skill, - fork: input.fork, - agentType: input.agentType, - } - const promptCacheKey = input.promptCacheKey ?? null - const entry = yield* appendToEventLog( - LogEntryInputs['agent_started']( - promptCacheKey === null ? agentStarted : { ...agentStarted, promptCacheKey }, - ), - ) - - // A fork appends no leading system message: its projection folds the forked-from agent's - // history, leading blocks included, keeping the fork's prompt prefix byte-identical for - // provider-cache reuse (D21). - if (input.mode === 'fresh') { - yield* appendLeadingSystemMessage(input) - } - - if (Predicate.isTagged(entry, 'agent_started')) return entry - - // Invariant! - return yield* Effect.die(new Error(`EventLog returned ${entry._tag} while appending agent_started`)) - }), - ) - - const switchModel: AgentRuntimeService['switchModel'] = Effect.fn('fold.agent_runtime.switch_model')( - (input: SwitchModelInput) => - Effect.gen(function* () { - // Epoch-transition choreography (D17): a model switch opens a new epoch, so the leading - // system prompt and toolset re-resolve for the new family and all three facts land durably. - // The next run's projection binds them; the caller swaps the LanguageModel layer (D15). - const previousLevel = runtimeForAgent(yield* collectEntries, input.agentId).reasoningLevel - - yield* appendToEventLog( - LogEntryInputs['model-change']({ - agentId: input.agentId, - parentAgentId: input.parentAgentId, - toolCallId: input.toolCallId, - model: input.model, - reason: input.reason, - }), - ) - - yield* appendLeadingSystemMessage(input) - - const resolvedToolset = yield* toolsetResolver.resolve({ model: input.model }) - yield* appendToEventLog( - LogEntryInputs['tools-change']({ - agentId: input.agentId, - parentAgentId: input.parentAgentId, - toolCallId: input.toolCallId, - tools: resolvedToolset.names, - reason: input.reason, - }), - ) - - // Reasoning is part of the switched configuration: when the incoming model's requested - // level differs from the projected level, the change lands as its own durable fact. The - // model-change fold already rebinds the level (D23 - not an epoch boundary), so this entry - // is written for log legibility and skipped when nothing changed. - if (previousLevel !== input.model.requestedReasoningLevel) { - yield* appendToEventLog( - LogEntryInputs['thinking-change']({ - agentId: input.agentId, - parentAgentId: input.parentAgentId, - toolCallId: input.toolCallId, - reasoningLevel: input.model.requestedReasoningLevel, - reason: input.reason, - }), - ) - } - }), - ) - - const run: AgentRuntimeService['run'] = Effect.fn('fold.agent_runtime.run')((input: RunAgentInput) => - Effect.gen(function* () { - const stopRef = yield* Ref.make(null) - const stopController: StopControllerService = { - requestStop: (reason) => Ref.set(stopRef, reason), - isStopRequested: Ref.get(stopRef).pipe(Effect.map((reason) => reason !== null)), - } - // One reactive compact-and-retry per run (D11's guard, pi's single-attempt precedent). - const overflowRecoveryRef = yield* Ref.make(false) - const doomLoopRef = yield* Ref.make(initialDoomLoopState) - - for (const text of input.messages) { - yield* appendUserMessage(input, text) - } - - while (true) { - const turn = yield* runTurn(input, stopController, stopRef, overflowRecoveryRef, doomLoopRef) - if (Predicate.isTagged(turn, 'finished')) return turn.entry - } - }), - ) - - const compact: AgentRuntimeService['compact'] = Effect.fn('fold.agent_runtime.compact')( - (input: CompactAgentInput) => - Effect.gen(function* () { - const entries = yield* collectEntries - const runtimeState = runtimeForAgent(entries, input.agentId) - - return yield* performCompaction( - input, - entries, - runtimeState.activeModel, - input.trigger, - input.additionalInstructions ?? null, - ) - }), - ) - - return { start, run, switchModel, compact } - }), -) diff --git a/packages/fold-core/src/AgentRuntime/AgentRuntimeService.ts b/packages/fold-core/src/AgentRuntime/AgentRuntimeService.ts index 02661df..ba7ca7e 100644 --- a/packages/fold-core/src/AgentRuntime/AgentRuntimeService.ts +++ b/packages/fold-core/src/AgentRuntime/AgentRuntimeService.ts @@ -3,20 +3,57 @@ * code consumes this service to start an agent and run it to its next terminal marker, while the live * implementation consumes EventLog, Ids, HookRunner, Toolset, ToolRuntime, and LanguageModel to loop * model turns and tool settlements until the agent finishes. + * + * The live service is the imperative model loop for one agent. Each run + * appends the user message, then loops turns: drain steering, run the top-of-turn auto-compaction + * check (D11), project messages from the EventLog, build the prompt, apply preRequest hooks, call the + * LanguageModel, persist the assistant message with fold tool-call ids (stashing provider ids in part + * options), settle tool calls through ToolRuntime, and consult onComplete hooks before writing the + * terminal agent-finished entry. Provider context-overflow failures take the reactive compaction path + * (compact and restart the turn, once per run) before becoming durable error outcomes. While the model streams, each + * text/reasoning delta is republished live through AgentEvents so UIs can render output as it arrives; + * those deltas are ephemeral and never persisted. Model provider failures become durable error + + * agent-finished entries, never service failures. */ -import { Context } from 'effect' -import type { Array as Arr, Effect } from 'effect' - -import type { CompactionTrigger } from '../Compaction/CompactionService' -import type { - ActiveModel, - AgentFinishedLogEntry, - AgentFork, - AgentLaunchMode, - AgentStartedLogEntry, - CompactionLogEntry, +import { Context, Array as Arr, Effect, Data, Layer, Predicate, Ref, Result, Schema, Stream } from 'effect' +import { LanguageModel, Prompt, type Response, type Toolkit } from 'effect/ai' + +import { AgentEvents } from '../AgentEvents/AgentEventsService' +import { CompactionArchiveAccess } from '../Compaction/CompactionArchiveAccess' +import { isContextOverflowError } from '../Compaction/CompactionEngine' +import { type CompactionTrigger, Compaction } from '../Compaction/CompactionService' +import { EventLog } from '../EventLog/EventLogService' +import { + type ActiveModel, + type AgentFinishedLogEntry, + type AgentFork, + type AgentLaunchMode, + type AgentStartedLogEntry, + type CompactionLogEntry, + LogEntryInputs, + type AgentFinishedOutcome, + type LogEntry, + type LogEntryInput, } from '../EventLog/Schemas' -import type { AgentId, ToolCallId } from '../Ids' +import { usageFromResponseUsage } from '../EventLog/Usage' +import { HookRunner } from '../HookRunner/HookRunnerService' +import { type AgentId, type ToolCallId, Ids } from '../Ids' +import { ModelRequestSettings } from '../Model/ModelRequestSettings' +import { buildPrompt, providerToolCallIdKey, foldPartOptionsKey } from '../Model/RequestBuilder' +import { messagesForAgent, runtimeForAgent } from '../Projection/Projection' +import { SessionControls } from '../Session/SessionControls' +import { + initialDoomLoopState, + observeDoomLoop, + StopConditions, + type DoomLoopState, + type ToolCallFingerprintInput, +} from '../StopConditions/StopConditions' +import { SystemPrompt } from '../SystemPrompt/SystemPromptService' +import { StopController, type StopControllerService } from '../ToolRuntime/ToolContextServices' +import { ToolRuntime } from '../ToolRuntime/ToolRuntimeService' +import { ToolsetResolver } from '../ToolRuntime/ToolsetResolverService' +import { type AnyTool, Toolset } from '../ToolRuntime/ToolsetService' /** Input for starting one agent. parentAgentId and toolCallId are null for root agents. */ export type StartAgentInput = { @@ -88,5 +125,612 @@ export type AgentRuntimeService = { readonly compact: (input: CompactAgentInput) => Effect.Effect } +const encodeSystemMessage = Schema.encodeUnknownSync(Prompt.SystemMessage) + +const anthropicEphemeralCacheControl = { type: 'ephemeral' } as const + +const leadingSystemMessageFor = (content: string, cacheBreakpoint: boolean): Prompt.SystemMessage => { + const options = cacheBreakpoint ? { anthropic: { cacheControl: anthropicEphemeralCacheControl } } : undefined + return Prompt.systemMessage({ content, options }) +} +const encodeUserMessage = Schema.encodeUnknownSync(Prompt.UserMessage) +const encodeAssistantMessage = Schema.encodeUnknownSync(Prompt.AssistantMessage) + +/** Result of one private model/tool turn. */ +type TurnResult = Data.TaggedEnum<{ finished: { readonly entry: AgentFinishedLogEntry }; continue: {} }> +const TurnResult = Data.taggedEnum() + +type CompactionEnvelope = Pick + +/** Concatenate the text parts of an assistant message, or null when it produced no text. */ +const assistantResultText = (message: Prompt.AssistantMessage): string | null => { + const text = message.content.flatMap((part) => (part.type === 'text' ? [part.text] : [])).join('') + + return text.length > 0 ? text : null +} + +/** + * The tool as handed to `streamText`, which accepts any params while it reads the model's reply. + * + * With tool resolution disabled, `streamText` decodes each tool call's params against + * `tool.setParameters(Schema.toEncoded(tool.parametersSchema))`, so one bad call fails the whole reply and + * ends the run. Fold decodes params when it runs the tool (`toolkit.handle`), which turns a bad call into a + * failed tool result the model can fix. Only `setParameters` changes: providers build the tool's JSON + * schema from the unchanged `parametersSchema`, so the model sees the same schema. + */ +const acceptAnyParamsInReply = (tool: AnyTool): AnyTool => + Object.assign(Object.create(Object.getPrototypeOf(tool)), tool, { + setParameters: () => tool.setParameters(Schema.Unknown), + }) + /** AgentRuntime service tag. */ -export class AgentRuntime extends Context.Service()('fold/AgentRuntime') {} +export class AgentRuntime extends Context.Service()('fold/AgentRuntime', { + /** Wires the EventLog, hooks, model, and tool settlement into the loop. */ + make: Effect.gen(function* () { + const eventLog = yield* EventLog + const ids = yield* Ids + const hooks = yield* HookRunner + const toolset = yield* Toolset + const toolsetResolver = yield* ToolsetResolver + const systemPrompt = yield* SystemPrompt + const modelRequestSettings = yield* ModelRequestSettings + const toolRuntime = yield* ToolRuntime + const languageModel = yield* LanguageModel.LanguageModel + const agentEvents = yield* AgentEvents + const sessionControls = yield* SessionControls + // Defaulted reference (D11): resolves the provisioned live policy, or the disabled no-op. The + // provisioning builds it over this runtime's own LanguageModel and the session ModelCatalog. + const compaction = yield* Compaction + // Defaulted reference: host-specific post-compaction archive/log access guidance. + const compactionArchiveAccess = yield* CompactionArchiveAccess + const stopConditions = yield* StopConditions + + const appendToEventLog = (input: LogEntryInput): Effect.Effect => + eventLog.append(input).pipe(Effect.orDie) + + const collectEntries: Effect.Effect> = Stream.runCollect(eventLog.entries()).pipe( + Effect.orDie, + Effect.map((entries): ReadonlyArray => entries), + ) + + const appendUserMessage = (input: RunAgentInput, text: string): Effect.Effect => + ids.makeMessageId.pipe( + Effect.flatMap((messageId) => + appendToEventLog( + LogEntryInputs['user-message']({ + agentId: input.agentId, + parentAgentId: input.parentAgentId, + toolCallId: input.toolCallId, + messageId, + message: encodeUserMessage(Prompt.userMessage({ content: [Prompt.textPart({ text })] })), + }), + ), + ), + Effect.asVoid, + ) + + const appendFinished = ( + input: RunAgentInput, + outcome: AgentFinishedOutcome, + resultText: string | null, + reason: string | null, + ): Effect.Effect => + Effect.gen(function* () { + const entry = yield* appendToEventLog( + LogEntryInputs['agent-finished']({ + agentId: input.agentId, + parentAgentId: input.parentAgentId, + toolCallId: input.toolCallId, + outcome, + resultText, + reason, + }), + ) + + if (Predicate.isTagged(entry, 'agent-finished')) return entry + + // Invariant! + return yield* Effect.die(new Error(`EventLog returned ${entry._tag} while appending agent-finished`)) + }) + + /** Rewrite provider tool-call ids to freshly minted fold ids, stashing the provider id per part. */ + const rewriteAssistantToolCallIds = ( + message: Prompt.AssistantMessage, + ): Effect.Effect => + Effect.gen(function* () { + const content: Array = [] + + for (const part of message.content) { + if (part.type !== 'tool-call') { + content.push(part) + continue + } + + const foldId = yield* ids.makeToolCallId + content.push( + Prompt.toolCallPart({ + id: foldId, + name: part.name, + params: part.params, + providerExecuted: part.providerExecuted, + options: { ...part.options, [foldPartOptionsKey]: { [providerToolCallIdKey]: part.id } }, + }), + ) + } + + return Prompt.assistantMessage({ content, options: message.options }) + }) + + /** + * Run one compaction against the agent's current projection (D11): plan through the Compaction + * service - the summarization call runs on this runtime's own LanguageModel, so every agent + * (root or subagent) summarizes with its own model - and append the durable `compaction` entry + * under this run's envelope. A summarization failure never kills the run: it lands as a durable + * `error` note and the turn proceeds uncompacted. Returns the written compaction entry, if any. + */ + const performCompaction = ( + input: CompactionEnvelope, + entries: ReadonlyArray, + model: ActiveModel | null, + trigger: CompactionTrigger, + additionalInstructions: string | null = null, + ): Effect.Effect => + Effect.gen(function* () { + const planned = yield* compaction + .plan({ + agentId: input.agentId, + entries, + model, + trigger, + additionalInstructions, + }) + .pipe(Effect.result) + + if (Result.isFailure(planned)) { + yield* appendToEventLog( + LogEntryInputs['error']({ + agentId: input.agentId, + parentAgentId: input.parentAgentId, + toolCallId: input.toolCallId, + errorType: 'compaction', + message: planned.failure.message, + details: { trigger }, + }), + ) + return null + } + + if (planned.success === null) return null + const postCompactionInstructions = yield* compactionArchiveAccess.instructions({ + agentId: input.agentId, + parentAgentId: input.parentAgentId, + trigger, + }) + + const compactionEntry = { + agentId: input.agentId, + parentAgentId: input.parentAgentId, + toolCallId: input.toolCallId, + compactionId: yield* ids.makeCompactionId, + prompt: planned.success.prompt, + summary: planned.success.summary, + replacesThroughSeq: planned.success.replacesThroughSeq, + tokensBefore: planned.success.tokensBefore, + } + const entry = yield* appendToEventLog( + LogEntryInputs['compaction']( + postCompactionInstructions === null + ? compactionEntry + : { ...compactionEntry, postCompactionInstructions }, + ), + ) + + if (Predicate.isTagged(entry, 'compaction')) return entry + return yield* Effect.die(new Error(`EventLog returned ${entry._tag} while appending compaction`)) + }) + + /** Settle one assistant message's tool calls, then decide whether the run stops or continues. */ + const settleToolCalls = ( + input: RunAgentInput, + persistedAssistant: Prompt.AssistantMessage, + toolCalls: ReadonlyArray, + doomLoopRef: Ref.Ref, + ): Effect.Effect => + Effect.gen(function* () { + const doomLoop = observeDoomLoop(stopConditions, yield* Ref.get(doomLoopRef), toolCalls) + yield* Ref.set(doomLoopRef, doomLoop.state) + + const settlement = yield* toolRuntime.settle({ + agentId: input.agentId, + parentAgentId: input.parentAgentId, + assistantMessage: persistedAssistant, + }) + + if (settlement.stopRequested) { + const entry = yield* appendFinished(input, 'stopped', null, 'a tool or hook requested a stop') + return TurnResult.finished({ entry }) + } + + // D9: a session-wide stop lets the in-flight batch finish and its results land (above), + // then ends the run here - no further LLM call. + const sessionStopAfterBatch = yield* sessionControls.sessionStopReason + if (sessionStopAfterBatch !== null) { + const entry = yield* appendFinished(input, 'stopped', null, sessionStopAfterBatch) + return TurnResult.finished({ entry }) + } + + if (doomLoop.reason !== null) { + const entry = yield* appendFinished(input, 'stopped', null, doomLoop.reason) + return TurnResult.finished({ entry }) + } + + return TurnResult.continue() + }) + + /** Run one model turn: build the request, call the model, persist, and settle tool calls. */ + const runTurn = ( + input: RunAgentInput, + stopController: StopControllerService, + stopRef: Ref.Ref, + overflowRecoveryRef: Ref.Ref, + doomLoopRef: Ref.Ref, + ): Effect.Effect => + Effect.gen(function* () { + // Drain queued steering before this turn's model call (D8): each steered message becomes an + // ordinary user-message right here, so the log records it exactly where the model saw it. + const steered = yield* sessionControls.drainSteering(input.agentId) + for (const text of steered) { + yield* appendUserMessage(input, text) + } + + const entriesBeforeCompaction = yield* collectEntries + const runtimeState = runtimeForAgent(entriesBeforeCompaction, input.agentId) + + // Top-of-turn compaction check (D11): compare the last post-compaction API-reported usage + // against the model's usable budget, and compact BEFORE building this turn's request so + // the projection the model sees is the compacted one. Runs identically for root agents + // and subagents - each against its own projection and its own model's limits (D21). + const compacted = (yield* compaction.shouldCompact({ + agentId: input.agentId, + entries: entriesBeforeCompaction, + model: runtimeState.activeModel, + })) + ? yield* performCompaction(input, entriesBeforeCompaction, runtimeState.activeModel, 'threshold') + : false + + const entries = compacted ? yield* collectEntries : entriesBeforeCompaction + const projected = messagesForAgent(entries, input.agentId) + const prompt = yield* buildPrompt(projected).pipe(Effect.orDie) + + // Hook typed failures are resolved at authorship (D16); an escaped HookExecutionError is a + // defect here. preRequest/onComplete defect policy is owned by the in-flight defect work. + const preRequestDecision = yield* hooks + .preRequest({ agentId: input.agentId, parentAgentId: input.parentAgentId, prompt }) + .pipe(Effect.provideService(StopController, stopController), Effect.orDie) + + const requestPrompt = Predicate.isTagged(preRequestDecision, 'changed') + ? preRequestDecision.prompt + : prompt + + // Both stop tracks bind before the model call: this run's own StopController (a preRequest + // hook requested it) and the session-wide signal (D9 - external Session.stop reaches every + // agent's loop, so the whole tree stops at its batch boundaries). + const stopReasonAfterPreRequest = + (yield* Ref.get(stopRef)) ?? (yield* sessionControls.sessionStopReason) + if (stopReasonAfterPreRequest !== null) { + const entry = yield* appendFinished(input, 'stopped', null, stopReasonAfterPreRequest) + return TurnResult.finished({ entry }) + } + + // Advertise only the epoch's active toolset (agent_started/tools-change fold): the installed + // toolkit is filtered to the projected active names, so a tools-change entry binds the very + // next request. Handlers stay untouched - settlement still executes against the full Toolset. + const withHandler = yield* toolset.withHandler + const activeToolEntries = Object.entries(withHandler.tools) + .filter(([name]) => runtimeState.activeTools.includes(name)) + .map(([name, tool]) => [name, acceptAnyParamsInReply(tool)] as const) + const toolkit: Toolkit.WithHandler> = { + tools: Object.fromEntries(activeToolEntries), + handle: withHandler.handle, + } + + // Tap the live model stream: republish each streamed text/reasoning delta as an ephemeral AgentEvents + // delta so UIs can render output as it arrives. All other part types publish nothing, and deltas never + // enter the durable log. A failing stream fails before any part, so failure turns publish no deltas. + // The whole collection runs under the active model request settings, so the provider reads the + // projected reasoning configuration when it builds the request (thinking-change binds next turn). + const modelParts = yield* Stream.runCollect( + languageModel.streamText({ prompt: requestPrompt, toolkit, disableToolCallResolution: true }).pipe( + Stream.tap((part) => + part.type === 'text-delta' || part.type === 'reasoning-delta' + ? agentEvents.publish({ + kind: 'delta', + agentId: input.agentId, + parentAgentId: input.parentAgentId, + toolCallId: input.toolCallId, + part: + part.type === 'text-delta' + ? { type: 'text-delta', id: part.id, delta: part.delta } + : { type: 'reasoning-delta', id: part.id, delta: part.delta }, + }) + : Effect.void, + ), + ), + ).pipe( + modelRequestSettings.wrap({ + model: runtimeState.activeModel, + reasoningLevel: runtimeState.reasoningLevel, + promptCacheKey: runtimeState.promptCacheKey, + }), + Effect.result, + ) + + if (Result.isFailure(modelParts)) { + const message = modelParts.failure.message + + // Reactive overflow path (D11): when the provider says the request exceeded the context + // window, compact and restart the turn - once per run. A recovered attempt writes no + // error entry (transient, like a within-provider retry); a second overflow, or nothing + // safely compactable, falls through to the durable error outcome below. + if ( + compaction.enabled && + isContextOverflowError(message) && + !(yield* Ref.get(overflowRecoveryRef)) + ) { + yield* Ref.set(overflowRecoveryRef, true) + const recovered = yield* performCompaction( + input, + yield* collectEntries, + runtimeState.activeModel, + 'overflow', + ) + if (recovered) return TurnResult.continue() + } + + yield* appendToEventLog( + LogEntryInputs['error']({ + agentId: input.agentId, + parentAgentId: input.parentAgentId, + toolCallId: input.toolCallId, + errorType: 'model', + message, + details: {}, + }), + ) + + const entry = yield* appendFinished(input, 'error', null, message) + return TurnResult.finished({ entry }) + } + + const parts: ReadonlyArray = modelParts.success + const finishPart = parts.findLast((part): part is Response.FinishPart => part.type === 'finish') + const responseMessages = Prompt.fromResponseParts(parts) + const assistantMessage = responseMessages.content.find( + (message): message is Prompt.AssistantMessage => message.role === 'assistant', + ) + + if (assistantMessage === undefined) { + const entry = yield* appendFinished(input, 'completed', null, null) + return TurnResult.finished({ entry }) + } + + const persistedAssistant = yield* rewriteAssistantToolCallIds(assistantMessage) + + yield* appendToEventLog( + LogEntryInputs['assistant-message']({ + agentId: input.agentId, + parentAgentId: input.parentAgentId, + toolCallId: input.toolCallId, + messageId: yield* ids.makeMessageId, + message: encodeAssistantMessage(persistedAssistant), + finish: + finishPart === undefined + ? null + : { reason: finishPart.reason, usage: usageFromResponseUsage(finishPart.usage) }, + }), + ) + + const toolCalls = persistedAssistant.content.flatMap((part) => + part.type === 'tool-call' ? [{ name: part.name, params: part.params }] : [], + ) + const hasToolCalls = Arr.isArrayNonEmpty(toolCalls) + + if (hasToolCalls) { + return yield* settleToolCalls(input, persistedAssistant, toolCalls, doomLoopRef) + } + + yield* Ref.set(doomLoopRef, initialDoomLoopState) + + const resultText = assistantResultText(persistedAssistant) + + const onCompleteDecision = yield* hooks + .onComplete({ agentId: input.agentId, parentAgentId: input.parentAgentId, resultText }) + .pipe(Effect.provideService(StopController, stopController), Effect.orDie) + + const stopReasonAfterComplete = yield* Ref.get(stopRef) + if (stopReasonAfterComplete !== null) { + const entry = yield* appendFinished(input, 'stopped', resultText, stopReasonAfterComplete) + return TurnResult.finished({ entry }) + } + + if (Predicate.isTagged(onCompleteDecision, 'continueWith')) { + yield* appendUserMessage(input, onCompleteDecision.text) + return TurnResult.continue() + } + + // D8: follow-ups queued while this agent was running drain exactly where the run would + // complete naturally - each becomes an ordinary user-message and the run continues. + const followUps = yield* sessionControls.drainFollowUps(input.agentId) + if (Arr.isReadonlyArrayNonEmpty(followUps)) { + for (const text of followUps) { + yield* appendUserMessage(input, text) + } + return TurnResult.continue() + } + + const entry = yield* appendFinished(input, 'completed', resultText, null) + return TurnResult.finished({ entry }) + }) + + /** Shared epoch fields: whose log rows these are, and which model's leading prompt to compose. */ + type LeadingSystemMessageInput = { + readonly agentId: AgentId + readonly parentAgentId: AgentId | null + readonly toolCallId: ToolCallId | null + readonly model: ActiveModel + readonly systemPrompt: ReadonlyArray | null + } + + /** Compose and append one epoch's leading system-message block set (agent start and model switch). */ + const appendLeadingSystemMessage = (input: LeadingSystemMessageInput): Effect.Effect => + Effect.gen(function* () { + const agentBlocks = input.systemPrompt ?? [] + + const blocks = yield* systemPrompt.compose({ model: input.model, agentBlocks }) + + if (Arr.isReadonlyArrayNonEmpty(blocks)) { + yield* appendToEventLog( + LogEntryInputs['system-message']({ + agentId: input.agentId, + parentAgentId: input.parentAgentId, + toolCallId: input.toolCallId, + messageId: yield* ids.makeMessageId, + messages: Arr.map(blocks, (content, index) => + encodeSystemMessage(leadingSystemMessageFor(content, index === blocks.length - 1)), + ), + placement: 'leading', + }), + ) + } + }) + + const start: AgentRuntimeService['start'] = Effect.fn('fold.agent_runtime.start')((input: StartAgentInput) => + Effect.gen(function* () { + // Epoch-open choreography (D17): resolve the family toolset and compose the leading system + // prompt block set once for the starting model; both are recorded durably. + const resolvedToolset = yield* toolsetResolver.resolve({ model: input.model }) + + const agentStarted = { + agentId: input.agentId, + parentAgentId: input.parentAgentId, + toolCallId: input.toolCallId, + mode: input.mode, + model: input.model, + tools: resolvedToolset.names, + skill: input.skill, + fork: input.fork, + agentType: input.agentType, + } + const promptCacheKey = input.promptCacheKey ?? null + const entry = yield* appendToEventLog( + LogEntryInputs['agent_started']( + promptCacheKey === null ? agentStarted : { ...agentStarted, promptCacheKey }, + ), + ) + + // A fork appends no leading system message: its projection folds the forked-from agent's + // history, leading blocks included, keeping the fork's prompt prefix byte-identical for + // provider-cache reuse (D21). + if (input.mode === 'fresh') { + yield* appendLeadingSystemMessage(input) + } + + if (Predicate.isTagged(entry, 'agent_started')) return entry + + // Invariant! + return yield* Effect.die(new Error(`EventLog returned ${entry._tag} while appending agent_started`)) + }), + ) + + const switchModel: AgentRuntimeService['switchModel'] = Effect.fn('fold.agent_runtime.switch_model')( + (input: SwitchModelInput) => + Effect.gen(function* () { + // Epoch-transition choreography (D17): a model switch opens a new epoch, so the leading + // system prompt and toolset re-resolve for the new family and all three facts land durably. + // The next run's projection binds them; the caller swaps the LanguageModel layer (D15). + const previousLevel = runtimeForAgent(yield* collectEntries, input.agentId).reasoningLevel + + yield* appendToEventLog( + LogEntryInputs['model-change']({ + agentId: input.agentId, + parentAgentId: input.parentAgentId, + toolCallId: input.toolCallId, + model: input.model, + reason: input.reason, + }), + ) + + yield* appendLeadingSystemMessage(input) + + const resolvedToolset = yield* toolsetResolver.resolve({ model: input.model }) + yield* appendToEventLog( + LogEntryInputs['tools-change']({ + agentId: input.agentId, + parentAgentId: input.parentAgentId, + toolCallId: input.toolCallId, + tools: resolvedToolset.names, + reason: input.reason, + }), + ) + + // Reasoning is part of the switched configuration: when the incoming model's requested + // level differs from the projected level, the change lands as its own durable fact. The + // model-change fold already rebinds the level (D23 - not an epoch boundary), so this entry + // is written for log legibility and skipped when nothing changed. + if (previousLevel !== input.model.requestedReasoningLevel) { + yield* appendToEventLog( + LogEntryInputs['thinking-change']({ + agentId: input.agentId, + parentAgentId: input.parentAgentId, + toolCallId: input.toolCallId, + reasoningLevel: input.model.requestedReasoningLevel, + reason: input.reason, + }), + ) + } + }), + ) + + const run: AgentRuntimeService['run'] = Effect.fn('fold.agent_runtime.run')((input: RunAgentInput) => + Effect.gen(function* () { + const stopRef = yield* Ref.make(null) + const stopController: StopControllerService = { + requestStop: (reason) => Ref.set(stopRef, reason), + isStopRequested: Ref.get(stopRef).pipe(Effect.map((reason) => reason !== null)), + } + // One reactive compact-and-retry per run (D11's guard, pi's single-attempt precedent). + const overflowRecoveryRef = yield* Ref.make(false) + const doomLoopRef = yield* Ref.make(initialDoomLoopState) + + for (const text of input.messages) { + yield* appendUserMessage(input, text) + } + + while (true) { + const turn = yield* runTurn(input, stopController, stopRef, overflowRecoveryRef, doomLoopRef) + if (Predicate.isTagged(turn, 'finished')) return turn.entry + } + }), + ) + + const compact: AgentRuntimeService['compact'] = Effect.fn('fold.agent_runtime.compact')( + (input: CompactAgentInput) => + Effect.gen(function* () { + const entries = yield* collectEntries + const runtimeState = runtimeForAgent(entries, input.agentId) + + return yield* performCompaction( + input, + entries, + runtimeState.activeModel, + input.trigger, + input.additionalInstructions ?? null, + ) + }), + ) + + return { start, run, switchModel, compact } + }), +}) { + static readonly layer = Layer.effect(this, this.make) +} diff --git a/packages/fold-core/src/Api/OpenSession.ts b/packages/fold-core/src/Api/OpenSession.ts index ccd21db..85c1342 100644 --- a/packages/fold-core/src/Api/OpenSession.ts +++ b/packages/fold-core/src/Api/OpenSession.ts @@ -24,7 +24,7 @@ import { } from 'effect' import { Prompt } from 'effect/ai' -import { toolEventSinkLayerFromAgentEvents, liveAgentEventsLayer } from '../AgentEvents/AgentEventsLayer' +import { AgentEvents } from '../AgentEvents/AgentEventsService' import type { FoldEvent } from '../AgentEvents/AgentEventsService' import { AgentRuntime, type AgentRuntimeService } from '../AgentRuntime/AgentRuntimeService' import { @@ -51,20 +51,13 @@ import { runtimeForAgent } from '../Projection/Projection' import { AgentNotRunningError } from '../Session/Errors' import { isProfileRole, - layerProfiles, profileModelFor, Profiles, type ProfileRole, type ProfilesService, type SessionProfiles, } from '../Session/Profiles' -import { - layerSessionControls, - SessionControls, - type SessionControlsService, - type SteeringMode, -} from '../Session/SessionControls' -import { liveSessionLayer } from '../Session/SessionLayer' +import { SessionControls, type SessionControlsService, type SteeringMode } from '../Session/SessionControls' import { Session, type SessionService, type StartedSession } from '../Session/SessionService' import type { SkillSourceService } from '../Skills/SkillSource' import { StopConditions } from '../StopConditions/StopConditions' @@ -79,6 +72,7 @@ import { SessionAgents, type RealizedAgentTools, type RootAgentSnapshot } from ' import { continueSubagent, type ContinueSubagentInput } from '../Subagents/SubagentEngine' import { layerSystemPrompt } from '../SystemPrompt/SystemPromptLayer' import { SystemPrompt, type SystemPromptService } from '../SystemPrompt/SystemPromptService' +import { ToolEventSink } from '../ToolRuntime/ToolContextServices' import { systemPromptBlocks, type AgentDefinition, type SystemPromptInput } from './AgentDefinition' import type { FoldModel } from './ModelDescriptor' import { provisionAgentRuntime, validateToolNames } from './Provisioning' @@ -254,6 +248,15 @@ const withHostServices = ( const activeModelsEquivalent = Schema.toEquivalence(Schema.NullOr(ActiveModel)) const promptBlocksEquivalent = Schema.toEquivalence(Schema.Array(Schema.String)) +/** + * One provisioned root runtime and the scope its resources live in. The scope is a child of the session + * scope, so a model switch can release the replaced runtime without waiting for the session to close. + */ +type ProvisionedRootRuntime = { + readonly runtime: AgentRuntimeService + readonly scope: Scope.Closeable +} + /** The assembled service graph shared by initialization and adoption. */ type SessionGraph = { readonly agent: AgentDefinition @@ -279,9 +282,9 @@ type SessionGraph = { readonly provisionRootRuntime: ( model: FoldModel, tools: ReadonlyArray>, - ) => Effect.Effect - readonly setProvisionedRuntime: (runtime: AgentRuntimeService) => Effect.Effect - readonly currentProvisionedRuntime: Effect.Effect + ) => Effect.Effect + readonly setProvisionedRuntime: (provisioned: ProvisionedRootRuntime) => Effect.Effect + readonly currentProvisionedRuntime: Effect.Effect readonly leadingPromptFor: ( systemPrompt: ReadonlyArray, tools: ReadonlyArray>, @@ -429,13 +432,13 @@ const assembleSessionGraph = (options: { // services from this graph too: Effect AI hands each handler the context its toolkit was built in. const idsLayer = layerLiveIdFactory const eventLog = yield* EventLog - const infraLayer = Layer.mergeAll(Layer.succeed(EventLog, eventLog), idsLayer, liveAgentEventsLayer) + const infraLayer = Layer.mergeAll(Layer.succeed(EventLog, eventLog), idsLayer, AgentEvents.layer) const servicesLayer = Layer.mergeAll( infraLayer, Layer.succeedContext(hostServices), layerSystemPrompt(agent.basePrompts === undefined ? {} : { basePrompts: agent.basePrompts }), liveModelRequestSettingsLayer, - toolEventSinkLayerFromAgentEvents.pipe(Layer.provide(infraLayer)), + ToolEventSink.layer.pipe(Layer.provide(infraLayer)), // How this session builds agents: the subagent operations and every provision read it. Layer.succeed(SessionAgents, { registry, @@ -450,10 +453,10 @@ const assembleSessionGraph = (options: { // entries build the empty catalog, which behaves exactly like the Reference default. Layer.succeed(ModelCatalog, modelCatalogFromEntries(options.catalog ?? [])), Layer.succeed(StopConditions, agent.stopConditions ?? {}), - layerSessionControls(options.steering === undefined ? {} : { steeringMode: options.steering }), + SessionControls.layer(options.steering === undefined ? {} : { steeringMode: options.steering }), // Session-wide role->model bindings (profiles slice): one mutable map shared by the facade's // setProfile and the subagent operations' per-dispatch/resume resolution. - layerProfiles(initialProfiles), + Profiles.layer(initialProfiles), ) // Builds use session-fresh memo maps, never the ambient CurrentMemoMap: layers are memoized by // reference per memo map, and under `Effect.provide` (any app or test harness) the ambient map @@ -469,29 +472,40 @@ const assembleSessionGraph = (options: { // HookRunner, the ToolRuntime executing against it, and the AgentRuntime bound to the model's // provider (Provisioning.ts owns the fresh-memo-map and ambient-scope invariants). The delegating // runtime below lets the Session service survive swaps (interim AgentModels seam - D15). Root - // provisions target the session scope explicitly: switchModel runs later, from the caller's own - // scope, and the provisioned provider client must outlive that caller. The session handle is the - // edge where host code calls in with nothing around it, so it runs these inside the session's - // services. + // provisions target a child of the session scope explicitly: switchModel runs later, from the + // caller's own scope, and the provisioned provider client must outlive that caller. Each root + // runtime gets its own child so a switch can release the runtime it replaces (see switchModel). + // The session handle is the edge where host code calls in with nothing around it, so it runs + // these inside the session's services. const provisionRootRuntime = ( model: FoldModel, tools: ReadonlyArray>, - ): Effect.Effect => - provisionAgentRuntime({ model, tools: realizeAgentTools(tools).tools, hooks: rootHooks }).pipe( - Scope.provide(sessionScope), - Effect.provideContext(sessionServices), - ) + ): Effect.Effect => + Effect.gen(function* () { + const scope = yield* Scope.fork(sessionScope) + const runtime = yield* provisionAgentRuntime({ + model, + tools: realizeAgentTools(tools).tools, + hooks: rootHooks, + }).pipe( + Scope.provide(scope), + Effect.provideContext(sessionServices), + Effect.onError((cause) => Scope.close(scope, Exit.failCause(cause))), + ) + return { runtime, scope } + }) const runtimeRef = yield* Ref.make(yield* provisionRootRuntime(agent.model, rootTools)) + const currentRuntime = Ref.get(runtimeRef).pipe(Effect.map((provisioned) => provisioned.runtime)) const delegatingRuntime: AgentRuntimeService = { - start: (input) => Ref.get(runtimeRef).pipe(Effect.flatMap((runtime) => runtime.start(input))), - run: (input) => Ref.get(runtimeRef).pipe(Effect.flatMap((runtime) => runtime.run(input))), - switchModel: (input) => Ref.get(runtimeRef).pipe(Effect.flatMap((runtime) => runtime.switchModel(input))), - compact: (input) => Ref.get(runtimeRef).pipe(Effect.flatMap((runtime) => runtime.compact(input))), + start: (input) => currentRuntime.pipe(Effect.flatMap((runtime) => runtime.start(input))), + run: (input) => currentRuntime.pipe(Effect.flatMap((runtime) => runtime.run(input))), + switchModel: (input) => currentRuntime.pipe(Effect.flatMap((runtime) => runtime.switchModel(input))), + compact: (input) => currentRuntime.pipe(Effect.flatMap((runtime) => runtime.compact(input))), } const sessionContext = yield* Layer.buildWithMemoMap( - liveSessionLayer.pipe( + Session.layer.pipe( Layer.provide(Layer.mergeAll(sessionServicesLayer, Layer.succeed(AgentRuntime, delegatingRuntime))), ), sessionMemoMap, @@ -530,7 +544,7 @@ const assembleSessionGraph = (options: { ensureToolContributions, collectNewSubagentDefinitions: (tools) => collectAgentDefinitions(tools), provisionRootRuntime, - setProvisionedRuntime: (runtime) => Ref.set(runtimeRef, runtime), + setProvisionedRuntime: (provisioned) => Ref.set(runtimeRef, provisioned), currentProvisionedRuntime: Ref.get(runtimeRef), leadingPromptFor, } @@ -847,8 +861,12 @@ const makeSessionHandle = (graph: SessionGraph, identity: StartedSession): .pipe(Effect.orDie, Effect.exit) if (Exit.isFailure(transition)) { yield* graph.setProvisionedRuntime(previousRuntime) + yield* Scope.close(nextRuntime.scope, transition) return yield* Effect.failCause(transition.cause) } + // The replaced runtime is idle (root runs hold the same gate), so release its provider + // client and layers now rather than at session close. + yield* Scope.close(previousRuntime.scope, Exit.void) graph.extendSubagentRegistry(introduced) yield* profiles.replace(candidateProfiles) yield* Ref.set(configRef, next) diff --git a/packages/fold-core/src/Api/Provisioning.ts b/packages/fold-core/src/Api/Provisioning.ts index 7c4ef8a..1ab2cd1 100644 --- a/packages/fold-core/src/Api/Provisioning.ts +++ b/packages/fold-core/src/Api/Provisioning.ts @@ -19,13 +19,12 @@ import type { Scope } from 'effect' import { LanguageModel, Toolkit } from 'effect/ai' import type { Tool } from 'effect/ai' -import { liveAgentRuntimeLayer } from '../AgentRuntime/AgentRuntimeLayer' import { AgentRuntime } from '../AgentRuntime/AgentRuntimeService' import { compactionLayerFor } from '../Compaction/CompactionLayer' -import { layerHookRunner } from '../HookRunner/HookRunnerLayer' +import { HookRunner } from '../HookRunner/HookRunnerService' import type { HookConfig } from '../HookRunner/Types' import { SessionAgents } from '../Subagents/SessionAgents' -import { liveToolRuntimeLayer } from '../ToolRuntime/ToolRuntimeLayer' +import { ToolRuntime } from '../ToolRuntime/ToolRuntimeService' import { toolsetLayerFromToolkit } from '../ToolRuntime/ToolsetFactory' import { layerToolsetResolver } from '../ToolRuntime/ToolsetResolverLayer' import type { FoldModel } from './ModelDescriptor' @@ -93,16 +92,16 @@ export const provisionAgentRuntime = Effect.fnUntraced(function* (input: Provisi const epochServicesLayer = Layer.mergeAll( toolsetLayer, layerToolsetResolver().pipe(Layer.provide(toolsetLayer)), - layerHookRunner(input.hooks), + HookRunner.layer(input.hooks), ) // Compaction summarizes with this runtime's own model and resolves limits through the session // ModelCatalog; both are captured when the layer is built. const compactionLayer = compactionLayerFor(autoCompact).pipe(Layer.provide(languageModelLayer)) const context = yield* Layer.buildWithMemoMap( - liveAgentRuntimeLayer.pipe( + AgentRuntime.layer.pipe( Layer.provide( Layer.mergeAll( - liveToolRuntimeLayer.pipe(Layer.provideMerge(epochServicesLayer)), + ToolRuntime.layer.pipe(Layer.provideMerge(epochServicesLayer)), languageModelLayer, compactionLayer, ), diff --git a/packages/fold-core/src/HookRunner/HookRunnerLayer.ts b/packages/fold-core/src/HookRunner/HookRunnerLayer.ts deleted file mode 100644 index 781d249..0000000 --- a/packages/fold-core/src/HookRunner/HookRunnerLayer.ts +++ /dev/null @@ -1,228 +0,0 @@ -/** - * This file builds the live HookRunner layer from hook configuration data. The layer consumes EventLog - * and Ids so it can provide every hook handler durable ToolState scoped to the current (agent, tool call); - * the namespace of each read or write comes from the hook's own defineToolState declaration, not its name. - * The caller of each hook point provides StopController from the surrounding run or batch. - */ -import { Data, Predicate, Cause, Effect, Layer } from 'effect' - -import { EventLog, type EventLogService } from '../EventLog/EventLogService' -import { Ids, type AgentId, type IdsService, type ToolCallId } from '../Ids' -import type { StopController } from '../ToolRuntime/ToolContextServices' -import { toolStateServiceForToolCall } from '../ToolRuntime/ToolStateFactory' -import { ToolState } from '../ToolRuntime/ToolStateService' -import { HookExecutionError, type HookPhase } from './Errors' -import { HookRunner, type HookRunnerService } from './HookRunnerService' -import type { - OnCompleteHookDecision, - OnCompleteHookInput, - PostToolUseHookDecision, - PostToolUseHookInput, - PreRequestHookDecision, - PreRequestHookInput, - PreToolUseHookDecision, - PreToolUseHookInput, -} from './Schema' -import type { HookScope, OnCompleteHook, PostToolUseHook, PreRequestHook, PreToolUseHook, HookConfig } from './Types' - -type HookDecision = PreRequestHookDecision | PreToolUseHookDecision | PostToolUseHookDecision | OnCompleteHookDecision -const HookDecision = Data.taggedEnum() - -const isHookConfiguredForTool = (hook: { readonly tools?: ReadonlyArray }, toolName: string): boolean => - hook.tools === undefined || hook.tools.includes(toolName) - -const catchHookExecutionError = ( - effect: Effect.Effect, - phase: HookPhase, - hookName: string, -): Effect.Effect => - effect.pipe( - Effect.catchCauseIf( - (cause) => !Cause.hasInterrupts(cause), - (cause) => Effect.fail(new HookExecutionError(phase, hookName, cause)), - ), - ) - -const preRequestSpanAttributes = (input: PreRequestHookInput) => ({ - agentId: input.agentId, -}) - -const preToolUseSpanAttributes = (input: PreToolUseHookInput) => ({ - agentId: input.agentId, - toolCallId: input.toolCallId, - toolName: input.toolName, -}) - -const postToolUseSpanAttributes = (input: PostToolUseHookInput) => ({ - agentId: input.agentId, - toolCallId: input.toolCallId, - toolName: input.toolName, - isFailure: input.isFailure, -}) - -const onCompleteSpanAttributes = (input: OnCompleteHookInput) => ({ - agentId: input.agentId, - hasResultText: input.resultText !== null, -}) - -/** Identity of the log scope a hook invocation writes its own durable state into. */ -type HookStateScope = { - readonly agentId: AgentId - readonly parentAgentId: AgentId | null - readonly toolCallId: ToolCallId | null -} - -/** Provide one hook handler its durable ToolState scoped to (agent, tool call), leaving StopController to the caller. */ -const provideHookState = - (eventLog: EventLogService, ids: IdsService) => - (effect: Effect.Effect, scope: HookStateScope): Effect.Effect => - Effect.gen(function* () { - const state = yield* toolStateServiceForToolCall({ - agentId: scope.agentId, - parentAgentId: scope.parentAgentId, - toolCallId: scope.toolCallId, - }).pipe(Effect.provideService(EventLog, eventLog), Effect.provideService(Ids, ids)) - - return yield* effect.pipe(Effect.provideService(ToolState, state)) - }) - -/** Build a HookRunner layer from hook configuration data. */ -export const layerHookRunner = (hooks: HookConfig): Layer.Layer => - Layer.effect( - HookRunner, - Effect.gen(function* () { - const eventLog = yield* EventLog - const ids = yield* Ids - const withHookState = provideHookState(eventLog, ids) - - const runPreRequestHook = (hook: PreRequestHook, input: PreRequestHookInput) => - catchHookExecutionError( - withHookState(hook.handler(input), { ...input, toolCallId: null }).pipe( - Effect.withSpan('fold.hook_runner.pre_request.hook', { - attributes: { hookName: hook.name, ...preRequestSpanAttributes(input) }, - }), - ), - 'preRequest', - hook.name, - ) - - const runPreToolUseHook = (hook: PreToolUseHook, input: PreToolUseHookInput) => - catchHookExecutionError( - withHookState(hook.handler(input), input).pipe( - Effect.withSpan('fold.hook_runner.pre_tool_use.hook', { - attributes: { hookName: hook.name, ...preToolUseSpanAttributes(input) }, - }), - ), - 'preToolUse', - hook.name, - ) - - const runPostToolUseHook = (hook: PostToolUseHook, input: PostToolUseHookInput) => - catchHookExecutionError( - withHookState(hook.handler(input), input).pipe( - Effect.withSpan('fold.hook_runner.post_tool_use.hook', { - attributes: { hookName: hook.name, ...postToolUseSpanAttributes(input) }, - }), - ), - 'postToolUse', - hook.name, - ) - - const runOnCompleteHook = (hook: OnCompleteHook, input: OnCompleteHookInput) => - catchHookExecutionError( - withHookState(hook.handler(input), { ...input, toolCallId: null }).pipe( - Effect.withSpan('fold.hook_runner.on_complete.hook', { - attributes: { hookName: hook.name, ...onCompleteSpanAttributes(input) }, - }), - ), - 'onComplete', - hook.name, - ) - - const service: HookRunnerService = { - preRequest: (input) => - Effect.gen(function* () { - let prompt = input.prompt - let changed = false - - for (const hook of hooks.preRequest ?? []) { - const decision = yield* runPreRequestHook(hook, { ...input, prompt }) - - if (Predicate.isTagged(decision, 'changed')) { - prompt = decision.prompt - changed = true - } - } - - return changed ? HookDecision.changed({ prompt }) : HookDecision.unchanged() - }).pipe( - Effect.withSpan('fold.hook_runner.pre_request', { - attributes: preRequestSpanAttributes(input), - }), - ), - - preToolUse: (input) => - Effect.gen(function* () { - let params = input.params - - for (const hook of hooks.preToolUse ?? []) { - if (!isHookConfiguredForTool(hook, input.toolName)) continue - - const decision = yield* runPreToolUseHook(hook, { ...input, params }) - - if (Predicate.isTagged(decision, 'replaceResult')) return decision - - params = decision.params - } - - return HookDecision.continue({ params }) - }).pipe( - Effect.withSpan('fold.hook_runner.pre_tool_use', { - attributes: preToolUseSpanAttributes(input), - }), - ), - - postToolUse: (input) => - Effect.gen(function* () { - let result = input.result - let isFailure = input.isFailure - let replaced = false - - for (const hook of hooks.postToolUse ?? []) { - if (!isHookConfiguredForTool(hook, input.toolName)) continue - - const decision = yield* runPostToolUseHook(hook, { ...input, result, isFailure }) - - if (Predicate.isTagged(decision, 'replace')) { - result = decision.result - isFailure = decision.isFailure - replaced = true - } - } - - return replaced ? HookDecision.replace({ result, isFailure }) : HookDecision.keep() - }).pipe( - Effect.withSpan('fold.hook_runner.post_tool_use', { - attributes: postToolUseSpanAttributes(input), - }), - ), - - onComplete: (input) => - Effect.gen(function* () { - for (const hook of hooks.onComplete ?? []) { - const decision = yield* runOnCompleteHook(hook, input) - - if (Predicate.isTagged(decision, 'continueWith')) return decision - } - - return HookDecision.complete() - }).pipe( - Effect.withSpan('fold.hook_runner.on_complete', { - attributes: onCompleteSpanAttributes(input), - }), - ), - } - - return service - }), - ) diff --git a/packages/fold-core/src/HookRunner/HookRunnerService.ts b/packages/fold-core/src/HookRunner/HookRunnerService.ts index 473f0d5..f8af176 100644 --- a/packages/fold-core/src/HookRunner/HookRunnerService.ts +++ b/packages/fold-core/src/HookRunner/HookRunnerService.ts @@ -1,8 +1,17 @@ -import { Context } from 'effect' -import type { Effect } from 'effect' +/** + * This file defines the HookRunner service, built from hook configuration data. It consumes EventLog + * and Ids so it can provide every hook handler durable ToolState scoped to the current (agent, tool call); + * the namespace of each read or write comes from the hook's own defineToolState declaration, not its name. + * The caller of each hook point provides StopController from the surrounding run or batch. + */ +import { Context, Effect, Data, Predicate, Cause, Layer } from 'effect' +import { EventLog, type EventLogService } from '../EventLog/EventLogService' +import { Ids, type AgentId, type IdsService, type ToolCallId } from '../Ids' import type { StopController } from '../ToolRuntime/ToolContextServices' -import type { HookExecutionError } from './Errors' +import { toolStateServiceForToolCall } from '../ToolRuntime/ToolStateFactory' +import { ToolState } from '../ToolRuntime/ToolStateService' +import { HookExecutionError, type HookPhase } from './Errors' import type { OnCompleteHookDecision, OnCompleteHookInput, @@ -13,6 +22,7 @@ import type { PreToolUseHookDecision, PreToolUseHookInput, } from './Schema' +import type { HookScope, OnCompleteHook, PostToolUseHook, PreRequestHook, PreToolUseHook, HookConfig } from './Types' /** * Runtime interpreter for hook configuration data. @@ -37,4 +47,206 @@ export type HookRunnerService = { ) => Effect.Effect } -export class HookRunner extends Context.Service()('fold/HookRunner') {} +type HookDecision = PreRequestHookDecision | PreToolUseHookDecision | PostToolUseHookDecision | OnCompleteHookDecision +const HookDecision = Data.taggedEnum() + +const isHookConfiguredForTool = (hook: { readonly tools?: ReadonlyArray }, toolName: string): boolean => + hook.tools === undefined || hook.tools.includes(toolName) + +const catchHookExecutionError = ( + effect: Effect.Effect, + phase: HookPhase, + hookName: string, +): Effect.Effect => + effect.pipe( + Effect.catchCauseIf( + (cause) => !Cause.hasInterrupts(cause), + (cause) => Effect.fail(new HookExecutionError(phase, hookName, cause)), + ), + ) + +const preRequestSpanAttributes = (input: PreRequestHookInput) => ({ + agentId: input.agentId, +}) + +const preToolUseSpanAttributes = (input: PreToolUseHookInput) => ({ + agentId: input.agentId, + toolCallId: input.toolCallId, + toolName: input.toolName, +}) + +const postToolUseSpanAttributes = (input: PostToolUseHookInput) => ({ + agentId: input.agentId, + toolCallId: input.toolCallId, + toolName: input.toolName, + isFailure: input.isFailure, +}) + +const onCompleteSpanAttributes = (input: OnCompleteHookInput) => ({ + agentId: input.agentId, + hasResultText: input.resultText !== null, +}) + +/** Identity of the log scope a hook invocation writes its own durable state into. */ +type HookStateScope = { + readonly agentId: AgentId + readonly parentAgentId: AgentId | null + readonly toolCallId: ToolCallId | null +} + +/** Provide one hook handler its durable ToolState scoped to (agent, tool call), leaving StopController to the caller. */ +const provideHookState = + (eventLog: EventLogService, ids: IdsService) => + (effect: Effect.Effect, scope: HookStateScope): Effect.Effect => + Effect.gen(function* () { + const state = yield* toolStateServiceForToolCall({ + agentId: scope.agentId, + parentAgentId: scope.parentAgentId, + toolCallId: scope.toolCallId, + }).pipe(Effect.provideService(EventLog, eventLog), Effect.provideService(Ids, ids)) + + return yield* effect.pipe(Effect.provideService(ToolState, state)) + }) + +/** HookRunner service tag. */ +export class HookRunner extends Context.Service()('fold/HookRunner', { + /** Build a HookRunner from hook configuration data. */ + make: (hooks: HookConfig) => + Effect.gen(function* () { + const eventLog = yield* EventLog + const ids = yield* Ids + const withHookState = provideHookState(eventLog, ids) + + const runPreRequestHook = (hook: PreRequestHook, input: PreRequestHookInput) => + catchHookExecutionError( + withHookState(hook.handler(input), { ...input, toolCallId: null }).pipe( + Effect.withSpan('fold.hook_runner.pre_request.hook', { + attributes: { hookName: hook.name, ...preRequestSpanAttributes(input) }, + }), + ), + 'preRequest', + hook.name, + ) + + const runPreToolUseHook = (hook: PreToolUseHook, input: PreToolUseHookInput) => + catchHookExecutionError( + withHookState(hook.handler(input), input).pipe( + Effect.withSpan('fold.hook_runner.pre_tool_use.hook', { + attributes: { hookName: hook.name, ...preToolUseSpanAttributes(input) }, + }), + ), + 'preToolUse', + hook.name, + ) + + const runPostToolUseHook = (hook: PostToolUseHook, input: PostToolUseHookInput) => + catchHookExecutionError( + withHookState(hook.handler(input), input).pipe( + Effect.withSpan('fold.hook_runner.post_tool_use.hook', { + attributes: { hookName: hook.name, ...postToolUseSpanAttributes(input) }, + }), + ), + 'postToolUse', + hook.name, + ) + + const runOnCompleteHook = (hook: OnCompleteHook, input: OnCompleteHookInput) => + catchHookExecutionError( + withHookState(hook.handler(input), { ...input, toolCallId: null }).pipe( + Effect.withSpan('fold.hook_runner.on_complete.hook', { + attributes: { hookName: hook.name, ...onCompleteSpanAttributes(input) }, + }), + ), + 'onComplete', + hook.name, + ) + + const service: HookRunnerService = { + preRequest: (input) => + Effect.gen(function* () { + let prompt = input.prompt + let changed = false + + for (const hook of hooks.preRequest ?? []) { + const decision = yield* runPreRequestHook(hook, { ...input, prompt }) + + if (Predicate.isTagged(decision, 'changed')) { + prompt = decision.prompt + changed = true + } + } + + return changed ? HookDecision.changed({ prompt }) : HookDecision.unchanged() + }).pipe( + Effect.withSpan('fold.hook_runner.pre_request', { + attributes: preRequestSpanAttributes(input), + }), + ), + + preToolUse: (input) => + Effect.gen(function* () { + let params = input.params + + for (const hook of hooks.preToolUse ?? []) { + if (!isHookConfiguredForTool(hook, input.toolName)) continue + + const decision = yield* runPreToolUseHook(hook, { ...input, params }) + + if (Predicate.isTagged(decision, 'replaceResult')) return decision + + params = decision.params + } + + return HookDecision.continue({ params }) + }).pipe( + Effect.withSpan('fold.hook_runner.pre_tool_use', { + attributes: preToolUseSpanAttributes(input), + }), + ), + + postToolUse: (input) => + Effect.gen(function* () { + let result = input.result + let isFailure = input.isFailure + let replaced = false + + for (const hook of hooks.postToolUse ?? []) { + if (!isHookConfiguredForTool(hook, input.toolName)) continue + + const decision = yield* runPostToolUseHook(hook, { ...input, result, isFailure }) + + if (Predicate.isTagged(decision, 'replace')) { + result = decision.result + isFailure = decision.isFailure + replaced = true + } + } + + return replaced ? HookDecision.replace({ result, isFailure }) : HookDecision.keep() + }).pipe( + Effect.withSpan('fold.hook_runner.post_tool_use', { + attributes: postToolUseSpanAttributes(input), + }), + ), + + onComplete: (input) => + Effect.gen(function* () { + for (const hook of hooks.onComplete ?? []) { + const decision = yield* runOnCompleteHook(hook, input) + + if (Predicate.isTagged(decision, 'continueWith')) return decision + } + + return HookDecision.complete() + }).pipe( + Effect.withSpan('fold.hook_runner.on_complete', { + attributes: onCompleteSpanAttributes(input), + }), + ), + } + + return service + }), +}) { + static readonly layer = (hooks: HookConfig) => Layer.effect(this, this.make(hooks)) +} diff --git a/packages/fold-core/src/Session/Profiles.ts b/packages/fold-core/src/Session/Profiles.ts index 4d35a5a..dd33bb7 100644 --- a/packages/fold-core/src/Session/Profiles.ts +++ b/packages/fold-core/src/Session/Profiles.ts @@ -58,12 +58,9 @@ export type ProfilesService = { } /** Profiles service tag; one instance per session, shared by the facade and the subagent operations. */ -export class Profiles extends Context.Service()('fold/Profiles') {} - -/** One session's profiles over the initial bindings from `Session.open`. */ -export const layerProfiles = (initial: SessionProfiles): Layer.Layer => - Layer.effect( - Profiles, +export class Profiles extends Context.Service()('fold/Profiles', { + /** One session's profiles over the initial bindings from `Session.open`. */ + make: (initial: SessionProfiles) => Effect.gen(function* () { const state = yield* Ref.make>(initial) @@ -93,4 +90,6 @@ export const layerProfiles = (initial: SessionProfiles): Layer.Layer Ref.set(state, profiles), snapshot: Ref.get(state) } }), - ) +}) { + static readonly layer = (initial: SessionProfiles) => Layer.effect(this, this.make(initial)) +} diff --git a/packages/fold-core/src/Session/SessionControls.ts b/packages/fold-core/src/Session/SessionControls.ts index 926da44..c88b11c 100644 --- a/packages/fold-core/src/Session/SessionControls.ts +++ b/packages/fold-core/src/Session/SessionControls.ts @@ -73,11 +73,6 @@ export type SessionControlsService = { readonly clearSessionStop: Effect.Effect } -/** SessionControls service tag; one instance per session, shared by every provisioned runtime. */ -export class SessionControls extends Context.Service()( - 'fold/SessionControls', -) {} - const notRunning = (agentId: AgentId): AgentNotRunningError => new AgentNotRunningError({ agentId, @@ -86,155 +81,163 @@ const notRunning = (agentId: AgentId): AgentNotRunningError => `use send(message, { agentId: "${agentId}" }) to continue a finished agent.`, }) -/** One session's controls. `steeringMode` fixes how steering queues drain (D8). */ -export const layerSessionControls = (options?: { - readonly steeringMode?: SteeringMode -}): Layer.Layer => - Layer.effect( - SessionControls, - Effect.gen(function* () { - const steeringMode = options?.steeringMode ?? 'one-at-a-time' - - const running = yield* SynchronizedRef.make>(new Map()) - const steering = yield* Ref.make>>(new Map()) - const followUps = yield* Ref.make>>(new Map()) - const stopReason = yield* Ref.make(null) - - const claimRunning = (agentId: AgentId): Effect.Effect => - SynchronizedRef.modifyEffect(running, (current) => - current.has(agentId) - ? Effect.succeed([false, current] as const) - : Effect.succeed([true, new Map(current).set(agentId, { fiber: null })] as const), - ) - - const setRunningFiber = ( - agentId: AgentId, - fiber: Fiber.Fiber, - ): Effect.Effect => - SynchronizedRef.modifyEffect(running, (current) => { - const record = current.get(agentId) - if (record === undefined) return Effect.succeed([undefined, current] as const) - return Effect.succeed([undefined, new Map(current).set(agentId, { ...record, fiber })] as const) - }) - - /** Abandon one agent's queued follow-ups: resolve every ticket false and clear the queue. */ - const abandonFollowUps = (agentId: AgentId): Effect.Effect => - Effect.gen(function* () { - const queued = yield* Ref.modify(followUps, (current) => { - const entries = current.get(agentId) ?? [] - const next = new Map(current) - next.delete(agentId) - return [entries, next] as const +/** SessionControls service tag; one instance per session, shared by every provisioned runtime. */ +export class SessionControls extends Context.Service()( + 'fold/SessionControls', + { + /** One session's controls. `steeringMode` fixes how steering queues drain (D8). */ + make: (options?: { readonly steeringMode?: SteeringMode }) => + Effect.gen(function* () { + const steeringMode = options?.steeringMode ?? 'one-at-a-time' + + const running = yield* SynchronizedRef.make>(new Map()) + const steering = yield* Ref.make>>(new Map()) + const followUps = yield* Ref.make>>(new Map()) + const stopReason = yield* Ref.make(null) + + const claimRunning = (agentId: AgentId): Effect.Effect => + SynchronizedRef.modifyEffect(running, (current) => + current.has(agentId) + ? Effect.succeed([false, current] as const) + : Effect.succeed([true, new Map(current).set(agentId, { fiber: null })] as const), + ) + + const setRunningFiber = ( + agentId: AgentId, + fiber: Fiber.Fiber, + ): Effect.Effect => + SynchronizedRef.modifyEffect(running, (current) => { + const record = current.get(agentId) + if (record === undefined) return Effect.succeed([undefined, current] as const) + return Effect.succeed([undefined, new Map(current).set(agentId, { ...record, fiber })] as const) }) - yield* Effect.forEach(queued, (entry) => Deferred.succeed(entry.consumed, false), { discard: true }) - }) - const releaseRunning = (agentId: AgentId): Effect.Effect => - Effect.gen(function* () { - yield* SynchronizedRef.modifyEffect(running, (current) => { - const next = new Map(current) - next.delete(agentId) - return Effect.succeed([undefined, next] as const) + /** Abandon one agent's queued follow-ups: resolve every ticket false and clear the queue. */ + const abandonFollowUps = (agentId: AgentId): Effect.Effect => + Effect.gen(function* () { + const queued = yield* Ref.modify(followUps, (current) => { + const entries = current.get(agentId) ?? [] + const next = new Map(current) + next.delete(agentId) + return [entries, next] as const + }) + yield* Effect.forEach(queued, (entry) => Deferred.succeed(entry.consumed, false), { + discard: true, + }) }) - yield* Ref.update(steering, (current) => { - const next = new Map(current) - next.delete(agentId) - return next + + const releaseRunning = (agentId: AgentId): Effect.Effect => + Effect.gen(function* () { + yield* SynchronizedRef.modifyEffect(running, (current) => { + const next = new Map(current) + next.delete(agentId) + return Effect.succeed([undefined, next] as const) + }) + yield* Ref.update(steering, (current) => { + const next = new Map(current) + next.delete(agentId) + return next + }) + yield* abandonFollowUps(agentId) }) - yield* abandonFollowUps(agentId) - }) - - const isRunning = (agentId: AgentId): Effect.Effect => - SynchronizedRef.get(running).pipe(Effect.map((current) => current.has(agentId))) - - const awaitRunning = (agentId: AgentId): Effect.Effect | null> => - SynchronizedRef.get(running).pipe( - Effect.flatMap((current) => { - const fiber = current.get(agentId)?.fiber ?? null - return fiber === null ? Effect.succeed(null) : Fiber.await(fiber) - }), - ) - const interruptRunning = (agentId: AgentId): Effect.Effect => - SynchronizedRef.get(running).pipe( - Effect.flatMap((current) => { - const fiber = current.get(agentId)?.fiber ?? null - return fiber === null ? Effect.succeed(false) : Fiber.interrupt(fiber).pipe(Effect.as(true)) - }), + const isRunning = (agentId: AgentId): Effect.Effect => + SynchronizedRef.get(running).pipe(Effect.map((current) => current.has(agentId))) + + const awaitRunning = (agentId: AgentId): Effect.Effect | null> => + SynchronizedRef.get(running).pipe( + Effect.flatMap((current) => { + const fiber = current.get(agentId)?.fiber ?? null + return fiber === null ? Effect.succeed(null) : Fiber.await(fiber) + }), + ) + + const interruptRunning = (agentId: AgentId): Effect.Effect => + SynchronizedRef.get(running).pipe( + Effect.flatMap((current) => { + const fiber = current.get(agentId)?.fiber ?? null + return fiber === null ? Effect.succeed(false) : Fiber.interrupt(fiber).pipe(Effect.as(true)) + }), + ) + + const interruptAllRunning: Effect.Effect = SynchronizedRef.get(running).pipe( + Effect.flatMap((current) => + Effect.forEach([...current.keys()], (agentId) => interruptRunning(agentId), { discard: true }), + ), ) - const interruptAllRunning: Effect.Effect = SynchronizedRef.get(running).pipe( - Effect.flatMap((current) => - Effect.forEach([...current.keys()], (agentId) => interruptRunning(agentId), { discard: true }), - ), - ) + const steer = (agentId: AgentId, text: string): Effect.Effect => + Effect.gen(function* () { + if (!(yield* isRunning(agentId))) return yield* notRunning(agentId) + yield* Ref.update(steering, (current) => { + const queued = current.get(agentId) ?? [] + return new Map(current).set(agentId, [...queued, text]) + }) + }) - const steer = (agentId: AgentId, text: string): Effect.Effect => - Effect.gen(function* () { - if (!(yield* isRunning(agentId))) return yield* notRunning(agentId) - yield* Ref.update(steering, (current) => { + const drainSteering = (agentId: AgentId): Effect.Effect> => + Ref.modify(steering, (current) => { const queued = current.get(agentId) ?? [] - return new Map(current).set(agentId, [...queued, text]) + if (Arr.isReadonlyArrayEmpty(queued)) return [[], current] as const + + const drained = steeringMode === 'all' ? queued : queued.slice(0, 1) + const remaining = steeringMode === 'all' ? [] : queued.slice(1) + const next = new Map(current) + if (Arr.isReadonlyArrayEmpty(remaining)) next.delete(agentId) + else next.set(agentId, remaining) + return [drained, next] as const }) - }) - - const drainSteering = (agentId: AgentId): Effect.Effect> => - Ref.modify(steering, (current) => { - const queued = current.get(agentId) ?? [] - if (Arr.isReadonlyArrayEmpty(queued)) return [[], current] as const - - const drained = steeringMode === 'all' ? queued : queued.slice(0, 1) - const remaining = steeringMode === 'all' ? [] : queued.slice(1) - const next = new Map(current) - if (Arr.isReadonlyArrayEmpty(remaining)) next.delete(agentId) - else next.set(agentId, remaining) - return [drained, next] as const - }) - - const pushFollowUp = ( - agentId: AgentId, - text: string, - ): Effect.Effect => - Effect.gen(function* () { - if (!(yield* isRunning(agentId))) return yield* notRunning(agentId) - const consumed = yield* Deferred.make() - yield* Ref.update(followUps, (current) => { - const queued = current.get(agentId) ?? [] - return new Map(current).set(agentId, [...queued, { text, consumed }]) + + const pushFollowUp = ( + agentId: AgentId, + text: string, + ): Effect.Effect => + Effect.gen(function* () { + if (!(yield* isRunning(agentId))) return yield* notRunning(agentId) + const consumed = yield* Deferred.make() + yield* Ref.update(followUps, (current) => { + const queued = current.get(agentId) ?? [] + return new Map(current).set(agentId, [...queued, { text, consumed }]) + }) + return { consumed: Deferred.await(consumed) } }) - return { consumed: Deferred.await(consumed) } - }) - const drainFollowUps = (agentId: AgentId): Effect.Effect> => - Effect.gen(function* () { - const queued = yield* Ref.modify(followUps, (current) => { - const entries = current.get(agentId) ?? [] - const next = new Map(current) - next.delete(agentId) - return [entries, next] as const + const drainFollowUps = (agentId: AgentId): Effect.Effect> => + Effect.gen(function* () { + const queued = yield* Ref.modify(followUps, (current) => { + const entries = current.get(agentId) ?? [] + const next = new Map(current) + next.delete(agentId) + return [entries, next] as const + }) + yield* Effect.forEach(queued, (entry) => Deferred.succeed(entry.consumed, true), { + discard: true, + }) + return queued.map((entry) => entry.text) }) - yield* Effect.forEach(queued, (entry) => Deferred.succeed(entry.consumed, true), { discard: true }) - return queued.map((entry) => entry.text) - }) - - const requestSessionStop = (reason: string): Effect.Effect => - Ref.update(stopReason, (current) => current ?? reason) - - return { - claimRunning, - setRunningFiber, - releaseRunning, - isRunning, - awaitRunning, - interruptRunning, - interruptAllRunning, - steer, - drainSteering, - pushFollowUp, - drainFollowUps, - requestSessionStop, - sessionStopReason: Ref.get(stopReason), - clearSessionStop: Ref.set(stopReason, null), - } - }), - ) + + const requestSessionStop = (reason: string): Effect.Effect => + Ref.update(stopReason, (current) => current ?? reason) + + return { + claimRunning, + setRunningFiber, + releaseRunning, + isRunning, + awaitRunning, + interruptRunning, + interruptAllRunning, + steer, + drainSteering, + pushFollowUp, + drainFollowUps, + requestSessionStop, + sessionStopReason: Ref.get(stopReason), + clearSessionStop: Ref.set(stopReason, null), + } + }), + }, +) { + static readonly layer = (options?: { readonly steeringMode?: SteeringMode }) => + Layer.effect(this, this.make(options)) +} diff --git a/packages/fold-core/src/Session/SessionLayer.ts b/packages/fold-core/src/Session/SessionLayer.ts deleted file mode 100644 index 4e7b257..0000000 --- a/packages/fold-core/src/Session/SessionLayer.ts +++ /dev/null @@ -1,177 +0,0 @@ -/** - * This file implements the live Session layer - the public facade over AgentRuntime, EventLog, Ids, and - * AgentEvents. It owns the single session/root-agent identity in a Ref, appends `session_started` as the - * first durable row, starts the root agent, and exposes `events` as the merge of durable EventLog replay - * (mapped to `kind: 'log'`) with the ephemeral AgentEvents delta stream. - */ -import { Effect, Layer, Ref, Stream } from 'effect' - -import { AgentEvents } from '../AgentEvents/AgentEventsService' -import type { FoldEvent } from '../AgentEvents/AgentEventsService' -import { AgentRuntime } from '../AgentRuntime/AgentRuntimeService' -import { EventLog } from '../EventLog/EventLogService' -import { LogEntryInputs, type LogSeq } from '../EventLog/Schemas' -import { Ids } from '../Ids' -import { SessionAlreadyStartedError, SessionNotStartedError } from './Errors' -import { - Session, - type SessionService, - type StartedSession, - type StartSessionInput, - type SwitchSessionModelInput, -} from './SessionService' - -/** Live Session layer wiring EventLog, Ids, AgentRuntime, and AgentEvents into the public facade. */ -export const liveSessionLayer: Layer.Layer = Layer.effect( - Session, - Effect.gen(function* () { - const eventLog = yield* EventLog - const ids = yield* Ids - const agentRuntime = yield* AgentRuntime - const agentEvents = yield* AgentEvents - - // One provided Session owns exactly one session; null until `start` claims the slot. - const startedRef = yield* Ref.make(null) - - const start: SessionService['start'] = Effect.fn('fold.session.start')((input: StartSessionInput) => - Effect.gen(function* () { - const sessionId = input.sessionId ?? (yield* ids.makeSessionId) - const rootAgentId = yield* ids.makeAgentId - const candidate: StartedSession = { sessionId, rootAgentId } - - // Atomically claim the single session slot. `previous` is the prior occupant: null means we won - // the claim, non-null means the session was already started. Claiming before the durable appends - // keeps a concurrent double-start from writing a second `session_started` row. - const previous = yield* Ref.modify( - startedRef, - (current): readonly [StartedSession | null, StartedSession | null] => - current === null ? [null, candidate] : [current, current], - ) - - if (previous !== null) { - return yield* new SessionAlreadyStartedError({ - message: 'session already started', - sessionId: previous.sessionId, - }) - } - - // `session_started` is the first durable row and carries the freshly minted root agent id. - yield* eventLog - .append( - LogEntryInputs['session_started']({ - agentId: null, - parentAgentId: null, - toolCallId: null, - cwd: input.cwd ?? null, - sessionId, - rootAgentId, - meta: input.meta ?? {}, - }), - ) - .pipe(Effect.orDie) - - yield* agentRuntime.start({ - agentId: rootAgentId, - parentAgentId: null, - toolCallId: null, - mode: 'fresh', - fork: null, - skill: null, - agentType: null, - model: input.model, - promptCacheKey: input.promptCacheKey ?? null, - systemPrompt: input.systemPrompt, - }) - - return candidate - }), - ) - - // Adoption is the resume path (slice 2): the identity comes from a replayed `session_started`, so - // claiming the slot writes NOTHING - the log already carries its first rows. - const adopt: SessionService['adopt'] = Effect.fn('fold.session.adopt')((input: StartedSession) => - Effect.gen(function* () { - const previous = yield* Ref.modify( - startedRef, - (current): readonly [StartedSession | null, StartedSession | null] => - current === null ? [null, input] : [current, current], - ) - - if (previous !== null) { - return yield* new SessionAlreadyStartedError({ - message: 'session already started', - sessionId: previous.sessionId, - }) - } - - return input - }), - ) - - const send: SessionService['send'] = Effect.fn('fold.session.send')((input: { readonly text: string }) => - Effect.gen(function* () { - const started = yield* Ref.get(startedRef) - - if (started === null) { - return yield* new SessionNotStartedError({ message: 'session not started' }) - } - - return yield* agentRuntime.run({ - agentId: started.rootAgentId, - parentAgentId: null, - toolCallId: null, - messages: [input.text], - }) - }), - ) - - const switchModel: SessionService['switchModel'] = Effect.fn('fold.session.switch_model')( - (input: SwitchSessionModelInput) => - Effect.gen(function* () { - const started = yield* Ref.get(startedRef) - - if (started === null) { - return yield* new SessionNotStartedError({ message: 'session not started' }) - } - - yield* agentRuntime.switchModel({ - agentId: started.rootAgentId, - parentAgentId: null, - toolCallId: null, - model: input.model, - systemPrompt: input.systemPrompt, - reason: input.reason ?? null, - }) - }), - ) - - const compact: SessionService['compact'] = Effect.fn('fold.session.compact')((options) => - Effect.gen(function* () { - const started = yield* Ref.get(startedRef) - - if (started === null) { - return yield* new SessionNotStartedError({ message: 'session not started' }) - } - - return yield* agentRuntime.compact({ - agentId: started.rootAgentId, - parentAgentId: null, - toolCallId: null, - trigger: 'manual', - additionalInstructions: options?.additionalInstructions ?? null, - }) - }), - ) - - // Durable rows replay from `fromSeq` and then follow; the EventLogError channel dies (infrastructure - // failure), leaving an error-free stream merged with the live ephemeral deltas. - const events: SessionService['events'] = (fromSeq?: LogSeq) => - eventLog.subscribe(fromSeq).pipe( - Stream.map((entry): FoldEvent => ({ kind: 'log', entry })), - Stream.orDie, - Stream.merge(agentEvents.subscribe), - ) - - return { start, adopt, send, switchModel, compact, events } - }), -) diff --git a/packages/fold-core/src/Session/SessionService.ts b/packages/fold-core/src/Session/SessionService.ts index 24e84f3..1a093b5 100644 --- a/packages/fold-core/src/Session/SessionService.ts +++ b/packages/fold-core/src/Session/SessionService.ts @@ -3,14 +3,26 @@ * starts a session (which mints the session and root-agent ids and appends the first durable rows), sends * user turns to the root agent, and subscribes to a merged stream of durable log rows and ephemeral * deltas. One provided Session instance represents exactly one session, mirroring EventLog. + * + * The live service is the facade over AgentRuntime, EventLog, Ids, and AgentEvents. It owns the single session/root-agent identity in a Ref, appends `session_started` as the + * first durable row, starts the root agent, and exposes `events` as the merge of durable EventLog replay + * (mapped to `kind: 'log'`) with the ephemeral AgentEvents delta stream. */ -import { Context, type Effect, type Schema, type Stream } from 'effect' +import { Context, Effect, type Schema, Stream, Layer, Ref } from 'effect' -import type { FoldEvent } from '../AgentEvents/AgentEventsService' +import { type FoldEvent, AgentEvents } from '../AgentEvents/AgentEventsService' +import { AgentRuntime } from '../AgentRuntime/AgentRuntimeService' import { openSession, type OpenSessionOptions } from '../Api/OpenSession' -import type { ActiveModel, AgentFinishedLogEntry, CompactionLogEntry, LogSeq } from '../EventLog/Schemas' -import type { AgentId, SessionId } from '../Ids' -import type { SessionAlreadyStartedError, SessionNotStartedError } from './Errors' +import { EventLog } from '../EventLog/EventLogService' +import { + type ActiveModel, + type AgentFinishedLogEntry, + type CompactionLogEntry, + type LogSeq, + LogEntryInputs, +} from '../EventLog/Schemas' +import { type AgentId, type SessionId, Ids } from '../Ids' +import { SessionAlreadyStartedError, SessionNotStartedError } from './Errors' /** Input for starting the one session owned by this Session instance. */ export type StartSessionInput = { @@ -67,7 +79,161 @@ export type SessionService = { } /** Session service tag. One provided instance represents one session. */ -export class Session extends Context.Service()('fold/Session') { +export class Session extends Context.Service()('fold/Session', { + /** Wires EventLog, Ids, AgentRuntime, and AgentEvents into the public facade. */ + make: Effect.gen(function* () { + const eventLog = yield* EventLog + const ids = yield* Ids + const agentRuntime = yield* AgentRuntime + const agentEvents = yield* AgentEvents + + // One provided Session owns exactly one session; null until `start` claims the slot. + const startedRef = yield* Ref.make(null) + + const start: SessionService['start'] = Effect.fn('fold.session.start')((input: StartSessionInput) => + Effect.gen(function* () { + const sessionId = input.sessionId ?? (yield* ids.makeSessionId) + const rootAgentId = yield* ids.makeAgentId + const candidate: StartedSession = { sessionId, rootAgentId } + + // Atomically claim the single session slot. `previous` is the prior occupant: null means we won + // the claim, non-null means the session was already started. Claiming before the durable appends + // keeps a concurrent double-start from writing a second `session_started` row. + const previous = yield* Ref.modify( + startedRef, + (current): readonly [StartedSession | null, StartedSession | null] => + current === null ? [null, candidate] : [current, current], + ) + + if (previous !== null) { + return yield* new SessionAlreadyStartedError({ + message: 'session already started', + sessionId: previous.sessionId, + }) + } + + // `session_started` is the first durable row and carries the freshly minted root agent id. + yield* eventLog + .append( + LogEntryInputs['session_started']({ + agentId: null, + parentAgentId: null, + toolCallId: null, + cwd: input.cwd ?? null, + sessionId, + rootAgentId, + meta: input.meta ?? {}, + }), + ) + .pipe(Effect.orDie) + + yield* agentRuntime.start({ + agentId: rootAgentId, + parentAgentId: null, + toolCallId: null, + mode: 'fresh', + fork: null, + skill: null, + agentType: null, + model: input.model, + promptCacheKey: input.promptCacheKey ?? null, + systemPrompt: input.systemPrompt, + }) + + return candidate + }), + ) + + // Adoption is the resume path (slice 2): the identity comes from a replayed `session_started`, so + // claiming the slot writes NOTHING - the log already carries its first rows. + const adopt: SessionService['adopt'] = Effect.fn('fold.session.adopt')((input: StartedSession) => + Effect.gen(function* () { + const previous = yield* Ref.modify( + startedRef, + (current): readonly [StartedSession | null, StartedSession | null] => + current === null ? [null, input] : [current, current], + ) + + if (previous !== null) { + return yield* new SessionAlreadyStartedError({ + message: 'session already started', + sessionId: previous.sessionId, + }) + } + + return input + }), + ) + + const send: SessionService['send'] = Effect.fn('fold.session.send')((input: { readonly text: string }) => + Effect.gen(function* () { + const started = yield* Ref.get(startedRef) + + if (started === null) { + return yield* new SessionNotStartedError({ message: 'session not started' }) + } + + return yield* agentRuntime.run({ + agentId: started.rootAgentId, + parentAgentId: null, + toolCallId: null, + messages: [input.text], + }) + }), + ) + + const switchModel: SessionService['switchModel'] = Effect.fn('fold.session.switch_model')( + (input: SwitchSessionModelInput) => + Effect.gen(function* () { + const started = yield* Ref.get(startedRef) + + if (started === null) { + return yield* new SessionNotStartedError({ message: 'session not started' }) + } + + yield* agentRuntime.switchModel({ + agentId: started.rootAgentId, + parentAgentId: null, + toolCallId: null, + model: input.model, + systemPrompt: input.systemPrompt, + reason: input.reason ?? null, + }) + }), + ) + + const compact: SessionService['compact'] = Effect.fn('fold.session.compact')((options) => + Effect.gen(function* () { + const started = yield* Ref.get(startedRef) + + if (started === null) { + return yield* new SessionNotStartedError({ message: 'session not started' }) + } + + return yield* agentRuntime.compact({ + agentId: started.rootAgentId, + parentAgentId: null, + toolCallId: null, + trigger: 'manual', + additionalInstructions: options?.additionalInstructions ?? null, + }) + }), + ) + + // Durable rows replay from `fromSeq` and then follow; the EventLogError channel dies (infrastructure + // failure), leaving an error-free stream merged with the live ephemeral deltas. + const events: SessionService['events'] = (fromSeq?: LogSeq) => + eventLog.subscribe(fromSeq).pipe( + Stream.map((entry): FoldEvent => ({ kind: 'log', entry })), + Stream.orDie, + Stream.merge(agentEvents.subscribe), + ) + + return { start, adopt, send, switchModel, compact, events } + }), +}) { + static readonly layer = Layer.effect(this, this.make) + /** Initialize a fresh log or resume an existing one. The handle lives in the caller's scope. */ static open(options: OpenSessionOptions) { return openSession(options) diff --git a/packages/fold-core/src/Skills/SkillSource.ts b/packages/fold-core/src/Skills/SkillSource.ts index 505c433..4b5b3ae 100644 --- a/packages/fold-core/src/Skills/SkillSource.ts +++ b/packages/fold-core/src/Skills/SkillSource.ts @@ -1,11 +1,11 @@ /** - * This file defines the SkillSource service (D20) - the isomorphic seam skills load through: `list` + * This file defines the SkillSource seam (D20) - the isomorphic seam skills load through: `list` * returns the metadata roster, `load` returns full content (progressive disclosure). Core ships the * from-data implementation for hosts that pass skills in (browser/workers/presets); fold-agent ships the * disk loader. Public configuration goes through descriptors ({@link skillsFromData} / * {@link skillSource}) so no service or layer appears in caller signatures. */ -import { Context, Effect, Schema } from 'effect' +import { Effect, Schema } from 'effect' import { skillDescriptionProblem, skillNameProblem, type Skill, type SkillMeta } from './Schemas' @@ -27,9 +27,6 @@ export type SkillSourceService = { readonly load: (name: string) => Effect.Effect } -/** SkillSource service tag for advanced/low-level composition. */ -export class SkillSource extends Context.Service()('fold/SkillSource') {} - /** Input for one data-backed skill. `baseDir` is for hosts mirroring on-disk skills. */ export type SkillData = { readonly name: string diff --git a/packages/fold-core/src/ToolRuntime/ToolContextServices.ts b/packages/fold-core/src/ToolRuntime/ToolContextServices.ts index 3e6a656..486605f 100644 --- a/packages/fold-core/src/ToolRuntime/ToolContextServices.ts +++ b/packages/fold-core/src/ToolRuntime/ToolContextServices.ts @@ -3,8 +3,9 @@ * per-call ToolEvents and StopController services, tool handlers consume them for progress and cooperative * stopping, and session-level code provides ToolEventSink to receive annotated progress events. */ -import { Context, Effect, type Schema } from 'effect' +import { Context, Effect, Layer, type Schema } from 'effect' +import { AgentEvents } from '../AgentEvents/AgentEventsService' import type { AgentId, ToolCallId } from '../Ids' /** UI/progress event emitted by a running tool. This is not the durable final tool result. */ @@ -23,7 +24,29 @@ export type ToolEventSinkService = { } /** Sink used by ToolRuntime to publish UI/progress events. */ -export class ToolEventSink extends Context.Service()('fold/ToolEventSink') {} +export class ToolEventSink extends Context.Service()('fold/ToolEventSink', { + /** + * Republish each ToolRuntime progress event as an AgentEvents `tool-progress` delta. This is the seam + * that lifts ephemeral tool progress into the merged session event stream, keeping ToolRuntime unaware + * of AgentEvents. + */ + make: Effect.gen(function* () { + const agentEvents = yield* AgentEvents + + return { + emit: (event) => + agentEvents.publish({ + kind: 'delta', + agentId: event.agentId, + parentAgentId: event.parentAgentId, + toolCallId: event.toolCallId, + part: { type: 'tool-progress', toolName: event.toolName, payload: event.payload }, + }), + } + }), +}) { + static readonly layer = Layer.effect(this, this.make) +} /** No-op event sink for tests that do not assert tool progress. */ export const noopToolEventSink: ToolEventSinkService = { diff --git a/packages/fold-core/src/ToolRuntime/ToolRuntimeLayer.ts b/packages/fold-core/src/ToolRuntime/ToolRuntimeLayer.ts deleted file mode 100644 index 28152ba..0000000 --- a/packages/fold-core/src/ToolRuntime/ToolRuntimeLayer.ts +++ /dev/null @@ -1,544 +0,0 @@ -/** - * This file defines the live ToolRuntime layer consumed by AgentRuntime after an assistant message has - * been persisted. The layer consumes EventLog, Ids, HookRunner, Toolset, and ToolEventSink; it produces - * per-call ToolState, ToolEvents, and StopController services while handlers run, then persists one durable - * tool-result entry per call, including synthetic interruption results when a tool fiber is interrupted. - */ -import { Data, Equal, Match, Predicate, Cause, Effect, Layer, Ref, Schema, Stream, Struct } from 'effect' -import { Prompt } from 'effect/ai' - -import { EventLog } from '../EventLog/EventLogService' -import { LogEntryInputs, type LogEntry, type ToolResultLogEntry } from '../EventLog/Schemas' -import type { HookExecutionError } from '../HookRunner/Errors' -import { HookRunner } from '../HookRunner/HookRunnerService' -import { Ids, ToolCallId, type AgentId } from '../Ids' -import { modelVisibleErrorDetailsFromCause, systemInformation } from './ModelVisibleErrors' -import { - CurrentAgent, - CurrentToolCall, - InterruptNote, - StopController, - ToolEventSink, - ToolEvents, -} from './ToolContextServices' -import { ToolRuntime, type ToolRuntimeService, type ToolSettlement } from './ToolRuntimeService' -import { Toolset, type ToolHandlerOutput } from './ToolsetService' -import { toolStateServiceBuilder } from './ToolStateFactory' -import { ToolState } from './ToolStateService' - -type ToolCallPart = Prompt.ToolCallPart - -type PreparedToolCall = Data.TaggedEnum<{ - execute: { - readonly original: ToolCallPart - readonly params: unknown - } - replaceResult: { - readonly original: ToolCallPart - readonly result: unknown - readonly isFailure: boolean - } -}> - -const PreparedToolCall = Data.taggedEnum() - -type FinalToolOutput = { - readonly result: unknown - readonly isFailure: boolean -} - -type ToolResultAppendInput = { - readonly result: unknown - readonly isFailure: boolean - readonly executedInput?: unknown -} - -const interruptedToolResult = - 'The user interrupted the execution of this tool call.' - -/** - * Compose the synthetic result for an interrupted tool call. When the handler recorded an - * {@link InterruptNote} before the interruption (the subagent tool's resume guidance, bash's - * partial-output path), the note rides inside the same system-information block so the model can act - * on it after a session resume. - */ -const interruptedToolResultWithNote = (note: string | null): string => - note === null - ? interruptedToolResult - : `The user interrupted the execution of this tool call.\n${note}` - -/** Return true when an assistant message part is a model-requested tool call. */ -const isToolCallPart = (part: Prompt.AssistantMessage['content'][number]): part is ToolCallPart => - part.type === 'tool-call' - -/** Extract the tool-call parts from a persisted assistant message in model order. */ -const toolCallsFromAssistantMessage = (assistantMessage: Prompt.AssistantMessage): ReadonlyArray => - assistantMessage.content.filter(isToolCallPart) - -/** Decode and validate the provider-supplied tool call id as a Fold ToolCallId. */ -const decodeToolCallId = (toolCall: ToolCallPart): Effect.Effect => - Schema.decodeEffect(ToolCallId)(toolCall.id).pipe(Effect.orDie) - -/** Encode one persisted tool-result message in Effect AI's Prompt schema. */ -const encodedToolResultMessage = (input: { - readonly toolCallId: ToolCallId - readonly toolName: string - readonly result: unknown - readonly isFailure: boolean -}) => - Schema.encodeEffect(Prompt.ToolMessage)( - Prompt.toolMessage({ - content: [ - Prompt.toolResultPart({ - id: input.toolCallId, - name: input.toolName, - result: input.result, - isFailure: input.isFailure, - providerExecuted: false, - }), - ], - }), - ).pipe(Effect.orDie) - -const unexpectedToolFailureResult = (toolName: string, cause: Cause.Cause): string => - systemInformation(`Tool "${toolName}" failed unexpectedly: ${modelVisibleErrorDetailsFromCause(cause)}`) - -const hookFailureResult = (error: HookExecutionError, toolName: string): string => { - const phase = error.phase === 'preToolUse' ? 'preToolUse' : 'postToolUse' - const action = error.phase === 'preToolUse' ? 'preparing' : 'finalizing' - - return systemInformation( - `${phase} hook "${error.hookName}" failed unexpectedly while ${action} tool "${toolName}": ${modelVisibleErrorDetailsFromCause(error.cause)}`, - ) -} - -/** Build the final handler output used when a tool handler fails while streaming. */ -const failedToolHandlerOutput = (toolName: string, cause: Cause.Cause): ToolHandlerOutput => ({ - result: unexpectedToolFailureResult(toolName, cause), - encodedResult: unexpectedToolFailureResult(toolName, cause), - isFailure: true, - preliminary: false, -}) - -/** Build the handler output for a failed or interrupted handler stream, reading the interrupt note. */ -const failedOrInterruptedToolHandlerOutput = ( - toolName: string, - cause: Cause.Cause, - interruptNoteRef: Ref.Ref, -): Effect.Effect => - Cause.hasInterrupts(cause) - ? Ref.get(interruptNoteRef).pipe( - Effect.map((note): ToolHandlerOutput => { - const text = interruptedToolResultWithNote(note) - return { result: text, encodedResult: text, isFailure: true, preliminary: false } - }), - ) - : Effect.succeed(failedToolHandlerOutput(toolName, cause)) - -/** Select the final non-preliminary handler output, or synthesize a failure when none exists. */ -const finalToolOutputFromHandlerOutputs = ( - outputs: ReadonlyArray, - toolName: string, -): FinalToolOutput => { - const final = outputs.findLast((output) => !output.preliminary) - - if (final !== undefined) { - return { - result: final.encodedResult, - isFailure: final.isFailure, - } - } - - return { - result: { - message: `Tool "${toolName}" completed without a final result.`, - }, - isFailure: true, - } -} - -type SettleToolCallsInput = Parameters[0] - -/** - * Live ToolRuntime layer. It captures EventLog, Ids, hooks, the Toolset, and the progress sink once at - * construction; settlement closes over them and only provides the per-call services it creates. - */ -export const liveToolRuntimeLayer: Layer.Layer< - ToolRuntime, - never, - EventLog | Ids | HookRunner | Toolset | ToolEventSink -> = Layer.effect( - ToolRuntime, - Effect.gen(function* () { - const eventLog = yield* EventLog - const ids = yield* Ids - const hooks = yield* HookRunner - const toolset = yield* Toolset - const sink = yield* ToolEventSink - const toolStateServiceForHandler = yield* toolStateServiceBuilder - - /** Append one tool-result entry through EventLog with a fresh message id. */ - const appendToolResultToEventLog = Effect.fn('fold.tool_runtime.append_tool_result')(function* (input: { - readonly agentId: AgentId - readonly parentAgentId: AgentId | null - readonly toolCallId: ToolCallId - readonly toolName: string - readonly result: unknown - readonly isFailure: boolean - readonly executedInput?: unknown - }) { - const message = yield* encodedToolResultMessage(input) - const entryInput = LogEntryInputs['tool-result']({ - agentId: input.agentId, - parentAgentId: input.parentAgentId, - toolCallId: input.toolCallId, - messageId: yield* ids.makeMessageId, - message, - ...Struct.pick(input, ['executedInput']), - }) - - const entry = yield* eventLog.append(entryInput).pipe(Effect.orDie) - - if (Predicate.isTagged(entry, 'tool-result')) return entry - - // Invariant! - return yield* Effect.die(new Error(`EventLog returned ${entry._tag} while appending tool-result`)) - }) - - /** Apply pre-tool hooks and return either an executable call or a replacement result. */ - const toolCallPreparedByPreToolHooks = (input: { - readonly agentId: AgentId - readonly parentAgentId: AgentId | null - readonly toolCall: ToolCallPart - }): Effect.Effect => - Effect.gen(function* () { - const toolCallId = yield* decodeToolCallId(input.toolCall) - - const decision = yield* hooks.preToolUse({ - agentId: input.agentId, - parentAgentId: input.parentAgentId, - toolCallId, - toolName: input.toolCall.name, - params: input.toolCall.params, - }) - - return Match.valueTags(decision, { - replaceResult: (replacement) => - PreparedToolCall.replaceResult({ - original: input.toolCall, - result: replacement.result, - isFailure: replacement.isFailure, - }), - continue: (continuation) => - PreparedToolCall.execute({ - original: input.toolCall, - params: continuation.params, - }), - }) - }).pipe( - Effect.catchTag('HookExecutionError', (error) => - Effect.succeed( - PreparedToolCall.replaceResult({ - original: input.toolCall, - result: hookFailureResult(error, input.toolCall.name), - isFailure: true, - }), - ), - ), - Effect.catchCause((cause) => - Effect.succeed( - PreparedToolCall.replaceResult({ - original: input.toolCall, - result: Cause.hasInterrupts(cause) - ? interruptedToolResult - : unexpectedToolFailureResult(input.toolCall.name, cause), - isFailure: true, - }), - ), - ), - ) - - /** Forward one preliminary handler output to the session ToolEventSink as JSON progress. */ - const emitPreliminaryToolOutput = (input: { - readonly agentId: AgentId - readonly parentAgentId: AgentId | null - readonly toolCallId: ToolCallId - readonly toolName: string - readonly output: ToolHandlerOutput - }): Effect.Effect => { - if (!input.output.preliminary) return Effect.void - - return Effect.gen(function* () { - const payload = yield* Schema.decodeUnknownEffect(Schema.Json)(input.output.encodedResult).pipe( - Effect.orDie, - ) - - yield* sink.emit({ - agentId: input.agentId, - parentAgentId: input.parentAgentId, - toolCallId: input.toolCallId, - toolName: input.toolName, - payload, - }) - }) - } - - /** Execute one prepared tool handler and reduce its output stream to a final persisted output. */ - const finalOutputFromToolHandler = (input: { - readonly agentId: AgentId - readonly parentAgentId: AgentId | null - readonly toolCallId: ToolCallId - readonly toolName: string - readonly params: unknown - readonly interruptNoteRef: Ref.Ref - }): Effect.Effect< - FinalToolOutput, - never, - ToolState | ToolEvents | StopController | CurrentAgent | CurrentToolCall | InterruptNote - > => - Effect.gen(function* () { - const stream = yield* toolset - .handle(input.toolName, input.params) - .pipe( - Effect.catchCause((cause) => - failedOrInterruptedToolHandlerOutput(input.toolName, cause, input.interruptNoteRef).pipe( - Effect.map(Stream.succeed), - ), - ), - ) - - const collectHandlerOutputs = stream.pipe( - Stream.tap((output) => - emitPreliminaryToolOutput({ - agentId: input.agentId, - parentAgentId: input.parentAgentId, - toolCallId: input.toolCallId, - toolName: input.toolName, - output, - }), - ), - Stream.runCollect, - ) - - const outputs = yield* collectHandlerOutputs.pipe( - Effect.catchCause((cause) => - failedOrInterruptedToolHandlerOutput(input.toolName, cause, input.interruptNoteRef).pipe( - Effect.map((output): ReadonlyArray => [output]), - ), - ), - ) - - return finalToolOutputFromHandlerOutputs(outputs, input.toolName) - }) - - /** Apply post-tool hooks to a successful final output, leaving failures untouched. */ - const finalOutputAfterPostToolHooks = (input: { - readonly agentId: AgentId - readonly parentAgentId: AgentId | null - readonly toolCallId: ToolCallId - readonly toolName: string - readonly originalInput: unknown - readonly executedInput: unknown - readonly output: FinalToolOutput - }): Effect.Effect => - Effect.gen(function* () { - if (input.output.isFailure) return input.output - - const decision = yield* hooks.postToolUse({ - agentId: input.agentId, - parentAgentId: input.parentAgentId, - toolCallId: input.toolCallId, - toolName: input.toolName, - originalInput: input.originalInput, - executedInput: input.executedInput, - handlerResult: input.output.result, - result: input.output.result, - isFailure: input.output.isFailure, - }) - - return Match.valueTags(decision, { - keep: () => input.output, - replace: ({ isFailure, result }) => ({ result, isFailure }), - }) - }) - - /** Execute or replace one prepared tool call and persist exactly one tool-result entry. */ - const settlePreparedToolCall = (input: { - readonly agentId: AgentId - readonly parentAgentId: AgentId | null - readonly prepared: PreparedToolCall - readonly stopRef: Ref.Ref - readonly stateSnapshot: ReadonlyArray - }): Effect.Effect => - Effect.gen(function* () { - const toolCallId = yield* decodeToolCallId(input.prepared.original) - const toolName = input.prepared.original.name - - const toolState = yield* toolStateServiceForHandler({ - agentId: input.agentId, - parentAgentId: input.parentAgentId, - toolCallId, - snapshot: input.stateSnapshot, - }) - - const toolEvents = { - /** Annotate and forward one JSON progress payload from the running handler. */ - emit: (payload: Schema.Json) => - sink.emit({ - agentId: input.agentId, - parentAgentId: input.parentAgentId, - toolCallId, - toolName, - payload, - }), - } - - const stopController = { - /** Record a cooperative stop request shared by all concurrently running tool calls. */ - requestStop: (reason: string) => Ref.set(input.stopRef, reason), - /** Report whether any tool or hook has requested a cooperative stop. */ - isStopRequested: Ref.get(input.stopRef).pipe(Effect.map((reason) => reason !== null)), - } - - // The per-call interrupt note: handlers set it as soon as resumable/externally-visible work - // exists, and finalizers may refine it during unwind; interrupt synthesis below reads it last. - const interruptNoteRef = yield* Ref.make(null) - const interruptNote = { - /** Record the note appended inside this call's synthetic result if it is interrupted. */ - set: (note: string) => Ref.set(interruptNoteRef, note), - } - - const output: Effect.Effect< - ToolResultAppendInput, - HookExecutionError, - StopController | ToolState | ToolEvents | CurrentAgent | CurrentToolCall | InterruptNote - > = Effect.gen(function* () { - if (Predicate.isTagged(input.prepared, 'replaceResult')) { - return { - result: input.prepared.result, - isFailure: input.prepared.isFailure, - } - } - - const handlerOutput = yield* finalOutputFromToolHandler({ - agentId: input.agentId, - parentAgentId: input.parentAgentId, - toolCallId, - toolName, - params: input.prepared.params, - interruptNoteRef, - }) - - const finalOutput = yield* finalOutputAfterPostToolHooks({ - agentId: input.agentId, - parentAgentId: input.parentAgentId, - toolCallId, - toolName, - originalInput: input.prepared.original.params, - executedInput: input.prepared.params, - output: handlerOutput, - }) - - const result: ToolResultAppendInput = { - result: finalOutput.result, - isFailure: finalOutput.isFailure, - } - // The executed input is recorded only when a hook rewrote what the model sent. - return Equal.equals(input.prepared.original.params, input.prepared.params) - ? result - : { ...result, executedInput: input.prepared.params } - }) - - const append = (result: ToolResultAppendInput) => - appendToolResultToEventLog({ - agentId: input.agentId, - parentAgentId: input.parentAgentId, - toolCallId, - toolName, - ...result, - }) - - const runnable = output.pipe( - Effect.provideService(ToolState, toolState), - Effect.provideService(ToolEvents, toolEvents), - Effect.provideService(StopController, stopController), - Effect.provideService(CurrentAgent, { agentId: input.agentId, parentAgentId: input.parentAgentId }), - Effect.provideService(CurrentToolCall, { toolCallId }), - Effect.provideService(InterruptNote, interruptNote), - ) - - return yield* Effect.uninterruptibleMask((restore) => - restore(runnable).pipe( - Effect.catchTag('HookExecutionError', (error) => - Effect.succeed({ result: hookFailureResult(error, toolName), isFailure: true }), - ), - Effect.catchCause((cause) => - Cause.hasInterrupts(cause) - ? // Handler finalizers already ran during unwind, so the note carries their final - // word (the subagent tool's agent_id + turn count, bash's spill-file path). - Ref.get(interruptNoteRef).pipe( - Effect.map((note) => ({ - result: interruptedToolResultWithNote(note), - isFailure: true, - })), - ) - : Effect.succeed({ - result: unexpectedToolFailureResult(toolName, cause), - isFailure: true, - }), - ), - Effect.flatMap(append), - ), - ) - }) - - /** Settle every tool call in one assistant message and report whether a stop was requested. */ - const settle = Effect.fn('fold.tool_runtime.settle')(function* (input: SettleToolCallsInput) { - yield* Effect.annotateCurrentSpan({ agentId: input.agentId, parentAgentId: input.parentAgentId ?? 'none' }) - const stopRef = yield* Ref.make(null) - const stopController = { - requestStop: (reason: string) => Ref.set(stopRef, reason), - isStopRequested: Ref.get(stopRef).pipe(Effect.map((reason) => reason !== null)), - } - const toolCalls = toolCallsFromAssistantMessage(input.assistantMessage) - - const prepared = yield* Effect.forEach( - toolCalls, - (toolCall) => - toolCallPreparedByPreToolHooks({ - agentId: input.agentId, - parentAgentId: input.parentAgentId, - toolCall, - }).pipe(Effect.provideService(StopController, stopController)), - { concurrency: 1 }, - ) - - // Snapshot state once at the fork point, after all preToolUse chains and before any handler runs, - // so parallel handlers read a stable view instead of each other's mid-batch writes. - const stateSnapshot = yield* Stream.runCollect(eventLog.entries()).pipe( - Effect.orDie, - Effect.map((entries): ReadonlyArray => entries), - ) - - const toolResults = yield* Effect.forEach( - prepared, - (preparedCall) => - settlePreparedToolCall({ - agentId: input.agentId, - parentAgentId: input.parentAgentId, - prepared: preparedCall, - stopRef, - stateSnapshot, - }), - { concurrency: 'unbounded' }, - ) - - const stopRequested = yield* Ref.get(stopRef).pipe(Effect.map((reason) => reason !== null)) - - const settlement: ToolSettlement = { toolResults, stopRequested } - return settlement - }) - - return { settle } - }), -) diff --git a/packages/fold-core/src/ToolRuntime/ToolRuntimeService.ts b/packages/fold-core/src/ToolRuntime/ToolRuntimeService.ts index 8c84978..39852da 100644 --- a/packages/fold-core/src/ToolRuntime/ToolRuntimeService.ts +++ b/packages/fold-core/src/ToolRuntime/ToolRuntimeService.ts @@ -3,13 +3,30 @@ * AgentRuntime consumes this service after a model response is persisted, while the live implementation * consumes EventLog, Ids, HookRunner, Toolset, and ToolEventSink to run hooks, execute handlers, persist * tool results, expose ToolState, and report progress. + * + * The live service runs after an assistant message has been persisted. It produces per-call ToolState, ToolEvents, and StopController services while handlers run, then persists one durable + * tool-result entry per call, including synthetic interruption results when a tool fiber is interrupted. */ -import { Context } from 'effect' -import type { Effect } from 'effect' -import type { Prompt } from 'effect/ai' +import { Context, Effect, Data, Equal, Match, Predicate, Cause, Layer, Ref, Schema, Stream, Struct } from 'effect' +import { Prompt } from 'effect/ai' -import type { ToolResultLogEntry } from '../EventLog/Schemas' -import type { AgentId } from '../Ids' +import { EventLog } from '../EventLog/EventLogService' +import { type ToolResultLogEntry, LogEntryInputs, type LogEntry } from '../EventLog/Schemas' +import type { HookExecutionError } from '../HookRunner/Errors' +import { HookRunner } from '../HookRunner/HookRunnerService' +import { type AgentId, Ids, ToolCallId } from '../Ids' +import { modelVisibleErrorDetailsFromCause, systemInformation } from './ModelVisibleErrors' +import { + CurrentAgent, + CurrentToolCall, + InterruptNote, + StopController, + ToolEventSink, + ToolEvents, +} from './ToolContextServices' +import { Toolset, type ToolHandlerOutput } from './ToolsetService' +import { toolStateServiceBuilder } from './ToolStateFactory' +import { ToolState } from './ToolStateService' /** Result of settling all tool calls from one persisted assistant message. */ export type ToolSettlement = { @@ -27,5 +44,517 @@ export type ToolRuntimeService = { }) => Effect.Effect } +type ToolCallPart = Prompt.ToolCallPart + +type PreparedToolCall = Data.TaggedEnum<{ + execute: { + readonly original: ToolCallPart + readonly params: unknown + } + replaceResult: { + readonly original: ToolCallPart + readonly result: unknown + readonly isFailure: boolean + } +}> + +const PreparedToolCall = Data.taggedEnum() + +type FinalToolOutput = { + readonly result: unknown + readonly isFailure: boolean +} + +type ToolResultAppendInput = { + readonly result: unknown + readonly isFailure: boolean + readonly executedInput?: unknown +} + +const interruptedToolResult = + 'The user interrupted the execution of this tool call.' + +/** + * Compose the synthetic result for an interrupted tool call. When the handler recorded an + * {@link InterruptNote} before the interruption (the subagent tool's resume guidance, bash's + * partial-output path), the note rides inside the same system-information block so the model can act + * on it after a session resume. + */ +const interruptedToolResultWithNote = (note: string | null): string => + note === null + ? interruptedToolResult + : `The user interrupted the execution of this tool call.\n${note}` + +/** Return true when an assistant message part is a model-requested tool call. */ +const isToolCallPart = (part: Prompt.AssistantMessage['content'][number]): part is ToolCallPart => + part.type === 'tool-call' + +/** Extract the tool-call parts from a persisted assistant message in model order. */ +const toolCallsFromAssistantMessage = (assistantMessage: Prompt.AssistantMessage): ReadonlyArray => + assistantMessage.content.filter(isToolCallPart) + +/** Decode and validate the provider-supplied tool call id as a Fold ToolCallId. */ +const decodeToolCallId = (toolCall: ToolCallPart): Effect.Effect => + Schema.decodeEffect(ToolCallId)(toolCall.id).pipe(Effect.orDie) + +/** Encode one persisted tool-result message in Effect AI's Prompt schema. */ +const encodedToolResultMessage = (input: { + readonly toolCallId: ToolCallId + readonly toolName: string + readonly result: unknown + readonly isFailure: boolean +}) => + Schema.encodeEffect(Prompt.ToolMessage)( + Prompt.toolMessage({ + content: [ + Prompt.toolResultPart({ + id: input.toolCallId, + name: input.toolName, + result: input.result, + isFailure: input.isFailure, + providerExecuted: false, + }), + ], + }), + ).pipe(Effect.orDie) + +const unexpectedToolFailureResult = (toolName: string, cause: Cause.Cause): string => + systemInformation(`Tool "${toolName}" failed unexpectedly: ${modelVisibleErrorDetailsFromCause(cause)}`) + +const hookFailureResult = (error: HookExecutionError, toolName: string): string => { + const phase = error.phase === 'preToolUse' ? 'preToolUse' : 'postToolUse' + const action = error.phase === 'preToolUse' ? 'preparing' : 'finalizing' + + return systemInformation( + `${phase} hook "${error.hookName}" failed unexpectedly while ${action} tool "${toolName}": ${modelVisibleErrorDetailsFromCause(error.cause)}`, + ) +} + +/** Build the final handler output used when a tool handler fails while streaming. */ +const failedToolHandlerOutput = (toolName: string, cause: Cause.Cause): ToolHandlerOutput => ({ + result: unexpectedToolFailureResult(toolName, cause), + encodedResult: unexpectedToolFailureResult(toolName, cause), + isFailure: true, + preliminary: false, +}) + +/** Build the handler output for a failed or interrupted handler stream, reading the interrupt note. */ +const failedOrInterruptedToolHandlerOutput = ( + toolName: string, + cause: Cause.Cause, + interruptNoteRef: Ref.Ref, +): Effect.Effect => + Cause.hasInterrupts(cause) + ? Ref.get(interruptNoteRef).pipe( + Effect.map((note): ToolHandlerOutput => { + const text = interruptedToolResultWithNote(note) + return { result: text, encodedResult: text, isFailure: true, preliminary: false } + }), + ) + : Effect.succeed(failedToolHandlerOutput(toolName, cause)) + +/** Select the final non-preliminary handler output, or synthesize a failure when none exists. */ +const finalToolOutputFromHandlerOutputs = ( + outputs: ReadonlyArray, + toolName: string, +): FinalToolOutput => { + const final = outputs.findLast((output) => !output.preliminary) + + if (final !== undefined) { + return { + result: final.encodedResult, + isFailure: final.isFailure, + } + } + + return { + result: { + message: `Tool "${toolName}" completed without a final result.`, + }, + isFailure: true, + } +} + +type SettleToolCallsInput = Parameters[0] + /** ToolRuntime service tag. */ -export class ToolRuntime extends Context.Service()('fold/ToolRuntime') {} +export class ToolRuntime extends Context.Service()('fold/ToolRuntime', { + /** + * Captures EventLog, Ids, hooks, the Toolset, and the progress sink once at construction; settlement + * closes over them and only provides the per-call services it creates. + */ + make: Effect.gen(function* () { + const eventLog = yield* EventLog + const ids = yield* Ids + const hooks = yield* HookRunner + const toolset = yield* Toolset + const sink = yield* ToolEventSink + const toolStateServiceForHandler = yield* toolStateServiceBuilder + + /** Append one tool-result entry through EventLog with a fresh message id. */ + const appendToolResultToEventLog = Effect.fn('fold.tool_runtime.append_tool_result')(function* (input: { + readonly agentId: AgentId + readonly parentAgentId: AgentId | null + readonly toolCallId: ToolCallId + readonly toolName: string + readonly result: unknown + readonly isFailure: boolean + readonly executedInput?: unknown + }) { + const message = yield* encodedToolResultMessage(input) + const entryInput = LogEntryInputs['tool-result']({ + agentId: input.agentId, + parentAgentId: input.parentAgentId, + toolCallId: input.toolCallId, + messageId: yield* ids.makeMessageId, + message, + ...Struct.pick(input, ['executedInput']), + }) + + const entry = yield* eventLog.append(entryInput).pipe(Effect.orDie) + + if (Predicate.isTagged(entry, 'tool-result')) return entry + + // Invariant! + return yield* Effect.die(new Error(`EventLog returned ${entry._tag} while appending tool-result`)) + }) + + /** Apply pre-tool hooks and return either an executable call or a replacement result. */ + const toolCallPreparedByPreToolHooks = (input: { + readonly agentId: AgentId + readonly parentAgentId: AgentId | null + readonly toolCall: ToolCallPart + }): Effect.Effect => + Effect.gen(function* () { + const toolCallId = yield* decodeToolCallId(input.toolCall) + + const decision = yield* hooks.preToolUse({ + agentId: input.agentId, + parentAgentId: input.parentAgentId, + toolCallId, + toolName: input.toolCall.name, + params: input.toolCall.params, + }) + + return Match.valueTags(decision, { + replaceResult: (replacement) => + PreparedToolCall.replaceResult({ + original: input.toolCall, + result: replacement.result, + isFailure: replacement.isFailure, + }), + continue: (continuation) => + PreparedToolCall.execute({ + original: input.toolCall, + params: continuation.params, + }), + }) + }).pipe( + Effect.catchTag('HookExecutionError', (error) => + Effect.succeed( + PreparedToolCall.replaceResult({ + original: input.toolCall, + result: hookFailureResult(error, input.toolCall.name), + isFailure: true, + }), + ), + ), + Effect.catchCause((cause) => + Effect.succeed( + PreparedToolCall.replaceResult({ + original: input.toolCall, + result: Cause.hasInterrupts(cause) + ? interruptedToolResult + : unexpectedToolFailureResult(input.toolCall.name, cause), + isFailure: true, + }), + ), + ), + ) + + /** Forward one preliminary handler output to the session ToolEventSink as JSON progress. */ + const emitPreliminaryToolOutput = (input: { + readonly agentId: AgentId + readonly parentAgentId: AgentId | null + readonly toolCallId: ToolCallId + readonly toolName: string + readonly output: ToolHandlerOutput + }): Effect.Effect => { + if (!input.output.preliminary) return Effect.void + + return Effect.gen(function* () { + const payload = yield* Schema.decodeUnknownEffect(Schema.Json)(input.output.encodedResult).pipe( + Effect.orDie, + ) + + yield* sink.emit({ + agentId: input.agentId, + parentAgentId: input.parentAgentId, + toolCallId: input.toolCallId, + toolName: input.toolName, + payload, + }) + }) + } + + /** Execute one prepared tool handler and reduce its output stream to a final persisted output. */ + const finalOutputFromToolHandler = (input: { + readonly agentId: AgentId + readonly parentAgentId: AgentId | null + readonly toolCallId: ToolCallId + readonly toolName: string + readonly params: unknown + readonly interruptNoteRef: Ref.Ref + }): Effect.Effect< + FinalToolOutput, + never, + ToolState | ToolEvents | StopController | CurrentAgent | CurrentToolCall | InterruptNote + > => + Effect.gen(function* () { + const stream = yield* toolset + .handle(input.toolName, input.params) + .pipe( + Effect.catchCause((cause) => + failedOrInterruptedToolHandlerOutput(input.toolName, cause, input.interruptNoteRef).pipe( + Effect.map(Stream.succeed), + ), + ), + ) + + const collectHandlerOutputs = stream.pipe( + Stream.tap((output) => + emitPreliminaryToolOutput({ + agentId: input.agentId, + parentAgentId: input.parentAgentId, + toolCallId: input.toolCallId, + toolName: input.toolName, + output, + }), + ), + Stream.runCollect, + ) + + const outputs = yield* collectHandlerOutputs.pipe( + Effect.catchCause((cause) => + failedOrInterruptedToolHandlerOutput(input.toolName, cause, input.interruptNoteRef).pipe( + Effect.map((output): ReadonlyArray => [output]), + ), + ), + ) + + return finalToolOutputFromHandlerOutputs(outputs, input.toolName) + }) + + /** Apply post-tool hooks to a successful final output, leaving failures untouched. */ + const finalOutputAfterPostToolHooks = (input: { + readonly agentId: AgentId + readonly parentAgentId: AgentId | null + readonly toolCallId: ToolCallId + readonly toolName: string + readonly originalInput: unknown + readonly executedInput: unknown + readonly output: FinalToolOutput + }): Effect.Effect => + Effect.gen(function* () { + if (input.output.isFailure) return input.output + + const decision = yield* hooks.postToolUse({ + agentId: input.agentId, + parentAgentId: input.parentAgentId, + toolCallId: input.toolCallId, + toolName: input.toolName, + originalInput: input.originalInput, + executedInput: input.executedInput, + handlerResult: input.output.result, + result: input.output.result, + isFailure: input.output.isFailure, + }) + + return Match.valueTags(decision, { + keep: () => input.output, + replace: ({ isFailure, result }) => ({ result, isFailure }), + }) + }) + + /** Execute or replace one prepared tool call and persist exactly one tool-result entry. */ + const settlePreparedToolCall = (input: { + readonly agentId: AgentId + readonly parentAgentId: AgentId | null + readonly prepared: PreparedToolCall + readonly stopRef: Ref.Ref + readonly stateSnapshot: ReadonlyArray + }): Effect.Effect => + Effect.gen(function* () { + const toolCallId = yield* decodeToolCallId(input.prepared.original) + const toolName = input.prepared.original.name + + const toolState = yield* toolStateServiceForHandler({ + agentId: input.agentId, + parentAgentId: input.parentAgentId, + toolCallId, + snapshot: input.stateSnapshot, + }) + + const toolEvents = { + /** Annotate and forward one JSON progress payload from the running handler. */ + emit: (payload: Schema.Json) => + sink.emit({ + agentId: input.agentId, + parentAgentId: input.parentAgentId, + toolCallId, + toolName, + payload, + }), + } + + const stopController = { + /** Record a cooperative stop request shared by all concurrently running tool calls. */ + requestStop: (reason: string) => Ref.set(input.stopRef, reason), + /** Report whether any tool or hook has requested a cooperative stop. */ + isStopRequested: Ref.get(input.stopRef).pipe(Effect.map((reason) => reason !== null)), + } + + // The per-call interrupt note: handlers set it as soon as resumable/externally-visible work + // exists, and finalizers may refine it during unwind; interrupt synthesis below reads it last. + const interruptNoteRef = yield* Ref.make(null) + const interruptNote = { + /** Record the note appended inside this call's synthetic result if it is interrupted. */ + set: (note: string) => Ref.set(interruptNoteRef, note), + } + + const output: Effect.Effect< + ToolResultAppendInput, + HookExecutionError, + StopController | ToolState | ToolEvents | CurrentAgent | CurrentToolCall | InterruptNote + > = Effect.gen(function* () { + if (Predicate.isTagged(input.prepared, 'replaceResult')) { + return { + result: input.prepared.result, + isFailure: input.prepared.isFailure, + } + } + + const handlerOutput = yield* finalOutputFromToolHandler({ + agentId: input.agentId, + parentAgentId: input.parentAgentId, + toolCallId, + toolName, + params: input.prepared.params, + interruptNoteRef, + }) + + const finalOutput = yield* finalOutputAfterPostToolHooks({ + agentId: input.agentId, + parentAgentId: input.parentAgentId, + toolCallId, + toolName, + originalInput: input.prepared.original.params, + executedInput: input.prepared.params, + output: handlerOutput, + }) + + const result: ToolResultAppendInput = { + result: finalOutput.result, + isFailure: finalOutput.isFailure, + } + // The executed input is recorded only when a hook rewrote what the model sent. + return Equal.equals(input.prepared.original.params, input.prepared.params) + ? result + : { ...result, executedInput: input.prepared.params } + }) + + const append = (result: ToolResultAppendInput) => + appendToolResultToEventLog({ + agentId: input.agentId, + parentAgentId: input.parentAgentId, + toolCallId, + toolName, + ...result, + }) + + const runnable = output.pipe( + Effect.provideService(ToolState, toolState), + Effect.provideService(ToolEvents, toolEvents), + Effect.provideService(StopController, stopController), + Effect.provideService(CurrentAgent, { agentId: input.agentId, parentAgentId: input.parentAgentId }), + Effect.provideService(CurrentToolCall, { toolCallId }), + Effect.provideService(InterruptNote, interruptNote), + ) + + return yield* Effect.uninterruptibleMask((restore) => + restore(runnable).pipe( + Effect.catchTag('HookExecutionError', (error) => + Effect.succeed({ result: hookFailureResult(error, toolName), isFailure: true }), + ), + Effect.catchCause((cause) => + Cause.hasInterrupts(cause) + ? // Handler finalizers already ran during unwind, so the note carries their final + // word (the subagent tool's agent_id + turn count, bash's spill-file path). + Ref.get(interruptNoteRef).pipe( + Effect.map((note) => ({ + result: interruptedToolResultWithNote(note), + isFailure: true, + })), + ) + : Effect.succeed({ + result: unexpectedToolFailureResult(toolName, cause), + isFailure: true, + }), + ), + Effect.flatMap(append), + ), + ) + }) + + /** Settle every tool call in one assistant message and report whether a stop was requested. */ + const settle = Effect.fn('fold.tool_runtime.settle')(function* (input: SettleToolCallsInput) { + yield* Effect.annotateCurrentSpan({ agentId: input.agentId, parentAgentId: input.parentAgentId ?? 'none' }) + const stopRef = yield* Ref.make(null) + const stopController = { + requestStop: (reason: string) => Ref.set(stopRef, reason), + isStopRequested: Ref.get(stopRef).pipe(Effect.map((reason) => reason !== null)), + } + const toolCalls = toolCallsFromAssistantMessage(input.assistantMessage) + + const prepared = yield* Effect.forEach( + toolCalls, + (toolCall) => + toolCallPreparedByPreToolHooks({ + agentId: input.agentId, + parentAgentId: input.parentAgentId, + toolCall, + }).pipe(Effect.provideService(StopController, stopController)), + { concurrency: 1 }, + ) + + // Snapshot state once at the fork point, after all preToolUse chains and before any handler runs, + // so parallel handlers read a stable view instead of each other's mid-batch writes. + const stateSnapshot = yield* Stream.runCollect(eventLog.entries()).pipe( + Effect.orDie, + Effect.map((entries): ReadonlyArray => entries), + ) + + const toolResults = yield* Effect.forEach( + prepared, + (preparedCall) => + settlePreparedToolCall({ + agentId: input.agentId, + parentAgentId: input.parentAgentId, + prepared: preparedCall, + stopRef, + stateSnapshot, + }), + { concurrency: 'unbounded' }, + ) + + const stopRequested = yield* Ref.get(stopRef).pipe(Effect.map((reason) => reason !== null)) + + const settlement: ToolSettlement = { toolResults, stopRequested } + return settlement + }) + + return { settle } + }), +}) { + static readonly layer = Layer.effect(this, this.make) +} diff --git a/packages/fold-core/src/index.ts b/packages/fold-core/src/index.ts index 7595ff7..d8e1a4c 100644 --- a/packages/fold-core/src/index.ts +++ b/packages/fold-core/src/index.ts @@ -1,5 +1,4 @@ export * from './Ids' -export * from './AgentRuntime/AgentRuntimeLayer' export * from './AgentRuntime/AgentRuntimeService' export * from './Api/AgentDefinition' export * from './Api/ModelDescriptor' @@ -22,7 +21,6 @@ export * from './Subagents/SubagentDefinition' export * from './Subagents/SessionAgents' export * from './Subagents/SubagentEngine' export * from './Subagents/SubagentTool' -export * from './AgentEvents/AgentEventsLayer' export * from './AgentEvents/AgentEventsService' export * from './Compaction/CompactionArchiveAccess' export * from './Compaction/CompactionEngine' @@ -37,7 +35,6 @@ export * from './EventLog/MessageContent' export * from './EventLog/Schemas' export * from './EventLog/StoredLogEntryDecoder' export * from './EventLog/Usage' -export * from './HookRunner/HookRunnerLayer' export * from './HookRunner/Errors' export * from './HookRunner/HookRunnerService' export * from './HookRunner/Schema' @@ -61,10 +58,8 @@ export * from './Session/Profiles' export * from './Session/SessionControls' export * from './SystemPrompt/SystemPromptLayer' export * from './SystemPrompt/SystemPromptService' -export * from './Session/SessionLayer' export * from './Session/SessionService' export * from './ToolRuntime/ToolContextServices' -export * from './ToolRuntime/ToolRuntimeLayer' export * from './ToolRuntime/ToolRuntimeService' export * from './ToolRuntime/ToolsetFactory' export * from './ToolRuntime/ToolsetResolverLayer' diff --git a/packages/fold-core/test/AgentRuntime/AgentRuntimeModelSettings.vi.test.ts b/packages/fold-core/test/AgentRuntime/AgentRuntimeModelSettings.vi.test.ts index 233cc98..e3df223 100644 --- a/packages/fold-core/test/AgentRuntime/AgentRuntimeModelSettings.vi.test.ts +++ b/packages/fold-core/test/AgentRuntime/AgentRuntimeModelSettings.vi.test.ts @@ -5,16 +5,15 @@ import { Tool, Toolkit } from 'effect/ai' import type { LanguageModel } from 'effect/ai' import { + HookRunner, + AgentEvents, + ToolRuntime, + SessionControls, AgentRuntime, EventLog, layerDefaultSystemPrompt, layerInMemoryEventLog, - liveAgentEventsLayer, - liveAgentRuntimeLayer, liveModelRequestSettingsLayer, - liveToolRuntimeLayer, - layerHookRunner, - layerSessionControls, layerSystemPrompt, layerToolsetResolver, noopToolEventSink, @@ -182,20 +181,20 @@ const familyAgentLayer = ( const sharedLayer = Layer.mergeAll( memoryLayer, idsLayer, - liveAgentEventsLayer, + AgentEvents.layer, toolsetLayer, layerToolsetResolver().pipe(Layer.provide(toolsetLayer)), systemPromptLayer, liveModelRequestSettingsLayer, - layerHookRunner({}).pipe(Layer.provide(Layer.mergeAll(memoryLayer, idsLayer))), + HookRunner.layer({}).pipe(Layer.provide(Layer.mergeAll(memoryLayer, idsLayer))), Layer.succeed(ToolEventSink, noopToolEventSink), - layerSessionControls(), + SessionControls.layer(), NodeFileSystem.layer, ) - const toolRuntimeLayer = liveToolRuntimeLayer.pipe(Layer.provideMerge(sharedLayer)) + const toolRuntimeLayer = ToolRuntime.layer.pipe(Layer.provideMerge(sharedLayer)) - return liveAgentRuntimeLayer.pipe(Layer.provideMerge(Layer.mergeAll(toolRuntimeLayer, modelLayer))) + return AgentRuntime.layer.pipe(Layer.provideMerge(Layer.mergeAll(toolRuntimeLayer, modelLayer))) } const codexModel: ActiveModel = { diff --git a/packages/fold-core/test/AgentRuntime/AgentRuntimeTestHelpers.ts b/packages/fold-core/test/AgentRuntime/AgentRuntimeTestHelpers.ts index 7c0f203..8033cb5 100644 --- a/packages/fold-core/test/AgentRuntime/AgentRuntimeTestHelpers.ts +++ b/packages/fold-core/test/AgentRuntime/AgentRuntimeTestHelpers.ts @@ -3,15 +3,15 @@ import { Layer } from 'effect' import type { LanguageModel, Tool } from 'effect/ai' import { + HookRunner, + AgentEvents, + AgentRuntime, + ToolRuntime, + SessionControls, AgentId, layerDefaultSystemPrompt, layerInMemoryEventLog, - liveAgentEventsLayer, - liveAgentRuntimeLayer, liveModelRequestSettingsLayer, - liveToolRuntimeLayer, - layerHookRunner, - layerSessionControls, layerToolsetResolver, noopToolEventSink, StopConditions, @@ -73,7 +73,7 @@ export const agentRuntimeBaseLayer = ( ) => { const memoryLayer = layerInMemoryEventLog const idsLayer = layerDeterministicRuntime({ startMillis: 1_000, stepMillis: 0 }) - const agentEventsLayer = liveAgentEventsLayer + const agentEventsLayer = AgentEvents.layer const hookDeps = Layer.mergeAll(memoryLayer, idsLayer) const toolsetLayer = toolsetLayerFromToolkit(TestToolkit).pipe(Layer.provide(toolHandlerLayer)) @@ -85,14 +85,14 @@ export const agentRuntimeBaseLayer = ( layerToolsetResolver().pipe(Layer.provide(toolsetLayer)), layerDefaultSystemPrompt, liveModelRequestSettingsLayer, - layerHookRunner(hooks).pipe(Layer.provide(hookDeps)), + HookRunner.layer(hooks).pipe(Layer.provide(hookDeps)), Layer.succeed(ToolEventSink, noopToolEventSink), Layer.succeed(StopConditions, stopConditions), - layerSessionControls(), + SessionControls.layer(), NodeFileSystem.layer, ) - const toolRuntimeLayer = liveToolRuntimeLayer.pipe(Layer.provideMerge(sharedLayer)) + const toolRuntimeLayer = ToolRuntime.layer.pipe(Layer.provideMerge(sharedLayer)) - return liveAgentRuntimeLayer.pipe(Layer.provideMerge(Layer.mergeAll(toolRuntimeLayer, modelLayer))) + return AgentRuntime.layer.pipe(Layer.provideMerge(Layer.mergeAll(toolRuntimeLayer, modelLayer))) } diff --git a/packages/fold-core/test/Api/SwitchModel.vi.test.ts b/packages/fold-core/test/Api/SwitchModel.vi.test.ts index 24780cb..6f5e218 100644 --- a/packages/fold-core/test/Api/SwitchModel.vi.test.ts +++ b/packages/fold-core/test/Api/SwitchModel.vi.test.ts @@ -7,11 +7,12 @@ import * as NodeFileSystem from '@effect/platform-node/NodeFileSystem' * log and the requests the scripted per-epoch models actually received. */ import { expect, it } from '@effect/vitest' -import { Predicate, Effect, Schema } from 'effect' +import { Predicate, Effect, Exit, Ref, Schema, Scope } from 'effect' import { defineAgent, defineSubagent, + customModel, defineTool, Session, subagentTool, @@ -350,3 +351,37 @@ it.effect('switchModel extends the subagent registry at the switch boundary', () }) }).pipe(Effect.scoped, Effect.provide(NodeFileSystem.layer)), ) + +it.effect('switchModel releases the replaced runtime and keeps the new one until the session closes', () => + Effect.gen(function* () { + const released = yield* Ref.make>([]) + /** A scripted model whose provider resource records its release. */ + const releasingModel = (label: string, activeModel: ActiveModel, text: string) => + scriptedModel(activeModel, [textTurn(text)]).pipe( + Effect.map(({ model }) => + customModel({ + activeModel, + make: Effect.addFinalizer(() => Ref.update(released, (labels) => [...labels, label])).pipe( + Effect.andThen(model.make), + ), + }), + ), + ) + const first = yield* releasingModel('first', gptActiveModel, 'from gpt') + const second = yield* releasingModel('second', claudeActiveModel, 'from claude') + const sessionScope = yield* Scope.make() + + const session = yield* Session.open({ agent: defineAgent({ model: first }) }).pipe(Scope.provide(sessionScope)) + yield* session.send('first turn') + expect(yield* Ref.get(released)).toEqual([]) + + yield* session.switchModel(second) + expect(yield* Ref.get(released)).toEqual(['first']) + + const turnTwo = yield* session.send('second turn') + expect(turnTwo.resultText).toBe('from claude') + + yield* Scope.close(sessionScope, Exit.void) + expect(yield* Ref.get(released)).toEqual(['first', 'second']) + }).pipe(Effect.provide(NodeFileSystem.layer)), +) diff --git a/packages/fold-core/test/HookRunner/HookScope.vi.test.ts b/packages/fold-core/test/HookRunner/HookScope.vi.test.ts index 78e6587..733bd78 100644 --- a/packages/fold-core/test/HookRunner/HookScope.vi.test.ts +++ b/packages/fold-core/test/HookRunner/HookScope.vi.test.ts @@ -6,7 +6,6 @@ import { defineToolState, HookRunner, layerInMemoryEventLog, - layerHookRunner, StopController, ToolCallId, type HookConfig, @@ -32,7 +31,7 @@ const hookScopeLayer = (config: HookConfig) => { layerDeterministicRuntime({ startMillis: 1_000, stepMillis: 0 }), ) - return Layer.mergeAll(layerHookRunner(config).pipe(Layer.provide(infra)), infra) + return Layer.mergeAll(HookRunner.layer(config).pipe(Layer.provide(infra)), infra) } describe('HookRunner hook scope services', () => { diff --git a/packages/fold-core/test/HookRunner/HookStateResume.vi.test.ts b/packages/fold-core/test/HookRunner/HookStateResume.vi.test.ts index 3feac6f..5d3cb1d 100644 --- a/packages/fold-core/test/HookRunner/HookStateResume.vi.test.ts +++ b/packages/fold-core/test/HookRunner/HookStateResume.vi.test.ts @@ -7,7 +7,6 @@ import { EventLog, HookRunner, layerInMemoryEventLog, - layerHookRunner, StateId, StopController, ToolCallId, @@ -48,7 +47,7 @@ it.effect('a hook reproduces its state from tool_state entries already persisted layerInMemoryEventLog, layerDeterministicRuntime({ startMillis: 1_000, stepMillis: 0 }), ) - const layer = Layer.mergeAll(layerHookRunner(config).pipe(Layer.provide(infra)), infra) + const layer = Layer.mergeAll(HookRunner.layer(config).pipe(Layer.provide(infra)), infra) const result = yield* Effect.gen(function* () { // Seed the log directly, as if a previous process had persisted hook state before shutdown. diff --git a/packages/fold-core/test/Session/SessionTestHelpers.ts b/packages/fold-core/test/Session/SessionTestHelpers.ts index 84bfb22..4793a03 100644 --- a/packages/fold-core/test/Session/SessionTestHelpers.ts +++ b/packages/fold-core/test/Session/SessionTestHelpers.ts @@ -3,17 +3,17 @@ import { Layer } from 'effect' import type { LanguageModel, Tool } from 'effect/ai' import { + Session, + HookRunner, + AgentEvents, + ToolEventSink, + AgentRuntime, + ToolRuntime, + SessionControls, layerDefaultSystemPrompt, layerInMemoryEventLog, - liveAgentEventsLayer, - liveAgentRuntimeLayer, liveModelRequestSettingsLayer, - liveSessionLayer, - liveToolRuntimeLayer, - layerHookRunner, - layerSessionControls, layerToolsetResolver, - toolEventSinkLayerFromAgentEvents, toolsetLayerFromToolkit, type HookConfig, type StartSessionInput, @@ -36,11 +36,11 @@ export const startSessionInput = (overrides?: Partial): Start /** * Real Session over the real AgentRuntime, ToolRuntime, EventLog, projections, live HookRunner, and live * AgentEvents. Mirrors `agentRuntimeBaseLayer` sharing discipline: one `layerInMemoryEventLog` reference, - * one `layerDeterministicRuntime` reference, and one `liveAgentEventsLayer` reference are shared across the + * one `layerDeterministicRuntime` reference, and one `AgentEvents.layer` reference are shared across the * whole graph so Effect layer memoization builds a single EventLog, clock/ids, and AgentEvents PubSub. * * Two differences from `agentRuntimeBaseLayer`: `ToolEventSink` is bridged into AgentEvents (so tool - * progress reaches `Session.events`), and `liveSessionLayer` is merged on top so tests can reach `Session`, + * progress reaches `Session.events`), and `Session.layer` is merged on top so tests can reach `Session`, * `EventLog`, and `AgentEvents`. */ export const sessionBaseLayer = ( @@ -50,7 +50,7 @@ export const sessionBaseLayer = ( ) => { const memoryLayer = layerInMemoryEventLog const idsLayer = layerDeterministicRuntime({ startMillis: 1_000, stepMillis: 0 }) - const agentEventsLayer = liveAgentEventsLayer + const agentEventsLayer = AgentEvents.layer const hookDeps = Layer.mergeAll(memoryLayer, idsLayer) const toolsetLayer = toolsetLayerFromToolkit(TestToolkit).pipe(Layer.provide(toolHandlerLayer)) @@ -62,17 +62,15 @@ export const sessionBaseLayer = ( layerToolsetResolver().pipe(Layer.provide(toolsetLayer)), layerDefaultSystemPrompt, liveModelRequestSettingsLayer, - layerHookRunner(hooks).pipe(Layer.provide(hookDeps)), - toolEventSinkLayerFromAgentEvents.pipe(Layer.provide(agentEventsLayer)), - layerSessionControls(), + HookRunner.layer(hooks).pipe(Layer.provide(hookDeps)), + ToolEventSink.layer.pipe(Layer.provide(agentEventsLayer)), + SessionControls.layer(), NodeFileSystem.layer, ) - const toolRuntimeLayer = liveToolRuntimeLayer.pipe(Layer.provideMerge(sharedLayer)) + const toolRuntimeLayer = ToolRuntime.layer.pipe(Layer.provideMerge(sharedLayer)) - const agentRuntimeLayer = liveAgentRuntimeLayer.pipe( - Layer.provideMerge(Layer.mergeAll(toolRuntimeLayer, modelLayer)), - ) + const agentRuntimeLayer = AgentRuntime.layer.pipe(Layer.provideMerge(Layer.mergeAll(toolRuntimeLayer, modelLayer))) - return liveSessionLayer.pipe(Layer.provideMerge(agentRuntimeLayer)) + return Session.layer.pipe(Layer.provideMerge(agentRuntimeLayer)) } diff --git a/packages/fold-core/test/TestLayers/HookRunnerTestHarness.ts b/packages/fold-core/test/TestLayers/HookRunnerTestHarness.ts index 60295b5..a18558e 100644 --- a/packages/fold-core/test/TestLayers/HookRunnerTestHarness.ts +++ b/packages/fold-core/test/TestLayers/HookRunnerTestHarness.ts @@ -1,9 +1,8 @@ import { Effect, Layer } from 'effect' import { - type HookRunner, + HookRunner, layerInMemoryEventLog, - layerHookRunner, StopController, type HookConfig, type StopControllerService, @@ -23,7 +22,7 @@ export const runWithHookRunner = ( effect.pipe( Effect.provideService(StopController, stopController), Effect.provide( - layerHookRunner(config).pipe( + HookRunner.layer(config).pipe( Layer.provide( Layer.mergeAll( layerInMemoryEventLog, diff --git a/packages/fold-core/test/ToolRuntime/ToolRuntimeDefect.vi.test.ts b/packages/fold-core/test/ToolRuntime/ToolRuntimeDefect.vi.test.ts index 3eee7e9..5eecefa 100644 --- a/packages/fold-core/test/ToolRuntime/ToolRuntimeDefect.vi.test.ts +++ b/packages/fold-core/test/ToolRuntime/ToolRuntimeDefect.vi.test.ts @@ -1,7 +1,7 @@ import { expect, it } from '@effect/vitest' import { Predicate, Effect, Ref } from 'effect' -import { layerHookRunner, messagesForAgent, StopController, ToolRuntime } from '../../src/index' +import { HookRunner, messagesForAgent, StopController, ToolRuntime } from '../../src/index' import { layerEchoTool, makeEchoRecorder, TestToolkit } from '../TestLayers/TestTools' import { agentId, collectEntries, makeAssistantToolCall, toolRuntimeBaseLayer } from './ToolRuntimeTestHelpers' @@ -29,7 +29,7 @@ it.effect('projects tool handler defects as model-visible tool failures', () => }), }), ) - const layer = toolRuntimeBaseLayer(layerHookRunner({}), toolLayer) + const layer = toolRuntimeBaseLayer(HookRunner.layer({}), toolLayer) const result = yield* Effect.gen(function* () { const runtime = yield* ToolRuntime @@ -63,7 +63,7 @@ it.effect('truncates long defect messages before projecting them to the model', echo: () => Effect.die(new Error(longMessage)), }), ) - const layer = toolRuntimeBaseLayer(layerHookRunner({}), toolLayer) + const layer = toolRuntimeBaseLayer(HookRunner.layer({}), toolLayer) const projected = yield* Effect.gen(function* () { const runtime = yield* ToolRuntime @@ -100,7 +100,7 @@ it.effect('projects tool defects after cooperative stop requests as model-visibl }), }), ) - const layer = toolRuntimeBaseLayer(layerHookRunner({}), toolLayer) + const layer = toolRuntimeBaseLayer(HookRunner.layer({}), toolLayer) const result = yield* Effect.gen(function* () { const runtime = yield* ToolRuntime @@ -127,7 +127,7 @@ it.effect('projects tool defects after cooperative stop requests as model-visibl it.effect('projects preToolUse hook defects as model-visible tool failures and skips the handler', () => Effect.gen(function* () { const recorder = yield* makeEchoRecorder - const hookLayer = layerHookRunner({ + const hookLayer = HookRunner.layer({ preToolUse: [ { name: 'guard', @@ -165,7 +165,7 @@ it.effect('projects preToolUse hook defects as model-visible tool failures and s it.effect('projects postToolUse hook defects as model-visible tool failures', () => Effect.gen(function* () { const recorder = yield* makeEchoRecorder - const hookLayer = layerHookRunner({ + const hookLayer = HookRunner.layer({ postToolUse: [ { name: 'redactor', @@ -203,7 +203,7 @@ it.effect('projects postToolUse hook defects as model-visible tool failures', () it.effect('projects hook defects after StopController requests as hook failures', () => Effect.gen(function* () { const recorder = yield* makeEchoRecorder - const hookLayer = layerHookRunner({ + const hookLayer = HookRunner.layer({ preToolUse: [ { name: 'stopper', diff --git a/packages/fold-core/test/ToolRuntime/ToolRuntimeHookState.vi.test.ts b/packages/fold-core/test/ToolRuntime/ToolRuntimeHookState.vi.test.ts index 37ebdfa..de9bd81 100644 --- a/packages/fold-core/test/ToolRuntime/ToolRuntimeHookState.vi.test.ts +++ b/packages/fold-core/test/ToolRuntime/ToolRuntimeHookState.vi.test.ts @@ -1,7 +1,7 @@ import { expect, it } from '@effect/vitest' import { Predicate, Effect, Schema } from 'effect' -import { defineToolState, layerHookRunner, toolStateForAgent, ToolRuntime } from '../../src/index' +import { HookRunner, defineToolState, toolStateForAgent, ToolRuntime } from '../../src/index' import { layerStatefulEchoTool, makeEchoRecorder } from '../TestLayers/TestTools' import { agentId, @@ -23,7 +23,7 @@ it.effect('a preToolUse hook writes durable state in its declared namespace, sep const recorder = yield* makeEchoRecorder // The hook name differs from its declared ToolState namespace ('audit'), proving the persisted // namespace comes from the AuditState definition rather than the hook's name. - const hookLayer = layerHookRunner({ + const hookLayer = HookRunner.layer({ preToolUse: [ { name: 'audit-hook', diff --git a/packages/fold-core/test/ToolRuntime/ToolRuntimeInterrupt.vi.test.ts b/packages/fold-core/test/ToolRuntime/ToolRuntimeInterrupt.vi.test.ts index bd3536c..e1228c5 100644 --- a/packages/fold-core/test/ToolRuntime/ToolRuntimeInterrupt.vi.test.ts +++ b/packages/fold-core/test/ToolRuntime/ToolRuntimeInterrupt.vi.test.ts @@ -3,13 +3,7 @@ import { expect, it } from '@effect/vitest' import { Predicate, Deferred, Effect, Fiber, Layer, Schema } from 'effect' import { Prompt, Tool, Toolkit } from 'effect/ai' -import { - layerInMemoryEventLog, - liveToolRuntimeLayer, - messagesForAgent, - ToolRuntime, - toolsetLayerFromToolkit, -} from '../../src/index' +import { layerInMemoryEventLog, messagesForAgent, ToolRuntime, toolsetLayerFromToolkit } from '../../src/index' import { layerDeterministicRuntime } from '../TestLayers/DeterministicRuntime' import { hookRunnerNoop } from '../TestLayers/NoOpHookRunner' import { agentId, collectEntries, layerNoopToolEvents, toolCallId } from './ToolRuntimeTestHelpers' @@ -51,7 +45,7 @@ it.effect('writes a synthetic interrupted tool-result when a running tool fiber }), }), ) - const layer = liveToolRuntimeLayer.pipe( + const layer = ToolRuntime.layer.pipe( Layer.provideMerge( Layer.mergeAll( layerInMemoryEventLog, diff --git a/packages/fold-core/test/ToolRuntime/ToolRuntimeLiveHooks.vi.test.ts b/packages/fold-core/test/ToolRuntime/ToolRuntimeLiveHooks.vi.test.ts index c7e2cdc..22d60d2 100644 --- a/packages/fold-core/test/ToolRuntime/ToolRuntimeLiveHooks.vi.test.ts +++ b/packages/fold-core/test/ToolRuntime/ToolRuntimeLiveHooks.vi.test.ts @@ -1,14 +1,14 @@ import { expect, it } from '@effect/vitest' import { Predicate, Effect, Ref } from 'effect' -import { layerHookRunner, messagesForAgent, ToolRuntime } from '../../src/index' +import { HookRunner, messagesForAgent, ToolRuntime } from '../../src/index' import { layerEchoTool, makeEchoRecorder } from '../TestLayers/TestTools' import { agentId, collectEntries, makeAssistantToolCall, toolRuntimeBaseLayer } from './ToolRuntimeTestHelpers' it.effect('live preToolUse hook can replace the result and skip the handler', () => Effect.gen(function* () { const recorder = yield* makeEchoRecorder - const hookLayer = layerHookRunner({ + const hookLayer = HookRunner.layer({ preToolUse: [ { name: 'block-echo', @@ -54,7 +54,7 @@ it.effect('live preToolUse hook can replace the result and skip the handler', () it.effect('live preToolUse hook can update execution params without changing prompt projection', () => Effect.gen(function* () { const recorder = yield* makeEchoRecorder - const hookLayer = layerHookRunner({ + const hookLayer = HookRunner.layer({ preToolUse: [ { name: 'mutate-echo', @@ -108,7 +108,7 @@ it.effect('postToolUse receives original, executed, and handler values while hoo Effect.gen(function* () { const recorder = yield* makeEchoRecorder const observed = yield* Ref.make>([]) - const hookLayer = layerHookRunner({ + const hookLayer = HookRunner.layer({ preToolUse: [ { name: 'mutate-echo', diff --git a/packages/fold-core/test/ToolRuntime/ToolRuntimeStateSnapshot.vi.test.ts b/packages/fold-core/test/ToolRuntime/ToolRuntimeStateSnapshot.vi.test.ts index 44fba62..9febb45 100644 --- a/packages/fold-core/test/ToolRuntime/ToolRuntimeStateSnapshot.vi.test.ts +++ b/packages/fold-core/test/ToolRuntime/ToolRuntimeStateSnapshot.vi.test.ts @@ -6,11 +6,9 @@ import { Prompt, Tool, Toolkit } from 'effect/ai' import { defineToolState, type EventLog, - type HookRunner, + HookRunner, type Ids, layerInMemoryEventLog, - liveToolRuntimeLayer, - layerHookRunner, ToolCallId, ToolRuntime, ToolState, @@ -52,7 +50,7 @@ const probeRuntimeLayer = ( const idsLayer = layerDeterministicRuntime({ startMillis: 1_000, stepMillis: 0 }) const hookDeps = Layer.mergeAll(memoryLayer, idsLayer) - return liveToolRuntimeLayer.pipe( + return ToolRuntime.layer.pipe( Layer.provideMerge( Layer.mergeAll( memoryLayer, @@ -161,7 +159,7 @@ describe('ToolRuntime handler state snapshots', () => { // The hook shares state with the tool because both use the same ProbeState definition, not // because of any name match - the hook name deliberately differs from the declared namespace. - const hookLayer = layerHookRunner({ + const hookLayer = HookRunner.layer({ preToolUse: [ { name: 'probe-seed', diff --git a/packages/fold-core/test/ToolRuntime/ToolRuntimeStopRequest.vi.test.ts b/packages/fold-core/test/ToolRuntime/ToolRuntimeStopRequest.vi.test.ts index 8d8a716..cca8e47 100644 --- a/packages/fold-core/test/ToolRuntime/ToolRuntimeStopRequest.vi.test.ts +++ b/packages/fold-core/test/ToolRuntime/ToolRuntimeStopRequest.vi.test.ts @@ -1,7 +1,7 @@ import { describe, expect, it } from '@effect/vitest' import { Effect, Ref } from 'effect' -import { layerHookRunner, StopController, ToolRuntime } from '../../src/index' +import { HookRunner, StopController, ToolRuntime } from '../../src/index' import { hookRunnerNoop } from '../TestLayers/NoOpHookRunner' import { layerEchoTool, layerStoppingEchoTool, makeEchoRecorder } from '../TestLayers/TestTools' import { agentId, makeAssistantToolCall, toolRuntimeBaseLayer } from './ToolRuntimeTestHelpers' @@ -40,7 +40,7 @@ describe('ToolRuntime cooperative stop requests', () => { it.effect('a preToolUse hook can request a stop while continuing; the tool still executes', () => Effect.gen(function* () { const recorder = yield* makeEchoRecorder - const hookLayer = layerHookRunner({ + const hookLayer = HookRunner.layer({ preToolUse: [ { name: 'stop-after-this-batch', diff --git a/packages/fold-core/test/ToolRuntime/ToolRuntimeTestHelpers.ts b/packages/fold-core/test/ToolRuntime/ToolRuntimeTestHelpers.ts index 6c1bc71..7fb42b0 100644 --- a/packages/fold-core/test/ToolRuntime/ToolRuntimeTestHelpers.ts +++ b/packages/fold-core/test/ToolRuntime/ToolRuntimeTestHelpers.ts @@ -4,12 +4,12 @@ import { Prompt } from 'effect/ai' import type { Tool } from 'effect/ai' import { + ToolRuntime, AgentId, EventLog, type HookRunner, type Ids, layerInMemoryEventLog, - liveToolRuntimeLayer, noopToolEventSink, ToolCallId, ToolEventSink, @@ -64,7 +64,7 @@ export const toolRuntimeBaseLayer = ( const idsLayer = layerDeterministicRuntime({ startMillis: 1_000, stepMillis: 0 }) const hookDeps = Layer.mergeAll(memoryLayer, idsLayer) - return liveToolRuntimeLayer.pipe( + return ToolRuntime.layer.pipe( Layer.provideMerge( Layer.mergeAll( memoryLayer,