feat: LLM auto session titles + live reasoning whisper

- POST /sessions/{id}/title/auto — summarize the first exchange into a short
  title in the user's language; never clobbers a manual rename
- Frontend fires it after the first completed exchange and refreshes the sidebar
- reasoning_delta now carries a bounded 600-char rolling tail on the existing
  throttled emit; ActivityLine whispers the live thought (serif italic, 3-line
  clamp) while the attempt is active
This commit is contained in:
Haozhe Wu committed 2026-07-30 11:58:19 +08:00
1 parent 8f40de5a5c
commit 3ed96c32ba
7 files changed
+114 -5

No files matched your search

+7 -2
View File
@@ -702,6 +702,7 @@ class AgentLoop:
# Streaming output + collect thinking text # Streaming output + collect thinking text
thinking_chunks: List[str] = [] thinking_chunks: List[str] = []
reasoning_chars = 0 reasoning_chars = 0
reasoning_tail = ""
last_reasoning_emit: float | None = None last_reasoning_emit: float | None = None
def _on_text_chunk(delta: str) -> None: def _on_text_chunk(delta: str) -> None:
@@ -714,8 +715,12 @@ class AgentLoop:
# and evicts tool_call/text_delta events. The first chunk # and evicts tool_call/text_delta events. The first chunk
# of each iteration always emits immediately so the UI # of each iteration always emits immediately so the UI
# flips to "Reasoning…" without delay. # flips to "Reasoning…" without delay.
nonlocal reasoning_chars, last_reasoning_emit nonlocal reasoning_chars, reasoning_tail, last_reasoning_emit
reasoning_chars += len(delta) reasoning_chars += len(delta)
# Rolling tail rides the already-throttled emit so the UI
# can whisper the current thought; a bounded window keeps
# replay-buffer pressure flat regardless of trace length.
reasoning_tail = (reasoning_tail + delta)[-600:]
now = _time.monotonic() now = _time.monotonic()
if ( if (
last_reasoning_emit is not None last_reasoning_emit is not None
@@ -725,7 +730,7 @@ class AgentLoop:
last_reasoning_emit = now last_reasoning_emit = now
self._emit( self._emit(
"reasoning_delta", "reasoning_delta",
{"iter": current_iter, "chars": reasoning_chars}, {"iter": current_iter, "chars": reasoning_chars, "tail": reasoning_tail},
) )
# On last iteration, drop tool definitions to force text output # On last iteration, drop tool definitions to force text output
+64
View File
@@ -5,6 +5,7 @@ Mounted by ``agent/api_server.py`` via ``register_sessions_routes(app)``.
from __future__ import annotations from __future__ import annotations
import asyncio
import json import json
import logging import logging
import re import re
@@ -615,6 +616,69 @@ def register_sessions_routes(app: FastAPI) -> None:
svc.store.update_session(session) svc.store.update_session(session)
return {"status": "updated", "session_id": session_id} return {"status": "updated", "session_id": session_id}
@app.post("/sessions/{session_id}/title/auto", dependencies=[Depends(require_auth)])
async def auto_title_session(session_id: str):
"""Summarize the first exchange into a short LLM-generated title.
Never clobbers a manual rename: only rewrites when the current title
is empty or still the auto-set first-prompt prefix from create time.
"""
_host_validate_path_param(session_id, "session_id")
svc = _host_get_session_service()
if not svc:
raise HTTPException(status_code=501, detail="Session runtime not enabled")
session = svc.store.get_session(session_id)
if not session:
raise HTTPException(status_code=404, detail=f"Session {session_id} not found")
messages = svc.get_messages(session_id, limit=6)
first_user = next(
(m for m in messages if m.role == "user" and (m.content or "").strip()), None
)
if first_user is None:
raise HTTPException(status_code=409, detail="Session has no user message to summarize")
current = (session.title or "").strip()
auto_prefix = first_user.content.strip()[:50].strip()
if current and current != auto_prefix:
return {"status": "kept", "session_id": session_id, "title": current}
first_assistant = next(
(m for m in messages if m.role == "assistant" and (m.content or "").strip()), None
)
excerpt = f"User: {first_user.content.strip()[:600]}"
if first_assistant:
excerpt += f"\nAssistant: {first_assistant.content.strip()[:600]}"
prompt = (
"Write a session title for the conversation below: at most 8 words "
"(or 16 CJK characters), in the same language as the user's message, "
"no quotes, no trailing punctuation. Reply with the title only.\n\n"
+ excerpt
)
def _generate() -> str:
from src.providers.chat import ChatLLM
response = ChatLLM().chat([{"role": "user", "content": prompt}], timeout=30)
return (getattr(response, "content", "") or "").strip()
try:
raw = await asyncio.to_thread(_generate)
except Exception as exc:
raise HTTPException(status_code=502, detail=f"title generation failed: {exc}")
title = raw.splitlines()[0].strip().strip("\"'“”「」『』").strip()
chars = list(title)
if len(chars) > 40:
title = "".join(chars[:40])
if not title:
raise HTTPException(status_code=502, detail="empty title from model")
session.title = title
session.updated_at = datetime.now(timezone.utc).isoformat()
svc.store.update_session(session)
return {"status": "updated", "session_id": session_id, "title": title}
@app.post("/sessions/{session_id}/messages", dependencies=[Depends(require_auth)]) @app.post("/sessions/{session_id}/messages", dependencies=[Depends(require_auth)])
async def send_message(session_id: str, payload: SendMessageRequest, http_request: Request): async def send_message(session_id: str, payload: SendMessageRequest, http_request: Request):
"""Send a user message and start the agent loop (natural language strategy).""" """Send a user message and start the agent loop (natural language strategy)."""
@@ -24,6 +24,8 @@ import type { AgentActivity } from "@/stores/agent";
interface ActivityLineProps { interface ActivityLineProps {
activity: AgentActivity; activity: AgentActivity;
/** Live rolling reasoning excerpt — only supplied for the in-flight attempt. */
reasoningTail?: string;
onContinue?: () => void; onContinue?: () => void;
onReattach?: () => void; onReattach?: () => void;
} }
@@ -59,6 +61,7 @@ function StateIcon({ state }: { state: AgentActivity["state"] }): JSX.Element {
/** The single live and historical status surface for one agent attempt. */ /** The single live and historical status surface for one agent attempt. */
export const ActivityLine = memo(function ActivityLine({ export const ActivityLine = memo(function ActivityLine({
activity, activity,
reasoningTail,
onContinue, onContinue,
onReattach, onReattach,
}: ActivityLineProps): JSX.Element { }: ActivityLineProps): JSX.Element {
@@ -159,6 +162,15 @@ export const ActivityLine = memo(function ActivityLine({
</button> </button>
<div id={detailsId} hidden={!expanded}> <div id={detailsId} hidden={!expanded}>
{/* Live reasoning whisper — DeepTutor-style sub-trace of the current
thought; disappears once the attempt leaves its active states. */}
{isActive && reasoningTail && (
<div className="border-t border-border/30 px-3 py-2" aria-hidden="true">
<p className="line-clamp-3 border-s-2 border-border/40 ps-2 font-serif text-xs italic leading-relaxed text-muted-foreground/80">
{reasoningTail}
</p>
</div>
)}
{activity.steps.length > 0 && ( {activity.steps.length > 0 && (
<div className="border-t border-border/30 px-3 py-2"> <div className="border-t border-border/30 px-3 py-2">
<ToolProgressIndicator toolCalls={activity.steps} /> <ToolProgressIndicator toolCalls={activity.steps} />
@@ -64,6 +64,14 @@ export function Layout() {
const isAgentPage = pathname.startsWith("/agent"); const isAgentPage = pathname.startsWith("/agent");
useEffect(() => { loadSessions(); }, [isAgentPage, activeSessionId]); useEffect(() => { loadSessions(); }, [isAgentPage, activeSessionId]);
// Re-list after out-of-band title changes (e.g. LLM auto-titling on the
// first completed exchange).
useEffect(() => {
const refresh = () => loadSessions();
window.addEventListener("vibe:sessions-refresh", refresh);
return () => window.removeEventListener("vibe:sessions-refresh", refresh);
}, []);
const [deleteTarget, setDeleteTarget] = useState<string | null>(null); const [deleteTarget, setDeleteTarget] = useState<string | null>(null);
const [renameTarget, setRenameTarget] = useState<string | null>(null); const [renameTarget, setRenameTarget] = useState<string | null>(null);
const [renameValue, setRenameValue] = useState(""); const [renameValue, setRenameValue] = useState("");
+3
View File
@@ -144,6 +144,9 @@ export const api = {
createSession: (title?: string) => request<SessionItem>("/sessions", { method: "POST", body: JSON.stringify({ title: title || "" }) }), createSession: (title?: string) => request<SessionItem>("/sessions", { method: "POST", body: JSON.stringify({ title: title || "" }) }),
deleteSession: (sid: string) => request<{ status: string }>(`/sessions/${sid}`, { method: "DELETE" }), deleteSession: (sid: string) => request<{ status: string }>(`/sessions/${sid}`, { method: "DELETE" }),
renameSession: (sid: string, title: string) => request<{ status: string }>(`/sessions/${sid}`, { method: "PATCH", body: JSON.stringify({ title }) }), renameSession: (sid: string, title: string) => request<{ status: string }>(`/sessions/${sid}`, { method: "PATCH", body: JSON.stringify({ title }) }),
// Codex-style LLM summary title from the first exchange; backend refuses to
// overwrite a manual rename, so this is safe to fire-and-forget.
autoTitleSession: (sid: string) => request<{ status: string; title: string }>(`/sessions/${sid}/title/auto`, { method: "POST" }),
sendMessage: (sid: string, content: string) => request<{ message_id: string; attempt_id: string }>(`/sessions/${sid}/messages`, { method: "POST", body: JSON.stringify({ content }) }), sendMessage: (sid: string, content: string) => request<{ message_id: string; attempt_id: string }>(`/sessions/${sid}/messages`, { method: "POST", body: JSON.stringify({ content }) }),
cancelSession: (sid: string) => request<{ status: string }>(`/sessions/${sid}/cancel`, { method: "POST" }), cancelSession: (sid: string) => request<{ status: string }>(`/sessions/${sid}/cancel`, { method: "POST" }),
getSessionMessages: (sid: string) => request<MessageItem[]>(`/sessions/${sid}/messages`), getSessionMessages: (sid: string) => request<MessageItem[]>(`/sessions/${sid}/messages`),
+12 -1
View File
@@ -242,6 +242,7 @@ export function Agent() {
const status = useAgentStore(s => s.status); const status = useAgentStore(s => s.status);
const sessionId = useAgentStore(s => s.sessionId); const sessionId = useAgentStore(s => s.sessionId);
const activity = useAgentStore(s => s.activity); const activity = useAgentStore(s => s.activity);
const reasoningTail = useAgentStore(s => s.reasoningTail);
const swarmRuns = useAgentStore(s => s.swarmRuns); const swarmRuns = useAgentStore(s => s.swarmRuns);
const sessionLoading = useAgentStore(s => s.sessionLoading); const sessionLoading = useAgentStore(s => s.sessionLoading);
const previousStatusRef = useRef(status); const previousStatusRef = useRef(status);
@@ -648,6 +649,8 @@ export function Agent() {
touch(); touch();
replayAttemptSeenRef.current = true; replayAttemptSeenRef.current = true;
if (!identifyActivity(d, "thinking")) return; if (!identifyActivity(d, "thinking")) return;
// The backend sends a bounded rolling tail — replace, never append.
act().setReasoningTail(String(d.tail ?? ""));
queueStreamUpdate("", true); queueStreamUpdate("", true);
if (act().status !== "streaming") act().setStatus("streaming"); if (act().status !== "streaming") act().setStatus("streaming");
}, },
@@ -832,6 +835,14 @@ export function Agent() {
s.addMessage({ id: "", type: "answer", content: finalAnswer, timestamp: Date.now() }); s.addMessage({ id: "", type: "answer", content: finalAnswer, timestamp: Date.now() });
} }
// First completed exchange → ask the backend for a Codex-style
// summary title, then nudge the sidebar to re-list sessions.
if (act().messages.filter((message) => message.type === "user").length === 1) {
api.autoTitleSession(sid)
.then(() => window.dispatchEvent(new Event("vibe:sessions-refresh")))
.catch(() => {});
}
// Detect Shadow Account id if render_shadow_report fired successfully this turn // Detect Shadow Account id if render_shadow_report fired successfully this turn
const shadowCall = completedTools.find( const shadowCall = completedTools.find(
(tc) => tc.tool === "render_shadow_report" && (tc.status || "ok") === "ok", (tc) => tc.tool === "render_shadow_report" && (tc.status || "ok") === "ok",
@@ -1616,7 +1627,7 @@ export function Agent() {
<div className="flex gap-3 msg-enter"> <div className="flex gap-3 msg-enter">
<AgentAvatar /> <AgentAvatar />
<div className="flex-1 min-w-0 space-y-2"> <div className="flex-1 min-w-0 space-y-2">
{activity && <ActivityLine activity={activity} />} {activity && <ActivityLine activity={activity} reasoningTail={reasoningTail} />}
{streamingText && ( {streamingText && (
<div aria-live="polite" aria-atomic="false"> <div aria-live="polite" aria-atomic="false">
<MarkdownContent content={streamingText} streaming showCursor /> <MarkdownContent content={streamingText} streaming showCursor />
+8 -2
View File
@@ -62,6 +62,8 @@ interface AgentState {
sessionId: string | null; sessionId: string | null;
status: "idle" | "streaming" | "error"; status: "idle" | "streaming" | "error";
streamingText: string; streamingText: string;
/** Rolling tail of the model's live reasoning trace (bounded server-side). */
reasoningTail: string;
/** The session currently streaming on the backend. Survives switchSession /** The session currently streaming on the backend. Survives switchSession
* so the sidebar spinner persists when the user navigates away. */ * so the sidebar spinner persists when the user navigates away. */
@@ -98,6 +100,7 @@ interface AgentState {
cacheSession: (sid: string, msgs: StoredAgentMessage[]) => void; cacheSession: (sid: string, msgs: StoredAgentMessage[]) => void;
getCachedSession: (sid: string) => StoredAgentMessage[] | undefined; getCachedSession: (sid: string) => StoredAgentMessage[] | undefined;
setReasoningTail: (tail: string) => void;
clearStreaming: () => void; clearStreaming: () => void;
clearStreamingSession: (sid: string) => void; clearStreamingSession: (sid: string) => void;
@@ -118,6 +121,7 @@ export const useAgentStore = create<AgentState>((set) => ({
sessionId: null, sessionId: null,
status: "idle", status: "idle",
streamingText: "", streamingText: "",
reasoningTail: "",
streamingSessionId: null, streamingSessionId: null,
toolCalls: [], toolCalls: [],
activity: null, activity: null,
@@ -292,7 +296,8 @@ export const useAgentStore = create<AgentState>((set) => ({
}), }),
getCachedSession: (sid) => _sessionCache.get(sid), getCachedSession: (sid) => _sessionCache.get(sid),
clearStreaming: () => set({ streamingText: "" }), setReasoningTail: (reasoningTail) => set({ reasoningTail }),
clearStreaming: () => set({ streamingText: "", reasoningTail: "" }),
clearStreamingSession: (sid) => clearStreamingSession: (sid) =>
set((s) => s.streamingSessionId === sid ? { streamingSessionId: null } : {}), set((s) => s.streamingSessionId === sid ? { streamingSessionId: null } : {}),
@@ -306,6 +311,7 @@ export const useAgentStore = create<AgentState>((set) => ({
messages: msgs || [], messages: msgs || [],
status: "idle", status: "idle",
streamingText: "", streamingText: "",
reasoningTail: "",
toolCalls: [], toolCalls: [],
activity: null, activity: null,
swarmRuns: msgs ? (_swarmSessionCache.get(sid) ?? {}) : {}, swarmRuns: msgs ? (_swarmSessionCache.get(sid) ?? {}) : {},
@@ -321,7 +327,7 @@ export const useAgentStore = create<AgentState>((set) => ({
reset: () => { reset: () => {
_id = 0; _id = 0;
set({ set({
messages: [], status: "idle", streamingText: "", messages: [], status: "idle", streamingText: "", reasoningTail: "",
sessionId: null, toolCalls: [], activity: null, swarmRuns: {}, sessionLoading: false, sessionId: null, toolCalls: [], activity: null, swarmRuns: {}, sessionLoading: false,
streamingSessionId: null, streamingSessionId: null,
}); });