Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
d6dd2c6
deps: membrane ^0.6.0
Oct 18, 2026
67d63dd
test: a tool call whose arguments are JSON null arrives as {}
Oct 18, 2026
f62ac15
test(deferred-window-only): the landed-shard tests pin context-manage…
Oct 18, 2026
9c21933
fix(mcpl,deferral): refuse content context-manager won't store at the…
Oct 18, 2026
b94da58
Merge main (15aadc96) into the floors branch
Oct 19, 2026
cbda885
deps: context-manager ^0.16.0
Oct 19, 2026
44334f0
fix(maintenance): a withdrawn maintenance call is context-manager's C…
Oct 19, 2026
d9b3d16
build: Node 22.12 or later, as membrane 0.6.0 and context-manager 0.1…
Oct 19, 2026
f8c57ca
docs(changelog): what hosts get with membrane ^0.6.0 and context-mana…
Oct 19, 2026
c16727d
test: the null-arguments test's framework half is a control since #501
Oct 19, 2026
b703ffd
fix(deferral): refuse content context-manager won't store where it's …
Oct 19, 2026
dd378fe
ci: run the packed-install check on Node 22.12.0 and 24
Oct 19, 2026
76bc135
docs: what hosts read with the floors, and comments that named older …
Oct 19, 2026
9aa67cb
fix(modules): refuse a malformed process response before any response…
Oct 19, 2026
8697f4d
fix(modules): the trace and process log list only responses that applied
Oct 19, 2026
3107edc
fix: a refused process response gets a trace; the API refuses content…
Oct 19, 2026
fc64381
Merge main (e01a2337) into the floors branch
Oct 19, 2026
ad3d535
docs(changelog): what message.send did with content that isn't text
Oct 19, 2026
f0a7c43
test: pin the refusal trace's places, one line per refused response, …
Oct 19, 2026
73477a8
Merge main (815bfe35) into the floors branch
Oct 19, 2026
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
28 changes: 21 additions & 7 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ jobs:
runs-on: ${{ matrix.os }}
# Without a limit, a step that hangs leaves the check pending until
# GitHub's six-hour limit. Passing runs take under seven minutes. A
# test file stuck under Node 20, or a test stuck under Node 24, fails
# test file stuck under Node 22.12, or a test stuck under Node 24, fails
# sooner and by name, through npm test's --test-timeout.
timeout-minutes: 20
strategy:
Expand All @@ -24,11 +24,17 @@ jobs:
# node_modules tree records only that machine's native binaries, so a
# lock made on macOS breaks Linux installs and vice versa. Each runner
# catches the lock the other's platform produced. Tests run on ubuntu.
# Node: the engines floor and current LTS — stdio flushing semantics
# around process exit differ between versions and platforms.
# Node: the engines floor, pinned exactly; the newest Node the range
# admits; and, between them, every maintained major line (24), as
# membrane and context-manager test theirs. stdio flushing semantics
# around process exit differ between versions and platforms. macOS runs
# the two ends only.
matrix:
os: [ubuntu-latest, macos-latest]
node: [20, 24]
node: ['22.12.0', 24, latest]
exclude:
- os: macos-latest
node: 24

steps:
- uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.1.0
Expand Down Expand Up @@ -61,7 +67,7 @@ jobs:
# leaves running, such as a late arrival after stop() ("Queue is
# closed"). These files run unforced so that class fails here, not in a
# later PR's qualification. They get npm test's --test-timeout too:
# under Node 20 it fails a file whose leftover work keeps it alive, by
# under Node 22.12 it fails a file whose leftover work keeps it alive, by
# name, and under Node 24 the job's limit ends such a file.
- name: Test unforced (work in flight at stop)
run: node --test --test-timeout=600000 dist/test/stop-with-work-in-flight.test.js dist/test/stop-with-host-operations.test.js dist/test/conversation-routing.test.js
Expand All @@ -78,9 +84,17 @@ jobs:
# hours: above the check's own limits (10 minutes each for npm pack and npm
# install, a minute for each load), so the check names what hung.
pack-check:
name: Packed release installs on its own
name: Packed release installs on its own (node ${{ matrix.node }})
runs-on: ubuntu-latest
timeout-minutes: 40
# The engines floor and current LTS: the check's load hook goes in through
# register below Node 22.15 and through registerHooks from there, so each
# branch a host takes runs before a merge, as publish.yml runs both before
# a release.
strategy:
fail-fast: false
matrix:
node: ['22.12.0', 24]

steps:
- uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.1.0
Expand All @@ -90,7 +104,7 @@ jobs:
- name: Setup Node.js
uses: actions/setup-node@249970729cb0ef3589644e2896645e5dc5ba9c38 # v6.5.0
with:
node-version: 24
node-version: ${{ matrix.node }}

# As publish installs, so a build that came to need a dependency's
# install script fails here, before a tag, rather than in publish.
Expand Down
13 changes: 10 additions & 3 deletions .github/workflows/publish.yml
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ jobs:
- name: Setup Node.js
uses: actions/setup-node@249970729cb0ef3589644e2896645e5dc5ba9c38 # v6.5.0
with:
node-version: 20
node-version: '22.12.0'

# The same check `npm version` runs first (see CONTRIBUTING.md): an
# open issue labelled release-hold stops the release. Both jobs that
Expand Down Expand Up @@ -64,11 +64,18 @@ jobs:
# 6 hours: above the check's own limits (10 minutes each for npm pack and
# npm install, a minute for each load), so the check names what hung.
pack-check:
name: Packed release installs on its own
name: Packed release installs on its own (node ${{ matrix.node }})
runs-on: ubuntu-latest
timeout-minutes: 40
permissions:
contents: read
# The engines floor and current LTS: the check's load hook goes in through
# register below Node 22.15 and through registerHooks from there, so each
# branch a host takes runs before a release.
strategy:
fail-fast: false
matrix:
node: ['22.12.0', 24]

steps:
- uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.1.0
Expand All @@ -78,7 +85,7 @@ jobs:
- name: Setup Node.js
uses: actions/setup-node@249970729cb0ef3589644e2896645e5dc5ba9c38 # v6.5.0
with:
node-version: 24
node-version: ${{ matrix.node }}

# As publish installs, so a build that came to need a dependency's
# install script fails here rather than in publish.
Expand Down
9 changes: 5 additions & 4 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -212,10 +212,11 @@ npx tsc --noEmit # typecheck
code you no longer have — always build before testing, and after switching
branches. The `pretest` hook copies `test/fixtures/*.mjs` into `dist/`.

`--test-timeout=600000` bounds each test file as a whole under Node 20,
and each test under Node 24, at ten minutes; the slowest file,
`mcpl-runtime-lifecycle`, takes about three. Each job in `ci.yml` also has
a `timeout-minutes` limit, which ends a hang the test timeout doesn't reach.
`--test-timeout=600000` bounds each test file as a whole under Node
22.12, as it did under Node 20, and each test under Node 24, at ten
minutes; the slowest file, `mcpl-runtime-lifecycle`, takes about three.
Each job in `ci.yml` also has a `timeout-minutes` limit, which ends a hang
the test timeout doesn't reach.

Push-time CI (`ci.yml`) builds, typechecks and tests every push and PR on
ubuntu and macos. Lockfiles are deliberately gitignored in this repo, so CI
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -666,4 +666,4 @@ npm test # Run tests
npm run typecheck # Type-check without emitting
```

Requires Node.js >= 20. Chronicle requires a Rust toolchain for native module compilation (`npm run build` in the chronicle directory).
Requires Node.js >= 22.12. Chronicle requires a Rust toolchain for native module compilation (`npm run build` in the chronicle directory).
9 changes: 9 additions & 0 deletions changelog.d/549-maintenance-withdrawn.fixed.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
- A maintenance call agent-framework withdraws, under a surgery's hold or
once `stop()` has begun, no longer counts against a chunk's compression
(#549). `MaintenanceWithdrawnError` now extends context-manager's
`CompressionWithdrawn`, so context-manager rethrows it unchanged from every
rung of its compression fallback ladder and its merge path, recording no
outcome, attempt or quarantine event. Before, a call withdrawn after a
chunk's canonical call was refused was recorded as that rung's provider
error, and once every rung had failed, the chunk's request family was
quarantined, with its alert.
41 changes: 41 additions & 0 deletions changelog.d/floors-membrane-060-cm-016.breaking.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
- **Hosts:** agent-framework now depends on membrane `^0.6.0` and
context-manager `^0.16.0`, where it depended on `^0.5.87` and `^0.14.1`.
context-manager 0.16.0 requires membrane `^0.6.0` itself, so the two
install one membrane between them. What reaches a host through
agent-framework, from those releases' notes:
- **Your own membrane:** a host that builds its own `Membrane` and hands it
to agent-framework raises its own `@animalabs/membrane` range to
`^0.6.0`. context-manager counts a compression attempt's prompt through
that instance, and with an older membrane it counts Gemini's cached input
twice (below 0.5.81, OpenAI's too, and OpenRouter's on routes that report
OpenAI's `cached_tokens`), so a compression fallback that fits can be
rejected at admission and its chunk quarantined (context-manager 0.16.0).
- **The kv-unified hysteresis certificate is on by default** for a
strategy built from a recipe: a `kvUnified` block without
`hysteresisCertificate` takes the certified exit, and
`hysteresisCertificate: false` restores the full solver on every compile
(context-manager 0.15.0).
- **Content context-manager won't store** is refused where agent-framework
writes it, as the entry on malformed content says.
- **Stored history:** content an older context-manager stored that doesn't
match `ContentBlock` is now read mended, or left out, with one line per
message naming each place, where some such content made every compile
throw. A `tool_use` stored with `input: null`, as membrane 0.5.87's
OpenAI-family adapters gave it for the arguments `null`, reads as `{}`
with `null` in `unparseableInput`. A request compiled from such a message
can change there, so a provider's prompt cache misses from that point,
once (context-manager 0.16.0).
- **Tools given `null` arguments:** an OpenAI-family tool call whose
arguments are the text `null` reaches the tool as `{}`, where it reached
it as `null`. membrane keeps the arguments' text on the `tool_use` block
as `unparseableInput` (membrane 0.6.0).
- **Anthropic streams:** after a stream's first event, SSE `ping`
keepalives now count as activity for its idle deadline, so a stream that
keeps sending only pings no longer ends at it (membrane 0.6.0).
- **Media notes:** two of the notes membrane writes in place of media it
doesn't send now say the model isn't seeing it. The first request after
upgrading carries the new text wherever it renders such a note, so a
provider's prompt cache misses once, from the first such note on
(membrane 0.6.0).
- The rest is in membrane 0.6.0's notes and context-manager 0.15.0's and
0.16.0's.
5 changes: 5 additions & 0 deletions changelog.d/malformed-content-refused.fixed.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
- Content context-manager won't store is refused where it's written, and a deferred write that context-manager refuses no longer holds its agent's later writes. From context-manager 0.16.0, a write whose content doesn't match `ContentBlock` is refused with a `MalformedContentError` (context-manager #184). Before it, such content was stored as given, and a compile that read it could throw.
- **Writes and edits through the framework:** a module's write, and its edit of a write still held for a turn's end, throw `MalformedContentError`, naming each place, as the store's own write and edit do. They throw whether the write would be stored at once or deferred while a turn runs, where a deferred one used to be refused only at a later flush. Content the store mends, a `tool_use` whose input isn't an object, is written as before. The API's `message.send` refuses content that isn't text with an error, where it answered `pending` and stored the message as given.
- **A process response** with such content, in a message it adds or an edit it makes, is refused whole before any response to the event is applied. None of it is applied, its module state update and inference request included, and it doesn't count as accepting console or API input. A `[process-response-refused]` line and a `process:response-refused` trace name the module, the other modules' responses apply, and the `process:completed` trace and the process log list only the responses that applied.
- **The push and channel lanes** check content with context-manager's `malformedContentPlaces` before anything else. Content from an MCPL server is converted at the boundary into blocks the store accepts, so this catches events that never crossed it. When it names any place, the message stores no row and queues no wake. A stderr line, `[push-event-refused]` or `[channel-incoming-refused]`, names the server, the channel where there is one, the event or message, and the places. A new trace, `mcpl:malformed-content-refused`, carries the same, with the places as `places`. Content that's absent is still the empty-content check's.
- **A deferred write that context-manager refuses as malformed is dropped at the drain** and logged, rather than queued again: it would be refused at every flush, and so would hold every later deferred write for its agent for good. Since writes through the framework's `addMessage` are refused before they're queued, this meets an entry a process before this release persisted, a pair `puppetToolCall` queues itself, or a write the strategy's chunking splits into a shard the store refuses, which the line names. Any other failure keeps the entry queued, as before, unless a read-back shows it landed. A `tool_use` dropped this way lets a `tool_result` queued behind it be stored alone.
9 changes: 9 additions & 0 deletions changelog.d/node-22-12.breaking.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
- **Anyone running agent-framework on a Node before 22.12:** agent-framework
now declares `engines.node` `>=22.12.0`, where it declared `>=20.0.0`, as
membrane 0.6.0 and context-manager 0.16.0, which it now depends on, both
do. CI tests at Node 22.12.0 exactly, at 24 and at the newest Node, where it
tested 20 and 24, and the publish workflow builds and tests at 22.12.0.
npm warns when it installs agent-framework on an older Node, and refuses
only with `engine-strict` set. Nothing in agent-framework's code changes
with this; older Node versions are simply no longer tested.
- **Migration:** run Node 22.12 or later.
6 changes: 3 additions & 3 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -48,8 +48,8 @@
"license": "MIT",
"dependencies": {
"@animalabs/chronicle": "^0.4.0",
"@animalabs/context-manager": "^0.14.1",
"@animalabs/membrane": "^0.5.87",
"@animalabs/context-manager": "^0.16.0",
"@animalabs/membrane": "^0.6.0",
"@modelcontextprotocol/client": "^2.3.1",
"chokidar": "^4.0.3",
"discord.js": "^14.25.1",
Expand All @@ -64,6 +64,6 @@
"typescript": "^7.0.2"
},
"engines": {
"node": ">=20.0.0"
"node": ">=22.12.0"
}
}
6 changes: 6 additions & 0 deletions src/api/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -489,6 +489,12 @@ export class ApiServer {
if (!params.participant || !params.content) {
throw new Error('participant and content are required');
}
// ApiModule stores the message as one text block, which context-manager
// refuses unless its text is a string, so content that isn't text is
// refused here, where the caller hears it, rather than at dispatch.
if (typeof params.content !== 'string') {
throw new Error('content must be text');
}

// Push api:message event for the ApiModule to handle
this.framework.pushEvent({
Expand Down
Loading
Loading