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
46 changes: 46 additions & 0 deletions apps/sidecar/src/workflow-substrate-factory/compactors.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,11 @@ import { expect, test } from "bun:test";
import type { ConversationTurn, StrategyContext } from "@intx/types/runtime";

import {
SUMMARIZE_BUDGETED_TURNS_NAME,
SUMMARIZE_OLDER_TURNS_NAME,
createBudgetedContextCompactor,
createSummarizeOlderTurnsCompactor,
estimateTurnsChars,
} from "./compactors";

function textTurn(
Expand Down Expand Up @@ -118,3 +121,46 @@ test("carries the compactor's name and a version for the manifest record", () =>
expect(compactor.name).toBe(SUMMARIZE_OLDER_TURNS_NAME);
expect(compactor.version).toBe("1");
});

test("createBudgetedContextCompactor: a short conversation under budget is untouched", async () => {
const turns = Array.from({ length: 6 }, (_, i) =>
textTurn(i % 2 === 0 ? "user" : "assistant", `turn ${i}`, i),
);
const compactor = createBudgetedContextCompactor(
estimateTurnsChars(turns) + 1_000,
);

const result = await compactor.apply(turns, makeCtx());

expect(result.output).toEqual(turns);
expect(result.record.reason).toBe("within-budget");
});

test("createBudgetedContextCompactor: a conversation past the budget is folded rather than resent whole", async () => {
const turns = Array.from({ length: 40 }, (_, i) =>
textTurn(i % 2 === 0 ? "user" : "assistant", `message number ${i}`, i),
);
// A budget generous enough for only the newest handful of turns.
const budgetChars = estimateTurnsChars(turns.slice(-6));
const compactor = createBudgetedContextCompactor(budgetChars);

const result = await compactor.apply(turns, makeCtx());

expect(result.record.reason).toBe("folded-older-turns");
expect(result.record.strategy).toBe(SUMMARIZE_BUDGETED_TURNS_NAME);
expect(result.output.length).toBeLessThan(turns.length);
const [summary] = result.output;
expect(summary?.role).toBe("system");
});

test("createBudgetedContextCompactor: always keeps a minimum verbatim tail even under a near-zero budget", async () => {
const turns = Array.from({ length: 10 }, (_, i) =>
textTurn(i % 2 === 0 ? "user" : "assistant", `message ${i}`, i),
);
const compactor = createBudgetedContextCompactor(1);

const result = await compactor.apply(turns, makeCtx());

// 1 synthetic summary turn + at least the floor of kept turns.
expect(result.output.length).toBeGreaterThanOrEqual(2);
});
266 changes: 197 additions & 69 deletions apps/sidecar/src/workflow-substrate-factory/compactors.ts
Original file line number Diff line number Diff line change
@@ -1,41 +1,29 @@
// The `summarize-older-turns` compactor (CL-6204): a `Compactor` (per
// `@intx/types/runtime`'s `ContextStrategy<ConversationTurn[],
// ConversationTurn[]>`) the sidecar registers on `env.compactors` for
// long-lived channel agents (the assistant workflow's warm, durable-
// conversation step) so a director that names it via
// `caps.compact("summarize-older-turns", reason)` gets a bounded fold
// instead of the reactor growing history until the model window
// truncates it.
// Two `Compactor`s (per `@intx/types/runtime`'s `ContextStrategy<
// ConversationTurn[], ConversationTurn[]>`), both deterministic textual
// folds, not LLM calls: `ContextStrategy.apply` receives only `{ state,
// trigger }` (`StrategyContext`) -- no inference handle -- so an
// LLM-based summary is not cleanly available inside this seam today.
//
// Deterministic textual fold, not an LLM call: `ContextStrategy.apply`
// receives only `{ state, trigger }` (`StrategyContext`) -- no inference
// handle -- so an LLM-based summary is not cleanly available inside this
// seam today. A future version could route through a director-supplied
// summarizer if the runtime grows one; until then this fold keeps the
// newest `keep` turns verbatim and replaces everything older with one
// synthetic `system` turn built from bounded, deterministic per-turn
// excerpts.
// - `summarize-older-turns`: fixed `keep` (default 12), registered on
// `env.compactors` for a director that names it via
// `caps.compact("summarize-older-turns", reason)`.
// - `summarize-budgeted-turns` (CL-6204): the one actually fired, by
// `WorkbenchDirector` (see `./workbench-director`) via the same
// `caps.compact` reactor action. `@intx/agent`'s `createAgent` does
// not thread a `ContextTransform` seam through `BaseEnv` at all (only
// `env.compactors` reaches `createReactorAssembly` -- confirmed
// against `@intx/agent`'s `agent.js`), so the reactor's `compact`
// action is the only seam actually available; `WorkbenchDirector`
// fires it only at the one point in the reactor's event flow where
// `compact`'s exclusion from `infer` in the same cycle cannot stall a
// reply -- see that file's header comment for exactly which point and
// why.
//
// NB (the remaining CL-6204 gap): nothing calls `caps.compact` yet. The
// built-in `DefaultDirector` (`@intx/inference`'s
// `createDefaultDirector`, the only director our channel-host/assistant
// `AgentDefinition`s reference) never emits a `compact` action -- see
// `resolveDirector` in `@intx/agent/agent.ts`, which surfaces
// `compactorNames` to a director factory but the default factory's
// `decide()` never reads them. Firing compaction for real needs a
// director that decides to compact, which the reactor's control flow
// (`@intx/inference/src/reactor.ts` ~1375) only accepts as an
// action exclusive of `infer` in the same cycle, with no synthetic
// follow-up event afterward -- so a director cannot fire it on
// `message.received` without stalling that message's reply. Registering
// this compactor makes the name resolvable and the fold available; the
// trigger itself needs either a vendor-side chained action/event after
// `executeCompact`, or a definition-side custom director authored via
// `defineDirector`/`createWorkflowDirectorRegistry` that fires compact
// on a cycle that doesn't owe an immediate reply (e.g. `tool.done`
// before the batch's re-infer would still hit the same pairing
// restriction, so even that needs the vendor seam to grow a hook this
// registry alone cannot supply).
// Both folds are deliberately lossy -- a bounded recap of what was
// active in the folded turns, not a durable record. Anything a channel
// agent needs to recall past the working window is firm memory's job
// (`@corbits/memory-tools`'s `memory_search`/`memory_add`, already
// pinned for the assistant workflow), not this recap's.

import type {
Compactor,
Expand All @@ -61,6 +49,52 @@ export type SummarizeOlderTurnsOptions = {
maxExcerptCharsPerTurn?: number;
};

export const SUMMARIZE_BUDGETED_TURNS_NAME = "summarize-budgeted-turns";
const SUMMARIZE_BUDGETED_TURNS_VERSION = "1";
const MIN_KEPT_TURNS = 4;

function turnChars(turn: ConversationTurn): number {
let total = 0;
for (const block of turn.content) {
total += excerptBlock(block).length;
}
return total;
}

/** Total character length of a turn list -- the budget check's estimate. */
export function estimateTurnsChars(turns: ConversationTurn[]): number {
let total = 0;
for (const turn of turns) {
total += turnChars(turn);
}
return total;
}

/**
* Newest-first walk that keeps turns while their combined length stays
* under `budgetChars`, with a floor of `MIN_KEPT_TURNS` so the most
* recent exchange is never folded away even when it alone exceeds
* budget (the honest-overflow case a director checks separately).
*/
function countTurnsWithinBudget(
turns: ConversationTurn[],
budgetChars: number,
): number {
let runningChars = 0;
let kept = 0;
for (let i = turns.length - 1; i >= 0; i--) {
const turn = turns[i];
if (turn === undefined) continue;
const chars = turnChars(turn);
if (kept >= MIN_KEPT_TURNS && runningChars + chars > budgetChars) {
break;
}
runningChars += chars;
kept += 1;
}
return kept;
}

function roleLabel(role: ConversationTurn["role"]): string {
return role === "user"
? "User"
Expand Down Expand Up @@ -139,41 +173,135 @@ export function createSummarizeOlderTurnsCompactor(
};
}

const splitAt = turns.length - keep;
const older = turns.slice(0, splitAt);
const kept = turns.slice(splitAt);

const lines = older.map(
(turn, i) =>
`${String(i + 1)}. ${roleLabel(turn.role)}: ${excerptTurn(turn, maxExcerptCharsPerTurn)}`,
);
let summaryText =
`[Summary of ${String(older.length)} earlier turns, folded to stay ` +
`within context]\n${lines.join("\n")}`;
if (summaryText.length > maxSummaryChars) {
summaryText = `${summaryText.slice(0, maxSummaryChars - 1)}…`;
}
return foldOlderTurns(turns, keep, {
strategy: SUMMARIZE_OLDER_TURNS_NAME,
version: SUMMARIZE_OLDER_TURNS_VERSION,
maxSummaryChars,
maxExcerptCharsPerTurn,
});
},
};
}

const summaryTurn: ConversationTurn = {
role: "system",
content: [{ type: "text", text: summaryText }],
timestamp: older[0]?.timestamp ?? Date.now(),
};

return {
output: [summaryTurn, ...kept],
record: {
strategy: SUMMARIZE_OLDER_TURNS_NAME,
version: SUMMARIZE_OLDER_TURNS_VERSION,
parameters: { keep, maxSummaryChars, maxExcerptCharsPerTurn },
reason: "folded-older-turns",
decisions: {
foldedCount: older.length,
keptCount: kept.length,
summaryChars: summaryText.length,
/**
* Shared fold: keeps the newest `keep` turns verbatim and replaces
* everything older with one synthetic `system` turn of numbered,
* per-turn excerpts, truncated to `maxSummaryChars`. The recap is
* deliberately lossy -- it is a bounded reminder of what was active in
* the folded turns, not a durable record. Anything a channel agent
* needs to recall past the working window belongs in firm memory
* (`@corbits/memory-tools`'s `memory_search`/`memory_add`), not in this
* recap.
*/
function foldOlderTurns(
turns: ConversationTurn[],
keep: number,
meta: {
strategy: string;
version: string;
maxSummaryChars: number;
maxExcerptCharsPerTurn: number;
},
): StrategyResult<ConversationTurn[]> {
const splitAt = turns.length - keep;
const older = turns.slice(0, splitAt);
const kept = turns.slice(splitAt);

const lines = older.map(
(turn, i) =>
`${String(i + 1)}. ${roleLabel(turn.role)}: ${excerptTurn(turn, meta.maxExcerptCharsPerTurn)}`,
);
let summaryText =
`[Summary of ${String(older.length)} earlier turns, folded to stay ` +
`within context]\n${lines.join("\n")}`;
if (summaryText.length > meta.maxSummaryChars) {
summaryText = `${summaryText.slice(0, meta.maxSummaryChars - 1)}…`;
}

const summaryTurn: ConversationTurn = {
role: "system",
content: [{ type: "text", text: summaryText }],
timestamp: older[0]?.timestamp ?? Date.now(),
};

return {
output: [summaryTurn, ...kept],
record: {
strategy: meta.strategy,
version: meta.version,
parameters: {
keep,
maxSummaryChars: meta.maxSummaryChars,
maxExcerptCharsPerTurn: meta.maxExcerptCharsPerTurn,
},
reason: "folded-older-turns",
decisions: {
foldedCount: older.length,
keptCount: kept.length,
summaryChars: summaryText.length,
},
},
};
}

export type BudgetedContextCompactorOptions = {
/** Hard cap on the synthetic summary turn's total text length. */
maxSummaryChars?: number;
/** Per-folded-turn excerpt cap, applied before the total cap. */
maxExcerptCharsPerTurn?: number;
};

/**
* Builds a `summarize-budgeted-turns` `Compactor`: folds turns older
* than the newest ones that fit `budgetChars` (with a floor of
* `MIN_KEPT_TURNS` verbatim), instead of a fixed turn count.
* `budgetChars` should come from `resolveContextBudgetChars` so the
* fold point tracks the model's actual context window rather than one
* constant shared by every model.
*
* Registered on `env.compactors` (see `./step-env`) and fired by
* `WorkbenchDirector` via `caps.compact("summarize-budgeted-turns", …)`
* only at the one point in the reactor's event flow where doing so
* cannot stall a reply -- see `./workbench-director`'s header comment.
*/
export function createBudgetedContextCompactor(
budgetChars: number,
options: BudgetedContextCompactorOptions = {},
): Compactor {
const maxSummaryChars = options.maxSummaryChars ?? DEFAULT_MAX_SUMMARY_CHARS;
const maxExcerptCharsPerTurn =
options.maxExcerptCharsPerTurn ?? DEFAULT_MAX_EXCERPT_CHARS_PER_TURN;

return {
name: SUMMARIZE_BUDGETED_TURNS_NAME,
version: SUMMARIZE_BUDGETED_TURNS_VERSION,
async apply(
turns: ConversationTurn[],
_ctx: StrategyContext,
): Promise<StrategyResult<ConversationTurn[]>> {
const keep = countTurnsWithinBudget(turns, budgetChars);
if (keep >= turns.length) {
return {
output: turns,
record: {
strategy: SUMMARIZE_BUDGETED_TURNS_NAME,
version: SUMMARIZE_BUDGETED_TURNS_VERSION,
parameters: {
budgetChars,
maxSummaryChars,
maxExcerptCharsPerTurn,
},
reason: "within-budget",
decisions: { turnCount: turns.length },
},
},
};
};
}
return foldOlderTurns(turns, keep, {
strategy: SUMMARIZE_BUDGETED_TURNS_NAME,
version: SUMMARIZE_BUDGETED_TURNS_VERSION,
maxSummaryChars,
maxExcerptCharsPerTurn,
});
},
};
}
Loading
Loading