From 963b1a7cb45687e46e9b52bab039de1f3a67a1ca Mon Sep 17 00:00:00 2001 From: Kyle Mistele Date: Tue, 29 Sep 2026 15:39:09 -0700 Subject: [PATCH] fix(core): stop requiring FileSystem to start a session startSession and resumeSession required FileSystem.FileSystem even for a memory log and tools that never touch disk, so hosts without a filesystem (Cloudflare Durable Objects, browsers) had to provide a fake one. The session now passes along whichever platform services (FileSystem, Path, ChildProcessSpawner) its caller provides, the way Path and ChildProcessSpawner already reached tool handlers. Descriptors that declare them - coding tools, the JSONL log, disk skills - still get them; one that declares a service the caller left out dies when it asks for it. - Drop FileSystem from ToolHandlerServices, defineTool's default dependencies, ToolRuntime, Toolset.handle, and provisioning services - Export PlatformServices; disk tools keep opting in through platformToolDependencies - Remove the NodeFileSystem layer from fold-core examples that need none Co-Authored-By: Claude Opus 5.5 HumanLayer-Session: https://app.dev.codelayer.gg/sessions/01a0ef47-f9da-79e2-a532-a2cd10daaa2b --- packages/fold-agent/test/TestHelpers.ts | 9 +- .../test/SessionModelPathTestHarness.ts | 3 +- packages/fold-core/examples/AnthropicAgent.ts | 3 +- .../fold-core/examples/AutoCompactAgent.ts | 3 +- packages/fold-core/examples/ModelSwitch.ts | 3 +- packages/fold-core/examples/OpenaiAgent.ts | 3 +- packages/fold-core/examples/SkillsAgent.ts | 3 +- packages/fold-core/src/Api/Provisioning.ts | 3 +- packages/fold-core/src/Api/StartSession.ts | 183 +++++++++--------- packages/fold-core/src/Api/ToolDefinition.ts | 13 +- .../src/ToolRuntime/ToolRuntimeLayer.ts | 20 +- .../src/ToolRuntime/ToolsetService.ts | 10 +- .../test/Api/StartSession.vi.test.ts | 48 ++++- 13 files changed, 170 insertions(+), 134 deletions(-) diff --git a/packages/fold-agent/test/TestHelpers.ts b/packages/fold-agent/test/TestHelpers.ts index dcd6254..4dae814 100644 --- a/packages/fold-agent/test/TestHelpers.ts +++ b/packages/fold-agent/test/TestHelpers.ts @@ -21,13 +21,14 @@ import { ToolEvents, ToolState, type FoldTool, + type PlatformServices, type ToolHandlerServices, } from '@humanlayer/fold-core' import { Effect, FileSystem, Layer, PlatformError, Ref, type Schema } from 'effect' /** Run a tool handler effect with stubbed ambient services and recorded ToolEvents/InterruptNote feeds. */ export const makeAmbientServices = (): Effect.Effect<{ - readonly layer: Layer.Layer + readonly layer: Layer.Layer readonly emitted: Effect.Effect> /** The most recent InterruptNote the handler recorded, or null. */ readonly interruptNote: Effect.Effect @@ -71,11 +72,13 @@ export const makeAmbientServices = (): Effect.Effect<{ /** Invoke one tool's handler through its init: the realized handler, still needing the ambient R. */ export const handlerOf = (tool: FoldTool) => - (params: unknown): Effect.Effect => + (params: unknown): Effect.Effect => tool.init.pipe(Effect.flatMap((contribution) => contribution.handler(params))) /** Run one handler with throwaway ambient services. */ -export const runHandler = (effect: Effect.Effect): Effect.Effect => +export const runHandler = ( + effect: Effect.Effect, +): Effect.Effect => Effect.gen(function* () { const ambient = yield* makeAmbientServices() return yield* effect.pipe(Effect.provide(ambient.layer)) diff --git a/packages/fold-codex/test/SessionModelPathTestHarness.ts b/packages/fold-codex/test/SessionModelPathTestHarness.ts index a140674..c20d660 100644 --- a/packages/fold-codex/test/SessionModelPathTestHarness.ts +++ b/packages/fold-codex/test/SessionModelPathTestHarness.ts @@ -24,6 +24,7 @@ import { ToolEvents, ToolState, type FoldTool, + type PlatformServices, type ToolHandlerServices, type ToolResultLogEntry, } from '@humanlayer/fold-core' @@ -122,7 +123,7 @@ export const makeDeterministicColorBandsPng = (): Uint8Array => { ]) } -const toolHandlerTestLayer: Layer.Layer = Layer.mergeAll( +const toolHandlerTestLayer: Layer.Layer = Layer.mergeAll( NodeServices.layer, Layer.succeed(ToolState, { get: () => Effect.succeed(null), set: () => Effect.void }), Layer.succeed(ToolEvents, { emit: () => Effect.void }), diff --git a/packages/fold-core/examples/AnthropicAgent.ts b/packages/fold-core/examples/AnthropicAgent.ts index a3e7d2b..8ed7df3 100644 --- a/packages/fold-core/examples/AnthropicAgent.ts +++ b/packages/fold-core/examples/AnthropicAgent.ts @@ -6,7 +6,6 @@ * * Run: ANTHROPIC_API_KEY=... bun packages/fold-core/examples/AnthropicAgent.ts */ -import * as NodeFileSystem from '@effect/platform-node/NodeFileSystem' import { Console, Effect, Schema } from 'effect' import { anthropicModel, defineAgent, defineTool, startSession } from '../src/index' @@ -42,7 +41,7 @@ const makeProgram = (apiKey: string) => yield* Console.log(`finished: ${finished.outcome}`) yield* Console.log(`result: ${finished.resultText ?? '(no text)'}`) yield* Console.log(`log: ${entries.map((entry) => entry._tag).join(' -> ')}`) - }).pipe(Effect.scoped, Effect.provide(NodeFileSystem.layer)) + }).pipe(Effect.scoped) if (apiKey === undefined || apiKey === '') { console.error('Set ANTHROPIC_API_KEY to run this example.') diff --git a/packages/fold-core/examples/AutoCompactAgent.ts b/packages/fold-core/examples/AutoCompactAgent.ts index 0f1f401..d324f0a 100644 --- a/packages/fold-core/examples/AutoCompactAgent.ts +++ b/packages/fold-core/examples/AutoCompactAgent.ts @@ -10,7 +10,6 @@ * * Run: OPENAI_API_KEY=... bun packages/fold-core/examples/AutoCompactAgent.ts */ -import * as NodeFileSystem from '@effect/platform-node/NodeFileSystem' import { Predicate, Console, Effect } from 'effect' import { defineAgent, openaiModel, startSession, type CompactionLogEntry } from '../src/index' @@ -78,7 +77,7 @@ const makeProgram = (key: string) => yield* Console.log('\nsend 3: the session keeps running on the compacted context...') const third = yield* session.send('And what were we told about the dedupe window?') yield* Console.log(` -> ${third.resultText ?? '(no text)'}`) - }).pipe(Effect.scoped, Effect.provide(NodeFileSystem.layer)) + }).pipe(Effect.scoped) if (apiKey === undefined || apiKey === '') { console.error('Set OPENAI_API_KEY to run this example.') diff --git a/packages/fold-core/examples/ModelSwitch.ts b/packages/fold-core/examples/ModelSwitch.ts index ea89b40..0379d26 100644 --- a/packages/fold-core/examples/ModelSwitch.ts +++ b/packages/fold-core/examples/ModelSwitch.ts @@ -10,7 +10,6 @@ * * Run: OPENAI_API_KEY=... ANTHROPIC_API_KEY=... bun packages/fold-core/examples/ModelSwitch.ts */ -import * as NodeFileSystem from '@effect/platform-node/NodeFileSystem' import { Console, Effect, Schema } from 'effect' import { anthropicModel, defineAgent, defineTool, openaiModel, startSession } from '../src/index' @@ -61,7 +60,7 @@ const makeProgram = (openAiKey: string, anthropicKey: string) => const entries = yield* session.entries yield* Console.log(`log: ${entries.map((entry) => entry._tag).join(' -> ')}`) - }).pipe(Effect.scoped, Effect.provide(NodeFileSystem.layer)) + }).pipe(Effect.scoped) if (openAiKey === undefined || openAiKey === '' || anthropicKey === undefined || anthropicKey === '') { console.error('Set OPENAI_API_KEY and ANTHROPIC_API_KEY to run this example.') diff --git a/packages/fold-core/examples/OpenaiAgent.ts b/packages/fold-core/examples/OpenaiAgent.ts index bdfec3f..76a04f1 100644 --- a/packages/fold-core/examples/OpenaiAgent.ts +++ b/packages/fold-core/examples/OpenaiAgent.ts @@ -5,7 +5,6 @@ * * Run: OPENAI_API_KEY=... bun packages/fold-core/examples/OpenaiAgent.ts */ -import * as NodeFileSystem from '@effect/platform-node/NodeFileSystem' import { Console, Effect, Schema } from 'effect' import { defineAgent, defineTool, openaiModel, startSession } from '../src/index' @@ -41,7 +40,7 @@ const makeProgram = (apiKey: string) => yield* Console.log(`finished: ${finished.outcome}`) yield* Console.log(`result: ${finished.resultText ?? '(no text)'}`) yield* Console.log(`log: ${entries.map((entry) => entry._tag).join(' -> ')}`) - }).pipe(Effect.scoped, Effect.provide(NodeFileSystem.layer)) + }).pipe(Effect.scoped) if (apiKey === undefined || apiKey === '') { console.error('Set OPENAI_API_KEY to run this example.') diff --git a/packages/fold-core/examples/SkillsAgent.ts b/packages/fold-core/examples/SkillsAgent.ts index 704d1ef..01f13f4 100644 --- a/packages/fold-core/examples/SkillsAgent.ts +++ b/packages/fold-core/examples/SkillsAgent.ts @@ -7,7 +7,6 @@ * * Run: ANTHROPIC_API_KEY=... bun packages/fold-core/examples/SkillsAgent.ts */ -import * as NodeFileSystem from '@effect/platform-node/NodeFileSystem' import { Console, Effect } from 'effect' import { anthropicModel, defineAgent, skillsFromData, skillTool, startSession } from '../src/index' @@ -51,7 +50,7 @@ const makeProgram = (apiKey: string) => yield* Console.log(`finished: ${finished.outcome}`) yield* Console.log(`result:\n${finished.resultText ?? '(no text)'}`) yield* Console.log(`log: ${entries.map((entry) => entry._tag).join(' -> ')}`) - }).pipe(Effect.scoped, Effect.provide(NodeFileSystem.layer)) + }).pipe(Effect.scoped) if (apiKey === undefined || apiKey === '') { console.error('Set ANTHROPIC_API_KEY to run this example.') diff --git a/packages/fold-core/src/Api/Provisioning.ts b/packages/fold-core/src/Api/Provisioning.ts index 53e0279..d9e277d 100644 --- a/packages/fold-core/src/Api/Provisioning.ts +++ b/packages/fold-core/src/Api/Provisioning.ts @@ -19,7 +19,7 @@ import { AnthropicClient, AnthropicLanguageModel } from '@humanlayer/effect-ai-anthropic' import { OpenAiClient, OpenAiLanguageModel } from '@humanlayer/effect-ai-openai' import { Context, Effect, Layer, Match, Stream } from 'effect' -import type { FileSystem, Scope } from 'effect' +import type { Scope } from 'effect' import { LanguageModel, Toolkit } from 'effect/unstable/ai' import type { Tool } from 'effect/unstable/ai' import { FetchHttpClient, HttpClient } from 'effect/unstable/http' @@ -95,7 +95,6 @@ export type SessionProvisioningServices = | ToolEventSink | Subagents | SessionControls - | FileSystem.FileSystem /** Lower a model descriptor to the LanguageModel layer for its provider connection. */ export const languageModelLayerFor = (model: FoldModel): Layer.Layer => { diff --git a/packages/fold-core/src/Api/StartSession.ts b/packages/fold-core/src/Api/StartSession.ts index 4f080b6..17b9132 100644 --- a/packages/fold-core/src/Api/StartSession.ts +++ b/packages/fold-core/src/Api/StartSession.ts @@ -37,6 +37,7 @@ import { FileSystem, Layer, Match, + Path, Ref, Schema, Scope, @@ -44,6 +45,7 @@ import { Stream, } from 'effect' import { Prompt } from 'effect/unstable/ai' +import { ChildProcessSpawner } from 'effect/unstable/process' import { toolEventSinkLayerFromAgentEvents, liveAgentEventsLayer } from '../AgentEvents/AgentEventsLayer' import type { FoldEvent } from '../AgentEvents/AgentEventsService' @@ -104,7 +106,7 @@ import type { AgentDefinition } from './AgentDefinition' import { memoryEventLog, type FoldEventLog } from './EventLogDescriptor' import type { FoldModel } from './ModelDescriptor' import { AgentProvisioner, makeAgentProvisioner, validateToolNames } from './Provisioning' -import type { RealizedFoldTool, SessionToolContribution, FoldTool } from './ToolDefinition' +import type { PlatformServices, RealizedFoldTool, SessionToolContribution, FoldTool } from './ToolDefinition' type Mutable = { -readonly [Key in keyof T]: T[Key] } @@ -281,11 +283,31 @@ type SessionAgentConfig = { readonly tools: ReadonlyArray } +/** + * Pass along whichever platform services the caller provided, the way Effect AI's Toolkit passes its + * build context to handlers. A session never requires them; descriptors that declare them (disk tools, + * a JSONL log, a disk skill source) read them from here, and die with a missing-service defect when the + * caller left one out. + */ +const providedPlatformServices: Effect.Effect> = Effect.map( + Effect.context(), + (caller) => { + const platform = Context.pick(FileSystem.FileSystem, Path.Path, ChildProcessSpawner.ChildProcessSpawner)(caller) + // SAFETY: `pick` keeps only the keys present, so this may hold some of the platform services. That + // is the point: a descriptor that declares one the caller did not provide fails when it asks for it. + // oxlint-disable-next-line typescript/consistent-type-assertions + return platform as Context.Context + }, +) + /** Lower the event log descriptor to its EventLog layer. */ -const eventLogLayerFor = (log: FoldEventLog): Layer.Layer => +const eventLogLayerFor = ( + log: FoldEventLog, + platformServices: Context.Context, +): Layer.Layer => Match.valueTags(log, { memory: () => layerInMemoryEventLogWithIds, - source: ({ make }) => Layer.effect(EventLog, make), + source: ({ make }) => Layer.effect(EventLog, make.pipe(Effect.provideContext(platformServices))), }) /** Fold a leading-prompt config value into an ordered block list. */ @@ -308,9 +330,7 @@ type SessionGraph = { profiles: SessionProfiles, ) => Effect.Effect readonly extendSubagentRegistry: (definitions: CollectedAgentDefinitions) => void - readonly ensureToolContributions: ( - tools: ReadonlyArray, - ) => Effect.Effect + readonly ensureToolContributions: (tools: ReadonlyArray) => Effect.Effect readonly collectNewSubagentDefinitions: (tools: ReadonlyArray) => Effect.Effect readonly provisionRootRuntime: ( model: FoldModel, @@ -318,7 +338,6 @@ type SessionGraph = { ) => Effect.Effect readonly setProvisionedRuntime: (runtime: AgentRuntimeService) => Effect.Effect readonly currentProvisionedRuntime: Effect.Effect - readonly fileSystem: FileSystem.FileSystem readonly leadingPromptFor: ( systemPrompt: string | ReadonlyArray | null, tools: ReadonlyArray, @@ -337,9 +356,10 @@ const assembleSessionGraph = (options: { readonly profiles?: SessionProfiles readonly catalog?: ReadonlyArray readonly compactionArchiveAccess?: CompactionArchiveAccessService -}): Effect.Effect => +}): Effect.Effect => Effect.gen(function* () { const agent = options.agent + const platformServices = yield* providedPlatformServices const rootTools = agent.tools ?? [] const rootHooks = agent.hooks ?? {} @@ -382,12 +402,14 @@ const assembleSessionGraph = (options: { // leading-prompt block, skill source - is reused by every agent listing that value, across // epochs, and by every subagent dispatch (D20's one-snapshot law). const toolContributions = new Map() - const ensureToolContributions = ( - tools: ReadonlyArray, - ): Effect.Effect => + const ensureToolContributions = (tools: ReadonlyArray): Effect.Effect => Effect.forEach( tools.filter((tool) => !toolContributions.has(tool)), - (tool) => tool.init.pipe(Effect.map((contribution) => toolContributions.set(tool, contribution))), + (tool) => + tool.init.pipe( + Effect.provideContext(platformServices), + Effect.map((contribution) => toolContributions.set(tool, contribution)), + ), { discard: true }, ).pipe(Effect.asVoid) @@ -459,18 +481,17 @@ const assembleSessionGraph = (options: { // One shared service graph per session; every provisioned runtime closes over these same // instances (one EventLog, one Ids source, one AgentEvents PubSub, one SessionControls, one // Subagents engine). HookRunner is deliberately NOT session-fixed: each provisioned runtime - // carries its own agent's hook chains (D16/D21). - const fileSystem = yield* FileSystem.FileSystem + // carries its own agent's hook chains (D16/D21). Tool handlers get their declared platform + // services from this graph too: Effect AI hands each handler the context its toolkit was built in. const idsLayer = layerLiveIdFactory - const fsLayer = Layer.succeed(FileSystem.FileSystem, fileSystem) const infraLayer = Layer.mergeAll( - eventLogLayerFor(options.log ?? memoryEventLog()).pipe(Layer.provide(Layer.mergeAll(idsLayer, fsLayer))), + eventLogLayerFor(options.log ?? memoryEventLog(), platformServices).pipe(Layer.provide(idsLayer)), idsLayer, liveAgentEventsLayer, ) const servicesLayer = Layer.mergeAll( infraLayer, - fsLayer, + Layer.succeedContext(platformServices), makeSystemPrompt(agent.basePrompts === undefined ? {} : { basePrompts: agent.basePrompts }), liveModelRequestSettingsLayer, toolEventSinkLayerFromAgentEvents.pipe(Layer.provide(infraLayer)), @@ -584,7 +605,6 @@ const assembleSessionGraph = (options: { provisionRootRuntime, setProvisionedRuntime: (runtime) => Ref.set(runtimeRef, runtime), currentProvisionedRuntime: Ref.get(runtimeRef), - fileSystem, leadingPromptFor, } }) @@ -846,72 +866,63 @@ const makeSessionHandle = (graph: SessionGraph, identity: StartedSession): FoldS const switchModel = (model: FoldModel, switchOptions?: SwitchModelOptions): Effect.Effect => gate.withPermit( - Effect.provideService( - FileSystem.FileSystem, - graph.fileSystem, - )( - Effect.gen(function* () { - const current = yield* Ref.get(configRef) - const currentProfiles = yield* profiles.snapshot - const candidateProfiles = switchOptions?.profiles ?? currentProfiles - const next: SessionAgentConfig = { - model, - promptCacheKey: current.promptCacheKey, - systemPrompt: switchOptions?.systemPrompt ?? current.systemPrompt, - tools: switchOptions?.tools ?? current.tools, - } - yield* validateToolNames(next.tools) - - // A switch may introduce new session-initialized tools and subagent types. The switch gate - // is the sole extension boundary, so dispatch can never observe a partially installed graph. - yield* graph.ensureToolContributions(next.tools) - const introduced = yield* graph.collectNewSubagentDefinitions(next.tools) - yield* Effect.forEach( - introduced.subagents, - (definition) => validateToolNames(definition.tools ?? []), - { - discard: true, - }, - ) - yield* Effect.forEach( - introduced.subagents, - (definition) => graph.ensureToolContributions(definition.tools ?? []), - { - discard: true, - }, - ) - yield* Effect.forEach(introduced.forkAgents, (definition) => validateToolNames(definition.tools), { + Effect.gen(function* () { + const current = yield* Ref.get(configRef) + const currentProfiles = yield* profiles.snapshot + const candidateProfiles = switchOptions?.profiles ?? currentProfiles + const next: SessionAgentConfig = { + model, + promptCacheKey: current.promptCacheKey, + systemPrompt: switchOptions?.systemPrompt ?? current.systemPrompt, + tools: switchOptions?.tools ?? current.tools, + } + yield* validateToolNames(next.tools) + + // A switch may introduce new session-initialized tools and subagent types. The switch gate + // is the sole extension boundary, so dispatch can never observe a partially installed graph. + yield* graph.ensureToolContributions(next.tools) + const introduced = yield* graph.collectNewSubagentDefinitions(next.tools) + yield* Effect.forEach(introduced.subagents, (definition) => validateToolNames(definition.tools ?? []), { + discard: true, + }) + yield* Effect.forEach( + introduced.subagents, + (definition) => graph.ensureToolContributions(definition.tools ?? []), + { discard: true, + }, + ) + yield* Effect.forEach(introduced.forkAgents, (definition) => validateToolNames(definition.tools), { + discard: true, + }) + yield* Effect.forEach( + introduced.forkAgents, + (definition) => graph.ensureToolContributions(definition.tools), + { discard: true }, + ) + yield* graph.validateSubagentRegistry(introduced, candidateProfiles) + + // Provision against the new toolset before writing the transition, so the durable + // tools-change below resolves over the newly installed tools. Nothing can run in + // between: root runs wait on the same gate. + const nextRuntime = yield* graph.provisionRootRuntime(model, next.tools) + const previousRuntime = yield* graph.currentProvisionedRuntime + yield* graph.setProvisionedRuntime(nextRuntime) + const transition = yield* session + .switchModel({ + model: model.activeModel, + systemPrompt: graph.leadingPromptFor(next.systemPrompt, next.tools), + reason: switchOptions?.reason ?? null, }) - yield* Effect.forEach( - introduced.forkAgents, - (definition) => graph.ensureToolContributions(definition.tools), - { discard: true }, - ) - yield* graph.validateSubagentRegistry(introduced, candidateProfiles) - - // Provision against the new toolset before writing the transition, so the durable - // tools-change below resolves over the newly installed tools. Nothing can run in - // between: root runs wait on the same gate. - const nextRuntime = yield* graph.provisionRootRuntime(model, next.tools) - const previousRuntime = yield* graph.currentProvisionedRuntime - yield* graph.setProvisionedRuntime(nextRuntime) - const transition = yield* session - .switchModel({ - model: model.activeModel, - systemPrompt: graph.leadingPromptFor(next.systemPrompt, next.tools), - reason: switchOptions?.reason ?? null, - }) - .pipe(Effect.orDie, Effect.exit) - if (Exit.isFailure(transition)) { - yield* graph.setProvisionedRuntime(previousRuntime) - return yield* Effect.failCause(transition.cause) - } - graph.extendSubagentRegistry(introduced) - yield* profiles.replace(candidateProfiles) - yield* Ref.set(configRef, next) - }), - ), + .pipe(Effect.orDie, Effect.exit) + if (Exit.isFailure(transition)) { + yield* graph.setProvisionedRuntime(previousRuntime) + return yield* Effect.failCause(transition.cause) + } + graph.extendSubagentRegistry(introduced) + yield* profiles.replace(candidateProfiles) + yield* Ref.set(configRef, next) + }), ) const compact = (options?: CompactOptions): Effect.Effect => @@ -955,9 +966,7 @@ const makeSessionHandle = (graph: SessionGraph, identity: StartedSession): FoldS * the surrounding scope: closing the scope releases the log backend, event spine, and provisioned model * runtimes. */ -export const startSession = ( - options: StartSessionOptions, -): Effect.Effect => +export const startSession = (options: StartSessionOptions): Effect.Effect => Effect.gen(function* () { const graph = yield* assembleSessionGraph(options) const config = yield* Ref.get(graph.configRef) @@ -990,9 +999,7 @@ export const startSession = ( * leading blocks, e.g. a freshly scanned skills roster (D20 resume rule) - one durable epoch * transition is written before the first send. */ -export const resumeSession = ( - options: ResumeSessionOptions, -): Effect.Effect => +export const resumeSession = (options: ResumeSessionOptions): Effect.Effect => Effect.gen(function* () { const graph = yield* assembleSessionGraph(options) const entries = yield* Stream.runCollect(graph.eventLog.entries()).pipe( diff --git a/packages/fold-core/src/Api/ToolDefinition.ts b/packages/fold-core/src/Api/ToolDefinition.ts index 33a35bd..18f1e2c 100644 --- a/packages/fold-core/src/Api/ToolDefinition.ts +++ b/packages/fold-core/src/Api/ToolDefinition.ts @@ -32,7 +32,7 @@ import { ToolState } from '../ToolRuntime/ToolStateService' * the executing call's identity (`CurrentAgent`/`CurrentToolCall` - D12), the `InterruptNote` enriching * this call's synthetic result if it is interrupted, and the `Subagents` engine (the subagent tool's * handler delegates to it). The runtime provides all of them around each call; handlers needing none of - * them simply have a smaller `R`. + * them simply have a smaller `R`. Platform services are not among them: see {@link PlatformServices}. */ export type ToolHandlerServices = | ToolState @@ -42,9 +42,13 @@ export type ToolHandlerServices = | CurrentToolCall | InterruptNote | Subagents - | FileSystem.FileSystem -type PlatformToolServices = FileSystem.FileSystem | Path.Path | ChildProcessSpawner.ChildProcessSpawner +/** + * Host services that disk- and process-backed descriptors (coding tools, a JSONL log, a disk skill + * source) may declare. A session never requires them: it passes along whichever ones its caller + * provides, so a host without a filesystem can run any session whose descriptors don't declare one. + */ +export type PlatformServices = FileSystem.FileSystem | Path.Path | ChildProcessSpawner.ChildProcessSpawner type ToolDependency = | typeof ToolState @@ -125,7 +129,7 @@ export type DefineToolOptions Effect.Effect + ) => Effect.Effect } /** @@ -162,7 +166,6 @@ export const defineTool = < CurrentToolCall, InterruptNote, Subagents, - FileSystem.FileSystem, ...(options.dependencies ?? []), ], } diff --git a/packages/fold-core/src/ToolRuntime/ToolRuntimeLayer.ts b/packages/fold-core/src/ToolRuntime/ToolRuntimeLayer.ts index 8f96ca6..9edc65b 100644 --- a/packages/fold-core/src/ToolRuntime/ToolRuntimeLayer.ts +++ b/packages/fold-core/src/ToolRuntime/ToolRuntimeLayer.ts @@ -4,7 +4,7 @@ * 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, Match, Predicate, Cause, Effect, FileSystem, Layer, Ref, Schema, Stream } from 'effect' +import { Data, Match, Predicate, Cause, Effect, Layer, Ref, Schema, Stream } from 'effect' import { Prompt } from 'effect/unstable/ai' import { EventLog } from '../EventLog/EventLogService' @@ -308,7 +308,6 @@ const finalOutputFromToolHandler = (input: { | CurrentToolCall | InterruptNote | Subagents - | FileSystem.FileSystem > => Effect.gen(function* () { const toolset = yield* Toolset @@ -385,11 +384,7 @@ const settlePreparedToolCall = (input: { readonly prepared: PreparedToolCall readonly stopRef: Ref.Ref readonly stateSnapshot: ReadonlyArray -}): Effect.Effect< - ToolResultLogEntry, - never, - EventLog | Ids | HookRunner | Toolset | ToolEventSink | Subagents | FileSystem.FileSystem -> => +}): Effect.Effect => Effect.gen(function* () { const toolCallId = yield* decodeToolCallId(input.prepared.original) const toolName = input.prepared.original.name @@ -442,7 +437,6 @@ const settlePreparedToolCall = (input: { | CurrentToolCall | InterruptNote | Subagents - | FileSystem.FileSystem > = Effect.gen(function* () { if (Predicate.isTagged(input.prepared, 'replaceResult')) { return { @@ -533,11 +527,7 @@ type SettleToolCallsInput = Parameters[0] /** Settle every tool call in one assistant message and report whether a stop was requested. */ const settleToolCalls = ( input: SettleToolCallsInput, -): Effect.Effect< - ToolSettlement, - never, - EventLog | Ids | HookRunner | Toolset | ToolEventSink | Subagents | FileSystem.FileSystem -> => +): Effect.Effect => Effect.gen(function* () { const stopRef = yield* Ref.make(null) const stopController = { @@ -597,7 +587,7 @@ const settleToolCalls = ( export const liveToolRuntimeLayer: Layer.Layer< ToolRuntime, never, - EventLog | Ids | HookRunner | Toolset | ToolEventSink | Subagents | FileSystem.FileSystem + EventLog | Ids | HookRunner | Toolset | ToolEventSink | Subagents > = Layer.effect( ToolRuntime, Effect.gen(function* () { @@ -607,7 +597,6 @@ export const liveToolRuntimeLayer: Layer.Layer< const toolset = yield* Toolset const sink = yield* ToolEventSink const subagents = yield* Subagents - const fs = yield* FileSystem.FileSystem const settle: ToolRuntimeService['settle'] = Effect.fn('fold.tool_runtime.settle')((input) => settleToolCalls(input).pipe( @@ -617,7 +606,6 @@ export const liveToolRuntimeLayer: Layer.Layer< Effect.provideService(Toolset, toolset), Effect.provideService(ToolEventSink, sink), Effect.provideService(Subagents, subagents), - Effect.provideService(FileSystem.FileSystem, fs), ), ) diff --git a/packages/fold-core/src/ToolRuntime/ToolsetService.ts b/packages/fold-core/src/ToolRuntime/ToolsetService.ts index c2c9e5b..f119846 100644 --- a/packages/fold-core/src/ToolRuntime/ToolsetService.ts +++ b/packages/fold-core/src/ToolRuntime/ToolsetService.ts @@ -3,7 +3,7 @@ * ToolRuntime live layer to execute tool handlers. The service keeps Effect AI's dynamic Toolkit boundary * contained so callers do not pass tool handlers around as arguments. */ -import { Context, type Effect, type FileSystem, type Stream } from 'effect' +import { Context, type Effect, type Stream } from 'effect' import type { Tool, Toolkit } from 'effect/unstable/ai' import type { CurrentAgent, CurrentToolCall, InterruptNote, StopController, ToolEvents } from './ToolContextServices' @@ -41,13 +41,7 @@ export type ToolsetService = { Stream.Stream< ToolHandlerOutput, unknown, - | ToolState - | ToolEvents - | StopController - | CurrentAgent - | CurrentToolCall - | InterruptNote - | FileSystem.FileSystem + ToolState | ToolEvents | StopController | CurrentAgent | CurrentToolCall | InterruptNote > > } diff --git a/packages/fold-core/test/Api/StartSession.vi.test.ts b/packages/fold-core/test/Api/StartSession.vi.test.ts index 5489799..52bfbd6 100644 --- a/packages/fold-core/test/Api/StartSession.vi.test.ts +++ b/packages/fold-core/test/Api/StartSession.vi.test.ts @@ -7,13 +7,14 @@ import * as NodeFileSystem from '@effect/platform-node/NodeFileSystem' * SessionIsolation.vi.test.ts. */ import { expect, it } from '@effect/vitest' -import { Predicate, Context, Effect, Fiber, Layer, Schema, Stream } from 'effect' +import { Predicate, Context, Effect, Fiber, FileSystem, Layer, Schema, Stream } from 'effect' import { defineAgent, defineTool, defineToolState, eventLogSource, + platformToolDependencies, startSession, EventLog, layerInMemoryEventLog, @@ -342,3 +343,48 @@ it.effect('rejects duplicate tool names as a defect', () => expect(String(exit)).toContain('duplicate tool names: echo') }).pipe(Effect.scoped, Effect.provide(NodeFileSystem.layer)), ) + +it.effect('runs a tool-calling session without a FileSystem when no descriptor declares one', () => + Effect.gen(function* () { + const { model } = yield* scriptedModel(gptActiveModel, [ + toolCallTurn([{ id: 'provider-call-1', name: 'echo', params: { text: 'no disk' } }]), + textTurn('done'), + ]) + + const session = yield* startSession({ agent: defineAgent({ model, tools: [echoTool] }) }) + const finished = yield* session.send('echo something') + + expect(finished.outcome).toBe('completed') + expect(firstToolResultPart(yield* session.entries).result).toEqual({ echoed: 'no disk' }) + }).pipe(Effect.scoped), +) + +it.effect('tool handlers that declare platform services read the FileSystem the caller provided', () => + Effect.gen(function* () { + const { model } = yield* scriptedModel(gptActiveModel, [ + toolCallTurn([{ id: 'provider-call-1', name: 'read_note', params: {} }]), + textTurn('done'), + ]) + const readNote = defineTool({ + name: 'read_note', + description: 'Reads the note file.', + success: Schema.String, + dependencies: platformToolDependencies, + handler: () => + Effect.flatMap(FileSystem.FileSystem, (fs) => fs.readFileString('/note.txt')).pipe(Effect.orDie), + }) + + const session = yield* startSession({ agent: defineAgent({ model, tools: [readNote] }) }) + yield* session.send('read the note') + + expect(firstToolResultPart(yield* session.entries).result).toBe('from the host') + }).pipe( + Effect.scoped, + Effect.provide( + Layer.succeed( + FileSystem.FileSystem, + FileSystem.makeNoop({ readFileString: () => Effect.succeed('from the host') }), + ), + ), + ), +)