refactor(session): fold the session family into packages/session/
git mv the 12 packages from session-persistence/, session-projection/, session-title/, and telemetry/ into one session/ group per the regrouping RFC; merge the four group READMEs into one bilingual triplet; rewrite the group segment in tsconfig references (intra-group references shorten to ../<pkg>), tsconfig.base.json paths/globs, knip.json keys, vitest include, gate scripts, and authored doc/note citations; regenerate module graph, doc graphs, catalogs, and the lockfile importer keys. No npm names change. Full unit suite: 8779 passed; the 18 reported failures reproduce as env flakes (ambient-proxy IPv6 tunneling, watched-dir inotify timeouts under parallel load) — each passes in isolation with NO_PROXY set, matching their known pre-existing behavior on master.
This commit is contained in:
6
packages/session/session-title-llm/README.i18n.yaml
Normal file
6
packages/session/session-title-llm/README.i18n.yaml
Normal file
@@ -0,0 +1,6 @@
|
||||
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
|
||||
# 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 packages/session-title/session-title-llm/README.md
|
||||
README.md: 342687b5aa0cd35a70abf8cc66b3fe80342bce0b
|
||||
README.zh.md: f6f39e07d300dbe53faa51514c29699ca1d6a3d2
|
||||
47
packages/session/session-title-llm/README.md
Normal file
47
packages/session/session-title-llm/README.md
Normal file
@@ -0,0 +1,47 @@
|
||||
# @deepseek-ai/dsh-session-title-llm
|
||||
|
||||
English | [中文](README.zh.md)
|
||||
|
||||
Shared implementation policy for model-backed session-title providers. It resolves the auxiliary route, frames exact selected human messages as JSON, records the exact dispatchable request, applies a language-aware title instruction, enforces input and output budgets, composes timeout and caller cancellation, assembles the stream, and returns normalized text with exact source seqs and model provenance.
|
||||
|
||||
This package is a library, not a Cordis plugin. The provider plugins call `registerSessionTitleLlmProvider()` with their cadence and message selector; it validates shared config and delegates each revision to `generateSessionTitleWithLlm()`, so registration, route, prompt, cancellation, and validation behavior cannot drift between them.
|
||||
|
||||
## Route and failure contract
|
||||
|
||||
`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. The helper measures the final JSON-framed user prompt, including seq fields, wrappers, and JSON escaping, against `maxInputBytes` before logging or dispatch instead of truncating it. Timeout and caller cancellation are rechecked while consuming the stream and after it completes, so a late successful result cannot be accepted even if an interceptor or adapter ignores abort. 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 directly through `Session` 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. Persistence observes the record eagerly; the append does not need a title-specific marker, cast, settlement queue, or flush. The dispatched envelope is deep-frozen, carries `purpose: 'session-title'`, and deliberately lacks dsh-agent-loop's process-local request identity. Interceptors stay aligned with the record while loop-only reconstruction observers do not compare it with the conversation header. The DeepSeek adapter maps that purpose to thinking-disabled so the small output budget is reserved for visible title text; other adapters own their purpose-specific behavior. A later model failure leaves the request record intact; validation failures that never become dispatchable requests do not create one. The event stays outside derived model history.
|
||||
|
||||
## Configuration
|
||||
|
||||
Every field is required except the paired route override; there are no library defaults.
|
||||
|
||||
| Key | Contract |
|
||||
|---|---|
|
||||
| `targetWords` | Positive target word count for non-CJK titles. |
|
||||
| `targetCjkCharacters` | Positive target character count for Chinese, Japanese, or Korean titles. |
|
||||
| `maxInputBytes` | Positive UTF-8 byte ceiling for the final JSON-framed user prompt. |
|
||||
| `maxOutputTokens` | Positive auxiliary generation token cap. |
|
||||
| `timeoutMs` | Positive end-to-end deadline within the runtime timer limit. |
|
||||
| `provider`, `model` | Optional explicit route; both or neither. |
|
||||
|
||||
## Model Experience
|
||||
|
||||
### Auxiliary title request
|
||||
|
||||
#### What the model sees
|
||||
|
||||
The title model receives a fixed system instruction to return one concise unadorned title in the input language, including the configured word and CJK-character targets. Its one user message contains a JSON array of the exact selected human messages and their seqs.
|
||||
|
||||
#### Token effect
|
||||
|
||||
The auxiliary request consumes tokens according to selected input size and `maxOutputTokens`. It is separate from the main agent request and does not add title text or framing to agent history. DeepSeek title calls disable thinking; the main conversation retains its configured thinking mode.
|
||||
|
||||
#### KV Cache effect
|
||||
|
||||
No main-request invalidation. Auxiliary cache reuse is provider-specific; the fixed instruction is reusable while the JSON message array changes with each revision.
|
||||
|
||||
## Known Limitations and Deferred Work
|
||||
|
||||
- The helper accepts text output only and rejects tool calls; structured-output adapters and provider-specific prompt variants are not exposed.
|
||||
- It enforces a byte ceiling for the whole framed user prompt rather than clipping individual messages or applying a retention policy.
|
||||
47
packages/session/session-title-llm/README.zh.md
Normal file
47
packages/session/session-title-llm/README.zh.md
Normal file
@@ -0,0 +1,47 @@
|
||||
# @deepseek-ai/dsh-session-title-llm
|
||||
|
||||
[English](README.md) | 中文
|
||||
|
||||
由模型支持的会话标题提供方的共享实现策略。它解析辅助路由,将精确选中的用户消息封装为 JSON,记录可分发的确切请求,应用语言感知的标题指令,强制执行输入和输出预算,组合超时与调用方取消,组装流,并返回带有确切来源 seq 和模型来源信息的规范化文本。
|
||||
|
||||
此包是普通库,不是 Cordis 插件。提供方插件调用 `registerSessionTitleLlmProvider()`,传入各自节奏与消息选择器;该函数验证共享配置,并将每次修订委派给 `generateSessionTitleWithLlm()`,使各插件的注册、路由、提示词、取消与验证行为不会漂移。
|
||||
|
||||
## 路由与失败契约
|
||||
|
||||
`provider` 和 `model` 覆盖项都是可选的,但必须同时作为非空字符串提供。如果没有这一对取值,辅助模块会使用当前会话已记录 `request/header` 中捕获的确切提供方/模型路由;因此,在任何路由出现前显式刷新时必须提供覆盖项。辅助模块在记录或分发前,依据 `maxInputBytes` 检查最终 JSON 封装用户提示词的大小,包括 seq 字段、包装层与 JSON 转义,而不是将其截断。消费流期间和流完成后都会重新检查超时与调用方取消,因此即使 interceptor 或适配器忽略 abort,也不能接受迟到的成功结果。格式错误或空输出、工具调用和非 stop 结束原因同样会导致调用被拒绝;会话标题服务决定该拒绝属于自动警告还是显式调用方失败。
|
||||
|
||||
路由与输入验证完成后,辅助模块会在模型分发前直接通过 `Session` 追加仅写入日志的 `session/title-llm-request` 事件。它包含标题提供方 id、确切来源 seq、路由、系统提示词、消息列表,以及该调用使用的输出 token 上限。持久化会立即观察到该记录;追加不需要标题专属标记、类型断言、结算队列或刷写。分发的请求封套会深度冻结,携带 `purpose: 'session-title'`,且有意不包含 dsh-agent-loop 的进程本地请求身份。拦截器会与记录保持一致,而循环专用重建观察者不会把它与对话请求头比较。DeepSeek 适配器会根据该用途禁用思考,使少量输出预算全部用于可见标题文本;其他适配器负责自身用途专用行为。后续模型失败会保留请求记录;从未成为可分发请求的验证失败不会创建记录。该事件始终位于派生模型历史之外。
|
||||
|
||||
## 配置
|
||||
|
||||
除成对的路由覆盖项外,每个字段都必填;库不提供默认值。
|
||||
|
||||
| 键 | 契约 |
|
||||
|---|---|
|
||||
| `targetWords` | 非 CJK 标题的正整数目标词数。 |
|
||||
| `targetCjkCharacters` | 中文、日文或韩文标题的正整数目标字符数。 |
|
||||
| `maxInputBytes` | 最终 JSON 封装用户提示词的正整数 UTF-8 字节上限。 |
|
||||
| `maxOutputTokens` | 辅助生成的正整数 token 上限。 |
|
||||
| `timeoutMs` | 运行时定时器限制内的正数端到端时限。 |
|
||||
| `provider`, `model` | 可选显式路由;二者同时提供或同时省略。 |
|
||||
|
||||
## 模型体验
|
||||
|
||||
### 辅助标题请求
|
||||
|
||||
#### 模型看到的内容
|
||||
|
||||
标题模型会收到固定系统指令,要求以输入语言返回一个简洁且无装饰的标题;该指令包含所配置的词数与 CJK 字符数目标。它唯一的用户消息包含一个 JSON 数组,其中是精确选中的用户消息及其 seq。
|
||||
|
||||
#### Token 影响
|
||||
|
||||
辅助请求根据所选输入大小和 `maxOutputTokens` 消耗 token。它与主 agent(智能体)请求相互独立,不会向 agent 历史增加标题文本或封装内容。DeepSeek 标题调用会关闭思考;主对话保留自身配置的思考模式。
|
||||
|
||||
#### KV Cache 影响
|
||||
|
||||
不会使主请求的 KV Cache 失效。辅助缓存复用由提供方决定;固定指令可复用,而 JSON 消息数组会随每次修订变化。
|
||||
|
||||
## 已知限制与暂缓事项
|
||||
|
||||
- 辅助模块只接受文本输出,并拒绝工具调用;不公开结构化输出适配器或提供方专用提示词变体。
|
||||
- 它对整个封装用户提示词强制执行字节上限,不会剪裁单条消息或应用保留策略。
|
||||
46
packages/session/session-title-llm/package.json
Normal file
46
packages/session/session-title-llm/package.json
Normal file
@@ -0,0 +1,46 @@
|
||||
{
|
||||
"name": "@deepseek-ai/dsh-session-title-llm",
|
||||
"description": "Shared LLM generation policy for DeepSeek Harness session-title providers",
|
||||
"version": "0.0.1",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"main": "lib/index.js",
|
||||
"types": "lib/types/index.d.ts",
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./lib/types/index.d.ts",
|
||||
"default": "./lib/index.js"
|
||||
},
|
||||
"./invariant": {
|
||||
"types": "./lib/types/invariant.d.ts",
|
||||
"default": "./lib/invariant.js"
|
||||
},
|
||||
"./src/*": "./src/*",
|
||||
"./package.json": "./package.json"
|
||||
},
|
||||
"files": [
|
||||
"lib/index.js",
|
||||
"lib/invariant.js",
|
||||
"lib/types/**/*.d.ts"
|
||||
],
|
||||
"license": "BSD-3-Clause",
|
||||
"peerDependencies": {
|
||||
"@deepseek-ai/dsh-invariants": "^0.0.1",
|
||||
"@deepseek-ai/dsh-llm": "^0.0.1",
|
||||
"@deepseek-ai/dsh-session": "^0.0.1",
|
||||
"@deepseek-ai/dsh-session-title": "^0.0.1",
|
||||
"@deepseek-ai/dsh-timeout": "^0.0.1",
|
||||
"cordis": "^4.0.0-rc.7"
|
||||
},
|
||||
"dependencies": {
|
||||
"schemastery": "^3.18.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@deepseek-ai/dsh-invariants": "workspace:^",
|
||||
"@deepseek-ai/dsh-llm": "workspace:^",
|
||||
"@deepseek-ai/dsh-session": "workspace:^",
|
||||
"@deepseek-ai/dsh-session-title": "workspace:^",
|
||||
"@deepseek-ai/dsh-timeout": "workspace:^",
|
||||
"cordis": "^4.0.0-rc.7"
|
||||
}
|
||||
}
|
||||
294
packages/session/session-title-llm/src/index.ts
Normal file
294
packages/session/session-title-llm/src/index.ts
Normal file
@@ -0,0 +1,294 @@
|
||||
/**
|
||||
* Shared route, framing, timeout, assembly, and validation policy for
|
||||
* model-backed session-title providers.
|
||||
* @module @deepseek-ai/dsh-session-title-llm
|
||||
*/
|
||||
|
||||
import type { Context } from 'cordis'
|
||||
import z from 'schemastery'
|
||||
import { createUserMessage, 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 type {
|
||||
SessionTitleAutomaticMode,
|
||||
SessionTitleModelProvenance,
|
||||
SessionTitleProviderRequest,
|
||||
SessionTitleProviderResult,
|
||||
SessionTitleUserMessage,
|
||||
} from '@deepseek-ai/dsh-session-title'
|
||||
|
||||
/** Exact model-visible request recorded before one auxiliary title dispatch. */
|
||||
export interface SessionTitleLlmRequestEventData {
|
||||
/** Registered title-provider identity responsible for the request. */
|
||||
readonly titleProvider: SessionTitleProviderId
|
||||
/** Exact human `user/message` seqs represented in `messages`. */
|
||||
readonly messageSeqs: number[]
|
||||
/** Exact auxiliary LLM route. */
|
||||
readonly route: SessionTitleModelProvenance
|
||||
/** Exact auxiliary system prompt. */
|
||||
readonly system: string
|
||||
/** Exact auxiliary message list. */
|
||||
readonly messages: Message[]
|
||||
/** Exact auxiliary output-token cap. */
|
||||
readonly maxTokens: number
|
||||
}
|
||||
|
||||
declare module '@deepseek-ai/dsh-session' {
|
||||
interface SessionEventMap {
|
||||
/** Log-only pre-dispatch record of one session-title model request. */
|
||||
'session/title-llm-request': SessionTitleLlmRequestEventData
|
||||
}
|
||||
}
|
||||
|
||||
/** Capability-owned timeout reason code for auxiliary title requests. */
|
||||
export const SESSION_TITLE_TIMEOUT_CODE = 'SESSION_TITLE_TIMEOUT'
|
||||
|
||||
/** Required deployment policy for one model-backed title plugin. */
|
||||
export interface SessionTitleLlmConfig {
|
||||
/** Target word count for non-CJK titles. */
|
||||
readonly targetWords: number
|
||||
/** Target character count for Chinese, Japanese, or Korean titles. */
|
||||
readonly targetCjkCharacters: number
|
||||
/** Maximum UTF-8 bytes in the final JSON-framed user prompt. */
|
||||
readonly maxInputBytes: number
|
||||
/** Auxiliary generation output-token cap. */
|
||||
readonly maxOutputTokens: number
|
||||
/** End-to-end auxiliary request deadline in milliseconds. */
|
||||
readonly timeoutMs: number
|
||||
/** Optional explicit provider route; must be paired with `model`. */
|
||||
readonly provider?: string
|
||||
/** Optional explicit model id; must be paired with `provider`. */
|
||||
readonly model?: string
|
||||
}
|
||||
|
||||
/** Validated immutable model-provider policy. */
|
||||
export interface ResolvedSessionTitleLlmConfig extends SessionTitleLlmConfig {}
|
||||
|
||||
/** Shared Loader field schemas with no library defaults. */
|
||||
export const SessionTitleLlmConfigFields = {
|
||||
targetWords: z.number().step(1).min(1).required(),
|
||||
targetCjkCharacters: z.number().step(1).min(1).required(),
|
||||
maxInputBytes: z.number().step(1).min(1).required(),
|
||||
maxOutputTokens: z.number().step(1).min(1).required(),
|
||||
timeoutMs: z.number().step(1).min(1).max(MAX_TIMER_DELAY_MS).required(),
|
||||
provider: z.string(),
|
||||
model: z.string(),
|
||||
}
|
||||
|
||||
/** Shared Loader schema with no library defaults. */
|
||||
export const SessionTitleLlmConfigSchema: z<SessionTitleLlmConfig> = z.object(SessionTitleLlmConfigFields)
|
||||
|
||||
/** Complete configuration key set for direct construction validation. */
|
||||
const CONFIG_KEYS: ReadonlySet<string> = new Set([
|
||||
'targetWords',
|
||||
'targetCjkCharacters',
|
||||
'maxInputBytes',
|
||||
'maxOutputTokens',
|
||||
'timeoutMs',
|
||||
'provider',
|
||||
'model',
|
||||
])
|
||||
|
||||
/** Validate one positive integer limit. */
|
||||
function assertPositiveInteger(name: string, value: number): void {
|
||||
if (!Number.isInteger(value) || value <= 0) {
|
||||
throw new Error(`session-title-llm: ${name} must be a positive integer`)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate and detach required model-provider configuration.
|
||||
* @param config - untrusted plugin configuration.
|
||||
* @returns immutable policy with optional route absence preserved.
|
||||
*/
|
||||
export function resolveSessionTitleLlmConfig(
|
||||
config: SessionTitleLlmConfig,
|
||||
): ResolvedSessionTitleLlmConfig {
|
||||
const candidate: unknown = config
|
||||
if (candidate === null || typeof candidate !== 'object') {
|
||||
throw new Error('session-title-llm: configuration is required')
|
||||
}
|
||||
const value = candidate as SessionTitleLlmConfig
|
||||
for (const key of Object.keys(value)) {
|
||||
if (!CONFIG_KEYS.has(key)) throw new Error(`session-title-llm: unknown config key "${key}"`)
|
||||
}
|
||||
assertPositiveInteger('targetWords', value.targetWords)
|
||||
assertPositiveInteger('targetCjkCharacters', value.targetCjkCharacters)
|
||||
assertPositiveInteger('maxInputBytes', value.maxInputBytes)
|
||||
assertPositiveInteger('maxOutputTokens', value.maxOutputTokens)
|
||||
assertPositiveInteger('timeoutMs', value.timeoutMs)
|
||||
if (value.timeoutMs > MAX_TIMER_DELAY_MS) {
|
||||
throw new Error(`session-title-llm: timeoutMs must not exceed ${MAX_TIMER_DELAY_MS}`)
|
||||
}
|
||||
const hasProvider = value.provider !== undefined
|
||||
const hasModel = value.model !== undefined
|
||||
if (hasProvider !== hasModel) {
|
||||
throw new Error('session-title-llm: provider and model must be supplied together')
|
||||
}
|
||||
if (hasProvider
|
||||
&& (typeof value.provider !== 'string' || value.provider.length === 0
|
||||
|| typeof value.model !== 'string' || value.model.length === 0)) {
|
||||
throw new Error('session-title-llm: provider and model overrides must be non-empty strings')
|
||||
}
|
||||
return deepFreeze({ ...value })
|
||||
}
|
||||
|
||||
/** Select the provider-owned message subset from one fixed service revision. */
|
||||
export type SessionTitleLlmMessageSelector = (
|
||||
messages: readonly SessionTitleUserMessage[],
|
||||
) => readonly SessionTitleUserMessage[]
|
||||
|
||||
/**
|
||||
* Register one model-backed provider through the shared configuration and call policy.
|
||||
* @param ctx - context exposing the title and LLM services.
|
||||
* @param config - untrusted required deployment policy.
|
||||
* @param id - stable plugin identity recorded in title provenance.
|
||||
* @param automatic - provider-owned automatic generation cadence.
|
||||
* @param selectMessages - exact source-message selection for one revision.
|
||||
*/
|
||||
export function registerSessionTitleLlmProvider(
|
||||
ctx: Context,
|
||||
config: SessionTitleLlmConfig,
|
||||
id: string,
|
||||
automatic: SessionTitleAutomaticMode,
|
||||
selectMessages: SessionTitleLlmMessageSelector,
|
||||
): void {
|
||||
const resolved = resolveSessionTitleLlmConfig(config)
|
||||
const titleProvider = SessionTitleProviderId(id)
|
||||
ctx.sessionTitle.register({
|
||||
id: titleProvider,
|
||||
automatic,
|
||||
async generate(request) {
|
||||
return generateSessionTitleWithLlm(ctx, resolved, request, selectMessages(request.messages), titleProvider)
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
/** Resolve the explicit pair or the exact route captured from `request/header`. */
|
||||
function resolveRoute(
|
||||
config: ResolvedSessionTitleLlmConfig,
|
||||
request: SessionTitleProviderRequest,
|
||||
): SessionTitleModelProvenance {
|
||||
if (config.provider !== undefined && config.model !== undefined) {
|
||||
return { provider: config.provider, model: config.model }
|
||||
}
|
||||
if (request.route === undefined) {
|
||||
throw new Error('session-title-llm: no logged request route is available; configure provider and model together')
|
||||
}
|
||||
return request.route
|
||||
}
|
||||
|
||||
/** Stable language-aware system instruction shared by both provider plugins. */
|
||||
function systemPrompt(config: ResolvedSessionTitleLlmConfig): string {
|
||||
return [
|
||||
'Create a concise title for an AI coding-assistant session from the supplied human messages.',
|
||||
'Return only the title on one line, **in plain text of natural language**, with no quotes, prefix, explanation, Markdown, XML, or terminal control codes. No code is allowed.',
|
||||
'Use the language of the messages.',
|
||||
`Aim for about ${config.targetWords} words in non-CJK languages or ${config.targetCjkCharacters} CJK characters.`,
|
||||
].join('\n')
|
||||
}
|
||||
|
||||
/** Frame exact messages as JSON so user text cannot break structural delimiters. */
|
||||
function frameMessages(messages: readonly SessionTitleUserMessage[]): string {
|
||||
return `Generate the session title from this JSON array of human messages:\n${JSON.stringify(messages)}`
|
||||
}
|
||||
|
||||
/** Translate terminal finish reasons into an auxiliary-call failure. */
|
||||
function finishError(finish: FinishReason): Error | undefined {
|
||||
switch (finish.kind) {
|
||||
case 'stop':
|
||||
return undefined
|
||||
case 'error':
|
||||
case 'aborted': {
|
||||
const error = new Error(finish.failure.message) as Error & { code?: string }
|
||||
error.code = finish.failure.code
|
||||
return error
|
||||
}
|
||||
case 'max-tokens':
|
||||
return new Error('session-title-llm: title output reached maxOutputTokens')
|
||||
case 'tool-calls':
|
||||
return new Error('session-title-llm: title model unexpectedly requested a tool')
|
||||
default:
|
||||
return new Error(`session-title-llm: unsupported finish reason "${String((finish as { kind?: unknown }).kind)}"`)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate one title through the shared auxiliary LLM call.
|
||||
* @param ctx - context exposing the registered LLM service.
|
||||
* @param config - validated model-provider policy.
|
||||
* @param request - service-owned session, route, message snapshot, and cancellation.
|
||||
* @param selectedMessages - exact provider-selected subset to frame and attribute.
|
||||
* @param titleProvider - registered title-provider identity recorded with the request.
|
||||
* @returns normalized non-empty title, exact source seqs, and used model route.
|
||||
*/
|
||||
export async function generateSessionTitleWithLlm(
|
||||
ctx: Context,
|
||||
config: ResolvedSessionTitleLlmConfig,
|
||||
request: SessionTitleProviderRequest,
|
||||
selectedMessages: readonly SessionTitleUserMessage[],
|
||||
titleProvider: SessionTitleProviderId,
|
||||
): Promise<SessionTitleProviderResult> {
|
||||
request.signal.throwIfAborted()
|
||||
if (selectedMessages.length === 0) {
|
||||
throw new Error('session-title-llm: at least one source message is required')
|
||||
}
|
||||
const framedInput = frameMessages(selectedMessages)
|
||||
const inputBytes = Buffer.byteLength(framedInput, 'utf8')
|
||||
if (inputBytes > config.maxInputBytes) {
|
||||
throw new Error(`session-title-llm: input is ${inputBytes} bytes, exceeding maxInputBytes ${config.maxInputBytes}`)
|
||||
}
|
||||
const route = resolveRoute(config, request)
|
||||
const messages: Message[] = [createUserMessage({
|
||||
content: [{ type: 'text', text: framedInput }],
|
||||
source: { kind: 'plugin', plugin: 'dsh-session-title-llm' },
|
||||
})]
|
||||
const system = systemPrompt(config)
|
||||
using callDeadline = deadline(request.signal, config.timeoutMs, SESSION_TITLE_TIMEOUT_CODE)
|
||||
const options: GenerateOptions = deepFreeze({
|
||||
provider: route.provider,
|
||||
model: route.model,
|
||||
messages,
|
||||
system,
|
||||
maxTokens: config.maxOutputTokens,
|
||||
sessionId: request.session.id,
|
||||
purpose: 'session-title',
|
||||
signal: callDeadline.signal,
|
||||
})
|
||||
request.session.append('session/title-llm-request', {
|
||||
titleProvider,
|
||||
messageSeqs: selectedMessages.map(message => message.seq),
|
||||
route,
|
||||
system,
|
||||
messages,
|
||||
maxTokens: config.maxOutputTokens,
|
||||
})
|
||||
callDeadline.signal.throwIfAborted()
|
||||
const assembler = new BlockAssembler()
|
||||
for await (const chunk of ctx.llm.stream(options)) {
|
||||
callDeadline.signal.throwIfAborted()
|
||||
assembler.push(chunk)
|
||||
}
|
||||
callDeadline.signal.throwIfAborted()
|
||||
const terminalError = finishError(assembler.finish)
|
||||
if (terminalError !== undefined) throw terminalError
|
||||
const blocks = assembler.blocks()
|
||||
if (blocks.some(block => block.type === 'tool-call')) {
|
||||
throw new Error('session-title-llm: title output must contain text only')
|
||||
}
|
||||
const text = blocks
|
||||
.filter((block): block is Extract<(typeof blocks)[number], { type: 'text' }> => block.type === 'text')
|
||||
.map(block => block.text)
|
||||
.join(' ')
|
||||
const title = normalizeSessionTitle(text, Number.MAX_SAFE_INTEGER)
|
||||
if (title.length === 0) throw new Error('session-title-llm: title model produced no text')
|
||||
return {
|
||||
title,
|
||||
messageSeqs: selectedMessages.map(message => message.seq),
|
||||
model: route,
|
||||
}
|
||||
}
|
||||
30
packages/session/session-title-llm/src/invariant.ts
Normal file
30
packages/session/session-title-llm/src/invariant.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
/**
|
||||
* Package-owned invariant companion for `@deepseek-ai/dsh-session-title-llm`.
|
||||
* @module @deepseek-ai/dsh-session-title-llm/invariant
|
||||
*/
|
||||
|
||||
/* jscpd:ignore-start */
|
||||
import type { Context } from 'cordis'
|
||||
import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants'
|
||||
|
||||
const PACKAGE_NAME = '@deepseek-ai/dsh-session-title-llm'
|
||||
|
||||
/** Cordis companion plugin name. */
|
||||
export const name = 'session-title-llm-invariant'
|
||||
/** Service required before the companion can reserve package ownership. */
|
||||
export const inject = ['invariants']
|
||||
|
||||
/**
|
||||
* No runtime invariant: this stateless helper validates and freezes each auxiliary request before
|
||||
* dispatch; deadline, stream, and provenance relationships are checked synchronously and by tests.
|
||||
*/
|
||||
const install: InvariantInstaller = () => {}
|
||||
|
||||
/**
|
||||
* Register this package's invariant companion.
|
||||
* @param ctx - Cordis context carrying the invariant service.
|
||||
* @returns the installed registration's disposer after setup succeeds.
|
||||
*/
|
||||
export const apply = (ctx: Context): Promise<() => void> =>
|
||||
Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install))
|
||||
/* jscpd:ignore-end */
|
||||
365
packages/session/session-title-llm/tests/llm.spec.ts
Normal file
365
packages/session/session-title-llm/tests/llm.spec.ts
Normal file
@@ -0,0 +1,365 @@
|
||||
import { Context } from 'cordis'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import LlmService, { createUserMessage, CallId, isAgentLoopRequest, LlmAdapter } from '@deepseek-ai/dsh-llm'
|
||||
import type { FinishReason, GenerateOptions, StreamChunk } from '@deepseek-ai/dsh-llm'
|
||||
import SessionStore, { SessionId } from '@deepseek-ai/dsh-session'
|
||||
import { SessionTitleProviderId } from '@deepseek-ai/dsh-session-title'
|
||||
import type { SessionTitleProviderRequest } from '@deepseek-ai/dsh-session-title'
|
||||
import { MAX_TIMER_DELAY_MS } from '@deepseek-ai/dsh-timeout'
|
||||
import {
|
||||
generateSessionTitleWithLlm,
|
||||
resolveSessionTitleLlmConfig,
|
||||
SESSION_TITLE_TIMEOUT_CODE,
|
||||
} from '@deepseek-ai/dsh-session-title-llm'
|
||||
import type { SessionTitleLlmConfig } from '@deepseek-ai/dsh-session-title-llm'
|
||||
|
||||
class RecordingAdapter extends LlmAdapter {
|
||||
readonly requests: GenerateOptions[] = []
|
||||
|
||||
constructor(
|
||||
private readonly script: readonly StreamChunk[],
|
||||
private readonly onDispatch?: () => void,
|
||||
) {
|
||||
super()
|
||||
}
|
||||
|
||||
override async * stream(options: GenerateOptions): AsyncIterable<StreamChunk> {
|
||||
this.onDispatch?.()
|
||||
this.requests.push(options)
|
||||
yield * this.script
|
||||
}
|
||||
}
|
||||
|
||||
class CooperativeAdapter extends LlmAdapter {
|
||||
override async * stream(options: GenerateOptions): AsyncIterable<StreamChunk> {
|
||||
const signal = options.signal
|
||||
if (signal === undefined) throw new Error('expected title request signal')
|
||||
await new Promise<never>((_resolve, reject) => {
|
||||
const rejectAbort = (): void => {
|
||||
// oxlint-disable-next-line typescript/prefer-promise-reject-errors -- exercise exact AbortSignal.reason propagation
|
||||
reject(signal.reason)
|
||||
}
|
||||
if (signal.aborted) {
|
||||
rejectAbort()
|
||||
return
|
||||
}
|
||||
signal.addEventListener('abort', rejectAbort, { once: true })
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
class DelayedSuccessAdapter extends LlmAdapter {
|
||||
constructor(private readonly delayMs: number) {
|
||||
super()
|
||||
}
|
||||
|
||||
override async * stream(): AsyncIterable<StreamChunk> {
|
||||
await new Promise<void>(resolve => setTimeout(resolve, this.delayMs))
|
||||
yield * SCRIPT
|
||||
}
|
||||
}
|
||||
|
||||
const SCRIPT: StreamChunk[] = [
|
||||
{ type: 'block-start', index: 0, blockType: 'text' },
|
||||
{ type: 'text-delta', index: 0, text: ' 五个字标题 ' },
|
||||
{ type: 'finish', reason: { kind: 'stop' } },
|
||||
]
|
||||
|
||||
const CONFIG = {
|
||||
targetWords: 5,
|
||||
targetCjkCharacters: 10,
|
||||
maxInputBytes: 1_000,
|
||||
maxOutputTokens: 32,
|
||||
timeoutMs: 1_000,
|
||||
} as const
|
||||
|
||||
const TITLE_PROVIDER = SessionTitleProviderId('test-title-provider')
|
||||
let nextSession = 0
|
||||
|
||||
function request(ctx: Context, signal = new AbortController().signal): SessionTitleProviderRequest {
|
||||
const session = ctx.sessions.create(SessionId(`title-call-${++nextSession}`))
|
||||
session.append('turn/start', {
|
||||
turn: 1,
|
||||
})
|
||||
const first = session.append('user/message', createUserMessage({
|
||||
content: [{ type: 'text', text: 'first prompt' }],
|
||||
source: { kind: 'user' },
|
||||
}), { surfaceOp: 'append' })
|
||||
const second = session.append('user/message', createUserMessage({
|
||||
content: [{ type: 'text', text: '第二个问题' }],
|
||||
source: { kind: 'user' },
|
||||
}), { surfaceOp: 'append' })
|
||||
session.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
|
||||
return {
|
||||
session,
|
||||
messages: [
|
||||
{ seq: first.seq, text: 'first prompt' },
|
||||
{ seq: second.seq, text: '第二个问题' },
|
||||
],
|
||||
route: { provider: 'current-route', model: 'current-model' },
|
||||
signal,
|
||||
}
|
||||
}
|
||||
|
||||
function requestWithoutRoute(ctx: Context, signal = new AbortController().signal): SessionTitleProviderRequest {
|
||||
const routed = request(ctx, signal)
|
||||
return { session: routed.session, messages: routed.messages, signal }
|
||||
}
|
||||
|
||||
async function withScript(script: readonly StreamChunk[]): Promise<{
|
||||
ctx: Context
|
||||
adapter: RecordingAdapter
|
||||
}> {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SessionStore)
|
||||
await ctx.plugin(LlmService)
|
||||
const adapter = new RecordingAdapter(script)
|
||||
ctx.llm.registerAdapter(['current-route'], adapter)
|
||||
return { ctx, adapter }
|
||||
}
|
||||
|
||||
describe('generateSessionTitleWithLlm', () => {
|
||||
it('uses the exact logged route, language targets, full framed input, and output token cap', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SessionStore)
|
||||
await ctx.plugin(LlmService)
|
||||
const providerRequest = request(ctx)
|
||||
let requestWasLoggedAtDispatch = false
|
||||
const adapter = new RecordingAdapter(SCRIPT, () => {
|
||||
requestWasLoggedAtDispatch = providerRequest.session.events
|
||||
.some(event => event.type === 'session/title-llm-request')
|
||||
})
|
||||
ctx.llm.registerAdapter(['current-route'], adapter)
|
||||
|
||||
const result = await generateSessionTitleWithLlm(
|
||||
ctx,
|
||||
resolveSessionTitleLlmConfig(CONFIG),
|
||||
providerRequest,
|
||||
providerRequest.messages,
|
||||
TITLE_PROVIDER,
|
||||
)
|
||||
|
||||
expect(result).toEqual({
|
||||
title: '五个字标题',
|
||||
messageSeqs: providerRequest.messages.map(message => message.seq),
|
||||
model: { provider: 'current-route', model: 'current-model' },
|
||||
})
|
||||
expect(requestWasLoggedAtDispatch).toBe(true)
|
||||
expect(adapter.requests).toHaveLength(1)
|
||||
const options = adapter.requests[0]!
|
||||
expect(Object.isFrozen(options)).toBe(true)
|
||||
expect(Object.isFrozen(options.messages)).toBe(true)
|
||||
expect(isAgentLoopRequest(options)).toBe(false)
|
||||
expect(options).toMatchObject({
|
||||
provider: 'current-route',
|
||||
model: 'current-model',
|
||||
maxTokens: 32,
|
||||
sessionId: providerRequest.session.id,
|
||||
purpose: 'session-title',
|
||||
})
|
||||
expect(options.system).toContain('5 words')
|
||||
expect(options.system).toContain('10 CJK characters')
|
||||
const prompt = options.messages[0]?.content[0]
|
||||
expect(prompt?.type === 'text' && prompt.text).toContain('first prompt')
|
||||
expect(prompt?.type === 'text' && prompt.text).toContain('第二个问题')
|
||||
expect(providerRequest.session.events.findLast(event => event.type === 'session/title-llm-request')?.data)
|
||||
.toEqual({
|
||||
titleProvider: TITLE_PROVIDER,
|
||||
messageSeqs: providerRequest.messages.map(message => message.seq),
|
||||
route: { provider: 'current-route', model: 'current-model' },
|
||||
system: options.system,
|
||||
messages: options.messages,
|
||||
maxTokens: 32,
|
||||
})
|
||||
})
|
||||
|
||||
it('uses paired explicit overrides and bounds the final framed input before model dispatch', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SessionStore)
|
||||
await ctx.plugin(LlmService)
|
||||
const adapter = new RecordingAdapter(SCRIPT)
|
||||
ctx.llm.registerAdapter(['explicit-route'], adapter)
|
||||
const oversized = request(ctx)
|
||||
const [selected] = oversized.messages
|
||||
if (selected === undefined) throw new Error('expected one selected message')
|
||||
const rawInputBytes = Buffer.byteLength(selected.text, 'utf8')
|
||||
const config = resolveSessionTitleLlmConfig({
|
||||
...CONFIG,
|
||||
provider: 'explicit-route',
|
||||
model: 'explicit-model',
|
||||
maxInputBytes: rawInputBytes,
|
||||
})
|
||||
|
||||
await expect(generateSessionTitleWithLlm(ctx, config, oversized, [selected], TITLE_PROVIDER))
|
||||
.rejects.toThrow(/input.*bytes.*maxInputBytes/i)
|
||||
expect(adapter.requests).toEqual([])
|
||||
expect(oversized.session.events.some(event => event.type === 'session/title-llm-request')).toBe(false)
|
||||
|
||||
const withinLimit = resolveSessionTitleLlmConfig({ ...config, maxInputBytes: 1_000 })
|
||||
const within = request(ctx)
|
||||
await generateSessionTitleWithLlm(ctx, withinLimit, within, [within.messages[0]!], TITLE_PROVIDER)
|
||||
expect(adapter.requests[0]).toMatchObject({
|
||||
provider: 'explicit-route',
|
||||
model: 'explicit-model',
|
||||
})
|
||||
})
|
||||
|
||||
it('requires every deployment limit and a complete optional route pair', () => {
|
||||
expect(() => resolveSessionTitleLlmConfig(undefined as never)).toThrow(/configuration is required/)
|
||||
expect(() => resolveSessionTitleLlmConfig(null as never)).toThrow(/configuration is required/)
|
||||
expect(() => resolveSessionTitleLlmConfig('invalid' as never)).toThrow(/configuration is required/)
|
||||
expect(() => resolveSessionTitleLlmConfig({ ...CONFIG, extra: true } as SessionTitleLlmConfig))
|
||||
.toThrow(/unknown config key "extra"/)
|
||||
expect(() => resolveSessionTitleLlmConfig({ ...CONFIG, targetWords: 0 }))
|
||||
.toThrow(/targetWords.*positive integer/)
|
||||
expect(() => resolveSessionTitleLlmConfig({ ...CONFIG, targetWords: 1.5 }))
|
||||
.toThrow(/targetWords.*positive integer/)
|
||||
expect(() => resolveSessionTitleLlmConfig({ ...CONFIG, provider: 'only-provider' }))
|
||||
.toThrow(/provider and model must be supplied together/)
|
||||
expect(() => resolveSessionTitleLlmConfig({ ...CONFIG, model: 'only-model' }))
|
||||
.toThrow(/provider and model must be supplied together/)
|
||||
expect(() => resolveSessionTitleLlmConfig({ ...CONFIG, provider: '', model: 'model' }))
|
||||
.toThrow(/overrides must be non-empty strings/)
|
||||
expect(() => resolveSessionTitleLlmConfig({ ...CONFIG, provider: 'provider', model: '' }))
|
||||
.toThrow(/overrides must be non-empty strings/)
|
||||
expect(() => resolveSessionTitleLlmConfig({ ...CONFIG, provider: 1, model: 'model' } as never))
|
||||
.toThrow(/overrides must be non-empty strings/)
|
||||
expect(() => resolveSessionTitleLlmConfig({ ...CONFIG, provider: 'provider', model: 1 } as never))
|
||||
.toThrow(/overrides must be non-empty strings/)
|
||||
expect(() => resolveSessionTitleLlmConfig({ ...CONFIG, timeoutMs: MAX_TIMER_DELAY_MS + 1 }))
|
||||
.toThrow(/timeoutMs must not exceed/)
|
||||
expect(() => resolveSessionTitleLlmConfig(CONFIG)).not.toThrow()
|
||||
})
|
||||
|
||||
it('rejects an absent route, empty selection, and pre-aborted caller before model dispatch', async () => {
|
||||
const { ctx, adapter } = await withScript(SCRIPT)
|
||||
const config = resolveSessionTitleLlmConfig(CONFIG)
|
||||
const unrouted = requestWithoutRoute(ctx)
|
||||
await expect(generateSessionTitleWithLlm(ctx, config, unrouted, unrouted.messages, TITLE_PROVIDER))
|
||||
.rejects.toThrow(/no logged request route/)
|
||||
const empty = request(ctx)
|
||||
await expect(generateSessionTitleWithLlm(ctx, config, empty, [], TITLE_PROVIDER))
|
||||
.rejects.toThrow(/at least one source message/)
|
||||
const controller = new AbortController()
|
||||
controller.abort(new Error('caller stopped'))
|
||||
const aborted = request(ctx, controller.signal)
|
||||
await expect(generateSessionTitleWithLlm(ctx, config, aborted, aborted.messages, TITLE_PROVIDER))
|
||||
.rejects.toThrow('caller stopped')
|
||||
expect(adapter.requests).toEqual([])
|
||||
})
|
||||
|
||||
it.each([
|
||||
[{ kind: 'error', failure: { message: 'provider failed', code: 'SERVER' } }, 'provider failed', 'SERVER'],
|
||||
[{ kind: 'aborted', failure: { message: 'provider aborted', code: 'ABORTED' } }, 'provider aborted', 'ABORTED'],
|
||||
] satisfies Array<[FinishReason, string, string]>)('preserves %s terminal failure details', async (reason, message, code) => {
|
||||
const { ctx } = await withScript([{ type: 'finish', reason }])
|
||||
const providerRequest = request(ctx)
|
||||
await expect(generateSessionTitleWithLlm(
|
||||
ctx,
|
||||
resolveSessionTitleLlmConfig(CONFIG),
|
||||
providerRequest,
|
||||
providerRequest.messages,
|
||||
TITLE_PROVIDER,
|
||||
)).rejects.toMatchObject({ message, code })
|
||||
expect(providerRequest.session.events.some(event => event.type === 'session/title-llm-request')).toBe(true)
|
||||
})
|
||||
|
||||
it.each([
|
||||
[{ kind: 'max-tokens' }, /reached maxOutputTokens/],
|
||||
[{ kind: 'tool-calls' }, /unexpectedly requested a tool/],
|
||||
[{ kind: 'future-finish' } as never, /unsupported finish reason "future-finish"/],
|
||||
] satisfies Array<[FinishReason, RegExp]>)('rejects the terminal finish reason %s', async (reason, error) => {
|
||||
const { ctx } = await withScript([{ type: 'finish', reason }])
|
||||
const providerRequest = request(ctx)
|
||||
await expect(generateSessionTitleWithLlm(
|
||||
ctx,
|
||||
resolveSessionTitleLlmConfig(CONFIG),
|
||||
providerRequest,
|
||||
providerRequest.messages,
|
||||
TITLE_PROVIDER,
|
||||
)).rejects.toThrow(error)
|
||||
})
|
||||
|
||||
it('rejects tool-call blocks and a successful response with no text', async () => {
|
||||
const toolScript: StreamChunk[] = [
|
||||
{ type: 'block-start', index: 0, blockType: 'tool-call' },
|
||||
{ type: 'tool-call-delta', index: 0, id: CallId('title-tool'), name: 'unexpected', argumentsDelta: '{}' },
|
||||
{ type: 'finish', reason: { kind: 'stop' } },
|
||||
]
|
||||
const tool = await withScript(toolScript)
|
||||
const toolRequest = request(tool.ctx)
|
||||
await expect(generateSessionTitleWithLlm(
|
||||
tool.ctx,
|
||||
resolveSessionTitleLlmConfig(CONFIG),
|
||||
toolRequest,
|
||||
toolRequest.messages,
|
||||
TITLE_PROVIDER,
|
||||
)).rejects.toThrow(/output must contain text only/)
|
||||
|
||||
const reasoning = await withScript([
|
||||
{ type: 'block-start', index: 0, blockType: 'reasoning' },
|
||||
{ type: 'reasoning-delta', index: 0, text: 'no final title' },
|
||||
{ type: 'finish', reason: { kind: 'stop' } },
|
||||
])
|
||||
const reasoningRequest = request(reasoning.ctx)
|
||||
await expect(generateSessionTitleWithLlm(
|
||||
reasoning.ctx,
|
||||
resolveSessionTitleLlmConfig(CONFIG),
|
||||
reasoningRequest,
|
||||
reasoningRequest.messages,
|
||||
TITLE_PROVIDER,
|
||||
)).rejects.toThrow(/produced no text/)
|
||||
})
|
||||
|
||||
it('aborts a cooperative model stream at the configured deadline', async () => {
|
||||
vi.useFakeTimers()
|
||||
try {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SessionStore)
|
||||
await ctx.plugin(LlmService)
|
||||
ctx.llm.registerAdapter(['current-route'], new CooperativeAdapter())
|
||||
const providerRequest = request(ctx)
|
||||
const pending = generateSessionTitleWithLlm(
|
||||
ctx,
|
||||
resolveSessionTitleLlmConfig({ ...CONFIG, timeoutMs: 10 }),
|
||||
providerRequest,
|
||||
providerRequest.messages,
|
||||
TITLE_PROVIDER,
|
||||
)
|
||||
const rejected = expect(pending).rejects.toMatchObject({
|
||||
code: SESSION_TITLE_TIMEOUT_CODE,
|
||||
timeoutMs: 10,
|
||||
})
|
||||
await vi.advanceTimersByTimeAsync(10)
|
||||
await rejected
|
||||
} finally {
|
||||
vi.useRealTimers()
|
||||
}
|
||||
})
|
||||
|
||||
it('rejects a successful stream that completes after the configured deadline', async () => {
|
||||
vi.useFakeTimers()
|
||||
try {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SessionStore)
|
||||
await ctx.plugin(LlmService)
|
||||
ctx.llm.registerAdapter(['current-route'], new DelayedSuccessAdapter(20))
|
||||
const providerRequest = request(ctx)
|
||||
const pending = generateSessionTitleWithLlm(
|
||||
ctx,
|
||||
resolveSessionTitleLlmConfig({ ...CONFIG, timeoutMs: 10 }),
|
||||
providerRequest,
|
||||
providerRequest.messages,
|
||||
TITLE_PROVIDER,
|
||||
)
|
||||
const rejected = expect(pending).rejects.toMatchObject({
|
||||
code: SESSION_TITLE_TIMEOUT_CODE,
|
||||
timeoutMs: 10,
|
||||
})
|
||||
await vi.advanceTimersByTimeAsync(20)
|
||||
await rejected
|
||||
} finally {
|
||||
vi.useRealTimers()
|
||||
}
|
||||
})
|
||||
})
|
||||
17
packages/session/session-title-llm/tsconfig.json
Normal file
17
packages/session/session-title-llm/tsconfig.json
Normal file
@@ -0,0 +1,17 @@
|
||||
{
|
||||
"extends": "../../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"rootDir": "src",
|
||||
"outDir": "lib/types"
|
||||
},
|
||||
"include": ["src"],
|
||||
"references": [
|
||||
{ "path": "../../../vendor/cosmokit" },
|
||||
{ "path": "../../../vendor/cordis" },
|
||||
{ "path": "../../../vendor/schemastery" },
|
||||
{ "path": "../../support/invariants" },
|
||||
{ "path": "../../llm/llm" },
|
||||
{ "path": "../../util/timeout" },
|
||||
{ "path": "../session-title" }
|
||||
]
|
||||
}
|
||||
Reference in New Issue
Block a user