feat(sdk): chat.agent — durable conversational task runtime (2/5)#3543
feat(sdk): chat.agent — durable conversational task runtime (2/5)#3543ericallam wants to merge 1 commit intofeature/sessions-primitivefrom
Conversation
Adds chat.agent({...}), a session-aware task definition for AI chat
agents. The runtime sits on top of the Sessions primitive and handles:
- Delta-only wire: each /in/append carries at most one message; agent
rebuilds prior history at run boot from an S3 snapshot + session.out
replay tail. Awaited snapshot writes after every onTurnComplete keep
the chain durable across idle suspends.
- Lifecycle hooks: onChatStart, onTurnStart, onTurnComplete, onAction,
onValidateMessages, hydrateMessages (short-circuits snapshot+replay
if the customer owns history).
- chat.history read primitives for HITL flows (getPendingToolCalls,
getResolvedToolCalls, extractNewToolResults, findMessage, all/getChain).
- chat.local — per-run typed data with Proxy access + dirty tracking.
- chat.headStart — first-turn TTFC bridge via a customer HTTP handler.
- oomMachine — one-shot OOM-retry on a larger machine; cutoff derived
from latest trigger:turn-complete chunk on session.out.
- Actions are no longer turns — onAction may mutate via chat.history.*
and may return a StreamTextResult without bumping the turn counter.
- gen_ai.conversation.id stamped on chat spans + metrics for telemetry.
- Skills runtime (loadable per-agent and per-task) + agent skills bundling.
Snapshot + replay integration tests in apps/webapp/test/.
mockChatAgent test harness updated for the new wire.
Includes the chat-agent, chat-agent-delta-wire-snapshots,
chat-history-read-primitives, chat-head-start, chat-actions-no-turn,
chat-session-attributes, agent-skills, and mock-chat-agent-test-harness
changesets.
🦋 Changeset detectedLatest commit: dbc0034 The changes in this PR will be included in the next version bump. This PR includes changesets to release 29 packages
Not sure what this means? Click here to learn what changesets are. Click here if you're a maintainer who wants to add another changeset to this PR |
|
Important Review skippedAuto reviews are disabled on base/target branches other than the default branch. Please check the settings in the CodeRabbit UI or the ⚙️ Run configurationConfiguration used: Repository UI Review profile: CHILL Plan: Pro Run ID: You can disable this status message by setting the Use the checkbox below for a quick retry:
✨ Finishing Touches🧪 Generate unit tests (beta)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
| */ | ||
| handoverResponse(result: StreamTextResult<any, any>): Response; | ||
| /** Manually dispatch the `handover` signal on `session.in`. */ | ||
| handover(args: { partialAssistantMessage: ModelMessage[] }): Promise<void>; |
There was a problem hiding this comment.
🔴 Public HeadStartSession.handover type omits required isFinal parameter, causing agent to always enter non-final path
The public HeadStartSession type declares handover(args: { partialAssistantMessage: ModelMessage[] }) at packages/trigger-sdk/src/v3/chat-server.ts:133, but the internal function it maps to (chat-server.ts:382-394) requires isFinal: boolean. When a customer calls handle.handover({ partialAssistantMessage: msgs }) through the chat.openSession() escape-hatch API, args.isFinal is undefined. In JSON.stringify at line 393, isFinal: undefined is omitted from the wire payload. The agent receiving this kind: "handover" chunk interprets the missing isFinal as falsy, so it always enters the non-final branch (runs streamText to execute tool-calls) — even when the customer intended a final handover (pure-text, no LLM call). There is no way for a customer using the manual handover() method to signal a final response.
| handover(args: { partialAssistantMessage: ModelMessage[] }): Promise<void>; | |
| handover(args: { partialAssistantMessage: ModelMessage[]; isFinal?: boolean; messageId?: string }): Promise<void>; |
Was this helpful? React with 👍 or 👎 to provide feedback.
| void handoverWhenDone(result) | ||
| .finally(() => clearTimeout(idleTimer)) | ||
| .catch(() => {}); |
There was a problem hiding this comment.
🟡 Idle timer leak when handoverWhenDone is called outside handoverResponse
In openHandoverSession, an idleTimer is created at chat-server.ts:321 that aborts the session's AbortController after the timeout. The clearTimeout(idleTimer) call only exists inside handoverResponse at line 603 (chained on handoverWhenDone's .finally()). When a customer uses the lower-level chat.openSession() API and calls handle.tee() + handle.handoverWhenDone() directly — without going through handle.handoverResponse() — the timer is never cleared. After idleTimeoutInSeconds (default 60s), the timer fires and calls abortController.abort(), which may cancel in-progress operations or SSE subscriptions that the customer still needs.
Prompt for agents
The idleTimer created at line 321 is only cleared inside handoverResponse (line 603). The HeadStartSession handle exposes handoverWhenDone as a standalone public method (line 640), but if a customer calls it directly via the chat.openSession() escape hatch, the timer never gets cleared. Move the clearTimeout(idleTimer) into the handoverWhenDone function itself (e.g. in its finally block), so the timer is always cleared regardless of which code path invokes handoverWhenDone.
Was this helpful? React with 👍 or 👎 to provide feedback.
| async append(value, options) { | ||
| // Use a single-write writer so objects are serialized the same way | ||
| // as stream.writer() — the raw append API sends BodyInit which | ||
| // doesn't serialize objects correctly for SSE consumers. | ||
| const { waitUntilComplete } = writer(opts.id, { | ||
| ...options, | ||
| spanName: "streams.append()", | ||
| execute: ({ write }) => { | ||
| write(value); | ||
| }, | ||
| }); | ||
| await waitUntilComplete(); | ||
| }, |
There was a problem hiding this comment.
🚩 streams.define().append() changed from raw append to writer-based serialization
The define<TPart>().append() method at streams.ts:664-676 was changed from delegating to the raw append() function (which sends BodyInit) to using writer() with a single write() call. The comment explains this ensures objects are serialized the same way as stream.writer() — the raw append API sends BodyInit which doesn't serialize objects correctly for SSE consumers. This is a behavioral change to the RealtimeDefinedStream.append() method. Any existing code that relied on the old raw-append behavior (e.g., passing pre-stringified data) will now get double-serialized through the writer path. The change is intentional and an improvement, but callers should be aware of the semantic shift.
Was this helpful? React with 👍 or 👎 to provide feedback.
| "peerDependencies": { | ||
| "zod": "^3.0.0 || ^4.0.0", | ||
| "ai": "^4.2.0 || ^5.0.0 || ^6.0.0" | ||
| "ai": "^5.0.0 || ^6.0.0", |
There was a problem hiding this comment.
🚩 peerDependency range for 'ai' package narrowed — drops v4 support
In packages/trigger-sdk/package.json, the ai peer dependency range changed from ^4.2.0 || ^5.0.0 || ^6.0.0 to ^5.0.0 || ^6.0.0, dropping v4 support. The changeset mentions this is intentional (new features require AI SDK v5+). This is a breaking change for any existing users on ai@4.x — they'll see peer dependency warnings on install. The devDependency was also bumped from ^6.0.0 to ^6.0.116.
Was this helpful? React with 👍 or 👎 to provide feedback.
Layer 2 of 5 in the chat.agent stack split
Adds
chat.agent({...}), a session-aware task definition for AI chat agents.The runtime sits on top of the Sessions primitive (L1) and handles the
durable conversational task lifecycle.
Targets
feature/sessions-primitive— merge after L1Delta-only wire + history reconstruction
Each
/in/appendcarries at most one message; the agent rebuilds priorhistory at run boot from an S3 snapshot +
session.outreplay tail.Awaited snapshot writes after every
onTurnCompletekeep the chaindurable across idle suspends.
Lifecycle hooks
onChatStart,onTurnStart,onTurnComplete,onAction,onValidateMessages,hydrateMessages(short-circuits snapshot+replayif the customer owns history).
Read primitives + helpers
chat.history.{getPendingToolCalls, getResolvedToolCalls, extractNewToolResults, findMessage, all, getChain}for HITL flowschat.local— per-run typed data with Proxy access + dirty trackingchat.headStart— first-turn TTFC bridge via customer HTTP handleroomMachine— one-shot OOM-retry on a larger machine; cutoff derivedfrom latest
trigger:turn-completechunk onsession.outonActionmay mutate viachat.history.*without bumping the turn counter
gen_ai.conversation.idstamped on chat spans + metricsIncludes snapshot + replay integration tests under
apps/webapp/test/.mockChatAgenttest harness updated for the new wire.Stack