Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
599 changes: 305 additions & 294 deletions packages/fold-agent/src/Bin/ManagedBinaries.ts

Large diffs are not rendered by default.

29 changes: 18 additions & 11 deletions packages/fold-agent/src/EventLog/JsonlLayer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -107,21 +107,16 @@ const encodeJsonlLine = (entry: LogEntry): Effect.Effect<string, EventLogInvalid
return `${line}\n`
})

const loadEntries = (
fs: FileSystem.FileSystem,
/** Read and decode every entry in one JSONL session log; a missing file has no entries. */
export const readJsonlEntries = (
filePath: string,
): Effect.Effect<
ReadonlyArray<LogEntry>,
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(
Expand Down Expand Up @@ -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<ReadonlyArray<LogEntry>>(initialEntries)
const pubsub = yield* PubSub.unbounded<LogEntry>()
const appendLock = yield* Semaphore.make(1)
Expand Down
106 changes: 48 additions & 58 deletions packages/fold-agent/src/Mode/Launch.ts
Original file line number Diff line number Diff line change
Expand Up @@ -64,20 +64,9 @@ import {
import { rolesForDirectProviderSelection } from '../Config/ModelSelections'
import { layerJsonl } from '../EventLog/JsonlLayer'
import { memoryPromptBlock } from '../Memory/AgentFiles'
import {
layerOutputStore,
sweepToolOutput,
toolOutputSessionDirFor,
type OutputStore,
} from '../OutputStore/OutputStore'
import {
latestSessionLog,
prepareSessionLog,
refreshSessionSummaryIndex,
sessionLogById,
type SessionLogRef,
} from '../Session/SessionLayout'
import { generateSessionTitle } from '../Session/TitleGenerator'
import { sweepToolOutput, toolOutputSessionDirFor, OutputStore } from '../OutputStore/OutputStore'
import { latestSessionLog, prepareSessionLog, sessionLogById, type SessionLogRef } from '../Session/SessionLayout'
import { SessionTitleGenerator } from '../Session/TitleGenerator'
import type { Photon } from '../Tools/Image/Photon'
import { compactionArchiveAccessFor } from './CompactionArchiveAccess'
import { defaultCodingMode, type FoldMode } from './Mode'
Expand Down Expand Up @@ -421,7 +410,7 @@ const buildAgentDefinition = (

/** The session's own output store, under `<foldHome>/tool-output/<sessionId>`. */
const outputStoreFor = (sessionId: SessionId, options: LaunchSessionOptions) =>
layerOutputStore({
OutputStore.layer({
directory: toolOutputSessionDirFor(
options.foldHome === undefined ? { sessionId } : { sessionId, foldHome: options.foldHome },
),
Expand Down Expand Up @@ -472,14 +461,9 @@ export const switchSessionMode = (
})
})

const withGeneratedTitles = <R>(
session: FoldSession<R>,
model: FoldModel<HttpClient.HttpClient | FileSystem.FileSystem>,
options: { readonly cwd: string; readonly foldHome?: string },
): Effect.Effect<FoldSession<R>, never, FileSystem.FileSystem | HttpClient.HttpClient> =>
const withGeneratedTitles = <R>(session: FoldSession<R>): Effect.Effect<FoldSession<R>, never, SessionTitleGenerator> =>
Effect.gen(function* () {
// Titles are generated after later sends, which run without these services, so take them now.
const titleServices = yield* Effect.context<FileSystem.FileSystem | HttpClient.HttpClient>()
const titles = yield* SessionTitleGenerator
return yield* Semaphore.make(1).pipe(
Effect.map((titleLock) => ({
...session,
Expand All @@ -502,24 +486,18 @@ const withGeneratedTitles = <R>(
)
const generatedTurns = lastTitle?.rootUserTurns ?? 0
if (rootUsers.length <= generatedTurns) return Effect.void
return generateSessionTitle(entries, session.rootAgentId, model).pipe(
Effect.provideContext(titleServices),
return titles.generate(entries, session.rootAgentId).pipe(
Effect.flatMap((title) => {
const generatedThroughSeq = entries.at(-1)?.seq
const rootUserTurns = rootUsers.length
const setTitle = session.setTitle(
// The title append changes the log, so the next summary listing
// rebuilds this session's index record from it.
return session.setTitle(
title,
generatedThroughSeq === undefined
? { rootUserTurns }
: { rootUserTurns, generatedThroughSeq },
)
return setTitle.pipe(
Effect.andThen(
refreshSessionSummaryIndex(session.sessionId, options).pipe(
Effect.provideContext(titleServices),
),
),
)
}),
)
}),
Expand Down Expand Up @@ -587,25 +565,31 @@ export const launchSession = (
yield* sweepToolOutput(opts.foldHome === undefined ? {} : { foldHome: opts.foldHome })
const agent = yield* buildAgentDefinition(opts, mode, models, cwd, config)

// The output store lives as long as the session, in the caller's scope.
// The session's own services live as long as the session, in the caller's scope.
const sessionServices = yield* Layer.build(
Layer.mergeAll(outputStoreFor(prepared.sessionId, opts), layerJsonl(prepared.path).pipe(Layer.orDie)),
Layer.mergeAll(
outputStoreFor(prepared.sessionId, opts),
layerJsonl(prepared.path).pipe(Layer.orDie),
SessionTitleGenerator.layer(models.fast),
),
)
const session = yield* Session.open({
agent,
cwd,
sessionId: prepared.sessionId,
meta: {
mode: mode.name,
rpi: opts.rpi === true || mode.rpiByDefault === true,
profile: opts.profile ?? 'default',
},
profiles: sessionProfilesFor(models),
catalog,
compactionArchiveAccess: compactionArchiveAccessFor({ logPath: prepared.path, modeName: mode.name }),
...Struct.pick(opts, ['steering']),
return yield* Effect.gen(function* () {
const session = yield* Session.open({
agent,
cwd,
sessionId: prepared.sessionId,
meta: {
mode: mode.name,
rpi: opts.rpi === true || mode.rpiByDefault === true,
profile: opts.profile ?? 'default',
},
profiles: sessionProfilesFor(models),
catalog,
compactionArchiveAccess: compactionArchiveAccessFor({ logPath: prepared.path, modeName: mode.name }),
...Struct.pick(opts, ['steering']),
})
return yield* withGeneratedTitles(session)
}).pipe(Effect.provideContext(sessionServices))
return yield* withGeneratedTitles(session, models.fast, { cwd, ...Struct.pick(opts, ['foldHome']) })
})

const resumeFromLog = (
Expand Down Expand Up @@ -638,19 +622,25 @@ const resumeFromLog = (
yield* sweepToolOutput(options.foldHome === undefined ? {} : { foldHome: options.foldHome })
const agent = yield* buildAgentDefinition(options, mode, models, cwd, config)

// The output store lives as long as the session, in the caller's scope.
// The session's own services live as long as the session, in the caller's scope.
const sessionServices = yield* Layer.build(
Layer.mergeAll(outputStoreFor(log.sessionId, options), layerJsonl(log.path).pipe(Layer.orDie)),
Layer.mergeAll(
outputStoreFor(log.sessionId, options),
layerJsonl(log.path).pipe(Layer.orDie),
SessionTitleGenerator.layer(models.fast),
),
)
const session = yield* Session.open({
agent,
sessionId: log.sessionId,
profiles: sessionProfilesFor(models),
catalog,
compactionArchiveAccess: compactionArchiveAccessFor({ logPath: log.path, modeName: mode.name }),
...Struct.pick(options, ['steering']),
return yield* Effect.gen(function* () {
const session = yield* Session.open({
agent,
sessionId: log.sessionId,
profiles: sessionProfilesFor(models),
catalog,
compactionArchiveAccess: compactionArchiveAccessFor({ logPath: log.path, modeName: mode.name }),
...Struct.pick(options, ['steering']),
})
return yield* withGeneratedTitles(session)
}).pipe(Effect.provideContext(sessionServices))
return yield* withGeneratedTitles(session, models.fast, { cwd, ...Struct.pick(options, ['foldHome']) })
})

/**
Expand Down
17 changes: 8 additions & 9 deletions packages/fold-agent/src/OutputStore/OutputStore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -50,9 +50,6 @@ export type OutputStoreService = {
readonly read: (ref: OutputStoreRef, options?: OutputStoreReadOptions) => Effect.Effect<string, OutputStoreError>
}

/** Where tools store their full output. The host provides it; the bash tool asks for it. */
export class OutputStore extends Context.Service<OutputStore, OutputStoreService>()('fold-agent/OutputStore') {}

/** Root directory for all stored tool output. */
export const toolOutputRootFor = (options?: { readonly foldHome?: string }): string =>
join(options?.foldHome ?? defaultFoldHome(), 'tool-output')
Expand Down Expand Up @@ -96,11 +93,10 @@ const lineSlice = (content: string, options?: OutputStoreReadOptions): string =>
}

/** A file-backed OutputStore writing one file per tool call into `directory`. */
export const layerOutputStore = (options: {
readonly directory: string
}): Layer.Layer<OutputStore, never, FileSystem.FileSystem> =>
Layer.effect(
OutputStore,
/** Where tools store their full output. The host provides it; the bash tool asks for it. */
export class OutputStore extends Context.Service<OutputStore, OutputStoreService>()('fold-agent/OutputStore', {
/** A file-backed OutputStore writing one file per tool call into `directory`. */
make: (options: { readonly directory: string }) =>
Effect.gen(function* () {
const fs = yield* FileSystem.FileSystem
const directory = options.directory
Expand Down Expand Up @@ -147,7 +143,10 @@ export const layerOutputStore = (options: {

return { directory, refFor, prepare, append, read }
}),
)
}) {
/** A file-backed OutputStore writing into `options.directory`. */
static readonly layer = (options: { readonly directory: string }) => Layer.effect(this, this.make(options))
}

/**
* Best-effort retention sweep over every session's stored output under `<foldHome>/tool-output`: files
Expand Down
39 changes: 5 additions & 34 deletions packages/fold-agent/src/Session/SessionLayout.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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. */
Expand Down Expand Up @@ -305,12 +305,8 @@ const sessionSummary = (ref: SessionLogRef, entries: ReadonlyArray<LogEntry>): S
}

const loadSessionSummary = (ref: SessionLogRef): Effect.Effect<SessionSummary | null, never, FileSystem.FileSystem> =>
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)),
)
Expand Down Expand Up @@ -417,28 +413,3 @@ export const sessionLogById = (
size: Number(info.size),
}
})

/** Rebuild and append one authoritative summary after session metadata changes. */
export const refreshSessionSummaryIndex = (
sessionId: SessionId,
options?: SessionLayoutOptions,
): Effect.Effect<void, never, FileSystem.FileSystem> =>
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,
),
),
)
}),
)
62 changes: 40 additions & 22 deletions packages/fold-agent/src/Session/TitleGenerator.ts
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -42,24 +41,43 @@ export const titleTranscript = (entries: ReadonlyArray<LogEntry>, 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 = <R>(
entries: ReadonlyArray<LogEntry>,
rootAgentId: string,
model: FoldModel<R>,
): Effect.Effect<string, never, Exclude<R, Scope.Scope>> => {
const fallback = fallbackSessionTitle(entries, rootAgentId)
const transcript = titleTranscript(entries, rootAgentId)
if (transcript.length === 0) return Effect.succeed(fallback)
/** Generates session titles over one LanguageModel built once per session. */
export type SessionTitleGeneratorService = {
/** One unlogged structured generation call. Callers persist only the resulting session_title event. */
readonly generate: (entries: ReadonlyArray<LogEntry>, rootAgentId: string) => Effect.Effect<string>
}

/** One session's title generator. The host provides it per session; title generation asks for it. */
export class SessionTitleGenerator extends Context.Service<SessionTitleGenerator>()(
'fold-agent/Session/SessionTitleGenerator',
{
/** Build `model`'s LanguageModel once; every title then reuses it. */
make: <R>(model: FoldModel<R>): Effect.Effect<SessionTitleGeneratorService, never, Scope.Scope | R> =>
Effect.gen(function* () {
const languageModel = yield* model.make

return {
generate: (entries, rootAgentId) => {
const fallback = fallbackSessionTitle(entries, rootAgentId)
const transcript = titleTranscript(entries, rootAgentId)
if (transcript.length === 0) return Effect.succeed(fallback)

return LanguageModel.generateObject({
schema: TitleResult,
prompt:
'Create a concise title of at most six words for this coding session. Return only the structured title.\n\n' +
transcript,
}).pipe(
Effect.provide(languageModelLayerFor(model)),
Effect.map((response) => normalizeSessionTitle(response.value.title) || fallback),
Effect.catchCause(() => Effect.succeed(fallback)),
)
return languageModel
.generateObject({
schema: TitleResult,
prompt:
'Create a concise title of at most six words for this coding session. Return only the structured title.\n\n' +
transcript,
})
.pipe(
Effect.map((response) => normalizeSessionTitle(response.value.title) || fallback),
Effect.catchCause(() => Effect.succeed(fallback)),
)
},
}
}),
},
) {
/** Titles generated by `model`, whose LanguageModel is built once in the layer's scope. */
static readonly layer = <R>(model: FoldModel<R>) => Layer.effect(this, this.make(model))
}
Loading
Loading