diff --git a/packages/agent-core-v2/docs/state-manifest.d.ts b/packages/agent-core-v2/docs/state-manifest.d.ts index 4f6bded88..1ab99674c 100644 --- a/packages/agent-core-v2/docs/state-manifest.d.ts +++ b/packages/agent-core-v2/docs/state-manifest.d.ts @@ -336,6 +336,7 @@ export interface SessionStateSnapshot { readonly title?: string; readonly isCustomTitle?: boolean; readonly lastPrompt?: string; + readonly prompts?: readonly string[]; readonly createdAt: number; readonly updatedAt: number; readonly archived: boolean; @@ -1010,7 +1011,7 @@ export interface AgentStateSnapshot { 'llmRequester.lastConfigLogSignature': string | undefined; 'llmRequester.mediaDegradedTurns': Set; 'llmRequester.mediaStrippedTurns': Map; 'llmRequester.turnConfigs': Map { if (text === undefined) return; const current = await target.metadata.read(); - const patch: { lastPrompt: string; title?: string; isCustomTitle?: boolean } = { + const patch: { + lastPrompt: string; + title?: string; + isCustomTitle?: boolean; + prompts?: readonly string[]; + } = { lastPrompt: text, }; if (!current.isCustomTitle && isUntitled(current.title)) { patch.title = titleFromPromptMetadataText(text); patch.isCustomTitle = false; } + // Keep the session's first few natural-language prompts for title + // generation. Skill / plugin activations pass recordPrompt=false so command + // text never becomes title input. + if (recordPrompt && (current.prompts?.length ?? 0) < SESSION_META_PROMPT_LIMIT) { + patch.prompts = [...(current.prompts ?? []), text]; + } await target.metadata.update(patch); target.eventService.publish({ type: 'session.meta.updated', diff --git a/packages/agent-core-v2/src/agent/rpc/rpcService.ts b/packages/agent-core-v2/src/agent/rpc/rpcService.ts index bb7ffef1d..f603a65a4 100644 --- a/packages/agent-core-v2/src/agent/rpc/rpcService.ts +++ b/packages/agent-core-v2/src/agent/rpc/rpcService.ts @@ -95,7 +95,7 @@ export class AgentRPCService implements IAgentRPCService { throw error; } } - await this.updatePromptMetadata(promptMetadataTextFromPayload(payload)); + await this.updatePromptMetadata(promptMetadataTextFromPayload(payload), true); const handle = await this.promptService.enqueue({ message: { role: 'user', content: [...payload.input], @@ -204,7 +204,10 @@ export class AgentRPCService implements IAgentRPCService { await this.updatePromptMetadata(promptMetadataTextFromPluginCommand(payload)); } - private async updatePromptMetadata(text: string | undefined): Promise { + private async updatePromptMetadata( + text: string | undefined, + recordPrompt = false, + ): Promise { await applyPromptMetadataUpdate( { metadata: this.metadata, @@ -212,6 +215,7 @@ export class AgentRPCService implements IAgentRPCService { sessionId: this.sessionContext.sessionId, }, text, + recordPrompt, ); } diff --git a/packages/agent-core-v2/src/session/sessionMetadata/sessionMetadata.ts b/packages/agent-core-v2/src/session/sessionMetadata/sessionMetadata.ts index 161c520d7..d40b88a55 100644 --- a/packages/agent-core-v2/src/session/sessionMetadata/sessionMetadata.ts +++ b/packages/agent-core-v2/src/session/sessionMetadata/sessionMetadata.ts @@ -25,12 +25,19 @@ export interface AgentMeta { export const SESSION_META_VERSION = 2; +/** How many of the session's first natural-language prompts are kept. */ +export const SESSION_META_PROMPT_LIMIT = 3; + export interface SessionMeta { readonly id: string; readonly version?: number; readonly title?: string; readonly isCustomTitle?: boolean; readonly lastPrompt?: string; + /** The session's first sanitized prompt texts (bounded by + * `SESSION_META_PROMPT_LIMIT`), recorded for title generation. Absent on + * documents written before the recording existed. */ + readonly prompts?: readonly string[]; readonly createdAt: number; readonly updatedAt: number; readonly archived: boolean; diff --git a/packages/agent-core-v2/src/session/sessionTitle/flag.ts b/packages/agent-core-v2/src/session/sessionTitle/flag.ts index 8cbdfc5d6..a211a3a60 100644 --- a/packages/agent-core-v2/src/session/sessionTitle/flag.ts +++ b/packages/agent-core-v2/src/session/sessionTitle/flag.ts @@ -19,7 +19,7 @@ export const autoTitleFlag: FlagDefinitionInput = { id: AUTO_TITLE_FLAG_ID, title: 'Auto session title', description: - 'Generate the session title on demand from the first prompt through the managed chat_title tool. Requires a managed Kimi Code OAuth login.', + 'Generate the session title on demand from the first prompts through the managed chat_title tool. Requires a managed Kimi Code OAuth login.', env: AUTO_TITLE_FLAG_ENV, default: false, surface: 'both', diff --git a/packages/agent-core-v2/src/session/sessionTitle/sessionTitle.ts b/packages/agent-core-v2/src/session/sessionTitle/sessionTitle.ts index 26b0186c6..edd0c55d5 100644 --- a/packages/agent-core-v2/src/session/sessionTitle/sessionTitle.ts +++ b/packages/agent-core-v2/src/session/sessionTitle/sessionTitle.ts @@ -2,8 +2,8 @@ * `sessionTitle` domain (L6) — session title generation contract. * * Defines the `ISessionTitleService` that (re)generates the session's title - * on demand from its first prompt through the managed platform's `chat_title` - * tool. Bound at Session scope — one instance per session. + * on demand from its first prompts through the managed platform's + * `chat_title` tool. Bound at Session scope — one instance per session. */ import { createDecorator, type ServiceIdentifier } from '#/_base/di/instantiation'; diff --git a/packages/agent-core-v2/src/session/sessionTitle/sessionTitleService.ts b/packages/agent-core-v2/src/session/sessionTitle/sessionTitleService.ts index b686ec1aa..cdd7f0486 100644 --- a/packages/agent-core-v2/src/session/sessionTitle/sessionTitleService.ts +++ b/packages/agent-core-v2/src/session/sessionTitle/sessionTitleService.ts @@ -1,9 +1,10 @@ /** * `sessionTitle` domain (L6) — `ISessionTitleService` implementation. * - * Generates the session's title from its already-sanitized first prompt - * (`sessionMetadata`'s `lastPrompt`, secrets redacted by the prompt-metadata - * flow) through the managed platform `/tools` `chat_title` endpoint, persists + * Generates the session's title from its already-sanitized first prompts + * (`sessionMetadata`'s bounded `prompts` list, secrets redacted by the + * prompt-metadata flow; `lastPrompt` as the fallback for older documents) + * through the managed platform `/tools` `chat_title` endpoint, persists * it through `sessionMetadata`, and rebroadcasts `session.meta.updated`. * Generation is on demand only: `generateTitle()` is the single entry point * (the kap-server route), gated by the `auto-title` experimental flag and a @@ -32,13 +33,19 @@ import { IHostRequestHeaders } from '#/kosong/model/hostRequestHeaders'; import { IProviderService } from '#/kosong/provider/provider'; import { isOAuthCatalogVendor } from '#/kosong/provider/providerDefinition'; import { ISessionContext } from '#/session/sessionContext/sessionContext'; -import { ISessionMetadata } from '#/session/sessionMetadata/sessionMetadata'; +import { + ISessionMetadata, + type SessionMeta, +} from '#/session/sessionMetadata/sessionMetadata'; import { AUTO_TITLE_FLAG_ID } from './flag'; import { ISessionTitleService } from './sessionTitle'; const MAX_GENERATED_TITLE_LENGTH = 200; +/** Total budget for the composed prompt texts sent to `chat_title`. */ +const MAX_TITLE_INPUT_LENGTH = 1000; + export class SessionTitleService implements ISessionTitleService { declare readonly _serviceBrand: undefined; @@ -58,8 +65,9 @@ export class SessionTitleService implements ISessionTitleService { async generateTitle(): Promise { if (!this.flags.enabled(AUTO_TITLE_FLAG_ID)) return undefined; const current = await this.metadata.read(); - if (current.lastPrompt === undefined || hasCustomTitle(current)) return undefined; - return this.generateAndApply(current.lastPrompt); + const input = titleInputFromMeta(current); + if (input === undefined || hasCustomTitle(current)) return undefined; + return this.generateAndApply(input); } private async generateAndApply(chatContent: string): Promise { @@ -103,7 +111,7 @@ export class SessionTitleService implements ISessionTitleService { const result = await fetchChatTitle( kimiCodeToolsUrl(runtimeAuth.baseUrl), token, - `user: ${chatContent}`, + chatContent, { headers: { ...parseKimiCodeCustomHeaders(), @@ -143,6 +151,25 @@ function hasCustomTitle(metadata: { return metadata.isCustomTitle === true || typeof metadata.customTitle === 'string'; } +/** + * Composes the `chat_title` input from the session's first recorded prompts + * (order-labeled), falling back to `lastPrompt` for documents written before + * prompt recording existed. Truncated to the total budget, keeping the head. + */ +function titleInputFromMeta(meta: SessionMeta): string | undefined { + const prompts = + meta.prompts !== undefined && meta.prompts.length > 0 + ? meta.prompts + : meta.lastPrompt !== undefined + ? [meta.lastPrompt] + : undefined; + if (prompts === undefined) return undefined; + return prompts + .map((prompt, index) => `user ${index + 1}: ${prompt}`) + .join('\n') + .slice(0, MAX_TITLE_INPUT_LENGTH); +} + registerScopedService( LifecycleScope.Session, ISessionTitleService, diff --git a/packages/agent-core-v2/test/agent/rpc/prompt-metadata.test.ts b/packages/agent-core-v2/test/agent/rpc/prompt-metadata.test.ts index 7e1527c44..88ebbc9fe 100644 --- a/packages/agent-core-v2/test/agent/rpc/prompt-metadata.test.ts +++ b/packages/agent-core-v2/test/agent/rpc/prompt-metadata.test.ts @@ -7,12 +7,24 @@ * - an inline image-compression caption (harness metadata placed next to * the image by prompt ingestion) never leaks into titles/lastPrompt, * whether it is a standalone text part or merged into the user's text + * - natural-language prompts append to the bounded `prompts` metadata list + * (the title-generation input) while skill / plugin activations do not */ import { describe, expect, it } from 'vitest'; -import { promptMetadataTextFromPayload } from '#/agent/rpc/prompt-metadata'; +import { + applyPromptMetadataUpdate, + promptMetadataTextFromPayload, + type PromptMetadataUpdateTarget, +} from '#/agent/rpc/prompt-metadata'; import { buildImageCompressionCaption } from '#/agent/media/image-compress'; +import type { IEventService } from '#/app/event/event'; +import { + type ISessionMetadata, + type SessionMeta, + type SessionMetaPatch, +} from '#/session/sessionMetadata/sessionMetadata'; const CAPTION = buildImageCompressionCaption({ original: { width: 3264, height: 666, byteLength: 344 * 1024, mimeType: 'image/png' }, @@ -53,3 +65,56 @@ describe('promptMetadataTextFromPayload', () => { expect(text).not.toContain('Image compressed'); }); }); + +describe('applyPromptMetadataUpdate', () => { + function createTarget(initial: Partial = {}) { + let meta: SessionMeta = { + id: 'sess-1', + createdAt: 0, + updatedAt: 0, + archived: false, + ...initial, + }; + const target: PromptMetadataUpdateTarget = { + metadata: { + read: () => Promise.resolve(meta), + update: (patch: SessionMetaPatch) => { + meta = { ...meta, ...patch }; + return Promise.resolve(); + }, + } as unknown as ISessionMetadata, + eventService: { publish: () => undefined } as unknown as IEventService, + sessionId: 'sess-1', + }; + return { target, readMeta: () => meta }; + } + + it('records natural-language prompts into the bounded prompts list', async () => { + const { target, readMeta } = createTarget(); + + await applyPromptMetadataUpdate(target, '第一条', true); + await applyPromptMetadataUpdate(target, '第二条', true); + + expect(readMeta().prompts).toEqual(['第一条', '第二条']); + expect(readMeta().lastPrompt).toBe('第二条'); + expect(readMeta().title).toBe('第一条'); + }); + + it('does not record skill / plugin activations', async () => { + const { target, readMeta } = createTarget(); + + await applyPromptMetadataUpdate(target, '/compact'); + + expect(readMeta().lastPrompt).toBe('/compact'); + expect(readMeta().prompts).toBeUndefined(); + }); + + it('stops recording once the prompt limit is reached', async () => { + const { target, readMeta } = createTarget({ prompts: ['一', '二', '三'] }); + + await applyPromptMetadataUpdate(target, '第四条', true); + + expect(readMeta().prompts).toEqual(['一', '二', '三']); + expect(readMeta().lastPrompt).toBe('第四条'); + }); +}); diff --git a/packages/agent-core-v2/test/session/sessionTitle/sessionTitleService.test.ts b/packages/agent-core-v2/test/session/sessionTitle/sessionTitleService.test.ts index b719f5951..b73d80bf0 100644 --- a/packages/agent-core-v2/test/session/sessionTitle/sessionTitleService.test.ts +++ b/packages/agent-core-v2/test/session/sessionTitle/sessionTitleService.test.ts @@ -184,7 +184,7 @@ describe('SessionTitleService', () => { }); it('replaces the easy title with the generated one', async () => { - await metadata.update({ lastPrompt: '帮我看一下这个 Go 的 nil pointer 报错' }); + await metadata.update({ prompts: ['帮我看一下这个 Go 的 nil pointer 报错'] }); const title = await ix.get(ISessionTitleService).generateTitle(); @@ -195,7 +195,7 @@ describe('SessionTitleService', () => { const [, init] = fetchMock.mock.calls[0]!; expect(JSON.parse(init?.body as string)).toEqual({ method: 'chat_title', - params: { chat_content: 'user: 帮我看一下这个 Go 的 nil pointer 报错' }, + params: { chat_content: 'user 1: 帮我看一下这个 Go 的 nil pointer 报错' }, }); expect(new Headers(init?.headers as Record).get('authorization')).toBe( 'Bearer test-token', @@ -209,6 +209,45 @@ describe('SessionTitleService', () => { expect(rebroadcast).toBeDefined(); }); + it('composes the title input from the recorded prompts in order', async () => { + await metadata.update({ + prompts: ['先帮我搭一个 Vite 项目', '加上路由', '现在配一下 ESLint'], + }); + + await ix.get(ISessionTitleService).generateTitle(); + + const [, init] = fetchMock.mock.calls[0]!; + expect(JSON.parse(init?.body as string)).toEqual({ + method: 'chat_title', + params: { + chat_content: 'user 1: 先帮我搭一个 Vite 项目\nuser 2: 加上路由\nuser 3: 现在配一下 ESLint', + }, + }); + }); + + it('truncates the composed title input to the total budget, keeping the head', async () => { + await metadata.update({ prompts: ['很长的输入'.repeat(400), '第二条'] }); + + await ix.get(ISessionTitleService).generateTitle(); + + const [, init] = fetchMock.mock.calls[0]!; + const body = JSON.parse(init?.body as string) as { params: { chat_content: string } }; + expect(body.params.chat_content.startsWith('user 1: 很长的输入')).toBe(true); + expect(body.params.chat_content).toHaveLength(1000); + }); + + it('falls back to lastPrompt for sessions without recorded prompts', async () => { + await metadata.update({ lastPrompt: '帮我看一下这个 Go 的 nil pointer 报错' }); + + await ix.get(ISessionTitleService).generateTitle(); + + const [, init] = fetchMock.mock.calls[0]!; + expect(JSON.parse(init?.body as string)).toEqual({ + method: 'chat_title', + params: { chat_content: 'user 1: 帮我看一下这个 Go 的 nil pointer 报错' }, + }); + }); + it('does nothing without a managed OAuth provider', async () => { delete providers['managed:kimi-code']; await metadata.update({ lastPrompt: 'hello' });