Deuz SDK 2.0 已发布 — 存储、护栏、交接与零配置 MCP。 2.0 新特性
Deuz SDK
Modules

Chat Persistence & State

The ChatStore seam, auto-persist via the chat call option, the pure chat state engine (applyUIPart and the ordered parts projection), and branching for regenerate / edit-and-resend.

文档正文为英文。导航、搜索和界面会跟随你选择的语言。

@deuz-sdk/core/chat is the framework-agnostic chat layer (1.7, extended in 1.9): a two-method ChatStore persistence seam the agentic loops auto-save into, plus a pure chat state engine — the render-friendly UIMessage shape, the reducer that folds Deuz UI wire parts into it, the ordered parts projection, canonical-history reconstruction in both directions, and the branch helpers behind regenerate / edit-and-resend. @deuz-sdk/react's hooks bind this module to React state — no business logic lives there.

Everything in @deuz-sdk/core/chat is edge-safe pure functions over immutable data. The file-backed reference store ships separately at @deuz-sdk/core/chat/node; the request validator that guards a chat route (validateChatRequest) is re-exported from this same subpath.

ChatStore — the persistence seam

The SessionStore pattern again: implement two methods against any backend — a Supabase table, Redis, the filesystem, or the in-memory reference store.

interface ChatRecord {
  chatId: string;
  /** Ownership/tenancy — REQUIRED, aligned with the memory scope model. */
  scope: MemoryScope;
  /** Full immutable history (the loops never mutate prior arrays). */
  messages: Message[];
  /** Branch lineage (edit-and-resend can fork a chat; optional). */
  parentId?: string;
  /** `deps.clock.now()` at save time. */
  updatedAt: number;
}

interface ChatStore {
  saveChat(record: ChatRecord): void | Promise<void>;
  loadChat(chatId: string): ChatRecord | undefined | Promise<ChatRecord | undefined>;
  /** Optional cleanup (the loops never call it). */
  deleteChat?(chatId: string): void | Promise<void>;
  /** Optional enumeration for pickers/tooling. */
  listChats?(scope?: MemoryScope): string[] | Promise<string[]>;
}

Scope is mandatory and reuses MemoryScope — which gained a chatId field in 1.7 precisely so chat records, memory records, and durable runs share one ownership model. listChats(scope) filters by exact match on the scope fields you provide.

Auto-persist: the chat call option

Set chat on any generateText / streamChat call and the loop persists the full immutable history into the store at terminal boundaries — completion, suspension (approval break), and mid-stream error (completed turns up to the failure still persist):

interface ChatPersistOptions {
  store: ChatStore;
  chatId: string;
  scope: MemoryScope; // mandatory ownership
  parentId?: string; // fork lineage recorded on the saved record
}
auto-persist.ts
import { streamChat } from '@deuz-sdk/core';
import { createInMemoryChatStore } from '@deuz-sdk/core/chat';

const store = createInMemoryChatStore();

const result = streamChat({
  model,
  messages,
  tools,
  maxSteps: 8,
  chat: { store, chatId: 'chat-42', scope: { userId: 'u_1' } },
});

Two contracts to know:

  • Best-effort by design. A throwing saveChat logs via deps.logger.error and never kills the run (the SessionStore rule).
  • Tool-less calls route through the loop. Setting chat (or memory) routes even a call without tools through the agentic loop, so every chat shape persists at the same terminal boundaries. The visible consequence: step-start / step-finish parts appear on the stream for calls that previously had none — keep the default case in your part switch.

There is no diff machinery to configure: history is immutable by contract, so each save is a superset of the last and saveChat simply receives the full array.

Reference stores

For production, use a store pack

The two stores below are references: a Map and one JSON file per chat. 2.0 ships real backends — createSqliteStores, createRedisStores and createPostgresStores — each returning a ChatStore alongside the matching SessionStore / MemoryStore on the same connection, with the binary-safe record codec already wired.

import { createPostgresStores } from '@deuz-sdk/core/stores/postgres';

const stores = createPostgresStores({ connectionString: process.env.DATABASE_URL! });
chat: { store: stores.chats, chatId, scope: { userId } }

The hand-rolled Supabase adapter further down still works and is still the template for any backend the packs do not cover.

createInMemoryChatStore (edge-safe)

A Map keyed by chatId; saveChat copies the message array. Implements all four methods. Single runtime only — dev, tests, one long-lived server.

createJsonlChatStore (Node)

@deuz-sdk/core/chat/node — one JSON file per chat under dir (created on first save), zero dependencies, lazy node:fs/promises. Writes are atomic-enough on one filesystem: the record is written to a .tmp file, then renamed over the target — a crash mid-write can never tear an existing chat. Binary message parts survive via the $deuzBytes codec (below). loadChat returns undefined for missing/corrupt files rather than throwing.

import { createJsonlChatStore } from '@deuz-sdk/core/chat/node';

const store = createJsonlChatStore({ dir: './data/chats' });

The serializeChatRecord codec

For persistent backends with text/JSON columns, serialize with the bundled codec — plain JSON.stringify would decay binary parts (raw Uint8Array images) into index-keyed objects the adapters cannot send back. It uses the same { "$deuzBytes": "<base64>" } tag as the durable checkpoint codec; on load, only the codec's own exact shape converts back to bytes — payloads that merely look like the tag stay plain data.

import { serializeChatRecord, deserializeChatRecord } from '@deuz-sdk/core/chat';

const row = serializeChatRecord(record); // JSON string, Uint8Array-safe
const back = deserializeChatRecord(row); // real Uint8Arrays restored

Supabase table adapter

create table chats (
  chat_id    text primary key,
  scope      jsonb not null,
  record     text  not null, -- serializeChatRecord output
  updated_at bigint not null
);
supabase-chat-store.ts
import type { ChatStore } from '@deuz-sdk/core/chat';
import { serializeChatRecord, deserializeChatRecord } from '@deuz-sdk/core/chat';
import { createClient } from '@supabase/supabase-js';

const supabase = createClient(process.env.SUPABASE_URL!, process.env.SUPABASE_SERVICE_KEY!);

export const chatStore: ChatStore = {
  async saveChat(record) {
    const { error } = await supabase.from('chats').upsert({
      chat_id: record.chatId,
      scope: record.scope, // queryable copy for listChats-style filters
      record: serializeChatRecord(record), // binary-safe source of truth
      updated_at: record.updatedAt,
    });
    if (error) throw error; // logged by the loop, never kills the run
  },
  async loadChat(chatId) {
    const { data } = await supabase.from('chats').select('record').eq('chat_id', chatId).single();
    return data ? deserializeChatRecord(data.record) : undefined;
  },
};

The pure chat engine

The engine is the part a UI binding needs regardless of storage. All functions are pure and total: new objects out, inputs untouched, unknown wire parts ignored (the open-union rule), errors recorded rather than thrown.

UIMessage and the per-turn reducer

interface UIToolCall {
  toolCallId: string;
  toolName: string;
  input: unknown;
  output?: unknown;
  isError?: boolean;
  state: 'call' | 'result' | 'approval-requested';
  /** Fine-grained lifecycle from `tool-state` parts (1.7 additive). */
  runState?: ToolRunState;
}

interface UIMessage {
  id: string;
  role: 'user' | 'assistant';
  content: string;
  reasoning?: string;
  toolCalls?: UIToolCall[];
  /** Ordered projection (1.9, additive) — absent until the turn has its first element. */
  parts?: UIMessagePart[];
}

One streamed assistant turn accumulates into an AssistantTurnState: the UIMessage plus approvals (gated calls awaiting verdicts), serverResults (which toolCallIds the server already executed — the rest are client tools), and the 1.7 wire additions: costUsd / cacheSavingsUsd (live cost parts), budgetExceeded, dataParts (app-defined data-{name} parts in arrival order), citations, and error (the redacted server error message, recorded — not thrown). 1.8 added plan (the latest plan-update snapshot) and activity; 1.9 added the terminal readouts usage / finishReason (from the wire's finish part), steps (per-step usage and finish reason, in arrival order) and verifications (verifyStep verdicts). All four stay absent until the matching part arrives — as do plan, costUsd and budgetExceeded before them, because createAssistantTurn's initial shape is public surface and never invents a key.

applyUIPart(turn, part) folds one wire part into the turn and returns a new state:

reduce-a-turn.ts
import { createAssistantTurn, applyUIPart } from '@deuz-sdk/core/chat';
import { connectDeuzStream } from '@deuz-sdk/core/ui';

let turn = createAssistantTurn(crypto.randomUUID());
for await (const part of connectDeuzStream(resumeUrl)) {
  turn = applyUIPart(turn, part); // pure — hand it straight to setState
  render(turn);
}

Reducer details worth knowing:

  • A tool-state: input-streaming part opens a placeholder tool call before the assembled tool-call part arrives, so the lifecycle is visible from the first fragment (the later tool-call completes it in place, no duplicates); start adopts the server's messageId; tool-result moves the call to state: 'result' and records it in serverResults.
  • Addressable data parts (1.9). A data-{name} part carrying an id replaces the earlier entry with the same name and id in place — in dataParts and in the ordered parts — instead of appending, so a live status widget stays one entry rather than three. A write without an id appends, exactly as in 1.7/1.8. Only a string id addresses an entry; anything else reads as absent and keeps the append-only path, because the value crossed a network boundary. See writeData for the server side.
  • Denial fields (1.9). tool-state carries optional denied / deniedReason, which the reducer folds onto UIToolCall so a declined call stops rendering as "getWeather failed". The streaming loop's tool-state emitter sets them at both of its terminal sites, so every refusal the built-in approval flow settles arrives marked: a server-mode approveToolCall returning false (denied: true, no reason — the hook's verdict is a bare boolean), an approvalResponses verdict (its own reason, verbatim), a gated call left unanswered, an unanswered client tool, and a token approvalSigner rejected. A tool that threw gains no denial fields, which is the distinction the fields exist for.
  • Three more channels (1.9). warning parts fold into turn.warnings (CallWarning[], the same payload StreamChatResult.warnings resolves), false-finish parts into turn.falseFinishes, and sub-agent parts into turn.subAgents (below). All three are optional and absent until the first entry arrives — createAssistantTurn's literal is public surface and stays unchanged.

Sub-agent frames (1.9)

applyUIPart folds a sub-agent part into its own channel, one frame per agentPath:

turn.subAgents?.[0];
// { agentPath: ['researcher'], afterPart: 3, turn: AssistantTurnState }

turn is a full AssistantTurnState folded by this reducer re-entering itself, so the child's text, reasoning, ordered parts, tool cards, citations and activity are all as complete as the parent's — one implementation, not a parallel one. afterPart is the count of the parent's ordered elements when the frame opened (normally just after the tool card for the delegating call), so a renderer splices the block back in at the handoff point. A 2nd-level sub-agent is a sibling frame with a two-segment path, because the wire is single-wrapped.

It is deliberately not folded into the parent's buckets or its ordered parts: that would attribute the child's prose to the main agent, and it would put the child's tool_use into assistantMessageFromTurn's output with no matching tool_result — the payload that 400s the next request. Frames are sealed alongside the parent at finish, error and sealAssistantTurn. useChat exposes them as subAgents — see React hooks.

Ordered parts (1.9)

content / reasoning / toolCalls are buckets. A multi-step turn — think → search → "I found 3 papers" → fetch → "here is the summary" — flattens into one reasoning blob, one text blob and a detached list of tool cards, so a renderer cannot place a tool card between the two sentences it belongs between.

Since 1.9 the reducer also records arrival order in UIMessage.parts. No wire change was needed: the canonical stream is strictly ordered and toDeuzStreamResponse preserves that order, so arrival order is the interleave. The buckets are not deprecated and keep their exact 1.8 semantics — content is still the turn's full text, byte-identical, and the same part.text lands in both views, so the two can never disagree.

type UIMessagePart =
  | { type: 'text'; text: string; state: 'streaming' | 'done' }
  | {
      type: 'reasoning';
      text: string;
      signature?: string;
      encrypted?: boolean; // opaque provider payload — do NOT render
      redacted?: boolean;
      state: 'streaming' | 'done';
    }
  | { type: 'tool'; toolCallId: string } // a REFERENCE into UIMessage.toolCalls
  | { type: 'data'; name: string; id?: string; payload: unknown }
  | Extract<DeuzUIPart, { type: 'citation' }> // the wire object verbatim
  | { type: 'step-start'; step: number }
  | { type: 'file'; mediaType: string; data: string | Uint8Array; url?: string };

A framework-agnostic walk — @deuz-sdk/react exposes the same array, see rendering ordered parts for the JSX version:

render-parts.ts
// No `parts` (pre-1.9, hand-built, or restored from old storage)? Render the buckets.
for (const part of message.parts ?? []) {
  switch (part.type) {
    case 'step-start':
      // A real streamed turn's parts[0] is normally one of these.
      drawStepDivider(part.step);
      break;
    case 'text':
      drawProse(part.text, part.state === 'streaming');
      break;
    case 'reasoning':
      // `encrypted` text is an opaque provider payload, not thinking text.
      if (!part.encrypted) drawThinking(part.text);
      break;
    case 'tool': {
      const call = message.toolCalls?.find((c) => c.toolCallId === part.toolCallId);
      if (call) drawToolCard(call);
      break;
    }
    case 'file':
      drawAttachment(part.mediaType, part.data, part.url);
      break;
    default:
      break; // open union — always keep a default
  }
}

What the union encodes, in the order people get it wrong:

  • parts is optional and absent until the first element exists. createAssistantTurn's shape is public surface, so the key is never invented; render parts when present, the buckets otherwise.
  • step-start is normally parts[0] of a real streamed turn, because the streaming loop pushes one at the top of every iteration. A renderer without a case for it hits its default on the very first element.
  • Skip reasoning parts flagged encrypted — the text is an opaque encrypted provider payload (OpenAI Responses), not display text; redacted marks a provider-redacted thinking block. Neither flag can appear on a streamed turn (the wire's reasoning-delta carries no such field) — they arrive on history projected by uiFromMessages.
  • A text/reasoning element is 'streaming' only while it is the newest thing in the turn. Opening anything else seals it, which is what makes a text delta arriving after a tool call a new paragraph instead of silently re-opening the one the tool interrupted.
  • A tool element is a reference, not a copy — a call's state (input → approval → result → denial) changes over the turn's life, and two copies of it would drift.
  • A file element carries the canonical ImagePart.image value verbatim (bytes stay bytes). url is set only when the value is already a renderable data: / http(s): src; for bytes, build one yourself. See Files & PDFs.

sealAssistantTurn

applyUIPart seals the tail on the wire's terminal parts (finish, error). sealAssistantTurn(turn) covers the boundaries only the binding knows about — a user abort, a reader that walked away, a turn restored from storage:

import { sealAssistantTurn } from '@deuz-sdk/core/chat';

turn = sealAssistantTurn(turn); // every 'streaming' text/reasoning element becomes 'done'

It is idempotent and returns the same object when there is nothing to seal (unlike applyUIPart, which always returns a new one), so a React binding can call it unconditionally without forcing a re-render. A stream that dies with neither finish nor error and is never sealed deliberately leaves the tail 'streaming' — that is the truth about a truncated turn.

Back to canonical history

  • assistantMessageFromTurn(turn) — the canonical assistant Message for the request history: text plus tool_use parts, exactly as the wire streamed them (the client-tools reconstruction).
  • clientToolResultMessage(results) — the canonical role: 'tool' message for client-executed tool results.
  • uiFromMessages(messages, generateId) — the reverse projection: a canonical history (e.g. a chat loaded from a ChatStore) into render-friendly UIMessages. tool messages merge their results into the preceding assistant turn; system messages are not rendered; generateId supplies stable ids (inject deps.generateId, or scripted ids in tests). Since 1.9 each message also carries the ordered parts projection, in canonical array order — which is what makes attachments visible instead of rendering as an empty bubble.
  • canonicalFromUI(ui) — the inverse of uiFromMessages (1.9), for a binding that lets the app replace history (setMessages): the two views must stay coherent, and only the canonical one is POSTable.
load-a-chat.ts
import { uiFromMessages } from '@deuz-sdk/core/chat';

const record = await store.loadChat('chat-42');
const ui = record ? uiFromMessages(record.messages, () => crypto.randomUUID()) : [];

canonicalFromUI prefers the ordered parts over the buckets, and it is lossy on purpose — where it loses matters:

  • Survives (with parts present): the interleave, ImagePart attachments with their media type (carrier verbatim), reasoning signature / encrypted / redacted, tool_use.providerMetadata (Gemini's thoughtSignature — a dropped one 400s the next request), and executed tool calls re-emitted as the role: 'tool' message that follows the turn, because every tool_use must get a tool_result.
  • Does not survive: system messages (uiFromMessages never rendered them, so nothing here can bring them back — re-prepend your own), message-level providerMetadata, consecutive text parts (they merge into one block, which is what makes a text-only history round-trip exactly), UI-only bookkeeping (runState, denial, pending approvals, data-* parts, citations, step boundaries), and a call still awaiting its result, which stays a bare tool_use.
  • Without parts — a hand-built turn, or one restored from a pre-1.9 store — it collapses to bucket order (one reasoning block, then one text block, then every tool_use) and attachments are gone entirely, because UIMessage.content is a plain string. That result is a plausible history, not the original one.

So when you hold the real thing, persist it: a binding that keeps { ui, canonical } (as useChat does) should save history.canonical rather than round-tripping through canonicalFromUI.

Branching: regenerate and edit-and-resend

A chat binding maintains two views of the same conversation — ChatHistory is the pair:

interface ChatHistory {
  ui: UIMessage[];
  canonical: Message[];
}

Because history is immutable, a branch is just a prefix: a new array cut before the point you re-run. Two helpers do the cutting on both views at once:

  • dropTrailingAssistant(history)regenerate: drop the trailing assistant/tool turns from both views so the last user turn runs again. No-op when nothing trails.
  • branchBeforeUserMessage(history, messageId)edit-and-resend: cut both views to just before the user turn holding messageId. The canonical cut uses the user-turn ordinal (not array index), so assistant/tool interleaving can never skew the pairing. Returns undefined when messageId is not a user message.
edit-and-resend.ts
import { branchBeforeUserMessage } from '@deuz-sdk/core/chat';

const branched = branchBeforeUserMessage(history, editedMessageId);
if (branched) {
  const messages = [...branched.canonical, { role: 'user' as const, content: editedText }];
  // Persist the fork as a NEW chat, keeping lineage via parentId:
  streamChat({
    model,
    messages,
    chat: { store, chatId: newChatId, scope, parentId: originalChatId },
  });
}

parentId on the saved ChatRecord is how forks stay traceable — a linear chat never sets it; every edit-and-resend fork points at the chat it branched from.

  • UI Streaming — the wire parts applyUIPart consumes, including the 1.7 and 1.9 additions.
  • Request validationvalidateChatRequest, re-exported from this same subpath, for the route that receives the history.
  • Memory — the shared MemoryScope model, and the memory call option for cross-session facts (persistence stores transcripts; memory stores knowledge).
  • The unbreakable chatbot — persisting the live stream; this page persists the conversation.
  • Durable Runtime — the $deuzBytes codec convention and the SessionStore pattern this seam follows.
  • React hooksuseChat, the binding built on this engine.

本页内容