Merge pull request #5902 from deepseek-harness/fix/stable-conversation-titles
fix(session-title): keep adequate conversation titles unchanged
This commit is contained in:
commit
0454b67568
51 files changed
+1763
-536
No files matched your search
@@ -308,9 +308,15 @@ describe.skipIf(!existsSync(join(root, 'apps/cli/lib/bin.js')))('Official option
|
||||
await expect.poll(() => titleRequests(following.agent).length).toBe(1)
|
||||
expect(titleRequests(following.agent)[0]?.data.titleProvider).toBe('session-title-all-prompts-llm')
|
||||
await expect.poll(() => ctx.sessionTitle.get(following.agent.session)?.source.kind).toBe('provider')
|
||||
const currentTitle = ctx.sessionTitle.get(following.agent.session)?.title
|
||||
const firstInput = titleRequests(following.agent)[0]?.data.messages[0]?.content[0]
|
||||
expect(firstInput?.type === 'text' && firstInput.text).toContain('Generate the session title from this JSON array')
|
||||
await turn(following.agent, 'Continue the growing conversation.', [textResponse('TWO')])
|
||||
await expect.poll(() => titleRequests(following.agent).length).toBe(2)
|
||||
expect(titleRequests(following.agent).every(event => event.data.titleProvider === 'session-title-all-prompts-llm')).toBe(true)
|
||||
const followingInput = titleRequests(following.agent)[1]?.data.messages[0]?.content[0]
|
||||
expect(followingInput?.type === 'text' && followingInput.text)
|
||||
.toContain(`Update the session title from this JSON object:\n{"currentTitle":${JSON.stringify(currentTitle)},`)
|
||||
await select(bundles.titles, false)
|
||||
const restored = await agent()
|
||||
await turn(restored.agent, 'Restore first-prompt titles.', [textResponse('ONE')])
|
||||
|
||||
+16
-6
@@ -2934,11 +2934,16 @@ export interface Config {
|
||||
|
||||
- `inject`: `sessionTitle` · `llm` · `sessions`
|
||||
- `refs`: [`SessionTitleLlmConfig`](../packages/session/session-title-llm/src/index.ts)
|
||||
- `source`: [`packages/experimental/session-title-all-prompts-llm/src/index.ts:15`](../packages/experimental/session-title-all-prompts-llm/src/index.ts)
|
||||
- `source`: [`packages/experimental/session-title-all-prompts-llm/src/index.ts:28`](../packages/experimental/session-title-all-prompts-llm/src/index.ts)
|
||||
|
||||
```ts config-catalog
|
||||
/** Required LLM policy; this plugin adds no defaults. */
|
||||
export type Config = SessionTitleLlmConfig
|
||||
/** Required execution controls plus this provider's title-length targets; no defaults. */
|
||||
export interface Config extends SessionTitleLlmConfig {
|
||||
/** Target word count for non-CJK titles. */
|
||||
readonly targetWords: number
|
||||
/** Target character count for Chinese, Japanese, or Korean titles. */
|
||||
readonly targetCjkCharacters: number
|
||||
}
|
||||
```
|
||||
<!-- END GENERATED config-catalog:@deepseek-ai/dsh-session-title-all-prompts-llm -->
|
||||
|
||||
@@ -2949,11 +2954,16 @@ export type Config = SessionTitleLlmConfig
|
||||
|
||||
- `inject`: `sessionTitle` · `llm` · `sessions`
|
||||
- `refs`: [`SessionTitleLlmConfig`](../packages/session/session-title-llm/src/index.ts)
|
||||
- `source`: [`packages/session/session-title-first-prompt-llm/src/index.ts:15`](../packages/session/session-title-first-prompt-llm/src/index.ts)
|
||||
- `source`: [`packages/session/session-title-first-prompt-llm/src/index.ts:28`](../packages/session/session-title-first-prompt-llm/src/index.ts)
|
||||
|
||||
```ts config-catalog
|
||||
/** Required LLM policy; this plugin adds no defaults. */
|
||||
export type Config = SessionTitleLlmConfig
|
||||
/** Required execution controls plus this provider's title-length targets; no defaults. */
|
||||
export interface Config extends SessionTitleLlmConfig {
|
||||
/** Target word count for non-CJK titles. */
|
||||
readonly targetWords: number
|
||||
/** Target character count for Chinese, Japanese, or Korean titles. */
|
||||
readonly targetCjkCharacters: number
|
||||
}
|
||||
```
|
||||
<!-- END GENERATED config-catalog:@deepseek-ai/dsh-session-title-first-prompt-llm -->
|
||||
|
||||
|
||||
@@ -2936,11 +2936,16 @@ export interface Config {
|
||||
|
||||
- `inject`: `sessionTitle` · `llm` · `sessions`
|
||||
- `refs`: [`SessionTitleLlmConfig`](../packages/session/session-title-llm/src/index.ts)
|
||||
- `source`: [`packages/experimental/session-title-all-prompts-llm/src/index.ts:15`](../packages/experimental/session-title-all-prompts-llm/src/index.ts)
|
||||
- `source`: [`packages/experimental/session-title-all-prompts-llm/src/index.ts:28`](../packages/experimental/session-title-all-prompts-llm/src/index.ts)
|
||||
|
||||
```ts config-catalog
|
||||
/** Required LLM policy; this plugin adds no defaults. */
|
||||
export type Config = SessionTitleLlmConfig
|
||||
/** Required execution controls plus this provider's title-length targets; no defaults. */
|
||||
export interface Config extends SessionTitleLlmConfig {
|
||||
/** Target word count for non-CJK titles. */
|
||||
readonly targetWords: number
|
||||
/** Target character count for Chinese, Japanese, or Korean titles. */
|
||||
readonly targetCjkCharacters: number
|
||||
}
|
||||
```
|
||||
<!-- END GENERATED config-catalog:@deepseek-ai/dsh-session-title-all-prompts-llm -->
|
||||
|
||||
@@ -2951,11 +2956,16 @@ export type Config = SessionTitleLlmConfig
|
||||
|
||||
- `inject`: `sessionTitle` · `llm` · `sessions`
|
||||
- `refs`: [`SessionTitleLlmConfig`](../packages/session/session-title-llm/src/index.ts)
|
||||
- `source`: [`packages/session/session-title-first-prompt-llm/src/index.ts:15`](../packages/session/session-title-first-prompt-llm/src/index.ts)
|
||||
- `source`: [`packages/session/session-title-first-prompt-llm/src/index.ts:28`](../packages/session/session-title-first-prompt-llm/src/index.ts)
|
||||
|
||||
```ts config-catalog
|
||||
/** Required LLM policy; this plugin adds no defaults. */
|
||||
export type Config = SessionTitleLlmConfig
|
||||
/** Required execution controls plus this provider's title-length targets; no defaults. */
|
||||
export interface Config extends SessionTitleLlmConfig {
|
||||
/** Target word count for non-CJK titles. */
|
||||
readonly targetWords: number
|
||||
/** Target character count for Chinese, Japanese, or Korean titles. */
|
||||
readonly targetCjkCharacters: number
|
||||
}
|
||||
```
|
||||
<!-- END GENERATED config-catalog:@deepseek-ai/dsh-session-title-first-prompt-llm -->
|
||||
|
||||
|
||||
@@ -177,8 +177,8 @@
|
||||
en: 6c6a27578dff5828
|
||||
zh: 9a5add2bc0df2d0a
|
||||
/session-persistence-event-catalog/events/session/session-title-llm-request-log-only:
|
||||
en: 5eb108772a8498c9
|
||||
zh: 1fbc45e377084bae
|
||||
en: 50d9e29d3d2bfe52
|
||||
zh: 3ff1d959cf7776b9
|
||||
/session-persistence-event-catalog/events/session-log-deepseek:
|
||||
en: 7b827672c80aecdd
|
||||
zh: 7b827672c80aecdd
|
||||
@@ -1158,8 +1158,8 @@
|
||||
en: 4ff17eae0b30056d
|
||||
zh: 6642afbaa87da3ab
|
||||
/session-persistence-event-catalog/resolved-persistence-types/sessiontitlellmrequesteventdata:
|
||||
en: 390c1daad2342ce8
|
||||
zh: 07ce68854dce8ea4
|
||||
en: d7491d51e36cd1b6
|
||||
zh: 2fa7d1c33bace7fc
|
||||
/session-persistence-event-catalog/resolved-persistence-types/sessiontitlemodelidentity:
|
||||
en: 9531468264499498
|
||||
zh: 9da23c0ad2776563
|
||||
|
||||
@@ -843,7 +843,7 @@ Source: [`packages/session/session-title/src/index.ts:77`](../packages/session/s
|
||||
|
||||
Types: [SessionTitleLlmRequestEventData](subsystems/session-title.md)
|
||||
|
||||
Source: [`packages/session/session-title-llm/src/index.ts:54`](../packages/session/session-title-llm/src/index.ts)
|
||||
Source: [`packages/session/session-title-llm/src/index.ts:57`](../packages/session/session-title-llm/src/index.ts)
|
||||
|
||||
### `session-log-deepseek/*`
|
||||
|
||||
@@ -4503,7 +4503,7 @@ Sources: [`packages/session/session-title/src/types.ts:41`](../packages/session/
|
||||
|
||||
SHA-256: `045926475a46f625f84ae2be292b384817059f16927c69a9adab93717923ac19`
|
||||
|
||||
Sources: [`packages/session/session-title-llm/src/index.ts:34`](../packages/session/session-title-llm/src/index.ts)
|
||||
Sources: [`packages/session/session-title-llm/src/index.ts:37`](../packages/session/session-title-llm/src/index.ts)
|
||||
|
||||
| Property | Presence | Type |
|
||||
|---|---|---|
|
||||
|
||||
@@ -845,7 +845,7 @@ export type SessionEvent<T extends SessionEventType = SessionEventType> = {
|
||||
|
||||
类型:[SessionTitleLlmRequestEventData](subsystems/session-title.zh.md)
|
||||
|
||||
来源:[`packages/session/session-title-llm/src/index.ts:54`](../packages/session/session-title-llm/src/index.ts)
|
||||
来源:[`packages/session/session-title-llm/src/index.ts:57`](../packages/session/session-title-llm/src/index.ts)
|
||||
|
||||
### `session-log-deepseek/*`
|
||||
|
||||
@@ -4505,7 +4505,7 @@ SHA-256: `b56f6a885da3dc394c69a3e3cc5c0cfc4da15601b63fda4989eee8f40efc5be4`
|
||||
|
||||
SHA-256: `045926475a46f625f84ae2be292b384817059f16927c69a9adab93717923ac19`
|
||||
|
||||
来源:[`packages/session/session-title-llm/src/index.ts:34`](../packages/session/session-title-llm/src/index.ts)
|
||||
来源:[`packages/session/session-title-llm/src/index.ts:37`](../packages/session/session-title-llm/src/index.ts)
|
||||
|
||||
| 属性 | 存在性 | 类型 |
|
||||
|---|---|---|
|
||||
|
||||
Generated
+1
-1
@@ -22052,7 +22052,7 @@
|
||||
"packages/session/session-title-llm/src/index.ts#SessionTitleLlmRequestEventData"
|
||||
],
|
||||
"sources": [
|
||||
"packages/session/session-title-llm/src/index.ts:34"
|
||||
"packages/session/session-title-llm/src/index.ts:37"
|
||||
]
|
||||
},
|
||||
{
|
||||
|
||||
@@ -3,14 +3,14 @@
|
||||
# After editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write docs/subsystems/session-title.md
|
||||
/session-titles:
|
||||
en: a0ad8d5f000e5258
|
||||
zh: 9527bc25711342de
|
||||
en: 40ede027d4f62b35
|
||||
zh: 0efd7cb0710a0ed9
|
||||
/session-titles/durable-title-state:
|
||||
en: c6be2d86647daa6f
|
||||
zh: fb259d9fb4738f39
|
||||
/session-titles/auxiliary-request-record:
|
||||
en: c36916905dad76f9
|
||||
zh: c82ff6c3d62e53e5
|
||||
en: e5d5045ddfa7e771
|
||||
zh: 8c989ef59853cce4
|
||||
/session-titles/provider-input-and-output:
|
||||
en: 886e036e442ae06a
|
||||
zh: 49f95782b8aa9bf3
|
||||
en: 28ee84e62db773f0
|
||||
zh: 93beca6fbcd02c4e
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
English | [中文](session-title.zh.md)
|
||||
|
||||
Durable latest-wins title state and the optional asynchronous provider vocabulary owned by [`@deepseek-ai/dsh-session-title`](../../packages/session/session-title). The shared LLM helper owns the exact auxiliary request record. Package READMEs own timing, fallback, failure, and fork behavior; the generated [persistence catalog](../persistence-catalog.md) owns the complete event declarations.
|
||||
Durable latest-wins title state and the optional asynchronous provider vocabulary owned by [`@deepseek-ai/dsh-session-title`](../../packages/session/session-title). The shared execution module owns the exact auxiliary request record. Package READMEs own timing, fallback, failure, and fork behavior; the generated [persistence catalog](../persistence-catalog.md) owns the complete event declarations.
|
||||
|
||||
Sources: [`packages/session/session-title/src/index.ts`](../../packages/session/session-title/src/index.ts), [`packages/session/session-title-llm/src/index.ts`](../../packages/session/session-title-llm/src/index.ts)
|
||||
|
||||
@@ -64,7 +64,7 @@ interface SessionTitleSnapshot extends SessionTitleEventData {
|
||||
|
||||
## Auxiliary request record
|
||||
|
||||
The shared LLM helper records each validated, dispatchable title request before calling the model. The payload reproduces the model-visible system and message input, routing, output limit, resolved reasoning effort, provider ownership, and source-message attribution even when generation later fails.
|
||||
The shared execution module records each validated, dispatchable title request before calling the model. The payload reproduces the model-visible system and message input, routing, output limit, resolved reasoning effort, provider ownership, and source-message attribution even when generation later fails.
|
||||
|
||||
```ts type-equiv
|
||||
/** Exact model-visible request recorded before one auxiliary title dispatch. */
|
||||
@@ -88,7 +88,7 @@ interface SessionTitleLlmRequestEventData {
|
||||
|
||||
## Provider input and output
|
||||
|
||||
The service snapshots eligible messages through one revision. A provider returns only seqs from that request; service-owned acceptance verifies ordering, normalizes the title, enforces the byte limit, and appends the title with its source-message seqs and source kind.
|
||||
The service snapshots eligible messages through one revision and, immediately before each provider call, the latest accepted title including the fallback it just ensured. A provider returns only seqs from that request; service-owned acceptance verifies ordering, normalizes the title, enforces the byte limit, and appends the title with its source-message seqs and source kind.
|
||||
|
||||
```ts type-equiv
|
||||
/** One eligible human text message exposed to title providers. */
|
||||
@@ -114,6 +114,12 @@ interface SessionTitleProviderRequest {
|
||||
readonly messages: readonly SessionTitleUserMessage[]
|
||||
/** Exact current logged main-request route, when one has been recorded. */
|
||||
readonly route?: SessionTitleModelIdentity
|
||||
/**
|
||||
* Latest accepted title captured at invocation, including an accepted
|
||||
* fallback whose event may follow the last eligible message. Absent before
|
||||
* any title is accepted. The provider decides whether and how to use it.
|
||||
*/
|
||||
readonly currentTitle?: SessionTitleSnapshot
|
||||
/** Cancellation for supersession, disposal, timeout composition, or the explicit caller. */
|
||||
readonly signal: AbortSignal
|
||||
}
|
||||
@@ -140,7 +146,7 @@ interface SessionTitleProvider {
|
||||
readonly automatic: SessionTitleAutomaticMode
|
||||
/**
|
||||
* Produce one title revision.
|
||||
* @param request - message snapshot, current route, session, and cancellation.
|
||||
* @param request - message snapshot, current title, current route, session, and cancellation.
|
||||
* @returns proposed title plus exact input seqs and the optional provider/model route used to generate it.
|
||||
*/
|
||||
generate(request: SessionTitleProviderRequest): Promise<SessionTitleProviderResult>
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
[English](session-title.md) | 中文
|
||||
|
||||
[`@deepseek-ai/dsh-session-title`](../../packages/session/session-title) 所拥有的持久、后写覆盖的标题状态与可选异步提供方词汇。共享 LLM(大语言模型)辅助组件负责精确的辅助请求记录。各包 README 负责时序、回退、失败与 fork 行为;生成的[持久化日志事件目录](../persistence-catalog.zh.md)负责完整的事件声明。
|
||||
[`@deepseek-ai/dsh-session-title`](../../packages/session/session-title) 所拥有的持久、后写覆盖的标题状态与可选异步提供方词汇。共享执行模块负责精确的辅助请求记录。各包 README 负责时序、回退、失败与 fork 行为;生成的[持久化日志事件目录](../persistence-catalog.zh.md)负责完整的事件声明。
|
||||
|
||||
源码:[`packages/session/session-title/src/index.ts`](../../packages/session/session-title/src/index.ts)、[`packages/session/session-title-llm/src/index.ts`](../../packages/session/session-title-llm/src/index.ts)
|
||||
|
||||
@@ -64,7 +64,7 @@ interface SessionTitleSnapshot extends SessionTitleEventData {
|
||||
|
||||
## 辅助请求记录
|
||||
|
||||
共享 LLM 辅助组件会在调用模型前,记录每一项已经过验证且可分发的标题请求。即使后续生成失败,载荷仍会复现模型可见的系统输入与消息输入、路由、输出上限、解析后的推理强度、提供方归属和源消息归因。
|
||||
共享执行模块会在调用模型前,记录每一项已经过验证且可分发的标题请求。即使后续生成失败,载荷仍会复现模型可见的系统输入与消息输入、路由、输出上限、解析后的推理强度、提供方归属和源消息归因。
|
||||
|
||||
```ts type-equiv
|
||||
/** Exact model-visible request recorded before one auxiliary title dispatch. */
|
||||
@@ -88,7 +88,7 @@ interface SessionTitleLlmRequestEventData {
|
||||
|
||||
## 提供方输入与输出
|
||||
|
||||
服务会对截至某一修订的合格消息创建快照。提供方返回的 seq 仅可来自该请求;由服务负责的接纳流程会验证顺序、规范化标题、强制执行字节上限,并追加标题及其来源消息 seq 和来源类型。
|
||||
服务会对截至某一修订的合格消息、以及在每次提供方调用前刚刚确保的回退和最新已接纳标题创建快照。提供方返回的 seq 仅可来自该请求;由服务负责的接纳流程会验证顺序、规范化标题、强制执行字节上限,并追加标题及其来源消息 seq 和来源类型。
|
||||
|
||||
```ts type-equiv
|
||||
/** One eligible human text message exposed to title providers. */
|
||||
@@ -114,6 +114,12 @@ interface SessionTitleProviderRequest {
|
||||
readonly messages: readonly SessionTitleUserMessage[]
|
||||
/** Exact current logged main-request route, when one has been recorded. */
|
||||
readonly route?: SessionTitleModelIdentity
|
||||
/**
|
||||
* Latest accepted title captured at invocation, including an accepted
|
||||
* fallback whose event may follow the last eligible message. Absent before
|
||||
* any title is accepted. The provider decides whether and how to use it.
|
||||
*/
|
||||
readonly currentTitle?: SessionTitleSnapshot
|
||||
/** Cancellation for supersession, disposal, timeout composition, or the explicit caller. */
|
||||
readonly signal: AbortSignal
|
||||
}
|
||||
@@ -140,7 +146,7 @@ interface SessionTitleProvider {
|
||||
readonly automatic: SessionTitleAutomaticMode
|
||||
/**
|
||||
* Produce one title revision.
|
||||
* @param request - message snapshot, current route, session, and cancellation.
|
||||
* @param request - message snapshot, current title, current route, session, and cancellation.
|
||||
* @returns proposed title plus exact input seqs and the optional provider/model route used to generate it.
|
||||
*/
|
||||
generate(request: SessionTitleProviderRequest): Promise<SessionTitleProviderResult>
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
# Bilingual-pair consistency record for guide.md (docs/i18n/README.md): per heading
|
||||
# section, a hash of its English and Chinese blocks outside code blocks and generated regions.
|
||||
# After editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write docs/upgrade-guide/v0.2.1-alpha.1/session-title-provider-strategy/guide.md
|
||||
/:
|
||||
en: 08f294cc358eaffb
|
||||
zh: 46ef86d9f933ea58
|
||||
/migrate-session-title-providers:
|
||||
en: c9e33d3bdc0595c6
|
||||
zh: 55a049680ff8ae9b
|
||||
/migrate-session-title-providers/change:
|
||||
en: 3a8d72fb2c793d51
|
||||
zh: 9acc89004dd31096
|
||||
/migrate-session-title-providers/migration:
|
||||
en: ea9efdc5234c0541
|
||||
zh: 845d80a7c6c05672
|
||||
@@ -0,0 +1,20 @@
|
||||
---
|
||||
kind: upgrade-guide
|
||||
description: "Session-title providers register directly, own their strategy settings, and share only prepared execution."
|
||||
---
|
||||
|
||||
# Migrate session-title providers
|
||||
|
||||
English | [中文](guide.zh.md)
|
||||
|
||||
## Change
|
||||
|
||||
`@deepseek-ai/dsh-session-title-llm` no longer exports `registerSessionTitleLlmProvider`, `generateSessionTitleWithLlm`, or `SessionTitleLlmMessageSelector`. `SessionTitleLlmConfig` now holds only execution controls (`maxInputBytes`, `maxOutputTokens`, `timeoutMs`, and the optional `provider`/`model` pair); `targetWords` and `targetCjkCharacters` moved into each provider's own `Config`. Each provider owns its system instruction, message framing, title parsing, reasoning selection, and output interpretation, registers directly with `ctx.sessionTitle.register()`, and calls `executeSessionTitleLlm(ctx, config, request, providerId, { system, input, messageSeqs, selectReasoningEffort })`. That function returns the assembled content blocks, the terminal finish, and the used model route. `SessionTitleProviderRequest` gains an optional `currentTitle` snapshot.
|
||||
|
||||
## Migration
|
||||
|
||||
1. Replace a `registerSessionTitleLlmProvider(...)` call with a direct `ctx.sessionTitle.register({ id, automatic, generate })`; keep the same `id` and cadence.
|
||||
2. In `generate(request)`, build your own system prompt and user input, pass a `selectReasoningEffort` function, then call `executeSessionTitleLlm` with `{ system, input, messageSeqs, selectReasoningEffort }`.
|
||||
3. Reject a `tool-calls` or `max-tokens` terminal finish and tool-call blocks; derive the title from the text blocks.
|
||||
4. Read `request.currentTitle` when the strategy should preserve an accepted provider title; it is absent before any title.
|
||||
5. Generate a title and confirm the saved result plus the exact `session/title-llm-request` record.
|
||||
@@ -0,0 +1,20 @@
|
||||
---
|
||||
kind: upgrade-guide
|
||||
description: "会话标题提供方直接注册、拥有自己的策略设置,并只共享准备好的执行。"
|
||||
---
|
||||
|
||||
# 迁移会话标题提供方
|
||||
|
||||
[English](guide.md) | 中文
|
||||
|
||||
## 变更
|
||||
|
||||
`@deepseek-ai/dsh-session-title-llm` 不再导出 `registerSessionTitleLlmProvider`、`generateSessionTitleWithLlm` 或 `SessionTitleLlmMessageSelector`。`SessionTitleLlmConfig` 现在只包含执行控制(`maxInputBytes`、`maxOutputTokens`、`timeoutMs` 以及可选的 `provider`/`model` 对);`targetWords` 与 `targetCjkCharacters` 已移入各提供方自己的 `Config`。各提供方拥有自己的系统指令、消息封装、标题解析、推理选择与输出解释,直接通过 `ctx.sessionTitle.register()` 注册,并调用 `executeSessionTitleLlm(ctx, config, request, providerId, { system, input, messageSeqs, selectReasoningEffort })`。该函数返回组装后的内容块、终止结束原因与所用模型路由。`SessionTitleProviderRequest` 新增可选的 `currentTitle` 快照。
|
||||
|
||||
## 迁移
|
||||
|
||||
1. 把 `registerSessionTitleLlmProvider(...)` 调用替换为直接 `ctx.sessionTitle.register({ id, automatic, generate })`;保持相同的 `id` 与节奏。
|
||||
2. 在 `generate(request)` 中构建自己的系统提示词与用户输入,传入 `selectReasoningEffort` 函数,然后用 `{ system, input, messageSeqs, selectReasoningEffort }` 调用 `executeSessionTitleLlm`。
|
||||
3. 拒绝 `tool-calls` 或 `max-tokens` 终止结束原因与工具调用块;从文本块派生标题。
|
||||
4. 当策略需要保留已接纳的提供方标题时读取 `request.currentTitle`;任何标题被接纳前该值为缺省。
|
||||
5. 生成一次标题,确认保存的结果以及确切的 `session/title-llm-request` 记录。
|
||||
@@ -9,20 +9,20 @@
|
||||
en: 1ab205ef1ee72cc2
|
||||
zh: f82abbf7a2461f27
|
||||
/deepseek-ai-dsh-session-title-all-prompts-llm/summary:
|
||||
en: 9f64c4dd5e30f1c8
|
||||
zh: 35cf397596083be5
|
||||
en: d73aa4194164b606
|
||||
zh: d543791ecb2ef8b4
|
||||
/deepseek-ai-dsh-session-title-all-prompts-llm/table-of-contents:
|
||||
en: d152484eb41ac6b4
|
||||
zh: 09388d293f9be9cb
|
||||
/deepseek-ai-dsh-session-title-all-prompts-llm/use-this-package:
|
||||
en: 2c0bccf753fe70eb
|
||||
zh: 36b96a547ddddfc1
|
||||
en: d978021948802932
|
||||
zh: 4af8f21a4f3529e7
|
||||
/deepseek-ai-dsh-session-title-all-prompts-llm/use-this-package/when-titles-are-generated:
|
||||
en: 3c9e14df96e6000e
|
||||
zh: a13f12a3df426380
|
||||
en: 5750c08ae93b2c07
|
||||
zh: 130724e56a833217
|
||||
/deepseek-ai-dsh-session-title-all-prompts-llm/use-this-package/configuration:
|
||||
en: 1965c9ce7dedb2e7
|
||||
zh: 9b9c9b68af995b51
|
||||
en: 8a831cfb1e35cead
|
||||
zh: 7b3ea664d5852a0f
|
||||
/deepseek-ai-dsh-session-title-all-prompts-llm/use-this-package/failures-and-recovery:
|
||||
en: 6077a4665a4edb87
|
||||
zh: 539c4e5969f9bb9b
|
||||
@@ -30,17 +30,17 @@
|
||||
en: 506933ffa7745b7d
|
||||
zh: a9bb6f06a32b7c18
|
||||
/deepseek-ai-dsh-session-title-all-prompts-llm/understand-the-implementation/design-concept:
|
||||
en: 2c307e1480e5c319
|
||||
zh: 60c7394728f7ff9c
|
||||
en: 3982890468a53b95
|
||||
zh: c400f2cd0913055f
|
||||
/deepseek-ai-dsh-session-title-all-prompts-llm/understand-the-implementation/source-map:
|
||||
en: e7ae42597cc501a6
|
||||
zh: 2e55e9b293b25958
|
||||
en: 18bfd548b52deec1
|
||||
zh: 85115c952ee14dec
|
||||
/deepseek-ai-dsh-session-title-all-prompts-llm/understand-the-implementation/scheduling:
|
||||
en: ca23821924af2b14
|
||||
zh: a384479d5e69b0f8
|
||||
/deepseek-ai-dsh-session-title-all-prompts-llm/further-exploration:
|
||||
en: 05c91c92fa3b1c7d
|
||||
zh: 8e90078647bdb018
|
||||
en: a13a6457ffbcccef
|
||||
zh: c951dc9dc86d4f58
|
||||
/deepseek-ai-dsh-session-title-all-prompts-llm/model-experience:
|
||||
en: 215e7ba838619b7b
|
||||
zh: a311da3843709f90
|
||||
@@ -48,17 +48,17 @@
|
||||
en: 42380e2d029288cf
|
||||
zh: e9c9f21ac00e3c9d
|
||||
/deepseek-ai-dsh-session-title-all-prompts-llm/model-experience/all-messages-title-request/what-the-model-sees:
|
||||
en: 39cb7059a3ab1fe6
|
||||
zh: a3fd93ac52f12a43
|
||||
en: 3c03a04339ff4f72
|
||||
zh: 8dea27ff49d6a9a7
|
||||
/deepseek-ai-dsh-session-title-all-prompts-llm/model-experience/all-messages-title-request/token-effect:
|
||||
en: 1a075b8073c16ce3
|
||||
zh: a51ff16156f9c137
|
||||
en: 4f043655b7b2c8fc
|
||||
zh: e4914d15ef9ff8b1
|
||||
/deepseek-ai-dsh-session-title-all-prompts-llm/model-experience/all-messages-title-request/kv-cache-effect:
|
||||
en: 6a70f43f38b8c075
|
||||
zh: 2b823aff93344134
|
||||
/deepseek-ai-dsh-session-title-all-prompts-llm/known-limitations-and-deferred-work:
|
||||
en: d0486c65afaebb85
|
||||
zh: 186a77e0d68bd968
|
||||
en: 48baddec2b04e7d2
|
||||
zh: 46e85efbead4a309
|
||||
/deepseek-ai-dsh-session-title-all-prompts-llm/known-limitations-and-deferred-work/dev-note:
|
||||
en: e67f633dc6fe7773
|
||||
zh: 3bb0c20c93f16f98
|
||||
@@ -9,7 +9,7 @@ English | [中文](README.zh.md)
|
||||
|
||||
## Summary
|
||||
|
||||
`dsh-session-title-all-prompts-llm` summarizes every eligible human message through `ctx.llm` as an optional `ctx.sessionTitle` provider. It registers the `all-prompts` cadence and starts a new revision after each new human prompt, using seeded history and child-session prompts. A newer revision aborts and supersedes older work, and even a provider that ignores cancellation cannot commit stale output. It uses the complete required shared LLM configuration from `dsh-session-title-llm`, so route, prompt, budget, and cancellation behavior cannot drift. Automatic behavior and configuration come first; the implementation is a thin registration over the shared policy.
|
||||
`dsh-session-title-all-prompts-llm` summarizes every eligible human message through `ctx.llm` as an optional `ctx.sessionTitle` provider. It registers the `all-prompts` cadence and starts a new revision after each new human prompt, using seeded history and child-session prompts. A newer revision aborts and supersedes older work, and even a provider that ignores cancellation cannot commit stale output. It owns both its initial-generation prompt and its revision prompt, and shares only the [execution module](../../session/session-title-llm/README.md) from `dsh-session-title-llm` with the first-prompt provider. Automatic behavior and configuration come first; the implementation is a small provider strategy over that executor.
|
||||
|
||||
## Table of Contents
|
||||
|
||||
@@ -25,15 +25,17 @@ English | [中文](README.zh.md)
|
||||
<a id="use-this-package"></a>
|
||||
## Use this package
|
||||
|
||||
Mount this plugin beside the title service when a session should be retitled as it grows, so the title keeps representing the whole conversation. It requires the full [shared LLM configuration](../../session/session-title-llm/README.md#configuration) with no defaults.
|
||||
Mount this plugin beside the title service when a session should be retitled as it grows, so the title keeps representing the whole conversation. It requires its own `targetWords` and `targetCjkCharacters` plus the shared [execution controls](../../session/session-title-llm/README.md#configuration), all with no defaults.
|
||||
|
||||
### When titles are generated
|
||||
|
||||
A new revision starts after each new eligible human prompt, including prompts in child sessions; the generation folds all eligible messages through the current revision, seeded history included. A newer revision aborts and supersedes older work, so a stale completion can never commit. An automatic failure — including input over `maxInputBytes`, which fails instead of truncating history — warns and keeps the prior title; `ctx.sessionTitle.refresh()` is the explicit retry.
|
||||
|
||||
Once a provider-generated title exists, each request includes it and asks the model to preserve it exactly while it still describes the main topic or task. Follow-up questions, same-topic details, acknowledgements such as “thanks”, and requests to continue do not by themselves justify rewording. The model is instructed to update the title only when a material change or expansion of the main topic or task makes it inaccurate. A fallback title still receives ordinary initial generation; every eligible prompt still schedules a revision.
|
||||
|
||||
### Configuration
|
||||
|
||||
The plugin accepts the complete required [shared LLM configuration](../../session/session-title-llm/README.md#configuration): `targetWords`, `targetCjkCharacters`, `maxInputBytes`, `maxOutputTokens`, `timeoutMs`, and the optional paired `provider`/`model` route. Omit both to inherit the exact route from each current logged main request, or set both to route title generation independently. The generated [configuration catalog](../../../docs/config-catalog.md#deepseek-aidsh-session-title-all-prompts-llm) is the exhaustive source for every accepted field.
|
||||
The plugin requires `targetWords` and `targetCjkCharacters` plus the shared [execution controls](../../session/session-title-llm/README.md#configuration): `maxInputBytes`, `maxOutputTokens`, `timeoutMs`, and the optional paired `provider`/`model` route. Omit both to inherit the exact route from each current logged main request, or set both to route title generation independently. The generated [configuration catalog](../../../docs/config-catalog.md#deepseek-aidsh-session-title-all-prompts-llm) is the exhaustive source for every accepted field.
|
||||
|
||||
### Failures and recovery
|
||||
|
||||
@@ -51,13 +53,13 @@ This section explains the plugin's shape; the observable behavior is fully cover
|
||||
|
||||
### Design concept
|
||||
|
||||
A thin provider plugin: it registers the `all-prompts` cadence with an identity selector over all eligible messages, and delegates everything else to the [shared LLM policy](../../session/session-title-llm/README.md).
|
||||
A small provider strategy: it owns both the initial-generation prompt and the revision prompt that anchors `currentTitle`, parses the assembled response into a title, and delegates only route, bounds, cancellation, logging, and assembly to the [shared execution module](../../session/session-title-llm/README.md).
|
||||
|
||||
### Source map
|
||||
|
||||
| File | Role |
|
||||
|---|---|
|
||||
| [`src/index.ts`](src/index.ts) | Plugin entry: shared config schema, provider registration with the all-messages selector |
|
||||
| [`src/index.ts`](src/index.ts) | Plugin entry: config schema, initial and revision prompts, output parsing, and direct provider registration |
|
||||
|
||||
### Scheduling
|
||||
|
||||
@@ -70,9 +72,9 @@ The title service schedules automatic work: for the `all-prompts` cadence, every
|
||||
<a id="further-exploration"></a>
|
||||
## Further Exploration
|
||||
|
||||
Read these pages when the provider contract is not enough. They move from the shared policy to the alternative cadence and the service it plugs into.
|
||||
Read these pages when the provider contract is not enough. They move from the shared execution module to the alternative cadence and the service it plugs into.
|
||||
|
||||
- [Shared LLM title policy](../../session/session-title-llm/README.md) — the generation helper this provider uses.
|
||||
- [Shared LLM execution module](../../session/session-title-llm/README.md) — the execution module this provider uses.
|
||||
- [First-message title provider](../../session/session-title-first-prompt-llm/README.md) — the cadence that titles a session once from its first prompt.
|
||||
- [Session title service](../../session/session-title/README.md) — fallback behavior, rename, refresh, and provider registration.
|
||||
- [Session package map](../../session/README.md) — adjacent persistence, projection, title, and telemetry packages.
|
||||
@@ -86,11 +88,11 @@ Read these pages when the provider contract is not enough. They move from the sh
|
||||
|
||||
#### What the model sees
|
||||
|
||||
The title model receives the shared title instruction and a JSON array of all eligible human messages through the current revision, in log order with exact seqs. Seeded history is included.
|
||||
The title model receives all eligible human messages through the current revision, in log order with exact seqs, including seeded history. When an accepted provider-generated title exists, this provider adds it as `currentTitle` with its own preservation instructions; fallback, user, and absent titles take its initial-generation path. Both the messages and the current title count toward `maxInputBytes`.
|
||||
|
||||
#### Token effect
|
||||
|
||||
One auxiliary request may follow every new eligible prompt, bounded per request by `maxInputBytes` and `maxOutputTokens`; explicit refreshes may add calls. The main agent request gains zero tokens.
|
||||
One auxiliary request may follow every new eligible prompt, bounded per request by `maxInputBytes` and `maxOutputTokens`; explicit refreshes may add calls. It selects the route's least supported reasoning effort, or leaves effort unspecified when the route exposes none. The main agent request gains zero tokens.
|
||||
|
||||
#### KV Cache effect
|
||||
|
||||
@@ -104,7 +106,8 @@ No main-request invalidation. Auxiliary input grows or changes after each prompt
|
||||
These limits define how the provider treats long and heterogeneous sessions. They are current package constraints.
|
||||
|
||||
- **No summarization-of-summaries** — input overflow retains the prior title; this provider has no summarization-of-summaries or retention policy for very long sessions.
|
||||
- **Messages are treated equally** — it treats all eligible human messages alike and offers no weighting, filtering, or manual-title precedence.
|
||||
- **Messages are treated equally** — it includes every eligible human message without configurable weighting or filtering.
|
||||
- **Model-dependent stability** — title preservation is a model instruction, not a deterministic semantic check; the model may still change an adequate title.
|
||||
|
||||
<a id="dev-note"></a>
|
||||
### Dev Note
|
||||
|
||||
@@ -9,7 +9,7 @@ kind: "package-reference"
|
||||
|
||||
## 概述
|
||||
|
||||
`dsh-session-title-all-prompts-llm` 作为可选的 `ctx.sessionTitle` 提供方,通过 `ctx.llm` 总结所有符合条件的用户消息。它注册 `all-prompts` 节奏,并在每条新用户提示词后启动新修订,使用预置历史与子会话提示词。较新的修订会中止并取代旧工作,即使提供方忽略取消,也无法提交陈旧输出。它使用 `dsh-session-title-llm` 的完整必填共享 LLM 配置,因此路由、提示词、预算与取消行为不会漂移。本文优先介绍自动行为与配置;实现只是基于共享策略进行的轻量注册。
|
||||
`dsh-session-title-all-prompts-llm` 作为可选的 `ctx.sessionTitle` 提供方,通过 `ctx.llm` 总结所有符合条件的用户消息。它注册 `all-prompts` 节奏,并在每条新用户提示词后启动新修订,使用预置历史与子会话提示词。较新的修订会中止并取代旧工作,即使提供方忽略取消,也无法提交陈旧输出。它同时拥有初始生成提示词与修订提示词,只与首消息提供方共享 `dsh-session-title-llm` 的[执行模块](../../session/session-title-llm/README.zh.md)。本文优先介绍自动行为与配置;实现是建立在该执行器之上的小型提供方策略。
|
||||
|
||||
## 目录
|
||||
|
||||
@@ -25,15 +25,17 @@ kind: "package-reference"
|
||||
<a id="use-this-package"></a>
|
||||
## 使用本包
|
||||
|
||||
当会话应随其增长而重新生成标题、使标题持续代表整个对话时,在标题服务旁挂载此插件。它要求完整的[共享 LLM 配置](../../session/session-title-llm/README.zh.md#configuration),且无默认值。
|
||||
当会话应随其增长而重新生成标题、使标题持续代表整个对话时,在标题服务旁挂载此插件。它要求自己的 `targetWords` 与 `targetCjkCharacters`,外加共享的[执行控制](../../session/session-title-llm/README.zh.md#configuration),且全部无默认值。
|
||||
|
||||
### 标题生成时机
|
||||
|
||||
每条新的符合条件用户提示词之后都会启动新修订,包括子会话中的提示词;生成会折叠截至当前修订的所有符合条件消息,预置历史也包含在内。较新的修订会中止并取代旧工作,因此陈旧的完成结果永远无法提交。自动失败——包括输入超过 `maxInputBytes`(此时请求失败而非截断历史)——会发出警告并保留先前标题;`ctx.sessionTitle.refresh()` 是显式重试。
|
||||
|
||||
已有提供方生成的标题后,每次请求都会包含该标题,并要求模型在它仍能描述主要主题或任务时逐字保留。同主题追问、补充细节、致谢(如“谢谢”)和继续请求本身都不构成改写理由。指令要求模型仅在主要主题或任务发生实质变化或扩展、使旧标题不再准确时更新。回退标题仍会正常触发首次生成;每条符合条件的提示词仍会安排一次修订。
|
||||
|
||||
### 配置
|
||||
|
||||
插件接受完整必填的[共享 LLM 配置](../../session/session-title-llm/README.zh.md#configuration):`targetWords`、`targetCjkCharacters`、`maxInputBytes`、`maxOutputTokens`、`timeoutMs`,以及可选成对的 `provider`/`model` 路由。同时省略二者,会继承每个当前已记录主请求的确切路由;同时设置二者,则让标题生成使用独立路由。生成的[配置目录](../../../docs/config-catalog.zh.md#deepseek-aidsh-session-title-all-prompts-llm)是每个受支持字段的穷尽式真源。
|
||||
插件要求 `targetWords` 与 `targetCjkCharacters`,外加共享的[执行控制](../../session/session-title-llm/README.zh.md#configuration):`maxInputBytes`、`maxOutputTokens`、`timeoutMs`,以及可选成对的 `provider`/`model` 路由。同时省略二者,会继承每个当前已记录主请求的确切路由;同时设置二者,则让标题生成使用独立路由。生成的[配置目录](../../../docs/config-catalog.zh.md#deepseek-aidsh-session-title-all-prompts-llm)是每个受支持字段的穷尽式真源。
|
||||
|
||||
### 失败与恢复
|
||||
|
||||
@@ -51,13 +53,13 @@ kind: "package-reference"
|
||||
|
||||
### 设计理念
|
||||
|
||||
一个薄提供方插件:它注册 `all-prompts` 节奏,用恒等选择器选取所有符合条件消息,其余全部委托给[共享 LLM 策略](../../session/session-title-llm/README.zh.md)。
|
||||
一个小型提供方策略:它同时拥有初始生成提示词与锚定 `currentTitle` 的修订提示词,把组装后的响应解析为标题,只把路由、上限、取消、记录与组装委托给[共享执行模块](../../session/session-title-llm/README.zh.md)。
|
||||
|
||||
### 源码地图
|
||||
|
||||
| 文件 | 职责 |
|
||||
|---|---|
|
||||
| [`src/index.ts`](src/index.ts) | 插件入口:共享配置 schema、以全消息选择器注册提供方 |
|
||||
| [`src/index.ts`](src/index.ts) | 插件入口:配置 schema、初始与修订提示词、输出解析与直接提供方注册 |
|
||||
|
||||
### 调度
|
||||
|
||||
@@ -70,9 +72,9 @@ kind: "package-reference"
|
||||
<a id="further-exploration"></a>
|
||||
## 进一步探索
|
||||
|
||||
当提供方约定不够用时阅读以下页面。它们从共享策略逐步进入替代节奏与它所插入的服务。
|
||||
当提供方约定不够用时阅读以下页面。它们从共享执行模块逐步进入替代节奏与它所插入的服务。
|
||||
|
||||
- [共享 LLM 标题策略](../../session/session-title-llm/README.zh.md)——此提供方使用的生成辅助模块。
|
||||
- [共享 LLM 执行模块](../../session/session-title-llm/README.zh.md)——此提供方使用的执行模块。
|
||||
- [首消息标题提供方](../../session/session-title-first-prompt-llm/README.zh.md)——只根据首条提示词为会话生成一次标题的节奏。
|
||||
- [会话标题服务](../../session/session-title/README.zh.md)——回退行为、重命名、刷新与提供方注册。
|
||||
- [会话包映射](../../session/README.zh.md)——相邻的持久化、投影、标题与遥测包。
|
||||
@@ -86,11 +88,11 @@ kind: "package-reference"
|
||||
|
||||
#### 模型看到什么
|
||||
|
||||
标题模型会收到共享标题指令,以及一个 JSON 数组,其中按日志顺序包含截至当前修订的所有符合条件用户消息和确切 seq。预置历史也包含在内。
|
||||
标题模型会收到截至当前修订的所有符合条件用户消息,按日志顺序排列并带有确切 seq,包括预置历史。已有已接纳的提供方生成标题时,此提供方会将其作为 `currentTitle` 传入,并添加自己的保留标题指令;回退、用户与不存在的标题走其初始生成路径。消息与当前标题均计入 `maxInputBytes`。
|
||||
|
||||
#### Token 影响
|
||||
|
||||
每条符合条件的新提示词之后都可能发出一次辅助请求,每次请求受 `maxInputBytes` 与 `maxOutputTokens` 约束;显式刷新可能增加调用。主 agent(智能体)请求不会增加 token。
|
||||
每条符合条件的新提示词之后都可能发出一次辅助请求,每次请求受 `maxInputBytes` 与 `maxOutputTokens` 约束;显式刷新可能增加调用。它选择路由支持的最低推理强度;路由没有可选强度时不指定。主 agent(智能体)请求不会增加 token。
|
||||
|
||||
#### KV Cache 影响
|
||||
|
||||
@@ -104,7 +106,8 @@ kind: "package-reference"
|
||||
这些限制说明提供方如何对待长会话与异构会话。它们是当前包约束。
|
||||
|
||||
- **没有基于摘要继续生成摘要的机制**——输入溢出时保留先前标题;对于很长的会话,此提供方没有基于摘要继续生成摘要的机制或保留策略。
|
||||
- **消息被平等对待**——它平等对待所有符合条件的用户消息,不提供权重、过滤或手动标题优先级。
|
||||
- **消息被平等对待**——它包含每条符合条件的用户消息,不提供可配置的权重或过滤。
|
||||
- **稳定性依赖模型**——保留标题依靠模型指令,没有确定性的语义校验;模型仍可能改动合适的标题。
|
||||
|
||||
<a id="dev-note"></a>
|
||||
### 开发备注
|
||||
|
||||
@@ -30,6 +30,7 @@
|
||||
"@deepseek-ai/cordis": "workspace:~"
|
||||
},
|
||||
"dependencies": {
|
||||
"@deepseek-ai/dsh-util-values": "workspace:*",
|
||||
"@deepseek-ai/schemastery": "workspace:~"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
||||
@@ -1,29 +1,149 @@
|
||||
/** All-human-messages model provider for `ctx.sessionTitle`. */
|
||||
|
||||
/* jscpd:ignore-start -- both shipped providers import the same shared packages. */
|
||||
import type { Context } from '@deepseek-ai/cordis'
|
||||
import z from '@deepseek-ai/schemastery'
|
||||
import type { LlmResolvedModelInfo, ReasoningEffortId } from '@deepseek-ai/dsh-llm'
|
||||
import { assertNever } from '@deepseek-ai/dsh-util-values'
|
||||
import {
|
||||
registerSessionTitleLlmProvider,
|
||||
normalizeSessionTitle,
|
||||
SessionTitleProviderId,
|
||||
type SessionTitleUserMessage,
|
||||
} from '@deepseek-ai/dsh-session-title'
|
||||
import {
|
||||
executeSessionTitleLlm,
|
||||
resolveSessionTitleLlmConfig,
|
||||
SessionTitleLlmConfigFields,
|
||||
type ResolvedSessionTitleLlmConfig,
|
||||
type SessionTitleLlmConfig,
|
||||
type SessionTitleLlmResponse,
|
||||
} from '@deepseek-ai/dsh-session-title-llm'
|
||||
import type { SessionTitleLlmConfig } from '@deepseek-ai/dsh-session-title-llm'
|
||||
/* jscpd:ignore-end */
|
||||
|
||||
export const name = 'session-title-all-prompts-llm'
|
||||
export const inject = ['sessionTitle', 'llm', 'sessions']
|
||||
|
||||
/** Required LLM policy; this plugin adds no defaults. */
|
||||
export type Config = SessionTitleLlmConfig
|
||||
/** Loader schema shared with the first-prompt provider. */
|
||||
/* jscpd:ignore-start -- Loader requires each plugin to export its own statically walkable schema; the field validators remain shared. */
|
||||
/* jscpd:ignore-start -- each provider owns its title-length targets and reasoning control. */
|
||||
/** Required execution controls plus this provider's title-length targets; no defaults. */
|
||||
export interface Config extends SessionTitleLlmConfig {
|
||||
/** Target word count for non-CJK titles. */
|
||||
readonly targetWords: number
|
||||
/** Target character count for Chinese, Japanese, or Korean titles. */
|
||||
readonly targetCjkCharacters: number
|
||||
}
|
||||
|
||||
/** Loader schema for this provider's required execution controls and title-length targets. */
|
||||
export const Config: z<Config> = z.object({
|
||||
targetWords: SessionTitleLlmConfigFields.targetWords,
|
||||
targetCjkCharacters: SessionTitleLlmConfigFields.targetCjkCharacters,
|
||||
targetWords: z.number().step(1).min(1).required(),
|
||||
targetCjkCharacters: z.number().step(1).min(1).required(),
|
||||
maxInputBytes: SessionTitleLlmConfigFields.maxInputBytes,
|
||||
maxOutputTokens: SessionTitleLlmConfigFields.maxOutputTokens,
|
||||
timeoutMs: SessionTitleLlmConfigFields.timeoutMs,
|
||||
provider: SessionTitleLlmConfigFields.provider,
|
||||
model: SessionTitleLlmConfigFields.model,
|
||||
})
|
||||
|
||||
/** Validated immutable provider policy. */
|
||||
interface ResolvedConfig extends ResolvedSessionTitleLlmConfig {
|
||||
readonly targetWords: number
|
||||
readonly targetCjkCharacters: number
|
||||
}
|
||||
|
||||
/** Validate one positive title-length target. */
|
||||
function assertTarget(name: 'targetWords' | 'targetCjkCharacters', value: number): number {
|
||||
if (!Number.isInteger(value) || value <= 0) {
|
||||
throw new Error(`session-title-all-prompts-llm: ${name} must be a positive integer`)
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate the shared execution controls and this provider's title-length targets.
|
||||
* @param config - untrusted plugin configuration.
|
||||
* @returns immutable provider policy.
|
||||
*/
|
||||
function resolveConfig(config: Config): ResolvedConfig {
|
||||
const { targetWords, targetCjkCharacters, ...execution } = config
|
||||
return {
|
||||
...resolveSessionTitleLlmConfig(execution),
|
||||
targetWords: assertTarget('targetWords', targetWords),
|
||||
targetCjkCharacters: assertTarget('targetCjkCharacters', targetCjkCharacters),
|
||||
}
|
||||
}
|
||||
|
||||
/** Select the route's least supported reasoning effort for a title call. */
|
||||
const selectReasoningEffort = (model: Readonly<LlmResolvedModelInfo>): ReasoningEffortId | undefined =>
|
||||
model.reasoning?.efforts[0]?.id
|
||||
/* jscpd:ignore-end */
|
||||
|
||||
/* jscpd:ignore-start -- independent provider strategies share only the execution module, not prompt or parsing policy. */
|
||||
/** Language-aware system instruction, with stability rules when a provider title is anchored. */
|
||||
function systemPrompt(config: ResolvedConfig, hasCurrentTitle: boolean): 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.`,
|
||||
'If the messages give little to name, still return a short best-effort title, such as Greeting, instead of explaining.',
|
||||
...hasCurrentTitle ? [
|
||||
'An existing title is supplied as currentTitle. If it still accurately describes the main topic or task, return it exactly unchanged.',
|
||||
'Follow-up questions, additional details within the same topic, acknowledgements such as "thanks", and requests to continue do not by themselves justify a title change.',
|
||||
'Do not reword, polish, shorten, or replace synonyms in an adequate title. Keeping its exact wording takes priority over the target length.',
|
||||
'Change the title only when the messages materially change or expand the main topic or task so that the existing title is no longer accurate.',
|
||||
] : [],
|
||||
].join('\n')
|
||||
}
|
||||
|
||||
/** Frame messages as JSON so user text cannot break structural delimiters. */
|
||||
function frameMessages(messages: readonly SessionTitleUserMessage[], currentTitle?: string): string {
|
||||
if (currentTitle !== undefined) {
|
||||
return `Update the session title from this JSON object:\n${JSON.stringify({ currentTitle, messages })}`
|
||||
}
|
||||
return `Generate the session title from this JSON array of human messages:\n${JSON.stringify(messages)}`
|
||||
}
|
||||
|
||||
/** Asterisk emphasis wrapping a whole line, such as `**Title**`. */
|
||||
const EMPHASIS_WRAPPER = /^(?<marker>\*{1,3})(?<inner>\S(?:.*\S)?)\k<marker>$/u
|
||||
|
||||
/**
|
||||
* Take the title from model output: the first non-empty line, without
|
||||
* asterisk emphasis that wraps that whole line. Models that disregard the
|
||||
* one-line instruction put the title first and commentary after it. A line
|
||||
* that is entirely `*` emphasis loses the marker pair even when the model
|
||||
* meant it literally; the inner-marker check keeps emphasis inside a longer
|
||||
* line, and the system instruction forbids Markdown.
|
||||
*/
|
||||
function titleFromOutput(text: string): string {
|
||||
const line = text.split(/\r?\n/u).map(item => item.trim()).find(item => item.length > 0) ?? ''
|
||||
const groups = EMPHASIS_WRAPPER.exec(line)?.groups
|
||||
const marker = groups?.['marker']
|
||||
const inner = groups?.['inner']
|
||||
return marker === undefined || inner === undefined || inner.includes(marker) ? line : inner
|
||||
}
|
||||
|
||||
/** Reject terminal tool-call or max-token outcomes and derive one non-empty title from the text blocks. */
|
||||
function titleFromResponse(response: SessionTitleLlmResponse): string {
|
||||
switch (response.finish.kind) {
|
||||
case 'stop':
|
||||
break
|
||||
case 'tool-calls':
|
||||
throw new Error('session-title-llm: title output must contain text only')
|
||||
case 'max-tokens':
|
||||
throw new Error('session-title-llm: title output reached maxOutputTokens')
|
||||
/* v8 ignore next -- closed SessionTitleLlmFinish union exhaustiveness guard */
|
||||
default: return assertNever(response.finish, 'SessionTitleLlmFinish')
|
||||
}
|
||||
if (response.blocks.some(block => block.type === 'tool-call')) {
|
||||
throw new Error('session-title-llm: title output must contain text only')
|
||||
}
|
||||
const text = response.blocks
|
||||
.filter((block): block is Extract<(typeof response.blocks)[number], { type: 'text' }> => block.type === 'text')
|
||||
.map(block => block.text)
|
||||
.join(' ')
|
||||
const title = normalizeSessionTitle(titleFromOutput(text), Number.MAX_SAFE_INTEGER)
|
||||
if (title.length === 0) throw new Error('session-title-llm: title model produced no text')
|
||||
return title
|
||||
}
|
||||
/* jscpd:ignore-end */
|
||||
|
||||
/**
|
||||
@@ -32,5 +152,25 @@ export const Config: z<Config> = z.object({
|
||||
* @param config - required route, target, byte, token, and timeout policy.
|
||||
*/
|
||||
export function apply(ctx: Context, config: Config): void {
|
||||
registerSessionTitleLlmProvider(ctx, config, name, 'all-prompts', messages => messages)
|
||||
const resolved = resolveConfig(config)
|
||||
const titleProvider = SessionTitleProviderId(name)
|
||||
ctx.sessionTitle.register({
|
||||
id: titleProvider,
|
||||
automatic: 'all-prompts',
|
||||
async generate(request) {
|
||||
const messageSeqs = request.messages.map(message => message.seq)
|
||||
const anchor = request.currentTitle?.source.kind === 'provider' ? request.currentTitle.title : undefined
|
||||
const response = await executeSessionTitleLlm(ctx, resolved, request, titleProvider, {
|
||||
system: systemPrompt(resolved, anchor !== undefined),
|
||||
input: frameMessages(request.messages, anchor),
|
||||
messageSeqs,
|
||||
selectReasoningEffort,
|
||||
})
|
||||
return {
|
||||
title: titleFromResponse(response),
|
||||
messageSeqs,
|
||||
model: response.model,
|
||||
}
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -1,23 +1,50 @@
|
||||
import { Context } from '@deepseek-ai/cordis'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import LlmRuntime, { createUserMessage, LlmAdapter } from '@deepseek-ai/dsh-llm'
|
||||
import type { GenerateOptions, StreamChunk } from '@deepseek-ai/dsh-llm'
|
||||
import { describe, expect, it, onTestFinished } from 'vitest'
|
||||
import LlmRuntime, { createUserMessage, LlmAdapter, ReasoningEffortId, ToolCallId } from '@deepseek-ai/dsh-llm'
|
||||
import type { GenerateOptions, LlmResolvedModelInfo, StreamChunk } from '@deepseek-ai/dsh-llm'
|
||||
import SessionStore, { Session, SessionId } from '@deepseek-ai/dsh-session'
|
||||
import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection'
|
||||
import { turnBoundaryProjectionDefinition } from '@deepseek-ai/dsh-agent-loop'
|
||||
import SessionTitleService from '@deepseek-ai/dsh-session-title'
|
||||
import SessionTitleService, {
|
||||
SessionTitleProviderId,
|
||||
type SessionTitleUserMessage,
|
||||
} from '@deepseek-ai/dsh-session-title'
|
||||
import * as providerPlugin from '@deepseek-ai/dsh-session-title-all-prompts-llm'
|
||||
|
||||
class RecordingAdapter extends LlmAdapter {
|
||||
readonly requests: GenerateOptions[] = []
|
||||
|
||||
constructor(
|
||||
private readonly script: readonly StreamChunk[] = ALL_TITLE_SCRIPT,
|
||||
private readonly reasoningEfforts?: readonly string[],
|
||||
) {
|
||||
super()
|
||||
}
|
||||
|
||||
override resolveModel(provider: string, model: string): Promise<LlmResolvedModelInfo> {
|
||||
const efforts = this.reasoningEfforts
|
||||
const floor = efforts?.[0]
|
||||
return Promise.resolve({
|
||||
provider,
|
||||
id: model,
|
||||
name: model,
|
||||
...efforts === undefined || floor === undefined
|
||||
? {}
|
||||
: { reasoning: { efforts: efforts.map(id => ({ id: ReasoningEffortId(id), name: id })) } },
|
||||
})
|
||||
}
|
||||
|
||||
override async * stream(options: GenerateOptions): AsyncIterable<StreamChunk> {
|
||||
this.requests.push(options)
|
||||
yield { type: 'text-delta', index: 0, text: 'All messages model title' }
|
||||
yield { type: 'finish', reason: { kind: 'stop' } }
|
||||
yield * this.script
|
||||
}
|
||||
}
|
||||
|
||||
const ALL_TITLE_SCRIPT: StreamChunk[] = [
|
||||
{ type: 'text-delta', index: 0, text: 'All messages model title' },
|
||||
{ type: 'finish', reason: { kind: 'stop' } },
|
||||
]
|
||||
|
||||
const TITLE_CONFIG = { fallbackMaxWords: 5, fallbackMaxBytes: 40, maxTitleBytes: 80 } as const
|
||||
const LLM_CONFIG = {
|
||||
targetWords: 5,
|
||||
@@ -25,13 +52,122 @@ const LLM_CONFIG = {
|
||||
maxInputBytes: 1_000,
|
||||
maxOutputTokens: 32,
|
||||
timeoutMs: 1_000,
|
||||
provider: 'title-route',
|
||||
model: 'title-model',
|
||||
} as const
|
||||
const INHERITED_LLM_CONFIG = {
|
||||
targetWords: 5,
|
||||
targetCjkCharacters: 10,
|
||||
maxInputBytes: 1_000,
|
||||
maxOutputTokens: 32,
|
||||
timeoutMs: 1_000,
|
||||
} as const
|
||||
|
||||
const SYSTEM_BASE = [
|
||||
'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 5 words in non-CJK languages or 10 CJK characters.',
|
||||
'If the messages give little to name, still return a short best-effort title, such as Greeting, instead of explaining.',
|
||||
].join('\n')
|
||||
const SYSTEM_WITH_TITLE = `${SYSTEM_BASE}\n${[
|
||||
'An existing title is supplied as currentTitle. If it still accurately describes the main topic or task, return it exactly unchanged.',
|
||||
'Follow-up questions, additional details within the same topic, acknowledgements such as "thanks", and requests to continue do not by themselves justify a title change.',
|
||||
'Do not reword, polish, shorten, or replace synonyms in an adequate title. Keeping its exact wording takes priority over the target length.',
|
||||
'Change the title only when the messages materially change or expand the main topic or task so that the existing title is no longer accurate.',
|
||||
].join('\n')}`
|
||||
|
||||
interface Harness {
|
||||
ctx: Context
|
||||
adapter: RecordingAdapter
|
||||
}
|
||||
|
||||
async function harness(
|
||||
config: typeof LLM_CONFIG | typeof INHERITED_LLM_CONFIG = LLM_CONFIG,
|
||||
script: readonly StreamChunk[] = ALL_TITLE_SCRIPT,
|
||||
reasoningEfforts?: readonly string[],
|
||||
): Promise<Harness> {
|
||||
const ctx = new Context()
|
||||
onTestFinished(() => ctx.fiber.dispose())
|
||||
await ctx.plugin(LlmRuntime)
|
||||
await ctx.plugin(SessionStore)
|
||||
await ctx.plugin(SessionProjectionRegistry)
|
||||
ctx.sessionProjections.register(turnBoundaryProjectionDefinition)
|
||||
await ctx.plugin(SessionTitleService, TITLE_CONFIG)
|
||||
const adapter = new RecordingAdapter(script, reasoningEfforts)
|
||||
ctx.llm.registerAdapter(['title-route'], adapter)
|
||||
await ctx.plugin(providerPlugin, config)
|
||||
return { ctx, adapter }
|
||||
}
|
||||
|
||||
function appendHumanPrompt(session: Session, text: string) {
|
||||
return session.append('user/message', createUserMessage({
|
||||
content: [{ type: 'text', text }],
|
||||
source: { kind: 'user' },
|
||||
}), { surfaceOp: 'append' })
|
||||
}
|
||||
|
||||
async function settle(): Promise<void> {
|
||||
await new Promise(resolve => setTimeout(resolve, 0))
|
||||
}
|
||||
|
||||
function textOf(options: GenerateOptions | undefined): string {
|
||||
const content = options?.messages[0]?.content[0]
|
||||
if (content?.type !== 'text') throw new Error('expected a title input text block')
|
||||
return content.text
|
||||
}
|
||||
|
||||
function initialInput(messages: readonly SessionTitleUserMessage[]): string {
|
||||
return `Generate the session title from this JSON array of human messages:\n${JSON.stringify(messages)}`
|
||||
}
|
||||
|
||||
function revisionInput(currentTitle: string, messages: readonly SessionTitleUserMessage[]): string {
|
||||
return `Update the session title from this JSON object:\n${JSON.stringify({ currentTitle, messages })}`
|
||||
}
|
||||
|
||||
describe('all-messages LLM title provider', () => {
|
||||
it('anchors currentTitle only for an accepted provider title and keeps initial generation otherwise', async () => {
|
||||
const { ctx, adapter } = await harness()
|
||||
|
||||
const fallbackSession = ctx.sessions.create(SessionId('all-source-fallback'))
|
||||
const fallbackMessage = appendHumanPrompt(fallbackSession, '阳台种番茄和罗勒')
|
||||
await ctx.sessionTitle.refresh(fallbackSession)
|
||||
const fallbackOptions = adapter.requests.at(-1)!
|
||||
expect(textOf(fallbackOptions)).toBe(initialInput([
|
||||
{ seq: fallbackMessage.seq, text: '阳台种番茄和罗勒' },
|
||||
]))
|
||||
expect(fallbackOptions.system).toBe(SYSTEM_BASE)
|
||||
|
||||
const userSession = ctx.sessions.create(SessionId('all-source-user'))
|
||||
const userMessage = appendHumanPrompt(userSession, '阳台种番茄和罗勒')
|
||||
ctx.sessionTitle.rename(userSession, '用户标题')
|
||||
await ctx.sessionTitle.refresh(userSession)
|
||||
const userOptions = adapter.requests.at(-1)!
|
||||
expect(textOf(userOptions)).toBe(initialInput([{ seq: userMessage.seq, text: '阳台种番茄和罗勒' }]))
|
||||
expect(userOptions.system).toBe(SYSTEM_BASE)
|
||||
|
||||
await ctx.sessionTitle.refresh(fallbackSession)
|
||||
const revisionOptions = adapter.requests.at(-1)!
|
||||
expect(textOf(revisionOptions)).toBe(revisionInput('All messages model title', [
|
||||
{ seq: fallbackMessage.seq, text: '阳台种番茄和罗勒' },
|
||||
]))
|
||||
expect(revisionOptions.system).toBe(SYSTEM_WITH_TITLE)
|
||||
|
||||
const foreignSession = ctx.sessions.create(SessionId('all-source-foreign'))
|
||||
const foreignMessage = appendHumanPrompt(foreignSession, '阳台种番茄和罗勒')
|
||||
foreignSession.append('session/title', {
|
||||
title: '前一个提供方标题',
|
||||
messageSeqs: [foreignMessage.seq],
|
||||
source: { kind: 'provider', provider: SessionTitleProviderId('previous-provider') },
|
||||
})
|
||||
await ctx.sessionTitle.refresh(foreignSession)
|
||||
const foreignOptions = adapter.requests.at(-1)!
|
||||
expect(textOf(foreignOptions)).toBe(revisionInput('前一个提供方标题', [
|
||||
{ seq: foreignMessage.seq, text: '阳台种番茄和罗勒' },
|
||||
]))
|
||||
expect(foreignOptions.system).toBe(SYSTEM_WITH_TITLE)
|
||||
})
|
||||
|
||||
it('includes seeded history and the latest prompt while inheriting the logged request route', async () => {
|
||||
const seeded = Session.create(SessionId('seed-source'))
|
||||
seeded.append('turn/start', { turn: 1 })
|
||||
@@ -43,15 +179,7 @@ describe('all-messages LLM title provider', () => {
|
||||
})
|
||||
seeded.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
|
||||
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(LlmRuntime)
|
||||
await ctx.plugin(SessionStore)
|
||||
await ctx.plugin(SessionProjectionRegistry)
|
||||
ctx.sessionProjections.register(turnBoundaryProjectionDefinition)
|
||||
await ctx.plugin(SessionTitleService, TITLE_CONFIG)
|
||||
const adapter = new RecordingAdapter()
|
||||
ctx.llm.registerAdapter(['current-route'], adapter)
|
||||
await ctx.plugin(providerPlugin, LLM_CONFIG)
|
||||
const { ctx, adapter } = await harness(INHERITED_LLM_CONFIG)
|
||||
const session = ctx.sessions.create(SessionId('all-plugin'), {
|
||||
seed: seeded.snapshotEvents(),
|
||||
inheritedEventCount: seeded.seq,
|
||||
@@ -63,16 +191,146 @@ describe('all-messages LLM title provider', () => {
|
||||
}), { surfaceOp: 'append' })
|
||||
await settle()
|
||||
session.append('request/header', {
|
||||
header: { config: { provider: 'current-route', model: 'current-model' } }, reason: 'resume',
|
||||
header: { config: { provider: 'title-route', model: 'title-model' } }, reason: 'resume',
|
||||
})
|
||||
await settle()
|
||||
|
||||
expect(adapter.requests[0]).toMatchObject({ provider: 'current-route', model: 'current-model' })
|
||||
const content = adapter.requests[0]?.messages[0]?.content[0]
|
||||
expect(content?.type === 'text' && content.text).toContain('inherited prompt')
|
||||
expect(content?.type === 'text' && content.text).toContain('latest prompt')
|
||||
expect(adapter.requests[0]).toMatchObject({ provider: 'title-route', model: 'title-model' })
|
||||
const options = adapter.requests[0]!
|
||||
expect(textOf(options)).toBe(initialInput([
|
||||
{ seq: inherited.seq, text: 'inherited prompt' },
|
||||
{ seq: latest.seq, text: 'latest prompt' },
|
||||
]))
|
||||
expect(options.system).toBe(SYSTEM_BASE)
|
||||
expect(ctx.sessionTitle.get(session)).toMatchObject({
|
||||
title: 'All messages model title',
|
||||
messageSeqs: [inherited.seq, latest.seq],
|
||||
source: { kind: 'provider', provider: providerPlugin.name },
|
||||
})
|
||||
})
|
||||
|
||||
it('takes the initial-generation path when no fallback is derivable and no title exists', async () => {
|
||||
const ctx = new Context()
|
||||
onTestFinished(() => ctx.fiber.dispose())
|
||||
await ctx.plugin(LlmRuntime)
|
||||
await ctx.plugin(SessionStore)
|
||||
await ctx.plugin(SessionProjectionRegistry)
|
||||
ctx.sessionProjections.register(turnBoundaryProjectionDefinition)
|
||||
// A one-byte fallback cap cannot derive a title from the multibyte message,
|
||||
// so the service supplies no currentTitle to the provider.
|
||||
await ctx.plugin(SessionTitleService, { fallbackMaxWords: 5, fallbackMaxBytes: 1, maxTitleBytes: 80 })
|
||||
const adapter = new RecordingAdapter()
|
||||
ctx.llm.registerAdapter(['title-route'], adapter)
|
||||
await ctx.plugin(providerPlugin, LLM_CONFIG)
|
||||
const session = ctx.sessions.create(SessionId('absent-current-title'))
|
||||
const message = appendHumanPrompt(session, '番')
|
||||
|
||||
await ctx.sessionTitle.refresh(session)
|
||||
|
||||
expect(textOf(adapter.requests[0])).toBe(initialInput([{ seq: message.seq, text: '番' }]))
|
||||
expect(adapter.requests[0]?.system).toBe(SYSTEM_BASE)
|
||||
})
|
||||
|
||||
it('selects the route least supported reasoning effort for its own model calls', async () => {
|
||||
const { ctx, adapter } = await harness(LLM_CONFIG, ALL_TITLE_SCRIPT, ['low', 'high'])
|
||||
const session = ctx.sessions.create(SessionId('all-floor-effort'))
|
||||
appendHumanPrompt(session, 'first prompt')
|
||||
|
||||
await ctx.sessionTitle.refresh(session)
|
||||
|
||||
expect(adapter.requests[0]?.reasoningEffort).toBe(ReasoningEffortId('low'))
|
||||
})
|
||||
|
||||
it.each([
|
||||
[['**Continuing Previous', ' Session**\n\nthe only message is "continue".'], 'Continuing Previous Session'],
|
||||
[['\n *Greeting* \n'], 'Greeting'],
|
||||
[['**a** and **b**\nnote'], '**a** and **b**'],
|
||||
[['Fix *args* handling'], 'Fix *args* handling'],
|
||||
[['*args'], '*args'],
|
||||
])('takes the title from the first non-empty output line %j', async (deltas, title) => {
|
||||
const { ctx } = await harness(LLM_CONFIG, [
|
||||
{ type: 'block-start', index: 0, blockType: 'text' },
|
||||
...deltas.map((text): StreamChunk => ({ type: 'text-delta', index: 0, text })),
|
||||
{ type: 'finish', reason: { kind: 'stop' } },
|
||||
])
|
||||
const session = ctx.sessions.create(SessionId(`all-parse-${title}`))
|
||||
appendHumanPrompt(session, 'first prompt')
|
||||
|
||||
await ctx.sessionTitle.refresh(session)
|
||||
|
||||
expect(ctx.sessionTitle.get(session)?.title).toBe(title)
|
||||
})
|
||||
|
||||
it('rejects tool-calls and max-tokens termination even when the blocks contain only text', async () => {
|
||||
const toolCalls = await harness(LLM_CONFIG, [
|
||||
{ type: 'block-start', index: 0, blockType: 'text' },
|
||||
{ type: 'text-delta', index: 0, text: 'Title from text' },
|
||||
{ type: 'finish', reason: { kind: 'tool-calls' } },
|
||||
])
|
||||
const toolCallsSession = toolCalls.ctx.sessions.create(SessionId('all-tool-calls-finish'))
|
||||
appendHumanPrompt(toolCallsSession, 'first prompt')
|
||||
await expect(toolCalls.ctx.sessionTitle.refresh(toolCallsSession)).rejects.toThrow(/output must contain text only/)
|
||||
|
||||
const maxTokens = await harness(LLM_CONFIG, [
|
||||
{ type: 'block-start', index: 0, blockType: 'text' },
|
||||
{ type: 'text-delta', index: 0, text: 'Partial title' },
|
||||
{ type: 'finish', reason: { kind: 'max-tokens' } },
|
||||
])
|
||||
const maxTokensSession = maxTokens.ctx.sessions.create(SessionId('all-max-tokens-finish'))
|
||||
appendHumanPrompt(maxTokensSession, 'first prompt')
|
||||
await expect(maxTokens.ctx.sessionTitle.refresh(maxTokensSession)).rejects.toThrow(/reached maxOutputTokens/)
|
||||
})
|
||||
|
||||
it('rejects tool-call blocks and a successful response with no text', async () => {
|
||||
const tool = await harness(LLM_CONFIG, [
|
||||
{ type: 'block-start', index: 0, blockType: 'tool-call' },
|
||||
{ type: 'tool-call-delta', index: 0, id: ToolCallId('title-tool'), name: 'unexpected', argumentsDelta: '{}' },
|
||||
{ type: 'finish', reason: { kind: 'stop' } },
|
||||
])
|
||||
const toolSession = tool.ctx.sessions.create(SessionId('all-tool'))
|
||||
appendHumanPrompt(toolSession, 'first prompt')
|
||||
await expect(tool.ctx.sessionTitle.refresh(toolSession)).rejects.toThrow(/output must contain text only/)
|
||||
|
||||
const reasoning = await harness(LLM_CONFIG, [
|
||||
{ type: 'block-start', index: 0, blockType: 'reasoning' },
|
||||
{ type: 'reasoning-delta', index: 0, text: 'no final title' },
|
||||
{ type: 'finish', reason: { kind: 'stop' } },
|
||||
])
|
||||
const reasoningSession = reasoning.ctx.sessions.create(SessionId('all-reasoning'))
|
||||
appendHumanPrompt(reasoningSession, 'first prompt')
|
||||
await expect(reasoning.ctx.sessionTitle.refresh(reasoningSession)).rejects.toThrow(/produced no text/)
|
||||
})
|
||||
|
||||
it('requires positive target settings and rejects unknown configuration keys', () => {
|
||||
const ctx = new Context()
|
||||
onTestFinished(() => ctx.fiber.dispose())
|
||||
expect(() => { providerPlugin.apply(ctx, { ...LLM_CONFIG, targetWords: 0 }) })
|
||||
.toThrow(/targetWords.*positive integer/)
|
||||
expect(() => { providerPlugin.apply(ctx, { ...LLM_CONFIG, targetCjkCharacters: 1.5 }) })
|
||||
.toThrow(/targetCjkCharacters.*positive integer/)
|
||||
expect(() => { providerPlugin.apply(ctx, { ...LLM_CONFIG, extra: true } as never) })
|
||||
.toThrow(/unknown config key "extra"/)
|
||||
})
|
||||
|
||||
it('preserves an accepted title unchanged when the model returns it verbatim', async () => {
|
||||
const { ctx, adapter } = await harness(LLM_CONFIG, [
|
||||
{ type: 'text-delta', index: 0, text: '阳台种番茄与罗勒' },
|
||||
{ type: 'finish', reason: { kind: 'stop' } },
|
||||
])
|
||||
const session = ctx.sessions.create(SessionId('all-stability'))
|
||||
const first = appendHumanPrompt(session, '我想在阳台种番茄和罗勒,请给入门建议。')
|
||||
await ctx.sessionTitle.refresh(session)
|
||||
expect(ctx.sessionTitle.get(session)?.title).toBe('阳台种番茄与罗勒')
|
||||
|
||||
const second = appendHumanPrompt(session, '花盆要多大?')
|
||||
const accepted = await ctx.sessionTitle.refresh(session)
|
||||
|
||||
expect(accepted).toMatchObject({
|
||||
title: '阳台种番茄与罗勒',
|
||||
messageSeqs: [first.seq, second.seq],
|
||||
source: { kind: 'provider', provider: providerPlugin.name },
|
||||
})
|
||||
expect(adapter.requests.at(-1)?.system).toBe(SYSTEM_WITH_TITLE)
|
||||
expect(textOf(adapter.requests.at(-1))).toContain('"currentTitle":"阳台种番茄与罗勒"')
|
||||
})
|
||||
})
|
||||
@@ -6,6 +6,7 @@
|
||||
{ "path": "../../../vendor/cosmokit" },
|
||||
{ "path": "../../../vendor/cordis" },
|
||||
{ "path": "../../../vendor/schemastery" },
|
||||
{ "path": "../../util/values" },
|
||||
{ "path": "../../llm/llm" },
|
||||
{ "path": "../../session/session-title" },
|
||||
{ "path": "../../session/session-title-llm" }
|
||||
|
||||
@@ -15,8 +15,8 @@
|
||||
en: d152484eb41ac6b4
|
||||
zh: 988e839059414d27
|
||||
/deepseek-ai-dsh-experimental-session-titles-bundle/use-this-package:
|
||||
en: 7a92ac057d492afa
|
||||
zh: 7f018e1611e4b6b9
|
||||
en: 3a843c2d148f94f1
|
||||
zh: e67b41828084eb86
|
||||
/deepseek-ai-dsh-experimental-session-titles-bundle/understand-the-implementation:
|
||||
en: f4a8d06bd561f7e9
|
||||
zh: de811cf2c131256c
|
||||
@@ -30,8 +30,8 @@
|
||||
en: 1b2209bbbf869b1a
|
||||
zh: 5b9de62271343e4e
|
||||
/deepseek-ai-dsh-experimental-session-titles-bundle/known-limitations-and-deferred-work:
|
||||
en: 76117a232098ada0
|
||||
zh: 5b40afc9b66384fd
|
||||
en: 833386166a2ec27c
|
||||
zh: a70ebec93a3be891
|
||||
/deepseek-ai-dsh-experimental-session-titles-bundle/known-limitations-and-deferred-work/dev-note:
|
||||
en: 279d01435256cd2a
|
||||
zh: 83780964e5de2f54
|
||||
@@ -27,7 +27,7 @@ Generate experimental session titles from the conversation's eligible human prom
|
||||
|
||||
In Web or Desktop, open Plugins and enable **Conversation-following titles** in Official. Switch it off to remove its profile layer.
|
||||
|
||||
The switch changes the title provider for existing and new Sessions. Titles update on eligible human prompts or explicit refresh; automatic updates preserve user-pinned titles. Agent tool catalogs remain unchanged.
|
||||
The switch changes the title provider for existing and new Sessions. Eligible human prompts or explicit refresh trigger title requests; the model is asked to keep an existing generated title exactly while it still describes the main topic or task. Automatic updates preserve user-pinned titles. Agent tool catalogs remain unchanged.
|
||||
|
||||
-----
|
||||
|
||||
@@ -63,7 +63,7 @@ The layer adds no request content directly; the capability implementation owns c
|
||||
|
||||
<a id="known-limitations-and-deferred-work"></a>
|
||||
|
||||
- Automatic title failures retain the previous title. The 4096-byte title-input budget rejects oversized input instead of truncating conversation history.
|
||||
- Automatic title failures retain the previous title. The 4096-byte title-input budget includes the current generated title when supplied and rejects oversized input instead of truncating conversation history.
|
||||
|
||||
-----
|
||||
|
||||
|
||||
@@ -27,7 +27,7 @@ kind: "package-bundle"
|
||||
|
||||
在 Web 或 Desktop 中打开插件页,在官方分组启用 **随对话更新的标题**。关闭后移除其配置层。
|
||||
|
||||
开关会更改已有和新建 Session 的标题提供方。标题在合格的人类提示或显式刷新时更新;自动更新不会覆盖用户固定的标题。智能体的工具目录保持不变。
|
||||
开关会更改已有和新建 Session 的标题提供方。符合条件的用户提示词或显式刷新会触发标题请求;模型被要求在已有生成标题仍能描述主要主题或任务时逐字保留它。自动更新不会覆盖用户固定的标题。智能体的工具目录保持不变。
|
||||
|
||||
-----
|
||||
|
||||
@@ -63,7 +63,7 @@ kind: "package-bundle"
|
||||
|
||||
<a id="known-limitations-and-deferred-work"></a>
|
||||
|
||||
- 自动标题生成失败时保留旧标题。标题输入预算为 4096 字节,超限时拒绝输入而不截断对话历史。
|
||||
- 自动标题生成失败时保留旧标题。标题输入预算为 4096 字节,包含传入的当前生成标题,超限时拒绝输入而不截断对话历史。
|
||||
|
||||
-----
|
||||
|
||||
|
||||
@@ -7215,7 +7215,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [
|
||||
},
|
||||
{
|
||||
name: 'SessionTitleProviderRequest',
|
||||
declaration: 'export interface SessionTitleProviderRequest {\n readonly session: Session;\n readonly messages: readonly SessionTitleUserMessage[];\n readonly route?: SessionTitleModelIdentity;\n readonly signal: AbortSignal;\n}',
|
||||
declaration: 'export interface SessionTitleProviderRequest {\n readonly session: Session;\n readonly messages: readonly SessionTitleUserMessage[];\n readonly route?: SessionTitleModelIdentity;\n readonly currentTitle?: SessionTitleSnapshot;\n readonly signal: AbortSignal;\n}',
|
||||
},
|
||||
{
|
||||
name: 'SessionTitleProviderResult',
|
||||
|
||||
@@ -24,8 +24,8 @@
|
||||
en: 706441e1ab2fa853
|
||||
zh: a7fa44caab3ddada
|
||||
/session-durable-session-data-plane/packages/titles:
|
||||
en: ca5da2f0648e6246
|
||||
zh: cda50ccf5f245153
|
||||
en: 81245e5ca5f8b773
|
||||
zh: 0abee7434238c342
|
||||
/session-durable-session-data-plane/packages/telemetry:
|
||||
en: e0edfc0566463495
|
||||
zh: 4af9904d7ba97d97
|
||||
|
||||
@@ -55,7 +55,7 @@ The group splits into four families: durable storage (persistence seam, backends
|
||||
| Package | Role | ctx key |
|
||||
|---|---|---|
|
||||
| [`session-title/`](session-title/README.md) | Log-backed session titles with a deterministic fallback and one optional provider | `ctx.sessionTitle` |
|
||||
| [`session-title-llm/`](session-title-llm/README.md) | Shared model-backed title-generation policy for the provider packages | library — no ctx key |
|
||||
| [`session-title-llm/`](session-title-llm/README.md) | Shared bounded execution for provider-owned title strategies | library — no ctx key |
|
||||
| [`session-title-first-prompt-llm/`](session-title-first-prompt-llm/README.md) | Titles a session from its first eligible human message | registers on `ctx.sessionTitle` |
|
||||
|
||||
### Telemetry
|
||||
|
||||
@@ -55,7 +55,7 @@ session 组让对话持久保存,恢复已发布的日志格式,并使已提
|
||||
| 包 | 职责 | ctx key |
|
||||
|---|---|---|
|
||||
| [`session-title/`](session-title/README.zh.md) | 基于日志的会话标题,带确定性回退与一个可选提供方 | `ctx.sessionTitle` |
|
||||
| [`session-title-llm/`](session-title-llm/README.zh.md) | 供提供方包共享的模型标题生成策略 | 库,不使用 ctx key |
|
||||
| [`session-title-llm/`](session-title-llm/README.zh.md) | 为提供方自有的标题策略提供的共享有界执行 | 库,不使用 ctx key |
|
||||
| [`session-title-first-prompt-llm/`](session-title-first-prompt-llm/README.zh.md) | 根据第一条合格的人类消息为会话生成标题 | 注册到 `ctx.sessionTitle` |
|
||||
|
||||
### 遥测
|
||||
|
||||
@@ -9,20 +9,20 @@
|
||||
en: c93e663d382f0ca7
|
||||
zh: ddbea0a6b61ddb72
|
||||
/deepseek-ai-dsh-session-title-first-prompt-llm/summary:
|
||||
en: b83df8efb409c03b
|
||||
zh: 9f1cba8b8a0964cb
|
||||
en: f285f80e499c78e7
|
||||
zh: 4001261b52131d29
|
||||
/deepseek-ai-dsh-session-title-first-prompt-llm/table-of-contents:
|
||||
en: d152484eb41ac6b4
|
||||
zh: 09388d293f9be9cb
|
||||
/deepseek-ai-dsh-session-title-first-prompt-llm/use-this-package:
|
||||
en: 0b38e82526f23693
|
||||
zh: bea7720f64970d22
|
||||
en: 2e60e0b77b5f4813
|
||||
zh: 0523c0af3a78f67a
|
||||
/deepseek-ai-dsh-session-title-first-prompt-llm/use-this-package/when-titles-are-generated:
|
||||
en: 3215179477572a94
|
||||
zh: 54bf9a139aeafa44
|
||||
/deepseek-ai-dsh-session-title-first-prompt-llm/use-this-package/configuration:
|
||||
en: c466522189ef3c87
|
||||
zh: 7687088a350e781f
|
||||
en: 623d02888bda633e
|
||||
zh: 969f25b04c20995c
|
||||
/deepseek-ai-dsh-session-title-first-prompt-llm/use-this-package/failures-and-recovery:
|
||||
en: 9e5b1bd0b388b87d
|
||||
zh: 7b1d07e48d4267ca
|
||||
@@ -30,17 +30,17 @@
|
||||
en: 506933ffa7745b7d
|
||||
zh: a9bb6f06a32b7c18
|
||||
/deepseek-ai-dsh-session-title-first-prompt-llm/understand-the-implementation/design-concept:
|
||||
en: de9d298216fda1c1
|
||||
zh: 7d14428cf3be3926
|
||||
en: dc7b69fcfd3a5538
|
||||
zh: 57cbc803f2938cc0
|
||||
/deepseek-ai-dsh-session-title-first-prompt-llm/understand-the-implementation/source-map:
|
||||
en: 71705f6436e44daf
|
||||
zh: 5796361346fd86ca
|
||||
en: 7daf8c1232bc702d
|
||||
zh: 4b4147eb8994e325
|
||||
/deepseek-ai-dsh-session-title-first-prompt-llm/understand-the-implementation/scheduling:
|
||||
en: a79488f550380f5e
|
||||
zh: 0ca8a457084c0fca
|
||||
/deepseek-ai-dsh-session-title-first-prompt-llm/further-exploration:
|
||||
en: a70619b36cdd416e
|
||||
zh: d40851bb696c0efc
|
||||
en: 5a8cd304162c6fd1
|
||||
zh: 7a8008989d8a0de8
|
||||
/deepseek-ai-dsh-session-title-first-prompt-llm/model-experience:
|
||||
en: 215e7ba838619b7b
|
||||
zh: a311da3843709f90
|
||||
@@ -48,11 +48,11 @@
|
||||
en: b216200be66e9ec4
|
||||
zh: 6805c5bfb9c55091
|
||||
/deepseek-ai-dsh-session-title-first-prompt-llm/model-experience/first-message-title-request/what-the-model-sees:
|
||||
en: c465d920d61d8564
|
||||
zh: 4b8843f5dac13dd8
|
||||
en: 54d7319253bad069
|
||||
zh: 48fe072d55271912
|
||||
/deepseek-ai-dsh-session-title-first-prompt-llm/model-experience/first-message-title-request/token-effect:
|
||||
en: eba887955bf3f8af
|
||||
zh: 61fdea0346e06428
|
||||
en: e4640d9fe3e33875
|
||||
zh: fd22d9196a32aa69
|
||||
/deepseek-ai-dsh-session-title-first-prompt-llm/model-experience/first-message-title-request/kv-cache-effect:
|
||||
en: 22c407527c295def
|
||||
zh: c7e35aa298fe6b5f
|
||||
|
||||
@@ -9,7 +9,7 @@ English | [中文](README.zh.md)
|
||||
|
||||
## Summary
|
||||
|
||||
`dsh-session-title-first-prompt-llm` summarizes the first eligible human message through `ctx.llm` as an optional `ctx.sessionTitle` provider. It registers the `first-prompt` cadence, runs automatically only when a fresh non-fork session first creates its fallback, and attributes the result to that message's exact seq. An automatic failure retains the fallback and is retried only through `ctx.sessionTitle.refresh()`. It uses the complete required shared LLM configuration from `dsh-session-title-llm`, so route, prompt, budget, and cancellation behavior cannot drift. Automatic behavior and configuration come first; the implementation is a thin registration over the shared policy.
|
||||
`dsh-session-title-first-prompt-llm` summarizes the first eligible human message through `ctx.llm` as an optional `ctx.sessionTitle` provider. It registers the `first-prompt` cadence, runs automatically only when a fresh non-fork session first creates its fallback, and attributes the result to that message's exact seq. An automatic failure retains the fallback and is retried only through `ctx.sessionTitle.refresh()`. The plugin owns its system instruction, JSON framing, and output parsing; it shares only the [execution module](../session-title-llm/README.md) from `dsh-session-title-llm` with the all-prompts provider. Automatic behavior and configuration come first; the implementation is a small provider strategy over that executor.
|
||||
|
||||
## Table of Contents
|
||||
|
||||
@@ -25,7 +25,7 @@ English | [中文](README.zh.md)
|
||||
<a id="use-this-package"></a>
|
||||
## Use this package
|
||||
|
||||
Mount this plugin beside the title service when a session should be titled from its first eligible human message. It requires the full [shared LLM configuration](../session-title-llm/README.md#configuration) with no defaults.
|
||||
Mount this plugin beside the title service when a session should be titled from its first eligible human message. It requires its own `targetWords` and `targetCjkCharacters` plus the shared [execution controls](../session-title-llm/README.md#configuration), all with no defaults.
|
||||
|
||||
### When titles are generated
|
||||
|
||||
@@ -33,7 +33,7 @@ Automatic generation runs only for a fresh session with no parent and no prior t
|
||||
|
||||
### Configuration
|
||||
|
||||
The plugin accepts the complete required [shared LLM configuration](../session-title-llm/README.md#configuration): `targetWords`, `targetCjkCharacters`, `maxInputBytes`, `maxOutputTokens`, `timeoutMs`, and the optional paired `provider`/`model` route. Omit both to inherit the exact route from the current logged main request, or set both to route title generation independently. The generated [configuration catalog](../../../docs/config-catalog.md#deepseek-aidsh-session-title-first-prompt-llm) is the exhaustive source for every accepted field.
|
||||
The plugin requires `targetWords` and `targetCjkCharacters` plus the shared [execution controls](../session-title-llm/README.md#configuration): `maxInputBytes`, `maxOutputTokens`, `timeoutMs`, and the optional paired `provider`/`model` route. Omit both to inherit the exact route from the current logged main request, or set both to route title generation independently. The generated [configuration catalog](../../../docs/config-catalog.md#deepseek-aidsh-session-title-first-prompt-llm) is the exhaustive source for every accepted field.
|
||||
|
||||
### Failures and recovery
|
||||
|
||||
@@ -51,13 +51,13 @@ This section explains the plugin's shape; the observable behavior is fully cover
|
||||
|
||||
### Design concept
|
||||
|
||||
A thin provider plugin: it registers the `first-prompt` cadence with a selector that takes the first eligible message, and delegates everything else to the [shared LLM policy](../session-title-llm/README.md).
|
||||
A small provider strategy: it builds the first-message system instruction and JSON input, parses the assembled response into a title, and delegates only route, bounds, cancellation, logging, and assembly to the [shared execution module](../session-title-llm/README.md).
|
||||
|
||||
### Source map
|
||||
|
||||
| File | Role |
|
||||
|---|---|
|
||||
| [`src/index.ts`](src/index.ts) | Plugin entry: shared config schema, provider registration with the first-message selector |
|
||||
| [`src/index.ts`](src/index.ts) | Plugin entry: config schema, first-message prompt and framing, output parsing, and direct provider registration |
|
||||
|
||||
### Scheduling
|
||||
|
||||
@@ -70,9 +70,9 @@ The title service schedules automatic work: for the `first-prompt` cadence it st
|
||||
<a id="further-exploration"></a>
|
||||
## Further Exploration
|
||||
|
||||
Read these pages when the provider contract is not enough. They move from the shared policy to the alternative cadence and the service it plugs into.
|
||||
Read these pages when the provider contract is not enough. They move from the shared execution module to the alternative cadence and the service it plugs into.
|
||||
|
||||
- [Shared LLM title policy](../session-title-llm/README.md) — the generation helper this provider uses.
|
||||
- [Shared LLM execution module](../session-title-llm/README.md) — the execution module this provider uses.
|
||||
- [All-messages title provider](../../experimental/session-title-all-prompts-llm/README.md) — the cadence that retitles after every new prompt.
|
||||
- [Session title service](../session-title/README.md) — fallback behavior, rename, refresh, and provider registration.
|
||||
- [Session package map](../README.md) — adjacent persistence, projection, title, and telemetry packages.
|
||||
@@ -86,11 +86,11 @@ Read these pages when the provider contract is not enough. They move from the sh
|
||||
|
||||
#### What the model sees
|
||||
|
||||
The title model receives the shared title instruction and a JSON array containing only the first eligible human message. Later prompts and inherited fork history do not trigger another automatic call.
|
||||
The title model receives this provider's title instruction and a JSON array containing only the first eligible human message; it never receives a `currentTitle`. Later prompts and inherited fork history do not trigger another automatic call.
|
||||
|
||||
#### Token effect
|
||||
|
||||
At most one automatic auxiliary request is made for a fresh session, bounded by `maxInputBytes` and `maxOutputTokens`; explicit refreshes may make additional calls. The main agent request gains zero tokens.
|
||||
At most one automatic auxiliary request is made for a fresh session, bounded by `maxInputBytes` and `maxOutputTokens`; explicit refreshes may make additional calls. It selects the route's least supported reasoning effort, or leaves effort unspecified when the route exposes none. The main agent request gains zero tokens.
|
||||
|
||||
#### KV Cache effect
|
||||
|
||||
|
||||
@@ -9,7 +9,7 @@ kind: "package-reference"
|
||||
|
||||
## 概述
|
||||
|
||||
`dsh-session-title-first-prompt-llm` 作为可选的 `ctx.sessionTitle` 提供方,通过 `ctx.llm` 总结第一条符合条件的用户消息。它注册 `first-prompt` 节奏,只在全新非 fork 会话首次创建回退时自动运行,并把结果归因于该消息的确切 seq。自动失败会保留回退,之后只能通过 `ctx.sessionTitle.refresh()` 重试。它使用 `dsh-session-title-llm` 的完整必填共享 LLM 配置,因此路由、提示词、预算与取消行为不会漂移。自动行为与配置优先;实现仅在共享策略之上进行轻量注册。
|
||||
`dsh-session-title-first-prompt-llm` 作为可选的 `ctx.sessionTitle` 提供方,通过 `ctx.llm` 总结第一条符合条件的用户消息。它注册 `first-prompt` 节奏,只在全新非 fork 会话首次创建回退时自动运行,并把结果归因于该消息的确切 seq。自动失败会保留回退,之后只能通过 `ctx.sessionTitle.refresh()` 重试。它拥有自己的系统指令、JSON 封装与输出解析;只与全消息提供方共享 `dsh-session-title-llm` 的[执行模块](../session-title-llm/README.zh.md)。自动行为与配置优先;实现是建立在该执行器之上的小型提供方策略。
|
||||
|
||||
## 目录
|
||||
|
||||
@@ -25,7 +25,7 @@ kind: "package-reference"
|
||||
<a id="use-this-package"></a>
|
||||
## 使用本包
|
||||
|
||||
当会话应从第一条符合条件用户消息生成标题时,在标题服务旁挂载此插件。它要求完整的[共享 LLM 配置](../session-title-llm/README.zh.md#configuration),且无默认值。
|
||||
当会话应从第一条符合条件用户消息生成标题时,在标题服务旁挂载此插件。它要求自己的 `targetWords` 与 `targetCjkCharacters`,外加共享的[执行控制](../session-title-llm/README.zh.md#configuration),且全部无默认值。
|
||||
|
||||
### 标题生成时机
|
||||
|
||||
@@ -33,7 +33,7 @@ kind: "package-reference"
|
||||
|
||||
### 配置
|
||||
|
||||
插件接受完整必填的[共享 LLM 配置](../session-title-llm/README.zh.md#configuration):`targetWords`、`targetCjkCharacters`、`maxInputBytes`、`maxOutputTokens`、`timeoutMs`,以及可选成对的 `provider`/`model` 路由。同时省略二者,会继承当前已记录主请求的确切路由;同时设置二者,则让标题生成使用独立路由。生成的[配置目录](../../../docs/config-catalog.zh.md#deepseek-aidsh-session-title-first-prompt-llm)是每个受支持字段的穷尽式真源。
|
||||
插件要求 `targetWords` 与 `targetCjkCharacters`,外加共享的[执行控制](../session-title-llm/README.zh.md#configuration):`maxInputBytes`、`maxOutputTokens`、`timeoutMs`,以及可选成对的 `provider`/`model` 路由。同时省略二者,会继承当前已记录主请求的确切路由;同时设置二者,则让标题生成使用独立路由。生成的[配置目录](../../../docs/config-catalog.zh.md#deepseek-aidsh-session-title-first-prompt-llm)是每个受支持字段的穷尽式真源。
|
||||
|
||||
### 失败与恢复
|
||||
|
||||
@@ -51,13 +51,13 @@ kind: "package-reference"
|
||||
|
||||
### 设计理念
|
||||
|
||||
一个薄提供方插件:它注册 `first-prompt` 节奏,用选择器取第一条符合条件消息,其余全部委托给[共享 LLM 策略](../session-title-llm/README.zh.md)。
|
||||
一个小型提供方策略:它构建首消息系统指令与 JSON 输入,把组装后的响应解析为标题,只把路由、上限、取消、记录与组装委托给[共享执行模块](../session-title-llm/README.zh.md)。
|
||||
|
||||
### 源码地图
|
||||
|
||||
| 文件 | 职责 |
|
||||
|---|---|
|
||||
| [`src/index.ts`](src/index.ts) | 插件入口:共享配置 schema、以首消息选择器注册提供方 |
|
||||
| [`src/index.ts`](src/index.ts) | 插件入口:配置 schema、首消息提示词与封装、输出解析与直接提供方注册 |
|
||||
|
||||
### 调度
|
||||
|
||||
@@ -70,9 +70,9 @@ kind: "package-reference"
|
||||
<a id="further-exploration"></a>
|
||||
## 进一步探索
|
||||
|
||||
当提供方约定不够用时阅读以下页面。它们从共享策略逐步进入替代节奏与它所插入的服务。
|
||||
当提供方约定不够用时阅读以下页面。它们从共享执行模块逐步进入替代节奏与它所插入的服务。
|
||||
|
||||
- [共享 LLM 标题策略](../session-title-llm/README.zh.md)——此提供方使用的生成辅助模块。
|
||||
- [共享 LLM 执行模块](../session-title-llm/README.zh.md)——此提供方使用的执行模块。
|
||||
- [全消息标题提供方](../../experimental/session-title-all-prompts-llm/README.zh.md)——在每条新提示词后重新生成标题的节奏。
|
||||
- [会话标题服务](../session-title/README.zh.md)——回退行为、重命名、刷新与提供方注册。
|
||||
- [会话包映射](../README.zh.md)——相邻的持久化、投影、标题与遥测包。
|
||||
@@ -86,11 +86,11 @@ kind: "package-reference"
|
||||
|
||||
#### 模型看到什么
|
||||
|
||||
标题模型会收到共享标题指令,以及一个只包含第一条符合条件用户消息的 JSON 数组。后续提示词与继承的 fork 历史不会触发再次自动调用。
|
||||
标题模型会收到此提供方的标题指令,以及一个只包含第一条符合条件用户消息的 JSON 数组,永远不会收到 `currentTitle`。后续提示词与继承的 fork 历史不会触发再次自动调用。
|
||||
|
||||
#### Token 影响
|
||||
|
||||
全新会话最多自动发出一次辅助请求,并受 `maxInputBytes` 与 `maxOutputTokens` 约束;显式刷新可能发出额外调用。主 agent 请求不会增加 token。
|
||||
全新会话最多自动发出一次辅助请求,并受 `maxInputBytes` 与 `maxOutputTokens` 约束;显式刷新可能发出额外调用。它选择路由支持的最低推理强度;路由没有可选强度时不指定。主 agent 请求不会增加 token。
|
||||
|
||||
#### KV Cache 影响
|
||||
|
||||
|
||||
@@ -30,6 +30,7 @@
|
||||
"@deepseek-ai/cordis": "workspace:~"
|
||||
},
|
||||
"dependencies": {
|
||||
"@deepseek-ai/dsh-util-values": "workspace:*",
|
||||
"@deepseek-ai/schemastery": "workspace:~"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
||||
@@ -1,29 +1,140 @@
|
||||
/** First-human-message model provider for `ctx.sessionTitle`. */
|
||||
|
||||
/* jscpd:ignore-start -- both shipped providers import the same shared packages. */
|
||||
import type { Context } from '@deepseek-ai/cordis'
|
||||
import z from '@deepseek-ai/schemastery'
|
||||
import type { LlmResolvedModelInfo, ReasoningEffortId } from '@deepseek-ai/dsh-llm'
|
||||
import { assertNever } from '@deepseek-ai/dsh-util-values'
|
||||
import {
|
||||
registerSessionTitleLlmProvider,
|
||||
normalizeSessionTitle,
|
||||
SessionTitleProviderId,
|
||||
type SessionTitleUserMessage,
|
||||
} from '@deepseek-ai/dsh-session-title'
|
||||
import {
|
||||
executeSessionTitleLlm,
|
||||
resolveSessionTitleLlmConfig,
|
||||
SessionTitleLlmConfigFields,
|
||||
type ResolvedSessionTitleLlmConfig,
|
||||
type SessionTitleLlmConfig,
|
||||
type SessionTitleLlmResponse,
|
||||
} from '@deepseek-ai/dsh-session-title-llm'
|
||||
import type { SessionTitleLlmConfig } from '@deepseek-ai/dsh-session-title-llm'
|
||||
/* jscpd:ignore-end */
|
||||
|
||||
export const name = 'session-title-first-prompt-llm'
|
||||
export const inject = ['sessionTitle', 'llm', 'sessions']
|
||||
|
||||
/** Required LLM policy; this plugin adds no defaults. */
|
||||
export type Config = SessionTitleLlmConfig
|
||||
/** Loader schema shared with the all-messages provider. */
|
||||
/* jscpd:ignore-start -- Loader requires each plugin to export its own statically walkable schema; the field validators remain shared. */
|
||||
/* jscpd:ignore-start -- each provider owns its title-length targets and reasoning control. */
|
||||
/** Required execution controls plus this provider's title-length targets; no defaults. */
|
||||
export interface Config extends SessionTitleLlmConfig {
|
||||
/** Target word count for non-CJK titles. */
|
||||
readonly targetWords: number
|
||||
/** Target character count for Chinese, Japanese, or Korean titles. */
|
||||
readonly targetCjkCharacters: number
|
||||
}
|
||||
|
||||
/** Loader schema for this provider's required execution controls and title-length targets. */
|
||||
export const Config: z<Config> = z.object({
|
||||
targetWords: SessionTitleLlmConfigFields.targetWords,
|
||||
targetCjkCharacters: SessionTitleLlmConfigFields.targetCjkCharacters,
|
||||
targetWords: z.number().step(1).min(1).required(),
|
||||
targetCjkCharacters: z.number().step(1).min(1).required(),
|
||||
maxInputBytes: SessionTitleLlmConfigFields.maxInputBytes,
|
||||
maxOutputTokens: SessionTitleLlmConfigFields.maxOutputTokens,
|
||||
timeoutMs: SessionTitleLlmConfigFields.timeoutMs,
|
||||
provider: SessionTitleLlmConfigFields.provider,
|
||||
model: SessionTitleLlmConfigFields.model,
|
||||
})
|
||||
|
||||
/** Validated immutable provider policy. */
|
||||
interface ResolvedConfig extends ResolvedSessionTitleLlmConfig {
|
||||
readonly targetWords: number
|
||||
readonly targetCjkCharacters: number
|
||||
}
|
||||
|
||||
/** Validate one positive title-length target. */
|
||||
function assertTarget(name: 'targetWords' | 'targetCjkCharacters', value: number): number {
|
||||
if (!Number.isInteger(value) || value <= 0) {
|
||||
throw new Error(`session-title-first-prompt-llm: ${name} must be a positive integer`)
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate the shared execution controls and this provider's title-length targets.
|
||||
* @param config - untrusted plugin configuration.
|
||||
* @returns immutable provider policy.
|
||||
*/
|
||||
function resolveConfig(config: Config): ResolvedConfig {
|
||||
const { targetWords, targetCjkCharacters, ...execution } = config
|
||||
return {
|
||||
...resolveSessionTitleLlmConfig(execution),
|
||||
targetWords: assertTarget('targetWords', targetWords),
|
||||
targetCjkCharacters: assertTarget('targetCjkCharacters', targetCjkCharacters),
|
||||
}
|
||||
}
|
||||
|
||||
/** Select the route's least supported reasoning effort for a title call. */
|
||||
const selectReasoningEffort = (model: Readonly<LlmResolvedModelInfo>): ReasoningEffortId | undefined =>
|
||||
model.reasoning?.efforts[0]?.id
|
||||
/* jscpd:ignore-end */
|
||||
|
||||
/* jscpd:ignore-start -- independent provider strategies share only the execution module, not prompt or parsing policy. */
|
||||
/** Language-aware system instruction for a title derived from one message. */
|
||||
function systemPrompt(config: ResolvedConfig): 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.`,
|
||||
'If the messages give little to name, still return a short best-effort title, such as Greeting, instead of explaining.',
|
||||
].join('\n')
|
||||
}
|
||||
|
||||
/** Frame the selected 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)}`
|
||||
}
|
||||
|
||||
/** Asterisk emphasis wrapping a whole line, such as `**Title**`. */
|
||||
const EMPHASIS_WRAPPER = /^(?<marker>\*{1,3})(?<inner>\S(?:.*\S)?)\k<marker>$/u
|
||||
|
||||
/**
|
||||
* Take the title from model output: the first non-empty line, without
|
||||
* asterisk emphasis that wraps that whole line. Models that disregard the
|
||||
* one-line instruction put the title first and commentary after it. A line
|
||||
* that is entirely `*` emphasis loses the marker pair even when the model
|
||||
* meant it literally; the inner-marker check keeps emphasis inside a longer
|
||||
* line, and the system instruction forbids Markdown.
|
||||
*/
|
||||
function titleFromOutput(text: string): string {
|
||||
const line = text.split(/\r?\n/u).map(item => item.trim()).find(item => item.length > 0) ?? ''
|
||||
const groups = EMPHASIS_WRAPPER.exec(line)?.groups
|
||||
const marker = groups?.['marker']
|
||||
const inner = groups?.['inner']
|
||||
return marker === undefined || inner === undefined || inner.includes(marker) ? line : inner
|
||||
}
|
||||
|
||||
/** Reject terminal tool-call or max-token outcomes and derive one non-empty title from the text blocks. */
|
||||
function titleFromResponse(response: SessionTitleLlmResponse): string {
|
||||
switch (response.finish.kind) {
|
||||
case 'stop':
|
||||
break
|
||||
case 'tool-calls':
|
||||
throw new Error('session-title-llm: title output must contain text only')
|
||||
case 'max-tokens':
|
||||
throw new Error('session-title-llm: title output reached maxOutputTokens')
|
||||
/* v8 ignore next -- closed SessionTitleLlmFinish union exhaustiveness guard */
|
||||
default: return assertNever(response.finish, 'SessionTitleLlmFinish')
|
||||
}
|
||||
if (response.blocks.some(block => block.type === 'tool-call')) {
|
||||
throw new Error('session-title-llm: title output must contain text only')
|
||||
}
|
||||
const text = response.blocks
|
||||
.filter((block): block is Extract<(typeof response.blocks)[number], { type: 'text' }> => block.type === 'text')
|
||||
.map(block => block.text)
|
||||
.join(' ')
|
||||
const title = normalizeSessionTitle(titleFromOutput(text), Number.MAX_SAFE_INTEGER)
|
||||
if (title.length === 0) throw new Error('session-title-llm: title model produced no text')
|
||||
return title
|
||||
}
|
||||
/* jscpd:ignore-end */
|
||||
|
||||
/**
|
||||
@@ -32,9 +143,25 @@ export const Config: z<Config> = z.object({
|
||||
* @param config - required route, target, byte, token, and timeout policy.
|
||||
*/
|
||||
export function apply(ctx: Context, config: Config): void {
|
||||
registerSessionTitleLlmProvider(ctx, config, name, 'first-prompt', (messages) => {
|
||||
const first = messages[0]
|
||||
if (first === undefined) throw new Error('first-prompt title provider requires one human message')
|
||||
return [first]
|
||||
const resolved = resolveConfig(config)
|
||||
const titleProvider = SessionTitleProviderId(name)
|
||||
ctx.sessionTitle.register({
|
||||
id: titleProvider,
|
||||
automatic: 'first-prompt',
|
||||
async generate(request) {
|
||||
const first = request.messages[0]
|
||||
if (first === undefined) throw new Error('first-prompt title provider requires one human message')
|
||||
const response = await executeSessionTitleLlm(ctx, resolved, request, titleProvider, {
|
||||
system: systemPrompt(resolved),
|
||||
input: frameMessages([first]),
|
||||
messageSeqs: [first.seq],
|
||||
selectReasoningEffort,
|
||||
})
|
||||
return {
|
||||
title: titleFromResponse(response),
|
||||
messageSeqs: [first.seq],
|
||||
model: response.model,
|
||||
}
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
import { Context } from '@deepseek-ai/cordis'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import LlmRuntime, { createUserMessage, LlmAdapter } from '@deepseek-ai/dsh-llm'
|
||||
import type { GenerateOptions, StreamChunk } from '@deepseek-ai/dsh-llm'
|
||||
import { describe, expect, it, onTestFinished } from 'vitest'
|
||||
import LlmRuntime, { createUserMessage, LlmAdapter, ReasoningEffortId, ToolCallId } from '@deepseek-ai/dsh-llm'
|
||||
import type { GenerateOptions, LlmResolvedModelInfo, StreamChunk } from '@deepseek-ai/dsh-llm'
|
||||
import SessionStore, { Session, SessionId } from '@deepseek-ai/dsh-session'
|
||||
import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection'
|
||||
import { turnBoundaryProjectionDefinition } from '@deepseek-ai/dsh-agent-loop'
|
||||
@@ -11,13 +11,37 @@ import * as providerPlugin from '@deepseek-ai/dsh-session-title-first-prompt-llm
|
||||
class RecordingAdapter extends LlmAdapter {
|
||||
readonly requests: GenerateOptions[] = []
|
||||
|
||||
constructor(
|
||||
private readonly script: readonly StreamChunk[] = FIRST_TITLE_SCRIPT,
|
||||
private readonly reasoningEfforts?: readonly string[],
|
||||
) {
|
||||
super()
|
||||
}
|
||||
|
||||
override resolveModel(provider: string, model: string): Promise<LlmResolvedModelInfo> {
|
||||
const efforts = this.reasoningEfforts
|
||||
const floor = efforts?.[0]
|
||||
return Promise.resolve({
|
||||
provider,
|
||||
id: model,
|
||||
name: model,
|
||||
...efforts === undefined || floor === undefined
|
||||
? {}
|
||||
: { reasoning: { efforts: efforts.map(id => ({ id: ReasoningEffortId(id), name: id })) } },
|
||||
})
|
||||
}
|
||||
|
||||
override async * stream(options: GenerateOptions): AsyncIterable<StreamChunk> {
|
||||
this.requests.push(options)
|
||||
yield { type: 'text-delta', index: 0, text: 'First-message model title' }
|
||||
yield { type: 'finish', reason: { kind: 'stop' } }
|
||||
yield * this.script
|
||||
}
|
||||
}
|
||||
|
||||
const FIRST_TITLE_SCRIPT: StreamChunk[] = [
|
||||
{ type: 'text-delta', index: 0, text: 'First-message model title' },
|
||||
{ type: 'finish', reason: { kind: 'stop' } },
|
||||
]
|
||||
|
||||
const TITLE_CONFIG = { fallbackMaxWords: 5, fallbackMaxBytes: 40, maxTitleBytes: 80 } as const
|
||||
const LLM_CONFIG = {
|
||||
targetWords: 5,
|
||||
@@ -29,64 +53,217 @@ const LLM_CONFIG = {
|
||||
model: 'title-model',
|
||||
} as const
|
||||
|
||||
/** Exact first-prompt system instruction pinned here so a shared prompt cannot change it silently. */
|
||||
const SYSTEM = [
|
||||
'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 5 words in non-CJK languages or 10 CJK characters.',
|
||||
'If the messages give little to name, still return a short best-effort title, such as Greeting, instead of explaining.',
|
||||
].join('\n')
|
||||
|
||||
interface Harness {
|
||||
ctx: Context
|
||||
adapter: RecordingAdapter
|
||||
}
|
||||
|
||||
async function harness(
|
||||
script: readonly StreamChunk[] = FIRST_TITLE_SCRIPT,
|
||||
reasoningEfforts?: readonly string[],
|
||||
): Promise<Harness> {
|
||||
const ctx = new Context()
|
||||
onTestFinished(() => ctx.fiber.dispose())
|
||||
await ctx.plugin(LlmRuntime)
|
||||
await ctx.plugin(SessionStore)
|
||||
await ctx.plugin(SessionProjectionRegistry)
|
||||
ctx.sessionProjections.register(turnBoundaryProjectionDefinition)
|
||||
await ctx.plugin(SessionTitleService, TITLE_CONFIG)
|
||||
const adapter = new RecordingAdapter(script, reasoningEfforts)
|
||||
ctx.llm.registerAdapter(['title-route'], adapter)
|
||||
await ctx.plugin(providerPlugin, LLM_CONFIG)
|
||||
return { ctx, adapter }
|
||||
}
|
||||
|
||||
function appendHumanPrompt(session: Session, text: string) {
|
||||
return session.append('user/message', createUserMessage({
|
||||
content: [{ type: 'text', text }],
|
||||
source: { kind: 'user' },
|
||||
}), { surfaceOp: 'append' })
|
||||
}
|
||||
|
||||
async function settle(): Promise<void> {
|
||||
await new Promise(resolve => setTimeout(resolve, 0))
|
||||
}
|
||||
|
||||
function textOf(options: GenerateOptions | undefined): string {
|
||||
const content = options?.messages[0]?.content[0]
|
||||
if (content?.type !== 'text') throw new Error('expected a title input text block')
|
||||
return content.text
|
||||
}
|
||||
|
||||
describe('first-prompt LLM title provider', () => {
|
||||
it('registers itself and always selects only the first eligible human message, including refresh', async () => {
|
||||
const { ctx, adapter } = await harness()
|
||||
const session = ctx.sessions.create(SessionId('first-plugin'))
|
||||
session.append('turn/start', { turn: 1 })
|
||||
const first = appendHumanPrompt(session, 'first input')
|
||||
await settle()
|
||||
session.append('request/header', {
|
||||
header: { config: { provider: 'main', model: 'main-model' } }, reason: 'initial',
|
||||
})
|
||||
await settle()
|
||||
appendHumanPrompt(session, 'second input must be ignored')
|
||||
await settle()
|
||||
|
||||
await ctx.sessionTitle.refresh(session)
|
||||
|
||||
expect(adapter.requests).toHaveLength(2)
|
||||
for (const options of adapter.requests) {
|
||||
const text = textOf(options)
|
||||
expect(text).toBe(`Generate the session title from this JSON array of human messages:\n${JSON.stringify([
|
||||
{ seq: first.seq, text: 'first input' },
|
||||
])}`)
|
||||
expect(text).not.toContain('currentTitle')
|
||||
expect(options.system).toBe(SYSTEM)
|
||||
expect(options.system).not.toContain('return it exactly unchanged')
|
||||
}
|
||||
expect(ctx.sessionTitle.get(session)).toMatchObject({
|
||||
title: 'First-message model title',
|
||||
messageSeqs: [first.seq],
|
||||
source: { kind: 'provider', provider: providerPlugin.name, model: { provider: 'title-route', model: 'title-model' } },
|
||||
})
|
||||
})
|
||||
|
||||
it('frames the first message as data and logs the exact system and input', async () => {
|
||||
const { ctx, adapter } = await harness()
|
||||
const session = ctx.sessions.create(SessionId('first-logging'))
|
||||
const first = appendHumanPrompt(session, 'Room "quote" and 番茄')
|
||||
|
||||
await ctx.sessionTitle.refresh(session)
|
||||
|
||||
const options = adapter.requests[0]!
|
||||
const prompt = options.messages[0]?.content[0]
|
||||
expect(prompt?.type === 'text' && prompt.text).toBe(
|
||||
`Generate the session title from this JSON array of human messages:\n${JSON.stringify([
|
||||
{ seq: first.seq, text: 'Room "quote" and 番茄' },
|
||||
])}`,
|
||||
)
|
||||
expect(options.system).toBe(SYSTEM)
|
||||
expect(session.snapshotEvents().findLast(event => event.type === 'session/title-llm-request')?.data)
|
||||
.toEqual({
|
||||
titleProvider: providerPlugin.name,
|
||||
messageSeqs: [first.seq],
|
||||
route: { provider: 'title-route', model: 'title-model' },
|
||||
system: SYSTEM,
|
||||
messages: options.messages,
|
||||
maxTokens: 32,
|
||||
})
|
||||
})
|
||||
|
||||
it('selects the route least supported reasoning effort for its own model calls', async () => {
|
||||
const { ctx, adapter } = await harness(FIRST_TITLE_SCRIPT, ['low', 'high'])
|
||||
const session = ctx.sessions.create(SessionId('first-floor-effort'))
|
||||
appendHumanPrompt(session, 'first input')
|
||||
|
||||
await ctx.sessionTitle.refresh(session)
|
||||
|
||||
expect(adapter.requests[0]?.reasoningEffort).toBe(ReasoningEffortId('low'))
|
||||
})
|
||||
|
||||
it.each([
|
||||
[['**Continuing Previous', ' Session**\n\nThe only message is "continue".'], 'Continuing Previous Session'],
|
||||
[['\n *Greeting* \n'], 'Greeting'],
|
||||
[['**a** and **b**\nnote'], '**a** and **b**'],
|
||||
[['Use **bold** for emphasis'], 'Use **bold** for emphasis'],
|
||||
[['Fix *args* handling'], 'Fix *args* handling'],
|
||||
[['*args'], '*args'],
|
||||
[['****'], '****'],
|
||||
])('takes the title from the first non-empty output line %j', async (deltas, title) => {
|
||||
const { ctx } = await harness([
|
||||
{ type: 'block-start', index: 0, blockType: 'text' },
|
||||
...deltas.map((text): StreamChunk => ({ type: 'text-delta', index: 0, text })),
|
||||
{ type: 'finish', reason: { kind: 'stop' } },
|
||||
])
|
||||
const session = ctx.sessions.create(SessionId(`first-parse-${title}`))
|
||||
appendHumanPrompt(session, 'first input')
|
||||
|
||||
await ctx.sessionTitle.refresh(session)
|
||||
|
||||
expect(ctx.sessionTitle.get(session)?.title).toBe(title)
|
||||
})
|
||||
|
||||
it('rejects tool-calls and max-tokens termination even when the blocks contain only text', async () => {
|
||||
const toolCalls = await harness([
|
||||
{ type: 'block-start', index: 0, blockType: 'text' },
|
||||
{ type: 'text-delta', index: 0, text: 'Title from text' },
|
||||
{ type: 'finish', reason: { kind: 'tool-calls' } },
|
||||
])
|
||||
const toolCallsSession = toolCalls.ctx.sessions.create(SessionId('first-tool-calls-finish'))
|
||||
appendHumanPrompt(toolCallsSession, 'first input')
|
||||
await expect(toolCalls.ctx.sessionTitle.refresh(toolCallsSession)).rejects.toThrow(/output must contain text only/)
|
||||
|
||||
const maxTokens = await harness([
|
||||
{ type: 'block-start', index: 0, blockType: 'text' },
|
||||
{ type: 'text-delta', index: 0, text: 'Partial title' },
|
||||
{ type: 'finish', reason: { kind: 'max-tokens' } },
|
||||
])
|
||||
const maxTokensSession = maxTokens.ctx.sessions.create(SessionId('first-max-tokens-finish'))
|
||||
appendHumanPrompt(maxTokensSession, 'first input')
|
||||
await expect(maxTokens.ctx.sessionTitle.refresh(maxTokensSession)).rejects.toThrow(/reached maxOutputTokens/)
|
||||
})
|
||||
|
||||
it('rejects tool-call blocks and a successful response with no text', async () => {
|
||||
const tool = await harness([
|
||||
{ type: 'block-start', index: 0, blockType: 'tool-call' },
|
||||
{ type: 'tool-call-delta', index: 0, id: ToolCallId('title-tool'), name: 'unexpected', argumentsDelta: '{}' },
|
||||
{ type: 'finish', reason: { kind: 'stop' } },
|
||||
])
|
||||
const toolSession = tool.ctx.sessions.create(SessionId('first-tool'))
|
||||
appendHumanPrompt(toolSession, 'first input')
|
||||
await expect(tool.ctx.sessionTitle.refresh(toolSession)).rejects.toThrow(/output must contain text only/)
|
||||
|
||||
const reasoning = await harness([
|
||||
{ type: 'block-start', index: 0, blockType: 'reasoning' },
|
||||
{ type: 'reasoning-delta', index: 0, text: 'no final title' },
|
||||
{ type: 'finish', reason: { kind: 'stop' } },
|
||||
])
|
||||
const reasoningSession = reasoning.ctx.sessions.create(SessionId('first-reasoning'))
|
||||
appendHumanPrompt(reasoningSession, 'first input')
|
||||
await expect(reasoning.ctx.sessionTitle.refresh(reasoningSession)).rejects.toThrow(/produced no text/)
|
||||
})
|
||||
|
||||
it('requires positive target settings and rejects unknown configuration keys', () => {
|
||||
const ctx = new Context()
|
||||
onTestFinished(() => ctx.fiber.dispose())
|
||||
expect(() => { providerPlugin.apply(ctx, { ...LLM_CONFIG, targetWords: 0 }) })
|
||||
.toThrow(/targetWords.*positive integer/)
|
||||
expect(() => { providerPlugin.apply(ctx, { ...LLM_CONFIG, targetCjkCharacters: 1.5 }) })
|
||||
.toThrow(/targetCjkCharacters.*positive integer/)
|
||||
expect(() => { providerPlugin.apply(ctx, { ...LLM_CONFIG, extra: true } as never) })
|
||||
.toThrow(/unknown config key "extra"/)
|
||||
})
|
||||
|
||||
it('rejects an impossible empty provider request at its own boundary', async () => {
|
||||
const ctx = new Context()
|
||||
onTestFinished(() => ctx.fiber.dispose())
|
||||
await ctx.plugin(LlmRuntime)
|
||||
await ctx.plugin(SessionStore)
|
||||
await ctx.plugin(SessionProjectionRegistry)
|
||||
ctx.sessionProjections.register(turnBoundaryProjectionDefinition)
|
||||
await ctx.plugin(SessionTitleService, TITLE_CONFIG)
|
||||
// Delegate to the real registration while capturing it, to reach the guard
|
||||
// the service never produces.
|
||||
let registered: SessionTitleProvider | undefined
|
||||
vi.spyOn(ctx.sessionTitle, 'register').mockImplementation((provider) => {
|
||||
const register = ctx.sessionTitle.register.bind(ctx.sessionTitle)
|
||||
ctx.sessionTitle.register = (provider) => {
|
||||
registered = provider
|
||||
return async () => undefined
|
||||
})
|
||||
providerPlugin.apply(ctx, LLM_CONFIG)
|
||||
|
||||
return register(provider)
|
||||
}
|
||||
await ctx.plugin(providerPlugin, LLM_CONFIG)
|
||||
await expect(registered!.generate({
|
||||
session: Session.create(SessionId('empty-first-provider')),
|
||||
messages: [],
|
||||
signal: new AbortController().signal,
|
||||
})).rejects.toThrow(/requires one human message/)
|
||||
})
|
||||
|
||||
it('always selects only the first eligible human message, including explicit refresh', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(LlmRuntime)
|
||||
await ctx.plugin(SessionStore)
|
||||
await ctx.plugin(SessionProjectionRegistry)
|
||||
ctx.sessionProjections.register(turnBoundaryProjectionDefinition)
|
||||
await ctx.plugin(SessionTitleService, TITLE_CONFIG)
|
||||
const adapter = new RecordingAdapter()
|
||||
ctx.llm.registerAdapter(['title-route'], adapter)
|
||||
await ctx.plugin(providerPlugin, LLM_CONFIG)
|
||||
const session = ctx.sessions.create(SessionId('first-plugin'))
|
||||
session.append('turn/start', { turn: 1 })
|
||||
const first = session.append('user/message', createUserMessage({
|
||||
content: [{ type: 'text', text: 'first input' }], source: { kind: 'user' },
|
||||
}), { surfaceOp: 'append' })
|
||||
await settle()
|
||||
session.append('request/header', {
|
||||
header: { config: { provider: 'main', model: 'main-model' } }, reason: 'initial',
|
||||
})
|
||||
await settle()
|
||||
session.append('user/message', createUserMessage({
|
||||
content: [{ type: 'text', text: 'second input must be ignored' }], source: { kind: 'user' },
|
||||
}), { surfaceOp: 'append' })
|
||||
|
||||
await ctx.sessionTitle.refresh(session)
|
||||
|
||||
expect(adapter.requests).toHaveLength(2)
|
||||
for (const options of adapter.requests) {
|
||||
const content = options.messages[0]?.content[0]
|
||||
expect(content?.type === 'text' && content.text).toContain('first input')
|
||||
expect(content?.type === 'text' && content.text).not.toContain('second input must be ignored')
|
||||
}
|
||||
expect(ctx.sessionTitle.get(session)).toMatchObject({ messageSeqs: [first.seq] })
|
||||
})
|
||||
})
|
||||
@@ -6,6 +6,7 @@
|
||||
{ "path": "../../../vendor/cosmokit" },
|
||||
{ "path": "../../../vendor/cordis" },
|
||||
{ "path": "../../../vendor/schemastery" },
|
||||
{ "path": "../../util/values" },
|
||||
{ "path": "../../llm/llm" },
|
||||
{ "path": "../session-title" },
|
||||
{ "path": "../session-title-llm" }
|
||||
|
||||
@@ -3,41 +3,41 @@
|
||||
# After editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write packages/session/session-title-llm/README.md
|
||||
/:
|
||||
en: da5040a980850a60
|
||||
zh: 0a1c0f6afbfa6c9e
|
||||
en: 129d1901a10435e8
|
||||
zh: ac0143c8ea8b82ee
|
||||
/deepseek-ai-dsh-session-title-llm:
|
||||
en: 7ef6dbec913fd508
|
||||
zh: 87c2448be60fb88e
|
||||
/deepseek-ai-dsh-session-title-llm/summary:
|
||||
en: 741cd917e857bd99
|
||||
zh: 43c290b6baf222e6
|
||||
en: 6cc16d88b07242a4
|
||||
zh: 76208dee63c2fcda
|
||||
/deepseek-ai-dsh-session-title-llm/table-of-contents:
|
||||
en: d152484eb41ac6b4
|
||||
zh: 09388d293f9be9cb
|
||||
/deepseek-ai-dsh-session-title-llm/use-this-package:
|
||||
en: 6ea51316a841bce7
|
||||
zh: df455b74ac9d4a91
|
||||
/deepseek-ai-dsh-session-title-llm/use-this-package/registering-a-provider:
|
||||
en: cf61c021b1bdb52f
|
||||
zh: dac17588e4143815
|
||||
en: 1a4681db86d285e2
|
||||
zh: 7a95a65b85afdb52
|
||||
/deepseek-ai-dsh-session-title-llm/use-this-package/executing-a-provider-request:
|
||||
en: d50165ad16f078f4
|
||||
zh: b22c2bd7964c05a2
|
||||
/deepseek-ai-dsh-session-title-llm/use-this-package/route-and-failure-contract:
|
||||
en: ef4c7003eaa84cf6
|
||||
zh: 5a13dde678ea3e14
|
||||
en: 9b9251b3acc75bcf
|
||||
zh: a508ebd71ec9c227
|
||||
/deepseek-ai-dsh-session-title-llm/use-this-package/configuration:
|
||||
en: b06986867581393e
|
||||
zh: 9b4d0b1ac115f800
|
||||
en: b317338d2d5335c6
|
||||
zh: b21543c2f97c7694
|
||||
/deepseek-ai-dsh-session-title-llm/understand-the-implementation:
|
||||
en: aa0fd15305df90bb
|
||||
zh: d28e3a28bb324167
|
||||
/deepseek-ai-dsh-session-title-llm/understand-the-implementation/design-concept:
|
||||
en: be8c34c44b1a8a90
|
||||
zh: 2a308143c209b5d2
|
||||
en: c7bb70f9f785bfca
|
||||
zh: ad0b375fb2de6dfa
|
||||
/deepseek-ai-dsh-session-title-llm/understand-the-implementation/source-map:
|
||||
en: 0fdc928704abdd3a
|
||||
zh: bd4c0a7dd47578cf
|
||||
en: ee1ac7a50e65d430
|
||||
zh: 2c97dd5cc71e8f22
|
||||
/deepseek-ai-dsh-session-title-llm/understand-the-implementation/request-flow:
|
||||
en: 0a6d1cd67188ab21
|
||||
zh: 6259ae486230e88d
|
||||
en: b3f23a8c2c8b38e3
|
||||
zh: de612ea07796fe9d
|
||||
/deepseek-ai-dsh-session-title-llm/further-exploration:
|
||||
en: d9844c0fc3e16c5c
|
||||
zh: 1e0c0b4b5d566972
|
||||
@@ -48,17 +48,17 @@
|
||||
en: 7dcc6f74474f78e7
|
||||
zh: 64cffa0b4d94c69d
|
||||
/deepseek-ai-dsh-session-title-llm/model-experience/auxiliary-title-request/what-the-model-sees:
|
||||
en: 780cf5c74d231b24
|
||||
zh: 4cf0e39c6a8475ad
|
||||
en: 7295cce9bf6e7008
|
||||
zh: 40d63f0f772f15ad
|
||||
/deepseek-ai-dsh-session-title-llm/model-experience/auxiliary-title-request/token-effect:
|
||||
en: 43f7973c421d4155
|
||||
zh: c63ffe0858e5b71e
|
||||
en: 37b7841dcd3d25e9
|
||||
zh: 1ad3050728315b49
|
||||
/deepseek-ai-dsh-session-title-llm/model-experience/auxiliary-title-request/kv-cache-effect:
|
||||
en: 5d3b5b7c8d8b4b67
|
||||
zh: 1b231d96bf6ca545
|
||||
en: ebf780cb08ebf13d
|
||||
zh: 0b92c06fb1ca0640
|
||||
/deepseek-ai-dsh-session-title-llm/known-limitations-and-deferred-work:
|
||||
en: eab2e1857d98680a
|
||||
zh: 02cd4a5d5f565e1b
|
||||
en: 892839ae7a4a0d99
|
||||
zh: a10c8cfdc300c178
|
||||
/deepseek-ai-dsh-session-title-llm/known-limitations-and-deferred-work/dev-note:
|
||||
en: e67f633dc6fe7773
|
||||
zh: 3bb0c20c93f16f98
|
||||
@@ -1,5 +1,5 @@
|
||||
---
|
||||
description: "Shared model-backed title generation policy for users and maintainers configuring title providers or debugging auxiliary LLM requests."
|
||||
description: "Shared model-execution policy for session-title providers: route, bounds, cancellation, request logging, and stream assembly."
|
||||
kind: "package-library"
|
||||
---
|
||||
|
||||
@@ -9,7 +9,7 @@ English | [中文](README.zh.md)
|
||||
|
||||
## Summary
|
||||
|
||||
`dsh-session-title-llm` generates concise session titles from selected human messages with a consistent model request policy. Callers choose which messages contribute to each revision and may either supply a provider and model route together or use the route recorded for the current session. Required limits cap the framed input, generated output, and end-to-end duration, while caller cancellation remains effective throughout streaming. Invalid, empty, late, tool-call, or otherwise non-text results are rejected before they can replace a title.
|
||||
`dsh-session-title-llm` executes one prepared auxiliary title request under consistent execution controls. Each provider supplies its own system instruction, user input, source-message attribution, reasoning selection, and output interpretation. This package resolves the route, caps the input, output, and end-to-end duration, keeps caller cancellation effective throughout streaming, records the exact model-visible request, and assembles the stream. Invalid or late operational results reject before a title can be replaced.
|
||||
|
||||
## Table of Contents
|
||||
|
||||
@@ -25,27 +25,27 @@ English | [中文](README.zh.md)
|
||||
<a id="use-this-package"></a>
|
||||
## Use this package
|
||||
|
||||
As a deployment, configure this policy through the [first-prompt](../session-title-first-prompt-llm/README.md) or [all-prompts](../../experimental/session-title-all-prompts-llm/README.md) provider plugin. As a provider author, register through the shared helper instead of hand-rolling generation.
|
||||
As a deployment, configure this policy through the [first-prompt](../session-title-first-prompt-llm/README.md) or [all-prompts](../../experimental/session-title-all-prompts-llm/README.md) provider plugin. As a provider author, build your own prepared input and execute it through the shared function.
|
||||
|
||||
### Registering a provider
|
||||
### Executing a provider request
|
||||
|
||||
A provider plugin calls `registerSessionTitleLlmProvider(ctx, config, id, automatic, selectMessages)`; the helper validates the shared config, registers the provider on `ctx.sessionTitle`, and runs every generation through the shared policy. The two shipped plugins register the `first-prompt` and `all-prompts` cadences with their message selectors, and a second registration on the service throws.
|
||||
A provider plugin registers directly on `ctx.sessionTitle` and, inside its `generate(request)`, passes `{ system, input, messageSeqs, selectReasoningEffort }` to `executeSessionTitleLlm`. The provider owns the system instruction, input framing, its reasoning selection, and output interpretation; the function owns route preparation, bounds, cancellation, request logging, and stream assembly. A second registration on the service throws.
|
||||
|
||||
Every request carries `currentTitle`, the latest accepted title snapshot the service captured for the call, including an accepted fallback. A provider decides whether to use it: the all-prompts provider anchors only a provider-sourced title, including one inherited from a different provider, and treats fallback, user, and absent titles as initial generation. The first-prompt provider ignores `currentTitle`.
|
||||
|
||||
### 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`, so an explicit refresh before any route exists needs overrides. The helper measures the final JSON-framed user prompt against `maxInputBytes` before logging or dispatch instead of truncating it, and rechecks timeout and caller cancellation 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 reject; the session-title service decides whether that rejection is an automatic warning or an explicit caller failure. The accepted title is the first non-empty line of the model's text output, with one emphasis pair removed when it wraps that whole line, so commentary a model writes after the title cannot become the title. The title request uses `maxOutputTokens` independently of the conversation's cap. Its route must have a registered adapter so preparation can resolve the request before it is recorded.
|
||||
`provider` and `model` overrides are optional but must be supplied together as non-empty strings. Without that pair, the function uses the exact provider/model route captured from the current session's logged `request/header`, so an explicit refresh before any route exists needs overrides. It measures the final prepared user input against `maxInputBytes` before logging or dispatch instead of truncating it, and rechecks timeout and caller cancellation while consuming the stream and after it completes, so a late successful result cannot be accepted even if an interceptor or adapter ignores abort. It records the exact request in `session/title-llm-request` and returns the assembled content blocks plus the terminal finish and the used model identity. Operational errors, aborts, and unsupported finish reasons reject; `stop`, `tool-calls`, and `max-tokens` return to the provider for its own acceptance decision. The title request uses `maxOutputTokens` independently of the conversation's cap. Its route must have a registered adapter so preparation can resolve the request before it is recorded.
|
||||
|
||||
### Configuration
|
||||
|
||||
<a id="configuration"></a>
|
||||
|
||||
Every field is required except the paired route override; there are no library defaults.
|
||||
Every execution field is required except the paired route override; there are no library defaults. Each provider adds its own title-length target settings, which that provider's README documents.
|
||||
|
||||
| Key | Default | Meaning |
|
||||
|---|---|---|
|
||||
| `targetWords` | required | Target word count for non-CJK titles |
|
||||
| `targetCjkCharacters` | required | Target character count for Chinese, Japanese, or Korean titles |
|
||||
| `maxInputBytes` | required | UTF-8 byte ceiling for the final JSON-framed user prompt |
|
||||
| `maxInputBytes` | required | UTF-8 byte ceiling for the prepared user input |
|
||||
| `maxOutputTokens` | required | Title output-token cap, independent of conversation requests |
|
||||
| `timeoutMs` | required | End-to-end deadline within the runtime timer limit |
|
||||
| `provider`, `model` | optional | Explicit route; both or neither |
|
||||
@@ -62,17 +62,17 @@ This section explains the generation path; the observable behavior is fully cove
|
||||
|
||||
### Design concept
|
||||
|
||||
One shared policy so provider plugins cannot drift: config validation, route resolution, prompt framing, budget enforcement, cancellation, and output validation all live here, parameterized only by the provider's cadence and message selector.
|
||||
One small shared execution policy so operational behavior cannot drift: execution-control validation, route resolution, budget enforcement, cancellation, request logging, and stream assembly live here. Prompt wording, title-length targets, reasoning selection, and output interpretation stay with each provider.
|
||||
|
||||
### Source map
|
||||
|
||||
| File | Role |
|
||||
|---|---|
|
||||
| [`src/index.ts`](src/index.ts) | Config schema and validation, provider registration helper, request framing, dispatch, and output validation |
|
||||
| [`src/index.ts`](src/index.ts) | Config schema and validation, route resolution, request bounds, dispatch, logging, and stream assembly |
|
||||
|
||||
### Request flow
|
||||
|
||||
Each revision frames the selected messages as JSON and checks `maxInputBytes`. The title configuration function selects the first effort from the route's least-to-greatest list during `ctx.llm.prepareCall()`. The helper records the exact input, output cap, and resolved effort in `session/title-llm-request`, then dispatches through the same captured adapter generation under the shared deadline. `purpose: 'session-title'` supplies attribution only. The request has no agent-loop identity and does not enter conversation history. Generation failures preserve the request record.
|
||||
The caller builds the prepared input and frames it; the function checks the complete input against `maxInputBytes`. The provider's reasoning selector chooses at most one effort during `ctx.llm.prepareCall()`. The function records the exact input, output cap, and resolved effort in `session/title-llm-request`, then dispatches through the same captured adapter generation under the shared deadline. `purpose: 'session-title'` supplies attribution only. The request has no agent-loop identity and does not enter conversation history. Generation failures preserve the request record.
|
||||
|
||||
</details>
|
||||
|
||||
@@ -98,15 +98,15 @@ Read these pages when the generation policy is not enough. They move from the se
|
||||
|
||||
#### 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 and a short title instead of an explanation when the messages give little to name. Its one user message contains a JSON array of the exact selected human messages and their seqs.
|
||||
The title model receives the provider-owned system instruction and one user message containing the provider-owned input. The executor records and dispatches those exact values without reconstruction; the framing and the output instruction belong to each provider, which the [first-prompt](../session-title-first-prompt-llm/README.md) and [all-prompts](../../experimental/session-title-all-prompts-llm/README.md) READMEs document.
|
||||
|
||||
#### 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. Title calls disable thinking on DeepSeek routes and use the model's lowest supported level on pi-ai routes; a model that cannot stop reasoning still spends part of `maxOutputTokens` on it. The main conversation retains its configured thinking mode.
|
||||
The auxiliary request consumes tokens according to the provider-prepared input size and `maxOutputTokens`. It is separate from the main agent request and does not add title text or framing to agent history. The executor applies the provider's selected reasoning effort, or the route's normal default when the selector returns `undefined`; a model that still reasons spends part of `maxOutputTokens` on it. 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.
|
||||
No main-request invalidation. Auxiliary cache reuse is provider-specific: this executor dispatches the provider's exact system prompt and input, so reuse depends on how each provider frames successive revisions.
|
||||
|
||||
## Known Limitations and Deferred Work
|
||||
|
||||
@@ -115,9 +115,9 @@ No main-request invalidation. Auxiliary cache reuse is provider-specific; the fi
|
||||
|
||||
These limits define the accepted generation shapes. They are current package constraints.
|
||||
|
||||
- **Text output only** — the helper accepts text output and rejects tool calls; structured-output adapters and provider-specific prompt variants are not exposed.
|
||||
- **Whole-prompt byte ceiling** — it enforces a byte ceiling for the whole framed user prompt rather than clipping individual messages or applying a retention policy.
|
||||
- **Minimum reasoning is capability-dependent** — title policy selects the first advertised effort. Routes without selectable reasoning leave effort unspecified; the least selectable effort does not guarantee zero reasoning tokens or a complete title within the output cap.
|
||||
- **Provider-interpreted output** — the executor returns assembled content blocks and the terminal finish and does not accept or reject them; each shipped provider's README owns its acceptance policy.
|
||||
- **Whole-input byte ceiling** — it rejects prepared input over `maxInputBytes` rather than truncating it.
|
||||
- **Reasoning is provider-selected** — the executor applies the effort the provider returns and never chooses one; when the selector returns `undefined`, the route's normal default applies.
|
||||
|
||||
<a id="dev-note"></a>
|
||||
### Dev Note
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
---
|
||||
description: "面向用户与维护者的共享模型标题生成策略说明,用于配置标题提供方或排查辅助 LLM 请求。"
|
||||
description: "面向会话标题提供方的共享模型执行策略:路由、上限、取消、请求记录与流式组装。"
|
||||
kind: "package-library"
|
||||
---
|
||||
|
||||
@@ -9,7 +9,7 @@ kind: "package-library"
|
||||
|
||||
## 概述
|
||||
|
||||
`dsh-session-title-llm` 使用一致的模型请求策略,根据选中的用户消息生成简洁的会话标题。调用方选择每次修订包含哪些消息,以及成对提供 `provider`/`model` 路由,还是使用当前会话记录的路由。必填上限约束封装后的输入、生成输出与端到端时长,调用方取消在整个流式处理期间持续生效。无效、空、迟到、包含工具调用或其他非纯文本的结果会在替换标题前被拒绝。
|
||||
`dsh-session-title-llm` 在一致且固定的执行控制下执行一次准备好的辅助标题请求。每个提供方提供自己的系统指令、用户输入、源消息归因、推理选择与输出解释。本包负责解析路由、限制输入、输出与端到端时长,在整个流式处理期间保持调用方取消有效,记录模型可见的确切请求,并组装流。无效或迟到的操作结果在替换标题前被拒绝。
|
||||
|
||||
## 目录
|
||||
|
||||
@@ -25,27 +25,27 @@ kind: "package-library"
|
||||
<a id="use-this-package"></a>
|
||||
## 使用本包
|
||||
|
||||
作为部署方,通过[首消息](../session-title-first-prompt-llm/README.zh.md)或[全消息](../../experimental/session-title-all-prompts-llm/README.zh.md)提供方插件配置此策略。作为提供方作者,通过共享辅助函数注册,而不是手写生成逻辑。
|
||||
作为部署方,通过[首消息](../session-title-first-prompt-llm/README.zh.md)或[全消息](../../experimental/session-title-all-prompts-llm/README.zh.md)提供方插件配置此策略。作为提供方作者,构建自己的准备输入,并通过共享函数执行。
|
||||
|
||||
### 注册提供方
|
||||
### 执行提供方请求
|
||||
|
||||
提供方插件调用 `registerSessionTitleLlmProvider(ctx, config, id, automatic, selectMessages)`;辅助函数验证共享配置、在 `ctx.sessionTitle` 上注册提供方,并让每次生成都经过共享策略。两个随附插件以各自的 `first-prompt` 与 `all-prompts` 节奏和消息选择器注册;服务上的第二次注册会立即抛出。
|
||||
提供方插件直接注册到 `ctx.sessionTitle`,并在其 `generate(request)` 中把 `{ system, input, messageSeqs, selectReasoningEffort }` 传给 `executeSessionTitleLlm`。提供方负责系统指令、输入封装、自己的推理选择与输出解释;该函数负责路由准备、上限、取消、请求记录与流式组装。服务上的第二次注册会立即抛出。
|
||||
|
||||
每个请求都带有 `currentTitle`,即服务在调用时捕获的最新已接纳标题快照,包括已接纳的回退标题。提供方自行决定是否使用它:全消息提供方只锚定来源为提供方的标题(包括继承自不同提供方的标题),并把回退、用户与不存在的标题视为初始生成。首消息提供方忽略 `currentTitle`。
|
||||
|
||||
### 路由与失败约定
|
||||
|
||||
`provider` 与 `model` 覆盖项都是可选的,但必须同时作为非空字符串提供。如果没有这一对取值,辅助函数使用当前会话已记录 `request/header` 中捕获的确切提供方/模型路由,因此在任何路由出现前显式刷新时必须提供覆盖项。辅助函数在记录或分发前,依据 `maxInputBytes` 检查最终 JSON 封装用户提示词的大小,而不是将其截断,并在消费流期间与完成后重新检查超时与调用方取消,因此即使拦截器或适配器忽略 abort,也不能接受迟到的成功结果。格式错误或空输出、工具调用与非 stop 结束原因都会拒绝;会话标题服务决定该拒绝属于自动警告还是显式调用方失败。被接受的标题取模型文本输出的第一个非空行;当整行被一对强调标记包裹时去掉这对标记,因此模型写在标题之后的说明不会成为标题。标题请求独立使用 `maxOutputTokens`,不继承对话请求的上限。其路由必须注册适配器,以便在记录前完成请求准备。
|
||||
`provider` 与 `model` 覆盖项都是可选的,但必须同时作为非空字符串提供。如果没有这一对取值,函数使用当前会话已记录 `request/header` 中捕获的确切提供方/模型路由,因此在任何路由出现前显式刷新时必须提供覆盖项。函数在记录或分发前,依据 `maxInputBytes` 检查准备好的最终用户输入的大小,而不是将其截断,并在消费流期间与完成后重新检查超时与调用方取消,因此即使拦截器或适配器忽略 abort,也不能接受迟到的成功结果。函数把确切请求记录到 `session/title-llm-request`,并返回组装后的内容块、终止结束原因与所用模型身份。操作性错误、中止与不支持的结束原因都会拒绝;`stop`、`tool-calls` 与 `max-tokens` 会返回提供方,由其自行决定是否接受。标题请求独立使用 `maxOutputTokens`,不继承对话请求的上限。其路由必须注册适配器,以便在记录前完成请求准备。
|
||||
|
||||
### 配置
|
||||
|
||||
<a id="configuration"></a>
|
||||
|
||||
除成对的路由覆盖项外,每个字段都必填;库不提供默认值。
|
||||
除成对的路由覆盖项外,每个执行字段都必填;库不提供默认值。每个提供方各自添加标题长度目标设置,并由该提供方的 README 记录。
|
||||
|
||||
| 键 | 默认值 | 含义 |
|
||||
|---|---|---|
|
||||
| `targetWords` | 必填 | 非 CJK 标题的目标词数 |
|
||||
| `targetCjkCharacters` | 必填 | 中文、日文或韩文标题的目标字符数 |
|
||||
| `maxInputBytes` | 必填 | 最终 JSON 封装用户提示词的 UTF-8 字节上限 |
|
||||
| `maxInputBytes` | 必填 | 准备好的用户输入的 UTF-8 字节上限 |
|
||||
| `maxOutputTokens` | 必填 | 标题输出 token 上限,独立于对话请求 |
|
||||
| `timeoutMs` | 必填 | 运行时定时器限制内的端到端时限 |
|
||||
| `provider`, `model` | 可选 | 显式路由;二者同时提供或同时省略 |
|
||||
@@ -62,17 +62,17 @@ kind: "package-library"
|
||||
|
||||
### 设计理念
|
||||
|
||||
一份共享策略让提供方插件无法漂移:配置校验、路由解析、提示词封装、预算执行、取消与输出校验都在这里,只以提供方的节奏与消息选择器为参数。
|
||||
一份小型共享执行策略,使操作行为无法漂移:执行控制校验、路由解析、预算执行、取消、请求记录与流式组装都在这里。提示词措辞、标题长度目标、推理选择与输出解释仍归各提供方所有。
|
||||
|
||||
### 源码地图
|
||||
|
||||
| 文件 | 职责 |
|
||||
|---|---|
|
||||
| [`src/index.ts`](src/index.ts) | 配置 schema 与校验、提供方注册辅助、请求封装、分发与输出校验 |
|
||||
| [`src/index.ts`](src/index.ts) | 配置 schema 与校验、路由解析、请求上限、分发、记录与流式组装 |
|
||||
|
||||
### 请求流程
|
||||
|
||||
每次修订把选中的消息封装为 JSON,并检查 `maxInputBytes`。标题配置函数在 `ctx.llm.prepareCall()` 期间,从路由按强度递增排列的列表中选择首个强度。辅助函数把确切输入、输出上限与解析后的强度记录到 `session/title-llm-request`,然后在共享截止时间内通过同一捕获的适配器代次分发。`purpose: 'session-title'` 仅提供归属标注。请求不带 agent loop 身份,也不进入对话历史。生成失败仍保留请求记录。
|
||||
调用方构建准备好的输入并进行封装;函数依据 `maxInputBytes` 检查完整输入。提供方的推理选择器在 `ctx.llm.prepareCall()` 期间最多选择一个强度。函数把确切输入、输出上限与解析后的强度记录到 `session/title-llm-request`,然后在共享截止时间内通过同一捕获的适配器代次分发。`purpose: 'session-title'` 仅提供归属标注。请求不带 agent loop 身份,也不进入对话历史。生成失败仍保留请求记录。
|
||||
|
||||
</details>
|
||||
|
||||
@@ -98,15 +98,15 @@ kind: "package-library"
|
||||
|
||||
#### 模型看到什么
|
||||
|
||||
标题模型会收到固定系统指令,要求以输入语言返回一个简洁且无装饰的标题;该指令包含所配置的词数与 CJK 字符数目标,并在消息内容很少时要求给出简短标题而不是解释。它唯一的用户消息包含一个 JSON 数组,其中是精确选中的用户消息及其 seq。
|
||||
标题模型会收到由提供方拥有的系统指令,以及一条包含提供方所拥有输入的用户消息。执行器记录并分发这些确切取值,不做重建;封装方式与输出指令都归提供方所有,并由[首消息](../session-title-first-prompt-llm/README.zh.md)与[全消息](../../experimental/session-title-all-prompts-llm/README.zh.md) README 记录。
|
||||
|
||||
#### Token 影响
|
||||
|
||||
辅助请求根据所选输入大小与 `maxOutputTokens` 消耗 token。它与主 agent 请求相互独立,不会向 agent 历史增加标题文本或封装内容。标题调用在 DeepSeek 路由上关闭思考,在 pi-ai 路由上使用模型支持的最低等级;无法停止推理的模型仍会把部分 `maxOutputTokens` 用于推理。主对话保留自身配置的思考模式。
|
||||
辅助请求根据提供方准备好的输入大小与 `maxOutputTokens` 消耗 token。它与主 agent 请求相互独立,不会向 agent 历史增加标题文本或封装内容。执行器应用提供方选定的推理强度;选择器返回 `undefined` 时使用路由的正常默认值。仍在推理的模型会把部分 `maxOutputTokens` 用于推理。主对话保留自身配置的思考模式。
|
||||
|
||||
#### KV Cache 影响
|
||||
|
||||
不会使主请求的 KV Cache 失效。辅助缓存复用由提供方决定;固定指令可复用,而 JSON 消息数组会随每次修订变化。
|
||||
不会使主请求的 KV Cache 失效。辅助缓存复用由提供方决定:此执行器分发提供方确切的系统提示词与输入,因此复用取决于各提供方对各次修订的封装方式。
|
||||
|
||||
## 已知限制与延期工作
|
||||
|
||||
@@ -115,9 +115,9 @@ kind: "package-library"
|
||||
|
||||
这些限制定义被接受的生成形态。它们是当前包约束。
|
||||
|
||||
- **仅文本输出**——辅助函数只接受文本输出并拒绝工具调用;不公开结构化输出适配器或提供方专用提示词变体。
|
||||
- **整体提示词字节上限**——它对整个封装用户提示词强制执行字节上限,而不是剪裁单条消息或应用保留策略。
|
||||
- **最低推理依赖能力声明**——标题策略选择首个公布的强度。没有可选推理控制的路由不指定强度;最低可选强度不保证零推理 token,也不保证在输出上限内生成完整标题。
|
||||
- **输出由提供方解释**——执行器返回组装后的内容块与终止结束原因,不决定接受或拒绝;各随附提供方的 README 记录其接纳策略。
|
||||
- **整体输入字节上限**——它对超过 `maxInputBytes` 的准备输入直接拒绝,而不是将其截断。
|
||||
- **推理由提供方选择**——执行器只应用提供方返回的强度,绝不自行选择;选择器返回 `undefined` 时使用路由的正常默认值。
|
||||
|
||||
<a id="dev-note"></a>
|
||||
### 开发备注
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
/**
|
||||
* Shared route, framing, timeout, assembly, and validation policy for
|
||||
* model-backed session-title providers.
|
||||
* Shared execution policy for model-backed session-title providers: route
|
||||
* preparation, input/token/time bounds, cancellation, exact auxiliary request
|
||||
* logging, and stream assembly. Title wording, length targets, reasoning
|
||||
* selection, and output interpretation belong to each provider.
|
||||
* @module @deepseek-ai/dsh-session-title-llm
|
||||
*/
|
||||
|
||||
@@ -14,20 +16,21 @@ declare module '@deepseek-ai/dsh-llm' {
|
||||
}
|
||||
}
|
||||
|
||||
import type { FinishReason, GenerateOptions, Message, ReasoningEffortId } from '@deepseek-ai/dsh-llm'
|
||||
import { deadline, MAX_TIMER_DELAY_MS } from '@deepseek-ai/dsh-timeout'
|
||||
import { deepFreeze } from '@deepseek-ai/dsh-util-values'
|
||||
import type { SessionSeq } from '@deepseek-ai/dsh-session'
|
||||
import {
|
||||
normalizeSessionTitle,
|
||||
SessionTitleProviderId,
|
||||
} from '@deepseek-ai/dsh-session-title'
|
||||
import type {
|
||||
SessionTitleAutomaticMode,
|
||||
ContentBlock,
|
||||
FinishReason,
|
||||
GenerateOptions,
|
||||
LlmResolvedModelInfo,
|
||||
Message,
|
||||
ReasoningEffortId,
|
||||
} from '@deepseek-ai/dsh-llm'
|
||||
import { deadline, MAX_TIMER_DELAY_MS } from '@deepseek-ai/dsh-timeout'
|
||||
import { assertNever, deepFreeze } from '@deepseek-ai/dsh-util-values'
|
||||
import type { SessionSeq } from '@deepseek-ai/dsh-session'
|
||||
import { SessionTitleProviderId } from '@deepseek-ai/dsh-session-title'
|
||||
import type {
|
||||
SessionTitleModelIdentity,
|
||||
SessionTitleProviderRequest,
|
||||
SessionTitleProviderResult,
|
||||
SessionTitleUserMessage,
|
||||
} from '@deepseek-ai/dsh-session-title'
|
||||
|
||||
/** Exact model-visible request recorded before one auxiliary title dispatch. */
|
||||
@@ -58,13 +61,9 @@ declare module '@deepseek-ai/dsh-session/types' {
|
||||
/** 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. */
|
||||
/** Required execution controls for one model-backed title request. */
|
||||
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. */
|
||||
/** Maximum UTF-8 bytes in the provider-prepared user input. */
|
||||
readonly maxInputBytes: number
|
||||
/** Output-token cap for the title request, independent of conversation requests. */
|
||||
readonly maxOutputTokens: number
|
||||
@@ -76,13 +75,11 @@ export interface SessionTitleLlmConfig {
|
||||
readonly model?: string
|
||||
}
|
||||
|
||||
/** Validated immutable model-provider policy. */
|
||||
/** Validated immutable execution controls. */
|
||||
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(),
|
||||
@@ -93,10 +90,8 @@ export const SessionTitleLlmConfigFields = {
|
||||
/** Shared Loader schema with no library defaults. */
|
||||
export const SessionTitleLlmConfigSchema: z<SessionTitleLlmConfig> = z.object(SessionTitleLlmConfigFields)
|
||||
|
||||
/** Complete configuration key set for direct construction validation. */
|
||||
/** Complete execution-control key set for direct construction validation. */
|
||||
const CONFIG_KEYS: ReadonlySet<string> = new Set([
|
||||
'targetWords',
|
||||
'targetCjkCharacters',
|
||||
'maxInputBytes',
|
||||
'maxOutputTokens',
|
||||
'timeoutMs',
|
||||
@@ -112,9 +107,9 @@ function assertPositiveInteger(name: string, value: number): void {
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate and detach required model-provider configuration.
|
||||
* @param config - untrusted plugin configuration.
|
||||
* @returns immutable policy with optional route absence preserved.
|
||||
* Validate and detach required execution controls.
|
||||
* @param config - untrusted execution configuration.
|
||||
* @returns immutable controls with optional route absence preserved.
|
||||
*/
|
||||
export function resolveSessionTitleLlmConfig(
|
||||
config: SessionTitleLlmConfig,
|
||||
@@ -127,8 +122,6 @@ export function resolveSessionTitleLlmConfig(
|
||||
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)
|
||||
@@ -148,35 +141,37 @@ export function resolveSessionTitleLlmConfig(
|
||||
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 id recorded with generated titles.
|
||||
* @param automatic - provider-owned automatic generation cadence.
|
||||
* @param selectMessages - exact source-message selection for one revision.
|
||||
* Provider-prepared model input, source-message attribution, and the
|
||||
* provider-owned reasoning selector forwarded to `ctx.llm.prepareCall`.
|
||||
*/
|
||||
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)
|
||||
},
|
||||
})
|
||||
export interface SessionTitleLlmPreparedRequest {
|
||||
/** Exact system prompt supplied by the provider. */
|
||||
readonly system: string
|
||||
/** Exact user message text supplied by the provider, including any framing. */
|
||||
readonly input: string
|
||||
/** Exact human `user/message` seqs represented by `input`, in log order. */
|
||||
readonly messageSeqs: readonly SessionSeq[]
|
||||
/**
|
||||
* Provider-owned reasoning selection for the captured route. The execution
|
||||
* module combines only the returned effort with its fixed output-token cap.
|
||||
* @param model - detached, deeply frozen metadata from the captured adapter generation.
|
||||
* @returns a supported effort id, or `undefined` to use the route's normal default.
|
||||
*/
|
||||
readonly selectReasoningEffort: (model: Readonly<LlmResolvedModelInfo>) => ReasoningEffortId | undefined
|
||||
}
|
||||
|
||||
/** Terminal finish kinds forwarded to a provider for its own interpretation. */
|
||||
export type SessionTitleLlmFinish = Extract<FinishReason, { kind: 'stop' | 'tool-calls' | 'max-tokens' }>
|
||||
|
||||
/** Assembled auxiliary model response for one provider to interpret. */
|
||||
export interface SessionTitleLlmResponse {
|
||||
/** Assembled content blocks in stream order; the provider rejects unwanted block types. */
|
||||
readonly blocks: readonly ContentBlock[]
|
||||
/** Terminal finish reason forwarded without title-output interpretation. */
|
||||
readonly finish: SessionTitleLlmFinish
|
||||
/** Exact auxiliary model route that produced the response. */
|
||||
readonly model: SessionTitleModelIdentity
|
||||
}
|
||||
|
||||
/** Resolve the explicit pair or the exact route captured from `request/header`. */
|
||||
@@ -193,114 +188,87 @@ function resolveRoute(
|
||||
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.`,
|
||||
'If the messages give little to name, still return a short best-effort title, such as Greeting, instead of explaining.',
|
||||
].join('\n')
|
||||
}
|
||||
|
||||
/** Asterisk emphasis wrapping a whole line, such as `**Title**`. */
|
||||
const EMPHASIS_WRAPPER = /^(?<marker>\*{1,3})(?<inner>\S(?:.*\S)?)\k<marker>$/u
|
||||
|
||||
/**
|
||||
* Take the title from model output: the first non-empty line, without
|
||||
* asterisk emphasis that wraps that whole line. Models that disregard the
|
||||
* one-line instruction put the title first and commentary after it. A line
|
||||
* that is entirely `*` emphasis loses the marker pair even when the model
|
||||
* meant it literally; the inner-marker check keeps emphasis inside a longer
|
||||
* line, and the system instruction forbids Markdown.
|
||||
* Classify one terminal finish reason. Operational failures throw; every other
|
||||
* terminal kind is returned for the provider to accept or reject.
|
||||
* @param finish - terminal finish reason from the assembled stream.
|
||||
* @returns the provider-facing finish reason.
|
||||
* @throws {Error} on a provider or caller abort, or an impossible finish variant.
|
||||
*/
|
||||
function titleFromOutput(text: string): string {
|
||||
const line = text.split(/\r?\n/u).map(item => item.trim()).find(item => item.length > 0) ?? ''
|
||||
const groups = EMPHASIS_WRAPPER.exec(line)?.groups
|
||||
const marker = groups?.['marker']
|
||||
const inner = groups?.['inner']
|
||||
return marker === undefined || inner === undefined || inner.includes(marker) ? line : inner
|
||||
}
|
||||
|
||||
/** 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 {
|
||||
function terminalFinish(finish: FinishReason): SessionTitleLlmFinish {
|
||||
switch (finish.kind) {
|
||||
case 'stop':
|
||||
return undefined
|
||||
case 'tool-calls':
|
||||
case 'max-tokens':
|
||||
return finish
|
||||
case 'error':
|
||||
case 'aborted': {
|
||||
const error = new Error(finish.failure.message) as Error & { code?: string }
|
||||
error.code = finish.failure.code
|
||||
return error
|
||||
throw 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)}"`)
|
||||
/* v8 ignore next -- closed FinishReason union exhaustiveness guard */
|
||||
default: return assertNever(finish, 'FinishReason')
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate one title through the shared auxiliary LLM call.
|
||||
* Execute one prepared auxiliary title request.
|
||||
*
|
||||
* The provider owns the system prompt, user input, reasoning selection, and
|
||||
* output interpretation. This function owns route preparation, the final input
|
||||
* byte limit, the output-token cap, the end-to-end deadline, cancellation, the
|
||||
* exact `session/title-llm-request` record, and stream assembly.
|
||||
* @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 config - validated execution controls.
|
||||
* @param request - service-owned session, route, message snapshot, current title, and cancellation.
|
||||
* @param titleProvider - registered title-provider identity recorded with the request.
|
||||
* @returns normalized non-empty title, exact source seqs, and used model route.
|
||||
* @param prepared - provider system prompt, input, source-message seqs, and reasoning selector.
|
||||
* @returns assembled response blocks, terminal finish, and the exact route used.
|
||||
* @throws {Error} when input bounds, route preparation, the deadline, cancellation, or an operational finish failure occurs.
|
||||
*/
|
||||
export async function generateSessionTitleWithLlm(
|
||||
export async function executeSessionTitleLlm(
|
||||
ctx: Context,
|
||||
config: ResolvedSessionTitleLlmConfig,
|
||||
request: SessionTitleProviderRequest,
|
||||
selectedMessages: readonly SessionTitleUserMessage[],
|
||||
titleProvider: SessionTitleProviderId,
|
||||
): Promise<SessionTitleProviderResult> {
|
||||
prepared: SessionTitleLlmPreparedRequest,
|
||||
): Promise<SessionTitleLlmResponse> {
|
||||
request.signal.throwIfAborted()
|
||||
if (selectedMessages.length === 0) {
|
||||
if (prepared.messageSeqs.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')
|
||||
const inputBytes = Buffer.byteLength(prepared.input, '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 }],
|
||||
content: [{ type: 'text', text: prepared.input }],
|
||||
source: { kind: 'dsh-session-title-llm' },
|
||||
})]
|
||||
const system = systemPrompt(config)
|
||||
using callDeadline = deadline(request.signal, config.timeoutMs, SESSION_TITLE_TIMEOUT_CODE)
|
||||
const maxTokens = config.maxOutputTokens
|
||||
const call = await ctx.llm.prepareCall({
|
||||
...route,
|
||||
maxTokens,
|
||||
}, callDeadline.signal, (controls, model) => {
|
||||
const effort = model.reasoning?.efforts[0]?.id
|
||||
const effort = prepared.selectReasoningEffort(model)
|
||||
return { ...controls, ...effort === undefined ? {} : { reasoningEffort: effort } }
|
||||
})
|
||||
const options: GenerateOptions = deepFreeze({
|
||||
...call.config,
|
||||
messages,
|
||||
system,
|
||||
system: prepared.system,
|
||||
sessionId: request.session.id,
|
||||
purpose: 'session-title',
|
||||
signal: callDeadline.signal,
|
||||
})
|
||||
request.session.append('session/title-llm-request', {
|
||||
titleProvider,
|
||||
messageSeqs: selectedMessages.map(message => message.seq),
|
||||
messageSeqs: [...prepared.messageSeqs],
|
||||
route,
|
||||
system,
|
||||
system: prepared.system,
|
||||
messages,
|
||||
maxTokens,
|
||||
...call.config.reasoningEffort === undefined ? {} : { reasoningEffort: call.config.reasoningEffort },
|
||||
@@ -312,21 +280,6 @@ export async function generateSessionTitleWithLlm(
|
||||
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(titleFromOutput(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,
|
||||
}
|
||||
const finish = terminalFinish(assembler.finish)
|
||||
return { blocks: assembler.blocks(), finish, model: route }
|
||||
}
|
||||
@@ -1,17 +1,22 @@
|
||||
import { Context } from '@deepseek-ai/cordis'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import LlmRuntime, { createUserMessage, ToolCallId, isAgentLoopRequest, LlmAdapter, ReasoningEffortId } from '@deepseek-ai/dsh-llm'
|
||||
import type { FinishReason, GenerateOptions, LlmResolvedModelInfo, StreamChunk } from '@deepseek-ai/dsh-llm'
|
||||
import { describe, expect, it, onTestFinished, vi } from 'vitest'
|
||||
import LlmRuntime, { createUserMessage, isAgentLoopRequest, LlmAdapter, ReasoningEffortId, ToolCallId } from '@deepseek-ai/dsh-llm'
|
||||
import type {
|
||||
FinishReason,
|
||||
GenerateOptions,
|
||||
LlmResolvedModelInfo,
|
||||
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,
|
||||
executeSessionTitleLlm,
|
||||
resolveSessionTitleLlmConfig,
|
||||
SESSION_TITLE_TIMEOUT_CODE,
|
||||
} from '@deepseek-ai/dsh-session-title-llm'
|
||||
import type { SessionTitleLlmConfig } from '@deepseek-ai/dsh-session-title-llm'
|
||||
import type { SessionTitleLlmConfig, SessionTitleLlmPreparedRequest } from '@deepseek-ai/dsh-session-title-llm'
|
||||
|
||||
class RecordingAdapter extends LlmAdapter {
|
||||
readonly requests: GenerateOptions[] = []
|
||||
@@ -83,14 +88,24 @@ const SCRIPT: StreamChunk[] = [
|
||||
{ type: 'finish', reason: { kind: 'stop' } },
|
||||
]
|
||||
|
||||
/** Execution controls only; title-length targets belong to each provider. */
|
||||
const CONFIG = {
|
||||
targetWords: 5,
|
||||
targetCjkCharacters: 10,
|
||||
maxInputBytes: 1_000,
|
||||
maxOutputTokens: 32,
|
||||
timeoutMs: 1_000,
|
||||
} as const
|
||||
|
||||
/** Leaves reasoning unselected, as a provider with no preference would. */
|
||||
const NO_EFFORT_SELECTOR = (): ReasoningEffortId | undefined => undefined
|
||||
|
||||
/** Selects the route's least advertised effort, as both shipped providers do. */
|
||||
const FLOOR_SELECTOR = (model: Readonly<LlmResolvedModelInfo>): ReasoningEffortId | undefined =>
|
||||
model.reasoning?.efforts[0]?.id
|
||||
|
||||
/** Selects the route's greatest effort, which the executor must not override. */
|
||||
const CEILING_SELECTOR = (model: Readonly<LlmResolvedModelInfo>): ReasoningEffortId | undefined =>
|
||||
model.reasoning?.efforts.at(-1)?.id
|
||||
|
||||
const TITLE_PROVIDER = SessionTitleProviderId('test-title-provider')
|
||||
let nextSession = 0
|
||||
|
||||
@@ -134,11 +149,26 @@ function requestWithoutRoute(ctx: Context, signal = new AbortController().signal
|
||||
return { session: routed.session, messages: routed.messages, signal }
|
||||
}
|
||||
|
||||
/** Provider-prepared input attributing both fixture messages to one exact text. */
|
||||
function prepared(
|
||||
providerRequest: SessionTitleProviderRequest,
|
||||
overrides: Partial<SessionTitleLlmPreparedRequest> = {},
|
||||
): SessionTitleLlmPreparedRequest {
|
||||
return {
|
||||
system: 'Exact provider system prompt',
|
||||
input: 'Exact provider user input',
|
||||
messageSeqs: providerRequest.messages.map(message => message.seq),
|
||||
selectReasoningEffort: NO_EFFORT_SELECTOR,
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
async function withScript(script: readonly StreamChunk[], reasoningEfforts?: readonly string[]): Promise<{
|
||||
ctx: Context
|
||||
adapter: RecordingAdapter
|
||||
}> {
|
||||
const ctx = new Context()
|
||||
onTestFinished(() => ctx.fiber.dispose())
|
||||
await ctx.plugin(SessionStore)
|
||||
await ctx.plugin(LlmRuntime)
|
||||
const adapter = new RecordingAdapter(script, undefined, reasoningEfforts)
|
||||
@@ -146,9 +176,10 @@ async function withScript(script: readonly StreamChunk[], reasoningEfforts?: rea
|
||||
return { ctx, adapter }
|
||||
}
|
||||
|
||||
describe('generateSessionTitleWithLlm', () => {
|
||||
it('uses the exact logged route, language targets, framed input, cap, and floor effort', async () => {
|
||||
describe('executeSessionTitleLlm', () => {
|
||||
it('dispatches the prepared system and input, logs attribution, and forwards the provider reasoning selector', async () => {
|
||||
const ctx = new Context()
|
||||
onTestFinished(() => ctx.fiber.dispose())
|
||||
await ctx.plugin(SessionStore)
|
||||
await ctx.plugin(LlmRuntime)
|
||||
const providerRequest = request(ctx)
|
||||
@@ -158,20 +189,23 @@ describe('generateSessionTitleWithLlm', () => {
|
||||
.some(event => event.type === 'session/title-llm-request')
|
||||
}, ['off', 'low', 'high', 'max'])
|
||||
ctx.llm.registerAdapter(['current-route'], adapter)
|
||||
const preparedRequest = prepared(providerRequest, {
|
||||
system: 'Provider-owned system instruction',
|
||||
input: 'Provider-owned user payload with 第二个问题',
|
||||
selectReasoningEffort: FLOOR_SELECTOR,
|
||||
})
|
||||
|
||||
const result = await generateSessionTitleWithLlm(
|
||||
const result = await executeSessionTitleLlm(
|
||||
ctx,
|
||||
resolveSessionTitleLlmConfig(CONFIG),
|
||||
providerRequest,
|
||||
providerRequest.messages,
|
||||
TITLE_PROVIDER,
|
||||
preparedRequest,
|
||||
)
|
||||
|
||||
expect(result).toEqual({
|
||||
title: '五个字标题',
|
||||
messageSeqs: providerRequest.messages.map(message => message.seq),
|
||||
model: { provider: 'current-route', model: 'current-model' },
|
||||
})
|
||||
expect(result.blocks).toEqual([{ type: 'text', text: ' 五个字标题 ' }])
|
||||
expect(result.finish).toEqual({ kind: 'stop' })
|
||||
expect(result.model).toEqual({ provider: 'current-route', model: 'current-model' })
|
||||
expect(requestWasLoggedAtDispatch).toBe(true)
|
||||
expect(adapter.requests).toHaveLength(1)
|
||||
const options = adapter.requests[0]!
|
||||
@@ -186,58 +220,105 @@ describe('generateSessionTitleWithLlm', () => {
|
||||
purpose: 'session-title',
|
||||
reasoningEffort: ReasoningEffortId('off'),
|
||||
})
|
||||
expect(options.system).toContain('5 words')
|
||||
expect(options.system).toContain('10 CJK characters')
|
||||
expect(options.system).toContain('still return a short best-effort title')
|
||||
expect(options.system).toBe(preparedRequest.system)
|
||||
const prompt = options.messages[0]?.content[0]
|
||||
expect(prompt?.type === 'text' && prompt.text).toContain('first prompt')
|
||||
expect(prompt?.type === 'text' && prompt.text).toContain('第二个问题')
|
||||
expect(prompt?.type === 'text' && prompt.text).toBe(preparedRequest.input)
|
||||
expect(providerRequest.session.snapshotEvents().findLast(event => event.type === 'session/title-llm-request')?.data)
|
||||
.toEqual({
|
||||
titleProvider: TITLE_PROVIDER,
|
||||
messageSeqs: providerRequest.messages.map(message => message.seq),
|
||||
messageSeqs: preparedRequest.messageSeqs,
|
||||
route: { provider: 'current-route', model: 'current-model' },
|
||||
system: options.system,
|
||||
system: preparedRequest.system,
|
||||
messages: options.messages,
|
||||
maxTokens: 32,
|
||||
reasoningEffort: ReasoningEffortId('off'),
|
||||
})
|
||||
})
|
||||
|
||||
it('forwards a different valid reasoning selector instead of choosing one itself', async () => {
|
||||
const { ctx, adapter } = await withScript(SCRIPT, ['off', 'low', 'high', 'max'])
|
||||
const providerRequest = request(ctx)
|
||||
|
||||
await executeSessionTitleLlm(
|
||||
ctx,
|
||||
resolveSessionTitleLlmConfig(CONFIG),
|
||||
providerRequest,
|
||||
TITLE_PROVIDER,
|
||||
prepared(providerRequest, { selectReasoningEffort: CEILING_SELECTOR }),
|
||||
)
|
||||
|
||||
const options = adapter.requests[0]!
|
||||
const logged = providerRequest.session.snapshotEvents()
|
||||
.findLast(event => event.type === 'session/title-llm-request')?.data
|
||||
expect(options.maxTokens).toBe(32)
|
||||
expect(logged?.maxTokens).toBe(32)
|
||||
expect(options.reasoningEffort).toBe(ReasoningEffortId('max'))
|
||||
expect(logged?.reasoningEffort).toBe(ReasoningEffortId('max'))
|
||||
})
|
||||
|
||||
it('keeps the title output cap independent of a smaller conversation cap', async () => {
|
||||
const { ctx, adapter } = await withScript(SCRIPT)
|
||||
const providerRequest = request(ctx, undefined, 8)
|
||||
|
||||
await expect(generateSessionTitleWithLlm(
|
||||
await expect(executeSessionTitleLlm(
|
||||
ctx,
|
||||
resolveSessionTitleLlmConfig(CONFIG),
|
||||
providerRequest,
|
||||
providerRequest.messages,
|
||||
TITLE_PROVIDER,
|
||||
)).resolves.toMatchObject({ title: '五个字标题' })
|
||||
prepared(providerRequest),
|
||||
)).resolves.toMatchObject({ model: { provider: 'current-route', model: 'current-model' } })
|
||||
|
||||
expect(adapter.requests[0]?.maxTokens).toBe(32)
|
||||
expect(providerRequest.session.snapshotEvents()
|
||||
.findLast(event => event.type === 'session/title-llm-request')?.data.maxTokens).toBe(32)
|
||||
})
|
||||
|
||||
it('enforces the exact multibyte input byte limit before logging or dispatch', async () => {
|
||||
const { ctx, adapter } = await withScript(SCRIPT)
|
||||
const providerRequest = request(ctx)
|
||||
const input = '标题输入:"番茄"'
|
||||
const inputBytes = Buffer.byteLength(input, 'utf8')
|
||||
expect(inputBytes).toBeGreaterThan(input.length)
|
||||
|
||||
await expect(executeSessionTitleLlm(
|
||||
ctx,
|
||||
resolveSessionTitleLlmConfig({ ...CONFIG, maxInputBytes: inputBytes - 1 }),
|
||||
providerRequest,
|
||||
TITLE_PROVIDER,
|
||||
prepared(providerRequest, { input }),
|
||||
)).rejects.toThrow(/input.*bytes.*maxInputBytes/i)
|
||||
expect(adapter.requests).toHaveLength(0)
|
||||
expect(providerRequest.session.snapshotEvents().some(event => event.type === 'session/title-llm-request')).toBe(false)
|
||||
|
||||
const result = await executeSessionTitleLlm(
|
||||
ctx,
|
||||
resolveSessionTitleLlmConfig({ ...CONFIG, maxInputBytes: inputBytes }),
|
||||
providerRequest,
|
||||
TITLE_PROVIDER,
|
||||
prepared(providerRequest, { input }),
|
||||
)
|
||||
expect(result.blocks).toHaveLength(1)
|
||||
expect(adapter.requests).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('keeps the configured output cap when the session request recorded a larger one', async () => {
|
||||
const { ctx, adapter } = await withScript(SCRIPT)
|
||||
const providerRequest = request(ctx, undefined, 4_096)
|
||||
|
||||
await expect(generateSessionTitleWithLlm(
|
||||
await expect(executeSessionTitleLlm(
|
||||
ctx,
|
||||
resolveSessionTitleLlmConfig(CONFIG),
|
||||
providerRequest,
|
||||
providerRequest.messages,
|
||||
TITLE_PROVIDER,
|
||||
)).resolves.toMatchObject({ title: '五个字标题' })
|
||||
prepared(providerRequest),
|
||||
)).resolves.toBeDefined()
|
||||
|
||||
expect(adapter.requests[0]?.maxTokens).toBe(32)
|
||||
})
|
||||
|
||||
it('rejects an unregistered route before recording or dispatching a title request', async () => {
|
||||
const ctx = new Context()
|
||||
onTestFinished(() => ctx.fiber.dispose())
|
||||
await ctx.plugin(SessionStore)
|
||||
await ctx.plugin(LlmRuntime)
|
||||
const requests: GenerateOptions[] = []
|
||||
@@ -247,43 +328,42 @@ describe('generateSessionTitleWithLlm', () => {
|
||||
})
|
||||
const providerRequest = request(ctx)
|
||||
|
||||
await expect(generateSessionTitleWithLlm(
|
||||
await expect(executeSessionTitleLlm(
|
||||
ctx,
|
||||
resolveSessionTitleLlmConfig(CONFIG),
|
||||
providerRequest,
|
||||
providerRequest.messages,
|
||||
TITLE_PROVIDER,
|
||||
prepared(providerRequest),
|
||||
)).rejects.toMatchObject({ code: 'NO_ADAPTER' })
|
||||
|
||||
expect(requests).toEqual([])
|
||||
expect(providerRequest.session.snapshotEvents().some(event => event.type === 'session/title-llm-request')).toBe(false)
|
||||
})
|
||||
|
||||
it('sends no effort when the route reports no selectable level', async () => {
|
||||
const { ctx, adapter } = await withScript(SCRIPT)
|
||||
it('sends no effort when the provider reasoning selector leaves it unset', async () => {
|
||||
const { ctx, adapter } = await withScript(SCRIPT, ['off', 'low'])
|
||||
const providerRequest = request(ctx)
|
||||
|
||||
await expect(generateSessionTitleWithLlm(
|
||||
await expect(executeSessionTitleLlm(
|
||||
ctx,
|
||||
resolveSessionTitleLlmConfig(CONFIG),
|
||||
providerRequest,
|
||||
providerRequest.messages,
|
||||
TITLE_PROVIDER,
|
||||
)).resolves.toMatchObject({ title: '五个字标题' })
|
||||
prepared(providerRequest),
|
||||
)).resolves.toBeDefined()
|
||||
|
||||
expect(adapter.requests[0]).not.toHaveProperty('reasoningEffort')
|
||||
})
|
||||
|
||||
it('uses paired explicit overrides and bounds the final framed input before model dispatch', async () => {
|
||||
it('uses paired explicit overrides and bounds the final input before model dispatch', async () => {
|
||||
const ctx = new Context()
|
||||
onTestFinished(() => ctx.fiber.dispose())
|
||||
await ctx.plugin(SessionStore)
|
||||
await ctx.plugin(LlmRuntime)
|
||||
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 rawInputBytes = Buffer.byteLength('x'.repeat(20), 'utf8')
|
||||
const config = resolveSessionTitleLlmConfig({
|
||||
...CONFIG,
|
||||
provider: 'explicit-route',
|
||||
@@ -291,30 +371,32 @@ describe('generateSessionTitleWithLlm', () => {
|
||||
maxInputBytes: rawInputBytes,
|
||||
})
|
||||
|
||||
await expect(generateSessionTitleWithLlm(ctx, config, oversized, [selected], TITLE_PROVIDER))
|
||||
await expect(executeSessionTitleLlm(ctx, config, oversized, TITLE_PROVIDER, prepared(oversized, { input: 'x'.repeat(21) })))
|
||||
.rejects.toThrow(/input.*bytes.*maxInputBytes/i)
|
||||
expect(adapter.requests).toEqual([])
|
||||
expect(oversized.session.snapshotEvents().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)
|
||||
await executeSessionTitleLlm(ctx, withinLimit, within, TITLE_PROVIDER, prepared(within, { input: 'x'.repeat(21) }))
|
||||
expect(adapter.requests[0]).toMatchObject({
|
||||
provider: 'explicit-route',
|
||||
model: 'explicit-model',
|
||||
})
|
||||
})
|
||||
|
||||
it('requires every deployment limit and a complete optional route pair', () => {
|
||||
it('requires every execution 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, maxInputBytes: 0 }))
|
||||
.toThrow(/maxInputBytes.*positive integer/)
|
||||
expect(() => resolveSessionTitleLlmConfig({ ...CONFIG, maxOutputTokens: 1.5 }))
|
||||
.toThrow(/maxOutputTokens.*positive integer/)
|
||||
expect(() => resolveSessionTitleLlmConfig({ ...CONFIG, timeoutMs: 0 }))
|
||||
.toThrow(/timeoutMs.*positive integer/)
|
||||
expect(() => resolveSessionTitleLlmConfig({ ...CONFIG, provider: 'only-provider' }))
|
||||
.toThrow(/provider and model must be supplied together/)
|
||||
expect(() => resolveSessionTitleLlmConfig({ ...CONFIG, model: 'only-model' }))
|
||||
@@ -332,19 +414,19 @@ describe('generateSessionTitleWithLlm', () => {
|
||||
expect(() => resolveSessionTitleLlmConfig(CONFIG)).not.toThrow()
|
||||
})
|
||||
|
||||
it('rejects an absent route, empty selection, and pre-aborted caller before model dispatch', async () => {
|
||||
it('rejects an absent route, empty attribution, 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))
|
||||
await expect(executeSessionTitleLlm(ctx, config, unrouted, TITLE_PROVIDER, prepared(unrouted)))
|
||||
.rejects.toThrow(/no logged request route/)
|
||||
const empty = request(ctx)
|
||||
await expect(generateSessionTitleWithLlm(ctx, config, empty, [], TITLE_PROVIDER))
|
||||
await expect(executeSessionTitleLlm(ctx, config, empty, TITLE_PROVIDER, prepared(empty, { messageSeqs: [] })))
|
||||
.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))
|
||||
await expect(executeSessionTitleLlm(ctx, config, aborted, TITLE_PROVIDER, prepared(aborted)))
|
||||
.rejects.toThrow('caller stopped')
|
||||
expect(adapter.requests).toEqual([])
|
||||
})
|
||||
@@ -355,104 +437,76 @@ describe('generateSessionTitleWithLlm', () => {
|
||||
] 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(
|
||||
await expect(executeSessionTitleLlm(
|
||||
ctx,
|
||||
resolveSessionTitleLlmConfig(CONFIG),
|
||||
providerRequest,
|
||||
providerRequest.messages,
|
||||
TITLE_PROVIDER,
|
||||
prepared(providerRequest),
|
||||
)).rejects.toMatchObject({ message, code })
|
||||
expect(providerRequest.session.snapshotEvents().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 }])
|
||||
it('returns a max-tokens terminal finish for provider interpretation', async () => {
|
||||
const { ctx } = await withScript([
|
||||
{ type: 'block-start', index: 0, blockType: 'text' },
|
||||
{ type: 'text-delta', index: 0, text: 'partial title' },
|
||||
{ type: 'finish', reason: { kind: 'max-tokens' } },
|
||||
])
|
||||
const providerRequest = request(ctx)
|
||||
await expect(generateSessionTitleWithLlm(
|
||||
|
||||
const result = await executeSessionTitleLlm(
|
||||
ctx,
|
||||
resolveSessionTitleLlmConfig(CONFIG),
|
||||
providerRequest,
|
||||
providerRequest.messages,
|
||||
TITLE_PROVIDER,
|
||||
)).rejects.toThrow(error)
|
||||
prepared(providerRequest),
|
||||
)
|
||||
|
||||
expect(result.finish).toEqual({ kind: 'max-tokens' })
|
||||
expect(result.blocks).toEqual([{ type: 'text', text: 'partial title' }])
|
||||
})
|
||||
|
||||
it('takes the title from the first non-empty output line without wrapping emphasis', async () => {
|
||||
const cases: [readonly string[], string][] = [
|
||||
[['**Continuing Previous', ' Session**\n\nThe only message is "continue".'], 'Continuing Previous Session'],
|
||||
[['\n *Greeting* \n'], 'Greeting'],
|
||||
[['**a** and **b**\nnote'], '**a** and **b**'],
|
||||
[['Use **bold** for emphasis'], 'Use **bold** for emphasis'],
|
||||
[['Fix *args* handling'], 'Fix *args* handling'],
|
||||
[['*args'], '*args'],
|
||||
[['****'], '****'],
|
||||
]
|
||||
for (const [deltas, title] of cases) {
|
||||
const { ctx } = await withScript([
|
||||
{ type: 'block-start', index: 0, blockType: 'text' },
|
||||
...deltas.map((text): StreamChunk => ({ type: 'text-delta', index: 0, text })),
|
||||
{ type: 'finish', reason: { kind: 'stop' } },
|
||||
])
|
||||
const providerRequest = request(ctx)
|
||||
await expect(generateSessionTitleWithLlm(
|
||||
ctx,
|
||||
resolveSessionTitleLlmConfig(CONFIG),
|
||||
providerRequest,
|
||||
providerRequest.messages,
|
||||
TITLE_PROVIDER,
|
||||
)).resolves.toMatchObject({ title })
|
||||
}
|
||||
})
|
||||
|
||||
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: ToolCallId('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' } },
|
||||
it('returns a tool-calls terminal finish and its assembled blocks for provider interpretation', async () => {
|
||||
const { ctx } = await withScript([
|
||||
{ type: 'block-start', index: 0, blockType: 'text' },
|
||||
{ type: 'text-delta', index: 0, text: 'text beside a tool call' },
|
||||
{ type: 'block-start', index: 1, blockType: 'tool-call' },
|
||||
{ type: 'tool-call-delta', index: 1, id: ToolCallId('title-tool'), name: 'unexpected', argumentsDelta: '{}' },
|
||||
{ type: 'finish', reason: { kind: 'tool-calls' } },
|
||||
])
|
||||
const reasoningRequest = request(reasoning.ctx)
|
||||
await expect(generateSessionTitleWithLlm(
|
||||
reasoning.ctx,
|
||||
const providerRequest = request(ctx)
|
||||
|
||||
const result = await executeSessionTitleLlm(
|
||||
ctx,
|
||||
resolveSessionTitleLlmConfig(CONFIG),
|
||||
reasoningRequest,
|
||||
reasoningRequest.messages,
|
||||
providerRequest,
|
||||
TITLE_PROVIDER,
|
||||
)).rejects.toThrow(/produced no text/)
|
||||
prepared(providerRequest),
|
||||
)
|
||||
|
||||
expect(result.finish).toEqual({ kind: 'tool-calls' })
|
||||
expect(result.blocks).toEqual([
|
||||
{ type: 'text', text: 'text beside a tool call' },
|
||||
{ type: 'tool-call', id: 'title-tool', name: 'unexpected', arguments: '{}' },
|
||||
])
|
||||
})
|
||||
|
||||
it('aborts a cooperative model stream at the configured deadline', async () => {
|
||||
vi.useFakeTimers()
|
||||
try {
|
||||
const ctx = new Context()
|
||||
onTestFinished(() => ctx.fiber.dispose())
|
||||
await ctx.plugin(SessionStore)
|
||||
await ctx.plugin(LlmRuntime)
|
||||
ctx.llm.registerAdapter(['current-route'], new CooperativeAdapter())
|
||||
const providerRequest = request(ctx)
|
||||
const pending = generateSessionTitleWithLlm(
|
||||
const pending = executeSessionTitleLlm(
|
||||
ctx,
|
||||
resolveSessionTitleLlmConfig({ ...CONFIG, timeoutMs: 10 }),
|
||||
providerRequest,
|
||||
providerRequest.messages,
|
||||
TITLE_PROVIDER,
|
||||
prepared(providerRequest),
|
||||
)
|
||||
const rejected = expect(pending).rejects.toMatchObject({
|
||||
code: SESSION_TITLE_TIMEOUT_CODE,
|
||||
@@ -469,16 +523,17 @@ describe('generateSessionTitleWithLlm', () => {
|
||||
vi.useFakeTimers()
|
||||
try {
|
||||
const ctx = new Context()
|
||||
onTestFinished(() => ctx.fiber.dispose())
|
||||
await ctx.plugin(SessionStore)
|
||||
await ctx.plugin(LlmRuntime)
|
||||
ctx.llm.registerAdapter(['current-route'], new DelayedSuccessAdapter(20))
|
||||
const providerRequest = request(ctx)
|
||||
const pending = generateSessionTitleWithLlm(
|
||||
const pending = executeSessionTitleLlm(
|
||||
ctx,
|
||||
resolveSessionTitleLlmConfig({ ...CONFIG, timeoutMs: 10 }),
|
||||
providerRequest,
|
||||
providerRequest.messages,
|
||||
TITLE_PROVIDER,
|
||||
prepared(providerRequest),
|
||||
)
|
||||
const rejected = expect(pending).rejects.toMatchObject({
|
||||
code: SESSION_TITLE_TIMEOUT_CODE,
|
||||
@@ -490,4 +545,24 @@ describe('generateSessionTitleWithLlm', () => {
|
||||
vi.useRealTimers()
|
||||
}
|
||||
})
|
||||
|
||||
it('propagates caller cancellation through the composed request signal', async () => {
|
||||
const ctx = new Context()
|
||||
onTestFinished(() => ctx.fiber.dispose())
|
||||
await ctx.plugin(SessionStore)
|
||||
await ctx.plugin(LlmRuntime)
|
||||
ctx.llm.registerAdapter(['current-route'], new CooperativeAdapter())
|
||||
const controller = new AbortController()
|
||||
const providerRequest = request(ctx, controller.signal)
|
||||
const pending = executeSessionTitleLlm(
|
||||
ctx,
|
||||
resolveSessionTitleLlmConfig(CONFIG),
|
||||
providerRequest,
|
||||
TITLE_PROVIDER,
|
||||
prepared(providerRequest),
|
||||
)
|
||||
const rejected = expect(pending).rejects.toThrow('caller stopped')
|
||||
controller.abort(new Error('caller stopped'))
|
||||
await rejected
|
||||
})
|
||||
})
|
||||
@@ -24,8 +24,8 @@
|
||||
en: f72239b38a91783b
|
||||
zh: 07e22375c09906a6
|
||||
/deepseek-ai-dsh-session-title/use-this-package/adding-a-provider:
|
||||
en: 2ed8217f95490c73
|
||||
zh: e3456cb027ce3fcc
|
||||
en: cda41e431e48f914
|
||||
zh: c6347ef731b0036e
|
||||
/deepseek-ai-dsh-session-title/use-this-package/reading-titles:
|
||||
en: 34148c23c20ee6f4
|
||||
zh: b0d10677722f3f63
|
||||
@@ -48,8 +48,8 @@
|
||||
en: 0fa22531683cc824
|
||||
zh: c6379873eb809e76
|
||||
/deepseek-ai-dsh-session-title/further-exploration:
|
||||
en: 7e0e5c9491acf9e5
|
||||
zh: df1fec7ecc66a47b
|
||||
en: 80d2f64367af8bda
|
||||
zh: cfdd94b628fc4d8d
|
||||
/deepseek-ai-dsh-session-title/model-experience:
|
||||
en: 215e7ba838619b7b
|
||||
zh: a311da3843709f90
|
||||
|
||||
@@ -54,7 +54,7 @@ The generated [configuration catalog](../../../docs/config-catalog.md#deepseek-a
|
||||
|
||||
### Adding a provider
|
||||
|
||||
One optional asynchronous provider may be registered through `ctx.sessionTitle.register(provider)`; a second registration throws until the first provider's disposal starts, so a replacement can activate while the previous provider drains, and the previous provider's late results never commit. The shipped model-backed providers are [first-prompt](../session-title-first-prompt-llm/README.md) and [all-prompts](../../experimental/session-title-all-prompts-llm/README.md), both using the shared [LLM generation policy](../session-title-llm/README.md). A provider starts only after a marked loop-built request's exact route matches the logged `request/header`, and a newer revision supersedes and aborts older work. For unchanged routes, an active marked request starts pending automatic generation without a new header event; accepted human messages may follow the current `step/start`.
|
||||
One optional asynchronous provider may be registered through `ctx.sessionTitle.register(provider)`; a second registration throws until the first provider's disposal starts, so a replacement can activate while the previous provider drains, and the previous provider's late results never commit. The shipped model-backed providers are [first-prompt](../session-title-first-prompt-llm/README.md) and [all-prompts](../../experimental/session-title-all-prompts-llm/README.md), both using the shared [LLM execution module](../session-title-llm/README.md). Immediately before each call the service captures the latest accepted title snapshot, including the fallback it just ensured, and passes it as `request.currentTitle`; the provider owns whether and how to use it. A provider starts only after a marked loop-built request's exact route matches the logged `request/header`, and a newer revision supersedes and aborts older work. For unchanged routes, an active marked request starts pending automatic generation without a new header event; accepted human messages may follow the current `step/start`.
|
||||
|
||||
### Reading titles
|
||||
|
||||
@@ -104,7 +104,7 @@ Accepted titles are cleaned of terminal control sequences, directional and invis
|
||||
Read these pages when the service contract is not enough. They move from the subsystem reference to the model-backed providers that plug in here.
|
||||
|
||||
- [Session title subsystem](../../../docs/subsystems/session-title.md) — durable title state and the provider vocabulary types.
|
||||
- [Shared LLM title policy](../session-title-llm/README.md) — the model-backed generation helper both shipped providers use.
|
||||
- [Shared LLM execution module](../session-title-llm/README.md) — the model-backed execution module both shipped providers use.
|
||||
- [First-message title provider](../session-title-first-prompt-llm/README.md) — titles from the first eligible human message.
|
||||
- [All-messages title provider](../../experimental/session-title-all-prompts-llm/README.md) — titles from every eligible human message.
|
||||
- [Session package map](../README.md) — adjacent persistence, projection, title, and telemetry packages.
|
||||
|
||||
@@ -54,7 +54,7 @@ kind: "package-reference"
|
||||
|
||||
### 添加提供方
|
||||
|
||||
可选异步提供方可通过 `ctx.sessionTitle.register(provider)` 注册一个;在前一个提供方开始释放之前,第二次注册会立即抛出。因此替代提供方可以在前一个提供方排空期间激活,前一个提供方迟到的结果不会提交。随附的模型支持提供方是[首消息](../session-title-first-prompt-llm/README.zh.md)与[全消息](../../experimental/session-title-all-prompts-llm/README.zh.md),两者都使用共享的 [LLM(大语言模型)生成策略](../session-title-llm/README.zh.md)。提供方只有在带标记、由循环构建的请求的确切路由与已记录 `request/header` 匹配时才启动,较新的修订会取代并中止旧工作。路由不变时,活跃步骤中带标记的请求会启动待处理的自动生成,无需新的 header 事件;已接受的人类消息可以位于当前 `step/start` 之后。
|
||||
可选异步提供方可通过 `ctx.sessionTitle.register(provider)` 注册一个;在前一个提供方开始释放之前,第二次注册会立即抛出。因此替代提供方可以在前一个提供方排空期间激活,前一个提供方迟到的结果不会提交。随附的模型支持提供方是[首消息](../session-title-first-prompt-llm/README.zh.md)与[全消息](../../experimental/session-title-all-prompts-llm/README.zh.md),两者都使用共享的 [LLM(大语言模型)执行模块](../session-title-llm/README.zh.md)。每次调用前,服务会捕获最新已接纳标题快照(包括它刚刚确保的回退),并作为 `request.currentTitle` 传入;提供方自行决定是否以及如何使用。提供方只有在带标记、由循环构建的请求的确切路由与已记录 `request/header` 匹配时才启动,较新的修订会取代并中止旧工作。路由不变时,活跃步骤中带标记的请求会启动待处理的自动生成,无需新的 header 事件;已接受的人类消息可以位于当前 `step/start` 之后。
|
||||
|
||||
### 读取标题
|
||||
|
||||
@@ -104,7 +104,7 @@ kind: "package-reference"
|
||||
当服务约定不够用时阅读以下页面。它们从子系统参考逐步进入在此插拔的模型支持提供方。
|
||||
|
||||
- [会话标题子系统](../../../docs/subsystems/session-title.zh.md)——持久标题状态与提供方词汇类型。
|
||||
- [共享 LLM 标题策略](../session-title-llm/README.zh.md)——两个随附提供方共用的模型生成辅助模块。
|
||||
- [共享 LLM 执行模块](../session-title-llm/README.zh.md)——两个随附提供方共用的模型执行模块。
|
||||
- [首消息标题提供方](../session-title-first-prompt-llm/README.zh.md)——根据第一条符合条件的用户消息生成标题。
|
||||
- [全消息标题提供方](../../experimental/session-title-all-prompts-llm/README.zh.md)——根据所有符合条件的用户消息生成标题。
|
||||
- [会话包映射](../README.zh.md)——相邻的持久化、投影、标题与遥测包。
|
||||
|
||||
@@ -99,6 +99,12 @@ export interface SessionTitleProviderRequest {
|
||||
readonly messages: readonly SessionTitleUserMessage[]
|
||||
/** Exact current logged main-request route, when one has been recorded. */
|
||||
readonly route?: SessionTitleModelIdentity
|
||||
/**
|
||||
* Latest accepted title captured at invocation, including an accepted
|
||||
* fallback whose event may follow the last eligible message. Absent before
|
||||
* any title is accepted. The provider decides whether and how to use it.
|
||||
*/
|
||||
readonly currentTitle?: SessionTitleSnapshot
|
||||
/** Cancellation for supersession, disposal, timeout composition, or the explicit caller. */
|
||||
readonly signal: AbortSignal
|
||||
}
|
||||
@@ -121,7 +127,7 @@ export interface SessionTitleProvider {
|
||||
readonly automatic: SessionTitleAutomaticMode
|
||||
/**
|
||||
* Produce one title revision.
|
||||
* @param request - message snapshot, current route, session, and cancellation.
|
||||
* @param request - message snapshot, current title, current route, session, and cancellation.
|
||||
* @returns proposed title plus exact input seqs and the optional provider/model route used to generate it.
|
||||
*/
|
||||
generate(request: SessionTitleProviderRequest): Promise<SessionTitleProviderResult>
|
||||
@@ -594,12 +600,17 @@ export class SessionTitleService extends Service {
|
||||
this.assertCurrent(session, work)
|
||||
await this.ensureFallback(session)
|
||||
this.assertCurrent(session, work)
|
||||
// One snapshot supplies both inputs: messages stop at the scheduled
|
||||
// watermark, while the current title is not bounded by that watermark.
|
||||
// oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred.
|
||||
const messages = collectSessionTitleMessages(session.snapshotEvents(), work.throughSeq)
|
||||
const events = session.snapshotEvents()
|
||||
const messages = collectSessionTitleMessages(events, work.throughSeq)
|
||||
const currentTitle = foldSessionTitle(events)
|
||||
const result = await work.registration.provider.generate({
|
||||
session,
|
||||
messages,
|
||||
...route === undefined ? {} : { route },
|
||||
...currentTitle === undefined ? {} : { currentTitle },
|
||||
signal: work.signal,
|
||||
})
|
||||
this.assertCurrent(session, work)
|
||||
|
||||
@@ -10,6 +10,7 @@ import SessionTitleService, {
|
||||
type SessionTitleProvider,
|
||||
type SessionTitleProviderRequest,
|
||||
type SessionTitleProviderResult,
|
||||
type SessionTitleSnapshot,
|
||||
} from '@deepseek-ai/dsh-session-title'
|
||||
|
||||
const CONFIG = {
|
||||
@@ -152,6 +153,13 @@ describe('SessionTitleService Provider lifecycle', () => {
|
||||
messages: [{ seq: first.seq, text: 'Explain asynchronous title generation' }],
|
||||
route: { provider: 'main-route', model: 'chat-model' },
|
||||
})
|
||||
// The service captures the fallback it ensured immediately before the
|
||||
// provider call, and that fallback event can follow the source message.
|
||||
expect(requests[0]?.currentTitle).toMatchObject({
|
||||
source: { kind: 'fallback' },
|
||||
messageSeqs: [first.seq],
|
||||
})
|
||||
expect(requests[0]?.currentTitle?.eventSeq).toBeGreaterThan(first.seq)
|
||||
expect(ctx.sessionTitle.get(session)).toMatchObject({
|
||||
title: 'A model-generated title',
|
||||
messageSeqs: [first.seq],
|
||||
@@ -172,6 +180,77 @@ describe('SessionTitleService Provider lifecycle', () => {
|
||||
expect(requests[1]?.messages.map(message => message.seq)).toEqual([first.seq, second.seq])
|
||||
})
|
||||
|
||||
it('omits currentTitle when a one-byte cap makes the fallback underivable from a multibyte message', async () => {
|
||||
const ctx = new Context()
|
||||
onTestFinished(() => ctx.fiber.dispose())
|
||||
await ctx.plugin(SessionStore)
|
||||
await ctx.plugin(SessionProjectionRegistry)
|
||||
await ctx.plugin(SessionTitleService, { fallbackMaxWords: 5, fallbackMaxBytes: 1, maxTitleBytes: 24 })
|
||||
const currentTitles: (SessionTitleSnapshot | undefined)[] = []
|
||||
ctx.sessionTitle.register({
|
||||
id: SessionTitleProviderId('absent-current'),
|
||||
automatic: 'all-prompts',
|
||||
async generate(request) {
|
||||
currentTitles.push(request.currentTitle)
|
||||
return { title: 'Provided title', messageSeqs: request.messages.map(message => message.seq) }
|
||||
},
|
||||
})
|
||||
const session = ctx.sessions.create(SessionId('underivable-fallback'))
|
||||
session.append('turn/start', { turn: 1 })
|
||||
appendHumanPrompt(session, '番')
|
||||
await settle()
|
||||
appendRoute(session)
|
||||
await settle()
|
||||
|
||||
expect(currentTitles).toEqual([undefined])
|
||||
expect(ctx.sessionTitle.get(session)).toMatchObject({
|
||||
title: 'Provided title',
|
||||
source: { kind: 'provider', provider: SessionTitleProviderId('absent-current') },
|
||||
})
|
||||
})
|
||||
|
||||
it('passes the latest accepted title snapshot to each provider revision', async () => {
|
||||
const ctx = new Context()
|
||||
onTestFinished(() => ctx.fiber.dispose())
|
||||
await ctx.plugin(SessionStore)
|
||||
await ctx.plugin(SessionProjectionRegistry)
|
||||
await ctx.plugin(SessionTitleService, CONFIG)
|
||||
const currentTitles: (SessionTitleSnapshot | undefined)[] = []
|
||||
ctx.sessionTitle.register({
|
||||
id: SessionTitleProviderId('current-title'),
|
||||
automatic: 'all-prompts',
|
||||
async generate(request) {
|
||||
currentTitles.push(request.currentTitle)
|
||||
return {
|
||||
title: `Revision ${String(currentTitles.length)}`,
|
||||
messageSeqs: request.messages.map(message => message.seq),
|
||||
}
|
||||
},
|
||||
})
|
||||
const session = ctx.sessions.create(SessionId('current-title-snapshot'))
|
||||
session.append('turn/start', { turn: 1 })
|
||||
const first = appendHumanPrompt(session, 'First prompt')
|
||||
await settle()
|
||||
appendRoute(session)
|
||||
await settle()
|
||||
|
||||
expect(currentTitles[0]).toMatchObject({
|
||||
source: { kind: 'fallback' },
|
||||
messageSeqs: [first.seq],
|
||||
})
|
||||
|
||||
appendHumanPrompt(session, 'Second prompt')
|
||||
appendRoute(session, 'change')
|
||||
await settle()
|
||||
|
||||
expect(currentTitles[1]).toMatchObject({
|
||||
title: 'Revision 1',
|
||||
messageSeqs: [first.seq],
|
||||
source: { kind: 'provider', provider: SessionTitleProviderId('current-title') },
|
||||
})
|
||||
expect(Object.isFrozen(currentTitles[1])).toBe(true)
|
||||
})
|
||||
|
||||
it('preserves provider input order across bounded title-input chunks', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SessionStore)
|
||||
|
||||
Generated
+6
@@ -8649,6 +8649,9 @@ importers:
|
||||
|
||||
packages/experimental/session-title-all-prompts-llm:
|
||||
dependencies:
|
||||
'@deepseek-ai/dsh-util-values':
|
||||
specifier: workspace:*
|
||||
version: link:../../util/values
|
||||
'@deepseek-ai/schemastery':
|
||||
specifier: link:../../../vendor/schemastery
|
||||
version: link:../../../vendor/schemastery
|
||||
@@ -12238,6 +12241,9 @@ importers:
|
||||
|
||||
packages/session/session-title-first-prompt-llm:
|
||||
dependencies:
|
||||
'@deepseek-ai/dsh-util-values':
|
||||
specifier: workspace:*
|
||||
version: link:../../util/values
|
||||
'@deepseek-ai/schemastery':
|
||||
specifier: link:../../../vendor/schemastery
|
||||
version: link:../../../vendor/schemastery
|
||||
|
||||
@@ -102,6 +102,8 @@ function dirOf(url: string): string {
|
||||
interface SdkAssertions {
|
||||
/** Additional profile patches applied after the shared composition. */
|
||||
patches?: readonly string[]
|
||||
/** Wait for the recorded provider title before sending each subsequent prompt. */
|
||||
waitForProviderTitle?: boolean
|
||||
/** Final response required from a completed turn before updating goldens. */
|
||||
expectedFinalResponse?: string
|
||||
/** Environment overrides passed to the runtime subprocess. */
|
||||
@@ -124,6 +126,7 @@ interface SdkAssertions {
|
||||
}
|
||||
|
||||
const SDK_ASSERTIONS: Readonly<Record<string, SdkAssertions>> = {
|
||||
'session-title-stability': { waitForProviderTitle: true },
|
||||
'dynamic-tool-updates': {
|
||||
expectedFinalResponse: 'DONE',
|
||||
},
|
||||
@@ -634,6 +637,11 @@ async function runScenario(scenario: CorpusScenario): Promise<{
|
||||
const liveSessions: (string | undefined)[] = [sessionId]
|
||||
await harness.start()
|
||||
const subscription = harness.client.subscribeSessionTree(sessionId)
|
||||
const titleSubscription = assertions.waitForProviderTitle === true
|
||||
? harness.client.subscribeSessionTree(sessionId)
|
||||
: undefined
|
||||
const expectedTitles = records(primaryFixture).filter(event => event.type === 'session/title'
|
||||
&& ((event.data as JsonObject).source as JsonObject).kind === 'provider')
|
||||
const completionSubscription = scenario.name === 'subagent-continuable'
|
||||
? harness.client.subscribeSessionTree(sessionId)
|
||||
: undefined
|
||||
@@ -663,6 +671,13 @@ async function runScenario(scenario: CorpusScenario): Promise<{
|
||||
},
|
||||
})
|
||||
results.push(result)
|
||||
if (titleSubscription !== undefined) {
|
||||
const expected = expectedTitles[results.length - 1]?.data as JsonObject | undefined
|
||||
expect(expected, 'each turn has a recorded provider title').toBeDefined()
|
||||
await waitForRootEvent(titleSubscription, sessionId, event => event.type === 'session/title'
|
||||
&& ((event.data as JsonObject).source as JsonObject).kind === 'provider'
|
||||
&& (event.data as JsonObject).title === expected?.title, observe)
|
||||
}
|
||||
if (completionSubscription !== undefined) {
|
||||
expect(result.finalResponse, 'run returns at the first parent idle').toBe('DONE')
|
||||
expect(result.events.filter(event => event.type === 'turn/end'))
|
||||
@@ -711,6 +726,7 @@ async function runScenario(scenario: CorpusScenario): Promise<{
|
||||
} finally {
|
||||
subscription.close()
|
||||
completionSubscription?.close()
|
||||
titleSubscription?.close()
|
||||
}
|
||||
await harness.close()
|
||||
const logs = (await Promise.all([
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
# Main responses derive from the session fixture; auxiliary title responses use
|
||||
# the explicit replay script. The SDK waits for each accepted title before sending
|
||||
# the next human message.
|
||||
- id: llm-deepseek
|
||||
name: '@deepseek-ai/dsh-llm-deepseek-api-key'
|
||||
disabled: true
|
||||
|
||||
- id: agent-default-model
|
||||
name: '@deepseek-ai/dsh-agent-default-model'
|
||||
config:
|
||||
provider: deepseek-official
|
||||
model: deepseek-v4-flash
|
||||
|
||||
- id: session-persistence-jsonl
|
||||
name: '@deepseek-ai/dsh-session-persistence-jsonl'
|
||||
config:
|
||||
root: !!js dshHomePath('sessions')
|
||||
compression: none
|
||||
|
||||
- id: agent-instructions
|
||||
name: '@deepseek-ai/dsh-agent-instructions'
|
||||
config:
|
||||
maxBytes: 65536
|
||||
|
||||
- id: system-prompt
|
||||
name: '@deepseek-ai/dsh-system-prompt'
|
||||
config:
|
||||
personaPrefix: |
|
||||
You are a coding assistant powered by the {{model}} model. Your bash tool runs under a file sandbox — a `[sandbox: file access denied …]` result is policy, not a command bug.
|
||||
|
||||
Verify your work by running the code or tests. Keep answers brief and factual.
|
||||
|
||||
- insert:
|
||||
- id: llm-replay-main
|
||||
name: '@deepseek-ai/dsh-llm-replay'
|
||||
config:
|
||||
overrideFile: ./.missing-main-replay-override.json
|
||||
providers:
|
||||
- id: deepseek-official
|
||||
name: DeepSeek
|
||||
models:
|
||||
- id: deepseek-v4-flash
|
||||
- id: llm-replay-title
|
||||
name: '@deepseek-ai/dsh-llm-replay'
|
||||
config:
|
||||
providers:
|
||||
- id: title-replay
|
||||
name: Title replay
|
||||
models:
|
||||
- id: title-model
|
||||
reasoningEfforts: [low, high]
|
||||
defaultReasoningEffort: high
|
||||
- id: session-title-provider
|
||||
name: '@deepseek-ai/dsh-session-title-all-prompts-llm'
|
||||
config:
|
||||
targetWords: 5
|
||||
targetCjkCharacters: 10
|
||||
maxInputBytes: 4096
|
||||
maxOutputTokens: 32
|
||||
timeoutMs: 5000
|
||||
provider: title-replay
|
||||
model: title-model
|
||||
@@ -0,0 +1,12 @@
|
||||
# Continuous session-title composition with the shipped all-prompts provider.
|
||||
- insert:
|
||||
- id: session-title-provider
|
||||
name: '@deepseek-ai/dsh-session-title-all-prompts-llm'
|
||||
config:
|
||||
targetWords: 5
|
||||
targetCjkCharacters: 10
|
||||
maxInputBytes: 4096
|
||||
maxOutputTokens: 32
|
||||
timeoutMs: 5000
|
||||
provider: deepseek-official
|
||||
model: deepseek-v4-flash
|
||||
@@ -0,0 +1,146 @@
|
||||
[
|
||||
{
|
||||
"kind": "chunks",
|
||||
"chunks": [
|
||||
{
|
||||
"type": "block-start",
|
||||
"index": 0,
|
||||
"blockType": "text"
|
||||
},
|
||||
{
|
||||
"type": "text-delta",
|
||||
"index": 0,
|
||||
"text": "阳台种番茄与罗勒"
|
||||
},
|
||||
{
|
||||
"type": "block-end",
|
||||
"index": 0,
|
||||
"block": {
|
||||
"type": "text",
|
||||
"text": "阳台种番茄与罗勒"
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "usage",
|
||||
"usage": {
|
||||
"inputTokens": 12,
|
||||
"outputTokens": 4
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "finish",
|
||||
"reason": {
|
||||
"kind": "stop"
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"kind": "chunks",
|
||||
"chunks": [
|
||||
{
|
||||
"type": "block-start",
|
||||
"index": 0,
|
||||
"blockType": "text"
|
||||
},
|
||||
{
|
||||
"type": "text-delta",
|
||||
"index": 0,
|
||||
"text": "阳台种番茄与罗勒"
|
||||
},
|
||||
{
|
||||
"type": "block-end",
|
||||
"index": 0,
|
||||
"block": {
|
||||
"type": "text",
|
||||
"text": "阳台种番茄与罗勒"
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "usage",
|
||||
"usage": {
|
||||
"inputTokens": 12,
|
||||
"outputTokens": 4
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "finish",
|
||||
"reason": {
|
||||
"kind": "stop"
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"kind": "chunks",
|
||||
"chunks": [
|
||||
{
|
||||
"type": "block-start",
|
||||
"index": 0,
|
||||
"blockType": "text"
|
||||
},
|
||||
{
|
||||
"type": "text-delta",
|
||||
"index": 0,
|
||||
"text": "阳台种番茄与罗勒"
|
||||
},
|
||||
{
|
||||
"type": "block-end",
|
||||
"index": 0,
|
||||
"block": {
|
||||
"type": "text",
|
||||
"text": "阳台种番茄与罗勒"
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "usage",
|
||||
"usage": {
|
||||
"inputTokens": 12,
|
||||
"outputTokens": 4
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "finish",
|
||||
"reason": {
|
||||
"kind": "stop"
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"kind": "chunks",
|
||||
"chunks": [
|
||||
{
|
||||
"type": "block-start",
|
||||
"index": 0,
|
||||
"blockType": "text"
|
||||
},
|
||||
{
|
||||
"type": "text-delta",
|
||||
"index": 0,
|
||||
"text": "制作网页待办清单"
|
||||
},
|
||||
{
|
||||
"type": "block-end",
|
||||
"index": 0,
|
||||
"block": {
|
||||
"type": "text",
|
||||
"text": "制作网页待办清单"
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "usage",
|
||||
"usage": {
|
||||
"inputTokens": 12,
|
||||
"outputTokens": 4
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "finish",
|
||||
"reason": {
|
||||
"kind": "stop"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
@@ -0,0 +1,49 @@
|
||||
{"type":"session","version":4,"id":"{{session:1}}","createdAt":0,"cwd":"{{cwd}}","isSeeded":false,"delegationDepth":0}
|
||||
{"type":"permission/preset","data":{"preset":"danger-full-access"}}
|
||||
{"type":"sandbox/mode","data":{"mode":"danger-full-access"}}
|
||||
{"type":"approval/policy","data":{"policy":"never"}}
|
||||
{"type":"agent/inbox/spliced","data":{"target":"next-turn","start":0,"inserted":[{"content":[{"type":"text","text":"我想在阳台种番茄和罗勒,请给入门建议。"}],"source":{"kind":"user"},"role":"user","id":"{{message:1}}"}]}}
|
||||
{"type":"turn/start","data":{"turn":1}}
|
||||
{"type":"agent/inbox/spliced","data":{"target":"next-turn","start":0,"removedCount":1,"inserted":[]}}
|
||||
{"type":"step/start","data":{"turn":1,"step":1}}
|
||||
{"type":"system/message","data":{"turn":1,"step":1,"message":{"role":"system","content":[{"type":"text","text":"{{system}}"}],"source":{"kind":"system-prompt"},"id":"{{message:2}}"}},"surfaceOp":"append"}
|
||||
{"type":"user/message","data":{"content":[{"type":"text","text":"我想在阳台种番茄和罗勒,请给入门建议。"}],"source":{"kind":"user"},"role":"user","id":"{{message:1}}"},"surfaceOp":"append"}
|
||||
{"type":"user/message","data":{"content":[{"type":"text","text":"Current runtime context. This snapshot supersedes earlier runtime-context snapshots.\n\nCurrent working directory: \"{{cwd}}\".\n\nCurrent DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations.\n\nApproval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`)."}],"source":{"kind":"runtime-context","form":"snapshot","sections":[{"name":"working-directory:current","text":"Current working directory: \"{{cwd}}\"."},{"name":"sandbox:policy","text":"Current DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations."},{"name":"approval:policy","text":"Approval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`)."}]},"role":"user","id":"{{message:3}}"},"surfaceOp":"append"}
|
||||
{"type":"request/header","data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"tools":"{{tools}}"},"reason":"initial"}}
|
||||
{"type":"request/context","data":{"provider":"deepseek-official","model":"deepseek-v4-flash"}}
|
||||
{"type":"session/title","data":{"title":"我想在阳台种番茄和罗勒,请","messageSeqs":[8],"source":{"kind":"fallback"}}}
|
||||
{"type":"session/title-llm-request","data":{"titleProvider":"session-title-all-prompts-llm","messageSeqs":[8],"route":{"provider":"title-replay","model":"title-model"},"system":"Create a concise title for an AI coding-assistant session from the supplied human messages.\nReturn 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.\nUse the language of the messages.\nAim for about 5 words in non-CJK languages or 10 CJK characters.\nIf the messages give little to name, still return a short best-effort title, such as Greeting, instead of explaining.","messages":[{"content":[{"type":"text","text":"Generate the session title from this JSON array of human messages:\n[{\"seq\":8,\"text\":\"我想在阳台种番茄和罗勒,请给入门建议。\"}]"}],"source":{"kind":"dsh-session-title-llm"},"role":"user","id":"{{message:4}}"}],"maxTokens":32,"reasoningEffort":"low"}}
|
||||
{"type":"session/title","data":{"title":"阳台种番茄与罗勒","messageSeqs":[8],"source":{"kind":"provider","provider":"session-title-all-prompts-llm","model":{"provider":"title-replay","model":"title-model"}}}}
|
||||
{"type":"assistant/message","data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"text","text":"ACKNOWLEDGED"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"{{message:5}}"},"usage":{"inputTokens":10,"outputTokens":2},"stream":[{"type":"chunk","time":1791466311688,"chunk":{"type":"block-start","index":0,"blockType":"text"}},{"type":"text-chunks","time0":1791466311688,"index":0,"dt":[],"texts":["ACKNOWLEDGED"]},{"type":"chunk","time":1791466311688,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"ACKNOWLEDGED"}}},{"type":"chunk","time":1791466311688,"chunk":{"type":"usage","usage":{"inputTokens":10,"outputTokens":2}}},{"type":"chunk","time":1791466311688,"chunk":{"type":"finish","reason":{"kind":"stop"}}}]},"surfaceOp":"append"}
|
||||
{"type":"step/end","data":{"turn":1,"step":1}}
|
||||
{"type":"turn/end","data":{"turn":1,"reason":{"kind":"completed"}}}
|
||||
{"type":"agent/inbox/spliced","data":{"target":"next-turn","start":0,"inserted":[{"content":[{"type":"text","text":"花盆要多大?"}],"source":{"kind":"user"},"role":"user","id":"{{message:6}}"}]}}
|
||||
{"type":"turn/start","data":{"turn":2}}
|
||||
{"type":"agent/inbox/spliced","data":{"target":"next-turn","start":0,"removedCount":1,"inserted":[]}}
|
||||
{"type":"step/start","data":{"turn":2,"step":1}}
|
||||
{"type":"user/message","data":{"content":[{"type":"text","text":"花盆要多大?"}],"source":{"kind":"user"},"role":"user","id":"{{message:6}}"},"surfaceOp":"append"}
|
||||
{"type":"session/title-llm-request","data":{"titleProvider":"session-title-all-prompts-llm","messageSeqs":[8,22],"route":{"provider":"title-replay","model":"title-model"},"system":"Create a concise title for an AI coding-assistant session from the supplied human messages.\nReturn 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.\nUse the language of the messages.\nAim for about 5 words in non-CJK languages or 10 CJK characters.\nIf the messages give little to name, still return a short best-effort title, such as Greeting, instead of explaining.\nAn existing title is supplied as currentTitle. If it still accurately describes the main topic or task, return it exactly unchanged.\nFollow-up questions, additional details within the same topic, acknowledgements such as \"thanks\", and requests to continue do not by themselves justify a title change.\nDo not reword, polish, shorten, or replace synonyms in an adequate title. Keeping its exact wording takes priority over the target length.\nChange the title only when the messages materially change or expand the main topic or task so that the existing title is no longer accurate.","messages":[{"content":[{"type":"text","text":"Update the session title from this JSON object:\n{\"currentTitle\":\"阳台种番茄与罗勒\",\"messages\":[{\"seq\":8,\"text\":\"我想在阳台种番茄和罗勒,请给入门建议。\"},{\"seq\":22,\"text\":\"花盆要多大?\"}]}"}],"source":{"kind":"dsh-session-title-llm"},"role":"user","id":"{{message:7}}"}],"maxTokens":32,"reasoningEffort":"low"}}
|
||||
{"type":"session/title","data":{"title":"阳台种番茄与罗勒","messageSeqs":[8,22],"source":{"kind":"provider","provider":"session-title-all-prompts-llm","model":{"provider":"title-replay","model":"title-model"}}}}
|
||||
{"type":"assistant/message","data":{"turn":2,"step":1,"message":{"role":"assistant","content":[{"type":"text","text":"ACKNOWLEDGED"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"{{message:8}}"},"usage":{"inputTokens":10,"outputTokens":2},"stream":[{"type":"chunk","time":1791466311714,"chunk":{"type":"block-start","index":0,"blockType":"text"}},{"type":"text-chunks","time0":1791466311714,"index":0,"dt":[],"texts":["ACKNOWLEDGED"]},{"type":"chunk","time":1791466311714,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"ACKNOWLEDGED"}}},{"type":"chunk","time":1791466311714,"chunk":{"type":"usage","usage":{"inputTokens":10,"outputTokens":2}}},{"type":"chunk","time":1791466311714,"chunk":{"type":"finish","reason":{"kind":"stop"}}}]},"surfaceOp":"append"}
|
||||
{"type":"step/end","data":{"turn":2,"step":1}}
|
||||
{"type":"turn/end","data":{"turn":2,"reason":{"kind":"completed"}}}
|
||||
{"type":"agent/inbox/spliced","data":{"target":"next-turn","start":0,"inserted":[{"content":[{"type":"text","text":"谢谢"}],"source":{"kind":"user"},"role":"user","id":"{{message:9}}"}]}}
|
||||
{"type":"turn/start","data":{"turn":3}}
|
||||
{"type":"agent/inbox/spliced","data":{"target":"next-turn","start":0,"removedCount":1,"inserted":[]}}
|
||||
{"type":"step/start","data":{"turn":3,"step":1}}
|
||||
{"type":"user/message","data":{"content":[{"type":"text","text":"谢谢"}],"source":{"kind":"user"},"role":"user","id":"{{message:9}}"},"surfaceOp":"append"}
|
||||
{"type":"session/title-llm-request","data":{"titleProvider":"session-title-all-prompts-llm","messageSeqs":[8,22,32],"route":{"provider":"title-replay","model":"title-model"},"system":"Create a concise title for an AI coding-assistant session from the supplied human messages.\nReturn 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.\nUse the language of the messages.\nAim for about 5 words in non-CJK languages or 10 CJK characters.\nIf the messages give little to name, still return a short best-effort title, such as Greeting, instead of explaining.\nAn existing title is supplied as currentTitle. If it still accurately describes the main topic or task, return it exactly unchanged.\nFollow-up questions, additional details within the same topic, acknowledgements such as \"thanks\", and requests to continue do not by themselves justify a title change.\nDo not reword, polish, shorten, or replace synonyms in an adequate title. Keeping its exact wording takes priority over the target length.\nChange the title only when the messages materially change or expand the main topic or task so that the existing title is no longer accurate.","messages":[{"content":[{"type":"text","text":"Update the session title from this JSON object:\n{\"currentTitle\":\"阳台种番茄与罗勒\",\"messages\":[{\"seq\":8,\"text\":\"我想在阳台种番茄和罗勒,请给入门建议。\"},{\"seq\":22,\"text\":\"花盆要多大?\"},{\"seq\":32,\"text\":\"谢谢\"}]}"}],"source":{"kind":"dsh-session-title-llm"},"role":"user","id":"{{message:10}}"}],"maxTokens":32,"reasoningEffort":"low"}}
|
||||
{"type":"session/title","data":{"title":"阳台种番茄与罗勒","messageSeqs":[8,22,32],"source":{"kind":"provider","provider":"session-title-all-prompts-llm","model":{"provider":"title-replay","model":"title-model"}}}}
|
||||
{"type":"assistant/message","data":{"turn":3,"step":1,"message":{"role":"assistant","content":[{"type":"text","text":"ACKNOWLEDGED"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"{{message:11}}"},"usage":{"inputTokens":10,"outputTokens":2},"stream":[{"type":"chunk","time":1791466311739,"chunk":{"type":"block-start","index":0,"blockType":"text"}},{"type":"text-chunks","time0":1791466311739,"index":0,"dt":[],"texts":["ACKNOWLEDGED"]},{"type":"chunk","time":1791466311739,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"ACKNOWLEDGED"}}},{"type":"chunk","time":1791466311739,"chunk":{"type":"usage","usage":{"inputTokens":10,"outputTokens":2}}},{"type":"chunk","time":1791466311739,"chunk":{"type":"finish","reason":{"kind":"stop"}}}]},"surfaceOp":"append"}
|
||||
{"type":"step/end","data":{"turn":3,"step":1}}
|
||||
{"type":"turn/end","data":{"turn":3,"reason":{"kind":"completed"}}}
|
||||
{"type":"agent/inbox/spliced","data":{"target":"next-turn","start":0,"inserted":[{"content":[{"type":"text","text":"换个话题:帮我制作一个网页待办清单。"}],"source":{"kind":"user"},"role":"user","id":"{{message:12}}"}]}}
|
||||
{"type":"turn/start","data":{"turn":4}}
|
||||
{"type":"agent/inbox/spliced","data":{"target":"next-turn","start":0,"removedCount":1,"inserted":[]}}
|
||||
{"type":"step/start","data":{"turn":4,"step":1}}
|
||||
{"type":"user/message","data":{"content":[{"type":"text","text":"换个话题:帮我制作一个网页待办清单。"}],"source":{"kind":"user"},"role":"user","id":"{{message:12}}"},"surfaceOp":"append"}
|
||||
{"type":"session/title-llm-request","data":{"titleProvider":"session-title-all-prompts-llm","messageSeqs":[8,22,32,42],"route":{"provider":"title-replay","model":"title-model"},"system":"Create a concise title for an AI coding-assistant session from the supplied human messages.\nReturn 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.\nUse the language of the messages.\nAim for about 5 words in non-CJK languages or 10 CJK characters.\nIf the messages give little to name, still return a short best-effort title, such as Greeting, instead of explaining.\nAn existing title is supplied as currentTitle. If it still accurately describes the main topic or task, return it exactly unchanged.\nFollow-up questions, additional details within the same topic, acknowledgements such as \"thanks\", and requests to continue do not by themselves justify a title change.\nDo not reword, polish, shorten, or replace synonyms in an adequate title. Keeping its exact wording takes priority over the target length.\nChange the title only when the messages materially change or expand the main topic or task so that the existing title is no longer accurate.","messages":[{"content":[{"type":"text","text":"Update the session title from this JSON object:\n{\"currentTitle\":\"阳台种番茄与罗勒\",\"messages\":[{\"seq\":8,\"text\":\"我想在阳台种番茄和罗勒,请给入门建议。\"},{\"seq\":22,\"text\":\"花盆要多大?\"},{\"seq\":32,\"text\":\"谢谢\"},{\"seq\":42,\"text\":\"换个话题:帮我制作一个网页待办清单。\"}]}"}],"source":{"kind":"dsh-session-title-llm"},"role":"user","id":"{{message:13}}"}],"maxTokens":32,"reasoningEffort":"low"}}
|
||||
{"type":"session/title","data":{"title":"制作网页待办清单","messageSeqs":[8,22,32,42],"source":{"kind":"provider","provider":"session-title-all-prompts-llm","model":{"provider":"title-replay","model":"title-model"}}}}
|
||||
{"type":"assistant/message","data":{"turn":4,"step":1,"message":{"role":"assistant","content":[{"type":"text","text":"ACKNOWLEDGED"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"{{message:14}}"},"usage":{"inputTokens":10,"outputTokens":2},"stream":[{"type":"chunk","time":1791466311763,"chunk":{"type":"block-start","index":0,"blockType":"text"}},{"type":"text-chunks","time0":1791466311763,"index":0,"dt":[],"texts":["ACKNOWLEDGED"]},{"type":"chunk","time":1791466311763,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"ACKNOWLEDGED"}}},{"type":"chunk","time":1791466311763,"chunk":{"type":"usage","usage":{"inputTokens":10,"outputTokens":2}}},{"type":"chunk","time":1791466311763,"chunk":{"type":"finish","reason":{"kind":"stop"}}}]},"surfaceOp":"append"}
|
||||
{"type":"step/end","data":{"turn":4,"step":1}}
|
||||
{"type":"turn/end","data":{"turn":4,"reason":{"kind":"completed"}}}
|
||||
@@ -0,0 +1,12 @@
|
||||
version: 1
|
||||
scenario: session-title-stability
|
||||
profile: sdk
|
||||
composition: session-title-stability
|
||||
recording: authored
|
||||
header:
|
||||
class: session-title-stability
|
||||
pin: true
|
||||
systemPromptSource: session/text-turn
|
||||
toolSchemasSource: session/text-turn
|
||||
replay:
|
||||
override: true
|
||||
Reference in new issue
Block a user