fix(session-title): enforce framed input and deadline
This commit is contained in:
@@ -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
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user