fix(web): generate model-backed session titles
This commit is contained in:
@@ -1409,7 +1409,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [
|
||||
},
|
||||
{
|
||||
name: 'GenerateOptions',
|
||||
declaration: 'export interface GenerateOptions {\n provider: string;\n model: string;\n messages: Message[];\n system?: string;\n tools?: ToolSchema[];\n temperature?: number;\n maxTokens?: number;\n stop?: string[];\n signal?: AbortSignal;\n sessionId?: Branded<\'SessionId\'>;\n purpose?: \'compaction\';\n}',
|
||||
declaration: 'export interface GenerateOptions {\n provider: string;\n model: string;\n messages: Message[];\n system?: string;\n tools?: ToolSchema[];\n temperature?: number;\n maxTokens?: number;\n stop?: string[];\n signal?: AbortSignal;\n sessionId?: Branded<\'SessionId\'>;\n purpose?: \'compaction\' | \'session-title\';\n}',
|
||||
},
|
||||
{
|
||||
name: 'GenericCallView',
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# @deepseek-ai/dsh-host-runtime
|
||||
|
||||
Host runtime assembly for `dsh`: `bootHost` composes the core plugin spine (LLM service + DeepSeek adapter, sessions with JSONL persistence and deterministic fallback titles, system prompt, tools, agents, agent loop, local bash), `createApiProxy` implements the [`dsh-host-apiproxy`](../apiproxy/README.md) contract over that composition, and `startHost` is the one-step shell seam returning `{ api, handler, defaults, ctx, dispose }`.
|
||||
Host runtime assembly for `dsh`: `bootHost` composes the core plugin spine (LLM service + DeepSeek adapter, sessions with JSONL persistence, immediate fallback titles and first-message model summaries, system prompt, tools, agents, agent loop, local bash), `createApiProxy` implements the [`dsh-host-apiproxy`](../apiproxy/README.md) contract over that composition, and `startHost` is the one-step shell seam returning `{ api, handler, defaults, ctx, dispose }`.
|
||||
|
||||
Which plugins mount and with what defaults is decided only here — shells must not `ctx.plugin` to alter the assembly. `RunningHost.ctx` is a formal seam with exactly two sanctioned uses: mounting protocol front-door plugins (e.g. a future `dsh acp`) and headless session-event subscription; consuming clients must not bypass `api` through it.
|
||||
|
||||
@@ -12,7 +12,8 @@ Which plugins mount and with what defaults is decided only here — shells must
|
||||
| `provider` | `'deepseek'` | Default provider route injected as agentOptions on create/resume and reported by `host.describe`. |
|
||||
| `model` | `'deepseek-v4-flash'` | Default model id, same single source as `provider`. |
|
||||
| `cwd` | `process.cwd()` | Default project directory for a session whose create request omits `cwd`. |
|
||||
| `sessionTitle` | 5 words / 40 fallback bytes / 80 accepted bytes | Deterministic fallback-title limits. The host mounts no asynchronous title provider, so title creation adds no model call. |
|
||||
| `sessionTitle` | 5 words / 40 fallback bytes / 80 accepted bytes | Deterministic fallback and accepted-title limits. |
|
||||
| `sessionTitleLlm` | 5 words / 10 CJK chars / 4,096 input bytes / 64 output tokens / 60 s | First-message model-title policy. An omitted route inherits the logged main-request provider and model. |
|
||||
|
||||
## ApiProxy implementation notes
|
||||
|
||||
@@ -20,11 +21,11 @@ Unary methods take the narrow `RpcRequest<P>` and echo `request.rpcId`; a prompt
|
||||
|
||||
## Model Experience
|
||||
|
||||
Indirectly, through the model-facing plugins bootHost mounts and the provider/model defaults injected into created and resumed agents.
|
||||
Indirectly, through the non-blocking first-message title request owned by [`dsh-session-title-llm`](../../session-title/session-title-llm/README.md) and the other model-facing plugins `bootHost` mounts.
|
||||
|
||||
#### KV Cache effect
|
||||
|
||||
No direct invalidation; the mounted model-facing plugins own their request-prefix changes.
|
||||
No main-request invalidation; the auxiliary title request has its own cache behavior and the conversation prefix remains unchanged.
|
||||
|
||||
## Known Limitations and Deferred Work
|
||||
|
||||
|
||||
@@ -50,6 +50,7 @@
|
||||
"@deepseek-ai/dsh-session-persistence": "workspace:^",
|
||||
"@deepseek-ai/dsh-session-persistence-jsonl": "workspace:^",
|
||||
"@deepseek-ai/dsh-session-title": "workspace:^",
|
||||
"@deepseek-ai/dsh-session-title-first-message-llm": "workspace:^",
|
||||
"@deepseek-ai/dsh-skill": "workspace:^",
|
||||
"@deepseek-ai/dsh-skill-local": "workspace:^",
|
||||
"@deepseek-ai/dsh-spill-local": "workspace:^",
|
||||
|
||||
@@ -9,6 +9,8 @@ import Timer from '@cordisjs/plugin-timer'
|
||||
import LlmService from '@deepseek-ai/dsh-llm'
|
||||
import SessionStore from '@deepseek-ai/dsh-session'
|
||||
import SessionTitleService, { type Config as SessionTitleConfig } from '@deepseek-ai/dsh-session-title'
|
||||
import * as SessionTitleFirstMessageLlm from '@deepseek-ai/dsh-session-title-first-message-llm'
|
||||
import type { Config as SessionTitleLlmConfig } from '@deepseek-ai/dsh-session-title-first-message-llm'
|
||||
import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
|
||||
import ToolRegistry from '@deepseek-ai/dsh-tools'
|
||||
import AgentRegistry from '@deepseek-ai/dsh-agent'
|
||||
@@ -46,6 +48,15 @@ const DEFAULT_SESSION_TITLE_CONFIG: SessionTitleConfig = {
|
||||
maxTitleBytes: 80,
|
||||
}
|
||||
|
||||
/** Default first-message model-title policy for sessions created through the host. */
|
||||
const DEFAULT_SESSION_TITLE_LLM_CONFIG: SessionTitleLlmConfig = {
|
||||
targetWords: 5,
|
||||
targetCjkCharacters: 10,
|
||||
maxInputBytes: 4_096,
|
||||
maxOutputTokens: 64,
|
||||
timeoutMs: 60_000,
|
||||
}
|
||||
|
||||
/** Options for bootHost — the assembly-layer composition knobs. */
|
||||
export interface BootHostOptions {
|
||||
/** Root directory for JSONL session persistence. */
|
||||
@@ -54,8 +65,10 @@ export interface BootHostOptions {
|
||||
provider?: string
|
||||
/** Default model id (defaults to 'deepseek-v4-flash', matching the demos). */
|
||||
model?: string
|
||||
/** Deterministic fallback-title limits; no asynchronous title provider is mounted by the host. */
|
||||
/** Deterministic fallback-title limits. */
|
||||
sessionTitle?: SessionTitleConfig
|
||||
/** First-message model-title policy; omitted provider/model inherit the session's logged main-request route. */
|
||||
sessionTitleLlm?: SessionTitleLlmConfig
|
||||
/**
|
||||
* Default project directory for sessions created without an explicit cwd
|
||||
* (defaults to the host process working directory). A session's cwd is its
|
||||
@@ -100,6 +113,7 @@ export async function bootHost(options: BootHostOptions): Promise<HostHandle> {
|
||||
await ctx.plugin(LlmService)
|
||||
await ctx.plugin(SessionStore)
|
||||
await ctx.plugin(SessionTitleService, options.sessionTitle ?? DEFAULT_SESSION_TITLE_CONFIG)
|
||||
await ctx.plugin(SessionTitleFirstMessageLlm, options.sessionTitleLlm ?? DEFAULT_SESSION_TITLE_LLM_CONFIG)
|
||||
await ctx.plugin(SystemPrompt, { persona: '' })
|
||||
await ctx.plugin(ToolRegistry)
|
||||
await ctx.plugin(AgentRegistry)
|
||||
|
||||
@@ -9,6 +9,7 @@ import type { GenerateOptions, StreamChunk } from '@deepseek-ai/dsh-llm'
|
||||
import { LlmAdapter } from '@deepseek-ai/dsh-llm'
|
||||
import type { SessionId } from '@deepseek-ai/dsh-session'
|
||||
import type { Config as SessionTitleConfig } from '@deepseek-ai/dsh-session-title'
|
||||
import type { Config as SessionTitleLlmConfig } from '@deepseek-ai/dsh-session-title-first-message-llm'
|
||||
import type { HostFrame, MuxFrame } from '@deepseek-ai/dsh-host-apiproxy/api'
|
||||
import type { RpcRequest, RpcResponse } from '@deepseek-ai/dsh-host-apiproxy/api/rpc'
|
||||
import { RpcId } from '@deepseek-ai/dsh-host-apiproxy/api/rpc'
|
||||
@@ -21,6 +22,10 @@ class ScriptedAdapter extends LlmAdapter {
|
||||
}
|
||||
|
||||
async * stream(options: GenerateOptions): AsyncIterable<StreamChunk> {
|
||||
if ((options.tools?.length ?? 0) === 0) {
|
||||
yield * textResponse('Durable append-only session titles')
|
||||
return
|
||||
}
|
||||
const entry = this.script.shift()
|
||||
if (!entry) throw new Error('ScriptedAdapter: script exhausted')
|
||||
if (entry === 'hang') {
|
||||
@@ -96,6 +101,7 @@ afterEach(async () => {
|
||||
async function boot(
|
||||
script: (StreamChunk[] | 'hang')[] = [],
|
||||
sessionTitle?: SessionTitleConfig,
|
||||
sessionTitleLlm?: SessionTitleLlmConfig,
|
||||
): Promise<RunningHost> {
|
||||
host = await startHost({
|
||||
boot: {
|
||||
@@ -103,6 +109,7 @@ async function boot(
|
||||
provider: 'scripted',
|
||||
model: 'test-model',
|
||||
...(sessionTitle === undefined ? {} : { sessionTitle }),
|
||||
...(sessionTitleLlm === undefined ? {} : { sessionTitleLlm }),
|
||||
},
|
||||
})
|
||||
host.ctx.llm.registerAdapter(['scripted'], new ScriptedAdapter(script))
|
||||
@@ -158,6 +165,63 @@ describe('sessions.create / list', () => {
|
||||
})
|
||||
|
||||
describe('sessions.prompt / cancel', () => {
|
||||
it.each([
|
||||
{ name: 'host default', config: undefined, target: '5 words', maxTokens: 64 },
|
||||
{
|
||||
name: 'configured policy',
|
||||
config: {
|
||||
targetWords: 3,
|
||||
targetCjkCharacters: 8,
|
||||
maxInputBytes: 2_048,
|
||||
maxOutputTokens: 24,
|
||||
timeoutMs: 2_000,
|
||||
},
|
||||
target: '3 words',
|
||||
maxTokens: 24,
|
||||
},
|
||||
] satisfies {
|
||||
name: string
|
||||
config: SessionTitleLlmConfig | undefined
|
||||
target: string
|
||||
maxTokens: number
|
||||
}[])('replaces the fallback with a model-backed first-message title using the $name', async ({ config, target, maxTokens }) => {
|
||||
const modelTitle = 'Durable append-only session titles'
|
||||
const running = await boot([textResponse('pong')], undefined, config)
|
||||
const { api, ctx } = running
|
||||
const { sessionId } = expectOk(await api.sessions.create(request({})))
|
||||
const agent = ctx.agents.get(sessionId) as Agent
|
||||
const idle = waitForIdle(ctx, agent)
|
||||
expectOk(await api.sessions.prompt(request({
|
||||
sessionId,
|
||||
mode: 'queue' as const,
|
||||
content: [{ type: 'text' as const, text: 'Explain why append-only logs make session titles durable.' }],
|
||||
})))
|
||||
await idle
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(agent.session.events.filter(event => event.type === 'session/title').map(event => event.data))
|
||||
.toEqual([
|
||||
{
|
||||
title: 'Explain why append-only logs make',
|
||||
messageSeqs: [1],
|
||||
source: { kind: 'fallback' },
|
||||
},
|
||||
{
|
||||
title: modelTitle,
|
||||
messageSeqs: [1],
|
||||
source: {
|
||||
kind: 'provider',
|
||||
provider: 'session-title-first-message-llm',
|
||||
model: { provider: 'scripted', model: 'test-model' },
|
||||
},
|
||||
},
|
||||
])
|
||||
})
|
||||
const titleRequest = agent.session.events.find(event => event.type === 'session/title-llm-request')
|
||||
expect(titleRequest?.data.system).toContain(target)
|
||||
expect(titleRequest?.data.maxTokens).toBe(maxTokens)
|
||||
})
|
||||
|
||||
it.each([
|
||||
{ name: 'host default', config: undefined, expected: 'Show the Web UI durable' },
|
||||
{
|
||||
|
||||
@@ -26,6 +26,9 @@
|
||||
{
|
||||
"path": "../../session-title/session-title"
|
||||
},
|
||||
{
|
||||
"path": "../../session-title/session-title-first-message-llm"
|
||||
},
|
||||
{
|
||||
"path": "../../core/system-prompt"
|
||||
},
|
||||
|
||||
@@ -32,7 +32,7 @@ The plugin registers the single provider route `deepseek`. A request selects it
|
||||
|
||||
`reasoningEffort` is **omitted by default** — when unset, the `reasoning_effort` wire field is not sent and the server applies its own default for the model. The only accepted values are `high` and `max` (DeepSeek's official effort levels). It is meaningful only with thinking enabled (the provider default).
|
||||
|
||||
`thinking`/`reasoningEffort` are adapter-level request defaults serialized as the official top-level `thinking: {type}` / `reasoning_effort` wire fields. They live in adapter config (not `GenerateOptions`) to keep the core vocabulary provider-neutral.
|
||||
`thinking`/`reasoningEffort` are adapter-level request defaults serialized as the official top-level `thinking: {type}` / `reasoning_effort` wire fields. They live in adapter config (not `GenerateOptions`) to keep the core vocabulary provider-neutral. A request with `GenerateOptions.purpose: 'session-title'` forces thinking disabled and omits `reasoning_effort`, reserving its bounded output for visible title text without changing conversation or compaction defaults.
|
||||
|
||||
`streamIdleTimeoutMs` bounds each outstanding provider read, including the initial `fetch`, without counting time the consumer spends between chunks. One stable abort signal reaches the request and body reader for the whole call; expiry stops the transport and throws `LlmError('TIMEOUT')`, while an earlier caller abort throws `LlmError('ABORTED')`. The adapter makes exactly one provider request per `stream()` call; agent-level retry is a separate plugin policy.
|
||||
|
||||
|
||||
@@ -118,14 +118,18 @@ export function serializeRequest(options: GenerateOptions, defaults: RequestDefa
|
||||
parameters: tool.parameters,
|
||||
},
|
||||
}))
|
||||
// A short title budget must produce visible text; conversation and
|
||||
// compaction calls continue to inherit the adapter's thinking defaults.
|
||||
const thinking = options.purpose === 'session-title' ? 'disabled' : defaults.thinking
|
||||
const reasoningEffort = options.purpose === 'session-title' ? undefined : defaults.reasoningEffort
|
||||
|
||||
return {
|
||||
model: options.model,
|
||||
messages,
|
||||
stream: true,
|
||||
stream_options: { include_usage: true },
|
||||
...defaults.thinking !== undefined ? { thinking: { type: defaults.thinking } } : {},
|
||||
...defaults.reasoningEffort !== undefined ? { reasoning_effort: defaults.reasoningEffort } : {},
|
||||
...thinking !== undefined ? { thinking: { type: thinking } } : {},
|
||||
...reasoningEffort !== undefined ? { reasoning_effort: reasoningEffort } : {},
|
||||
...tools !== undefined && tools.length > 0 ? { tools } : {},
|
||||
...options.temperature !== undefined ? { temperature: options.temperature } : {},
|
||||
...options.maxTokens !== undefined ? { max_tokens: options.maxTokens } : {},
|
||||
|
||||
@@ -180,6 +180,15 @@ describe('serializeRequest', () => {
|
||||
expect(wire.reasoning_effort).toBe('max')
|
||||
})
|
||||
|
||||
it('disables thinking for session-title requests without changing adapter defaults', () => {
|
||||
const wire = serializeRequest(
|
||||
request({ messages: history, purpose: 'session-title' }),
|
||||
{ thinking: 'enabled', reasoningEffort: 'max' },
|
||||
)
|
||||
expect(wire.thinking).toEqual({ type: 'disabled' })
|
||||
expect(wire.reasoning_effort).toBeUndefined()
|
||||
})
|
||||
|
||||
it('omits thinking fields when unset (provider default applies)', () => {
|
||||
const wire = serializeRequest(request({ messages: history }))
|
||||
expect(wire.thinking).toBeUndefined()
|
||||
|
||||
@@ -39,7 +39,7 @@ Streaming is a raw chunk protocol (`block-start`, `text-delta`, `reasoning-delta
|
||||
|
||||
### Call configuration (`call-config.ts`)
|
||||
|
||||
`LlmCallConfig` is the provider + model + sampling scalars of one conversation's requests (`provider`, `model`, `temperature`, `maxTokens`, `stop` — each mapping 1:1 onto the same-named `GenerateOptions` field). It is per-conversation state recorded in the session log as part of the request header (see the dsh-session `request/header` events), never a silently-adjustable per-call knob: the `agent/request` waterfall proposes a replacement and the loop logs a real change. `callConfigEquals(a, b)` is the field-wise real-change detector; `deepFreeze(value)` is the ownership helper the loop applies to every built request before dispatch (`llm/stream` listeners and adapters read, never rewrite). `markAgentLoopRequest()` gives that exact object process-local loop provenance, and `isAgentLoopRequest()` lets observers distinguish it from independently logged auxiliary calls that may also be frozen and session-associated.
|
||||
`LlmCallConfig` is the provider + model + sampling scalars of one conversation's requests (`provider`, `model`, `temperature`, `maxTokens`, `stop` — each mapping 1:1 onto the same-named `GenerateOptions` field). It is per-conversation state recorded in the session log as part of the request header (see the dsh-session `request/header` events), never a silently-adjustable per-call knob: the `agent/request` waterfall proposes a replacement and the loop logs a real change. `callConfigEquals(a, b)` is the field-wise real-change detector; `deepFreeze(value)` is the ownership helper the loop applies to every built request before dispatch (`llm/stream` listeners and adapters read, never rewrite). `markAgentLoopRequest()` gives that exact object process-local loop provenance, and `isAgentLoopRequest()` lets observers distinguish it from independently logged auxiliary calls that may also be frozen and session-associated. `GenerateOptions.purpose` classifies logged auxiliary compaction and session-title calls so adapters can apply purpose-specific transport policy without changing ordinary conversation requests.
|
||||
|
||||
### App attribution (`attribution.ts`)
|
||||
|
||||
|
||||
@@ -228,8 +228,8 @@ export interface GenerateOptions {
|
||||
sessionId?: Branded<'SessionId'>
|
||||
/**
|
||||
* Provider-neutral classification for an auxiliary model call. Adapters may
|
||||
* map the purpose to model-hidden transport metadata. Ordinary conversation
|
||||
* requests leave it unset.
|
||||
* map the purpose to model-hidden transport metadata or purpose-specific
|
||||
* generation policy. Ordinary conversation requests leave it unset.
|
||||
*/
|
||||
purpose?: 'compaction'
|
||||
purpose?: 'compaction' | 'session-title'
|
||||
}
|
||||
|
||||
@@ -8,7 +8,7 @@ This package is a library, not a Cordis plugin. The provider plugins call `regis
|
||||
|
||||
`provider` and `model` overrides are optional but must be supplied together as non-empty strings. Without that pair, the helper uses the exact provider/model route captured from the current session's logged `request/header`; an explicit refresh before any route exists therefore needs overrides. 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.
|
||||
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, carries `purpose: 'session-title'`, and deliberately lacks dsh-agent-loop's process-local request identity. Interceptors stay aligned with the record while loop-only reconstruction observers do not compare it with the conversation header. The DeepSeek adapter maps that purpose to thinking-disabled so the small output budget is reserved for visible title text; other adapters own their purpose-specific behavior. A later model failure leaves the request record intact; validation failures that never become dispatchable requests do not create one. The event stays outside derived model history.
|
||||
|
||||
## Configuration
|
||||
|
||||
@@ -33,7 +33,7 @@ The title model receives a fixed system instruction to return one concise unador
|
||||
|
||||
#### 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.
|
||||
The auxiliary request consumes tokens according to selected input size and `maxOutputTokens`. It is separate from the main agent request and does not add title text or framing to agent history. DeepSeek title calls disable thinking; the main conversation retains its configured thinking mode.
|
||||
|
||||
#### KV Cache effect
|
||||
|
||||
|
||||
@@ -261,6 +261,7 @@ export async function generateSessionTitleWithLlm(
|
||||
system,
|
||||
maxTokens: config.maxOutputTokens,
|
||||
sessionId: request.session.id,
|
||||
purpose: 'session-title',
|
||||
signal: callDeadline.signal,
|
||||
})
|
||||
await appendSessionTitleOutOfBand(ctx, request.session, 'session/title-llm-request', {
|
||||
|
||||
@@ -156,6 +156,7 @@ describe('generateSessionTitleWithLlm', () => {
|
||||
model: 'current-model',
|
||||
maxTokens: 32,
|
||||
sessionId: providerRequest.session.id,
|
||||
purpose: 'session-title',
|
||||
})
|
||||
expect(options.system).toContain('5 words')
|
||||
expect(options.system).toContain('10 CJK characters')
|
||||
|
||||
Reference in New Issue
Block a user