diff --git a/packages/fold-core/src/AgentRuntime/AgentRuntimeLayer.ts b/packages/fold-core/src/AgentRuntime/AgentRuntimeLayer.ts index ba0fb27..c81748f 100644 --- a/packages/fold-core/src/AgentRuntime/AgentRuntimeLayer.ts +++ b/packages/fold-core/src/AgentRuntime/AgentRuntimeLayer.ts @@ -229,10 +229,17 @@ export const liveAgentRuntimeLayer: Layer.Layer< entries: ReadonlyArray, model: ActiveModel | null, trigger: CompactionTrigger, + additionalInstructions: string | null = null, ): Effect.Effect => Effect.gen(function* () { const planned = yield* compaction - .plan({ agentId: input.agentId, entries, model, trigger }) + .plan({ + agentId: input.agentId, + entries, + model, + trigger, + additionalInstructions, + }) .pipe(Effect.provideService(LanguageModel.LanguageModel, languageModel), Effect.result) if (Result.isFailure(planned)) { @@ -655,7 +662,13 @@ export const liveAgentRuntimeLayer: Layer.Layer< const entries = yield* collectEntries const runtimeState = runtimeForAgent(entries, input.agentId) - return yield* performCompaction(input, entries, runtimeState.activeModel, input.trigger) + return yield* performCompaction( + input, + entries, + runtimeState.activeModel, + input.trigger, + input.additionalInstructions ?? null, + ) }), ) diff --git a/packages/fold-core/src/AgentRuntime/AgentRuntimeService.ts b/packages/fold-core/src/AgentRuntime/AgentRuntimeService.ts index 091b0cd..fa28298 100644 --- a/packages/fold-core/src/AgentRuntime/AgentRuntimeService.ts +++ b/packages/fold-core/src/AgentRuntime/AgentRuntimeService.ts @@ -66,6 +66,7 @@ export type CompactAgentInput = { readonly parentAgentId: AgentId | null readonly toolCallId: ToolCallId | null readonly trigger: CompactionTrigger + readonly additionalInstructions?: string | null } /** diff --git a/packages/fold-core/src/Api/AgentDefinition.ts b/packages/fold-core/src/Api/AgentDefinition.ts index 593f682..d5a3c7c 100644 --- a/packages/fold-core/src/Api/AgentDefinition.ts +++ b/packages/fold-core/src/Api/AgentDefinition.ts @@ -36,7 +36,7 @@ export type AgentDefinition = { */ readonly basePrompts?: Partial> /** - * Auto-compaction policy (D11). Omitted means disabled. When enabled, every agent in the session - + * Auto-compaction policy (D11). Omitted means enabled with Fold defaults. When enabled, every agent in the session - * root and subagents alike - compacts near its model's context limit: old history is summarized * (pi's structured checkpoint template by default; `compactionPrompt` replaces it) into a durable * `compaction` entry, and subsequent requests see the summary plus only the messages after the cut. diff --git a/packages/fold-core/src/Api/StartSession.ts b/packages/fold-core/src/Api/StartSession.ts index 9007ee9..c185ac9 100644 --- a/packages/fold-core/src/Api/StartSession.ts +++ b/packages/fold-core/src/Api/StartSession.ts @@ -185,6 +185,10 @@ export type AgentTargetOptions = { readonly agentId?: AgentId | string } +export type CompactOptions = { + readonly additionalInstructions?: string | null +} + export type InjectedSkillEntries = { readonly call: AssistantMessageLogEntry readonly result: ToolResultLogEntry @@ -245,7 +249,7 @@ export type FoldSession = { */ readonly switchModel: (model: FoldModel, options?: SwitchModelOptions) => Effect.Effect /** Force a root-agent compaction now. Returns null when there is nothing safe to summarize. */ - readonly compact: () => Effect.Effect + readonly compact: (options?: CompactOptions) => Effect.Effect /** * Rebind one profile role to a different model (profiles slice). Role-bound subagent types resolve * their binding at each dispatch/resume, so the swap applies from the very next run. No gating or @@ -908,8 +912,8 @@ const makeSessionHandle = (graph: SessionGraph, identity: StartedSession): FoldS ), ) - const compact = (): Effect.Effect => - gate.withPermit(session.compact().pipe(Effect.orDie)) + const compact = (options?: CompactOptions): Effect.Effect => + gate.withPermit(session.compact(options).pipe(Effect.orDie)) // Deliberately un-gated (unlike switchModel): role bindings are read at dispatch/resume time, so a // racing dispatch coherently gets the old or the new binding and nothing mid-run ever rebinds. diff --git a/packages/fold-core/src/Compaction/CompactionLayer.ts b/packages/fold-core/src/Compaction/CompactionLayer.ts index 664eafa..f3451ec 100644 --- a/packages/fold-core/src/Compaction/CompactionLayer.ts +++ b/packages/fold-core/src/Compaction/CompactionLayer.ts @@ -32,7 +32,6 @@ import { } from './CompactionPrompts' import { CompactionSummarizeError, - noopCompaction, type AutoCompactConfig, type CompactionCheckInput, type CompactionPlan, @@ -118,17 +117,25 @@ export const makeCompactionService = (config: EnabledAutoCompactConfig): Compact const modelOutputLimit = yield* modelOutputLimitFor(input) const maxOutputTokens = Math.min(Math.floor(outputFraction * reserveTokens), modelOutputLimit) const languageModel = yield* LanguageModel.LanguageModel - const request = Stream.runCollect( + const baseRequest = Stream.runCollect( languageModel.streamText({ prompt: Prompt.fromMessages([ Prompt.systemMessage({ content: compactionSystemPrompt }), Prompt.userMessage({ content: [Prompt.textPart({ text: requestText })] }), ]), }), - ).pipe( - input.model?.providerKind === 'anthropic' - ? AnthropicLanguageModel.withConfigOverride({ max_tokens: maxOutputTokens }) - : OpenAiLanguageModel.withConfigOverride({ max_output_tokens: maxOutputTokens }), + ) + let configuredRequest = baseRequest + if (input.model?.providerKind === 'anthropic') { + configuredRequest = baseRequest.pipe( + AnthropicLanguageModel.withConfigOverride({ max_tokens: maxOutputTokens }), + ) + } else if (input.model?.providerKind !== 'codex') { + configuredRequest = baseRequest.pipe( + OpenAiLanguageModel.withConfigOverride({ max_output_tokens: maxOutputTokens }), + ) + } + const request = configuredRequest.pipe( Effect.mapError((error) => new CompactionSummarizeError({ message: describeSummarizerError(error) })), ) const parts = yield* request @@ -169,12 +176,19 @@ export const makeCompactionService = (config: EnabledAutoCompactConfig): Compact ) const cut = findCompactionCutPlan(conversation, keepRecentTokens) - if (cut.firstKeptIndex <= 0) return null - - const historyEnd = cut.isSplitTurn ? cut.turnStartIndex : cut.firstKeptIndex + const summarizeCompleteConversation = + input.trigger === 'manual' && cut.firstKeptIndex <= 0 && conversation.length >= 2 + if (cut.firstKeptIndex <= 0 && !summarizeCompleteConversation) return null + + const firstKeptIndex = summarizeCompleteConversation ? conversation.length : cut.firstKeptIndex + const historyEnd = summarizeCompleteConversation + ? conversation.length + : cut.isSplitTurn + ? cut.turnStartIndex + : firstKeptIndex const toSummarize = conversation.slice(0, historyEnd) - const turnPrefix = cut.isSplitTurn ? conversation.slice(cut.turnStartIndex, cut.firstKeptIndex) : [] - const discarded = conversation.slice(0, cut.firstKeptIndex) + const turnPrefix = cut.isSplitTurn ? conversation.slice(cut.turnStartIndex, firstKeptIndex) : [] + const discarded = conversation.slice(0, firstKeptIndex) const lastReplaced = discarded[discarded.length - 1] if (lastReplaced === undefined) return null @@ -190,8 +204,13 @@ export const makeCompactionService = (config: EnabledAutoCompactConfig): Compact conversationText: serializeConversation(toSummarize), previousSummary, customPrompt: config.compactionPrompt ?? null, + additionalInstructions: input.additionalInstructions ?? null, + }) + const prompt = compactionInstruction({ + previousSummary, + customPrompt: config.compactionPrompt ?? null, + additionalInstructions: input.additionalInstructions ?? null, }) - const prompt = compactionInstruction({ previousSummary, customPrompt: config.compactionPrompt ?? null }) const historySummary = toSummarize.length > 0 @@ -223,6 +242,10 @@ export const makeCompactionService = (config: EnabledAutoCompactConfig): Compact return { enabled: true, shouldCompact, plan } } -/** Resolve an agent definition's `autoCompact` config to the service the session should install. */ -export const compactionServiceFor = (config: AutoCompactConfig | undefined): CompactionService => - config === undefined || !config.enabled ? noopCompaction : makeCompactionService(config) +/** Resolve an agent definition's automatic policy while retaining an explicit compaction planner. */ +export const compactionServiceFor = (config: AutoCompactConfig | undefined): CompactionService => { + const live = makeCompactionService(config?.enabled === true ? config : { enabled: true }) + if (config?.enabled !== false) return live + + return { ...live, enabled: false, shouldCompact: () => Effect.succeed(false) } +} diff --git a/packages/fold-core/src/Compaction/CompactionPrompts.ts b/packages/fold-core/src/Compaction/CompactionPrompts.ts index 47578d2..868b1e9 100644 --- a/packages/fold-core/src/Compaction/CompactionPrompts.ts +++ b/packages/fold-core/src/Compaction/CompactionPrompts.ts @@ -37,6 +37,11 @@ Use this EXACT format: ## Key Decisions - **[Decision]**: [Brief rationale] +## Important Tactics & Commands +- [Proven commands, useful investigation or implementation tactics, important command flags, and operational details worth reusing] +- [Failed approaches or commands to avoid, including why they failed when that prevents repeated work] +- [Or "(none)" if there is nothing important to preserve] + ## Next Steps 1. [Ordered list of what should happen next] @@ -44,7 +49,7 @@ Use this EXACT format: - [Any data, examples, or references needed to continue] - [Or "(none)" if not applicable] -Keep each section concise. Preserve exact file paths, function names, and error messages.` +Keep each section concise. Preserve exact file paths, function names, commands, command flags, and error messages. Record only tactics and commands that help the next agent act correctly or avoid repeating failed work.` /** * Instruction when a previous summary exists (pi's `UPDATE_SUMMARIZATION_PROMPT`, verbatim). The new @@ -56,6 +61,7 @@ Update the existing structured summary with new information. RULES: - PRESERVE all existing information from the previous summary - ADD new progress, decisions, and context from the new messages - UPDATE the Progress section: move items from "In Progress" to "Done" when completed +- PRESERVE still-relevant Important Tactics & Commands, ADD newly proven tactics and commands, and REMOVE stale entries - UPDATE "Next Steps" based on what was accomplished - PRESERVE exact file paths, function names, and error messages - If something is no longer relevant, you may remove it @@ -81,13 +87,18 @@ Use this EXACT format: ## Key Decisions - **[Decision]**: [Brief rationale] (preserve all previous, add new) +## Important Tactics & Commands +- [Preserve still-relevant commands and tactics, add newly proven ones, and remove stale entries] +- [Preserve failed approaches worth avoiding and why they failed] +- [Or "(none)" if there is nothing important to preserve] + ## Next Steps 1. [Update based on current state] ## Critical Context - [Preserve important context, add new if needed] -Keep each section concise. Preserve exact file paths, function names, and error messages.` +Keep each section concise. Preserve exact file paths, function names, commands, command flags, and error messages.` /** Instruction for separately summarizing the discarded prefix of an oversized recent turn (pi). */ export const turnPrefixCompactionPrompt = `This is the PREFIX of a turn that was too large to keep. The SUFFIX (recent work) is retained. @@ -113,13 +124,25 @@ export type CompactionRequestTextInput = { readonly previousSummary: string | null /** Replaces the default instruction template when the agent configured `compactionPrompt`. */ readonly customPrompt: string | null + /** Host guidance appended to the resolved instruction for this compaction only. */ + readonly additionalInstructions?: string | null } +/** Stable separator used when a host adds guidance to Fold's compaction instruction. */ +export const additionalCompactionInstructionsHeading = 'Additional user guidance for this compaction:' + /** Resolve the exact instruction template shown to the summarizer. */ export const compactionInstruction = ( - input: Pick, -): string => - input.customPrompt ?? (input.previousSummary === null ? defaultCompactionPrompt : defaultCompactionUpdatePrompt) + input: Pick, +): string => { + const base = + input.customPrompt ?? (input.previousSummary === null ? defaultCompactionPrompt : defaultCompactionUpdatePrompt) + const guidance = input.additionalInstructions?.trim() + + return guidance === undefined || guidance.length === 0 + ? base + : `${base}\n\n${additionalCompactionInstructionsHeading}\n${guidance}` +} /** * Assemble the summarizer's user message (pi's `generateSummary` shape): the serialized conversation, diff --git a/packages/fold-core/src/Compaction/CompactionService.ts b/packages/fold-core/src/Compaction/CompactionService.ts index 89612cf..79d15d7 100644 --- a/packages/fold-core/src/Compaction/CompactionService.ts +++ b/packages/fold-core/src/Compaction/CompactionService.ts @@ -19,8 +19,8 @@ import type { ActiveModel, LogEntry, LogSeq } from '../EventLog/Schemas' import type { AgentId } from '../Ids' /** - * Auto-compaction configuration on an agent definition. Omitted (`undefined`) means disabled - the - * D11 disabled-by-default ruling. One config applies to the whole session: the root agent and every + * Auto-compaction configuration on an agent definition. Omitted (`undefined`) uses the enabled + * defaults. One config applies to the whole session: the root agent and every * subagent compact under the same policy, each against its own projection and its own model's * context window. */ @@ -59,6 +59,8 @@ export type CompactionCheckInput = { /** Input for building one compaction: the check input plus what caused it. */ export type CompactionPlanInput = CompactionCheckInput & { readonly trigger: CompactionTrigger + /** Optional guidance appended to the resolved standard instruction for this compaction only. */ + readonly additionalInstructions?: string | null } /** The payload of one durable `compaction` entry, ready for the loop to append. */ @@ -81,7 +83,8 @@ export class CompactionSummarizeError extends Schema.TaggedError Effect.Effect } -/** The disabled default: never compacts, and `plan` is unreachable (the loop gates on the checks). */ +/** Low-level fallback for graphs that do not install an agent policy. */ export const noopCompaction: CompactionService = { enabled: false, shouldCompact: () => Effect.succeed(false), @@ -106,9 +109,8 @@ export const noopCompaction: CompactionService = { } /** - * Compaction service key with the no-op default (D11: optional, disabled by default). Sessions - * started with `autoCompact: { enabled: true, ... }` provide the live service; everything else - - * including low-level layer graphs that never mention compaction - resolves the no-op. + * Compaction service key with a no-op fallback. Public session composition always installs a live + * planner so explicit compaction is available regardless of automatic policy. */ export const Compaction: Context.Reference = Context.Reference('fold/Compaction', { defaultValue: () => noopCompaction, diff --git a/packages/fold-core/src/Session/SessionLayer.ts b/packages/fold-core/src/Session/SessionLayer.ts index 8223bad..4e7b257 100644 --- a/packages/fold-core/src/Session/SessionLayer.ts +++ b/packages/fold-core/src/Session/SessionLayer.ts @@ -145,7 +145,7 @@ export const liveSessionLayer: Layer.Layer + const compact: SessionService['compact'] = Effect.fn('fold.session.compact')((options) => Effect.gen(function* () { const started = yield* Ref.get(startedRef) @@ -158,6 +158,7 @@ export const liveSessionLayer: Layer.Layer Effect.Effect readonly send: (input: { readonly text: string }) => Effect.Effect readonly switchModel: (input: SwitchSessionModelInput) => Effect.Effect - readonly compact: () => Effect.Effect + readonly compact: ( + options?: CompactSessionOptions, + ) => Effect.Effect readonly events: (fromSeq?: LogSeq) => Stream.Stream } diff --git a/packages/fold-core/test/Compaction/AutoCompactSession.vi.test.ts b/packages/fold-core/test/Compaction/AutoCompactSession.vi.test.ts index dd85f5e..1013f30 100644 --- a/packages/fold-core/test/Compaction/AutoCompactSession.vi.test.ts +++ b/packages/fold-core/test/Compaction/AutoCompactSession.vi.test.ts @@ -36,6 +36,14 @@ import { import { failureTurn, textTurn, toolCallTurn } from '../TestLayers/ScriptedLanguageModel' import { echoTool, gptActiveModel, scriptedModel } from './../Api/ApiTestHelpers' +const codexActiveModel = { + ...gptActiveModel, + providerId: 'codex', + providerKind: 'codex' as const, + requestedReasoningLevel: 'off' as const, + reasoning: { _tag: 'disabled' as const }, +} + /** * Small-window config for deterministic triggering: usable = 10000 - 2500 - 1250 = 6250 tokens, so * a scripted response reporting ~7000 input tokens trips the threshold, and a 10-token keep budget @@ -245,36 +253,48 @@ it.effect('stale pre-compaction usage never re-triggers: no second compaction wi }).pipe(Effect.scoped, Effect.provide(NodeFileSystem.layer)), ) -it.effect('compaction is off by default and with enabled: false, even under huge reported usage', () => +it.effect('enabled: false disables automatic compaction while leaving explicit compaction available', () => Effect.gen(function* () { - const runWithout = (autoCompact: AutoCompactConfig | undefined) => - Effect.gen(function* () { - const { model, scripted } = yield* scriptedModel(gptActiveModel, [ - textTurn('first', hugeUsage), - textTurn('second'), - ]) - const session = yield* startSession({ - agent: defineAgent({ - model, - ...(autoCompact === undefined ? {} : { autoCompact }), - }), - }) + const { model, scripted } = yield* scriptedModel(gptActiveModel, [ + textTurn('first', hugeUsage), + textTurn('second'), + textTurn('manual summary'), + ]) + const session = yield* startSession({ + agent: defineAgent({ model, autoCompact: { enabled: false } }), + }) - yield* session.send('one: the anchor phrase') - const finished = yield* session.send('two') - const entries = yield* session.entries + yield* session.send('one: the anchor phrase') + const finished = yield* session.send('two') + expect(finished.outcome).toBe('completed') + expect(compactionEntries(yield* session.entries)).toHaveLength(0) - expect(finished.outcome).toBe('completed') - expect(compactionEntries(entries)).toHaveLength(0) + const compacted = yield* session.compact() + expect(compacted?.summary).toBe('manual summary') + expect(compactionEntries(yield* session.entries)).toHaveLength(1) - // The full history still reaches the model - nothing was cut or summarized. - const requests = yield* scripted.requests - expect(JSON.stringify(requests[1]?.prompt)).toContain('one: the anchor phrase') - }).pipe(Effect.scoped) + const requests = yield* scripted.requests + expect(JSON.stringify(requests[1]?.prompt)).toContain('one: the anchor phrase') + expect(yield* scripted.remainingTurns).toBe(0) + }).pipe(Effect.scoped, Effect.provide(NodeFileSystem.layer)), +) - yield* runWithout(undefined) - yield* runWithout({ enabled: false }) - }).pipe(Effect.provide(NodeFileSystem.layer)), +it.effect('automatic compaction is enabled when the definition omits autoCompact', () => + Effect.gen(function* () { + const { model, scripted } = yield* scriptedModel(gptActiveModel, [ + textTurn(`first ${'x'.repeat(100_000)}`, { inputTokens: 120_000 }), + textTurn('default policy summary'), + textTurn('second'), + ]) + const session = yield* startSession({ agent: defineAgent({ model }) }) + + yield* session.send('one') + const finished = yield* session.send('two') + + expect(finished.outcome).toBe('completed') + expect(compactionEntries(yield* session.entries)).toHaveLength(1) + expect(yield* scripted.remainingTurns).toBe(0) + }).pipe(Effect.scoped, Effect.provide(NodeFileSystem.layer)), ) it.effect('manual facade compaction delegates through the provisioned root runtime', () => @@ -306,6 +326,42 @@ it.effect('manual facade compaction delegates through the provisioned root runti }).pipe(Effect.scoped, Effect.provide(NodeFileSystem.layer)), ) +it.effect('manual compaction appends host guidance to the standard instruction without a follow-up turn', () => + Effect.gen(function* () { + const { model, scripted } = yield* scriptedModel(gptActiveModel, [ + textTurn('first answer'), + textTurn('guided summary'), + ]) + const session = yield* startSession({ agent: defineAgent({ model }) }) + + yield* session.send('preserve the migration investigation') + const compacted = yield* session.compact({ additionalInstructions: 'Keep the failed migration command.' }) + + expect(compacted?.prompt).toContain('Additional user guidance for this compaction:') + expect(compacted?.prompt?.endsWith('Keep the failed migration command.')).toBe(true) + const requests = yield* scripted.requests + expect(JSON.stringify(requests[1]?.prompt)).toContain('Keep the failed migration command.') + expect(requests).toHaveLength(2) + expect(yield* scripted.remainingTurns).toBe(0) + }).pipe(Effect.scoped, Effect.provide(NodeFileSystem.layer)), +) + +it.effect('Codex compaction omits the unsupported max output token override', () => + Effect.gen(function* () { + const { model, scripted } = yield* scriptedModel(codexActiveModel, [ + textTurn('first answer'), + textTurn('Codex summary'), + ]) + const session = yield* startSession({ agent: defineAgent({ model }) }) + + yield* session.send('preserve the investigation') + yield* session.compact() + + const requests = yield* scripted.requests + expect(requests[1]?.openAiConfig?.max_output_tokens).toBeUndefined() + }).pipe(Effect.scoped, Effect.provide(NodeFileSystem.layer)), +) + it.effect('split-turn compaction separately summarizes a coherent discarded prefix and keeps its suffix', () => Effect.gen(function* () { const { model, scripted } = yield* scriptedModel(gptActiveModel, [