From 0fdf9c8a2fd5b78987ec0cdd67aac080e530af6d Mon Sep 17 00:00:00 2001 From: Kyle Mistele Date: Thu, 15 Oct 2026 15:15:38 -0700 Subject: [PATCH 1/3] refactor: release replaced runtimes on switchModel; name service constructors - fold-core: each root runtime gets its own child of the session scope. A successful switchModel closes the replaced runtime's scope (its provider client and layers); a failed one closes the new runtime's. Previously both stayed open until the session closed. - fold-agent: SessionTitleGenerator service, provided per session with the output store and log, builds the title model once instead of providing a LanguageModel layer for every title. - fold-agent: loadSessionSummary reads entries with readJsonlEntries instead of building a whole JSONL EventLog layer. - fold-core: drop the SkillSource service key; nothing provided or yielded it. - Name constructors: Photon, ManagedBinaries and OutputStore put make on the service key with layer = Layer.effect(this, this.make); fold-core layer files export makeX beside each live layer. Co-Authored-By: Claude Opus 5.5 HumanLayer-Session: https://app.dev.codelayer.gg/sessions/01a14171-1b19-772e-812c-382bdc40b824 --- .../fold-agent/src/Bin/ManagedBinaries.ts | 27 +- .../fold-agent/src/EventLog/JsonlLayer.ts | 29 +- packages/fold-agent/src/Mode/Launch.ts | 88 +- .../fold-agent/src/OutputStore/OutputStore.ts | 106 +- .../fold-agent/src/Session/SessionLayout.ts | 14 +- .../fold-agent/src/Session/TitleGenerator.ts | 63 +- packages/fold-agent/src/Tools/CodingTools.ts | 4 +- packages/fold-agent/src/Tools/Image/Photon.ts | 28 +- .../test/OutputStore/OutputStore.vi.test.ts | 9 +- .../test/Session/TitleGenerator.vi.test.ts | 14 +- packages/fold-agent/test/TestHelpers.ts | 4 +- .../src/AgentEvents/AgentEventsLayer.ts | 63 +- .../src/AgentRuntime/AgentRuntimeLayer.ts | 1007 +++++++++-------- packages/fold-core/src/Api/OpenSession.ts | 58 +- .../src/HookRunner/HookRunnerLayer.ts | 255 ++--- packages/fold-core/src/Session/Profiles.ts | 61 +- .../fold-core/src/Session/SessionControls.ts | 268 +++-- .../fold-core/src/Session/SessionLayer.ts | 10 +- packages/fold-core/src/Skills/SkillSource.ts | 7 +- .../src/ToolRuntime/ToolRuntimeLayer.ts | 672 +++++------ .../fold-core/test/Api/SwitchModel.vi.test.ts | 37 +- 21 files changed, 1472 insertions(+), 1352 deletions(-) diff --git a/packages/fold-agent/src/Bin/ManagedBinaries.ts b/packages/fold-agent/src/Bin/ManagedBinaries.ts index 81a0394..ce81c9f 100644 --- a/packages/fold-agent/src/Bin/ManagedBinaries.ts +++ b/packages/fold-agent/src/Bin/ManagedBinaries.ts @@ -179,7 +179,18 @@ const readHostEnvironment: Effect.Effect = Effect.gen(function* } }) -const makeManagedBinaries = (registry: ReadonlyArray) => +/** The per-process managed-binary resolver's interface. */ +export type ManagedBinariesService = { + readonly ensure: (options: EnsureManagedBinariesOptions) => Effect.Effect> +} + +const makeManagedBinaries = ( + registry: ReadonlyArray, +): Effect.Effect< + ManagedBinariesService, + never, + FileSystem.FileSystem | ChildProcessSpawner.ChildProcessSpawner | HttpClient.HttpClient +> => Effect.gen(function* () { const fs = yield* FileSystem.FileSystem const spawner = yield* ChildProcessSpawner.ChildProcessSpawner @@ -483,19 +494,17 @@ const makeManagedBinaries = (registry: ReadonlyArray) = return resolved }) - return ManagedBinaries.of({ ensure }) + return { 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') { +export class ManagedBinaries extends Context.Service()( + 'fold-agent/Bin/ManagedBinaries', + { make: makeManagedBinaries }, +) { /** A resolver over a given registry (tests pass a small one). */ static readonly layerWith = ( registry: ReadonlyArray, @@ -503,7 +512,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..91c6ca1 100644 --- a/packages/fold-agent/src/Mode/Launch.ts +++ b/packages/fold-agent/src/Mode/Launch.ts @@ -64,12 +64,7 @@ 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 { sweepToolOutput, toolOutputSessionDirFor, OutputStore } from '../OutputStore/OutputStore' import { latestSessionLog, prepareSessionLog, @@ -77,7 +72,7 @@ import { sessionLogById, type SessionLogRef, } from '../Session/SessionLayout' -import { generateSessionTitle } from '../Session/TitleGenerator' +import { SessionTitleGenerator } from '../Session/TitleGenerator' import type { Photon } from '../Tools/Image/Photon' import { compactionArchiveAccessFor } from './CompactionArchiveAccess' import { defaultCodingMode, type FoldMode } from './Mode' @@ -421,7 +416,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 }, ), @@ -474,12 +469,12 @@ export const switchSessionMode = ( const withGeneratedTitles = ( session: FoldSession, - model: FoldModel, options: { readonly cwd: string; readonly foldHome?: string }, -): Effect.Effect, never, FileSystem.FileSystem | HttpClient.HttpClient> => +): Effect.Effect, never, SessionTitleGenerator | FileSystem.FileSystem> => 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 + // The summary index refresh runs after later sends, which run without this service, so take it now. + const indexServices = yield* Effect.context() return yield* Semaphore.make(1).pipe( Effect.map((titleLock) => ({ ...session, @@ -502,8 +497,7 @@ 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 @@ -516,7 +510,7 @@ const withGeneratedTitles = ( return setTitle.pipe( Effect.andThen( refreshSessionSummaryIndex(session.sessionId, options).pipe( - Effect.provideContext(titleServices), + Effect.provideContext(indexServices), ), ), ) @@ -587,25 +581,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, { cwd, ...Struct.pick(opts, ['foldHome']) }) }).pipe(Effect.provideContext(sessionServices)) - return yield* withGeneratedTitles(session, models.fast, { cwd, ...Struct.pick(opts, ['foldHome']) }) }) const resumeFromLog = ( @@ -638,19 +638,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, { cwd, ...Struct.pick(options, ['foldHome']) }) }).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..58b5053 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,58 +93,63 @@ const lineSlice = (content: string, options?: OutputStoreReadOptions): string => } /** A file-backed OutputStore writing one file per tool call into `directory`. */ -export const layerOutputStore = (options: { +const makeOutputStore = (options: { readonly directory: string -}): Layer.Layer => - Layer.effect( - OutputStore, - Effect.gen(function* () { - const fs = yield* FileSystem.FileSystem - const directory = options.directory - - const refFor = (toolCallId: ToolCallId): OutputStoreRef => - new OutputStoreRef({ toolCallId, path: join(directory, `${toolCallId}.txt`) }) - - const prepare = (toolCallId: ToolCallId): Effect.Effect => { - const ref = refFor(toolCallId) - return fs.makeDirectory(directory, { recursive: true }).pipe( - Effect.andThen(fs.writeFileString(ref.path, '', { flag: 'a' })), - Effect.as(ref), - Effect.mapError((cause) => fileOperationError({ operation: 'prepare', path: ref.path, cause })), - Effect.tapError(logStoreError), - Effect.withSpan('output_store.prepare', { attributes: { toolCallId, path: ref.path } }), - ) - } +}): Effect.Effect => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem + const directory = options.directory + + const refFor = (toolCallId: ToolCallId): OutputStoreRef => + new OutputStoreRef({ toolCallId, path: join(directory, `${toolCallId}.txt`) }) + + const prepare = (toolCallId: ToolCallId): Effect.Effect => { + const ref = refFor(toolCallId) + return fs.makeDirectory(directory, { recursive: true }).pipe( + Effect.andThen(fs.writeFileString(ref.path, '', { flag: 'a' })), + Effect.as(ref), + Effect.mapError((cause) => fileOperationError({ operation: 'prepare', path: ref.path, cause })), + Effect.tapError(logStoreError), + Effect.withSpan('output_store.prepare', { attributes: { toolCallId, path: ref.path } }), + ) + } - const append = (toolCallId: ToolCallId, chunk: string): Effect.Effect => { - const ref = refFor(toolCallId) - return fs.makeDirectory(directory, { recursive: true }).pipe( - Effect.andThen(fs.writeFileString(ref.path, chunk, { flag: 'a' })), - Effect.as(ref), - Effect.mapError((cause) => fileOperationError({ operation: 'append', path: ref.path, cause })), - Effect.tapError(logStoreError), - Effect.withSpan('output_store.append', { - attributes: { toolCallId, path: ref.path, bytes: chunk.length }, - }), - ) - } + const append = (toolCallId: ToolCallId, chunk: string): Effect.Effect => { + const ref = refFor(toolCallId) + return fs.makeDirectory(directory, { recursive: true }).pipe( + Effect.andThen(fs.writeFileString(ref.path, chunk, { flag: 'a' })), + Effect.as(ref), + Effect.mapError((cause) => fileOperationError({ operation: 'append', path: ref.path, cause })), + Effect.tapError(logStoreError), + Effect.withSpan('output_store.append', { + attributes: { toolCallId, path: ref.path, bytes: chunk.length }, + }), + ) + } - const read = ( - ref: OutputStoreRef, - readOptions?: OutputStoreReadOptions, - ): Effect.Effect => - fs.readFileString(ref.path).pipe( - Effect.map((content) => lineSlice(content, readOptions)), - Effect.mapError((cause) => fileOperationError({ operation: 'read', path: ref.path, cause })), - Effect.tapError(logStoreError), - Effect.withSpan('output_store.read', { - attributes: { toolCallId: ref.toolCallId, path: ref.path }, - }), - ) - - return { directory, refFor, prepare, append, read } - }), - ) + const read = ( + ref: OutputStoreRef, + readOptions?: OutputStoreReadOptions, + ): Effect.Effect => + fs.readFileString(ref.path).pipe( + Effect.map((content) => lineSlice(content, readOptions)), + Effect.mapError((cause) => fileOperationError({ operation: 'read', path: ref.path, cause })), + Effect.tapError(logStoreError), + Effect.withSpan('output_store.read', { + attributes: { toolCallId: ref.toolCallId, path: ref.path }, + }), + ) + + return { directory, refFor, prepare, append, read } + }) + +/** 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', { + make: makeOutputStore, +}) { + /** 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..f217887 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)), ) diff --git a/packages/fold-agent/src/Session/TitleGenerator.ts b/packages/fold-agent/src/Session/TitleGenerator.ts index fad5935..f028024 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,46 @@ 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, +/** 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 +} + +const makeSessionTitleGenerator = ( model: FoldModel, -): Effect.Effect> => { - const fallback = fallbackSessionTitle(entries, rootAgentId) - const transcript = titleTranscript(entries, rootAgentId) - if (transcript.length === 0) return Effect.succeed(fallback) +): 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.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.provide(languageModelLayerFor(model)), - Effect.map((response) => normalizeSessionTitle(response.value.title) || fallback), - Effect.catchCause(() => Effect.succeed(fallback)), - ) +/** 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', +) { + /** Titles generated by `model`, whose LanguageModel is built once in the layer's scope. */ + static readonly layer = ( + model: FoldModel, + ): Layer.Layer> => + Layer.effect(this, makeSessionTitleGenerator(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 index cb767f3..2a8cb41 100644 --- a/packages/fold-core/src/AgentEvents/AgentEventsLayer.ts +++ b/packages/fold-core/src/AgentEvents/AgentEventsLayer.ts @@ -10,43 +10,44 @@ import { ToolEventSink, type ToolEventSinkService } from '../ToolRuntime/ToolCon 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 - }), -) +export const makeAgentEvents: Effect.Effect = 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 +}) + +/** The layer over {@link makeAgentEvents}. */ +export const liveAgentEventsLayer: Layer.Layer = Layer.effect(AgentEvents, makeAgentEvents) /** * 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 makeToolEventSink: Effect.Effect = 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 +}) + +/** The layer over {@link makeToolEventSink}. */ 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 - }), + makeToolEventSink, ) diff --git a/packages/fold-core/src/AgentRuntime/AgentRuntimeLayer.ts b/packages/fold-core/src/AgentRuntime/AgentRuntimeLayer.ts index 961f4f1..4a0eb44 100644 --- a/packages/fold-core/src/AgentRuntime/AgentRuntimeLayer.ts +++ b/packages/fold-core/src/AgentRuntime/AgentRuntimeLayer.ts @@ -94,8 +94,8 @@ const acceptAnyParamsInReply = (tool: AnyTool): AnyTool => }) /** Live AgentRuntime layer wiring the EventLog, hooks, model, and tool settlement into the loop. */ -export const liveAgentRuntimeLayer: Layer.Layer< - AgentRuntime, +export const makeAgentRuntime: Effect.Effect< + AgentRuntimeService, never, | EventLog | Ids @@ -108,571 +108,576 @@ export const liveAgentRuntimeLayer: Layer.Layer< | 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']({ +> = 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, - outcome, - resultText, - reason, + messageId, + message: encodeUserMessage(Prompt.userMessage({ content: [Prompt.textPart({ text })] })), }), - ) + ), + ), + Effect.asVoid, + ) - if (Predicate.isTagged(entry, 'agent-finished')) return entry + 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, + }), + ) - // Invariant! - return yield* Effect.die(new Error(`EventLog returned ${entry._tag} while appending agent-finished`)) - }) + if (Predicate.isTagged(entry, 'agent-finished')) return entry - /** 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 } }, - }), - ) - } + // Invariant! + return yield* Effect.die(new Error(`EventLog returned ${entry._tag} while appending agent-finished`)) + }) - return Prompt.assistantMessage({ content, options: message.options }) - }) + /** 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 = [] - /** - * 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 + for (const part of message.content) { + if (part.type !== 'tool-call') { + content.push(part) + continue } - if (planned.success === null) return null - const postCompactionInstructions = yield* compactionArchiveAccess.instructions({ + 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, - parentAgentId: input.parentAgentId, + entries, + model, trigger, + additionalInstructions, }) + .pipe(Effect.result) - 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 (Result.isFailure(planned)) { + yield* appendToEventLog( + LogEntryInputs['error']({ + agentId: input.agentId, + parentAgentId: input.parentAgentId, + toolCallId: input.toolCallId, + errorType: 'compaction', + message: planned.failure.message, + details: { trigger }, + }), ) - - if (Predicate.isTagged(entry, 'compaction')) return entry - return yield* Effect.die(new Error(`EventLog returned ${entry._tag} while appending compaction`)) + return null + } + + if (planned.success === null) return null + const postCompactionInstructions = yield* compactionArchiveAccess.instructions({ + agentId: input.agentId, + parentAgentId: input.parentAgentId, + trigger, }) - /** 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 }) - } + 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 }, + ), + ) - return TurnResult.continue() + 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, }) - /** 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 }) - } + if (settlement.stopRequested) { + const entry = yield* appendFinished(input, 'stopped', null, 'a tool or hook requested a stop') + 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, - } + // 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 }) + } - // 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, - ), + 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, - ) + ), + ).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() - } + if (Result.isFailure(modelParts)) { + const message = modelParts.failure.message - yield* appendToEventLog( - LogEntryInputs['error']({ - agentId: input.agentId, - parentAgentId: input.parentAgentId, - toolCallId: input.toolCallId, - errorType: 'model', - message, - details: {}, - }), + // 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', ) - - 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 }) + if (recovered) return TurnResult.continue() } - const persistedAssistant = yield* rewriteAssistantToolCallIds(assistantMessage) - yield* appendToEventLog( - LogEntryInputs['assistant-message']({ + LogEntryInputs['error']({ 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) }, + errorType: 'model', + message, + details: {}, }), ) - const toolCalls = persistedAssistant.content.flatMap((part) => - part.type === 'tool-call' ? [{ name: part.name, params: part.params }] : [], - ) - const hasToolCalls = Arr.isArrayNonEmpty(toolCalls) + const entry = yield* appendFinished(input, 'error', null, message) + return TurnResult.finished({ entry }) + } - if (hasToolCalls) { - return yield* settleToolCalls(input, persistedAssistant, toolCalls, doomLoopRef) - } + 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', + ) - yield* Ref.set(doomLoopRef, initialDoomLoopState) + if (assistantMessage === undefined) { + const entry = yield* appendFinished(input, 'completed', null, null) + return TurnResult.finished({ entry }) + } - const resultText = assistantResultText(persistedAssistant) + const persistedAssistant = yield* rewriteAssistantToolCallIds(assistantMessage) - const onCompleteDecision = yield* hooks - .onComplete({ agentId: input.agentId, parentAgentId: input.parentAgentId, resultText }) - .pipe(Effect.provideService(StopController, stopController), Effect.orDie) + 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 stopReasonAfterComplete = yield* Ref.get(stopRef) - if (stopReasonAfterComplete !== null) { - const entry = yield* appendFinished(input, 'stopped', resultText, stopReasonAfterComplete) - return TurnResult.finished({ entry }) - } + const toolCalls = persistedAssistant.content.flatMap((part) => + part.type === 'tool-call' ? [{ name: part.name, params: part.params }] : [], + ) + const hasToolCalls = Arr.isArrayNonEmpty(toolCalls) - if (Predicate.isTagged(onCompleteDecision, 'continueWith')) { - yield* appendUserMessage(input, onCompleteDecision.text) - return TurnResult.continue() - } + if (hasToolCalls) { + return yield* settleToolCalls(input, persistedAssistant, toolCalls, doomLoopRef) + } - // 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() - } + yield* Ref.set(doomLoopRef, initialDoomLoopState) - const entry = yield* appendFinished(input, 'completed', resultText, null) - return TurnResult.finished({ entry }) - }) + const resultText = assistantResultText(persistedAssistant) - /** 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 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 }) + } - const blocks = yield* systemPrompt.compose({ model: input.model, agentBlocks }) + if (Predicate.isTagged(onCompleteDecision, 'continueWith')) { + yield* appendUserMessage(input, onCompleteDecision.text) + return TurnResult.continue() + } - 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', - }), - ) + // 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 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 entry = yield* appendFinished(input, 'completed', resultText, null) + return TurnResult.finished({ entry }) + }) - 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 }, - ), + /** 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) - } + // 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 + if (Predicate.isTagged(entry, 'agent_started')) return entry - // Invariant! - return yield* Effect.die(new Error(`EventLog returned ${entry._tag} while appending agent_started`)) - }), - ) + // 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 + 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* appendToEventLog( + LogEntryInputs['model-change']({ + agentId: input.agentId, + parentAgentId: input.parentAgentId, + toolCallId: input.toolCallId, + model: input.model, + reason: input.reason, + }), + ) + + yield* appendLeadingSystemMessage(input) - 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, + }), + ) - const resolvedToolset = yield* toolsetResolver.resolve({ model: input.model }) + // 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['tools-change']({ + LogEntryInputs['thinking-change']({ agentId: input.agentId, parentAgentId: input.parentAgentId, toolCallId: input.toolCallId, - tools: resolvedToolset.names, + reasoningLevel: input.model.requestedReasoningLevel, 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 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, + ) + }), + ) - 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 { start, run, switchModel, compact } +}) - return yield* performCompaction( - input, - entries, - runtimeState.activeModel, - input.trigger, - input.additionalInstructions ?? null, - ) - }), - ) - - return { start, run, switchModel, compact } - }), -) +/** The layer over {@link makeAgentRuntime}. */ +export const liveAgentRuntimeLayer: Layer.Layer< + AgentRuntime, + never, + | EventLog + | Ids + | HookRunner + | Toolset + | ToolsetResolver + | SystemPrompt + | ModelRequestSettings + | ToolRuntime + | LanguageModel.LanguageModel + | AgentEvents + | SessionControls +> = Layer.effect(AgentRuntime, makeAgentRuntime) diff --git a/packages/fold-core/src/Api/OpenSession.ts b/packages/fold-core/src/Api/OpenSession.ts index ccd21db..0b810d4 100644 --- a/packages/fold-core/src/Api/OpenSession.ts +++ b/packages/fold-core/src/Api/OpenSession.ts @@ -254,6 +254,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 +288,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>, @@ -469,25 +478,36 @@ 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( @@ -530,7 +550,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 +867,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/HookRunner/HookRunnerLayer.ts b/packages/fold-core/src/HookRunner/HookRunnerLayer.ts index 781d249..e8112f7 100644 --- a/packages/fold-core/src/HookRunner/HookRunnerLayer.ts +++ b/packages/fold-core/src/HookRunner/HookRunnerLayer.ts @@ -87,142 +87,143 @@ const provideHookState = }) /** 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 - } +export const makeHookRunner = (hooks: HookConfig): Effect.Effect => + 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), - }), - ), + 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 + preToolUse: (input) => + Effect.gen(function* () { + let params = input.params - for (const hook of hooks.preToolUse ?? []) { - if (!isHookConfiguredForTool(hook, input.toolName)) continue + for (const hook of hooks.preToolUse ?? []) { + if (!isHookConfiguredForTool(hook, input.toolName)) continue - const decision = yield* runPreToolUseHook(hook, { ...input, params }) + const decision = yield* runPreToolUseHook(hook, { ...input, params }) - if (Predicate.isTagged(decision, 'replaceResult')) return decision + if (Predicate.isTagged(decision, 'replaceResult')) return decision - params = decision.params - } + 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 HookDecision.continue({ params }) + }).pipe( + Effect.withSpan('fold.hook_runner.pre_tool_use', { + attributes: preToolUseSpanAttributes(input), + }), + ), - return replaced ? HookDecision.replace({ result, isFailure }) : HookDecision.keep() - }).pipe( - Effect.withSpan('fold.hook_runner.post_tool_use', { - attributes: postToolUseSpanAttributes(input), - }), - ), + postToolUse: (input) => + Effect.gen(function* () { + let result = input.result + let isFailure = input.isFailure + let replaced = false - onComplete: (input) => - Effect.gen(function* () { - for (const hook of hooks.onComplete ?? []) { - const decision = yield* runOnCompleteHook(hook, input) + for (const hook of hooks.postToolUse ?? []) { + if (!isHookConfiguredForTool(hook, input.toolName)) continue - if (Predicate.isTagged(decision, 'continueWith')) return decision - } + const decision = yield* runPostToolUseHook(hook, { ...input, result, isFailure }) - return HookDecision.complete() - }).pipe( - Effect.withSpan('fold.hook_runner.on_complete', { - attributes: onCompleteSpanAttributes(input), - }), - ), - } - - return service - }), - ) + 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 + }) + +/** The layer over {@link makeHookRunner}. */ +export const layerHookRunner = (hooks: HookConfig): Layer.Layer => + Layer.effect(HookRunner, makeHookRunner(hooks)) diff --git a/packages/fold-core/src/Session/Profiles.ts b/packages/fold-core/src/Session/Profiles.ts index 4d35a5a..ed0bdbc 100644 --- a/packages/fold-core/src/Session/Profiles.ts +++ b/packages/fold-core/src/Session/Profiles.ts @@ -61,36 +61,37 @@ export type ProfilesService = { 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, - Effect.gen(function* () { - const state = yield* Ref.make>(initial) +export const makeProfiles = (initial: SessionProfiles): Effect.Effect => + Effect.gen(function* () { + const state = yield* Ref.make>(initial) + + const resolve = (role: ProfileRole): Effect.Effect> => + Ref.get(state).pipe( + Effect.flatMap((profiles) => { + const model = profileModelFor(profiles, role) + return model === undefined + ? Effect.die( + new Error( + `profile role "${role}" has no bound model - session-start validation should have rejected this roster`, + ), + ) + : Effect.succeed(model) + }), + ) - const resolve = (role: ProfileRole): Effect.Effect> => - Ref.get(state).pipe( - Effect.flatMap((profiles) => { - const model = profileModelFor(profiles, role) - return model === undefined - ? Effect.die( - new Error( - `profile role "${role}" has no bound model - session-start validation should have rejected this roster`, - ), - ) - : Effect.succeed(model) - }), - ) + const set = (role: ProfileRole, model: FoldModel): Effect.Effect => + Ref.update(state, (profiles) => + Match.value(role).pipe( + Match.when('smart', () => ({ ...profiles, smart: model })), + Match.when('fast', () => ({ ...profiles, fast: model })), + Match.when('orchestrator', () => ({ ...profiles, orchestrator: model })), + Match.exhaustive, + ), + ) - const set = (role: ProfileRole, model: FoldModel): Effect.Effect => - Ref.update(state, (profiles) => - Match.value(role).pipe( - Match.when('smart', () => ({ ...profiles, smart: model })), - Match.when('fast', () => ({ ...profiles, fast: model })), - Match.when('orchestrator', () => ({ ...profiles, orchestrator: model })), - Match.exhaustive, - ), - ) + return { resolve, set, replace: (profiles) => Ref.set(state, profiles), snapshot: Ref.get(state) } + }) - return { resolve, set, replace: (profiles) => Ref.set(state, profiles), snapshot: Ref.get(state) } - }), - ) +/** The layer over {@link makeProfiles}. */ +export const layerProfiles = (initial: SessionProfiles): Layer.Layer => + Layer.effect(Profiles, makeProfiles(initial)) diff --git a/packages/fold-core/src/Session/SessionControls.ts b/packages/fold-core/src/Session/SessionControls.ts index 926da44..ae6b4c0 100644 --- a/packages/fold-core/src/Session/SessionControls.ts +++ b/packages/fold-core/src/Session/SessionControls.ts @@ -87,154 +87,150 @@ const notRunning = (agentId: AgentId): AgentNotRunningError => }) /** One session's controls. `steeringMode` fixes how steering queues drain (D8). */ -export const layerSessionControls = (options?: { +export const makeSessionControls = (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) - }) +}): Effect.Effect => + 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), + ) - /** 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 }) + 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 }) + 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) - }) - yield* Ref.update(steering, (current) => { - const next = new Map(current) - next.delete(agentId) - return next - }) - yield* abandonFollowUps(agentId) + 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) + }) + + 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 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 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 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 interruptAllRunning: Effect.Effect = SynchronizedRef.get(running).pipe( + Effect.flatMap((current) => + Effect.forEach([...current.keys()], (agentId) => interruptRunning(agentId), { discard: true }), + ), + ) - const drainSteering = (agentId: AgentId): Effect.Effect> => - Ref.modify(steering, (current) => { + 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) ?? [] - 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 + return new Map(current).set(agentId, [...queued, text]) }) - - 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) } + }) + + 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 }]) }) + 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 - }) - yield* Effect.forEach(queued, (entry) => Deferred.succeed(entry.consumed, true), { discard: true }) - return queued.map((entry) => entry.text) + 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) + }) + + 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), - } - }), - ) +/** The layer over {@link makeSessionControls}. */ +export const layerSessionControls = (options?: { + readonly steeringMode?: SteeringMode +}): Layer.Layer => Layer.effect(SessionControls, makeSessionControls(options)) diff --git a/packages/fold-core/src/Session/SessionLayer.ts b/packages/fold-core/src/Session/SessionLayer.ts index 4e7b257..7d5a5d0 100644 --- a/packages/fold-core/src/Session/SessionLayer.ts +++ b/packages/fold-core/src/Session/SessionLayer.ts @@ -22,8 +22,7 @@ import { } from './SessionService' /** Live Session layer wiring EventLog, Ids, AgentRuntime, and AgentEvents into the public facade. */ -export const liveSessionLayer: Layer.Layer = Layer.effect( - Session, +export const makeSession: Effect.Effect = Effect.gen(function* () { const eventLog = yield* EventLog const ids = yield* Ids @@ -173,5 +172,10 @@ export const liveSessionLayer: Layer.Layer = Layer.effect( + Session, + makeSession, ) 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/ToolRuntimeLayer.ts b/packages/fold-core/src/ToolRuntime/ToolRuntimeLayer.ts index 28152ba..706d3f3 100644 --- a/packages/fold-core/src/ToolRuntime/ToolRuntimeLayer.ts +++ b/packages/fold-core/src/ToolRuntime/ToolRuntimeLayer.ts @@ -163,382 +163,386 @@ 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, +export const makeToolRuntime: Effect.Effect< + ToolRuntimeService, 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']), - }) +> = 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) + const entry = yield* eventLog.append(entryInput).pipe(Effect.orDie) - if (Predicate.isTagged(entry, 'tool-result')) return entry + if (Predicate.isTagged(entry, 'tool-result')) return entry - // Invariant! - return yield* Effect.die(new Error(`EventLog returned ${entry._tag} while appending tool-result`)) - }) + // 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) + /** 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, - }) + 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, - }), - ), + 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, - }), - ), + ), + 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, ) - /** 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, - }) + 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( + /** 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((output): ReadonlyArray => [output]), + Effect.map(Stream.succeed), ), ), ) - 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 }), - }) - }) + const collectHandlerOutputs = stream.pipe( + Stream.tap((output) => + emitPreliminaryToolOutput({ + agentId: input.agentId, + parentAgentId: input.parentAgentId, + toolCallId: input.toolCallId, + toolName: input.toolName, + output, + }), + ), + Stream.runCollect, + ) - /** 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 outputs = yield* collectHandlerOutputs.pipe( + Effect.catchCause((cause) => + failedOrInterruptedToolHandlerOutput(input.toolName, cause, input.interruptNoteRef).pipe( + Effect.map((output): ReadonlyArray => [output]), + ), + ), + ) - 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, - }), - } + return finalToolOutputFromHandlerOutputs(outputs, input.toolName) + }) - 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)), - } + /** 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, + }) - // 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), - } + return Match.valueTags(decision, { + keep: () => input.output, + replace: ({ isFailure, result }) => ({ result, isFailure }), + }) + }) - 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, - } - } + /** 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 handlerOutput = yield* finalOutputFromToolHandler({ + 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, - params: input.prepared.params, - interruptNoteRef, - }) + payload, + }), + } - 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, + 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, } - // 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 handlerOutput = yield* finalOutputFromToolHandler({ + agentId: input.agentId, + parentAgentId: input.parentAgentId, + toolCallId, + toolName, + params: input.prepared.params, + interruptNoteRef, }) - 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), - ) + const finalOutput = yield* finalOutputAfterPostToolHooks({ + agentId: input.agentId, + parentAgentId: input.parentAgentId, + toolCallId, + toolName, + originalInput: input.prepared.original.params, + executedInput: input.prepared.params, + output: handlerOutput, + }) - 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), - ), - ) + 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 } }) - /** 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 append = (result: ToolResultAppendInput) => + appendToolResultToEventLog({ + agentId: input.agentId, + parentAgentId: input.parentAgentId, + toolCallId, + toolName, + ...result, + }) - const prepared = yield* Effect.forEach( - toolCalls, - (toolCall) => - toolCallPreparedByPreToolHooks({ - agentId: input.agentId, - parentAgentId: input.parentAgentId, - toolCall, - }).pipe(Effect.provideService(StopController, stopController)), - { concurrency: 1 }, + 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), ) - // 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), + 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), + ), ) + }) - const toolResults = yield* Effect.forEach( - prepared, - (preparedCall) => - settlePreparedToolCall({ - agentId: input.agentId, - parentAgentId: input.parentAgentId, - prepared: preparedCall, - stopRef, - stateSnapshot, - }), - { concurrency: 'unbounded' }, - ) + /** 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 stopRequested = yield* Ref.get(stopRef).pipe(Effect.map((reason) => reason !== null)) + 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 settlement: ToolSettlement = { toolResults, stopRequested } - return settlement - }) + const stopRequested = yield* Ref.get(stopRef).pipe(Effect.map((reason) => reason !== null)) + + const settlement: ToolSettlement = { toolResults, stopRequested } + return settlement + }) + + return { settle } +}) - return { settle } - }), -) +/** The layer over {@link makeToolRuntime}. */ +export const liveToolRuntimeLayer: Layer.Layer< + ToolRuntime, + never, + EventLog | Ids | HookRunner | Toolset | ToolEventSink +> = Layer.effect(ToolRuntime, makeToolRuntime) 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)), +) From 9072149c0601be8e55d218df609b891e2d4a656a Mon Sep 17 00:00:00 2001 From: Kyle Mistele Date: Thu, 15 Oct 2026 15:35:23 -0700 Subject: [PATCH 2/3] refactor(fold-agent): drop the Effect.context capture from title generation withGeneratedTitles captured FileSystem with Effect.context only to refresh the summary index right after each title. listSessionSummaries already rebuilds any record whose log changed, and the title append changes the log, so the eager refresh only saved one rebuild (which the next turn's append would invalidate anyway). Remove it and the now-unused refreshSessionSummaryIndex; withGeneratedTitles now needs only SessionTitleGenerator. Co-Authored-By: Claude Opus 5.5 HumanLayer-Session: https://app.dev.codelayer.gg/sessions/01a14171-1b19-772e-812c-382bdc40b824 --- packages/fold-agent/src/Mode/Launch.ts | 30 +++++-------------- .../fold-agent/src/Session/SessionLayout.ts | 25 ---------------- 2 files changed, 7 insertions(+), 48 deletions(-) diff --git a/packages/fold-agent/src/Mode/Launch.ts b/packages/fold-agent/src/Mode/Launch.ts index 91c6ca1..5f3dd24 100644 --- a/packages/fold-agent/src/Mode/Launch.ts +++ b/packages/fold-agent/src/Mode/Launch.ts @@ -65,13 +65,7 @@ import { rolesForDirectProviderSelection } from '../Config/ModelSelections' import { layerJsonl } from '../EventLog/JsonlLayer' import { memoryPromptBlock } from '../Memory/AgentFiles' import { sweepToolOutput, toolOutputSessionDirFor, OutputStore } from '../OutputStore/OutputStore' -import { - latestSessionLog, - prepareSessionLog, - refreshSessionSummaryIndex, - sessionLogById, - type SessionLogRef, -} from '../Session/SessionLayout' +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' @@ -467,14 +461,9 @@ export const switchSessionMode = ( }) }) -const withGeneratedTitles = ( - session: FoldSession, - options: { readonly cwd: string; readonly foldHome?: string }, -): Effect.Effect, never, SessionTitleGenerator | FileSystem.FileSystem> => +const withGeneratedTitles = (session: FoldSession): Effect.Effect, never, SessionTitleGenerator> => Effect.gen(function* () { const titles = yield* SessionTitleGenerator - // The summary index refresh runs after later sends, which run without this service, so take it now. - const indexServices = yield* Effect.context() return yield* Semaphore.make(1).pipe( Effect.map((titleLock) => ({ ...session, @@ -501,19 +490,14 @@ const withGeneratedTitles = ( 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(indexServices), - ), - ), - ) }), ) }), @@ -604,7 +588,7 @@ export const launchSession = ( compactionArchiveAccess: compactionArchiveAccessFor({ logPath: prepared.path, modeName: mode.name }), ...Struct.pick(opts, ['steering']), }) - return yield* withGeneratedTitles(session, { cwd, ...Struct.pick(opts, ['foldHome']) }) + return yield* withGeneratedTitles(session) }).pipe(Effect.provideContext(sessionServices)) }) @@ -655,7 +639,7 @@ const resumeFromLog = ( compactionArchiveAccess: compactionArchiveAccessFor({ logPath: log.path, modeName: mode.name }), ...Struct.pick(options, ['steering']), }) - return yield* withGeneratedTitles(session, { cwd, ...Struct.pick(options, ['foldHome']) }) + return yield* withGeneratedTitles(session) }).pipe(Effect.provideContext(sessionServices)) }) diff --git a/packages/fold-agent/src/Session/SessionLayout.ts b/packages/fold-agent/src/Session/SessionLayout.ts index f217887..de5df47 100644 --- a/packages/fold-agent/src/Session/SessionLayout.ts +++ b/packages/fold-agent/src/Session/SessionLayout.ts @@ -413,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, - ), - ), - ) - }), - ) From 29ca3acd58bed31c809cf67c6309112083bb8489 Mon Sep 17 00:00:00 2001 From: Kyle Mistele Date: Thu, 15 Oct 2026 15:45:34 -0700 Subject: [PATCH 3/3] refactor: inline make into each service class Follow the RcMap post's pattern: every service this PR touches declares its constructor inline as the `make` option of `Context.Service`, with `static readonly layer = Layer.effect(this, this.make)` (or a function of the make's arguments). No separate makeX effects or liveXLayer constants remain. - fold-core: the ToolRuntime, AgentRuntime, Session, HookRunner, and AgentEvents implementations move into their service modules; the *Layer.ts files are deleted. ToolEventSink, SessionControls, and Profiles inline make the same way. Callers use X.layer. - fold-agent: ManagedBinaries, OutputStore, and SessionTitleGenerator inline make (Photon already did). Co-Authored-By: Claude Opus 5.5 HumanLayer-Session: https://app.dev.codelayer.gg/sessions/01a14171-1b19-772e-812c-382bdc40b824 --- .../fold-agent/src/Bin/ManagedBinaries.ts | 594 +++++++-------- .../fold-agent/src/OutputStore/OutputStore.ts | 99 ++- .../fold-agent/src/Session/TitleGenerator.ts | 61 +- .../src/AgentEvents/AgentEventsLayer.ts | 53 -- .../src/AgentEvents/AgentEventsService.ts | 26 +- .../src/AgentRuntime/AgentRuntimeLayer.ts | 683 ------------------ .../src/AgentRuntime/AgentRuntimeService.ts | 670 ++++++++++++++++- packages/fold-core/src/Api/OpenSession.ts | 22 +- packages/fold-core/src/Api/Provisioning.ts | 11 +- .../src/HookRunner/HookRunnerLayer.ts | 229 ------ .../src/HookRunner/HookRunnerService.ts | 220 +++++- packages/fold-core/src/Session/Profiles.ts | 66 +- .../fold-core/src/Session/SessionControls.ts | 311 ++++---- .../fold-core/src/Session/SessionLayer.ts | 181 ----- .../fold-core/src/Session/SessionService.ts | 178 ++++- .../src/ToolRuntime/ToolContextServices.ts | 27 +- .../src/ToolRuntime/ToolRuntimeLayer.ts | 548 -------------- .../src/ToolRuntime/ToolRuntimeService.ts | 541 +++++++++++++- packages/fold-core/src/index.ts | 5 - .../AgentRuntimeModelSettings.vi.test.ts | 19 +- .../AgentRuntime/AgentRuntimeTestHelpers.ts | 20 +- .../test/HookRunner/HookScope.vi.test.ts | 3 +- .../HookRunner/HookStateResume.vi.test.ts | 3 +- .../test/Session/SessionTestHelpers.ts | 34 +- .../test/TestLayers/HookRunnerTestHarness.ts | 5 +- .../ToolRuntime/ToolRuntimeDefect.vi.test.ts | 14 +- .../ToolRuntimeHookState.vi.test.ts | 4 +- .../ToolRuntimeInterrupt.vi.test.ts | 10 +- .../ToolRuntimeLiveHooks.vi.test.ts | 8 +- .../ToolRuntimeStateSnapshot.vi.test.ts | 8 +- .../ToolRuntimeStopRequest.vi.test.ts | 4 +- .../ToolRuntime/ToolRuntimeTestHelpers.ts | 4 +- 32 files changed, 2268 insertions(+), 2393 deletions(-) delete mode 100644 packages/fold-core/src/AgentEvents/AgentEventsLayer.ts delete mode 100644 packages/fold-core/src/AgentRuntime/AgentRuntimeLayer.ts delete mode 100644 packages/fold-core/src/HookRunner/HookRunnerLayer.ts delete mode 100644 packages/fold-core/src/Session/SessionLayer.ts delete mode 100644 packages/fold-core/src/ToolRuntime/ToolRuntimeLayer.ts diff --git a/packages/fold-agent/src/Bin/ManagedBinaries.ts b/packages/fold-agent/src/Bin/ManagedBinaries.ts index ce81c9f..1ae9132 100644 --- a/packages/fold-agent/src/Bin/ManagedBinaries.ts +++ b/packages/fold-agent/src/Bin/ManagedBinaries.ts @@ -184,326 +184,328 @@ export type ManagedBinariesService = { readonly ensure: (options: EnsureManagedBinariesOptions) => Effect.Effect> } -const makeManagedBinaries = ( - registry: ReadonlyArray, -): Effect.Effect< - ManagedBinariesService, - never, - FileSystem.FileSystem | ChildProcessSpawner.ChildProcessSpawner | HttpClient.HttpClient -> => - 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. `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 { ensure } - }) -/** - * 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: makeManagedBinaries }, + return { ensure } + }), + }, ) { /** A resolver over a given registry (tests pass a small one). */ static readonly layerWith = ( diff --git a/packages/fold-agent/src/OutputStore/OutputStore.ts b/packages/fold-agent/src/OutputStore/OutputStore.ts index 58b5053..ff3f97b 100644 --- a/packages/fold-agent/src/OutputStore/OutputStore.ts +++ b/packages/fold-agent/src/OutputStore/OutputStore.ts @@ -93,59 +93,56 @@ const lineSlice = (content: string, options?: OutputStoreReadOptions): string => } /** A file-backed OutputStore writing one file per tool call into `directory`. */ -const makeOutputStore = (options: { - readonly directory: string -}): Effect.Effect => - Effect.gen(function* () { - const fs = yield* FileSystem.FileSystem - const directory = options.directory - - const refFor = (toolCallId: ToolCallId): OutputStoreRef => - new OutputStoreRef({ toolCallId, path: join(directory, `${toolCallId}.txt`) }) - - const prepare = (toolCallId: ToolCallId): Effect.Effect => { - const ref = refFor(toolCallId) - return fs.makeDirectory(directory, { recursive: true }).pipe( - Effect.andThen(fs.writeFileString(ref.path, '', { flag: 'a' })), - Effect.as(ref), - Effect.mapError((cause) => fileOperationError({ operation: 'prepare', path: ref.path, cause })), - Effect.tapError(logStoreError), - Effect.withSpan('output_store.prepare', { attributes: { toolCallId, path: ref.path } }), - ) - } - - const append = (toolCallId: ToolCallId, chunk: string): Effect.Effect => { - const ref = refFor(toolCallId) - return fs.makeDirectory(directory, { recursive: true }).pipe( - Effect.andThen(fs.writeFileString(ref.path, chunk, { flag: 'a' })), - Effect.as(ref), - Effect.mapError((cause) => fileOperationError({ operation: 'append', path: ref.path, cause })), - Effect.tapError(logStoreError), - Effect.withSpan('output_store.append', { - attributes: { toolCallId, path: ref.path, bytes: chunk.length }, - }), - ) - } - - const read = ( - ref: OutputStoreRef, - readOptions?: OutputStoreReadOptions, - ): Effect.Effect => - fs.readFileString(ref.path).pipe( - Effect.map((content) => lineSlice(content, readOptions)), - Effect.mapError((cause) => fileOperationError({ operation: 'read', path: ref.path, cause })), - Effect.tapError(logStoreError), - Effect.withSpan('output_store.read', { - attributes: { toolCallId: ref.toolCallId, path: ref.path }, - }), - ) - - return { directory, refFor, prepare, append, read } - }) - /** 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', { - make: makeOutputStore, + /** 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 + + const refFor = (toolCallId: ToolCallId): OutputStoreRef => + new OutputStoreRef({ toolCallId, path: join(directory, `${toolCallId}.txt`) }) + + const prepare = (toolCallId: ToolCallId): Effect.Effect => { + const ref = refFor(toolCallId) + return fs.makeDirectory(directory, { recursive: true }).pipe( + Effect.andThen(fs.writeFileString(ref.path, '', { flag: 'a' })), + Effect.as(ref), + Effect.mapError((cause) => fileOperationError({ operation: 'prepare', path: ref.path, cause })), + Effect.tapError(logStoreError), + Effect.withSpan('output_store.prepare', { attributes: { toolCallId, path: ref.path } }), + ) + } + + const append = (toolCallId: ToolCallId, chunk: string): Effect.Effect => { + const ref = refFor(toolCallId) + return fs.makeDirectory(directory, { recursive: true }).pipe( + Effect.andThen(fs.writeFileString(ref.path, chunk, { flag: 'a' })), + Effect.as(ref), + Effect.mapError((cause) => fileOperationError({ operation: 'append', path: ref.path, cause })), + Effect.tapError(logStoreError), + Effect.withSpan('output_store.append', { + attributes: { toolCallId, path: ref.path, bytes: chunk.length }, + }), + ) + } + + const read = ( + ref: OutputStoreRef, + readOptions?: OutputStoreReadOptions, + ): Effect.Effect => + fs.readFileString(ref.path).pipe( + Effect.map((content) => lineSlice(content, readOptions)), + Effect.mapError((cause) => fileOperationError({ operation: 'read', path: ref.path, cause })), + Effect.tapError(logStoreError), + Effect.withSpan('output_store.read', { + attributes: { toolCallId: ref.toolCallId, path: ref.path }, + }), + ) + + 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)) diff --git a/packages/fold-agent/src/Session/TitleGenerator.ts b/packages/fold-agent/src/Session/TitleGenerator.ts index f028024..743fd8a 100644 --- a/packages/fold-agent/src/Session/TitleGenerator.ts +++ b/packages/fold-agent/src/Session/TitleGenerator.ts @@ -47,40 +47,37 @@ export type SessionTitleGeneratorService = { readonly generate: (entries: ReadonlyArray, rootAgentId: string) => Effect.Effect } -const makeSessionTitleGenerator = ( - 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.map((response) => normalizeSessionTitle(response.value.title) || fallback), - Effect.catchCause(() => Effect.succeed(fallback)), - ) - }, - } - }) - /** One session's title generator. The host provides it per session; title generation asks for it. */ -export class SessionTitleGenerator extends Context.Service()( +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.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.Layer> => - Layer.effect(this, makeSessionTitleGenerator(model)) + static readonly layer = (model: FoldModel) => Layer.effect(this, this.make(model)) } diff --git a/packages/fold-core/src/AgentEvents/AgentEventsLayer.ts b/packages/fold-core/src/AgentEvents/AgentEventsLayer.ts deleted file mode 100644 index 2a8cb41..0000000 --- a/packages/fold-core/src/AgentEvents/AgentEventsLayer.ts +++ /dev/null @@ -1,53 +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 makeAgentEvents: Effect.Effect = 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 -}) - -/** The layer over {@link makeAgentEvents}. */ -export const liveAgentEventsLayer: Layer.Layer = Layer.effect(AgentEvents, makeAgentEvents) - -/** - * 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 makeToolEventSink: Effect.Effect = 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 -}) - -/** The layer over {@link makeToolEventSink}. */ -export const toolEventSinkLayerFromAgentEvents: Layer.Layer = Layer.effect( - ToolEventSink, - makeToolEventSink, -) 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 4a0eb44..0000000 --- a/packages/fold-core/src/AgentRuntime/AgentRuntimeLayer.ts +++ /dev/null @@ -1,683 +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 makeAgentRuntime: Effect.Effect< - AgentRuntimeService, - never, - | EventLog - | Ids - | HookRunner - | Toolset - | ToolsetResolver - | SystemPrompt - | ModelRequestSettings - | ToolRuntime - | LanguageModel.LanguageModel - | AgentEvents - | SessionControls -> = 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 } -}) - -/** The layer over {@link makeAgentRuntime}. */ -export const liveAgentRuntimeLayer: Layer.Layer< - AgentRuntime, - never, - | EventLog - | Ids - | HookRunner - | Toolset - | ToolsetResolver - | SystemPrompt - | ModelRequestSettings - | ToolRuntime - | LanguageModel.LanguageModel - | AgentEvents - | SessionControls -> = Layer.effect(AgentRuntime, makeAgentRuntime) 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 0b810d4..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' @@ -438,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, @@ -459,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 @@ -511,7 +505,7 @@ const assembleSessionGraph = (options: { } const sessionContext = yield* Layer.buildWithMemoMap( - liveSessionLayer.pipe( + Session.layer.pipe( Layer.provide(Layer.mergeAll(sessionServicesLayer, Layer.succeed(AgentRuntime, delegatingRuntime))), ), sessionMemoMap, 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 e8112f7..0000000 --- a/packages/fold-core/src/HookRunner/HookRunnerLayer.ts +++ /dev/null @@ -1,229 +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 makeHookRunner = (hooks: HookConfig): Effect.Effect => - 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 - }) - -/** The layer over {@link makeHookRunner}. */ -export const layerHookRunner = (hooks: HookConfig): Layer.Layer => - Layer.effect(HookRunner, makeHookRunner(hooks)) 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 ed0bdbc..dd33bb7 100644 --- a/packages/fold-core/src/Session/Profiles.ts +++ b/packages/fold-core/src/Session/Profiles.ts @@ -58,40 +58,38 @@ 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') {} +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) -/** One session's profiles over the initial bindings from `Session.open`. */ -export const makeProfiles = (initial: SessionProfiles): Effect.Effect => - Effect.gen(function* () { - const state = yield* Ref.make>(initial) + const resolve = (role: ProfileRole): Effect.Effect> => + Ref.get(state).pipe( + Effect.flatMap((profiles) => { + const model = profileModelFor(profiles, role) + return model === undefined + ? Effect.die( + new Error( + `profile role "${role}" has no bound model - session-start validation should have rejected this roster`, + ), + ) + : Effect.succeed(model) + }), + ) - const resolve = (role: ProfileRole): Effect.Effect> => - Ref.get(state).pipe( - Effect.flatMap((profiles) => { - const model = profileModelFor(profiles, role) - return model === undefined - ? Effect.die( - new Error( - `profile role "${role}" has no bound model - session-start validation should have rejected this roster`, - ), - ) - : Effect.succeed(model) - }), - ) + const set = (role: ProfileRole, model: FoldModel): Effect.Effect => + Ref.update(state, (profiles) => + Match.value(role).pipe( + Match.when('smart', () => ({ ...profiles, smart: model })), + Match.when('fast', () => ({ ...profiles, fast: model })), + Match.when('orchestrator', () => ({ ...profiles, orchestrator: model })), + Match.exhaustive, + ), + ) - const set = (role: ProfileRole, model: FoldModel): Effect.Effect => - Ref.update(state, (profiles) => - Match.value(role).pipe( - Match.when('smart', () => ({ ...profiles, smart: model })), - Match.when('fast', () => ({ ...profiles, fast: model })), - Match.when('orchestrator', () => ({ ...profiles, orchestrator: model })), - Match.exhaustive, - ), - ) - - return { resolve, set, replace: (profiles) => Ref.set(state, profiles), snapshot: Ref.get(state) } - }) - -/** The layer over {@link makeProfiles}. */ -export const layerProfiles = (initial: SessionProfiles): Layer.Layer => - Layer.effect(Profiles, makeProfiles(initial)) + return { resolve, set, replace: (profiles) => 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 ae6b4c0..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,151 +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 makeSessionControls = (options?: { - readonly steeringMode?: SteeringMode -}): Effect.Effect => - 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 - }) - 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) - }) - yield* Ref.update(steering, (current) => { - const next = new Map(current) - next.delete(agentId) - return next - }) - 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 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 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 }]) - }) - return { consumed: Deferred.await(consumed) } - }) - - const drainFollowUps = (agentId: AgentId): Effect.Effect> => +/** 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 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) - }) - - 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), - } - }) - -/** The layer over {@link makeSessionControls}. */ -export const layerSessionControls = (options?: { - readonly steeringMode?: SteeringMode -}): Layer.Layer => Layer.effect(SessionControls, makeSessionControls(options)) + 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 + }) + 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) + }) + yield* Ref.update(steering, (current) => { + const next = new Map(current) + next.delete(agentId) + return next + }) + 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 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 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 }]) + }) + 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 + }) + 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), + } + }), + }, +) { + 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 7d5a5d0..0000000 --- a/packages/fold-core/src/Session/SessionLayer.ts +++ /dev/null @@ -1,181 +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 makeSession: Effect.Effect = - 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 } - }) - -/** The layer over {@link makeSession}. */ -export const liveSessionLayer: Layer.Layer = Layer.effect( - Session, - makeSession, -) 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/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 706d3f3..0000000 --- a/packages/fold-core/src/ToolRuntime/ToolRuntimeLayer.ts +++ /dev/null @@ -1,548 +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 makeToolRuntime: Effect.Effect< - ToolRuntimeService, - never, - EventLog | Ids | HookRunner | Toolset | ToolEventSink -> = 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 } -}) - -/** The layer over {@link makeToolRuntime}. */ -export const liveToolRuntimeLayer: Layer.Layer< - ToolRuntime, - never, - EventLog | Ids | HookRunner | Toolset | ToolEventSink -> = Layer.effect(ToolRuntime, makeToolRuntime) 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/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,