diff --git a/.oxlintrc.jsonc b/.oxlintrc.jsonc index f4eb6cc..83d01b2 100644 --- a/.oxlintrc.jsonc +++ b/.oxlintrc.jsonc @@ -9,6 +9,7 @@ ], "ignorePatterns": ["**/node_modules/**", "**/dist/**", ".release/**", "tools/oxlint/**"], "rules": { + "anti-slop/no-conditional-empty-object-spread": "error", "anti-slop/no-module-mocking": "error", "anti-slop/no-object-parameters": "error", "automation/no-shadowed-standard-array-static": "error", diff --git a/packages/fold-agent/src/Compatibility/GrokCompatibility.ts b/packages/fold-agent/src/Compatibility/GrokCompatibility.ts index 8e699cb..7a82c92 100644 --- a/packages/fold-agent/src/Compatibility/GrokCompatibility.ts +++ b/packages/fold-agent/src/Compatibility/GrokCompatibility.ts @@ -19,13 +19,19 @@ export type GrokCompatibility = { export const loadGrokCompatibility = Effect.fn('fold.grok_compatibility.load')(function* ( options: GrokCompatibilityOptions, ) { - const pluginOptions = { + const pluginOptions: { + cwd: string + home?: string + grokHome?: string + projectRoot?: string + configuredPaths?: ReadonlyArray + } = { cwd: options.cwd, - ...(options.home === undefined ? {} : { home: options.home }), - ...(options.grokHome === undefined ? {} : { grokHome: options.grokHome }), - ...(options.projectRoot === undefined ? {} : { projectRoot: options.projectRoot }), - ...(options.configuredPluginPaths === undefined ? {} : { configuredPaths: options.configuredPluginPaths }), } + if (options.home !== undefined) pluginOptions.home = options.home + if (options.grokHome !== undefined) pluginOptions.grokHome = options.grokHome + if (options.projectRoot !== undefined) pluginOptions.projectRoot = options.projectRoot + if (options.configuredPluginPaths !== undefined) pluginOptions.configuredPaths = options.configuredPluginPaths const plugins = yield* discoverGrokPluginSkillRoots(pluginOptions) const instructions = yield* loadGrokInstructions(options) const skills = yield* makeGrokSkillSource({ diff --git a/packages/fold-agent/src/Config/ConfigSchemaJson.ts b/packages/fold-agent/src/Config/ConfigSchemaJson.ts index 672d1a8..5c8644c 100644 --- a/packages/fold-agent/src/Config/ConfigSchemaJson.ts +++ b/packages/fold-agent/src/Config/ConfigSchemaJson.ts @@ -23,12 +23,12 @@ export const configSchemaPath = (foldHome?: string): string => join(foldHome ?? export const foldConfigJsonSchema = (): Record => { const document = JsonSchema.toDocumentDraft07(Schema.toJsonSchemaDocument(FoldConfig)) const hasDefinitions = Object.keys(document.definitions).length > 0 - - return { + const schema: Record = { $schema: JsonSchema.META_SCHEMA_URI_DRAFT_07, ...document.schema, - ...(hasDefinitions ? { definitions: document.definitions } : {}), } + if (hasDefinitions) schema.definitions = document.definitions + return schema } /** The generated schema serialized as JSON text (tab-indented, trailing newline). */ diff --git a/packages/fold-agent/src/Config/ModelSelections.ts b/packages/fold-agent/src/Config/ModelSelections.ts index 3df12c8..d738d1c 100644 --- a/packages/fold-agent/src/Config/ModelSelections.ts +++ b/packages/fold-agent/src/Config/ModelSelections.ts @@ -2,11 +2,23 @@ import { DEFAULT_CODEX_MODEL_ID } from '@humanlayer/fold-codex' import { DEFAULT_ANTHROPIC_MODEL_ID, type ModelCatalogEntry, type FoldModel } from '@humanlayer/fold-core' import { DEFAULT_OPENCODE_MODEL_ID, GROK_BUILD_MODEL_ID } from '@humanlayer/fold-opencode' import { DEFAULT_XAI_MODEL_ID, XAI_FRONTIER_MODELS } from '@humanlayer/fold-xai' -import { Effect, Match, Predicate } from 'effect' +import { Data, Effect, Match, Predicate } from 'effect' import { agentModelsFromConfig, type AgentModelsOptions, RoleResolutionError } from './AgentModels' import type { ConfigRole, ProfileConfig, ProfileModeName, RoleBinding, FoldConfig } from './ConfigSchema' +type RoleBindingBuilder = { + provider: string + model?: string + reasoning?: NonNullable +} + +type RolesBuilder = { + smart: RoleBinding + fast: RoleBinding + orchestrator?: RoleBinding +} + export type ProfileModelSelection = { readonly _tag: 'profile'; readonly profile: string } export type DirectModelSelection = { readonly _tag: 'direct' @@ -15,6 +27,7 @@ export type DirectModelSelection = { readonly reasoning?: RoleBinding['reasoning'] } export type ConfiguredModelSelection = ProfileModelSelection | DirectModelSelection +export const ConfiguredModelSelection = Data.taggedEnum() export type ModelConfiguration = { readonly profiles: ReadonlyArray<{ readonly name: string; readonly mode: ProfileConfig['mode'] | null }> @@ -102,13 +115,11 @@ export const describeModelConfiguration = ( const rolesForProfile = (config: FoldConfig, name: string): FoldConfig['roles'] | null => { if (name === 'default') return config.roles const profile = config.profiles?.[name] - return profile === undefined - ? null - : { - smart: profile.smart, - fast: profile.fast, - ...(profile.orchestrator === undefined ? {} : { orchestrator: profile.orchestrator }), - } + if (profile === undefined) return null + + const roles: RolesBuilder = { smart: profile.smart, fast: profile.fast } + if (profile.orchestrator !== undefined) roles.orchestrator = profile.orchestrator + return roles } type DirectProviderSelection = { @@ -143,15 +154,15 @@ export const rolesForDirectProviderSelection = ( selection: DirectProviderSelection, ): FoldConfig['roles'] => { const models = defaultModelsForProvider(config, selection) - const bindingFor = (role: ConfigRole): RoleBinding => ({ - provider: selection.provider, - ...(models[role] === undefined ? {} : { model: models[role] }), - }) - const root: RoleBinding = { - ...bindingFor(rootRole), - ...(selection.model === undefined ? {} : { model: selection.model }), - ...(selection.reasoning === undefined ? {} : { reasoning: selection.reasoning }), + const bindingFor = (role: ConfigRole): RoleBindingBuilder => { + const model = models[role] + const binding: RoleBindingBuilder = { provider: selection.provider } + if (model !== undefined) binding.model = model + return binding } + const root = bindingFor(rootRole) + if (selection.model !== undefined) root.model = selection.model + if (selection.reasoning !== undefined) root.reasoning = selection.reasoning return { orchestrator: rootRole === 'orchestrator' ? root : bindingFor('orchestrator'), smart: rootRole === 'smart' ? root : bindingFor('smart'), diff --git a/packages/fold-agent/src/Config/ProviderConfig.ts b/packages/fold-agent/src/Config/ProviderConfig.ts index e4beed9..1d31c72 100644 --- a/packages/fold-agent/src/Config/ProviderConfig.ts +++ b/packages/fold-agent/src/Config/ProviderConfig.ts @@ -150,13 +150,19 @@ export const configureProvider = ( const config = yield* loadFoldConfig(options) const previousModels = config.providers[name]?.configuredModels ?? [] const configuredModels = model === undefined ? previousModels : [...new Set([...previousModels, model])] - const provider = { + const provider: { + kind: ProviderKind + baseUrl: string + apiKey?: string + apiKeyEnv?: string + configuredModels?: ReadonlyArray + } = { kind: input.kind, baseUrl, - ...(apiKey === undefined ? {} : { apiKey }), - ...(apiKeyEnv === undefined ? {} : { apiKeyEnv }), - ...(configuredModels.length === 0 ? {} : { configuredModels }), } + if (apiKey !== undefined) provider.apiKey = apiKey + if (apiKeyEnv !== undefined) provider.apiKeyEnv = apiKeyEnv + if (configuredModels.length > 0) provider.configuredModels = configuredModels const updated: FoldConfig = { ...config, providers: { ...config.providers, [name]: provider } } yield* writeConfig(updated, options) diff --git a/packages/fold-agent/src/EventLog/JsonlLayer.ts b/packages/fold-agent/src/EventLog/JsonlLayer.ts index f2d9128..742c218 100644 --- a/packages/fold-agent/src/EventLog/JsonlLayer.ts +++ b/packages/fold-agent/src/EventLog/JsonlLayer.ts @@ -38,14 +38,18 @@ const unavailableError = ( cause, }) -const corruptEntryError = (line: number, message: string, cause?: unknown, seq?: number) => - new EventLogCorruptEntryError({ - operation: 'entries', - message, - line, - ...(seq === undefined ? {} : { seq }), - ...(cause === undefined ? {} : { cause }), - }) +const corruptEntryError = (line: number, message: string, cause?: unknown, seq?: number) => { + const input: { + operation: 'entries' + message: string + line: number + seq?: number + cause?: unknown + } = { operation: 'entries', message, line } + if (seq !== undefined) input.seq = seq + if (cause !== undefined) input.cause = cause + return new EventLogCorruptEntryError(input) +} const invalidEntryError = (message: string, cause: unknown) => new EventLogInvalidEntryError({ diff --git a/packages/fold-agent/src/Mode/Launch.ts b/packages/fold-agent/src/Mode/Launch.ts index 19cc971..3c9f9d9 100644 --- a/packages/fold-agent/src/Mode/Launch.ts +++ b/packages/fold-agent/src/Mode/Launch.ts @@ -24,6 +24,9 @@ import { SessionId, startSession, type AgentDefinition, + type ResumeSessionOptions, + type StartSessionOptions, + type SwitchModelOptions, type AutoCompactConfig, type ModelCatalogEntry, type ReasoningLevel, @@ -34,11 +37,17 @@ import { type FoldSession, type FoldTool, type Ids, + type LogSeq, } from '@humanlayer/fold-core' import { Predicate, Effect, FileSystem, Layer, Match, Schema, Semaphore, type Scope } from 'effect' -import { loadModelCatalog } from '../Catalog/LoadCatalog' -import { agentModelsFromConfig, type EnvLookup, type RoleResolutionError } from '../Config/AgentModels' +import { loadModelCatalog, type LoadModelCatalogOptions } from '../Catalog/LoadCatalog' +import { + agentModelsFromConfig, + type AgentModelsOptions, + type EnvLookup, + type RoleResolutionError, +} from '../Config/AgentModels' import type { ConfigRole, ProfileModeName, RoleBinding, FoldConfig } from '../Config/ConfigSchema' import { defaultFoldHome, @@ -46,17 +55,19 @@ import { type ConfigDecodeError, type ConfigFileNotFoundError, type ConfigParseError, + type LoadConfigOptions, } from '../Config/Load' import { rolesForDirectProviderSelection } from '../Config/ModelSelections' import { jsonlEventLog } from '../EventLog/JsonlDescriptor' -import { memoryPromptBlock } from '../Memory/AgentFiles' -import { makeOutputStore, type OutputStoreService } from '../OutputStore/OutputStore' +import { memoryPromptBlock, type AgentFilesOptions } from '../Memory/AgentFiles' +import { makeOutputStore, type MakeOutputStoreOptions, type OutputStoreService } from '../OutputStore/OutputStore' import { latestSessionLog, prepareSessionLog, refreshSessionSummaryIndex, sessionLogById, type SessionLogRef, + type SessionLayoutOptions, } from '../Session/SessionLayout' import { generateSessionTitle } from '../Session/TitleGenerator' import { compactionArchiveAccessFor } from './CompactionArchiveAccess' @@ -65,6 +76,20 @@ import { modeForName } from './ModeName' import { RPI_HINT_PROMPT } from './Rpi' import type { ModeModels } from './Subagents' +type Mutable = { -readonly [Key in keyof Value]: Value[Key] } + +type RolesBuilder = { + smart: RoleBinding + fast: RoleBinding + orchestrator?: RoleBinding +} + +type DirectProviderSelectionBuilder = { + provider: string + model?: string + reasoning?: ReasoningLevel +} + /** A `--profile` name that is not defined under the config's `profiles` map. */ export class UnknownProfileError extends Schema.TaggedError()('UnknownProfileError', { profile: Schema.String, @@ -199,8 +224,14 @@ const resolveProfileSelection = ( Effect.gen(function* () { if (opts.profile === undefined) return { options: opts, profileMode: null } - const config = - opts.config ?? (yield* loadFoldConfig(opts.foldHome === undefined ? {} : { foldHome: opts.foldHome })) + let config: FoldConfig + if (opts.config === undefined) { + const configOptions: Mutable = {} + if (opts.foldHome !== undefined) configOptions.foldHome = opts.foldHome + config = yield* loadFoldConfig(configOptions) + } else { + config = opts.config + } const profile = config.profiles?.[opts.profile] if (profile === undefined) { return yield* new UnknownProfileError({ @@ -209,11 +240,8 @@ const resolveProfileSelection = ( }) } - const roles = { - smart: profile.smart, - fast: profile.fast, - ...(profile.orchestrator === undefined ? {} : { orchestrator: profile.orchestrator }), - } + const roles: RolesBuilder = { smart: profile.smart, fast: profile.fast } + if (profile.orchestrator !== undefined) roles.orchestrator = profile.orchestrator return { options: { ...opts, config: { ...config, roles } }, profileMode: profile.mode ?? null } }) @@ -245,11 +273,12 @@ export const mergeModelSelection = (config: FoldConfig, base: RoleBinding, selec const model = selection.model ?? (providerKindChanged ? undefined : base.model) const reasoning = selection.reasoning ?? base.reasoning - return { + const binding: { provider: string; model?: string; reasoning?: NonNullable } = { provider, - ...(model === undefined ? {} : { model }), - ...(reasoning === undefined ? {} : { reasoning }), } + if (model !== undefined) binding.model = model + if (reasoning !== undefined) binding.reasoning = reasoning + return binding } const withSelectedRoleBinding = (config: FoldConfig, role: ConfigRole, binding: RoleBinding): FoldConfig => ({ @@ -284,30 +313,33 @@ const resolveModeModels = ( const selection = options.modelSelection ?? {} const role = selection.role ?? mode.role - const config = - options.config ?? - (yield* loadFoldConfig(options.foldHome === undefined ? {} : { foldHome: options.foldHome })) - const selectedConfig = - selection.provider !== undefined - ? { - ...config, - roles: rolesForDirectProviderSelection(config, role, { - provider: selection.provider, - ...(selection.model === undefined ? {} : { model: selection.model }), - ...(selection.reasoning === undefined ? {} : { reasoning: selection.reasoning }), - }), - } - : selection.model === undefined && selection.reasoning === undefined - ? config - : withSelectedRoleBinding( - config, - role, - mergeModelSelection(config, roleBindingFor(config, role), selection), - ) - const models = agentModelsFromConfig(selectedConfig, { - ...(options.env === undefined ? {} : { env: options.env }), - catalog, - }) + let config: FoldConfig + if (options.config === undefined) { + const configOptions: Mutable = {} + if (options.foldHome !== undefined) configOptions.foldHome = options.foldHome + config = yield* loadFoldConfig(configOptions) + } else { + config = options.config + } + let selectedConfig = config + if (selection.provider !== undefined) { + const directProviderSelection: DirectProviderSelectionBuilder = { provider: selection.provider } + if (selection.model !== undefined) directProviderSelection.model = selection.model + if (selection.reasoning !== undefined) directProviderSelection.reasoning = selection.reasoning + selectedConfig = { + ...config, + roles: rolesForDirectProviderSelection(config, role, directProviderSelection), + } + } else if (selection.model !== undefined || selection.reasoning !== undefined) { + selectedConfig = withSelectedRoleBinding( + config, + role, + mergeModelSelection(config, roleBindingFor(config, role), selection), + ) + } + const modelOptions: Mutable = { catalog } + if (options.env !== undefined) modelOptions.env = options.env + const models = agentModelsFromConfig(selectedConfig, modelOptions) return { primary: yield* models.resolve(role), @@ -341,10 +373,9 @@ const buildAgentDefinition = ( outputStore: OutputStoreService, ): Effect.Effect => Effect.gen(function* () { - const memoryBlock = yield* memoryPromptBlock({ - cwd, - ...(options.home === undefined ? {} : { home: options.home }), - }) + const memoryOptions: Mutable = { cwd } + if (options.home !== undefined) memoryOptions.home = options.home + const memoryBlock = yield* memoryPromptBlock(memoryOptions) // Effective RPI: the flag, or the mode's own default (RLM always carries the specialists). const rpi = options.rpi === true || mode.rpiByDefault === true const tools = [...mode.buildTools({ cwd, models, rpi, outputStore }), ...(options.extraTools ?? [])] @@ -356,15 +387,15 @@ const buildAgentDefinition = ( foldInfoBlock(options.foldHome ?? defaultFoldHome()), ] const autoCompact = options.autoCompact ?? config?.compaction ?? defaultAutoCompact - - return defineAgent({ + const agentOptions: Mutable = { name: options.name ?? mode.name, model: models.primary, tools, - ...(blocks.length === 0 ? {} : { systemPrompt: blocks }), autoCompact, stopConditions: options.stopConditions ?? config?.stopConditions ?? defaultStopConditions, - }) + } + if (blocks.length > 0) agentOptions.systemPrompt = blocks + return defineAgent(agentOptions) }) /** @@ -396,19 +427,19 @@ export const switchSessionMode = ( const catalog = yield* catalogFor(profiled) const models = yield* resolveModeModels(profiled, mode, catalog) const config = yield* runtimeConfigFor(profiled) - const outputStore = yield* makeOutputStore({ - sessionId: session.sessionId, - ...(profiled.foldHome === undefined ? {} : { foldHome: profiled.foldHome }), - }) + const outputStoreOptions: Mutable = { sessionId: session.sessionId } + if (profiled.foldHome !== undefined) outputStoreOptions.foldHome = profiled.foldHome + const outputStore = yield* makeOutputStore(outputStoreOptions) yield* outputStore.sweep const agent = yield* buildAgentDefinition(profiled, mode, models, cwd, config, outputStore) - yield* session.switchModel(models.primary, { - ...(agent.systemPrompt === undefined ? {} : { systemPrompt: agent.systemPrompt }), - ...(agent.tools === undefined ? {} : { tools: agent.tools }), + const switchOptions: Mutable = { reason: options.reason ?? `switch mode to ${mode.name}`, profiles: sessionProfilesFor(models), - }) + } + if (agent.systemPrompt !== undefined) switchOptions.systemPrompt = agent.systemPrompt + if (agent.tools !== undefined) switchOptions.tools = agent.tools + yield* session.switchModel(models.primary, switchOptions) }) const withGeneratedTitles = ( @@ -444,21 +475,20 @@ const withGeneratedTitles = ( return generateSessionTitle(entries, session.rootAgentId, model).pipe( Effect.flatMap((title) => { const generatedThroughSeq = entries.at(-1)?.seq - return session - .setTitle(title, { - ...(generatedThroughSeq === undefined - ? {} - : { generatedThroughSeq }), - rootUserTurns: rootUsers.length, - }) - .pipe( - Effect.andThen( - refreshSessionSummaryIndex( - session.sessionId, - options, - ).pipe(Effect.provide(fsLayer)), + const provenance: { + rootUserTurns: number + generatedThroughSeq?: LogSeq + } = { rootUserTurns: rootUsers.length } + if (generatedThroughSeq !== undefined) + provenance.generatedThroughSeq = generatedThroughSeq + const setTitle = session.setTitle(title, provenance) + return setTitle.pipe( + Effect.andThen( + refreshSessionSummaryIndex(session.sessionId, options).pipe( + Effect.provide(fsLayer), ), - ) + ), + ) }), ) }), @@ -477,19 +507,21 @@ const runtimeConfigFor = ( if (options.config !== undefined) return Effect.succeed(options.config) if (options.model !== undefined) return Effect.succeed(null) - return loadFoldConfig(options.foldHome === undefined ? {} : { foldHome: options.foldHome }) + const configOptions: Mutable = {} + if (options.foldHome !== undefined) configOptions.foldHome = options.foldHome + return loadFoldConfig(configOptions) } /** The catalog for a launch: the caller's (the CLI loads once), else a fresh load (never fails). */ const catalogFor = ( options: LaunchSessionOptions, -): Effect.Effect, never, FileSystem.FileSystem> => - options.catalog !== undefined - ? Effect.succeed(options.catalog) - : loadModelCatalog({ - foldHome: options.foldHome ?? defaultFoldHome(), - ...(options.env === undefined ? {} : { env: options.env }), - }) +): Effect.Effect, never, FileSystem.FileSystem> => { + if (options.catalog !== undefined) return Effect.succeed(options.catalog) + + const catalogOptions: Mutable = { foldHome: options.foldHome ?? defaultFoldHome() } + if (options.env !== undefined) catalogOptions.env = options.env + return loadModelCatalog(catalogOptions) +} /** * Start a fresh coding session: resolve the model, load agentfiles, build the mode's tools, and @@ -507,18 +539,16 @@ export const launchSession = ( const catalog = yield* catalogFor(opts) const models = yield* resolveModeModels(opts, mode, catalog) const config = yield* runtimeConfigFor(opts) - const prepared = yield* prepareSessionLog({ - cwd, - ...(opts.foldHome === undefined ? {} : { foldHome: opts.foldHome }), - }) - const outputStore = yield* makeOutputStore({ - sessionId: prepared.sessionId, - ...(opts.foldHome === undefined ? {} : { foldHome: opts.foldHome }), - }) + const prepareOptions: Mutable = { cwd } + if (opts.foldHome !== undefined) prepareOptions.foldHome = opts.foldHome + const prepared = yield* prepareSessionLog(prepareOptions) + const outputStoreOptions: Mutable = { sessionId: prepared.sessionId } + if (opts.foldHome !== undefined) outputStoreOptions.foldHome = opts.foldHome + const outputStore = yield* makeOutputStore(outputStoreOptions) yield* outputStore.sweep const agent = yield* buildAgentDefinition(opts, mode, models, cwd, config, outputStore) - const session = yield* startSession({ + const startOptions: Mutable = { agent, log: prepared.log, cwd, @@ -531,12 +561,12 @@ export const launchSession = ( profiles: sessionProfilesFor(models), catalog, compactionArchiveAccess: compactionArchiveAccessFor({ logPath: prepared.path, modeName: mode.name }), - ...(opts.steering === undefined ? {} : { steering: opts.steering }), - }) - return yield* withGeneratedTitles(session, models.fast, { - cwd, - ...(opts.foldHome === undefined ? {} : { foldHome: opts.foldHome }), - }) + } + if (opts.steering !== undefined) startOptions.steering = opts.steering + const session = yield* startSession(startOptions) + const titleOptions: { cwd: string; foldHome?: string } = { cwd } + if (opts.foldHome !== undefined) titleOptions.foldHome = opts.foldHome + return yield* withGeneratedTitles(session, models.fast, titleOptions) }) const resumeFromLog = ( @@ -550,25 +580,24 @@ const resumeFromLog = ( const catalog = yield* catalogFor(options) const models = yield* resolveModeModels(options, mode, catalog) const config = yield* runtimeConfigFor(options) - const outputStore = yield* makeOutputStore({ - sessionId: log.sessionId, - ...(options.foldHome === undefined ? {} : { foldHome: options.foldHome }), - }) + const outputStoreOptions: Mutable = { sessionId: log.sessionId } + if (options.foldHome !== undefined) outputStoreOptions.foldHome = options.foldHome + const outputStore = yield* makeOutputStore(outputStoreOptions) yield* outputStore.sweep const agent = yield* buildAgentDefinition(options, mode, models, cwd, config, outputStore) - const session = yield* resumeSession({ + const resumeOptions: Mutable = { agent, log: jsonlEventLog(log.path), profiles: sessionProfilesFor(models), catalog, compactionArchiveAccess: compactionArchiveAccessFor({ logPath: log.path, modeName: mode.name }), - ...(options.steering === undefined ? {} : { steering: options.steering }), - }) - return yield* withGeneratedTitles(session, models.fast, { - cwd, - ...(options.foldHome === undefined ? {} : { foldHome: options.foldHome }), - }) + } + if (options.steering !== undefined) resumeOptions.steering = options.steering + const session = yield* resumeSession(resumeOptions) + const sessionLayoutOptions: { cwd: string; foldHome?: string } = { cwd } + if (options.foldHome !== undefined) sessionLayoutOptions.foldHome = options.foldHome + return yield* withGeneratedTitles(session, models.fast, sessionLayoutOptions) }) /** @@ -584,10 +613,9 @@ export const resumeLatestSession = ( const mode = modeFor(opts, profileMode) const cwd = opts.cwd ?? process.cwd() - const latest = yield* latestSessionLog({ - cwd, - ...(opts.foldHome === undefined ? {} : { foldHome: opts.foldHome }), - }) + const sessionLayoutOptions: Mutable = { cwd } + if (opts.foldHome !== undefined) sessionLayoutOptions.foldHome = opts.foldHome + const latest = yield* latestSessionLog(sessionLayoutOptions) if (latest === null) return yield* new NoSessionToResumeError({ cwd }) return yield* resumeFromLog(latest, opts, mode, cwd) @@ -607,10 +635,9 @@ export const resumeSessionById = ( const mode = modeFor(opts, profileMode) const cwd = opts.cwd ?? process.cwd() - const log = yield* sessionLogById(sessionId, { - cwd, - ...(opts.foldHome === undefined ? {} : { foldHome: opts.foldHome }), - }) + const sessionLayoutOptions: Mutable = { cwd } + if (opts.foldHome !== undefined) sessionLayoutOptions.foldHome = opts.foldHome + const log = yield* sessionLogById(sessionId, sessionLayoutOptions) if (log === null) return yield* new SessionToResumeNotFoundError({ cwd, sessionId }) return yield* resumeFromLog(log, opts, mode, cwd) diff --git a/packages/fold-agent/src/Mode/Mode.ts b/packages/fold-agent/src/Mode/Mode.ts index 743e6d0..8bbe129 100644 --- a/packages/fold-agent/src/Mode/Mode.ts +++ b/packages/fold-agent/src/Mode/Mode.ts @@ -68,9 +68,15 @@ export const defaultCodingMode: FoldMode = { name: 'coding', role: 'smart', systemPrompt: DEFAULT_CODING_PROMPT, - buildTools: ({ cwd, rpi, outputStore }) => [ - ...codingTools({ cwd, ...(outputStore === undefined ? {} : { outputStore }) }), - skillTool(skillsFromDisk({ cwd })), - subagentTool(modeSubagents({ cwd, rpi, ...(outputStore === undefined ? {} : { outputStore }) })), - ], + buildTools: ({ cwd, rpi, outputStore }) => { + const codingOptions: { cwd: string; outputStore?: OutputStoreService } = { cwd } + if (outputStore !== undefined) codingOptions.outputStore = outputStore + const subagentOptions: { cwd: string; rpi: boolean; outputStore?: OutputStoreService } = { cwd, rpi } + if (outputStore !== undefined) subagentOptions.outputStore = outputStore + return [ + ...codingTools(codingOptions), + skillTool(skillsFromDisk({ cwd })), + subagentTool(modeSubagents(subagentOptions)), + ] + }, } diff --git a/packages/fold-agent/src/Mode/Rlm.ts b/packages/fold-agent/src/Mode/Rlm.ts index 41ef3d4..85e39b9 100644 --- a/packages/fold-agent/src/Mode/Rlm.ts +++ b/packages/fold-agent/src/Mode/Rlm.ts @@ -10,6 +10,7 @@ */ import { skillTool, subagentTool } from '@humanlayer/fold-core' +import type { OutputStoreService } from '../OutputStore/OutputStore' import { skillsFromDisk } from '../Skills/DiskSkills' import { applyPatchTool } from '../Tools/ApplyPatchTool' import { editTool } from '../Tools/EditTool' @@ -57,12 +58,16 @@ export const rlmMode: FoldMode = { // RLM always carries the RPI specialists (user ruling 2026-07-09): an orchestrator with no bash // lives and dies by the quality of its delegates, so the full specialist roster is the default. rpiByDefault: true, - buildTools: ({ cwd, rpi, outputStore }) => [ - readTool({ cwd }), - writeTool({ cwd }), - editTool({ cwd }), - applyPatchTool({ cwd }), - skillTool(skillsFromDisk({ cwd })), - subagentTool(modeSubagents({ cwd, rpi, ...(outputStore === undefined ? {} : { outputStore }) })), - ], + buildTools: ({ cwd, rpi, outputStore }) => { + const subagentOptions: { cwd: string; rpi: boolean; outputStore?: OutputStoreService } = { cwd, rpi } + if (outputStore !== undefined) subagentOptions.outputStore = outputStore + return [ + readTool({ cwd }), + writeTool({ cwd }), + editTool({ cwd }), + applyPatchTool({ cwd }), + skillTool(skillsFromDisk({ cwd })), + subagentTool(modeSubagents(subagentOptions)), + ] + }, } diff --git a/packages/fold-agent/src/Mode/Rpi.ts b/packages/fold-agent/src/Mode/Rpi.ts index 94a8104..e12da47 100644 --- a/packages/fold-agent/src/Mode/Rpi.ts +++ b/packages/fold-agent/src/Mode/Rpi.ts @@ -662,10 +662,11 @@ export const rpiSubagents = ({ delegates, }: RpiSubagentOptions): ReadonlyArray => { const read = readTool({ cwd }) - const bashOptions = { cwd, ...(outputStore === undefined ? {} : { outputStore }) } - const bash = bashTool(bashOptions) + const toolOptions: { cwd: string; outputStore?: OutputStoreService } = { cwd } + if (outputStore !== undefined) toolOptions.outputStore = outputStore + const bash = bashTool(toolOptions) const readAndBash = [read, bash] - const coding = codingTools(bashOptions) + const coding = codingTools(toolOptions) const implementerDelegates = subagentTool([delegates.bash, delegates.generalPurpose]) const codebaseLocator = defineSubagent({ @@ -764,18 +765,20 @@ const delegateByName = (roster: ReadonlyArray, name: string) * Shared by `defaultCodingMode` and `rlmMode` so the roster composition never diverges between modes. */ export const modeSubagents = ({ cwd, outputStore, rpi }: ModeSubagentOptions): ReadonlyArray => { - const roster = defaultSubagents({ cwd, ...(outputStore === undefined ? {} : { outputStore }) }) + const rosterOptions: { cwd: string; outputStore?: OutputStoreService } = { cwd } + if (outputStore !== undefined) rosterOptions.outputStore = outputStore + const roster = defaultSubagents(rosterOptions) if (!rpi) return roster - return [ - ...roster, - ...rpiSubagents({ - cwd, - ...(outputStore === undefined ? {} : { outputStore }), - delegates: { - bash: delegateByName(roster, 'bash'), - generalPurpose: delegateByName(roster, 'general-purpose'), - }, - }), - ] + const delegates = { + bash: delegateByName(roster, 'bash'), + generalPurpose: delegateByName(roster, 'general-purpose'), + } + const specialistOptions: { + cwd: string + outputStore?: OutputStoreService + delegates: RpiSubagentOptions['delegates'] + } = { cwd, delegates } + if (outputStore !== undefined) specialistOptions.outputStore = outputStore + return [...roster, ...rpiSubagents(specialistOptions)] } diff --git a/packages/fold-agent/src/Mode/Subagents.ts b/packages/fold-agent/src/Mode/Subagents.ts index bef87a4..36946de 100644 --- a/packages/fold-agent/src/Mode/Subagents.ts +++ b/packages/fold-agent/src/Mode/Subagents.ts @@ -153,10 +153,11 @@ export const WEB_SEARCH_RESEARCHER_PROMPT: string = * binds its model by profile role, resolved through the session's profiles map at each dispatch. */ export const defaultSubagents = ({ cwd, outputStore }: SubagentRosterOptions): ReadonlyArray => { - const coding = codingTools({ cwd, ...(outputStore === undefined ? {} : { outputStore }) }) + const toolOptions: { cwd: string; outputStore?: OutputStoreService } = { cwd } + if (outputStore !== undefined) toolOptions.outputStore = outputStore + const coding = codingTools(toolOptions) const skills = skillTool(skillsFromDisk({ cwd })) const web = webTools() - const bashOptions = { cwd, ...(outputStore === undefined ? {} : { outputStore }) } const bash = defineSubagent({ name: 'bash', @@ -164,7 +165,7 @@ export const defaultSubagents = ({ cwd, outputStore }: SubagentRosterOptions): R 'Run shell commands (builds, tests, git, rg searches) and report the commands, exit status, and ' + 'the output that matters. Use it to execute something without spending your own context on raw output.', systemPrompt: BASH_SUBAGENT_PROMPT, - tools: [bashTool(bashOptions)], + tools: [bashTool(toolOptions)], model: 'fast', }) @@ -176,7 +177,7 @@ export const defaultSubagents = ({ cwd, outputStore }: SubagentRosterOptions): R 'Locate code and explain how it works, returning a structured report with file:line references. ' + 'Use it for "where is X" and "how does Y work" questions that would otherwise require reading many files.', systemPrompt: [RESEARCHER_SUBAGENT_PROMPT, AST_GREP_OUTLINE_GUIDANCE], - tools: [readTool({ cwd }), bashTool(bashOptions), skills], + tools: [readTool({ cwd }), bashTool(toolOptions), skills], model: 'fast', }) diff --git a/packages/fold-agent/src/Session/SessionLayout.ts b/packages/fold-agent/src/Session/SessionLayout.ts index f3d576d..fc7e535 100644 --- a/packages/fold-agent/src/Session/SessionLayout.ts +++ b/packages/fold-agent/src/Session/SessionLayout.ts @@ -17,6 +17,8 @@ import { Predicate, Clock, Effect, Exit, FileSystem, Match, Option, Schema, Stre import { jsonlEventLog } from '../EventLog/JsonlDescriptor' import { toolOutputSessionDirFor } from '../OutputStore/OutputStore' +type Mutable = { -readonly [Key in keyof Value]: Value[Key] } + /** Options shared by the layout helpers. */ export type SessionLayoutOptions = { /** The project working directory the sessions belong to. Defaults to `process.cwd()`. */ @@ -335,11 +337,10 @@ export const listSessionSummaries = ( if (isCacheHit(cached, ref)) { // Explicitly construct to ensure size conforms to SessionLogRef's optional semantics. const summary = cached.summary - return Effect.succeed({ + const cachedSummary: Mutable = { sessionId: summary.sessionId, path: ref.path, mtimeMs: ref.mtimeMs, - ...(ref.size === undefined ? {} : { size: ref.size }), title: summary.title, status: summary.status, turns: summary.turns, @@ -350,7 +351,9 @@ export const listSessionSummaries = ( mode: summary.mode, rpi: summary.rpi, profile: summary.profile, - }) + } + if (ref.size !== undefined) cachedSummary.size = ref.size + return Effect.succeed(cachedSummary) } return loadSessionSummary(ref).pipe( Effect.tap((summary) => diff --git a/packages/fold-agent/src/Tools/WebSearchTool.ts b/packages/fold-agent/src/Tools/WebSearchTool.ts index b372b51..531dc84 100644 --- a/packages/fold-agent/src/Tools/WebSearchTool.ts +++ b/packages/fold-agent/src/Tools/WebSearchTool.ts @@ -30,10 +30,9 @@ const resolveExaUrl = (options?: WebSearchToolOptions): string => { const resolveParallelHeaders = (options?: WebSearchToolOptions): Record => { const apiKey = options?.parallelApiKey ?? resolveEnv(options, 'PARALLEL_API_KEY') - return { - 'User-Agent': 'fold/1.0', - ...(apiKey === undefined || apiKey.length === 0 ? {} : { Authorization: `Bearer ${apiKey}` }), - } + const headers: { 'User-Agent': string; Authorization?: string } = { 'User-Agent': 'fold/1.0' } + if (apiKey !== undefined && apiKey.length > 0) headers.Authorization = `Bearer ${apiKey}` + return headers } const checksum = (text: string): number => { diff --git a/packages/fold-agent/test/Config/ConfigProfiles.vi.test.ts b/packages/fold-agent/test/Config/ConfigProfiles.vi.test.ts index 2749e52..3482900 100644 --- a/packages/fold-agent/test/Config/ConfigProfiles.vi.test.ts +++ b/packages/fold-agent/test/Config/ConfigProfiles.vi.test.ts @@ -92,14 +92,14 @@ const profileBindings = (profile: ProfileConfig): ReadonlyArray => ] /** Substitute one profile's roles as the config's active roles (what --profile does at launch). */ -const withProfileRoles = (config: FoldConfig, profile: ProfileConfig): FoldConfig => ({ - ...config, - roles: { +const withProfileRoles = (config: FoldConfig, profile: ProfileConfig): FoldConfig => { + const roles: { smart: RoleBinding; fast: RoleBinding; orchestrator?: RoleBinding } = { smart: profile.smart, fast: profile.fast, - ...(profile.orchestrator === undefined ? {} : { orchestrator: profile.orchestrator }), - }, -}) + } + if (profile.orchestrator !== undefined) roles.orchestrator = profile.orchestrator + return { ...config, roles } +} it.effect('the starter config ships the ultraclaude, powerclaude, and ultracodex everything-max RLM presets', () => Effect.gen(function* () { diff --git a/packages/fold-cli/src/Commands.ts b/packages/fold-cli/src/Commands.ts index 4d1e62e..731d027 100644 --- a/packages/fold-cli/src/Commands.ts +++ b/packages/fold-cli/src/Commands.ts @@ -39,6 +39,8 @@ import { ResumeTarget, runPrompt, type CliSessionOptions } from './Run' declare const FOLD_VERSION: string const version = typeof FOLD_VERSION === 'string' ? FOLD_VERSION : '0.0.0' +type Mutable = { -readonly [Key in keyof Type]: Type[Key] } + const decodeSessionId = Schema.decodeUnknownOption(SessionId) /** The sentinel `--resume` value selecting the newest session log for the working directory. */ @@ -201,7 +203,9 @@ const providerId = (provider: Option.Option, fallback: string): string = const providerAuthStoreOptions = (provider: Option.Option, foldHome: string | undefined, fallback: string) => { const path = authStorePath(foldHome) - return { providerId: providerId(provider, fallback), ...(path === undefined ? {} : { path }) } + const options: Mutable = { providerId: providerId(provider, fallback) } + if (path !== undefined) options.path = path + return options } const codexAuthStoreOptions = ( @@ -209,7 +213,9 @@ const codexAuthStoreOptions = ( foldHome: string | undefined, ): MakeCodexAuthStoreOptions => { const path = authStorePath(foldHome) - return { providerId: codexProviderId(provider), ...(path === undefined ? {} : { path }) } + const options: Mutable = { providerId: codexProviderId(provider) } + if (path !== undefined) options.path = path + return options } const browserOpenCommand = (url: string): { readonly command: string; readonly args: ReadonlyArray } => { @@ -269,14 +275,14 @@ const modelSelectionFromFlags = (input: { const model = optionValue(input.model) const reasoning = optionValue(input.reasoning) - return role === undefined && provider === undefined && model === undefined && reasoning === undefined - ? undefined - : { - ...(role === undefined ? {} : { role }), - ...(provider === undefined ? {} : { provider }), - ...(model === undefined ? {} : { model }), - ...(reasoning === undefined ? {} : { reasoning }), - } + if (role === undefined && provider === undefined && model === undefined && reasoning === undefined) return undefined + + const selection: Mutable = {} + if (role !== undefined) selection.role = role + if (provider !== undefined) selection.provider = provider + if (model !== undefined) selection.model = model + if (reasoning !== undefined) selection.reasoning = reasoning + return selection } const autoCompactFromFlags = (input: CommonFlagValues): AutoCompactConfig | undefined => { @@ -293,15 +299,14 @@ const autoCompactFromFlags = (input: CommonFlagValues): AutoCompactConfig | unde reserveTokens !== undefined || keepRecentTokens !== undefined - return hasCompactionOptions - ? { - enabled: true, - ...(compactionPrompt === undefined ? {} : { compactionPrompt }), - ...(thresholdTokens === undefined ? {} : { thresholdTokens }), - ...(reserveTokens === undefined ? {} : { reserveTokens }), - ...(keepRecentTokens === undefined ? {} : { keepRecentTokens }), - } - : undefined + if (!hasCompactionOptions) return undefined + + const autoCompact: Mutable = { enabled: true } + if (compactionPrompt !== undefined) autoCompact.compactionPrompt = compactionPrompt + if (thresholdTokens !== undefined) autoCompact.thresholdTokens = thresholdTokens + if (reserveTokens !== undefined) autoCompact.reserveTokens = reserveTokens + if (keepRecentTokens !== undefined) autoCompact.keepRecentTokens = keepRecentTokens + return autoCompact } /** CLI rendering mode selected by `--output*` flags. */ @@ -333,16 +338,15 @@ export const sessionOptionsFromFlags = ( const modelSelection = modelSelectionFromFlags(input) const autoCompact = autoCompactFromFlags(input) - return { - cwd: optionValue(input.cwd) ?? process.cwd(), - ...(foldHome === undefined ? {} : { foldHome }), - ...(profile === undefined ? {} : { profile }), - ...(mode === undefined ? {} : { mode }), - ...(input.rpi ? { rpi: true } : {}), - ...(resume === undefined ? {} : { resume }), - ...(modelSelection === undefined ? {} : { modelSelection }), - ...(autoCompact === undefined ? {} : { autoCompact }), - } + const options: Mutable = { cwd: optionValue(input.cwd) ?? process.cwd() } + if (foldHome !== undefined) options.foldHome = foldHome + if (profile !== undefined) options.profile = profile + if (mode !== undefined) options.mode = mode + if (input.rpi) options.rpi = true + if (resume !== undefined) options.resume = resume + if (modelSelection !== undefined) options.modelSelection = modelSelection + if (autoCompact !== undefined) options.autoCompact = autoCompact + return options }) const run = Command.make('foldcode', commonFlags, (input) => @@ -402,7 +406,9 @@ const launchTui = (options: CliSessionOptions, catalog: ReadonlyArray import('@opentui/solid/preload')) const module = yield* Effect.promise(() => import('./tui/Shell')) - yield* module.runTui({ ...options, catalog, ...(prompt === undefined ? {} : { prompt }) }).pipe( + const tuiOptions: Mutable[0]> = { ...options, catalog } + if (prompt !== undefined) tuiOptions.prompt = prompt + yield* module.runTui(tuiOptions).pipe( Effect.catchTags({ TuiRequiresTtyError: () => printFailure( @@ -447,7 +453,9 @@ const sessions = Command.make( Effect.gen(function* () { const cwd = optionValue(input.cwd) ?? process.cwd() const foldHome = optionValue(input.foldHome) - const sessions = yield* listSessionLogs({ cwd, ...(foldHome === undefined ? {} : { foldHome }) }) + const sessionOptions: Mutable[0]>> = { cwd } + if (foldHome !== undefined) sessionOptions.foldHome = foldHome + const sessions = yield* listSessionLogs(sessionOptions) if (sessions.length === 0) { yield* Console.log(`No fold sessions for ${cwd}`) return @@ -509,20 +517,18 @@ const config = Command.make('config').pipe( const apiKeyEnv = optionValue(input.apiKeyEnv) const model = optionValue(input.model) const foldHome = optionValue(input.foldHome) - yield* configureProvider( - { - name: input.name, - kind: input.kind, - baseUrl: input.baseUrl, - ...(apiKey === undefined ? {} : { apiKey }), - ...(apiKeyEnv === undefined ? {} : { apiKeyEnv }), - ...(model === undefined ? {} : { model }), - }, - foldHome === undefined ? {} : { foldHome }, - ) - yield* Console.log( - `Saved provider "${input.name}" in ${configPathFor(foldHome === undefined ? {} : { foldHome })}`, - ) + const provider: Mutable[0]> = { + name: input.name, + kind: input.kind, + baseUrl: input.baseUrl, + } + if (apiKey !== undefined) provider.apiKey = apiKey + if (apiKeyEnv !== undefined) provider.apiKeyEnv = apiKeyEnv + if (model !== undefined) provider.model = model + const configOptions: Mutable[1]>> = {} + if (foldHome !== undefined) configOptions.foldHome = foldHome + yield* configureProvider(provider, configOptions) + yield* Console.log(`Saved provider "${input.name}" in ${configPathFor(configOptions)}`) }), ).pipe( Command.withDescription('Add or replace an Anthropic/OpenAI-compatible URL and credential'), @@ -539,7 +545,9 @@ const config = Command.make('config').pipe( Command.make('init', { foldHome: commonFlags.foldHome }, (input) => Effect.gen(function* () { const foldHome = optionValue(input.foldHome) - const result = yield* configInit(foldHome === undefined ? {} : { foldHome }) + const configOptions: Mutable[0]>> = {} + if (foldHome !== undefined) configOptions.foldHome = foldHome + const result = yield* configInit(configOptions) yield* Console.log(`${result.createdConfig ? 'Created' : 'Found'} ${result.configPath}`) yield* Console.log(`${result.createdAuth ? 'Created' : 'Found'} ${result.authPath}`) yield* Console.log(`Wrote ${result.schemaPath}`) @@ -549,8 +557,10 @@ const config = Command.make('config').pipe( Command.make('validate', { foldHome: commonFlags.foldHome }, (input) => Effect.gen(function* () { const foldHome = optionValue(input.foldHome) - yield* loadFoldConfig(foldHome === undefined ? {} : { foldHome }) - yield* Console.log(`Valid ${configPathFor(foldHome === undefined ? {} : { foldHome })}`) + const configOptions: Mutable[0]>> = {} + if (foldHome !== undefined) configOptions.foldHome = foldHome + yield* loadFoldConfig(configOptions) + yield* Console.log(`Valid ${configPathFor(configOptions)}`) }), ).pipe(Command.withDescription('Validate ~/.fold/config.jsonc')), ]), diff --git a/packages/fold-cli/src/Run.ts b/packages/fold-cli/src/Run.ts index be26771..5edd9df 100644 --- a/packages/fold-cli/src/Run.ts +++ b/packages/fold-cli/src/Run.ts @@ -84,16 +84,19 @@ type OpenedSession = { type OpenSessionError = LaunchModelError | SessionToResumeNotFoundError | NoSessionToResumeError -const launchOptions = (options: CliSessionOptions) => ({ - cwd: options.cwd, - ...(options.foldHome === undefined ? {} : { foldHome: options.foldHome }), - ...(options.mode === undefined ? {} : { mode: modeForName(options.mode) }), - ...(options.rpi === true ? { rpi: true } : {}), - ...(options.profile === undefined ? {} : { profile: options.profile }), - ...(options.modelSelection === undefined ? {} : { modelSelection: options.modelSelection }), - ...(options.autoCompact === undefined ? {} : { autoCompact: options.autoCompact }), - ...(options.catalog === undefined ? {} : { catalog: options.catalog }), -}) +type Mutable = { -readonly [Key in keyof Type]: Type[Key] } + +const launchOptions = (options: CliSessionOptions) => { + const launch: Mutable[0]> = { cwd: options.cwd } + if (options.foldHome !== undefined) launch.foldHome = options.foldHome + if (options.mode !== undefined) launch.mode = modeForName(options.mode) + if (options.rpi === true) launch.rpi = true + if (options.profile !== undefined) launch.profile = options.profile + if (options.modelSelection !== undefined) launch.modelSelection = options.modelSelection + if (options.autoCompact !== undefined) launch.autoCompact = options.autoCompact + if (options.catalog !== undefined) launch.catalog = options.catalog + return launch +} /** Start fresh, resume the project's newest log, or adopt one exact session id. */ const openSessionFor = ( @@ -112,10 +115,9 @@ const openSession = ( ): Effect.Effect => Effect.gen(function* () { const session = yield* openSessionFor(options) - const logPath = sessionLogPathFor(session.sessionId, { - cwd: options.cwd, - ...(options.foldHome === undefined ? {} : { foldHome: options.foldHome }), - }) + const logOptions: Mutable[1]>> = { cwd: options.cwd } + if (options.foldHome !== undefined) logOptions.foldHome = options.foldHome + const logPath = sessionLogPathFor(session.sessionId, logOptions) return { session, @@ -139,10 +141,9 @@ const credentialSummary = (model: ActiveModel | null, options: CliSessionOptions if (model === null) return CredentialSummary.unknown({ detail: 'no active model row found in the session log' }) if (model.providerKind === 'codex') { - const store = yield* makeCodexAuthStore({ - providerId: model.providerId, - ...(options.foldHome === undefined ? {} : { path: join(options.foldHome, 'auth.json') }), - }).pipe(Effect.provide(NodeFileSystem.layer)) + const authStoreOptions: Mutable[0]> = { providerId: model.providerId } + if (options.foldHome !== undefined) authStoreOptions.path = join(options.foldHome, 'auth.json') + const store = yield* makeCodexAuthStore(authStoreOptions).pipe(Effect.provide(NodeFileSystem.layer)) const token = yield* store.load if (Option.isNone(token)) { return CredentialSummary.missing({ detail: `entry "${model.providerId}" in ${store.path}` }) @@ -213,17 +214,18 @@ const sessionHeader = (opened: OpenedSession, options: CliSessionOptions): Effec const credential = yield* credentialSummary(model, options) const agentMode = agentModeLabel(options) - return { + const header: Mutable = { sessionId: opened.session.sessionId, cwd: options.cwd, logPath: opened.logPath, mode: opened.mode, - ...(agentMode === undefined ? {} : { agentMode }), - ...(options.profile === undefined ? {} : { profile: options.profile }), resumeFlags: resumeFlagsFor(options), model, credential, } + if (agentMode !== undefined) header.agentMode = agentMode + if (options.profile !== undefined) header.profile = options.profile + return header }) const renderLiveEvents = ( @@ -277,11 +279,14 @@ const withProcessSignals = ( * absent), and the regenerated `config.schema.json` + `FOLD_INFO.md`. Never fails a run - a broken * home surfaces as the launch's own config error moments later. */ -const bootstrapForRun = (options: CliSessionOptions): Effect.Effect => - bootstrapFoldHome(options.foldHome === undefined ? {} : { foldHome: options.foldHome }).pipe( +const bootstrapForRun = (options: CliSessionOptions): Effect.Effect => { + const bootstrapOptions: Mutable[0]>> = {} + if (options.foldHome !== undefined) bootstrapOptions.foldHome = options.foldHome + return bootstrapFoldHome(bootstrapOptions).pipe( Effect.asVoid, Effect.catchCause(() => Effect.void), ) +} const forkStartupEnsures = ( options: CliSessionOptions, diff --git a/packages/fold-cli/src/tui/ActivityIndicator.tsx b/packages/fold-cli/src/tui/ActivityIndicator.tsx index 9425e53..f0053d9 100644 --- a/packages/fold-cli/src/tui/ActivityIndicator.tsx +++ b/packages/fold-cli/src/tui/ActivityIndicator.tsx @@ -1,9 +1,11 @@ /** @jsxImportSource @opentui/solid */ -import { createSignal, onCleanup } from 'solid-js' +import type { TextProps } from '@opentui/solid' +import { createMemo, createSignal, onCleanup } from 'solid-js' import { theme } from './ThemeState' export type ActivityState = 'ready' | 'running' | 'compacting' | 'stopped' | 'error' +type Mutable = { -readonly [Key in keyof Type]: Type[Key] } const presentation = (state: ActivityState, frame: number): { readonly glyph: string; readonly color: string } => { switch (state) { @@ -37,13 +39,15 @@ export const ActivityIndicator = (props: { }, 180) onCleanup(() => clearInterval(timer)) - return ( - - {`${presentation(props.state, frame()).glyph} ${props.label ?? props.state.toUpperCase()}`} - - ) + const value = createMemo(() => presentation(props.state, frame())) + const textProps = createMemo(() => { + const text: Mutable> = { + fg: value().color, + wrapMode: 'none', + } + if (props.width !== undefined) text.width = props.width + return text + }) + + return {`${value().glyph} ${props.label ?? props.state.toUpperCase()}`} } diff --git a/packages/fold-cli/src/tui/HostedTuiSession.ts b/packages/fold-cli/src/tui/HostedTuiSession.ts index 5fafd39..97755ef 100644 --- a/packages/fold-cli/src/tui/HostedTuiSession.ts +++ b/packages/fold-cli/src/tui/HostedTuiSession.ts @@ -15,6 +15,8 @@ import { executeRootInputAction, unexpectedActionCauseNotice, type RootInputVerb import type { ModelSelectionRequest } from './ModelSelectionState' import { makeSessionStateFromEntries, reduceSessionEvents, type SessionState } from './SessionState' +type Mutable = { -readonly [Key in keyof Type]: Type[Key] } + export type HostedTuiSessionMetadata = { readonly cwd: string readonly profile: string @@ -175,25 +177,28 @@ export const makeHostedTuiSession = ( return } setNotice('APPLYING MODEL CONFIGURATION') + const switchOptions: Mutable[1]> = { + mode: modeForName(selection.mode ?? mode()), + cwd: options.metadata.cwd, + config, + reason: `TUI switch to ${selection.mode ?? mode()} mode`, + } + if (options.foldHome !== undefined) switchOptions.foldHome = options.foldHome + if (options.catalog !== undefined) switchOptions.catalog = options.catalog + if (options.rpi === true) switchOptions.rpi = true + if (selection._tag === 'profile') { + switchOptions.profile = selection.profile + } else { + const modelSelection: Mutable[1]['modelSelection']>> = + { + provider: selection.provider, + model: selection.model, + } + if (selection.reasoning !== undefined) modelSelection.reasoning = selection.reasoning + switchOptions.modelSelection = modelSelection + } run( - switchSessionMode(session, { - mode: modeForName(selection.mode ?? mode()), - cwd: options.metadata.cwd, - config, - ...(options.foldHome === undefined ? {} : { foldHome: options.foldHome }), - ...(options.catalog === undefined ? {} : { catalog: options.catalog }), - ...(options.rpi === true ? { rpi: true } : {}), - ...(selection._tag === 'profile' - ? { profile: selection.profile } - : { - modelSelection: { - provider: selection.provider, - model: selection.model, - ...(selection.reasoning === undefined ? {} : { reasoning: selection.reasoning }), - }, - }), - reason: `TUI switch to ${selection.mode ?? mode()} mode`, - }).pipe( + switchSessionMode(session, switchOptions).pipe( Effect.tap(() => Effect.sync(() => { setProfile(selection._tag === 'profile' ? selection.profile : 'direct') diff --git a/packages/fold-cli/src/tui/LaunchRequests.ts b/packages/fold-cli/src/tui/LaunchRequests.ts index 6bdd1c3..739e0c7 100644 --- a/packages/fold-cli/src/tui/LaunchRequests.ts +++ b/packages/fold-cli/src/tui/LaunchRequests.ts @@ -2,25 +2,25 @@ import type { NewSessionRequest } from './NewSessionModal' import type { SessionRow } from './SessionListProjection' import type { TuiOptions } from './TuiSessionOptions' +type Mutable = { -readonly [Key in keyof Type]: Type[Key] } + /** Build a fresh launch request without carrying process-level model, profile, or mode choices across sessions. */ export const requestToLaunchOptions = (options: TuiOptions, request: NewSessionRequest): TuiOptions => { const { profile: _profile, modelSelection: _modelSelection, mode: _mode, ...base } = options - return { - ...base, - cwd: request.cwd, - ...(request._tag === 'profile' - ? request.profile === 'default' - ? {} - : { profile: request.profile } - : { - modelSelection: { - provider: request.provider, - model: request.model, - ...(request.reasoning === undefined ? {} : { reasoning: request.reasoning }), - }, - mode: request.mode, - }), + const launch: Mutable = { ...base, cwd: request.cwd } + if (request._tag === 'profile') { + if (request.profile !== 'default') launch.profile = request.profile + return launch + } + + const modelSelection: Mutable> = { + provider: request.provider, + model: request.model, } + if (request.reasoning !== undefined) modelSelection.reasoning = request.reasoning + launch.modelSelection = modelSelection + launch.mode = request.mode + return launch } /** Resume with the durable session's model intent instead of the process's current model selection. */ @@ -30,20 +30,20 @@ export const sessionToLaunchOptions = ( ): TuiOptions => { const { profile: _profile, modelSelection: _modelSelection, mode: _mode, ...base } = options const mode = session.mode === 'rlm' ? 'rlm' : 'default' - if (session.profile !== null && session.profile !== 'default') return { ...base, profile: session.profile, mode } + const launch: Mutable = { ...base, mode } + if (session.profile !== null && session.profile !== 'default') { + launch.profile = session.profile + return launch + } const model = session.model - return { - ...base, - mode, - ...(model === null - ? {} - : { - modelSelection: { - provider: model.providerId, - model: model.modelId, - reasoning: model.requestedReasoningLevel, - ...(model.role === null || model.role === 'inherit' ? {} : { role: model.role }), - }, - }), + if (model === null) return launch + + const modelSelection: Mutable> = { + provider: model.providerId, + model: model.modelId, + reasoning: model.requestedReasoningLevel, } + if (model.role !== null && model.role !== 'inherit') modelSelection.role = model.role + launch.modelSelection = modelSelection + return launch } diff --git a/packages/fold-cli/src/tui/ModelSelectionState.ts b/packages/fold-cli/src/tui/ModelSelectionState.ts index 18c53f6..f300df0 100644 --- a/packages/fold-cli/src/tui/ModelSelectionState.ts +++ b/packages/fold-cli/src/tui/ModelSelectionState.ts @@ -1,6 +1,8 @@ -import type { ConfiguredModelSelection, ModelConfiguration, ProfileModeName } from '@humanlayer/fold-agent' +import { ConfiguredModelSelection, type ModelConfiguration, type ProfileModeName } from '@humanlayer/fold-agent' import type { ReasoningLevel } from '@humanlayer/fold-core' +type Mutable = { -readonly [Key in keyof Type]: Type[Key] } + export type ModelSelectionContext = 'active' | 'new-session' export type ModelSelectionRequest = | { readonly _tag: 'profile'; readonly profile: string; readonly mode?: ProfileModeName } @@ -23,15 +25,16 @@ export type ModelPickerState = | { readonly _tag: 'mode'; readonly selection: StagedModelSelection; readonly reasoning?: ReasoningLevel } export type ModelPickerChoice = { readonly id: string; readonly label: string; readonly detail: string } -export const configuredSelection = (request: ModelSelectionRequest): ConfiguredModelSelection => - request._tag === 'profile' - ? request - : { - _tag: 'direct', - provider: request.provider, - model: request.model, - ...(request.reasoning === undefined ? {} : { reasoning: request.reasoning }), - } +export const configuredSelection = (request: ModelSelectionRequest): ConfiguredModelSelection => { + if (request._tag === 'profile') return request + + const selection: Mutable, '_tag'>> = { + provider: request.provider, + model: request.model, + } + if (request.reasoning !== undefined) selection.reasoning = request.reasoning + return ConfiguredModelSelection.direct(selection) +} const REASONING_LEVELS: ReadonlyArray<{ id: ReasoningLevel; label: string; detail: string }> = [ { id: 'off', label: 'Off', detail: 'No extended thinking' }, @@ -109,20 +112,25 @@ export const advanceModelPicker = ( return { _tag: 'model', provider: choice } case 'model': return { _tag: 'reasoning', selection: { _tag: 'direct', provider: state.provider, model: choice } } - case 'reasoning': - return { + case 'reasoning': { + const next: Mutable> = { _tag: 'mode', selection: state.selection, - ...(choice === 'off' ? {} : { reasoning: toReasoningLevel(choice) }), } - case 'mode': - return state.selection._tag === 'profile' - ? { ...state.selection, mode: choice === 'rlm' ? 'rlm' : 'default' } - : { - ...state.selection, - ...(state.reasoning === undefined ? {} : { reasoning: state.reasoning }), - mode: choice === 'rlm' ? 'rlm' : 'default', - } + if (choice !== 'off') next.reasoning = toReasoningLevel(choice) + return next + } + case 'mode': { + const mode = choice === 'rlm' ? 'rlm' : 'default' + if (state.selection._tag === 'profile') return { ...state.selection, mode } + + const selection: Mutable> = { + ...state.selection, + mode, + } + if (state.reasoning !== undefined) selection.reasoning = state.reasoning + return selection + } } } export const retreatModelPicker = (state: ModelPickerState): ModelPickerState | null => { diff --git a/packages/fold-cli/src/tui/NewSessionModal.tsx b/packages/fold-cli/src/tui/NewSessionModal.tsx index bc0da05..a552a34 100644 --- a/packages/fold-cli/src/tui/NewSessionModal.tsx +++ b/packages/fold-cli/src/tui/NewSessionModal.tsx @@ -26,6 +26,8 @@ export type NewSessionRequest = { readonly cwd: string } & ( } ) +type Mutable = { -readonly [Key in keyof Type]: Type[Key] } + const expandHome = (value: string): string => value === '~' ? homedir() : value.startsWith('~/') ? join(homedir(), value.slice(2)) : value @@ -195,14 +197,15 @@ export const NewSessionModal = (props: { if (selection._tag === 'profile') { props.onSubmit({ _tag: 'profile', profile: selection.profile, cwd: cwd() }) } else if (selection.mode !== undefined) { - props.onSubmit({ + const request: Mutable> = { _tag: 'direct', provider: selection.provider, model: selection.model, - ...(selection.reasoning === undefined ? {} : { reasoning: selection.reasoning }), mode: selection.mode, cwd: cwd(), - }) + } + if (selection.reasoning !== undefined) request.reasoning = selection.reasoning + props.onSubmit(request) } }} /> diff --git a/packages/fold-cli/src/tui/ProviderConfigState.ts b/packages/fold-cli/src/tui/ProviderConfigState.ts index f6d0101..e23ce0f 100644 --- a/packages/fold-cli/src/tui/ProviderConfigState.ts +++ b/packages/fold-cli/src/tui/ProviderConfigState.ts @@ -6,6 +6,7 @@ export const providerFormFields: ReadonlyArray = ['kind', 'na export type ProviderForm = ConfigureProviderInput & { readonly model: string } type ConfiguredProvider = ModelConfiguration['providers'][number] +type Mutable = { -readonly [Key in keyof Type]: Type[Key] } export type ProviderManagementRow = | { @@ -99,14 +100,15 @@ export const providerFormFor = (configuration: ModelConfiguration, name: string) const provider = configuration.providers.find((candidate) => candidate.name === name) if (provider === undefined) return emptyProviderForm() const fallback = defaults(provider.kind) - return { + const form: Mutable = { kind: provider.kind, name: provider.name, baseUrl: provider.baseUrl ?? fallback.baseUrl, apiKey: '', - ...(provider.apiKeyEnv === null ? {} : { apiKeyEnv: provider.apiKeyEnv }), model: provider.models[0] ?? fallback.model, } + if (provider.apiKeyEnv !== null) form.apiKeyEnv = provider.apiKeyEnv + return form } const nextKinds: Record = { @@ -127,9 +129,8 @@ export const withNextProviderKind = (form: ProviderForm): ProviderForm => { export const providerInput = (form: ProviderForm): ConfigureProviderInput => { const { model, apiKey, ...required } = form const oauth = form.kind === 'codex' || form.kind === 'opencode' || form.kind === 'xai' - return { - ...required, - ...(oauth || apiKey === undefined || apiKey.trim() === '' ? {} : { apiKey }), - ...(model.trim() === '' ? {} : { model }), - } + const input: Mutable = { ...required } + if (!oauth && apiKey !== undefined && apiKey.trim() !== '') input.apiKey = apiKey + if (model.trim() !== '') input.model = model + return input } diff --git a/packages/fold-cli/src/tui/TuiSessionWorkspace.ts b/packages/fold-cli/src/tui/TuiSessionWorkspace.ts index 495381d..c76913d 100644 --- a/packages/fold-cli/src/tui/TuiSessionWorkspace.ts +++ b/packages/fold-cli/src/tui/TuiSessionWorkspace.ts @@ -24,19 +24,19 @@ import type { NewSessionRequest } from './NewSessionModal' import { projectSessionRows, type SessionRow } from './SessionListProjection' import type { TuiOptions } from './TuiSessionOptions' -const launchOptions = (options: TuiOptions) => ({ - cwd: options.cwd, - ...(options.foldHome === undefined ? {} : { foldHome: options.foldHome }), - ...(options.mode === undefined ? {} : { mode: modeForName(options.mode) }), - ...(options.rpi === true ? { rpi: true } : {}), - ...(options.modelSelection !== undefined - ? { modelSelection: options.modelSelection } - : options.profile === undefined - ? {} - : { profile: options.profile }), - ...(options.autoCompact === undefined ? {} : { autoCompact: options.autoCompact }), - ...(options.catalog === undefined ? {} : { catalog: options.catalog }), -}) +type Mutable = { -readonly [Key in keyof Type]: Type[Key] } + +const launchOptions = (options: TuiOptions) => { + const launch: Mutable[0]> = { cwd: options.cwd } + if (options.foldHome !== undefined) launch.foldHome = options.foldHome + if (options.mode !== undefined) launch.mode = modeForName(options.mode) + if (options.rpi === true) launch.rpi = true + if (options.modelSelection !== undefined) launch.modelSelection = options.modelSelection + else if (options.profile !== undefined) launch.profile = options.profile + if (options.autoCompact !== undefined) launch.autoCompact = options.autoCompact + if (options.catalog !== undefined) launch.catalog = options.catalog + return launch +} const initialSession = (options: TuiOptions) => { if (options.resume === undefined) return launchSession(launchOptions(options)) @@ -82,16 +82,15 @@ export const makeTuiSessionWorkspace = (options: { const cwds = new Set([options.tui.cwd]) const cwdBySession = new Map() const loadSummaries = Effect.suspend(() => - Effect.forEach([...cwds], (cwd) => - listSessionSummaries({ - cwd, - ...(options.tui.foldHome === undefined ? {} : { foldHome: options.tui.foldHome }), - }).pipe( + Effect.forEach([...cwds], (cwd) => { + const summaryOptions: Mutable[0]>> = { cwd } + if (options.tui.foldHome !== undefined) summaryOptions.foldHome = options.tui.foldHome + return listSessionSummaries(summaryOptions).pipe( Effect.tap((rows) => Effect.sync(() => rows.forEach((row) => cwdBySession.set(row.sessionId, cwd))), ), - ), - ).pipe( + ) + }).pipe( Effect.map((groups) => { const byId = new Map(groups.flat().map((summary) => [summary.sessionId, summary])) return [...byId.values()] @@ -150,18 +149,19 @@ export const makeTuiSessionWorkspace = (options: { ) => session.pipe( Effect.provide(NodeFileSystem.layer), - Effect.flatMap((value) => - makeHostedTuiSession(value, { + Effect.flatMap((value) => { + const hostedOptions: Mutable[1]> = { metadata, initialInputFocused: focused, config: currentConfig, configNotice: options.configNotice, - ...(options.tui.foldHome === undefined ? {} : { foldHome: options.tui.foldHome }), - ...(options.tui.catalog === undefined ? {} : { catalog: options.tui.catalog }), - ...(options.tui.rpi === true ? { rpi: true } : {}), onDurableSummaryChange: refresh, - }), - ), + } + if (options.tui.foldHome !== undefined) hostedOptions.foldHome = options.tui.foldHome + if (options.tui.catalog !== undefined) hostedOptions.catalog = options.tui.catalog + if (options.tui.rpi === true) hostedOptions.rpi = true + return makeHostedTuiSession(value, hostedOptions) + }), ) const finish = (hosted: HostedTuiSession) => loadSummaries.pipe( @@ -266,10 +266,9 @@ export const makeTuiSessionWorkspace = (options: { Effect.gen(function* () { const cwd = host.get(sessionId)?.cwd ?? cwdBySession.get(sessionId) ?? options.tui.cwd yield* host.close(sessionId) - const result = yield* deleteSession(sessionId, { - cwd, - ...(options.tui.foldHome === undefined ? {} : { foldHome: options.tui.foldHome }), - }) + const deleteOptions: Mutable[1]>> = { cwd } + if (options.tui.foldHome !== undefined) deleteOptions.foldHome = options.tui.foldHome + const result = yield* deleteSession(sessionId, deleteOptions) setSummaries(yield* loadSummaries) setNotice( !result.deleted diff --git a/packages/fold-codex/src/AuthStore.ts b/packages/fold-codex/src/AuthStore.ts index 2c05cf2..f9bbc96 100644 --- a/packages/fold-codex/src/AuthStore.ts +++ b/packages/fold-codex/src/AuthStore.ts @@ -63,13 +63,16 @@ const decodeDocument = Schema.decodeUnknownOption(Schema.fromJsonString(AuthDocu const decodeToken = Schema.decodeUnknownOption(CodexTokenData) -const encodeToken = (token: CodexTokenData): Record => ({ - type: token.type, - access: token.access, - refresh: token.refresh, - expires: token.expires, - ...(token.accountId === undefined ? {} : { accountId: token.accountId }), -}) +const encodeToken = (token: CodexTokenData): Record => { + const encoded: Record = { + type: token.type, + access: token.access, + refresh: token.refresh, + expires: token.expires, + } + if (token.accountId !== undefined) encoded['accountId'] = token.accountId + return encoded +} /** Build a file-backed Codex credential store. */ export const makeCodexAuthStore = ( diff --git a/packages/fold-codex/src/CodexModel.ts b/packages/fold-codex/src/CodexModel.ts index 4c3f3dc..3bb9dd2 100644 --- a/packages/fold-codex/src/CodexModel.ts +++ b/packages/fold-codex/src/CodexModel.ts @@ -51,6 +51,7 @@ type ResponsesPayload = Omit +type MutableCodexRetryOptions = { -readonly [Key in keyof CodexRetryOptions]: CodexRetryOptions[Key] } // The exact shape the provider emits for a prompt system message: a message item with plain string // content. Anything else (array content, other roles, non-message items) ends the leading run. @@ -247,9 +248,9 @@ export const makeCodexLanguageModel = ( const httpContext = yield* Layer.build(FetchHttpClient.layer) const baseClient = Context.get(httpContext, HttpClient.HttpClient) - const auth = yield* makeCodexAuth(options.store === undefined ? {} : { store: options.store }).pipe( - Effect.provideService(HttpClient.HttpClient, baseClient), - ) + const authOptions: { store?: CodexAuthStore } = {} + if (options.store !== undefined) authOptions.store = options.store + const auth = yield* makeCodexAuth(authOptions).pipe(Effect.provideService(HttpClient.HttpClient, baseClient)) // retryTransient sits below the auth wrapper: transport retries reuse the injected headers and never // re-enter (or retry) the auth path itself. Status responses are mapped to AiError above this seam, @@ -270,11 +271,9 @@ export const makeCodexLanguageModel = ( ) const stockClient = Context.get(clientContext, OpenAiClient.OpenAiClient) - const codexClient = decorateCodexClient(stockClient, { - ...defaultCodexHardening, - ...options.hardening, - ...(options.onStreamRetry === undefined ? {} : { onStreamRetry: options.onStreamRetry }), - }) + const hardening: MutableCodexRetryOptions = { ...defaultCodexHardening, ...options.hardening } + if (options.onStreamRetry !== undefined) hardening.onStreamRetry = options.onStreamRetry + const codexClient = decorateCodexClient(stockClient, hardening) const reasoning = resolveCodexReasoning(options.reasoning ?? 'off') const reasoningConfig = Match.valueTags(reasoning, { diff --git a/packages/fold-codex/src/OAuthFlows.ts b/packages/fold-codex/src/OAuthFlows.ts index 522395d..10d9795 100644 --- a/packages/fold-codex/src/OAuthFlows.ts +++ b/packages/fold-codex/src/OAuthFlows.ts @@ -64,6 +64,18 @@ const TokenResponse = Schema.Struct({ }) type TokenResponse = typeof TokenResponse.Type +type CodexTokenDataInput = { + type: 'oauth' + access: string + refresh: string + expires: number + accountId?: string +} +type CodexAuthErrorInput = { + reason: CodexAuthError['reason'] + message: string + cause?: unknown +} // --- JWT account-id extraction (clanka port) -------------------------------------------------------- @@ -93,13 +105,18 @@ const toJwtClaims = (value: unknown): Option.Option => { ? getString(organizationsValue[0]['id']) : undefined - return Option.some({ - ...(accountId === undefined ? {} : { chatgpt_account_id: accountId }), - ...(nestedAccountId === undefined - ? {} - : { 'https://api.openai.com/auth': { chatgpt_account_id: nestedAccountId } }), - ...(organizationId === undefined ? {} : { organizations: [{ id: organizationId }] }), - }) + const claims: { + chatgpt_account_id?: string + 'https://api.openai.com/auth'?: { chatgpt_account_id?: string } + organizations?: Array<{ id: string }> + } = {} + if (accountId !== undefined) claims.chatgpt_account_id = accountId + if (nestedAccountId !== undefined) { + claims['https://api.openai.com/auth'] = { chatgpt_account_id: nestedAccountId } + } + if (organizationId !== undefined) claims.organizations = [{ id: organizationId }] + + return Option.some(claims) } const decodeJwtPayload = (token: string): Option.Option => { @@ -151,14 +168,14 @@ const extractAccountId = (token: TokenResponse): string | undefined => { const toTokenData = (token: TokenResponse): Effect.Effect => Effect.map(Clock.currentTimeMillis, (now) => { const accountId = extractAccountId(token) - - return new CodexTokenData({ + const data: CodexTokenDataInput = { type: 'oauth', access: token.access_token, refresh: token.refresh_token, expires: now + (token.expires_in ?? DEFAULT_TOKEN_EXPIRY_SECONDS) * 1000, - ...(accountId === undefined ? {} : { accountId }), - }) + } + if (accountId !== undefined) data.accountId = accountId + return new CodexTokenData(data) }) /** Carry an account id a token response omitted forward from the previous credential. */ @@ -190,17 +207,19 @@ export const makeIssuerHttpClient = (client: HttpClient.HttpClient): HttpClient. }), ) -const refreshError = (message: string, cause?: unknown) => - new CodexAuthError({ reason: 'RefreshFailed', message, ...(cause === undefined ? {} : { cause }) }) +const authError = (reason: CodexAuthError['reason'], message: string, cause?: unknown): CodexAuthError => { + const options: CodexAuthErrorInput = { reason, message } + if (cause !== undefined) options.cause = cause + return new CodexAuthError(options) +} + +const refreshError = (message: string, cause?: unknown) => authError('RefreshFailed', message, cause) -const exchangeError = (message: string, cause?: unknown) => - new CodexAuthError({ reason: 'TokenExchangeFailed', message, ...(cause === undefined ? {} : { cause }) }) +const exchangeError = (message: string, cause?: unknown) => authError('TokenExchangeFailed', message, cause) -const deviceFlowError = (message: string, cause?: unknown) => - new CodexAuthError({ reason: 'DeviceFlowFailed', message, ...(cause === undefined ? {} : { cause }) }) +const deviceFlowError = (message: string, cause?: unknown) => authError('DeviceFlowFailed', message, cause) -const browserFlowError = (message: string, cause?: unknown) => - new CodexAuthError({ reason: 'BrowserFlowFailed', message, ...(cause === undefined ? {} : { cause }) }) +const browserFlowError = (message: string, cause?: unknown) => authError('BrowserFlowFailed', message, cause) /** Refresh an access token through the issuer. The client must come from {@link makeIssuerHttpClient}. */ export const refreshAccessToken = Effect.fn('fold.codexAuth.refreshAccessToken')(function* ( diff --git a/packages/fold-core/src/AgentRuntime/AgentRuntimeLayer.ts b/packages/fold-core/src/AgentRuntime/AgentRuntimeLayer.ts index c81748f..8564b8c 100644 --- a/packages/fold-core/src/AgentRuntime/AgentRuntimeLayer.ts +++ b/packages/fold-core/src/AgentRuntime/AgentRuntimeLayer.ts @@ -59,17 +59,16 @@ const encodeSystemMessage = Schema.encodeUnknownSync(Prompt.SystemMessage) const anthropicEphemeralCacheControl = { type: 'ephemeral' } as const -const leadingSystemMessageFor = (content: string, cacheBreakpoint: boolean): Prompt.SystemMessage => - Prompt.systemMessage({ - content, - ...(cacheBreakpoint - ? { - options: { - anthropic: { cacheControl: anthropicEphemeralCacheControl }, - }, - } - : {}), - }) +type Mutable = { -readonly [Key in keyof T]: T[Key] } + +const leadingSystemMessageFor = (content: string, cacheBreakpoint: boolean): Prompt.SystemMessage => { + const input: Mutable[0]> = { content } + if (cacheBreakpoint) { + input.options = { anthropic: { cacheControl: anthropicEphemeralCacheControl } } + } + + return Prompt.systemMessage(input) +} const encodeUserMessage = Schema.encodeUnknownSync(Prompt.UserMessage) const encodeAssistantMessage = Schema.encodeUnknownSync(Prompt.AssistantMessage) @@ -263,19 +262,20 @@ export const liveAgentRuntimeLayer: Layer.Layer< trigger, }) - const entry = yield* appendToEventLog( - LogEntryInputs['compaction']({ - agentId: input.agentId, - parentAgentId: input.parentAgentId, - toolCallId: input.toolCallId, - compactionId: yield* ids.makeCompactionId, - prompt: planned.success.prompt, - summary: planned.success.summary, - ...(postCompactionInstructions === null ? {} : { postCompactionInstructions }), - replacesThroughSeq: planned.success.replacesThroughSeq, - tokensBefore: planned.success.tokensBefore, - }), - ) + const compactionInput: Mutable[0]> = { + 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, + } + if (postCompactionInstructions !== null) { + compactionInput.postCompactionInstructions = postCompactionInstructions + } + const entry = yield* appendToEventLog(LogEntryInputs['compaction'](compactionInput)) if (Predicate.isTagged(entry, 'compaction')) return entry return yield* Effect.die(new Error(`EventLog returned ${entry._tag} while appending compaction`)) @@ -556,20 +556,21 @@ export const liveAgentRuntimeLayer: Layer.Layer< // prompt block set once for the starting model; both are recorded durably. const resolvedToolset = yield* toolsetResolver.resolve({ model: input.model }) - const entry = yield* appendToEventLog( - LogEntryInputs['agent_started']({ - agentId: input.agentId, - parentAgentId: input.parentAgentId, - toolCallId: input.toolCallId, - mode: input.mode, - model: input.model, - ...(input.promptCacheKey == null ? {} : { promptCacheKey: input.promptCacheKey }), - tools: resolvedToolset.names, - skill: input.skill, - fork: input.fork, - agentType: input.agentType, - }), - ) + const agentStartedInput: Mutable[0]> = { + 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, + } + if (input.promptCacheKey != null) { + agentStartedInput.promptCacheKey = input.promptCacheKey + } + const entry = yield* appendToEventLog(LogEntryInputs['agent_started'](agentStartedInput)) // 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 diff --git a/packages/fold-core/src/Api/Provisioning.ts b/packages/fold-core/src/Api/Provisioning.ts index aa4d326..4742434 100644 --- a/packages/fold-core/src/Api/Provisioning.ts +++ b/packages/fold-core/src/Api/Provisioning.ts @@ -42,6 +42,8 @@ import { makeToolsetResolver } from '../ToolRuntime/ToolsetResolverLayer' import type { FoldModel } from './ModelDescriptor' import type { RealizedFoldTool, FoldTool } from './ToolDefinition' +type Mutable = { -readonly [Key in keyof T]: T[Key] } + const anthropicDecoderModelFor = (modelId: string): string | null => { const id = modelId.toLowerCase() if (id.includes('opus')) return id === 'claude-opus-4-6' ? null : 'claude-opus-4-6' @@ -101,19 +103,23 @@ export const languageModelLayerFor = (model: FoldModel): Layer.Layer { - const clientLayer = OpenAiClient.layer({ - apiKey: connection.apiKey, - ...(connection.baseUrl === null ? {} : { apiUrl: connection.baseUrl }), - }).pipe(Layer.provide(FetchHttpClient.layer)) + const clientOptions: Mutable[0]> = { apiKey: connection.apiKey } + if (connection.baseUrl !== null) { + clientOptions.apiUrl = connection.baseUrl + } + const clientLayer = OpenAiClient.layer(clientOptions).pipe(Layer.provide(FetchHttpClient.layer)) return OpenAiLanguageModel.layer({ model: model.activeModel.modelId }).pipe(Layer.provide(clientLayer)) }, anthropic: (connection) => { - const clientLayer = AnthropicClient.layer({ + const clientOptions: Mutable[0]> = { apiKey: connection.apiKey, transformClient: relaxAnthropicResponseModel(model.activeModel.modelId), - ...(connection.baseUrl === null ? {} : { apiUrl: connection.baseUrl }), - }).pipe(Layer.provide(FetchHttpClient.layer)) + } + if (connection.baseUrl !== null) { + clientOptions.apiUrl = connection.baseUrl + } + const clientLayer = AnthropicClient.layer(clientOptions).pipe(Layer.provide(FetchHttpClient.layer)) return AnthropicLanguageModel.layer({ model: model.activeModel.modelId }).pipe(Layer.provide(clientLayer)) }, diff --git a/packages/fold-core/src/Api/StartSession.ts b/packages/fold-core/src/Api/StartSession.ts index c185ac9..4f080b6 100644 --- a/packages/fold-core/src/Api/StartSession.ts +++ b/packages/fold-core/src/Api/StartSession.ts @@ -86,7 +86,7 @@ import { type SteeringMode, } from '../Session/SessionControls' import { liveSessionLayer } from '../Session/SessionLayer' -import { Session, type SessionService, type StartedSession } from '../Session/SessionService' +import { Session, type SessionService, type StartSessionInput, type StartedSession } from '../Session/SessionService' import type { SkillSourceService } from '../Skills/SkillSource' import { StopConditions } from '../StopConditions/StopConditions' import { agentIdsFromEntries, resolveAgentIdRef } from '../Subagents/AgentIdRef' @@ -106,6 +106,8 @@ import type { FoldModel } from './ModelDescriptor' import { AgentProvisioner, makeAgentProvisioner, validateToolNames } from './Provisioning' import type { RealizedFoldTool, SessionToolContribution, FoldTool } from './ToolDefinition' +type Mutable = { -readonly [Key in keyof T]: T[Key] } + /** Options for {@link startSession}. */ export type StartSessionOptions = { readonly agent: AgentDefinition @@ -959,20 +961,24 @@ export const startSession = ( Effect.gen(function* () { const graph = yield* assembleSessionGraph(options) const config = yield* Ref.get(graph.configRef) + const meta: Mutable> = { ...options.meta } + if (options.agent.name !== undefined) { + meta.agentName = options.agent.name + } + const startInput: Mutable = { + cwd: options.cwd ?? null, + model: options.agent.model.activeModel, + systemPrompt: graph.leadingPromptFor(config.systemPrompt, config.tools), + meta, + } + if (options.agent.promptCacheKey !== undefined) { + startInput.promptCacheKey = options.agent.promptCacheKey + } + if (options.sessionId !== undefined) { + startInput.sessionId = options.sessionId + } - const started = yield* graph.session - .start({ - cwd: options.cwd ?? null, - model: options.agent.model.activeModel, - ...(options.agent.promptCacheKey === undefined ? {} : { promptCacheKey: options.agent.promptCacheKey }), - systemPrompt: graph.leadingPromptFor(config.systemPrompt, config.tools), - meta: { - ...options.meta, - ...(options.agent.name === undefined ? {} : { agentName: options.agent.name }), - }, - ...(options.sessionId === undefined ? {} : { sessionId: options.sessionId }), - }) - .pipe(Effect.orDie) + const started = yield* graph.session.start(startInput).pipe(Effect.orDie) return makeSessionHandle(graph, started) }) diff --git a/packages/fold-core/src/Api/ToolDefinition.ts b/packages/fold-core/src/Api/ToolDefinition.ts index 49a0ea3..33a35bd 100644 --- a/packages/fold-core/src/Api/ToolDefinition.ts +++ b/packages/fold-core/src/Api/ToolDefinition.ts @@ -46,6 +46,27 @@ export type ToolHandlerServices = type PlatformToolServices = FileSystem.FileSystem | Path.Path | ChildProcessSpawner.ChildProcessSpawner +type ToolDependency = + | typeof ToolState + | typeof ToolEvents + | typeof StopController + | typeof CurrentAgent + | typeof CurrentToolCall + | typeof InterruptNote + | typeof Subagents + | typeof FileSystem.FileSystem + | typeof Path.Path + | typeof ChildProcessSpawner.ChildProcessSpawner + +type ToolOptionsBuilder = { + description: string + parameters?: Params + success: Success | typeof Schema.Undefined + failure?: Failure + failureMode: 'return' + dependencies: Array +} + /** Neutral platform services used by filesystem and process-backed tools. */ export const platformToolDependencies = [ FileSystem.FileSystem, @@ -127,11 +148,9 @@ export const defineTool = < >( options: DefineToolOptions, ): FoldTool => { - const tool = Tool.make(options.name, { + const toolOptions: ToolOptionsBuilder = { description: options.description, - ...(options.parameters === undefined ? {} : { parameters: options.parameters }), success: options.success ?? Schema.Undefined, - ...(options.failure === undefined ? {} : { failure: options.failure }), failureMode: 'return', // Every tool may use the ambient per-call services; declaring them here keeps handler `R` // honest while the runtime provides all of them around each execution. @@ -146,7 +165,14 @@ export const defineTool = < FileSystem.FileSystem, ...(options.dependencies ?? []), ], - }).annotate(Tool.Strict, false) + } + if (options.parameters !== undefined) { + toolOptions.parameters = options.parameters + } + if (options.failure !== undefined) { + toolOptions.failure = options.failure + } + const tool = Tool.make(options.name, toolOptions).annotate(Tool.Strict, false) // asVoid yields the undefined value at runtime, which is exactly what Schema.Undefined encodes. const handlerWithDependencies = diff --git a/packages/fold-core/src/EventLog/StoredLogEntryDecoder.ts b/packages/fold-core/src/EventLog/StoredLogEntryDecoder.ts index 1bd03b2..53c9c55 100644 --- a/packages/fold-core/src/EventLog/StoredLogEntryDecoder.ts +++ b/packages/fold-core/src/EventLog/StoredLogEntryDecoder.ts @@ -11,13 +11,20 @@ import { const PersistedRecord = Schema.Record(Schema.String, Schema.Unknown) -const corruptEntry = (message: string, cause: unknown, seq?: number) => - new EventLogCorruptEntryError({ +type Mutable = { -readonly [Key in keyof T]: T[Key] } + +const corruptEntry = (message: string, cause: unknown, seq?: number) => { + const input: Mutable[0]> = { operation: 'entries', message, - ...(seq === undefined ? {} : { seq }), cause, - }) + } + if (seq !== undefined) { + input.seq = seq + } + + return new EventLogCorruptEntryError(input) +} /** * Decode one persisted Fold event by its wire-format version. diff --git a/packages/fold-core/src/Model/ModelRequestSettings.ts b/packages/fold-core/src/Model/ModelRequestSettings.ts index e48ba2e..41c7fdb 100644 --- a/packages/fold-core/src/Model/ModelRequestSettings.ts +++ b/packages/fold-core/src/Model/ModelRequestSettings.ts @@ -23,6 +23,13 @@ const OpenAiReasoning = Data.taggedEnum() const CodexReasoning = Data.taggedEnum() const AnthropicThinking = Data.taggedEnum() +type Mutable = { -readonly [Key in keyof T]: T[Key] } + +// The provider's Config omits this Responses API field even though the request encoder accepts it. +type OpenAiConfigBuilder = Mutable[1]> & { + prompt_cache_key?: string +} + /** * Map one reasoning level onto the OpenAI effort scale. `off` disables reasoning config entirely * (provider default applies); every other level passes straight through - the wire scale includes @@ -155,12 +162,15 @@ export const liveModelRequestSettingsLayer: Layer.Layer = effort: ({ effort }) => ({ reasoning: { effort } }), }) - return (self: Effect.Effect) => - OpenAiLanguageModel.withConfigOverride(self, { - model: model.modelId, - ...(promptCacheKey === null ? {} : { prompt_cache_key: promptCacheKey }), - ...reasoning, - }) + const config: OpenAiConfigBuilder = { + model: model.modelId, + ...reasoning, + } + if (promptCacheKey !== null) { + config.prompt_cache_key = promptCacheKey + } + + return (self: Effect.Effect) => OpenAiLanguageModel.withConfigOverride(self, config) } case 'codex': { @@ -170,12 +180,15 @@ export const liveModelRequestSettingsLayer: Layer.Layer = effort: ({ effort, summary }) => ({ reasoning: { effort, summary } }), }) - return (self: Effect.Effect) => - OpenAiLanguageModel.withConfigOverride(self, { - model: model.modelId, - ...(promptCacheKey === null ? {} : { prompt_cache_key: promptCacheKey }), - ...reasoning, - }) + const config: OpenAiConfigBuilder = { + model: model.modelId, + ...reasoning, + } + if (promptCacheKey !== null) { + config.prompt_cache_key = promptCacheKey + } + + return (self: Effect.Effect) => OpenAiLanguageModel.withConfigOverride(self, config) } case 'anthropic': { diff --git a/packages/fold-core/src/Projection/Projection.ts b/packages/fold-core/src/Projection/Projection.ts index 38e0e11..8085470 100644 --- a/packages/fold-core/src/Projection/Projection.ts +++ b/packages/fold-core/src/Projection/Projection.ts @@ -73,6 +73,8 @@ export type ProjectedMessage = const ProjectedMessage = Data.taggedEnum() +type Mutable = { -readonly [Key in keyof T]: T[Key] } + /** Tool-owned key/value state for one agent namespace, built by folding tool_state entries in log order. */ export type ToolStateProjection = Readonly> @@ -387,18 +389,17 @@ export const messagesForAgent = ( } if (compaction !== null) { - projected.push( - ProjectedMessage['compaction-summary']({ - sourceSeq: compaction.seq, - compactionId: compaction.compactionId, - replacesThroughSeq: compaction.replacesThroughSeq, - summary: compaction.summary, - ...(compaction.postCompactionInstructions === undefined - ? {} - : { postCompactionInstructions: compaction.postCompactionInstructions }), - tokensBefore: compaction.tokensBefore, - }), - ) + const summaryInput: Mutable[0]> = { + sourceSeq: compaction.seq, + compactionId: compaction.compactionId, + replacesThroughSeq: compaction.replacesThroughSeq, + summary: compaction.summary, + tokensBefore: compaction.tokensBefore, + } + if (compaction.postCompactionInstructions !== undefined) { + summaryInput.postCompactionInstructions = compaction.postCompactionInstructions + } + projected.push(ProjectedMessage['compaction-summary'](summaryInput)) } for (const entry of visibleEntries) { diff --git a/packages/fold-core/src/Subagents/Schemas.ts b/packages/fold-core/src/Subagents/Schemas.ts index ac7f8bb..21e6265 100644 --- a/packages/fold-core/src/Subagents/Schemas.ts +++ b/packages/fold-core/src/Subagents/Schemas.ts @@ -101,6 +101,9 @@ export type SubagentToolWireParameters = { readonly fork?: boolean } +const nonEmptyOptionalString = (value: string | undefined): string | undefined => + value === undefined || value.trim().length === 0 ? undefined : value + /** * Parse the tool's flat wire parameters into exactly one {@link SubagentCommand}. The wire shape stays * flat because schema unions confuse models (D21 ruling); this is the single boundary where it becomes @@ -111,8 +114,14 @@ export const parseSubagentCommand = ( params: SubagentToolWireParameters, ): Effect.Effect => Effect.gen(function* () { - const skill = params.skill ?? null - const selectorCount = [params.agent !== undefined, params.agent_id !== undefined, params.fork === true].filter( + // OpenAI-compatible providers expose optional fields as required nullable fields. Some models, + // notably Grok, use empty strings and false instead of null for inactive selectors. Normalize + // those placeholders at the semantic parsing boundary so they do not count as active selectors. + const description = nonEmptyOptionalString(params.description) + const skill = nonEmptyOptionalString(params.skill) ?? null + const agent = nonEmptyOptionalString(params.agent) + const agentIdRef = nonEmptyOptionalString(params.agent_id) + const selectorCount = [agent !== undefined, agentIdRef !== undefined, params.fork === true].filter( Boolean, ).length @@ -124,28 +133,41 @@ export const parseSubagentCommand = ( }) } - if (params.agent !== undefined) { + if (agent !== undefined) { + if (description === undefined) { + return DispatchSubagentCommand.make({ + agent, + prompt: params.prompt, + skill, + }) + } return DispatchSubagentCommand.make({ - agent: params.agent, - ...(params.description === undefined ? {} : { description: params.description }), + agent, + description, prompt: params.prompt, skill, }) } if (params.fork === true) { + if (description === undefined) { + return ForkSubagentCommand.make({ + prompt: params.prompt, + skill, + }) + } return ForkSubagentCommand.make({ - ...(params.description === undefined ? {} : { description: params.description }), + description, prompt: params.prompt, skill, }) } - const agentId = yield* decodeAgentIdRef(params.agent_id).pipe( + const agentId = yield* decodeAgentIdRef(agentIdRef).pipe( Effect.mapError( () => new InvalidSubagentCommandError({ message: - `agent_id "${params.agent_id ?? ''}" is not a valid subagent id. Use the agent_id line ` + + `agent_id "${agentIdRef ?? ''}" is not a valid subagent id. Use the agent_id line ` + `from a previous subagent result, or dispatch a fresh agent with the agent parameter.`, }), ), diff --git a/packages/fold-core/src/Subagents/SubagentTool.ts b/packages/fold-core/src/Subagents/SubagentTool.ts index 70e9b10..f750498 100644 --- a/packages/fold-core/src/Subagents/SubagentTool.ts +++ b/packages/fold-core/src/Subagents/SubagentTool.ts @@ -26,6 +26,8 @@ export type SubagentToolCapabilities = { readonly forkAgent?: ForkAgentDefinition } +type Mutable = { -readonly [Key in keyof T]: T[Key] } + const capabilitiesBySubagentTool = new WeakMap() /** Attach Fold's roster and fork behavior to any host-defined model-visible tool. */ @@ -207,5 +209,10 @@ export const subagentTool = ( }), }) - return withSubagentCapabilities(tool, { agents, ...(options?.forkAgent === undefined ? {} : options) }) + const capabilities: Mutable = { agents } + if (options?.forkAgent !== undefined) { + capabilities.forkAgent = options.forkAgent + } + + return withSubagentCapabilities(tool, capabilities) } diff --git a/packages/fold-core/src/Subagents/SubagentsLayer.ts b/packages/fold-core/src/Subagents/SubagentsLayer.ts index 2f83cf2..c253eba 100644 --- a/packages/fold-core/src/Subagents/SubagentsLayer.ts +++ b/packages/fold-core/src/Subagents/SubagentsLayer.ts @@ -61,6 +61,8 @@ import type { const encodeUserMessage = Schema.encodeUnknownSync(Prompt.UserMessage) +type Mutable = { -readonly [Key in keyof T]: T[Key] } + /** One agent's tools realized against the session-start contributions (once-per-value inits). */ export type RealizedAgentTools = { readonly tools: ReadonlyArray @@ -808,6 +810,13 @@ export const makeSubagents = ( : yield* deriveChildPromptCacheKey(dispatcherSnapshot.promptCacheKey, subagentId) const agentLabel = `fork of ${shortAgentId(dispatcher.agentId)}` yield* interruptNote.set(interruptedSubagentNote(agentLabel, subagentId, 0)) + const fork: Mutable = { fromAgentId: dispatcher.agentId, atSeq: lastEntry.seq } + if (input.forkAgentDefinitionId !== null) { + fork.definitionId = input.forkAgentDefinitionId + } + if (input.history !== undefined) { + fork.history = input.history + } return yield* runSubagentToResult({ subagentId, @@ -816,12 +825,7 @@ export const makeSubagents = ( parentAgentId: dispatcher.agentId, toolCallId: currentCall.toolCallId, mode: 'fork', - fork: { - fromAgentId: dispatcher.agentId, - atSeq: lastEntry.seq, - ...(input.forkAgentDefinitionId === null ? {} : { definitionId: input.forkAgentDefinitionId }), - ...(input.history === undefined ? {} : { history: input.history }), - }, + fork, model: dispatcherSnapshot.model, promptCacheKey, tools: realized.tools, diff --git a/packages/fold-core/src/ToolRuntime/ToolRuntimeLayer.ts b/packages/fold-core/src/ToolRuntime/ToolRuntimeLayer.ts index 8142cbf..8f96ca6 100644 --- a/packages/fold-core/src/ToolRuntime/ToolRuntimeLayer.ts +++ b/packages/fold-core/src/ToolRuntime/ToolRuntimeLayer.ts @@ -44,6 +44,8 @@ type PreparedToolCall = const PreparedToolCall = Data.taggedEnum() +type Mutable = { -readonly [Key in keyof T]: T[Key] } + type FinalToolOutput = { readonly result: unknown readonly isFailure: boolean @@ -118,19 +120,18 @@ const appendToolResultToEventLog = (input: { const eventLog = yield* EventLog const ids = yield* Ids const message = yield* encodedToolResultMessage(input) + const entryInput: Mutable[0]> = { + agentId: input.agentId, + parentAgentId: input.parentAgentId, + toolCallId: input.toolCallId, + messageId: yield* ids.makeMessageId, + message, + } + if (input.executedInput !== undefined) { + entryInput.executedInput = input.executedInput + } - const entry = yield* eventLog - .append( - LogEntryInputs['tool-result']({ - agentId: input.agentId, - parentAgentId: input.parentAgentId, - toolCallId: input.toolCallId, - messageId: yield* ids.makeMessageId, - message, - ...(input.executedInput === undefined ? {} : { executedInput: input.executedInput }), - }), - ) - .pipe(Effect.orDie) + const entry = yield* eventLog.append(LogEntryInputs['tool-result'](entryInput)).pipe(Effect.orDie) if (Predicate.isTagged(entry, 'tool-result')) return entry @@ -469,25 +470,32 @@ const settlePreparedToolCall = (input: { output: handlerOutput, }) - return { + const result: Mutable = { result: finalOutput.result, isFailure: finalOutput.isFailure, - ...(valuesHaveSameJsonRepresentation(input.prepared.original.params, input.prepared.params) - ? {} - : { executedInput: input.prepared.params }), } + if (!valuesHaveSameJsonRepresentation(input.prepared.original.params, input.prepared.params)) { + result.executedInput = input.prepared.params + } + + return result }) - const append = (result: ToolResultAppendInput) => - appendToolResultToEventLog({ + const append = (result: ToolResultAppendInput) => { + const appendInput: Mutable[0]> = { agentId: input.agentId, parentAgentId: input.parentAgentId, toolCallId, toolName, result: result.result, isFailure: result.isFailure, - ...(result.executedInput === undefined ? {} : { executedInput: result.executedInput }), - }) + } + if (result.executedInput !== undefined) { + appendInput.executedInput = result.executedInput + } + + return appendToolResultToEventLog(appendInput) + } const runnable = output.pipe( Effect.provideService(ToolState, toolState), diff --git a/packages/fold-core/src/Tools/Contracts.ts b/packages/fold-core/src/Tools/Contracts.ts index f425f2e..6e8dfab 100644 --- a/packages/fold-core/src/Tools/Contracts.ts +++ b/packages/fold-core/src/Tools/Contracts.ts @@ -294,8 +294,10 @@ export const subagentToolContract = { description: 'Delegate work to a subagent with its own context window. Dispatch a fresh subagent by agent ' + 'type, resume a previous one by agent_id (its context is preserved across completions, errors, ' + - 'and interruptions), or fork a copy of your own context. The result reports the agent_id, the ' + - 'turns taken, and the final output.', + 'and interruptions), or fork a copy of your own context. Exactly one selector must be active: a ' + + 'non-empty agent, a non-empty agent_id, or fork=true. Leave inactive selectors unset or null; do ' + + 'not use empty strings or false as placeholders. The result reports the agent_id, the turns taken, ' + + 'and the final output.', parameters: SubagentParameters, success: SubagentSuccess, failure: SubagentFailure, diff --git a/packages/fold-core/test/EventLog/StoredLogEntryDecoder.vi.test.ts b/packages/fold-core/test/EventLog/StoredLogEntryDecoder.vi.test.ts index 1407aae..c788f95 100644 --- a/packages/fold-core/test/EventLog/StoredLogEntryDecoder.vi.test.ts +++ b/packages/fold-core/test/EventLog/StoredLogEntryDecoder.vi.test.ts @@ -11,20 +11,41 @@ import { decodeStoredLogEntry, } from '../../src/index' -const sessionStartedEntry = (version?: number) => ({ - _tag: 'session_started', - seq: 0, - eventId: EventId.create(), - ts: 1, - ...(version === undefined ? {} : { version }), - agentId: null, - parentAgentId: null, - toolCallId: null, - cwd: '/tmp/fold', - sessionId: SessionId.create(), - rootAgentId: AgentId.create(), - meta: {}, -}) +type SessionStartedEntryBuilder = { + _tag: 'session_started' + seq: number + eventId: ReturnType + ts: number + version?: number + agentId: null + parentAgentId: null + toolCallId: null + cwd: string + sessionId: ReturnType + rootAgentId: ReturnType + meta: Record +} + +const sessionStartedEntry = (version?: number) => { + const entry: SessionStartedEntryBuilder = { + _tag: 'session_started', + seq: 0, + eventId: EventId.create(), + ts: 1, + agentId: null, + parentAgentId: null, + toolCallId: null, + cwd: '/tmp/fold', + sessionId: SessionId.create(), + rootAgentId: AgentId.create(), + meta: {}, + } + if (version !== undefined) { + entry.version = version + } + + return entry +} const legacySessionTitleEntry = () => ({ _tag: 'session_title', diff --git a/packages/fold-core/test/Subagents/DriveHarness.ts b/packages/fold-core/test/Subagents/DriveHarness.ts index da585b4..c59a96d 100644 --- a/packages/fold-core/test/Subagents/DriveHarness.ts +++ b/packages/fold-core/test/Subagents/DriveHarness.ts @@ -29,6 +29,8 @@ import { import { gptActiveModel, scriptedModel } from '../Api/ApiTestHelpers' import { textTurn, toolCallTurn, type ScriptedTurn } from '../TestLayers/ScriptedLanguageModel' +type Mutable = { -readonly [Key in keyof T]: T[Key] } + /** One engine operation the drive tool should perform on its next invocation. */ export type DriveInstruction = | { readonly op: 'dispatch'; readonly agent: string; readonly prompt: string; readonly skill?: string } @@ -105,14 +107,16 @@ export const makeDriveSession = (input: { ]).flat(), ) - const session = yield* startSession({ - agent: defineAgent({ - model: rootScripted.model, - systemPrompt: 'root', - tools: [makeDriveTool(instructions, roster), subagentTool(input.definitions)], - }), - ...(input.profiles === undefined ? {} : { profiles: input.profiles }), + const agent = defineAgent({ + model: rootScripted.model, + systemPrompt: 'root', + tools: [makeDriveTool(instructions, roster), subagentTool(input.definitions)], }) + const startOptions: Mutable[0]> = { agent } + if (input.profiles !== undefined) { + startOptions.profiles = input.profiles + } + const session = yield* startSession(startOptions) /** Queue one instruction; the caller decides how to run the send (await, fork, ...). */ const queue = (instruction: DriveInstruction) => Ref.set(instructions, [instruction]) diff --git a/packages/fold-core/test/Subagents/SubagentToolWire.vi.test.ts b/packages/fold-core/test/Subagents/SubagentToolWire.vi.test.ts index 526a531..8b8c301 100644 --- a/packages/fold-core/test/Subagents/SubagentToolWire.vi.test.ts +++ b/packages/fold-core/test/Subagents/SubagentToolWire.vi.test.ts @@ -45,7 +45,13 @@ it.effect('the model resumes a subagent through the tool wire by its SHORT id: f { id: 'provider-call-1', name: 'subagent', - params: { description: 'map module', prompt: 'map the module', agent: 'researcher' }, + params: { + description: 'map module', + prompt: 'map the module', + agent: 'researcher', + agent_id: '', + fork: false, + }, }, ]), textTurn('synthesized'), @@ -73,7 +79,13 @@ it.effect('the model resumes a subagent through the tool wire by its SHORT id: f { id: 'provider-call-2', name: 'subagent', - params: { description: 'follow up', prompt: 'keep going', agent_id: shortAgentId(started.agentId) }, + params: { + description: 'follow up', + prompt: 'keep going', + agent: '', + agent_id: shortAgentId(started.agentId), + fork: false, + }, }, ]), textTurn('synthesized again'), @@ -119,7 +131,14 @@ it.effect('the public fork wire persists completed-history selection', () => { id: 'provider-call-1', name: 'subagent', - params: { description: 'inspect context', prompt: 'inspect the completed context', fork: true }, + params: { + description: 'inspect context', + prompt: 'inspect the completed context', + agent: '', + agent_id: '', + fork: true, + skill: '', + }, }, ]), textTurn('fork findings'), @@ -163,7 +182,13 @@ it.effect('malformed wire commands come back as instructive tool failures the mo { id: 'provider-call-1', name: 'subagent', - params: { description: 'oops', prompt: 'do something' }, + params: { + description: 'oops', + prompt: 'do something', + agent: '', + agent_id: '', + fork: false, + }, }, ]), toolCallTurn([ diff --git a/packages/fold-opencode/src/OpenCodeAuth.ts b/packages/fold-opencode/src/OpenCodeAuth.ts index 1c8a399..1348a62 100644 --- a/packages/fold-opencode/src/OpenCodeAuth.ts +++ b/packages/fold-opencode/src/OpenCodeAuth.ts @@ -23,6 +23,13 @@ const Pending = Schema.Struct({ error: Schema.String }) const DeviceToken = Schema.Union([Token, Pending]) const User = Schema.Struct({ id: Schema.String, email: Schema.String }) const Org = Schema.Struct({ id: Schema.String, name: Schema.String }) +type OpenCodeTokenMetadata = { + server: string + accountID: string + email: string + orgID?: string + orgName?: string +} export class OpenCodeAuthError extends Schema.TaggedError()('OpenCodeAuthError', { reason: Schema.Literals(['NotAuthenticated', 'AuthorizationFailed', 'RefreshFailed', 'StoreFailed']), @@ -75,17 +82,21 @@ const credential = (client: HttpClient.HttpClient, server: string, token: typeof ) const org = orgs.toSorted((a, b) => a.name.localeCompare(b.name) || a.id.localeCompare(b.id))[0] const now = yield* Clock.currentTimeMillis + const metadata: OpenCodeTokenMetadata = { + server, + accountID: user.id, + email: user.email, + } + if (org !== undefined) { + metadata.orgID = org.id + metadata.orgName = org.name + } return new OpenCodeTokenData({ type: 'oauth', access: token.access_token, refresh: token.refresh_token, expires: now + token.expires_in * 1000, - metadata: { - server, - accountID: user.id, - email: user.email, - ...(org === undefined ? {} : { orgID: org.id, orgName: org.name }), - }, + metadata, }) }) @@ -237,14 +248,15 @@ export const withOpenCodeAuth = (client: HttpClient.HttpClient, auth: OpenCodeAu client.pipe( HttpClient.mapRequestEffect((request) => auth.get.pipe( - Effect.map((token) => - request.pipe( + Effect.map((token) => { + const headers: Record = {} + const orgId = token.metadata?.orgID + if (orgId !== undefined) headers['x-org-id'] = orgId + return request.pipe( HttpClientRequest.bearerToken(token.access), - HttpClientRequest.setHeaders( - token.metadata?.orgID === undefined ? {} : { 'x-org-id': token.metadata.orgID }, - ), - ), - ), + HttpClientRequest.setHeaders(headers), + ) + }), Effect.mapError( (cause) => new HttpClientError.HttpClientError({ diff --git a/packages/fold-opencode/src/OpenCodeModel.ts b/packages/fold-opencode/src/OpenCodeModel.ts index a648a24..6ce64bc 100644 --- a/packages/fold-opencode/src/OpenCodeModel.ts +++ b/packages/fold-opencode/src/OpenCodeModel.ts @@ -110,10 +110,10 @@ export const makeOpenCodeLanguageModel = ( Effect.gen(function* () { const httpContext = yield* Layer.build(FetchHttpClient.layer) const http = Context.get(httpContext, HttpClient.HttpClient) - const auth = yield* makeOpenCodeAuth({ - ...(options.store === undefined ? {} : { store: options.store }), - ...(options.consoleUrl === undefined ? {} : { server: options.consoleUrl }), - }).pipe(Effect.provideService(HttpClient.HttpClient, http)) + const authOptions: { store?: OpenCodeAuthStore; server?: string } = {} + if (options.store !== undefined) authOptions.store = options.store + if (options.consoleUrl !== undefined) authOptions.server = options.consoleUrl + const auth = yield* makeOpenCodeAuth(authOptions).pipe(Effect.provideService(HttpClient.HttpClient, http)) const authenticated = withOpenCodeAuth(http, auth) const requestedModel = options.model ?? DEFAULT_OPENCODE_MODEL_ID const credential = yield* Effect.option(auth.get) diff --git a/packages/fold-tui-theme/src/github/client.ts b/packages/fold-tui-theme/src/github/client.ts index 247d367..6e2f61e 100644 --- a/packages/fold-tui-theme/src/github/client.ts +++ b/packages/fold-tui-theme/src/github/client.ts @@ -34,6 +34,8 @@ interface RawItem { pull_request?: unknown } +type MutableGhItem = { -readonly [Key in keyof GhItem]: GhItem[Key] } + const str = (v: unknown, fallback = ''): string => (typeof v === 'string' ? v : fallback) const num = (v: unknown, fallback = 0): number => (typeof v === 'number' ? v : fallback) @@ -54,7 +56,7 @@ function parseLabels(v: unknown): Array { function normalize(raw: RawItem, kind: GhItem['kind']): GhItem { const headRef = raw.head ? str(raw.head.ref) : '' const baseRef = raw.base ? str(raw.base.ref) : '' - return { + const item: MutableGhItem = { kind, number: num(raw.number), title: str(raw.title, '(untitled)'), @@ -68,11 +70,12 @@ function normalize(raw: RawItem, kind: GhItem['kind']): GhItem { labels: parseLabels(raw.labels), body: str(raw.body), url: str(raw.html_url), - // `exactOptionalPropertyTypes` forbids assigning `undefined` to an - // optional prop, so omit the key entirely instead. - ...(headRef ? { headRef } : {}), - ...(baseRef ? { baseRef } : {}), } + // `exactOptionalPropertyTypes` forbids assigning `undefined` to an + // optional prop, so only add the keys when their refs are non-empty. + if (headRef) item.headRef = headRef + if (baseRef) item.baseRef = baseRef + return item } /** Env vars first, then whatever `gh` is already logged in as. */ diff --git a/packages/fold-xai/src/AuthStore.ts b/packages/fold-xai/src/AuthStore.ts index 3f8f64d..fdc3ef2 100644 --- a/packages/fold-xai/src/AuthStore.ts +++ b/packages/fold-xai/src/AuthStore.ts @@ -63,13 +63,16 @@ const decodeDocument = Schema.decodeUnknownOption(Schema.fromJsonString(AuthDocu const decodeToken = Schema.decodeUnknownOption(XaiTokenData) -const encodeToken = (token: XaiTokenData): Record => ({ - type: token.type, - access: token.access, - refresh: token.refresh, - expires: token.expires, - ...(token.accountId === undefined ? {} : { accountId: token.accountId }), -}) +const encodeToken = (token: XaiTokenData): Record => { + const encoded: Record = { + type: token.type, + access: token.access, + refresh: token.refresh, + expires: token.expires, + } + if (token.accountId !== undefined) encoded['accountId'] = token.accountId + return encoded +} /** Build a file-backed Xai credential store. */ export const makeXaiAuthStore = ( diff --git a/packages/fold-xai/src/OAuthFlows.ts b/packages/fold-xai/src/OAuthFlows.ts index fc32202..58d0245 100644 --- a/packages/fold-xai/src/OAuthFlows.ts +++ b/packages/fold-xai/src/OAuthFlows.ts @@ -54,8 +54,11 @@ const DeviceError = Schema.Struct({ error_description: Schema.optional(Schema.String), }) -const failure = (reason: XaiAuthError['reason'], message: string, cause?: unknown) => - new XaiAuthError({ reason, message, ...(cause === undefined ? {} : { cause }) }) +const failure = (reason: XaiAuthError['reason'], message: string, cause?: unknown): XaiAuthError => { + const options: { reason: XaiAuthError['reason']; message: string; cause?: unknown } = { reason, message } + if (cause !== undefined) options.cause = cause + return new XaiAuthError(options) +} const tokenData = (payload: typeof TokenResponse.Type, fallbackRefresh?: string) => Effect.map( diff --git a/packages/fold-xai/src/XaiModel.ts b/packages/fold-xai/src/XaiModel.ts index 0170485..1d0e134 100644 --- a/packages/fold-xai/src/XaiModel.ts +++ b/packages/fold-xai/src/XaiModel.ts @@ -76,9 +76,9 @@ export const makeXaiLanguageModel = ( Effect.gen(function* () { const httpContext = yield* Layer.build(FetchHttpClient.layer) const base = Context.get(httpContext, HttpClient.HttpClient) - const auth = yield* makeXaiAuth(options.store === undefined ? {} : { store: options.store }).pipe( - Effect.provideService(HttpClient.HttpClient, base), - ) + const authOptions: { store?: XaiAuthStore } = {} + if (options.store !== undefined) authOptions.store = options.store + const auth = yield* makeXaiAuth(authOptions).pipe(Effect.provideService(HttpClient.HttpClient, base)) const clientContext = yield* Layer.build(OpenAiClient.layer({ apiUrl: options.apiUrl ?? XAI_API_URL })).pipe( Effect.provideService(HttpClient.HttpClient, withXaiAuth(base, auth)), ) diff --git a/scripts/build/binaries.ts b/scripts/build/binaries.ts index 489211c..579a21e 100644 --- a/scripts/build/binaries.ts +++ b/scripts/build/binaries.ts @@ -46,6 +46,11 @@ for (const target of selected) { await mkdir(outdir, { recursive: true }) const bunTarget: Bun.CompileTarget = `bun-${os === 'windows' ? 'windows' : os}-${cpu}${variant.includes('baseline') ? '-baseline' : ''}${variant.includes('musl') ? '-musl' : ''}` const bunfs = os === 'windows' ? 'B:/~BUN/root/' : '/$bunfs/root/' + const define: Record = { + FOLD_VERSION: JSON.stringify(versionArg), + OTUI_TREE_SITTER_WORKER_PATH: JSON.stringify(bunfs + workerRelative), + } + if (os === 'linux') define['process.env.OPENTUI_LIBC'] = JSON.stringify(variant.includes('musl') ? 'musl' : 'glibc') const result = await Bun.build({ entrypoints: [join(root, 'packages/fold-cli/src/cli.ts'), parserWorker], plugins: [createSolidTransformPlugin()], @@ -53,13 +58,7 @@ for (const target of selected) { format: 'esm', minify: true, sourcemap: 'inline', - define: { - FOLD_VERSION: JSON.stringify(versionArg), - OTUI_TREE_SITTER_WORKER_PATH: JSON.stringify(bunfs + workerRelative), - ...(os === 'linux' - ? { 'process.env.OPENTUI_LIBC': JSON.stringify(variant.includes('musl') ? 'musl' : 'glibc') } - : {}), - }, + define, compile: { target: bunTarget, outfile: join(outdir, os === 'windows' ? 'foldcode.exe' : 'foldcode'), diff --git a/scripts/build/packages.ts b/scripts/build/packages.ts index 1776422..e15413e 100644 --- a/scripts/build/packages.ts +++ b/scripts/build/packages.ts @@ -26,6 +26,8 @@ for (const name of libraries) { const outdir = join(dir, 'dist') await rm(outdir, { recursive: true, force: true }) await mkdir(outdir, { recursive: true }) + const define: Record = {} + if (name === 'fold-cli') define.FOLD_VERSION = JSON.stringify(version) const result = await Bun.build({ entrypoints: entries.map((entry) => join(dir, entry)), outdir, @@ -36,7 +38,7 @@ for (const name of libraries) { external: name === 'fold-cli' ? ['@opentui/core', '@opentui/core/*'] : [], sourcemap: 'external', plugins: name === 'fold-cli' ? [solidTransformPlugin] : [], - define: name === 'fold-cli' ? { FOLD_VERSION: JSON.stringify(version) } : {}, + define, }) if (!result.success) throw new AggregateError(result.logs, `Failed to build ${manifest.name}`) const buildConfig = join(dir, 'tsconfig.release.json') diff --git a/scripts/release/prepare.ts b/scripts/release/prepare.ts index c59a010..3921f44 100644 --- a/scripts/release/prepare.ts +++ b/scripts/release/prepare.ts @@ -21,6 +21,19 @@ type PackageManifest = { exports: Record bin?: Record } +type NativePackageManifest = { + name: string + version: string + description: string + license: string + repository: typeof repository + preferUnplugged: boolean + os: Array + cpu: Array + libc?: Array + files: Array + publishConfig: { access: string } +} const rootManifest = await json<{ workspaces: { catalog: Record } }>(join(root, 'package.json')) const catalog = rootManifest.workspaces.catalog @@ -88,10 +101,20 @@ for (const target of targets) { await mkdir(dest, { recursive: true }) await cp(join(source, 'bin'), join(dest, 'bin'), { recursive: true }) const [os, cpu, variant] = target - await Bun.write( - join(dest, 'package.json'), - `${JSON.stringify({ name, version, description: 'Platform binary for @humanlayer/fold', license: 'MIT', repository, preferUnplugged: true, os: [os === 'windows' ? 'win32' : os], cpu: [cpu], ...(variant.includes('musl') ? { libc: ['musl'] } : {}), files: ['bin'], publishConfig: { access: 'public' } }, null, 2)}\n`, - ) + const manifest: NativePackageManifest = { + name, + version, + description: 'Platform binary for @humanlayer/fold', + license: 'MIT', + repository, + preferUnplugged: true, + os: [os === 'windows' ? 'win32' : os], + cpu: [cpu], + files: ['bin'], + publishConfig: { access: 'public' }, + } + if (variant.includes('musl')) manifest.libc = ['musl'] + await Bun.write(join(dest, 'package.json'), `${JSON.stringify(manifest, null, 2)}\n`) await cp(join(root, 'LICENSE'), join(dest, 'LICENSE')) } const platform = await json(join(root, 'packages/fold/package.json')) diff --git a/tools/oxlint/anti-slop/index.ts b/tools/oxlint/anti-slop/index.ts index 8035521..3917980 100644 --- a/tools/oxlint/anti-slop/index.ts +++ b/tools/oxlint/anti-slop/index.ts @@ -1,5 +1,6 @@ import { eslintCompatPlugin } from '@oxlint/plugins' +import { noConditionalEmptyObjectSpreadRule } from './rules/no-conditional-empty-object-spread.ts' import { noModuleMockingRule } from './rules/no-module-mocking.ts' import { noObjectParametersRule } from './rules/no-object-parameters.ts' import { noReflectApplyRule } from './rules/no-reflect-apply.ts' @@ -7,6 +8,7 @@ import { noReflectApplyRule } from './rules/no-reflect-apply.ts' export default eslintCompatPlugin({ meta: { name: 'anti-slop' }, rules: { + 'no-conditional-empty-object-spread': noConditionalEmptyObjectSpreadRule, 'no-module-mocking': noModuleMockingRule, 'no-object-parameters': noObjectParametersRule, 'no-reflect-apply': noReflectApplyRule, diff --git a/tools/oxlint/anti-slop/rules/no-conditional-empty-object-spread.ts b/tools/oxlint/anti-slop/rules/no-conditional-empty-object-spread.ts new file mode 100644 index 0000000..43eced7 --- /dev/null +++ b/tools/oxlint/anti-slop/rules/no-conditional-empty-object-spread.ts @@ -0,0 +1,47 @@ +// Vendored from https://github.com/K-Mistele/anti-slop at cf9bad836a7ba5562f5167a3471c97a1849a9f5f +// (MIT), src/rules/no-conditional-empty-object-spread.ts. +import { defineRule } from '@oxlint/plugins' +import type { ESTree } from '@oxlint/plugins' + +function unwrapParentheses(node: ESTree.Expression): ESTree.Expression { + let current = node + while (current.type === 'ParenthesizedExpression') { + current = current.expression + } + return current +} + +function isEmptyObjectExpression(node: ESTree.Expression): boolean { + return node.type === 'ObjectExpression' && node.properties.length === 0 +} + +function isConditionalEmptyObjectSpread(node: ESTree.Expression): boolean { + const conditional = unwrapParentheses(node) + return ( + conditional.type === 'ConditionalExpression' && + (isEmptyObjectExpression(conditional.consequent) || isEmptyObjectExpression(conditional.alternate)) + ) +} + +/** Ban conditional empty-object spreads without changing their omission semantics. */ +export const noConditionalEmptyObjectSpreadRule = defineRule({ + meta: { + type: 'suggestion', + docs: { + description: 'Disallow object spreads that conditionally spread an empty object to omit fields.', + }, + messages: { + avoid: 'This conditional spread hides property omission behind an empty object. Build the object in separate statements and add the property only when present.', + }, + }, + createOnce(context) { + return { + SpreadElement(node) { + if (node.parent.type !== 'ObjectExpression') return + if (isConditionalEmptyObjectSpread(node.argument)) { + context.report({ node, messageId: 'avoid' }) + } + }, + } + }, +})