DSH / Atlas
2026-07-21implementedfeature

Log-backed session titles

基于日志的会话标题

A session needs a short human-facing title before an editor, terminal, or query consumer can present it usefully. The cheapest implementation can derive one from the first prompt, while higher-quality implementations may call a model over the first prompt or the whole conversation. Those strategies have different latency, cost, routing, and retry behavior, but every consumer needs one durable source of truth. Session

English

Problem

A session needs a short human-facing title before an editor, terminal, or query consumer can present it usefully. The cheapest implementation can derive one from the first prompt, while higher-quality implementations may call a model over the first prompt or the whole conversation. Those strategies have different latency, cost, routing, and retry behavior, but every consumer needs one durable source of truth.

Session identity metadata is immutable, and the event log is the replay and fork boundary. A model-generated title often finishes after the main turn closes, so writing it synchronously would delay the agent response while writing it as mutable metadata would bypass ordinary persistence, replay, and lineage semantics. Concurrent prompts, provider HMR, cancellation, and ignored abort signals also make an unfenced background result capable of overwriting a newer title.

Decision

The session-title capability family owns title state and generation policy. @deepseek-ai/dsh-session-title provides ctx.sessionTitle, a deterministic first-prompt fallback, and a registry for at most one optional asynchronous provider. @deepseek-ai/dsh-session-title-llm owns the common auxiliary-model request policy; separate first-prompt and all-prompts plugins choose input cadence. The shared agent spine mounts only the fallback service. The Web host mounts that service plus the first-prompt model provider with explicit overridable limits, so a fresh Web session gains an immediate fallback and then a non-blocking model summary. Other compositions choose either model provider explicitly.

Event ownership and folding

Every accepted revision is a log-only session/title event. Its payload contains normalized non-empty text, the exact eligible human user/message seqs used to derive it, and either the fallback source kind or the registered provider id plus optional provider/model route. Before an auxiliary title-model dispatch, the shared helper appends a log-only session/title-llm-request event containing the title-provider id, exact source seqs, route, system prompt, messages, and output-token cap; a later generation failure leaves the request auditable. The dispatched envelope is deep-frozen to preserve exact agreement with that record but carries no process-local agent-loop request identity, so loop-only reconstruction checks do not compare it with the main conversation header. Validation failures that never reach dispatch create no request event. foldSessionTitle() selects the latest title event and adds that event's seq and timestamp as SessionTitleSnapshot. Neither event enters session.surface or deriveMessages().

The title service appends session/title directly after checking its current revision and exact live session; the bundled model helper likewise appends its literal session/title-llm-request record before dispatch. Both records may sit between turns without inventing an execution boundary. Persistence admits them to bounded background batches and drains through ordinary checkpoints and lifecycle teardown; title publication does not force a per-event flush. No generic marker, cast, or settlement queue sits between the event owner and Session.append(). This is the domain-specific application of the standalone log-only event decision.

Input and asynchronous timing

Only text blocks from human-source user/message events are eligible. Empty, control-only, and non-text prompts wait for the next eligible message. The service schedules the first fallback without awaiting it from the prompt path, normalizes whitespace and control sequences, applies the configured word and UTF-8 byte limits without splitting a code point, and records the first message seq.

Automatic provider work starts only after the main loop has a current logged provider/model route. A newly appended request/header starts pending work directly; when the header is unchanged, the marked loop-built llm/stream request starts it after matching the folded route. Generation then runs independently of the agent response, and a completion appends a standalone event without changing turn state. Explicit refresh(session, signal?) materializes any missing fallback and awaits the registered provider; without a provider it returns the fallback. Caller cancellation does not roll back an already accepted fallback event, and refresh() rechecks the signal before returning success. Concurrent refreshes reserve their session-local revision before provider work, so a newer call aborts and supersedes an older call before either can invert provider completion order. Automatic work and concurrent refreshes share one session-local in-flight fallback promise, so the first fallback creates only one title event. A title accepted during asynchronous compaction remains log-only, so the compactor's post-summary surface-node check tolerates it; a concurrent surface mutation still invalidates the replacement.

The first-prompt provider schedules once when a fresh session first creates its fallback. An automatic failure does not reschedule on later prompts; refresh() is the retry path. The all-messages provider schedules after every eligible human prompt and passes all eligible messages through that revision, including seeded history. Its newer revision aborts and supersedes older pending or active work.

Registration, routing, and failure policy

register(provider) validates one branded stable id, cadence, and generation function, then returns an awaitable effect disposer. A second live registration throws immediately. Provider disposal marks the registration closing, aborts its pending and active work, and waits for every call to settle before removing the registration, so replacement cannot overlap a provider that ignores cancellation. Session disposal aborts its active work. Service teardown prevents queued fallback and provider microtasks from starting, aborts active work, and drains tracked promises before unloading completes. Every session-local generation has a monotonic revision and exact registration identity; acceptance rechecks revision, registration, session liveness, service liveness, and cancellation, so stale output cannot commit.

Model providers require explicit word, CJK-character, input-byte, output-token, and timeout limits. Optional provider and model overrides are a pair; without them the helper uses the exact route from the logged main request header. Selected messages are framed as JSON under one fixed language-aware instruction. The dispatched GenerateOptions carries purpose: 'session-title'; the DeepSeek adapter maps that purpose to thinking-disabled and omits reasoning effort so the bounded output is visible title text, while the main conversation keeps its configured thinking mode. The input limit measures the final user prompt, including wrappers, seq fields, and JSON escaping, before the request is logged or dispatched. Oversized input is rejected rather than truncated because truncation would make the recorded source seqs falsely imply complete use. The fused deadline is checked while consuming each stream chunk and after completion, so a successful result returned after timeout cannot be accepted even when an interceptor or adapter ignores abort.

Automatic provider failures are nonfatal warnings and retain the latest title. Explicit refresh failures reject to the caller. Output must be non-empty text with unique ordered seqs drawn from the fixed request; the service normalizes and byte-limits it before log acceptance.

Explicit rename

rename(session, title) accepts a user title synchronously: it normalizes the text under the accepted-title byte limit, supersedes in-flight automatic work, and appends a session/title event with the third source kind, user. A user-sourced latest title pins the session: onUserMessage schedules no automatic revision while it stands, under either cadence. An explicit refresh() remains the deliberate unpin — it appends a provider or fallback event over the pinned one whenever a replacement title is derivable (an underivable fallback, e.g. under a tiny byte cap, leaves the pin standing). The Web host exposes this as the session.rename unary method (resuming cold sessions first) and returns the normalized title plus its event seq so the client settles its title projection cell before the push frame arrives.

Forks and consumers

A fork inherits seed title events unchanged, like the rest of its source log — a pinned (user-sourced) title stays pinned in the child until an explicit refresh. The first-prompt provider does not automatically retitle a fork. The all-messages provider may append a child-owned revision after a later child prompt, using inherited and new eligible messages.

ctx.sessionQuery.readTitle() folds one live-preferred or persisted log without loading titles during listSessions(). The TUI uses the latest title as its header subtitle and sets the terminal window title to <session title> — <configured product title> after terminal-safe rendering. The Web host folds the same log state into a validated mux control frame after each attached-session subscription baseline and immediately after forwarding a live raw title event. The browser retains only newer title event seqs even when the control frame precedes list or session-instance creation; sidebar labels, search, breadcrumbs, and the browser title then react to the projected revision. session.list remains metadata-only, so a cold persisted session uses the cwd basename or id until opening or resuming it attaches the log. The browser title uses <session title> — <existing HTML title> only for a selected titled session and otherwise preserves the product title. Consumers reporting agent completion use the core foldConsumedWork() fold, so a later between-turn title record cannot replace the preceding message-triggered outcome.

Alternatives considered

  • Mutable SessionHeader or side metadata — rejected because it creates a second persistence mutation protocol, weakens immutable identity metadata, makes crash atomicity backend-specific, and gives forks ambiguous copy-versus-reference behavior. The append-only log already owns replayable latest-wins state.
  • Await title generation before returning the agent response — rejected because auxiliary provider latency and failure would sit on the main interaction's critical path. The deterministic fallback gives immediate useful state while a better title may arrive later.
  • Put titles in derived history or the request prefix — rejected because UI metadata would consume tokens, change cache identity, and make the main model observe its own label. A log-only event remains reconstructable without becoming model-visible.
  • Permit multiple registered providers and resolve precedence after completion — rejected because completion order is not product precedence and would make retries, HMR, and the recorded provider nondeterministic. A deployment that needs a composite policy can register one provider that owns that policy.
  • Silently truncate oversized auxiliary input — rejected because the provider result would cite source-message seqs whose complete text it did not receive. Keeping the prior title and warning preserves the exact input record.
  • Index titles in listSessions() immediately — rejected because the existing lightweight metadata list would need per-backend derived-index synchronization. Exact readTitle() establishes the read contract without precommitting search or indexing policy.
  • Keep the Web host fallback-only — rejected because the UI would expose durable titles but never improve them beyond the first-prompt prefix. The first-prompt provider keeps its latency off the main response path while making model summaries the default Web outcome.

Consequences

  • Titles survive JSONL and SQLite persistence, replay, and fork inheritance without a separate mutable record.
  • Web title delivery stays incremental and log-backed without a title index or persisted-list scan; cold list rows improve after attach.
  • A fallback appears immediately. Each fresh Web session adds one first-prompt auxiliary call; other compositions choose whether better titles justify model cost and whether later prompts should retitle a session.
  • Auxiliary request records and late accepted titles consume event seqs without consuming turn numbers, so persistence exposes both attempted dispatches and accepted updates even though model history and KV-cache identity do not change.
  • One provider and monotonic per-session revisions make disposal, supersession, and stale-result rejection explicit, at the cost of leaving multi-strategy precedence to a composite provider.
  • Deletion (unpinning without an explicit refresh), search, and list indexing remain outside the capability.

中文

问题

会话需要一个面向用户的简短标题,编辑器、终端或查询消费方才能有效呈现它。成本最低的实现可以从第一条提示词派生标题,质量更高的实现则可以让模型处理第一条提示词或整个对话。这些策略在延迟、成本、路由和重试行为上各有不同,但所有消费方都需要一个持久的真源。

会话身份元数据不可变,事件日志是回放和 fork 的边界。模型生成的标题往往在主轮次结束后才完成,因此同步写入会延迟 agent(智能体)响应,而作为可变元数据写入则会绕过常规的持久化、回放和沿袭语义。并发提示词、提供方 HMR(热模块替换)、取消以及被忽略的中止信号,还可能让未受版本校验约束的后台结果覆盖更新的标题。

决策

session-title 能力包族负责标题状态和生成策略。@deepseek-ai/dsh-session-title 提供 ctx.sessionTitle、确定性的首消息回退方案,以及一个至多接受单个可选异步提供方的注册表。@deepseek-ai/dsh-session-title-llm 负责通用的辅助模型请求策略;首消息插件和全部用户消息插件分别选择输入调度方式。共享 agent 主干只挂载回退服务。Web host 会挂载该服务和首消息模型提供方,并显式设置可覆盖的限制,因此新建的 Web 会话会立即获得回退标题,随后在不阻塞主响应的情况下获得模型摘要。其他组合需显式选择任一模型提供方。

事件归属与折叠

每个已接受的修订都是纯日志 session/title 事件。其载荷包含规范化后的非空文本、用于派生标题的所有合格且来源为人类的 user/message 的准确 seq,以及回退来源 kind,或已注册的提供方 id 加可选的提供方和模型路由。辅助标题模型发起调用前,共享辅助组件会追加一个纯日志 session/title-llm-request 事件,其载荷包含标题提供方 id、准确的源 seq、路由、系统提示词、消息和输出 token 上限;即使后续生成失败,这次请求仍可审计。发送的请求信封经过深度冻结,以确保其与该记录精确一致,但它有意不携带进程本地的 agent loop(智能体循环)请求身份,因此仅针对 agent loop 的重建检查不会将它与主对话请求头进行比较。未进入调用阶段的验证失败不会创建请求事件。foldSessionTitle() 选择最新的标题事件,并将该事件的 seq 和时间戳加入 SessionTitleSnapshot。这两类事件都不会进入 session.surfacederiveMessages()

标题服务会在检查当前修订和确切的实时会话后,直接追加 session/title;随附模型辅助函数同样会在发起调用前追加其字面量 session/title-llm-request 记录。两类记录都可以位于轮次之间,而无需虚构执行边界。持久化会将它们接纳到有界后台批次中,并通过常规检查点和生命周期 teardown 排空;标题发布不会强制逐事件 flush。事件所有方与 Session.append() 之间不存在通用标记、类型断言或结算队列。这是独立纯日志事件决策在特定领域中的应用。

输入与异步时序

只有人类来源的 user/message 事件中的文本块才符合条件。空提示词、仅含控制内容的提示词和非文本提示词会等待下一条合格消息。服务从提示词路径调度首个回退标题而不等待其完成,随后规范化空白和控制序列,应用已配置的单词数和 UTF-8 字节限制且不拆分代码点,并记录第一条消息的 seq。

仅当主循环存在已记录在日志中的当前提供方/模型路由时,自动提供方工作才会启动。request/header 新追加到日志时,会直接启动待执行工作;如果请求头没有变化,则由循环构建并带有标记的 llm/stream 请求会先与折叠所得的路由匹配,再启动该工作。随后,生成工作独立于 agent 响应运行;完成结果会追加一个独立事件,而不改变轮次状态。显式调用 refresh(session, signal?) 会生成尚缺的回退标题并等待已注册的提供方;没有提供方时则返回回退标题。调用方取消不会回滚已接受的回退事件,refresh() 会在返回成功前重新检查信号。并发刷新会在提供方工作之前预留会话本地修订号,因此较新的调用会在任何调用有机会造成提供方完成顺序倒置之前中止并取代较早的调用。自动工作与并发刷新在每个会话内共用同一个进行中的回退 promise,因此首次回退只会创建一个标题事件。异步压缩(compaction)期间接受的标题仍是纯日志事件,因此压缩器在摘要完成后执行的表层节点检查不会因该标题而失败;并发的表层变更仍会使替换失效。

首消息提供方仅在新会话首次创建回退标题时调度一次。自动执行失败后,后续提示词不会重新调度;refresh() 是重试路径。全部消息提供方会在每条合格且由人类发出的提示词后调度,并传入截至该修订的所有合格消息,包括预置历史记录。较新的修订会中止并取代更早的待执行或活跃工作。

注册、路由与失败策略

register(provider) 会验证一个带品牌类型的稳定 id、执行时机和生成函数,然后返回一个可等待完成的 effect dispose(资源释放)函数。第二个活跃注册会立即抛出错误。对提供方注册执行 dispose 时,会将注册标记为正在关闭,中止其待执行和活跃工作,并等待所有调用结束后才移除注册,因此替代提供方不会与忽略取消的旧提供方重叠运行。会话 dispose 会中止其活跃工作。服务卸载时,会阻止排队中的回退和提供方微任务启动,中止活跃工作,并且卸载完成前会等待所有已跟踪的 promise 结算。每项会话本地生成都有单调递增的修订号和确切的注册身份;接受结果时会重新检查修订号、注册、会话活跃状态、服务活跃状态和取消状态,因此陈旧输出无法提交。

模型提供方必须显式配置单词数、CJK 字符数、输入字节数、输出 token 数和超时限制。可选的 providermodel 覆盖项必须成对提供;两者均未提供时,辅助组件会使用主请求已记录请求头中的准确路由。系统在一条固定且能区分语言的指令下,将选中的消息封装为 JSON。发出的 GenerateOptions 携带 purpose: 'session-title';DeepSeek 适配器将该用途映射为禁用思考且省略推理强度设置的请求,使受限输出成为可见的标题文本,而主对话仍沿用已配置的思考模式。输入字节数按最终形成的用户提示词计算,其中包括包装文本、seq 字段和 JSON 转义;系统会在记录请求或发起调用前完成这项检查。过大输入会被拒绝而不是截断,因为截断会让记录的源消息 seq 错误地表示这些消息已被完整使用。系统在消费每个流分片时以及流完成后都会检查融合后的截止时间,因此即使拦截器或适配器忽略中止信号,超时后返回的成功结果也不会被接受。

自动提供方故障只会发出非致命警告,并保留最新标题。显式刷新失败则会使调用方的调用被拒绝。输出必须是非空文本,并包含来自固定请求、唯一且有序的 seq;服务会在日志接受前对其进行规范化并施加字节限制。

显式重命名

rename(session, title) 同步接受用户标题:按已接受标题的字节上限规范化文本、取代在途自动工作,并追加一条第三种来源 usersession/title 事件。最新标题来源为 user 即钉住该会话:只要它还在,onUserMessage 在任一节奏下都不再安排自动修订。显式 refresh() 仍是有意的解钉手段——只要能推导出替代标题,它就在被钉住的标题之上追加提供方或回退事件(推导不出回退标题时,例如字节上限过小,钉住状态保持不变)。Web host 将其暴露为 session.rename unary 方法(冷会话先恢复),并返回规范化后的标题及其事件 seq,使 client 在推送帧到达前就结算自己的 title 投影格。

Fork 与消费方

与源日志的其他部分相同,fork 会原样继承作为种子的标题事件——被钉住(user 来源)的标题在子会话中保持钉住,直到显式 refresh。首消息提供方不会自动为 fork 重新生成标题。全部消息提供方可以在子会话出现后续提示词后追加一项归子会话所有的修订,并使用继承的合格消息和新增的合格消息。

ctx.sessionQuery.readTitle() 会折叠一份实时优先或已持久化的日志,而不会在 listSessions() 期间加载标题。TUI 使用最新标题作为其标题栏副标题,并在完成终端安全渲染后,将终端窗口标题设置为 <session title> — <configured product title>。Web host 会在每个已附加会话的订阅基线之后,以及转发实时原始标题事件后立即,将同一份日志状态折叠为经过校验的 mux 控制帧。即使控制帧先于列表或会话实例创建抵达,浏览器也只保留标题事件 seq 较新的版本;侧边栏标签、搜索、面包屑和浏览器标题会随投影后的修订更新。session.list 仍只包含元数据,因此尚未打开的持久化会话会继续以 cwd 基名或 id 作为回退,直至打开或恢复会话时附加其日志。浏览器仅在选中已有标题的会话时将标题设置为 <session title> — <existing HTML title>,否则保留产品标题。报告 agent 完成情况的消费方使用核心的 foldConsumedWork() 折叠逻辑,因此后续的轮次间标题记录无法取代此前由消息触发的结果。

考虑过的替代方案

  • 可变 SessionHeader 或独立元数据:不予采纳,因为这会创建第二套持久化变更协议,削弱不可变身份元数据,让崩溃原子性因后端而异,并使 fork 的复制或引用行为产生歧义。仅追加日志已经负责可回放的后写覆盖状态。
  • 返回 agent 响应前等待标题生成:不予采纳,因为辅助提供方的延迟和故障会进入主交互的关键路径。确定性回退方案可以立即提供可用状态,质量更高的标题则可稍后到达。
  • 将标题放入派生历史记录或请求前缀:不予采纳,因为 UI 元数据会消耗 token、改变缓存标识,并让主模型观察到自己的标签。纯日志事件既保持可重建,又不会变得对模型可见。
  • 允许注册多个提供方,并在完成后解析优先级:不予采纳,因为完成顺序并不等于产品优先级,而且会让重试、HMR 和已记录的提供方变得不确定。需要组合策略的部署可以注册一个自行负责该策略的提供方。
  • 静默截断过大的辅助输入:不予采纳,因为提供方结果会引用源消息 seq,却没有收到这些消息的完整文本。保留原有标题并发出警告,可以保留准确的输入记录。
  • 立即在 listSessions() 中索引标题:不予采纳,因为现有的轻量元数据列表将需要逐后端同步派生索引。精确的 readTitle() 建立了读取约定,而没有提前锁定搜索或索引策略。
  • 让 Web host 只使用回退标题:不予采纳,因为 UI 虽会显示持久标题,却始终无法将第一条提示词的前缀改进为更好的标题。首消息提供方在主响应路径之外运行,并让模型摘要成为 Web 的默认结果。

后果

  • 标题可以在 JSONL 和 SQLite 持久化中存续、重放并遵循 fork 继承语义,而无需单独的可变记录。
  • Web 标题仍以增量方式从日志交付,无需标题索引或扫描持久化列表;冷会话的列表项会在会话附加后改用标题。
  • 回退标题会立即出现。每个新建的 Web 会话都会增加一次针对首消息的辅助调用;其他组合可以自行决定更优标题是否值得模型成本,以及后续提示词是否需要重新生成会话标题。
  • 辅助请求记录和延迟接受的标题会占用事件 seq,但不会占用轮次编号,因此持久化会同时呈现尝试发起的调用与已接受的更新,尽管模型历史和 KV Cache 标识保持不变。
  • 单个提供方和每会话单调递增的修订号让 dispose、取代和陈旧结果拒绝行为明确可见,但多策略优先级必须由复合提供方负责。
  • 删除(不经显式 refresh 的解钉)、搜索和列表索引不在此能力范围内。