@cloudflare/ai-chat@0.9.0
cloudflare/agents@cloudflare/ai-chat@0.9.0Jun 26, 2026by github-actions[bot]
AI Summary
Adds progress signalling and milestones for sub-agents, and implements robust recovery for interrupted server-tool calls to improve stability.
Key Highlights
- Recovery of interrupted server-tool calls to prevent transcript corruption
- Overridable `repairInterruptedToolPart` hook for custom repair logic
- Progress signalling and durable milestones for sub-agents
- WebSocket connection error handling improvements
- Refined `waitUntilStable` stability gating
New Features
- Tool call interruption recovery
- Custom repair hooks
- Progress reporting
- Milestone tracking
Full Release Notes
### Minor Changes
- [#1758](https://github.com/cloudflare/agents/pull/1758) [`6b46b04`](https://github.com/cloudflare/agents/commit/6b46b044c03e9fda280c9916fef6ec8b6baa7d73) Thanks [@threepointone](https://github.com/threepointone)! - Add progress signalling and durable milestones for agent-tool sub-agents
(cloudflare/agents#1758, rfc-detached-agent-tools §progress, phases 4a + 4b).
A sub-agent running as an agent tool (awaited or detached/background) can now
report mid-run progress:
```ts
// Inside the child sub-agent (e.g. from a tool's execute):
await this.reportProgress({
fraction: 0.6,
phase: "deploying",
message: "Generating menu page…"
});
```
These signals ride the child's own turn stream as a transient
`data-agent-progress` part, so they re-broadcast to the parent's connected
clients and surface on `AgentToolRunState.progress` via `useAgentToolEvents` — a
background-runs tray can render a live bar / phase / status line without drilling
in. Highlights:
- **`reportProgress({ fraction?, message?, phase?, data? }, { persist? })`** on
chat agents (`@cloudflare/think`, `AIChatAgent`); a no-op with a dev warning on
the base `Agent` and when called outside an active agent-tool run. The framework
resolves the run id from the active turn — no threading required. Bursts are
coalesced (latest-wins; a `fraction >= 1` "done" frame always flushes). `data`
is live-only unless `{ persist: true }`.
- **`onProgress(run, progress)`** parent hook, fired best-effort from the tail
for both awaited and detached runs.
- **Latest-snapshot persistence + recovery inspect.** The child stores a
`progress_json` + `last_signal_at` on its run row and surfaces it through
`inspectAgentToolRun().progress`, so a rehydrated parent reconstructs progress
after eviction.
- **Resetting no-progress budget for detached runs.** Once a detached child has
reported at least one signal, the backbone gives up if it then goes silent for
`detachedNoProgressBudgetMs` (default 1h; per-run override via
`detached: { noProgressBudgetMs }`), surfaced as `interrupted` with the
`no-progress` reason. A child that never reports is bounded only by the absolute
`detachedMaxBudgetMs` ceiling — we never give up on a run merely for being slow.
## Durable milestones (phase 4b)
Naming a `milestone` promotes a signal from the ephemeral tier to a **durable**
one — there is still only one emit method:
```ts
// Inside the child sub-agent:
await this.reportProgress({
milestone: "sources-gathered",
data: { sources: 2 }
});
```
- **Persisted + replayable.** Each milestone is one row on the child
(`cf_agent_tool_milestones` / `cf_ai_chat_agent_tool_milestones`) with a
monotonic per-run `sequence`. It rides the stream as a **persisted**
`data-agent-milestone` part (vs. transient progress), so drill-in replay and a
rehydrated parent both see it. Surfaced via `inspectAgentToolRun().milestones`
and `AgentToolRunState.milestones` (deduped by `sequence`).
- **`onProgress` fires for milestones too** — the snapshot carries
`progress.milestone`, so a consumer can branch on milestone vs. ephemeral.
- **`detached: { onMilestones }` chat convenience** (`@cloudflare/think` and
`AIChatAgent`). When a configured milestone lands, the chat agent surfaces an
idempotent synthetic chat message (keyed/idempotent per `(runId, name)`)
_before_ the run finishes. Delivered from both the warm tail and the cold
backbone reconcile; the deterministic id collapses them to at-most-once. Two
modes (the `string[]` shorthand defaults to `"narrate"`):
- `"narrate"` (default) — a synthetic **assistant** message injected directly
(no inference): a cheap, honest status line that does not trigger a turn.
- `"react"` — a **user-role** turn so the model responds to the milestone
(steer, start dependent work). Costs a model turn.
```ts
detached: { onMilestones: ["preview-ready"] } // narrate (default)
detached: { onMilestones: { names: ["needs-approval"], mode: "react" } }
```
Override the wording via `formatDetachedMilestone(run, milestone)`. These
synthetic messages carry `metadata.source` so clients can render them as an
agent **event** rather than a human turn (the example does this).
The awaitable join point (`awaitAgentToolMilestone`, phase 4c) is intentionally
not included here — it is gated behind a design addendum.
- [#1799](https://github.com/cloudflare/agents/pull/1799) [`3c2afc9`](https://github.com/cloudflare/agents/commit/3c2afc9379f34fe51e401999ec03e9efc0fe93f2) Thanks [@threepointone](https://github.com/threepointone)! - Stop reconnecting on terminal WebSocket close events and expose terminal connection failures via `connectionError` / `onConnectionError` on `AgentClient`, `useAgent`, and `useAgentChat`.
- [#1794](https://github.com/cloudflare/agents/pull/1794) [`b6ad4d5`](https://github.com/cloudflare/agents/commit/b6ad4d5bc078ede978b9b68fd7beb6ed3194f848) Thanks [@threepointone](https://github.com/threepointone)! - Recover interrupted server-tool calls on resume instead of abandoning them.
When a turn is interrupted mid tool call (e.g. a server tool whose `execute()`
died with an evicted isolate, leaving an `input-available` orphan that nothing
will ever resolve), `AIChatAgent` now repairs the transcript before re-entering
inference on the recovered turn — the same behavior `@cloudflare/think` already
has. The interrupted tool part is flipped to an errored tool-result through the
shared `agents/chat` repair primitive, so the next `convertToModelMessages` no
longer 400s with `AI_MissingToolResultsError` and the turn continues.
Adds an overridable `repairInterruptedToolPart(part)` hook (default: flip to an
`output-error` result) so apps can customize the repaired shape for
client-resolved tools (e.g. preserve an interrupted question tool as text).
Repair only ever reshapes assistant tool parts; the corrected transcript is
persisted and broadcast through the normal write path.
Repair runs before EVERY inference chokepoint — live submit, tool
auto-continuation, `continueLastTurn`, `saveMessages`/retry, and the chat
recovery callbacks — mirroring how `@cloudflare/think` repairs before every
inference (the app owns `convertToModelMessages`, so the framework repairs
`this.messages` right before handing control to `onChatMessage`). This closes
the cases a recovery-only repair missed: a mixed client+server orphan whose
client replay drives an auto-continuation, and any agent running with
`chatRecovery` disabled. Repair is scoped per-part to dead SERVER orphans: a
part still legitimately awaiting a client (an `input-available` client tool or an
`approval-requested` part the user may still answer) is left verbatim, so a fresh
dead-server orphan at the leaf is repaired even when an unrelated abandoned client
orphan sits earlier in history. It is a no-op (no write, no broadcast) for a
healthy transcript.
The recovery-path stability wait (`waitUntilStable`) now gates on the narrower
client-resolvable predicate so a dead server-tool orphan no longer blocks
stability — it is repaired and the turn continues. `waitUntilStable` gains an
optional `pendingInteraction` predicate; its default (and the documented
semantics for app overrides) is unchanged.
- [#1788](https://github.com/cloudflare/agents/pull/1788) [`3b2af54`](https://github.com/cloudflare/agents/commit/3b2af5444af5002cd54fd493452e03c721d31999) Thanks [@threepointone](https://github.com/threepointone)! - `AIChatAgent` now uses an event-driven auto-continuation barrier that parks
indefinitely on an incomplete parallel tool batch instead of force-continuing
after a fixed timeout.
Previously, when a turn ended with several parallel client tool calls and only
some results had arrived, `AIChatAgent` ran the completeness barrier _inside_
the continuation turn and polled for up to 60s
(`AUTO_CONTINUATION_PENDING_TOOL_TIMEOUT_MS`), after which it continued
inference against whatever results had landed — potentially a half-complete tool
batch. The barrier is now event-driven and runs _before_ the continuation is
enqueued (converging onto `@cloudflare/think`'s model): it fires only once every
result in the batch has arrived, re-arms as each sibling result is applied and
when a streaming turn finalizes, guards against double-fire, and is gated on no
active stream. There is **no orphan timeout** — a batch with a never-arriving
sibling now parks budget-free until it completes (the same way a turn already
parks on a pending HITL/client interaction) rather than force-continuing with
missing results.
This is a behavior change for the rare stuck-tool case: a result that never
arrives no longer triggers a continuation after 60s; it parks until the missing
result lands (or a later user turn / chat recovery repairs the transcript). A
parked continuation leaves the same on-disk signature as a HITL park, so a
deploy/crash mid-park recovers by re-arming rather than terminalizing.
- [#1788](https://github.com/cloudflare/agents/pull/1788) [`3b2af54`](https://github.com/cloudflare/agents/commit/3b2af5444af5002cd54fd493452e03c721d31999) Thanks [@threepointone](https://github.com/threepointone)! - `AIChatAgent` now replays the live "recovering…" status on connect ([#1620](https://github.com/cloudflare/agents/issues/1620)).
Previously the `cf_agent_chat_recovering` frame was only broadcast live, so a
client that connected (or reconnected) while a durable turn was mid-recovery —
between a scheduled continuation and its first chunk — saw nothing and appeared
frozen until the turn resumed or failed. It now receives the recovering status
directly on connect (when no stream is active to resume), so `useAgentChat`'s
`isRecovering` reflects the in-progress recovery immediately. This converges
`AIChatAgent` onto `@cloudflare/think`'s behavior. The status is still cleared on
completion, exhaustion, or any terminal outcome, and stale records (older than
the recovering-flag TTL) are skipped so a recovery abandoned without a terminal
cannot show "recovering…" forever.
- [#1788](https://github.com/cloudflare/agents/pull/1788) [`3b2af54`](https://github.com/cloudflare/agents/commit/3b2af5444af5002cd54fd493452e03c721d31999) Thanks [@threepointone](https://github.com/threepointone)! - `AIChatAgent` now compacts oversized tool outputs structurally instead of
replacing them with a flat summary string.
Previously, when a persisted assistant message exceeded the SQLite row-size
limit, `AIChatAgent` replaced each large tool output with a single english
summary string (`"This tool output was too large to persist… Preview: …"`),
discarding the original shape. It now uses the shared shape-preserving
`truncateToolOutput` compactor (the same one `@cloudflare/think` already used):
objects and arrays keep their structure, long strings are truncated in place
with a `... [truncated N chars]` marker, and only genuinely unrepresentable
nesting collapses to a marker object. This makes a compacted tool result far
easier for the model to keep reasoning about, and converges `AIChatAgent` and
`@cloudflare/think` onto one row-size compaction path. The
`metadata.compactedToolOutputs` / `metadata.compactedTextParts` annotations and
the compaction `console.warn`s are unchanged.
- [#1788](https://github.com/cloudflare/agents/pull/1788) [`3b2af54`](https://github.com/cloudflare/agents/commit/3b2af5444af5002cd54fd493452e03c721d31999) Thanks [@threepointone](https://github.com/threepointone)! - `AIChatAgent` can now detect and recover from a hung model/transport stream via
the opt-in `chatStreamStallTimeoutMs` watchdog ([#1626](https://github.com/cloudflare/agents/issues/1626)).
Set `chatStreamStallTimeoutMs` (a class field, like `chatRecovery`) to the
maximum number of milliseconds allowed between stream chunks. If a turn parks
longer than that — a hung provider or a stalled transport — the watchdog aborts
the live stream instead of leaving the turn spinning forever. When `chatRecovery`
is enabled, the stall is routed into the same bounded-recovery machinery a
deploy/eviction interruption uses: the partial generated so far is persisted and
a continuation is scheduled (or, once the recovery budget is spent, the
configured terminal message is delivered). With `chatRecovery` disabled, a stall
surfaces as a terminal stream error so the spinner is cleared.
The default is `0`, which disables the watchdog (no behavior change unless you
opt in), matching `@cloudflare/think`. Because the watchdog measures the gap
between chunks — not total turn duration — a steadily streaming turn never trips
it regardless of overall length. Internally this is built on the shared
`iterateWithStallWatchdog` primitive both `@cloudflare/ai-chat` and
`@cloudflare/think` consume (an internal `agents/chat` seam, not a public API),
so this change ships under the `@cloudflare/ai-chat` bump alone.
- [#1758](https://github.com/cloudflare/agents/pull/1758) [`6b46b04`](https://github.com/cloudflare/agents/commit/6b46b044c03e9fda280c9916fef6ec8b6baa7d73) Thanks [@threepointone](https://github.com/threepointone)! - Add `detached: { notify: true }` support for `runAgentTool` on chat agents
(`@cloudflare/think` and `AIChatAgent`) (cloudflare/agents#1752).
When a detached sub-agent run finishes, a chat agent can inject a message back
into the chat so the model reacts to the result — without you wiring `onFinish`
by hand:
```ts
await this.runAgentTool(ResearchAgent, {
input,
detached: { notify: { source: "research-background" } }
});
```
The injected turn is idempotent per run + terminal status, so an exactly-once
finish never duplicates, while a soft give-up followed by a real late completion
surfaces as two distinct turns. (Think dedupes via a `submitMessages`
idempotency key; `AIChatAgent`, which has no durable-submission layer, persists
under a deterministic message id and runs the follow-up turn inline within the
already-serialized delivery slot.) Use `notify: true` for the default
`metadata.source`, pass `notify: { source }` to match your app's message
taxonomy, and override `formatDetachedCompletion(run, result)` to customize (or
suppress) the injected text.
### Patch Changes
- [#1788](https://github.com/cloudflare/agents/pull/1788) [`3b2af54`](https://github.com/cloudflare/agents/commit/3b2af5444af5002cd54fd493452e03c721d31999) Thanks [@threepointone](https://github.com/threepointone)! - `AIChatAgent` now delivers the terminal banner **before** persisting the durable
terminal record when chat recovery gives up, converging onto
`@cloudflare/think`'s broadcast-first ordering.
Previously `_exhaustChatRecovery` persisted the durable terminal record first
and broadcast the banner second. A terminal-record write can reject in the
deploy/storage window a give-up runs in ([#1730](https://github.com/cloudflare/agents/issues/1730)); under persist-first the throw
propagated before the banner was sent, so the live banner was dropped on that
pass and only delivered on the healthy re-run (potentially a different isolate,
after the affected connections had gone). Broadcasting first makes the banner
resilient to a failing storage write: the throw still propagates and the whole
give-up re-runs on a healthy isolate, which persists the record idempotently and
re-delivers the banner (the documented at-least-once edge). Persisting first
gained no durability — the re-run persists either way — while losing this banner
resilience, so both chat hosts now terminalize broadcast-first.
- [#1801](https://github.com/cloudflare/agents/pull/1801) [`c58b401`](https://github.com/cloudflare/agents/commit/c58b4015b7616581b3d7fca86a5fde6e49bd9cd3) Thanks [@threepointone](https://github.com/threepointone)! - Refactor `@cloudflare/ai-chat/react` to re-export the shared implementation from `agents/chat/react` while preserving existing behavior and exports.
- [#1797](https://github.com/cloudflare/agents/pull/1797) [`f599892`](https://github.com/cloudflare/agents/commit/f599892390991d9110311b51ac647b7018f95926) Thanks [@threepointone](https://github.com/threepointone)! - Fix: a recovered agent-tool **child** turn now re-binds its run row to the
recovery turn's request id (parity with `@cloudflare/think`).
When an `AIChatAgent` facet running as an agent-tool child was interrupted
mid-run, its recovery continuation (`continueLastTurn` / `_retryLastUserTurn`)
minted a fresh request id but left `cf_ai_chat_agent_tool_runs.request_id`
pointing at the pre-eviction turn, breaking frame attribution. A long-running
recovered child then forwarded nothing to the parent's re-attach tail and could
be abandoned as `interrupted` once the no-progress budget elapsed. The recovery
paths now re-bind the child-run row (and the in-memory attribution map) so frames
keep flowing across recovery.
- [#1802](https://github.com/cloudflare/agents/pull/1802) [`391b034`](https://github.com/cloudflare/agents/commit/391b0340299fa2079c8665ee5343c4667ed3ddcf) Thanks [@threepointone](https://github.com/threepointone)! - Ensure tool approval updates always retain a provider-facing approval id.
Older or hand-seeded transcripts can contain an `approval-requested` tool part
without an `approval.id`. When that part is approved and auto-continuation
re-enters inference, the AI SDK requires a matching approval id in the converted
model messages. Approval updates now synthesize a stable id from the
`toolCallId` when the transcript is missing one, preventing invalid prompt
errors while preserving existing approval metadata. `@cloudflare/ai-chat` now
routes its approval merge through the shared `toolApprovalUpdate` builder so it
benefits from the same fallback instead of its own divergent copy.
- [#1788](https://github.com/cloudflare/agents/pull/1788) [`3b2af54`](https://github.com/cloudflare/agents/commit/3b2af5444af5002cd54fd493452e03c721d31999) Thanks [@threepointone](https://github.com/threepointone)! - Converge recovery forward-progress crediting between `AIChatAgent` and `Think`.
Both hosts now credit the recovery no-progress counter through one shared, host-agnostic rule (`shouldCreditStreamProgress`): a progress milestone (a started text/reasoning segment or a settled tool input/output) credits unconditionally, and mid-segment streaming deltas (`text-delta`/`reasoning-delta`/`tool-input-delta`) credit at most once per throttle window via a per-isolate `StreamProgressCreditThrottle`. Previously `AIChatAgent` credited only on chunk-type milestones while `Think` credited on its flush cadence, so a long single content segment spanning repeated crashes could read as "no progress" under `AIChatAgent` and false-fire its `no_progress_timeout`. The new rule is never coarser than either host's prior cadence, so it can only delay or avoid a false no-progress timeout, never hasten give-up.
- [#1803](https://github.com/cloudflare/agents/pull/1803) [`c476265`](https://github.com/cloudflare/agents/commit/c476265c9f18a2a6eb5f01137515a8776ca8b63c) Thanks [@threepointone](https://github.com/threepointone)! - Fix AI SDK `status` getting stuck after a reconnect that races a turn's
pre-stream window ([#1784](https://github.com/cloudflare/agents/issues/1784)).
A turn is "accepted but pre-stream" while it is queued, debouncing, or awaiting
async setup before its resumable stream starts. A client that connected or sent
a `STREAM_RESUME_REQUEST` in that window was answered with `STREAM_RESUME_NONE`
("nothing to resume"), so its short resume probe resolved `null` and AI SDK
`status` settled on `ready` even though the server went on to stream — leaving
the UI unable to render the in-flight turn until a full remount.
This adds a shared `PreStreamTurns` tracker (`agents/chat`) and a new
server→client `cf_agent_stream_pending` frame:
- The resume handshake now parks resume requests that arrive during the
pre-stream window and emits `STREAM_PENDING` ("keep waiting") instead of
`STREAM_RESUME_NONE`, then flushes parked connections into the normal
`STREAM_RESUMING` handshake once the stream actually starts (and releases them
with `STREAM_RESUME_NONE` if the turn is superseded/cleared before streaming).
- On `STREAM_PENDING` the client transport extends its resume probe from the
5s fast-path to a 60s backstop so the probe stays open across the gap.
- `useAgentChat` re-probes the stream on a transparent socket reopen (e.g. a
1006 reconnect that does not remount the component) so `status` recovers.
- Continuation affinity is relaxed via an optional `isConnectionPresent` host
hook so a transparent reconnect (whose connection id changed) can resume a
continuation whose original owner connection is gone.
Wired into both `AIChatAgent` and `@cloudflare/think`.
The pre-stream tracker is in-memory only; it is hibernation-safe because a turn
in its pre-stream window is an unresolved message-handler promise that pins the
Durable Object in memory, so eviction only happens once a stream is durably
recorded (and resumes via `ResumableStream`) or the turn has finished. Skipped
turns (supersede/generation change) settle without releasing parked
connections, so a client parked during the window survives onto the successor
turn instead of being cut loose by a premature `STREAM_RESUME_NONE`.
- [#1788](https://github.com/cloudflare/agents/pull/1788) [`3b2af54`](https://github.com/cloudflare/agents/commit/3b2af5444af5002cd54fd493452e03c721d31999) Thanks [@threepointone](https://github.com/threepointone)! - Recovery give-up now resolves the orphaned stream by newest metadata row.
The stable-timeout/error give-up path that terminalizes an exhausted recovery
turn previously resolved the turn's orphaned stream id with an in-memory
first-match scan over all stream metadata, while the wake (restart) path already
used the newest durable row keyed by the recovery-root request id. These two
lookups are now a single seam, so both paths surface the same partial — the
newest stream the turn produced — when a request id spans more than one
recovery attempt. Single-attempt turns (one stream row per request id) are
unaffected.