From 6ae80715f77d3207f670ae0a678be5a640b25516 Mon Sep 17 00:00:00 2001 From: Ada-1017 Date: Wed, 14 Oct 2026 23:57:17 -0400 Subject: [PATCH 1/2] fix(deps): require context-manager 0.14.1 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit context-manager 0.14.0 mishandled a dry compile that was the first compile after a message's removal, under the autobiographical strategy: it put the strategy's store order back without the listing the order was built from, so later compiles read message positions from before the removal. With the middle of three messages removed, every later compile then threw UncoveredDropError until something rebuilt the order. 0.14.1 fixes it (anima-research/context-manager#180). This package now runs compiles that are never sent as dry runs: previews, message.list, the agent context commands and a conversation fork's seed. Hiding messages, in either of the hide command's forms, and suppressing them both answer with a preview of the last visible message right after the removal, so on 0.14.0 that dry compile was the first one after it, and the agent's next turn was refused. The new tests remove the middle of three messages, by hiding one message, by hiding a range and by suppressing it, then run a turn. Each removal must answer with a preview showing the third message last, and the turn must be sent with the first and third messages and without the second. On context-manager 0.14.0 all three turns fail with UncoveredDropError; on 0.14.1 all three pass. The fragment also says what 0.14.1's other change means here: a calibration record written by 0.13.0 or earlier is read as provisional (anima-research/context-manager#179). The dry-run fragment's "With context-manager 0.14" becomes 0.14.1, since 0.14.0 is the release whose dry run changed what the next compile reads. Closes #345, the release hold that asked for this floor. Co-Authored-By: Claude Opus 5.5 --- changelog.d/context-manager-0.14.1.fixed.md | 16 +++ changelog.d/unsent-compiles-dry-run.fixed.md | 2 +- package.json | 2 +- test/hide-then-turn.test.ts | 118 +++++++++++++++++++ 4 files changed, 136 insertions(+), 2 deletions(-) create mode 100644 changelog.d/context-manager-0.14.1.fixed.md create mode 100644 test/hide-then-turn.test.ts diff --git a/changelog.d/context-manager-0.14.1.fixed.md b/changelog.d/context-manager-0.14.1.fixed.md new file mode 100644 index 000000000..4734a57ef --- /dev/null +++ b/changelog.d/context-manager-0.14.1.fixed.md @@ -0,0 +1,16 @@ +- Requires context-manager 0.14.1 (was `^0.14.0`). In 0.14.0, under the + autobiographical strategy, a dry compile that was the first compile after a + message's removal left later compiles reading message positions from before + the removal, so every later compile could throw `UncoveredDropError` until + something rebuilt the order (anima-research/context-manager#180). Previews + now compile dry, and hiding or suppressing messages answers with one right + after it removes them, so with 0.14.0 a hide or a suppression could leave the + agent unable to compile its next turn. +- context-manager 0.14.1 also reads a calibration record written by 0.13.0 or + earlier, which has no pricing stamp, as provisional rather than learned + (anima-research/context-manager#179). It may be discarded at load; one above + 1 that puts a compile over budget drops to 1; and the first provider sample + replaces it. So an autobiographical agent upgrading from such a store can + compile a different window once. A store that has already run a committing + compile on 0.14.0 keeps its re-stamped multiplier; context-manager 0.14.0's + notes give the one-time reset it may still need. diff --git a/changelog.d/unsent-compiles-dry-run.fixed.md b/changelog.d/unsent-compiles-dry-run.fixed.md index 58c3249e1..97ff3c9f1 100644 --- a/changelog.d/unsent-compiles-dry-run.fixed.md +++ b/changelog.d/unsent-compiles-dry-run.fixed.md @@ -12,7 +12,7 @@ prepared budget change alone, as `previewActivation` already promised; a preview at the live window used to settle a converging budget change. `ActivationRequestOptions` gains `dryRun`. -- With context-manager 0.14, a dry run leaves what a select leaves behind as +- With context-manager 0.14.1, a dry run leaves what a select leaves behind as the last committing compile left it: no chunk record, no calibration, and none of the bookkeeping the next compile or an observer reads. The one thing it can still leave is the release of a compression hold whose deadline has diff --git a/package.json b/package.json index b6142b986..ade05956c 100644 --- a/package.json +++ b/package.json @@ -48,7 +48,7 @@ "license": "MIT", "dependencies": { "@animalabs/chronicle": "^0.4.0", - "@animalabs/context-manager": "^0.14.0", + "@animalabs/context-manager": "^0.14.1", "@animalabs/membrane": "^0.5.87", "chokidar": "^4.0.3", "discord.js": "^14.25.1", diff --git a/test/hide-then-turn.test.ts b/test/hide-then-turn.test.ts new file mode 100644 index 000000000..cea75f597 --- /dev/null +++ b/test/hide-then-turn.test.ts @@ -0,0 +1,118 @@ +/** + * Hiding or suppressing messages answers with a preview of the last visible + * message, and a preview is a dry compile. Under the autobiographical strategy + * that dry compile is the first compile after the removal, which + * context-manager 0.14.0 mishandled: it left later compiles reading message + * positions from before the removal, so with the middle of three messages + * removed, the agent's next turn was refused with UncoveredDropError + * (anima-research/context-manager#180, fixed in 0.14.1, this package's floor). + */ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { mkdtempSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { AutobiographicalStrategy } from '@animalabs/context-manager'; +import type { TraceEvent } from '../src/types/trace.js'; +import { AgentFramework } from '../src/index.js'; +import { MockMembrane, createMockResponse } from './helpers/mock-membrane.js'; + +async function waitFor(description: string, predicate: () => boolean, timeoutMs = 5_000): Promise { + const deadline = Date.now() + timeoutMs; + while (!predicate()) { + if (Date.now() > deadline) throw new Error(`Timed out waiting for ${description}`); + await new Promise((resolve) => setTimeout(resolve, 25)); + } +} + +const requestText = (request: unknown): string => JSON.stringify((request as { messages?: unknown }).messages ?? []); + +type HostCommand = (serverId: string, params: Record) => Promise>; +type LastVisible = { preview?: string } | null | undefined; + +/** + * Three messages on an autobiographical agent, the middle one removed by + * `remove`, which returns the removal's answer; then a turn. The removal must + * answer with a preview showing the third message last, and the turn must be + * sent with the first and third messages and without the second. + */ +async function turnAfterRemoving( + remove: (framework: AgentFramework, internalIdOf: (externalId: string) => string) => Promise<{ lastVisible?: LastVisible }>, +): Promise { + const dir = mkdtempSync(join(tmpdir(), 'hide-then-turn-')); + const membrane = new MockMembrane(); + membrane.pushResponse(createMockResponse([{ type: 'text', text: 'zz-answered' }])); + const framework = await AgentFramework.create({ + storePath: join(dir, 'store'), + membrane: membrane.asMembrane(), + agents: [{ + name: 'scout', + model: 'test-model', + systemPrompt: 'test', + strategy: new AutobiographicalStrategy({ recentWindowTokens: 30_000 }), + }], + modules: [], + syncIntervalMs: 0, + maintenanceIntervalMs: 0, + // One attempt: a refused compile ends the turn at once. + errorPolicy: { maxRetries: 0, onInferenceError: () => ({ retry: false }) }, + }); + const traces: TraceEvent[] = []; + framework.onTrace((e) => traces.push(e)); + try { + const cm = framework.getAgent('scout')!.getContextManager(); + for (const [i, text] of ['zz-m1', 'zz-m2', 'zz-m3'].entries()) { + cm.addMessage('user', [{ type: 'text', text }], { messageId: `d${i + 1}`, channelId: 'zz-channel' }); + } + const internalIdOf = (externalId: string): string => String(cm.getAllMessages().find((m) => + (m.metadata as { messageId?: unknown } | undefined)?.messageId === externalId)!.id); + + const answer = await remove(framework, internalIdOf); + assert.ok(answer.lastVisible?.preview?.includes('zz-m3'), + `the removal answered with a preview showing the third message last: ${JSON.stringify(answer.lastVisible)}`); + + framework.start(); + (framework as unknown as { pendingRequests: unknown[] }).pendingRequests.push({ + agentName: 'scout', reason: 'external-message', source: 'test', timestamp: Date.now(), + }); + await waitFor('the turn to end', () => traces.some((e) => + e.type === 'inference:completed' || e.type === 'inference:failed' || e.type === 'inference:exhausted')); + const failed = traces.find((e) => e.type === 'inference:failed') as { error?: string } | undefined; + assert.equal(failed, undefined, `the turn failed: ${failed?.error}`); + assert.equal(membrane.calls.length, 1, 'the turn was sent'); + const sent = requestText(membrane.calls[0]); + assert.ok(sent.includes('zz-m1') && sent.includes('zz-m3'), 'it carries the messages left'); + assert.ok(!sent.includes('zz-m2'), 'and not the removed one'); + } finally { + await framework.stop(); + rmSync(dir, { recursive: true, force: true }); + } +} + +const hostCommand = (framework: AgentFramework): HostCommand => + (framework as unknown as { handleHostCommand: HostCommand }).handleHostCommand.bind(framework); + +test('a turn after hiding one message is sent with the messages left', async () => { + await turnAfterRemoving(async (framework) => { + const hidden = await hostCommand(framework)('discord', { + command: 'hide', agentName: 'scout', fromMessageId: 'd2', requesterName: 'zz-operator', + }); + assert.equal(hidden.ok, true, JSON.stringify(hidden)); + return hidden as { lastVisible?: LastVisible }; + }); +}); + +test('a turn after hiding a range of messages is sent with the messages left', async () => { + await turnAfterRemoving(async (framework) => { + const hidden = await hostCommand(framework)('discord', { + command: 'hide', agentName: 'scout', fromMessageId: 'd2', toMessageId: 'd2', requesterName: 'zz-operator', + }); + assert.equal(hidden.ok, true, JSON.stringify(hidden)); + return hidden as { lastVisible?: LastVisible }; + }); +}); + +test('a turn after suppressing a message is sent with the messages left', async () => { + await turnAfterRemoving(async (framework, internalIdOf) => + framework.suppressMessages('scout', { messageIds: [internalIdOf('d2')] })); +}); From ddd65ebce5e3803bf06b658878b21666ab48345b Mon Sep 17 00:00:00 2001 From: Ada-1017 Date: Thu, 15 Oct 2026 00:11:22 -0400 Subject: [PATCH 2/2] docs(changelog): say when 0.14.1's calibration reading changes the window Only the adaptive select path reads a calibration record, so the note names an agent whose autobiographical strategy uses adaptive resolution, not every autobiographical agent. And the window can change twice, not once: when the record is read (a discard at load, or a drop to 1 on an over-budget compile, restarts from a provisional 1) and again when the first provider sample sets the multiplier, rather than over about ten samples. Co-Authored-By: Claude Opus 5.5 --- changelog.d/context-manager-0.14.1.fixed.md | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/changelog.d/context-manager-0.14.1.fixed.md b/changelog.d/context-manager-0.14.1.fixed.md index 4734a57ef..4a43050fb 100644 --- a/changelog.d/context-manager-0.14.1.fixed.md +++ b/changelog.d/context-manager-0.14.1.fixed.md @@ -10,7 +10,9 @@ earlier, which has no pricing stamp, as provisional rather than learned (anima-research/context-manager#179). It may be discarded at load; one above 1 that puts a compile over budget drops to 1; and the first provider sample - replaces it. So an autobiographical agent upgrading from such a store can - compile a different window once. A store that has already run a committing - compile on 0.14.0 keeps its re-stamped multiplier; context-manager 0.14.0's - notes give the one-time reset it may still need. + replaces it. So an agent whose autobiographical strategy uses adaptive + resolution, upgrading from such a store, can compile a different window when + the record is read, and again when the first provider sample sets the + multiplier, rather than over about ten samples. A store that has already run + a committing compile on 0.14.0 keeps its re-stamped multiplier; + context-manager 0.14.0's notes give the one-time reset it may still need.