agentfootprint
Version:
The explainable agent framework — backtrack a wrong answer to the exact context that caused it (evidence, not guesses). Built on footprintjs.
181 lines • 9.4 kB
JavaScript
;
/**
* window/strategies/drop — the mechanic both drop strategies share.
*
* Pattern: One pure function over a `WindowStrategyInput`.
* Role: core/ layer. `slidingWindow` and `tokenBudget` differ ONLY in when
* they engage and what they file; what actually leaves the window is
* decided here, once, so a refusal reason means the same thing under
* both — and under compaction, which asks the same `planRemoval`.
* Emits: N/A.
*
* A drop makes no LLM call and writes no summary. What it must still do is
* keep the request valid and keep the record honest:
*
* • the span comes from the shared refusal engine, so the CURRENT REQUEST,
* an unanswered tool call, the paused tool, a pending check-in and the
* recent turns never leave, and each refusal is named;
* • when the span reaches the front of what may leave, an authored notice
* takes that position — the window must open on a user turn (see
* notice.ts), and a message we are forced to author should tell the
* truth. That position is the window's HEAD in the ordinary case, and the
* slot just after the kept request when the request was the head;
* • when it does not, nothing is inserted: the original opening turn is
* still there, so there is no wire problem to solve.
*
* ## The notice is owed to the WIRE, not to politeness (9.57.0)
*
* That distinction used to be missing here, and it produced a livelock. The
* notice was authored whenever the removal reached the front of what may
* leave, and the whole removal was abandoned when the notice was not smaller
* than the span — but the obligation being guarded is narrower: **the window
* must open on a user turn.** When the message that would BECOME the head is
* already a user turn (the kept request, or an older user turn from a restored
* conversation), no notice is owed at all, and its 245–358 characters had no
* business vetoing a legitimate drop.
*
* Because the span is the longest CONTIGUOUS removable run, a blocker one turn
* along caps it at the same small span at every boundary — so the same verdict
* came back for ever and the window grew without bound. Two locally-correct
* rules composing into non-progress.
*
* So the decision is a LADDER: the rich notice, else the plain one, else no
* notice at all — and `'replacement-not-smaller'` only when the wire genuinely
* needs a message in that position and none of them is smaller than the span.
*/
Object.defineProperty(exports, "__esModule", { value: true });
exports.dropOldestSpan = void 0;
const notice_js_1 = require("../notice.js");
const toolNames_js_1 = require("../toolNames.js");
const removal_js_1 = require("../removal.js");
const turns_js_1 = require("../turns.js");
/**
* The first rung of the ladder that is SMALLER than the span it would replace,
* or `undefined` when none is.
*
* "Smaller", not "no larger": a notice exactly the size of the span buys
* nothing and costs the turns it replaced.
*/
function fittingNotice(spanChars, facts, keptRequestAtHead, toolNames) {
// The kept-request sentence is not a courtesy and is never traded away for
// size: a model that reads "3 earlier messages were dropped" and then finds
// a request above it has to be told which of the two facts to trust. The
// TOOL NAMES are the rung that can be traded — losing them costs the model
// a hint, while losing the drop costs it the whole window.
const base = { ...facts, ...(keptRequestAtHead && { currentRequestKept: true }) };
if (toolNames.length > 0) {
const rich = (0, notice_js_1.buildDropNotice)({ ...base, toolNames });
if (rich.content.length < spanChars)
return rich;
}
const plain = (0, notice_js_1.buildDropNotice)(base);
return plain.content.length < spanChars ? plain : undefined;
}
/**
* Drop the oldest contiguous removable span, if there is one.
*
* @param input the strategy's input, with the bound refusal engine
* @param keepRecentTurns how many trailing turns are off-limits
* @param strategyName named in the authored notice, so a reader of the
* window itself can tell which policy removed things
*/
function dropOldestSpan(input, keepRecentTurns, strategyName) {
const { history, turns, iteration } = input;
const charsBefore = (0, turns_js_1.windowChars)(history);
const nothing = (refusals) => ({
refusals,
removedStageIds: [],
removedMessageCount: 0,
evictions: [],
windowCharsBefore: charsBefore,
windowCharsAfter: charsBefore,
turnsBefore: turns.length,
turnsAfter: turns.length,
});
// No `isExistingSummary` predicate: that refusal exists to stop a strategy
// spending an LLM call to re-summarize a summary. A drop spends nothing, so
// there is nothing to protect against — a stale summary is as droppable as
// any other old turn.
const plan = input.planRemoval(keepRecentTurns);
if (plan.from === -1)
return nothing(plan.refusals);
const spanStart = turns[plan.from].start;
const spanEnd = turns[plan.to].start + turns[plan.to].length;
const head = history.slice(0, spanStart);
const span = history.slice(spanStart, spanEnd);
const tail = history.slice(spanEnd);
let window;
let insertedAtMs;
const droppedAtMs = input.now();
// The drop stopped one turn short of the head because the CURRENT REQUEST
// was sitting there and refused (9.55.0). The wire is fine — the request is
// a user turn, so the window still opens on one — but the notice is still
// owed: this removal reaches the front of everything that MAY leave, and a
// model that reads the request must also be told what went missing under
// it. Read off the refusal the engine already filed rather than re-derived,
// so the notice can only move for the reason that actually moved the span.
const keptRequestAtHead = plan.from === 1 &&
plan.refusals.some((r) => r.reason === 'current-request' && r.turnIndex === 0);
if (spanStart === 0 || keptRequestAtHead) {
// The front of the droppable window is leaving. Ask the two questions
// separately, because they have different answers:
//
// 1. does the WIRE need a message in the head position? Only when the
// message that would become the head is not a user turn already.
// `head[0]` is the kept request on the 9.55.0 path (a user turn by
// definition); on the ordinary path `head` is empty and the new head
// is the first survivor. An empty result (reachable only from a
// strategy that keeps zero recent turns) counts as needing one.
// 2. does a notice FIT — is it smaller than what it replaces? Dropping
// two tiny turns to insert a longer notice is pure loss.
const newHead = head[0] ?? tail[0];
const wireNeedsNotice = newHead === undefined || newHead.role !== 'user';
const spanChars = (0, turns_js_1.windowChars)(span);
// The ladder. The rich notice is the one that names the tools whose
// results left; the plain one drops that sentence and keeps the rest.
const notice = fittingNotice(spanChars, { droppedMessageCount: span.length, iteration, strategy: strategyName }, keptRequestAtHead, (0, toolNames_js_1.droppedToolNames)(span, history));
if (notice !== undefined) {
// `head` is empty on the ordinary path, so this is byte-for-byte the
// `[notice, ...tail]` this function has always produced; when the
// request was kept it is `[request, notice, ...tail]`.
window = [...head, notice, ...tail];
insertedAtMs = droppedAtMs;
}
else if (wireNeedsNotice) {
// `'replacement-not-smaller'` since 8.14.0. A drop writes no summary —
// it never calls a summarizer at all — and the reason's old spelling
// (`'summary-not-smaller'`) named one that does not exist on this path.
return nothing([
{ reason: 'replacement-not-smaller', turnIndex: plan.from, messageIndex: spanStart },
...plan.refusals,
]);
}
else {
// Nothing is owed and nothing fits: drop silently. The record still
// names what left, which is where a reader looks anyway.
window = [...head, ...tail];
}
}
else {
window = [...head, ...tail];
}
const facts = input.removalFacts((0, removal_js_1.indexRange)(spanStart, spanEnd), droppedAtMs);
return {
refusals: plan.refusals,
window,
rebase: {
headCount: head.length,
keptTailCount: tail.length,
...(insertedAtMs !== undefined && { insertedAtMs }),
},
removedStageIds: facts.removedStageIds,
removedMessageCount: span.length,
evictions: facts.evictions,
windowCharsBefore: charsBefore,
windowCharsAfter: (0, turns_js_1.windowChars)(window),
turnsBefore: turns.length,
turnsAfter: (0, turns_js_1.segmentTurns)(window).length,
};
}
exports.dropOldestSpan = dropOldestSpan;
//# sourceMappingURL=drop.js.map