Skip to content

Commit da288b9

Browse files
committed
feat: auto-title chat session with auxiliary
1 parent 056b9bd commit da288b9

7 files changed

Lines changed: 126 additions & 4 deletions

File tree

api/src/agent/agent.model.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ export const AGENT_PHASES = [
77
'VERIFY',
88
'JUDGE',
99
'CHAT',
10+
'TITLE',
1011
] as const;
1112
export type AgentPhase = (typeof AGENT_PHASES)[number];
1213

api/src/agent/agent.service.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -219,7 +219,11 @@ export class HermesAgentService implements OnModuleInit {
219219
},
220220
});
221221

222-
await this.persistEvents(run.id);
222+
// Only runs owned by a job or chat session surface their events anywhere; ownerless
223+
// utility runs (e.g. TITLE generation) skip persistence to avoid orphan AgentEvent rows.
224+
if (opts.jobId || opts.sessionId) {
225+
await this.persistEvents(run.id);
226+
}
223227
this.langfuse.complete(run.id);
224228
this.metrics.recordAgentRun(opts.phase, status, raw.durationMs);
225229

api/src/chat/chat.prompts.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,3 +25,15 @@ export function buildChatPrompt(p: {
2525
`Respond to the latest user message above. Output only your reply in GitHub-flavored Markdown — no preamble, no sign-off.`,
2626
].join('\n\n');
2727
}
28+
29+
/**
30+
* Prompt for auto-titling a chat from its first user message. Demands ONLY the title text so
31+
* the (tool-less) run's stdout can be used near-verbatim after a light cleanup.
32+
*/
33+
export function buildTitlePrompt(firstMessage: string): string {
34+
return [
35+
`Generate a short, descriptive title for a chat conversation that opens with the message below.`,
36+
`Rules:\n- Output ONLY the title — no quotes, no markdown, no trailing punctuation, no preamble.\n- 3 to 6 words, Title Case.\n- Do NOT use any tools; just reply with the title.`,
37+
`--- FIRST MESSAGE ---\n${firstMessage}\n--- END ---`,
38+
].join('\n\n');
39+
}

api/src/chat/chat.service.spec.ts

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import type { AppConfigService } from '../config/config.service.js';
55
import type { HermesAgentService } from '../agent/agent.service.js';
66
import type { WorkspaceService } from '../workspace/workspace.service.js';
77
import type { LangfuseService } from '../langfuse/langfuse.service.js';
8+
import { cleanTitle } from './chat.utility.js';
89

910
const resolved = (value: unknown) => jest.fn((..._args: unknown[]) => Promise.resolve(value));
1011

@@ -31,6 +32,7 @@ function setup(overrides: { session?: Record<string, unknown> | null } = {}) {
3132
chatMessage: {
3233
create: resolved(undefined),
3334
findMany: resolved([{ role: 'user', content: 'hello' }]),
35+
count: resolved(1),
3436
},
3537
agentEvent: { findMany: resolved([] as unknown[]), createMany: resolved(undefined) },
3638
};
@@ -132,3 +134,28 @@ describe('ChatService', () => {
132134
expect(activity).toEqual({ 'run-a': [ev] });
133135
});
134136
});
137+
138+
describe('cleanTitle', () => {
139+
it('strips quotes, markdown, a Title: prefix and trailing punctuation', () => {
140+
expect(cleanTitle('"Refactor the Auth Module."')).toBe('Refactor the Auth Module');
141+
expect(cleanTitle('Title: **Dark Mode Toggle**')).toBe('Dark Mode Toggle');
142+
expect(cleanTitle('### Fix Flaky Tests')).toBe('Fix Flaky Tests');
143+
});
144+
145+
it('takes the first non-empty line and collapses whitespace', () => {
146+
expect(cleanTitle('\n\n Cache Invalidation Strategy \nmore prose')).toBe(
147+
'Cache Invalidation Strategy',
148+
);
149+
});
150+
151+
it('returns empty string when nothing usable remains', () => {
152+
expect(cleanTitle(' \n ')).toBe('');
153+
});
154+
155+
it('caps very long output on a word boundary', () => {
156+
const long = `${'word '.repeat(40)}`.trim();
157+
const out = cleanTitle(long);
158+
expect(out.length).toBeLessThanOrEqual(80);
159+
expect(out.endsWith('word')).toBe(true);
160+
});
161+
});

api/src/chat/chat.service.ts

Lines changed: 45 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,8 +12,8 @@ import {
1212
type ChatSessionDetailDto,
1313
type ChatSessionSummaryDto,
1414
} from './chat.model.js';
15-
import { buildChatPrompt } from './chat.prompts.js';
16-
import { eventsByRun, toMessageDto } from './chat.utility.js';
15+
import { buildChatPrompt, buildTitlePrompt } from './chat.prompts.js';
16+
import { cleanTitle, eventsByRun, toMessageDto } from './chat.utility.js';
1717

1818
/**
1919
* Owns interactive chat sessions. Reuses HermesAgentService (CHAT phase) and the Langfuse
@@ -131,6 +131,8 @@ export class ChatService {
131131
throw new NotFoundException(`Chat session ${sessionId} not found`);
132132
}
133133

134+
const priorCount = await this.prisma.chatMessage.count({ where: { sessionId } });
135+
134136
await this.prisma.chatMessage.create({ data: { sessionId, role: 'user', content } });
135137
await this.touch(sessionId);
136138

@@ -146,6 +148,11 @@ export class ChatService {
146148
branchName: `chat-${sessionId.slice(0, 8)}`,
147149
});
148150

151+
// First message of an untitled session → auto-generate a title in the background.
152+
if (priorCount === 0 && session.title === DEFAULT_CHAT_TITLE) {
153+
void this.generateTitle(sessionId, ws.dir, content);
154+
}
155+
149156
const prompt = buildChatPrompt({ repoUrl: session.repoUrl, history });
150157

151158
const runId = await new Promise<string>((resolve, reject) => {
@@ -195,6 +202,42 @@ export class ChatService {
195202
}
196203
}
197204

205+
/**
206+
* Best-effort auto-title from the first user message via a short, tool-less agent run on the
207+
* auxiliary model (falling back to the primary when no auxiliary is configured). Runs under the
208+
* concurrency semaphore and is unowned (no jobId/sessionId), so it never shows as chat activity
209+
* or as the session's active run. A failure or empty result leaves the default title in place.
210+
*/
211+
private async generateTitle(sessionId: string, cwd: string, firstMessage: string): Promise<void> {
212+
await this.acquire();
213+
214+
try {
215+
const res = await this.agent.run({
216+
phase: 'TITLE',
217+
cwd,
218+
prompt: buildTitlePrompt(firstMessage),
219+
model: this.config.get('HERMES_AUXILIARY_MODEL') || this.config.get('HERMES_PRIMARY_MODEL'),
220+
provider:
221+
this.config.get('HERMES_AUXILIARY_PROVIDER') ||
222+
this.config.get('HERMES_PRIMARY_PROVIDER'),
223+
});
224+
225+
const title = res.status === 'SUCCEEDED' ? cleanTitle(res.stdout) : '';
226+
227+
// Only overwrite if still the default — a user/explicit title set meanwhile wins.
228+
if (title.length > 0) {
229+
await this.prisma.chatSession.updateMany({
230+
where: { id: sessionId, title: DEFAULT_CHAT_TITLE },
231+
data: { title },
232+
});
233+
}
234+
} catch (e) {
235+
this.logger.warn(`chat title generation failed: ${(e as Error).message}`);
236+
} finally {
237+
this.release();
238+
}
239+
}
240+
198241
private authFor(session: ChatSession): RemoteAuth {
199242
return session.repoUrl ? { kind: 'ssh', url: session.repoUrl } : { kind: 'none' };
200243
}

api/src/chat/chat.utility.ts

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,40 @@ import { type ChatMessage } from '@prisma/client';
22
import { type LangfuseEvent } from '../langfuse/langfuse.model.js';
33
import { type ChatMessageDto } from './chat.model.js';
44

5+
/**
6+
* Normalizes a model's raw title output into a clean, short session title: takes the first
7+
* non-empty line and strips quotes/markdown/`Title:` prefixes and trailing punctuation, then
8+
* caps the length on a word boundary. Returns '' when nothing usable remains.
9+
*/
10+
export function cleanTitle(raw: string): string {
11+
const firstLine = raw
12+
.split('\n')
13+
.map((l) => l.trim())
14+
.find((l) => l.length > 0);
15+
16+
if (!firstLine) {
17+
return '';
18+
}
19+
20+
let t = firstLine
21+
.replace(/^title:\s*/i, '')
22+
.replace(/^[`*_#>\s-]+/, '')
23+
.replace(/[`*_#]+$/, '')
24+
.replace(/^["']+|["']+$/g, '')
25+
.replace(/[.:;,]+$/, '')
26+
.replace(/\s+/g, ' ')
27+
.trim();
28+
29+
if (t.length > 80) {
30+
t = t
31+
.slice(0, 80)
32+
.replace(/\s+\S*$/, '')
33+
.trim();
34+
}
35+
36+
return t;
37+
}
38+
539
export function toMessageDto(m: ChatMessage): ChatMessageDto {
640
return {
741
id: m.id,

api/src/metrics/metrics.model.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,5 +8,6 @@ export type AgentPhaseLabel =
88
| 'SUMMARY'
99
| 'VERIFY'
1010
| 'JUDGE'
11-
| 'CHAT';
11+
| 'CHAT'
12+
| 'TITLE';
1213
export type AgentRunStatusLabel = 'SUCCEEDED' | 'FAILED' | 'TIMED_OUT';

0 commit comments

Comments
 (0)