fix(session-title): enforce framed input and deadline

This commit is contained in:
Tianyi Cui
2026-07-21 15:00:15 +08:00
parent d9557fa5e0
commit 9b048354b8
7 changed files with 61 additions and 16 deletions

View File

@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write
2026-07-21-log-backed-session-titles.md: 483fbdba1f89703f4baeadfe76623e143a008c7e
2026-07-21-log-backed-session-titles.zh.md: 4e5d16196dbd4dd498489ab0dd7939e295522a08
2026-07-21-log-backed-session-titles.md: c78f4f16d12cdfacbf7bf5d494e93276d6ac9769
2026-07-21-log-backed-session-titles.zh.md: 845ea9fbc1c50574d61086b9c744d2efe65cf433

View File

@@ -32,7 +32,7 @@ The first-message provider schedules once when a fresh session first creates its
`register(provider)` validates one branded stable id, cadence, and generation function, then returns an awaitable effect disposer. A second live registration throws immediately. Provider disposal marks the registration closing, aborts its pending and active work, and waits for every call to settle before removing the registration, so replacement cannot overlap a provider that ignores cancellation. Session disposal aborts its active work. Service teardown prevents queued fallback and provider microtasks from starting, aborts active work, and drains tracked promises before unloading completes. Every session-local generation has a monotonic revision and exact registration identity; acceptance rechecks revision, registration, session liveness, service liveness, and cancellation, so stale output cannot commit.
Model providers require explicit word, CJK-character, input-byte, output-token, and timeout limits. Optional `provider` and `model` overrides are a pair; without them the helper uses the exact route from the logged main request header. Selected messages are framed as JSON under one fixed language-aware instruction. Oversized input is rejected rather than truncated because truncation would make the recorded source seqs falsely imply complete use.
Model providers require explicit word, CJK-character, input-byte, output-token, and timeout limits. Optional `provider` and `model` overrides are a pair; without them the helper uses the exact route from the logged main request header. Selected messages are framed as JSON under one fixed language-aware instruction. The input limit measures that final user prompt, including wrappers, seq fields, and JSON escaping, before the request is logged or dispatched. Oversized input is rejected rather than truncated because truncation would make the recorded source seqs falsely imply complete use. The fused deadline is checked while consuming each stream chunk and after completion, so a successful result returned after timeout cannot be accepted even when an interceptor or adapter ignores abort.
Automatic provider failures are nonfatal warnings and retain the latest title. Explicit refresh failures reject to the caller. Output must be non-empty text with unique ordered seqs drawn from the fixed request; the service normalizes and byte-limits it before durable acceptance.

View File

@@ -32,7 +32,7 @@ Status: implemented
`register(provider)` 会验证一个带品牌类型的稳定 id、执行时机和生成函数然后返回一个可等待完成的 effect 资源释放函数。第二个活跃注册会立即抛出错误。提供方执行资源释放时,会将注册标记为正在关闭,中止其待执行和活跃工作,并等待所有调用结束后才移除注册,因此替代提供方不会与忽略取消的旧提供方重叠运行。会话资源释放会中止其活跃工作。服务卸载时,会阻止排队中的回退和提供方微任务启动,中止活跃工作,并且卸载完成前会等待所有已跟踪的 promise 结算。每项会话本地生成都有单调递增的修订号和对应的注册身份;接受结果时会重新检查修订号、注册、会话活跃状态、服务活跃状态和取消状态,因此陈旧输出无法提交。
模型提供方必须显式配置单词数、CJK 字符数、输入字节数、输出 token 数和超时限制。可选的 `provider``model` 覆盖项必须成对提供;两者均未提供时,辅助组件会使用主请求已记录请求头中的准确路由。系统在一条固定且能区分语言的指令下,将选中的消息封装为 JSON。过大输入会被拒绝而不是截断,因为截断会让记录的源消息 seq 错误地表示这些消息已被完整使用
模型提供方必须显式配置单词数、CJK 字符数、输入字节数、输出 token 数和超时限制。可选的 `provider``model` 覆盖项必须成对提供;两者均未提供时,辅助组件会使用主请求已记录请求头中的准确路由。系统在一条固定且能区分语言的指令下,将选中的消息封装为 JSON。输入字节数按最终形成的用户提示词计算其中包括包装文本、seq 字段和 JSON 转义;系统会在记录请求或发起调用前完成这项检查。过大输入会被拒绝而不是截断,因为截断会让记录的源消息 seq 错误地表示这些消息已被完整使用。系统在消费每个流分片时以及流完成后都会检查融合后的截止时间,因此即使拦截器或适配器忽略中止信号,超时后返回的成功结果也不会被接受
自动提供方故障只会发出非致命警告,并保留最新标题。显式刷新失败则会向调用方返回拒绝。输出必须是非空文本,并包含来自固定请求、唯一且有序的 seq服务会在持久接受前对其进行规范化并施加字节限制。

View File

@@ -2,7 +2,7 @@
Optional `ctx.sessionTitle` provider that summarizes every eligible human message through `ctx.llm`. It registers the `all-user-messages` cadence and starts a new revision after each new human prompt, using seeded history as well as child-session prompts. A newer revision aborts and supersedes older work; even a provider that ignores cancellation cannot commit stale output.
The plugin uses the complete required [shared LLM configuration](../session-title-llm/README.md#configuration). Omit both `provider` and `model` to inherit the exact route from each current logged main request, or set both to route title generation independently. If aggregate input exceeds `maxInputBytes`, the request fails instead of truncating history; automatic use warns and keeps the prior title.
The plugin uses the complete required [shared LLM configuration](../session-title-llm/README.md#configuration). Omit both `provider` and `model` to inherit the exact route from each current logged main request, or set both to route title generation independently. If the final framed aggregate prompt exceeds `maxInputBytes`, the request fails instead of truncating history; automatic use warns and keeps the prior title.
## Model Experience

View File

@@ -6,7 +6,7 @@ This package is a library, not a Cordis plugin. The provider plugins call `regis
## Route and failure contract
`provider` and `model` overrides are optional but must be supplied together as non-empty strings. Without that pair, the helper uses the exact provider/model route captured from the current session's logged `request/header`; an explicit refresh before any route exists therefore needs overrides. Input exceeding `maxInputBytes` rejects instead of being truncated. Timeout, cancellation, malformed or empty output, tool calls, and non-stop finish reasons also reject; the session-title service decides whether that rejection is an automatic warning or an explicit caller failure.
`provider` and `model` overrides are optional but must be supplied together as non-empty strings. Without that pair, the helper uses the exact provider/model route captured from the current session's logged `request/header`; an explicit refresh before any route exists therefore needs overrides. The helper measures the final JSON-framed user prompt, including seq fields, wrappers, and JSON escaping, against `maxInputBytes` before logging or dispatch instead of truncating it. Timeout and caller cancellation are rechecked while consuming the stream and after it completes, so a late successful result cannot be accepted even if an interceptor or adapter ignores abort. Malformed or empty output, tool calls, and non-stop finish reasons also reject; the session-title service decides whether that rejection is an automatic warning or an explicit caller failure.
After route and input validation, the helper appends a log-only `session/title-llm-request` event before model dispatch. It contains the title-provider id, exact source seqs, route, system prompt, message list, and output-token cap used by the call. The append shares the title capability's per-session settlement queue, so a superseding request cannot collide with an earlier fallback, request record, or accepted-title flush. The dispatched envelope is deep-frozen to keep interceptors aligned with that record but deliberately lacks dsh-agent-loop's process-local request identity, so loop-only reconstruction observers do not compare it with the conversation header. A later model failure leaves that request record intact; validation failures that never become dispatchable requests do not create one. The event stays outside derived model history.
@@ -18,7 +18,7 @@ Every field is required except the paired route override; there are no library d
|---|---|
| `targetWords` | Positive target word count for non-CJK titles. |
| `targetCjkCharacters` | Positive target character count for Chinese, Japanese, or Korean titles. |
| `maxInputBytes` | Positive aggregate UTF-8 byte ceiling across selected messages. |
| `maxInputBytes` | Positive UTF-8 byte ceiling for the final JSON-framed user prompt. |
| `maxOutputTokens` | Positive auxiliary generation token cap. |
| `timeoutMs` | Positive end-to-end deadline within the runtime timer limit. |
| `provider`, `model` | Optional explicit route; both or neither. |
@@ -42,4 +42,4 @@ No main-request invalidation. Auxiliary cache reuse is provider-specific; the fi
## Known Limitations and Deferred Work
- The helper accepts text output only and rejects tool calls; structured-output adapters and provider-specific prompt variants are not exposed.
- It enforces a byte ceiling for the whole selected input rather than clipping individual messages or applying a retention policy.
- It enforces a byte ceiling for the whole framed user prompt rather than clipping individual messages or applying a retention policy.

View File

@@ -58,7 +58,7 @@ export interface SessionTitleLlmConfig {
readonly targetWords: number
/** Target character count for Chinese, Japanese, or Korean titles. */
readonly targetCjkCharacters: number
/** Maximum total UTF-8 bytes across selected source-message text. */
/** Maximum UTF-8 bytes in the final JSON-framed user prompt. */
readonly maxInputBytes: number
/** Auxiliary generation output-token cap. */
readonly maxOutputTokens: number
@@ -242,14 +242,15 @@ export async function generateSessionTitleWithLlm(
if (selectedMessages.length === 0) {
throw new Error('session-title-llm: at least one source message is required')
}
const inputBytes = selectedMessages.reduce((total, message) => total + Buffer.byteLength(message.text, 'utf8'), 0)
const framedInput = frameMessages(selectedMessages)
const inputBytes = Buffer.byteLength(framedInput, 'utf8')
if (inputBytes > config.maxInputBytes) {
throw new Error(`session-title-llm: input is ${inputBytes} bytes, exceeding maxInputBytes ${config.maxInputBytes}`)
}
const route = resolveRoute(config, request)
const messages: Message[] = [{
role: 'user',
content: [{ type: 'text', text: frameMessages(selectedMessages) }],
content: [{ type: 'text', text: framedInput }],
}]
const system = systemPrompt(config)
using callDeadline = deadline(request.signal, config.timeoutMs, SESSION_TITLE_TIMEOUT_CODE)
@@ -272,7 +273,11 @@ export async function generateSessionTitleWithLlm(
}, callDeadline.signal)
callDeadline.signal.throwIfAborted()
const assembler = new BlockAssembler()
for await (const chunk of ctx.llm.stream(options)) assembler.push(chunk)
for await (const chunk of ctx.llm.stream(options)) {
callDeadline.signal.throwIfAborted()
assembler.push(chunk)
}
callDeadline.signal.throwIfAborted()
const terminalError = finishError(assembler.finish)
if (terminalError !== undefined) throw terminalError
const blocks = assembler.message().content

View File

@@ -48,6 +48,17 @@ class CooperativeAdapter extends LlmAdapter {
}
}
class DelayedSuccessAdapter extends LlmAdapter {
constructor(private readonly delayMs: number) {
super()
}
override async * stream(): AsyncIterable<StreamChunk> {
await new Promise<void>(resolve => setTimeout(resolve, this.delayMs))
yield * SCRIPT
}
}
const SCRIPT: StreamChunk[] = [
{ type: 'block-start', index: 0, blockType: 'text' },
{ type: 'text-delta', index: 0, text: ' 五个字标题 ' },
@@ -162,21 +173,24 @@ describe('generateSessionTitleWithLlm', () => {
})
})
it('uses paired explicit overrides and rejects an oversized input without calling the model', async () => {
it('uses paired explicit overrides and bounds the final framed input before model dispatch', async () => {
const ctx = new Context()
await ctx.plugin(SessionStore)
await ctx.plugin(LlmService)
const adapter = new RecordingAdapter(SCRIPT)
ctx.llm.registerAdapter(['explicit-route'], adapter)
const oversized = request(ctx)
const [selected] = oversized.messages
if (selected === undefined) throw new Error('expected one selected message')
const rawInputBytes = Buffer.byteLength(selected.text, 'utf8')
const config = resolveSessionTitleLlmConfig({
...CONFIG,
provider: 'explicit-route',
model: 'explicit-model',
maxInputBytes: 4,
maxInputBytes: rawInputBytes,
})
const oversized = request(ctx)
await expect(generateSessionTitleWithLlm(ctx, config, oversized, oversized.messages, TITLE_PROVIDER))
await expect(generateSessionTitleWithLlm(ctx, config, oversized, [selected], TITLE_PROVIDER))
.rejects.toThrow(/input.*bytes.*maxInputBytes/i)
expect(adapter.requests).toEqual([])
expect(oversized.session.events.some(event => event.type === 'session/title-llm-request')).toBe(false)
@@ -322,4 +336,30 @@ describe('generateSessionTitleWithLlm', () => {
vi.useRealTimers()
}
})
it('rejects a successful stream that completes after the configured deadline', async () => {
vi.useFakeTimers()
try {
const ctx = new Context()
await ctx.plugin(SessionStore)
await ctx.plugin(LlmService)
ctx.llm.registerAdapter(['current-route'], new DelayedSuccessAdapter(20))
const providerRequest = request(ctx)
const pending = generateSessionTitleWithLlm(
ctx,
resolveSessionTitleLlmConfig({ ...CONFIG, timeoutMs: 10 }),
providerRequest,
providerRequest.messages,
TITLE_PROVIDER,
)
const rejected = expect(pending).rejects.toMatchObject({
code: SESSION_TITLE_TIMEOUT_CODE,
timeoutMs: 10,
})
await vi.advanceTimersByTimeAsync(20)
await rejected
} finally {
vi.useRealTimers()
}
})
})