diff --git a/.agents/notes/implemented/feature/2026-07-21-log-backed-session-titles.i18n.yaml b/.agents/notes/implemented/feature/2026-07-21-log-backed-session-titles.i18n.yaml index ebc6a207fb..a92265acae 100644 --- a/.agents/notes/implemented/feature/2026-07-21-log-backed-session-titles.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-21-log-backed-session-titles.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write -2026-07-21-log-backed-session-titles.md: 18e7f53c701605971c45c0afff4d7e91d141191e -2026-07-21-log-backed-session-titles.zh.md: b9a6b8c7e8c4ca8ae7cb66c1209f9096cc2548cb +2026-07-21-log-backed-session-titles.md: 483fbdba1f89703f4baeadfe76623e143a008c7e +2026-07-21-log-backed-session-titles.zh.md: 4e5d16196dbd4dd498489ab0dd7939e295522a08 diff --git a/.agents/notes/implemented/feature/2026-07-21-log-backed-session-titles.md b/.agents/notes/implemented/feature/2026-07-21-log-backed-session-titles.md index 18e7f53c70..483fbdba1f 100644 --- a/.agents/notes/implemented/feature/2026-07-21-log-backed-session-titles.md +++ b/.agents/notes/implemented/feature/2026-07-21-log-backed-session-titles.md @@ -24,7 +24,7 @@ The core session package exposes `ctx.sessions.appendOutOfBand()` only for plugi 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 joins whichever turn is open at acceptance time or uses the zero-step append path. Explicit `refresh(session, signal?)` materializes any missing fallback and awaits the registered provider; without a provider it returns the fallback. Caller cancellation during fallback flush does not roll back the durable append, but `refresh()` rechecks the signal and rejects instead of returning success. Concurrent refreshes reserve their session-local revision before waiting for fallback durability, so a newer call supersedes an older call before either can invert provider completion order. +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 joins whichever turn is open at acceptance time or uses the zero-step append path. Explicit `refresh(session, signal?)` materializes any missing fallback and awaits the registered provider; without a provider it returns the fallback. Caller cancellation during fallback flush does not roll back the durable append, but `refresh()` rechecks the signal and rejects instead of returning success. Concurrent refreshes reserve their session-local revision before waiting for fallback durability, so a newer call supersedes an older call before either can invert provider completion order. All title-capability out-of-band writes share a per-session settlement queue; a replacement model request waits for any earlier title write, while the superseded model call itself remains independently abortable and cannot commit stale output. The first-message 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. diff --git a/.agents/notes/implemented/feature/2026-07-21-log-backed-session-titles.zh.md b/.agents/notes/implemented/feature/2026-07-21-log-backed-session-titles.zh.md index b9a6b8c7e8..4e5d16196d 100644 --- a/.agents/notes/implemented/feature/2026-07-21-log-backed-session-titles.zh.md +++ b/.agents/notes/implemented/feature/2026-07-21-log-backed-session-titles.zh.md @@ -24,7 +24,7 @@ Status: implemented 只有人类来源的 `user/message` 事件中的文本块才符合条件。空提示词、仅含控制字符的提示词和非文本提示词会等待下一条合格消息。服务从提示词路径调度首个回退标题而不等待其完成,随后规范化空白和控制序列,应用已配置的单词数和 UTF-8 字节限制且不拆分代码点,并记录第一条消息的 seq。 -仅当主循环存在已记录在日志中的当前提供方/模型路由时,自动提供方工作才会启动。`request/header` 新追加到日志时,会直接启动待执行工作;如果请求头没有变化,则由循环构建并带有标记的 `llm/stream` 请求会先与折叠所得的路由匹配,再启动该工作。随后,生成工作独立于 agent 响应运行;完成结果在被接受时加入当时开放的轮次,否则使用零步骤追加路径。显式调用 `refresh(session, signal?)` 会生成尚缺的回退标题并等待已注册的提供方;没有提供方时则返回回退标题。调用方在回退标题刷写期间取消调用不会回滚这次持久化追加,但 `refresh()` 会重新检查取消信号,并让调用以拒绝结束,而非返回成功。并发刷新会在等待回退标题持久化完成前预留会话本地修订号,因此在任何调用有机会造成提供方完成顺序倒置之前,较新的调用就会取代较早的调用。 +仅当主循环存在已记录在日志中的当前提供方/模型路由时,自动提供方工作才会启动。`request/header` 新追加到日志时,会直接启动待执行工作;如果请求头没有变化,则由循环构建并带有标记的 `llm/stream` 请求会先与折叠所得的路由匹配,再启动该工作。随后,生成工作独立于 agent 响应运行;完成结果在被接受时加入当时开放的轮次,否则使用零步骤追加路径。显式调用 `refresh(session, signal?)` 会生成尚缺的回退标题并等待已注册的提供方;没有提供方时则返回回退标题。调用方在回退标题刷写期间取消调用不会回滚这次持久化追加,但 `refresh()` 会重新检查取消信号,并让调用以拒绝结束,而非返回成功。并发刷新会在等待回退标题持久化完成前预留会话本地修订号,因此在任何调用有机会造成提供方完成顺序倒置之前,较新的调用就会取代较早的调用。会话标题功能产生的所有带外写入在每个会话内共用一个结算队列;接替执行的模型请求会等待任何更早的标题写入完成,而被取代的模型调用本身仍可独立中止,且无法提交陈旧输出。 首消息提供方仅在新会话首次创建回退标题时调度一次。自动执行失败后,后续提示词不会重新调度;`refresh()` 是重试路径。全部消息提供方会在每条合格且由人类发出的提示词后调度,并传入截至该修订的所有合格消息,包括预置历史记录。较新的修订会中止并取代更早的待执行或活跃工作。 diff --git a/docs/config-catalog.md b/docs/config-catalog.md index 55a20b4c5c..caf8b8fd58 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -884,7 +884,7 @@ export interface Config { } ``` -Source: [`packages/session-title/session-title/src/index.ts:64`](../packages/session-title/session-title/src/index.ts) +Source: [`packages/session-title/session-title/src/index.ts:69`](../packages/session-title/session-title/src/index.ts) ## `@deepseek-ai/dsh-session-title-all-messages-llm` diff --git a/docs/cordis-catalog/services.md b/docs/cordis-catalog/services.md index a7a47b5fe6..c5aedf9dbe 100644 --- a/docs/cordis-catalog/services.md +++ b/docs/cordis-catalog/services.md @@ -1023,7 +1023,7 @@ register(provider: SessionTitleProvider): () => Promise Types: [Session](../core-data-structures/session.md) · [SessionTitleProvider](../core-data-structures/session-title.md) · [SessionTitleSnapshot](../core-data-structures/session-title.md) -Source: [`packages/session-title/session-title/src/index.ts:236`](../../packages/session-title/session-title/src/index.ts) +Source: [`packages/session-title/session-title/src/index.ts:281`](../../packages/session-title/session-title/src/index.ts) ## `ctx.skills` — `SkillService` diff --git a/docs/persistence-catalog.md b/docs/persistence-catalog.md index 43218c047a..8c099d2997 100644 --- a/docs/persistence-catalog.md +++ b/docs/persistence-catalog.md @@ -390,7 +390,7 @@ Source: [`packages/sandbox/sandbox-policy/src/session-mode.ts:34`](../packages/s Types: [SessionTitleEventData](core-data-structures/session-title.md) -Source: [`packages/session-title/session-title/src/index.ts:90`](../packages/session-title/session-title/src/index.ts) +Source: [`packages/session-title/session-title/src/index.ts:95`](../packages/session-title/session-title/src/index.ts) #### `session/title-llm-request` — log-only @@ -401,7 +401,7 @@ Source: [`packages/session-title/session-title/src/index.ts:90`](../packages/ses Types: [SessionTitleLlmRequestEventData](core-data-structures/session-title.md) -Source: [`packages/session-title/session-title-llm/src/index.ts:40`](../packages/session-title/session-title-llm/src/index.ts) +Source: [`packages/session-title/session-title-llm/src/index.ts:44`](../packages/session-title/session-title-llm/src/index.ts) ### `steering/*` diff --git a/packages/session-title/session-title-llm/README.md b/packages/session-title/session-title-llm/README.md index ebe4461aa8..b5df1ba10e 100644 --- a/packages/session-title/session-title-llm/README.md +++ b/packages/session-title/session-title-llm/README.md @@ -8,7 +8,7 @@ This package is a library, not a Cordis plugin. The provider plugins call `regis `provider` and `model` overrides are optional but must be supplied together as non-empty strings. Without that pair, the helper uses the exact provider/model route captured from the current session's logged `request/header`; an explicit refresh before any route exists therefore needs overrides. Input exceeding `maxInputBytes` rejects instead of being truncated. Timeout, cancellation, malformed or empty output, tool calls, and non-stop finish reasons also reject; the session-title service decides whether that rejection is an automatic warning or an explicit caller failure. -After route and input validation, the helper appends a log-only `session/title-llm-request` event before model dispatch. It contains the title-provider id, exact source seqs, route, system prompt, message list, and output-token cap used by the call. The dispatched envelope is deep-frozen to keep interceptors aligned with that record but deliberately lacks dsh-agent-loop's process-local request identity, so loop-only reconstruction observers do not compare it with the conversation header. A later model failure leaves that request record intact; validation failures that never become dispatchable requests do not create one. The event stays outside derived model history. +After route and input validation, the helper appends a log-only `session/title-llm-request` event before model dispatch. It contains the title-provider id, exact source seqs, route, system prompt, message list, and output-token cap used by the call. The append shares the title capability's per-session settlement queue, so a superseding request cannot collide with an earlier fallback, request record, or accepted-title flush. The dispatched envelope is deep-frozen to keep interceptors aligned with that record but deliberately lacks dsh-agent-loop's process-local request identity, so loop-only reconstruction observers do not compare it with the conversation header. A later model failure leaves that request record intact; validation failures that never become dispatchable requests do not create one. The event stays outside derived model history. ## Configuration diff --git a/packages/session-title/session-title-llm/src/index.ts b/packages/session-title/session-title-llm/src/index.ts index c7ffc1eb1c..572145e89a 100644 --- a/packages/session-title/session-title-llm/src/index.ts +++ b/packages/session-title/session-title-llm/src/index.ts @@ -9,7 +9,11 @@ import z from 'schemastery' import { BlockAssembler, deepFreeze } from '@deepseek-ai/dsh-llm' import type { FinishReason, GenerateOptions, Message } from '@deepseek-ai/dsh-llm' import { deadline, MAX_TIMER_DELAY_MS } from '@deepseek-ai/dsh-timeout' -import { normalizeSessionTitle, SessionTitleProviderId } from '@deepseek-ai/dsh-session-title' +import { + appendSessionTitleOutOfBand, + normalizeSessionTitle, + SessionTitleProviderId, +} from '@deepseek-ai/dsh-session-title' import type { SessionTitleAutomaticMode, SessionTitleModelProvenance, @@ -258,14 +262,14 @@ export async function generateSessionTitleWithLlm( sessionId: request.session.id, signal: callDeadline.signal, }) - await ctx.sessions.appendOutOfBand(request.session, 'session/title-llm-request', { + await appendSessionTitleOutOfBand(ctx, request.session, 'session/title-llm-request', { titleProvider, messageSeqs: selectedMessages.map(message => message.seq), route, system, messages, maxTokens: config.maxOutputTokens, - }, { kind: 'session-title' }) + }, callDeadline.signal) callDeadline.signal.throwIfAborted() const assembler = new BlockAssembler() for await (const chunk of ctx.llm.stream(options)) assembler.push(chunk) diff --git a/packages/session-title/session-title/README.md b/packages/session-title/session-title/README.md index 218637d29c..e8538a1d0c 100644 --- a/packages/session-title/session-title/README.md +++ b/packages/session-title/session-title/README.md @@ -10,7 +10,7 @@ Only text blocks from human `user/message` events are eligible. The first eligib - `refresh(session, signal?)` materializes the fallback when needed, then explicitly runs the registered provider over the current eligible messages. Provider errors and caller cancellation reject; cancellation does not roll back a fallback append already entering durability. - `register(provider)` installs the sole optional provider and returns its awaitable Cordis effect disposer. A second registration throws immediately; disposal aborts pending and active calls, waits for their settlement, and only then permits another provider to register. -Automatic work never delays the main agent response. A provider starts only after a marked loop-built request's exact route matches the current logged `request/header`, including when the unchanged header needs no new snapshot. Its late completion joins an open turn or uses a flushed zero-step `session-title` turn through `ctx.sessions.appendOutOfBand()`. Automatic failures warn and retain the latest title. New all-message revisions, provider disposal, session disposal, and explicit refresh abort older work, and a stale completion cannot append. Concurrent explicit refreshes reserve their order before fallback durability waits, so only the newest call may reach the provider. Service teardown cancels queued work and drains calls that ignore cancellation before unloading completes. +Automatic work never delays the main agent response. A provider starts only after a marked loop-built request's exact route matches the current logged `request/header`, including when the unchanged header needs no new snapshot. Its late completion joins an open turn or uses a flushed zero-step `session-title` turn through `ctx.sessions.appendOutOfBand()`. Automatic failures warn and retain the latest title. New all-message revisions, provider disposal, session disposal, and explicit refresh abort older work, and a stale completion cannot append. Concurrent explicit refreshes reserve their order before fallback durability waits. Service and bundled model-provider records use `appendSessionTitleOutOfBand()` to share a per-session settlement queue, so a replacement request record waits for an earlier title write without serializing the superseded model call itself. Service teardown cancels queued work and drains calls that ignore cancellation before unloading completes. Forks inherit title events in their seed unchanged. The first-message cadence does not automatically retitle a child; the all-messages cadence may append a new revision after the child receives a later human prompt. diff --git a/packages/session-title/session-title/src/index.ts b/packages/session-title/session-title/src/index.ts index 35e92f7a94..4059b7cd47 100644 --- a/packages/session-title/session-title/src/index.ts +++ b/packages/session-title/session-title/src/index.ts @@ -8,7 +8,12 @@ import z from 'schemastery' import type { Branded } from '@deepseek-ai/dsh-brand' import { deepFreeze, isAgentLoopRequest } from '@deepseek-ai/dsh-llm' import type { GenerateOptions } from '@deepseek-ai/dsh-llm' -import type { Session, SessionEvent } from '@deepseek-ai/dsh-session' +import type { + OutOfBandSessionEventType, + Session, + SessionEvent, + SessionEventMap, +} from '@deepseek-ai/dsh-session' import { fallbackSessionTitle, normalizeSessionTitle } from './normalize.ts' export { fallbackSessionTitle, normalizeSessionTitle, truncateTitleUtf8 } from './normalize.ts' @@ -95,6 +100,46 @@ declare module '@deepseek-ai/dsh-session' { } } +/** Per-session settlement tails for title-capability out-of-band writes. */ +const SESSION_TITLE_WRITE_TAILS = new WeakMap>() + +/** Convert either write outcome into a fulfilled queue tail. */ +function settleSessionTitleWrite(): void {} + +/** + * Serialize one title-capability out-of-band event with its session peers. + * Cancellation is checked when the write reaches the head of the queue; once + * the core append starts, its durability contract runs to completion. + * @param ctx - context exposing the live session store. + * @param session - exact live session that owns the title-capability event. + * @param type - plugin-declared log-only title event type. + * @param data - typed JSON payload for the event. + * @param signal - service or provider lifetime checked before publication starts. + * @returns the durably accepted event. + */ +export async function appendSessionTitleOutOfBand( + ctx: Context, + session: Session, + type: T, + data: SessionEventMap[T], + signal: AbortSignal, +): Promise> { + const predecessor = SESSION_TITLE_WRITE_TAILS.get(session) + const run = Promise.resolve(predecessor).then(() => { + signal.throwIfAborted() + return ctx.sessions.appendOutOfBand(session, type, data, { kind: 'session-title' }) + }) + const tail = run.then(settleSessionTitleWrite, settleSessionTitleWrite) + SESSION_TITLE_WRITE_TAILS.set(session, tail) + try { + return await run + } finally { + if (SESSION_TITLE_WRITE_TAILS.get(session) === tail) { + SESSION_TITLE_WRITE_TAILS.delete(session) + } + } +} + /** One eligible human text message exposed to title providers. */ export interface SessionTitleUserMessage { /** Source `user/message` event seq. */ @@ -481,7 +526,7 @@ export class SessionTitleService extends Service { }) this.assertCurrent(session, work) const accepted = this.validateResult(result, messages) - await this.ctx.sessions.appendOutOfBand(session, 'session/title', { + await appendSessionTitleOutOfBand(this.ctx, session, 'session/title', { title: accepted.title, messageSeqs: [...accepted.messageSeqs], source: { @@ -489,7 +534,7 @@ export class SessionTitleService extends Service { provider: work.registration.provider.id, ...accepted.model === undefined ? {} : { model: accepted.model }, }, - }, { kind: 'session-title' }) + }, work.signal) return this.get(session) } finally { const state = this.work.get(session) @@ -662,11 +707,11 @@ export class SessionTitleService extends Service { this.config.fallbackMaxBytes, ) if (title.length === 0) return undefined - await this.ctx.sessions.appendOutOfBand(session, 'session/title', { + await appendSessionTitleOutOfBand(this.ctx, session, 'session/title', { title, messageSeqs: [first.seq], source: { kind: 'fallback' }, - }, { kind: 'session-title' }) + }, this.lifetime.signal) return this.get(session) } } diff --git a/packages/session-title/session-title/tests/service-contracts.spec.ts b/packages/session-title/session-title/tests/service-contracts.spec.ts index 802fb70d5b..b46f4637d7 100644 --- a/packages/session-title/session-title/tests/service-contracts.spec.ts +++ b/packages/session-title/session-title/tests/service-contracts.spec.ts @@ -2,6 +2,7 @@ import { Context, type Fiber } from 'cordis' import { describe, expect, it, vi } from 'vitest' import SessionStore, { Session, SessionId } from '@deepseek-ai/dsh-session' import SessionTitleService, { + appendSessionTitleOutOfBand, SessionTitleProviderId, type Config, type SessionTitleProvider, @@ -9,6 +10,16 @@ import SessionTitleService, { type SessionTitleProviderResult, } from '@deepseek-ai/dsh-session-title' +declare module '@deepseek-ai/dsh-session' { + interface SessionEventMap { + 'test/title-provider-request': { revision: number } + } + + interface OutOfBandSessionEventMap { + 'test/title-provider-request': true + } +} + const CONFIG = { fallbackMaxWords: 5, fallbackMaxBytes: 40, @@ -247,6 +258,78 @@ describe('SessionTitleService configuration and refresh boundaries', () => { expect(olderError.message).toMatch(/superseded/) }) + it('serializes a newer provider write after the superseded write', async () => { + const ctx = await setup() + const session = startSession(ctx, 'refresh-provider-write-order') + const source = appendPrompt(session, 'Serialize explicit provider writes') + await settle() + session.append('turn/end', { turn: 1, reason: { kind: 'completed' } }) + const flushStarted = deferred() + const releaseFlush = deferred() + let flushCount = 0 + ctx.on('session/flush', async (subject) => { + if (subject !== session || ++flushCount !== 1) return + flushStarted.resolve(undefined) + await releaseFlush.promise + }) + let generation = 0 + ctx.sessionTitle.register({ + id: SessionTitleProviderId('refresh-provider-write-order'), + automatic: 'first-message', + async generate(request) { + generation += 1 + const revision = generation + await appendSessionTitleOutOfBand(ctx, request.session, 'test/title-provider-request', { + revision, + }, request.signal) + return { + title: `Generated title ${revision}`, + messageSeqs: [source.seq], + } + }, + }) + + const older = ctx.sessionTitle.refresh(session) + const olderOutcome = older.then( + () => undefined, + (error: unknown) => error, + ) + await flushStarted.promise + const middle = ctx.sessionTitle.refresh(session) + const middleOutcome = middle.then( + value => value, + (error: unknown) => error, + ) + await settle() + + expect(generation).toBe(2) + expect(session.events.filter(event => event.type === 'test/title-provider-request')) + .toHaveLength(1) + const newer = ctx.sessionTitle.refresh(session) + const newerOutcome = newer.then( + value => value, + (error: unknown) => error, + ) + await settle() + expect(generation).toBe(3) + expect(session.events.filter(event => event.type === 'test/title-provider-request')) + .toHaveLength(1) + + releaseFlush.resolve(undefined) + const newerResult = await newerOutcome + expect(newerResult).toMatchObject({ title: 'Generated title 3' }) + const olderError = await olderOutcome + expect(olderError).toBeInstanceOf(Error) + if (!(olderError instanceof Error)) throw new Error('expected older refresh to reject') + expect(olderError.message).toMatch(/superseded/) + const middleError = await middleOutcome + expect(middleError).toBeInstanceOf(Error) + if (!(middleError instanceof Error)) throw new Error('expected middle refresh to reject') + expect(middleError.message).toMatch(/superseded/) + expect(session.events.filter(event => event.type === 'test/title-provider-request').map(event => event.data.revision)) + .toEqual([1, 3]) + }) + it('cancels a queued fallback when the session-title service unloads', async () => { const ctx = new Context() await ctx.plugin(SessionStore)