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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 15 additions & 2 deletions packages/fold-core/src/AgentRuntime/AgentRuntimeLayer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -229,10 +229,17 @@ export const liveAgentRuntimeLayer: Layer.Layer<
entries: ReadonlyArray<LogEntry>,
model: ActiveModel | null,
trigger: CompactionTrigger,
additionalInstructions: string | null = null,
): Effect.Effect<CompactionLogEntry | null> =>
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)) {
Expand Down Expand Up @@ -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,
)
}),
)

Expand Down
1 change: 1 addition & 0 deletions packages/fold-core/src/AgentRuntime/AgentRuntimeService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,7 @@ export type CompactAgentInput = {
readonly parentAgentId: AgentId | null
readonly toolCallId: ToolCallId | null
readonly trigger: CompactionTrigger
readonly additionalInstructions?: string | null
}

/**
Expand Down
2 changes: 1 addition & 1 deletion packages/fold-core/src/Api/AgentDefinition.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@ export type AgentDefinition = {
*/
readonly basePrompts?: Partial<Record<ModelFamily, string>>
/**
* 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.
Expand Down
10 changes: 7 additions & 3 deletions packages/fold-core/src/Api/StartSession.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -245,7 +249,7 @@ export type FoldSession = {
*/
readonly switchModel: (model: FoldModel, options?: SwitchModelOptions) => Effect.Effect<void>
/** Force a root-agent compaction now. Returns null when there is nothing safe to summarize. */
readonly compact: () => Effect.Effect<CompactionLogEntry | null>
readonly compact: (options?: CompactOptions) => Effect.Effect<CompactionLogEntry | null>
/**
* 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
Expand Down Expand Up @@ -908,8 +912,8 @@ const makeSessionHandle = (graph: SessionGraph, identity: StartedSession): FoldS
),
)

const compact = (): Effect.Effect<CompactionLogEntry | null> =>
gate.withPermit(session.compact().pipe(Effect.orDie))
const compact = (options?: CompactOptions): Effect.Effect<CompactionLogEntry | null> =>
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.
Expand Down
53 changes: 38 additions & 15 deletions packages/fold-core/src/Compaction/CompactionLayer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,6 @@ import {
} from './CompactionPrompts'
import {
CompactionSummarizeError,
noopCompaction,
type AutoCompactConfig,
type CompactionCheckInput,
type CompactionPlan,
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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

Expand All @@ -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
Expand Down Expand Up @@ -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) }
}
33 changes: 28 additions & 5 deletions packages/fold-core/src/Compaction/CompactionPrompts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,14 +37,19 @@ 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]

## Critical Context
- [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
Expand All @@ -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
Expand All @@ -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.
Expand All @@ -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<CompactionRequestTextInput, 'previousSummary' | 'customPrompt'>,
): string =>
input.customPrompt ?? (input.previousSummary === null ? defaultCompactionPrompt : defaultCompactionUpdatePrompt)
input: Pick<CompactionRequestTextInput, 'previousSummary' | 'customPrompt' | 'additionalInstructions'>,
): 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,
Expand Down
16 changes: 9 additions & 7 deletions packages/fold-core/src/Compaction/CompactionService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
*/
Expand Down Expand Up @@ -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. */
Expand All @@ -81,7 +83,8 @@ export class CompactionSummarizeError extends Schema.TaggedError<CompactionSumma
) {}

/**
* Compaction operations consulted by the agent loop each turn.
* Compaction operations consulted by the agent loop each turn. `enabled` gates automatic threshold
* and overflow recovery only; explicit compaction remains available when it is false.
*
* `shouldCompact` is the cheap proactive gate: it compares the agent's last post-compaction
* API-reported usage against the model's usable budget and never calls a model. `plan` does the
Expand All @@ -98,17 +101,16 @@ export type CompactionService = {
) => Effect.Effect<CompactionPlan | null, CompactionSummarizeError, LanguageModel.LanguageModel>
}

/** 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),
plan: () => Effect.die(new Error('compaction is disabled: the no-op Compaction service cannot plan a compaction')),
}

/**
* 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<CompactionService> = Context.Reference('fold/Compaction', {
defaultValue: () => noopCompaction,
Expand Down
3 changes: 2 additions & 1 deletion packages/fold-core/src/Session/SessionLayer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -145,7 +145,7 @@ export const liveSessionLayer: Layer.Layer<Session, never, EventLog | Ids | Agen
}),
)

const compact: SessionService['compact'] = Effect.fn('fold.session.compact')(() =>
const compact: SessionService['compact'] = Effect.fn('fold.session.compact')((options) =>
Effect.gen(function* () {
const started = yield* Ref.get(startedRef)

Expand All @@ -158,6 +158,7 @@ export const liveSessionLayer: Layer.Layer<Session, never, EventLog | Ids | Agen
parentAgentId: null,
toolCallId: null,
trigger: 'manual',
additionalInstructions: options?.additionalInstructions ?? null,
})
}),
)
Expand Down
8 changes: 7 additions & 1 deletion packages/fold-core/src/Session/SessionService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,10 @@ export type SwitchSessionModelInput = {
readonly reason?: string | null
}

export type CompactSessionOptions = {
readonly additionalInstructions?: string | null
}

/**
* Public SDK facade for one session.
*
Expand All @@ -55,7 +59,9 @@ export type SessionService = {
readonly adopt: (input: StartedSession) => Effect.Effect<StartedSession, SessionAlreadyStartedError>
readonly send: (input: { readonly text: string }) => Effect.Effect<AgentFinishedLogEntry, SessionNotStartedError>
readonly switchModel: (input: SwitchSessionModelInput) => Effect.Effect<void, SessionNotStartedError>
readonly compact: () => Effect.Effect<CompactionLogEntry | null, SessionNotStartedError>
readonly compact: (
options?: CompactSessionOptions,
) => Effect.Effect<CompactionLogEntry | null, SessionNotStartedError>
readonly events: (fromSeq?: LogSeq) => Stream.Stream<FoldEvent>
}

Expand Down
Loading
Loading