Skip to content

v0.12.0

Choose a tag to compare

@raymondfeng raymondfeng released this 26 Sep 16:46
· 50 commits to main since this release

A feature release: a new model-call gateway, resumable SSE, one
cancellation seam across REST, MCP, jobs and agent turns, and plugins that
can be mounted into a running app. Every dependency moves to its latest
version, majors included, and the Node floor rises to 22.18.

⚠️ Breaking

Node.js 22.18 or newer

engines.node is now >=22.18 on every @agentback/* package and on
create-agentback's templates (was >=22.13). 22.18 is the first 22.x that
ships import.meta.main (backported from 24.2). @types/node is pinned to
~22.18.x, so the compiler rejects any Node API the floor does not have.

Node 22.13–22.17 users get an engines warning (a hard failure under
engine-strict). CI tests Node 22.18, 24 and 26.

✨ New

@agentback/model-gateway — the model-call seam

Language-model middleware, not a gateway.call() API: wrapModel(model, opts) returns a drop-in model, so generateText, streamText, ToolLoopAgent
and @agentback/agents all route through it unchanged. The stack is
accounting → fallback → retry → breaker → provider:

  • Retry takes 408/409/429/5xx only, always jitters, and honours
    Retry-After; with retry on, the gateway owns retrying, so the AI SDK does
    not retry the whole stack again.
  • Breaker counts provider health only: a 4xx is neutral, and half-open
    admits exactly one probe, whose lease expires so a stalled probe cannot wedge
    the circuit.
  • Fallback sits outside the breaker; secondaries are called raw.
  • Accounting bills tokens, not calls, including streams (tapped for their
    finish part). The principal comes from an ambient withModelScope();
    @agentback/agents opens one per turn, and generate({tokenBudget}) caps a
    turn.

ai is an optional peer, imported lazily. See
docs/concepts/model-gateway.md.

Resumable SSE

@get(..., {streamOf, resumable: {windowMs?, maxEvents?}}) puts an id: on
every SSE frame and keeps the producer running for windowMs (default 30s)
after a disconnect. A reconnecting EventSource resumes through the standard
Last-Event-ID header with no client code, and the handler is not
re-invoked
, so a browser refresh no longer throws away an in-flight agent
turn. Both hosts implement it.

  • A Last-Event-ID that cannot be honoured (unknown, expired, trimmed, or
    another route's or principal's stream) is an HTTP 409; the handler is
    never re-run for it.
  • Live streams are capped server-wide (rest.resumable.maxLiveStreams, default
    1000): past it a new stream gets 503 + Retry-After. A resume is never
    capped.
  • A slow reader now pauses the producer (backpressure), and stop() ends open
    streams.
  • The buffer is in memory, so resumption is single-process.

See docs/guides/streaming.md.

One cancellation seam: CoreBindings.ABORT_SIGNAL

Every entry point that owns a cancellable unit of work binds an AbortSignal
into its context, and DI carries it to anything nested: REST (client
disconnect), MCP (notifications/cancelled), callTool({signal}), and agent
turns, which forward it to the model so an abandoned caller stops the
generation instead of paying for it. Inject it with {optional: true}. A queue
job gets JobContext.signal as a field. See
docs/concepts/cancellation.md.

Job timeouts: WorkerOptions.timeoutMs

An opt-in wall-clock backstop for a job attempt. A timed-out attempt is
abandoned (its signal aborts, it is recorded failed, the seat is freed) and
never retried, even under attempts: 3. cancel() on a started job now aborts
that attempt's signal when the job runs in this process.

Plugins mount into a running app

@agentback/plugin mounts now mean bound, lifecycle-started and served, or
the mount fails and unwinds all three. Routes, MCP tools, resources and prompts
added after app.start() are served; MCP now derives tools, resources and
prompts per request, so addition and retraction are one mechanism.

installSteps in @agentback/common

An install* helper written as an async generator yields the inverse of each
step as it lands, so a failure part-way rolls back exactly the steps that ran.
A failed rollback is attached to the thrown error (rollbackFailure) instead
of being swallowed.

🐛 Fixes

  • @agentback/context / core: a binding that resolves to null no longer
    misaligns a ContextView; one null observer binding used to crash
    app.start() with a TypeError.
  • @agentback/mcp: @tool accepts an output: schema without an input:
    schema (previously a type error, which pushed tools to return JSON text and
    lose structuredContent). The SDK request signal is re-expressed as an
    AbortError with an AbortReasons message, the same shape on every host.
  • @agentback/messaging: a handler whose own fetch times out is retried
    under attempts again; only an attempt the runtime abandoned is terminal.
  • @agentback/agents: a streamed turn the token budget refused is a failed
    turn and is not charged against quota.
  • @agentback/core: a partial lifecycle notify resolves only the observers
    it selected.

Behaviour changes

@agentback/messaging-bullmq on BullMQ 6 and ioredis 6

  • enqueue(q, data, {repeat}) now creates a BullMQ job scheduler. BullMQ 6
    removed legacy repeatable jobs. The port's repeat option keeps working: the
    scheduler id is repeat.key, else jobId, else one derived from the cadence,
    so re-enqueueing the same key updates the schedule in place. delayMs becomes
    the scheduler's start date. The exported mapEnqueueOptions() no longer
    returns a repeat field.
  • QueueAdmin.stats() counts a paused queue's jobs as waiting, which is
    how BullMQ 6 reports them. The totals are unchanged.
  • ioredis 6 speaks RESP3 by default. Pass
    connection: {url, options: {protocol: 2}} to keep the RESP2 wire protocol.
    A client passed as connection.client is now typed as an ioredis 6 Redis.
  • BullMQ 6 makes ioredis an optional peer; @agentback/messaging-bullmq
    already depends on it directly, so nothing changes for consumers.

MCP @tool input guardrail accepts object intersections

Zod 4.6 lowers z.intersection(z.object(…), z.object(…)) to a single object
schema. The guardrail checks the emitted schema, so such an input: is now
accepted. An intersection Zod cannot merge (for example with a record) still
lowers to allOf and is still rejected at registration.

Packages

  • @agentback/model-gateway: new.
  • @agentback/command: the optional @toon-format/toon peer is widened to
    ^2.3.0 || ^4.0.0 (tested against 4.1).
  • create-agentback: new apps get TypeScript 7, vitest 5, zod 4.6 and
    @types/node ~22.18. A scaffolded app runs only the tsc CLI, so it does not
    need the TypeScript 6 side-by-side install this repo keeps for eslint.

Dependencies

Package From To
zod 4.4 4.6
@modelcontextprotocol/* (v2) 2.0 2.1
@modelcontextprotocol/ext-apps (example) 1.7 2.0 (now on the v2 SDK)
ai 7.0.18 7.0.114
bullmq 5.79 6.3
ioredis 5.11 6.0
dotenv 17 18
nanoid 5 6
React (UI packages) 19.2 19.3
vitest 4.1 5.0
pnpm 11.10 12.6
eslint / typescript-eslint 10.6 / 8.63 10.11 / 8.70

Peer ranges other than the toon widening are unchanged.

🛠 Repo and CI

  • CI now runs pnpm lint and pnpm check:generated (the chat dock's embedded
    framework guide must match SKILL.md).
  • The release workflow's version step and pnpm clean no longer trip pnpm's
    workspace-cycle check, and the workflow's default npm dist-tag is latest.