From af63f0e2fb0988dee521f49d7810516b23161437 Mon Sep 17 00:00:00 2001 From: Brian Love Date: Thu, 21 May 2026 10:50:51 -0700 Subject: [PATCH 1/2] feat: align chat thread title generation --- cockpit/chat/debug/python/src/graph.py | 58 ++++++++++- cockpit/chat/footprint.spec.ts | 12 +++ .../chat/generative-ui/python/src/graph.py | 58 ++++++++++- cockpit/chat/input/python/src/graph.py | 58 ++++++++++- cockpit/chat/interrupts/python/src/graph.py | 58 ++++++++++- cockpit/chat/messages/python/src/graph.py | 58 ++++++++++- cockpit/chat/subagents/python/src/graph.py | 56 ++++++++++- cockpit/chat/theming/python/src/graph.py | 58 ++++++++++- cockpit/chat/timeline/python/src/graph.py | 58 ++++++++++- cockpit/chat/tool-calls/python/src/graph.py | 58 ++++++++++- .../src/app/shell/demo-shell.component.ts | 4 +- examples/chat/python/src/graph.py | 85 ++++++---------- .../chat/python/tests/test_graph_smoke.py | 99 ++++++++++++++----- examples/chat/smoke/CHECKLIST.md | 2 +- 14 files changed, 620 insertions(+), 102 deletions(-) diff --git a/cockpit/chat/debug/python/src/graph.py b/cockpit/chat/debug/python/src/graph.py index a67620c32..87ec56880 100644 --- a/cockpit/chat/debug/python/src/graph.py +++ b/cockpit/chat/debug/python/src/graph.py @@ -6,13 +6,65 @@ Multiple nodes create rich state transitions for the debug panel. """ +import os from pathlib import Path from langgraph.graph import StateGraph, MessagesState, END from langchain_openai import ChatOpenAI -from langchain_core.messages import SystemMessage, AIMessage +from langchain_core.messages import HumanMessage, SystemMessage, AIMessage +from langgraph_sdk import get_client PROMPTS_DIR = Path(__file__).parent.parent / "prompts" +# ── generate_title node (inline; matches Pattern D from spec +# 2026-05-19-llm-generated-labels-design.md) ────────────────────────────── + +_TITLE_PROMPT = ( + "In 3-5 words, summarize what the user is asking about. " + "Output ONLY the title — no quotes, no period, no prefix." +) +_TITLE_MODEL = "gpt-5-mini" + + +async def generate_title(state: MessagesState, config) -> dict: + """Background title generation: on the first turn, summarize the user's + intent into 3-5 words and persist to LangGraph thread metadata. + + Idempotent — skips when metadata.title already exists. Errors are + swallowed because the title is a UX nicety, never a blocker. + """ + thread_id = (config.get("configurable") or {}).get("thread_id") + if not thread_id: + return {} + sdk_url = os.environ.get("LANGGRAPH_API_URL") + try: + client = get_client(url=sdk_url) + thread = await client.threads.get(thread_id) + if (thread.get("metadata") or {}).get("title"): + return {} + first_user = next( + (m for m in state["messages"] if getattr(m, "type", None) == "human"), + None, + ) + if not first_user or not isinstance(first_user.content, str): + return {} + if first_user.content.lstrip().startswith("{"): + return {} + llm = ChatOpenAI(model=_TITLE_MODEL, temperature=0) + response = await llm.ainvoke([ + SystemMessage(content=_TITLE_PROMPT), + HumanMessage(content=first_user.content), + ]) + title = (response.content or "").strip().strip('"').strip("'")[:80] + if title: + await client.threads.update(thread_id, metadata={"title": title}) + except Exception as e: # noqa: BLE001 — title is a UX nicety; never block + print( + f"[generate_title] failed for thread {thread_id}: " + f"{type(e).__name__}: {e}", + flush=True, + ) + return {} + def build_debug_graph(): """ @@ -46,10 +98,12 @@ async def summarize(state: MessagesState) -> dict: graph.add_node("generate", generate) graph.add_node("process", process) graph.add_node("summarize", summarize) + graph.add_node("generate_title", generate_title) graph.set_entry_point("generate") graph.add_edge("generate", "process") graph.add_edge("process", "summarize") - graph.add_edge("summarize", END) + graph.add_edge("summarize", "generate_title") + graph.add_edge("generate_title", END) return graph.compile() diff --git a/cockpit/chat/footprint.spec.ts b/cockpit/chat/footprint.spec.ts index 958e00a41..d35fa568b 100644 --- a/cockpit/chat/footprint.spec.ts +++ b/cockpit/chat/footprint.spec.ts @@ -52,4 +52,16 @@ describe('Chat footprint', () => { } } }); + + it('wires inline thread-title generation into every python graph', () => { + for (const topic of topicNames) { + const graphPath = path.join(chatRoot, topic, 'python', 'src', 'graph.py'); + const graphSource = fs.readFileSync(graphPath, 'utf8'); + + expect(graphSource).toContain('async def generate_title'); + expect(graphSource).toContain('metadata={"title": title}'); + expect(graphSource).toContain('add_node("generate_title", generate_title)'); + expect(graphSource).toContain('add_edge("generate_title", END)'); + } + }); }); diff --git a/cockpit/chat/generative-ui/python/src/graph.py b/cockpit/chat/generative-ui/python/src/graph.py index 0be503c14..184cc3af2 100644 --- a/cockpit/chat/generative-ui/python/src/graph.py +++ b/cockpit/chat/generative-ui/python/src/graph.py @@ -15,14 +15,16 @@ """ import json +import os from pathlib import Path from typing import Literal -from langchain_core.messages import AIMessage, SystemMessage, ToolMessage +from langchain_core.messages import AIMessage, HumanMessage, SystemMessage, ToolMessage from langchain_core.tools import tool from langchain_openai import ChatOpenAI from langgraph.graph import StateGraph, MessagesState, END from langgraph.prebuilt import ToolNode +from langgraph_sdk import get_client from src.dashboard_tools import ALL_TOOLS as _DATA_TOOLS @@ -72,6 +74,56 @@ async def render_spec(elements: dict, root: str) -> str: _respond_llm = ChatOpenAI(model="gpt-5-mini", temperature=0, streaming=True) +# ── generate_title node (inline; matches Pattern D from spec +# 2026-05-19-llm-generated-labels-design.md) ────────────────────────────── + +_TITLE_PROMPT = ( + "In 3-5 words, summarize what the user is asking about. " + "Output ONLY the title — no quotes, no period, no prefix." +) +_TITLE_MODEL = "gpt-5-mini" + + +async def generate_title(state: DashboardState, config) -> dict: + """Background title generation: on the first turn, summarize the user's + intent into 3-5 words and persist to LangGraph thread metadata. + + Idempotent — skips when metadata.title already exists. Errors are + swallowed because the title is a UX nicety, never a blocker. + """ + thread_id = (config.get("configurable") or {}).get("thread_id") + if not thread_id: + return {} + sdk_url = os.environ.get("LANGGRAPH_API_URL") + try: + client = get_client(url=sdk_url) + thread = await client.threads.get(thread_id) + if (thread.get("metadata") or {}).get("title"): + return {} + first_user = next( + (m for m in state["messages"] if getattr(m, "type", None) == "human"), + None, + ) + if not first_user or not isinstance(first_user.content, str): + return {} + if first_user.content.lstrip().startswith("{"): + return {} + llm = ChatOpenAI(model=_TITLE_MODEL, temperature=0) + response = await llm.ainvoke([ + SystemMessage(content=_TITLE_PROMPT), + HumanMessage(content=first_user.content), + ]) + title = (response.content or "").strip().strip('"').strip("'")[:80] + if title: + await client.threads.update(thread_id, metadata={"title": title}) + except Exception as e: # noqa: BLE001 — title is a UX nicety; never block + print( + f"[generate_title] failed for thread {thread_id}: " + f"{type(e).__name__}: {e}", + flush=True, + ) + return {} + async def agent(state: DashboardState) -> dict: """Single agentic node: LLM bound with all 5 tools.""" @@ -250,6 +302,7 @@ async def respond(state: DashboardState) -> dict: _builder.add_node("finalize", finalize) _builder.add_node("emit_state", emit_state) _builder.add_node("respond", respond) +_builder.add_node("generate_title", generate_title) _builder.set_entry_point("agent") _builder.add_conditional_edges("agent", should_continue) @@ -257,6 +310,7 @@ async def respond(state: DashboardState) -> dict: _builder.add_edge("wrap_spec_into_ai", "agent") _builder.add_edge("finalize", "emit_state") _builder.add_edge("emit_state", "respond") -_builder.add_edge("respond", END) +_builder.add_edge("respond", "generate_title") +_builder.add_edge("generate_title", END) graph = _builder.compile() diff --git a/cockpit/chat/input/python/src/graph.py b/cockpit/chat/input/python/src/graph.py index 27bbbb21f..6146a357d 100644 --- a/cockpit/chat/input/python/src/graph.py +++ b/cockpit/chat/input/python/src/graph.py @@ -5,13 +5,65 @@ including keyboard handling, disabled state, and custom placeholder. """ +import os from pathlib import Path from langgraph.graph import StateGraph, MessagesState, END from langchain_openai import ChatOpenAI -from langchain_core.messages import SystemMessage +from langchain_core.messages import HumanMessage, SystemMessage +from langgraph_sdk import get_client PROMPTS_DIR = Path(__file__).parent.parent / "prompts" +# ── generate_title node (inline; matches Pattern D from spec +# 2026-05-19-llm-generated-labels-design.md) ────────────────────────────── + +_TITLE_PROMPT = ( + "In 3-5 words, summarize what the user is asking about. " + "Output ONLY the title — no quotes, no period, no prefix." +) +_TITLE_MODEL = "gpt-5-mini" + + +async def generate_title(state: MessagesState, config) -> dict: + """Background title generation: on the first turn, summarize the user's + intent into 3-5 words and persist to LangGraph thread metadata. + + Idempotent — skips when metadata.title already exists. Errors are + swallowed because the title is a UX nicety, never a blocker. + """ + thread_id = (config.get("configurable") or {}).get("thread_id") + if not thread_id: + return {} + sdk_url = os.environ.get("LANGGRAPH_API_URL") + try: + client = get_client(url=sdk_url) + thread = await client.threads.get(thread_id) + if (thread.get("metadata") or {}).get("title"): + return {} + first_user = next( + (m for m in state["messages"] if getattr(m, "type", None) == "human"), + None, + ) + if not first_user or not isinstance(first_user.content, str): + return {} + if first_user.content.lstrip().startswith("{"): + return {} + llm = ChatOpenAI(model=_TITLE_MODEL, temperature=0) + response = await llm.ainvoke([ + SystemMessage(content=_TITLE_PROMPT), + HumanMessage(content=first_user.content), + ]) + title = (response.content or "").strip().strip('"').strip("'")[:80] + if title: + await client.threads.update(thread_id, metadata={"title": title}) + except Exception as e: # noqa: BLE001 — title is a UX nicety; never block + print( + f"[generate_title] failed for thread {thread_id}: " + f"{type(e).__name__}: {e}", + flush=True, + ) + return {} + def build_input_graph(): """ @@ -28,8 +80,10 @@ async def generate(state: MessagesState) -> dict: graph = StateGraph(MessagesState) graph.add_node("generate", generate) + graph.add_node("generate_title", generate_title) graph.set_entry_point("generate") - graph.add_edge("generate", END) + graph.add_edge("generate", "generate_title") + graph.add_edge("generate_title", END) return graph.compile() diff --git a/cockpit/chat/interrupts/python/src/graph.py b/cockpit/chat/interrupts/python/src/graph.py index 5cbbf6b30..2eeadc250 100644 --- a/cockpit/chat/interrupts/python/src/graph.py +++ b/cockpit/chat/interrupts/python/src/graph.py @@ -5,13 +5,15 @@ so the chat-interrupt-panel renders and the user can Accept or Ignore. """ +import os from pathlib import Path from langgraph.graph import StateGraph, MessagesState, END from langgraph.prebuilt import ToolNode from langgraph.types import interrupt from langchain_openai import ChatOpenAI -from langchain_core.messages import SystemMessage +from langchain_core.messages import HumanMessage, SystemMessage from langchain_core.tools import tool +from langgraph_sdk import get_client from src.aviation_tools import ( get_airport_info, @@ -22,6 +24,56 @@ PROMPTS_DIR = Path(__file__).parent.parent / "prompts" MODEL = "gpt-5-mini" +# ── generate_title node (inline; matches Pattern D from spec +# 2026-05-19-llm-generated-labels-design.md) ────────────────────────────── + +_TITLE_PROMPT = ( + "In 3-5 words, summarize what the user is asking about. " + "Output ONLY the title — no quotes, no period, no prefix." +) +_TITLE_MODEL = "gpt-5-mini" + + +async def generate_title(state: MessagesState, config) -> dict: + """Background title generation: on the first turn, summarize the user's + intent into 3-5 words and persist to LangGraph thread metadata. + + Idempotent — skips when metadata.title already exists. Errors are + swallowed because the title is a UX nicety, never a blocker. + """ + thread_id = (config.get("configurable") or {}).get("thread_id") + if not thread_id: + return {} + sdk_url = os.environ.get("LANGGRAPH_API_URL") + try: + client = get_client(url=sdk_url) + thread = await client.threads.get(thread_id) + if (thread.get("metadata") or {}).get("title"): + return {} + first_user = next( + (m for m in state["messages"] if getattr(m, "type", None) == "human"), + None, + ) + if not first_user or not isinstance(first_user.content, str): + return {} + if first_user.content.lstrip().startswith("{"): + return {} + llm = ChatOpenAI(model=_TITLE_MODEL, temperature=0) + response = await llm.ainvoke([ + SystemMessage(content=_TITLE_PROMPT), + HumanMessage(content=first_user.content), + ]) + title = (response.content or "").strip().strip('"').strip("'")[:80] + if title: + await client.threads.update(thread_id, metadata={"title": title}) + except Exception as e: # noqa: BLE001 — title is a UX nicety; never block + print( + f"[generate_title] failed for thread {thread_id}: " + f"{type(e).__name__}: {e}", + flush=True, + ) + return {} + @tool async def book_flight(flight_number: str) -> str: @@ -83,9 +135,11 @@ def should_continue(state: MessagesState) -> str: graph = StateGraph(MessagesState) graph.add_node("agent", agent) graph.add_node("tools", ToolNode(tools)) + graph.add_node("generate_title", generate_title) graph.set_entry_point("agent") - graph.add_conditional_edges("agent", should_continue, {"tools": "tools", END: END}) + graph.add_conditional_edges("agent", should_continue, {"tools": "tools", END: "generate_title"}) graph.add_edge("tools", "agent") + graph.add_edge("generate_title", END) return graph.compile() diff --git a/cockpit/chat/messages/python/src/graph.py b/cockpit/chat/messages/python/src/graph.py index 518225fcf..5ce173065 100644 --- a/cockpit/chat/messages/python/src/graph.py +++ b/cockpit/chat/messages/python/src/graph.py @@ -5,13 +5,65 @@ with different message types (human, AI, system). """ +import os from pathlib import Path from langgraph.graph import StateGraph, MessagesState, END from langchain_openai import ChatOpenAI -from langchain_core.messages import SystemMessage +from langchain_core.messages import HumanMessage, SystemMessage +from langgraph_sdk import get_client PROMPTS_DIR = Path(__file__).parent.parent / "prompts" +# ── generate_title node (inline; matches Pattern D from spec +# 2026-05-19-llm-generated-labels-design.md) ────────────────────────────── + +_TITLE_PROMPT = ( + "In 3-5 words, summarize what the user is asking about. " + "Output ONLY the title — no quotes, no period, no prefix." +) +_TITLE_MODEL = "gpt-5-mini" + + +async def generate_title(state: MessagesState, config) -> dict: + """Background title generation: on the first turn, summarize the user's + intent into 3-5 words and persist to LangGraph thread metadata. + + Idempotent — skips when metadata.title already exists. Errors are + swallowed because the title is a UX nicety, never a blocker. + """ + thread_id = (config.get("configurable") or {}).get("thread_id") + if not thread_id: + return {} + sdk_url = os.environ.get("LANGGRAPH_API_URL") + try: + client = get_client(url=sdk_url) + thread = await client.threads.get(thread_id) + if (thread.get("metadata") or {}).get("title"): + return {} + first_user = next( + (m for m in state["messages"] if getattr(m, "type", None) == "human"), + None, + ) + if not first_user or not isinstance(first_user.content, str): + return {} + if first_user.content.lstrip().startswith("{"): + return {} + llm = ChatOpenAI(model=_TITLE_MODEL, temperature=0) + response = await llm.ainvoke([ + SystemMessage(content=_TITLE_PROMPT), + HumanMessage(content=first_user.content), + ]) + title = (response.content or "").strip().strip('"').strip("'")[:80] + if title: + await client.threads.update(thread_id, metadata={"title": title}) + except Exception as e: # noqa: BLE001 — title is a UX nicety; never block + print( + f"[generate_title] failed for thread {thread_id}: " + f"{type(e).__name__}: {e}", + flush=True, + ) + return {} + def build_messages_graph(): """ @@ -30,8 +82,10 @@ async def generate(state: MessagesState) -> dict: graph = StateGraph(MessagesState) graph.add_node("generate", generate) + graph.add_node("generate_title", generate_title) graph.set_entry_point("generate") - graph.add_edge("generate", END) + graph.add_edge("generate", "generate_title") + graph.add_edge("generate_title", END) return graph.compile() diff --git a/cockpit/chat/subagents/python/src/graph.py b/cockpit/chat/subagents/python/src/graph.py index fc9465d00..04525a176 100644 --- a/cockpit/chat/subagents/python/src/graph.py +++ b/cockpit/chat/subagents/python/src/graph.py @@ -5,6 +5,7 @@ copied into this module. """ +import os from pathlib import Path from typing import Literal @@ -13,6 +14,7 @@ from langchain_openai import ChatOpenAI from langgraph.graph import StateGraph, MessagesState, END from langgraph.prebuilt import ToolNode +from langgraph_sdk import get_client from src.aviation_tools import ( get_airport_info, @@ -22,6 +24,56 @@ PROMPTS_DIR = Path(__file__).parent.parent / "prompts" +# ── generate_title node (inline; matches Pattern D from spec +# 2026-05-19-llm-generated-labels-design.md) ────────────────────────────── + +_TITLE_PROMPT = ( + "In 3-5 words, summarize what the user is asking about. " + "Output ONLY the title — no quotes, no period, no prefix." +) +_TITLE_MODEL = "gpt-5-mini" + + +async def generate_title(state: MessagesState, config) -> dict: + """Background title generation: on the first turn, summarize the user's + intent into 3-5 words and persist to LangGraph thread metadata. + + Idempotent — skips when metadata.title already exists. Errors are + swallowed because the title is a UX nicety, never a blocker. + """ + thread_id = (config.get("configurable") or {}).get("thread_id") + if not thread_id: + return {} + sdk_url = os.environ.get("LANGGRAPH_API_URL") + try: + client = get_client(url=sdk_url) + thread = await client.threads.get(thread_id) + if (thread.get("metadata") or {}).get("title"): + return {} + first_user = next( + (m for m in state["messages"] if getattr(m, "type", None) == "human"), + None, + ) + if not first_user or not isinstance(first_user.content, str): + return {} + if first_user.content.lstrip().startswith("{"): + return {} + llm = ChatOpenAI(model=_TITLE_MODEL, temperature=0) + response = await llm.ainvoke([ + SystemMessage(content=_TITLE_PROMPT), + HumanMessage(content=first_user.content), + ]) + title = (response.content or "").strip().strip('"').strip("'")[:80] + if title: + await client.threads.update(thread_id, metadata={"title": title}) + except Exception as e: # noqa: BLE001 — title is a UX nicety; never block + print( + f"[generate_title] failed for thread {thread_id}: " + f"{type(e).__name__}: {e}", + flush=True, + ) + return {} + _RESEARCH_PROMPT = """You are a Research Agent for trip planning. Your job is to gather destination intel about airports the traveler is considering. Use the @@ -125,9 +177,11 @@ def should_continue(state: MessagesState) -> str: graph = StateGraph(MessagesState) graph.add_node("orchestrator", orchestrator) graph.add_node("tools", ToolNode([task])) + graph.add_node("generate_title", generate_title) graph.set_entry_point("orchestrator") - graph.add_conditional_edges("orchestrator", should_continue, {"tools": "tools", END: END}) + graph.add_conditional_edges("orchestrator", should_continue, {"tools": "tools", END: "generate_title"}) graph.add_edge("tools", "orchestrator") + graph.add_edge("generate_title", END) return graph.compile() diff --git a/cockpit/chat/theming/python/src/graph.py b/cockpit/chat/theming/python/src/graph.py index c88e8ef95..61d03626e 100644 --- a/cockpit/chat/theming/python/src/graph.py +++ b/cockpit/chat/theming/python/src/graph.py @@ -5,13 +5,65 @@ frontend theming with CSS custom properties, not graph complexity. """ +import os from pathlib import Path from langgraph.graph import StateGraph, MessagesState, END from langchain_openai import ChatOpenAI -from langchain_core.messages import SystemMessage +from langchain_core.messages import HumanMessage, SystemMessage +from langgraph_sdk import get_client PROMPTS_DIR = Path(__file__).parent.parent / "prompts" +# ── generate_title node (inline; matches Pattern D from spec +# 2026-05-19-llm-generated-labels-design.md) ────────────────────────────── + +_TITLE_PROMPT = ( + "In 3-5 words, summarize what the user is asking about. " + "Output ONLY the title — no quotes, no period, no prefix." +) +_TITLE_MODEL = "gpt-5-mini" + + +async def generate_title(state: MessagesState, config) -> dict: + """Background title generation: on the first turn, summarize the user's + intent into 3-5 words and persist to LangGraph thread metadata. + + Idempotent — skips when metadata.title already exists. Errors are + swallowed because the title is a UX nicety, never a blocker. + """ + thread_id = (config.get("configurable") or {}).get("thread_id") + if not thread_id: + return {} + sdk_url = os.environ.get("LANGGRAPH_API_URL") + try: + client = get_client(url=sdk_url) + thread = await client.threads.get(thread_id) + if (thread.get("metadata") or {}).get("title"): + return {} + first_user = next( + (m for m in state["messages"] if getattr(m, "type", None) == "human"), + None, + ) + if not first_user or not isinstance(first_user.content, str): + return {} + if first_user.content.lstrip().startswith("{"): + return {} + llm = ChatOpenAI(model=_TITLE_MODEL, temperature=0) + response = await llm.ainvoke([ + SystemMessage(content=_TITLE_PROMPT), + HumanMessage(content=first_user.content), + ]) + title = (response.content or "").strip().strip('"').strip("'")[:80] + if title: + await client.threads.update(thread_id, metadata={"title": title}) + except Exception as e: # noqa: BLE001 — title is a UX nicety; never block + print( + f"[generate_title] failed for thread {thread_id}: " + f"{type(e).__name__}: {e}", + flush=True, + ) + return {} + def build_theming_graph(): """ @@ -28,8 +80,10 @@ async def generate(state: MessagesState) -> dict: graph = StateGraph(MessagesState) graph.add_node("generate", generate) + graph.add_node("generate_title", generate_title) graph.set_entry_point("generate") - graph.add_edge("generate", END) + graph.add_edge("generate", "generate_title") + graph.add_edge("generate_title", END) return graph.compile() diff --git a/cockpit/chat/timeline/python/src/graph.py b/cockpit/chat/timeline/python/src/graph.py index 99c20592e..d124ffa2e 100644 --- a/cockpit/chat/timeline/python/src/graph.py +++ b/cockpit/chat/timeline/python/src/graph.py @@ -5,13 +5,65 @@ is managed by the agent() ref on the frontend side. """ +import os from pathlib import Path from langgraph.graph import StateGraph, MessagesState, END from langchain_openai import ChatOpenAI -from langchain_core.messages import SystemMessage +from langchain_core.messages import HumanMessage, SystemMessage +from langgraph_sdk import get_client PROMPTS_DIR = Path(__file__).parent.parent / "prompts" +# ── generate_title node (inline; matches Pattern D from spec +# 2026-05-19-llm-generated-labels-design.md) ────────────────────────────── + +_TITLE_PROMPT = ( + "In 3-5 words, summarize what the user is asking about. " + "Output ONLY the title — no quotes, no period, no prefix." +) +_TITLE_MODEL = "gpt-5-mini" + + +async def generate_title(state: MessagesState, config) -> dict: + """Background title generation: on the first turn, summarize the user's + intent into 3-5 words and persist to LangGraph thread metadata. + + Idempotent — skips when metadata.title already exists. Errors are + swallowed because the title is a UX nicety, never a blocker. + """ + thread_id = (config.get("configurable") or {}).get("thread_id") + if not thread_id: + return {} + sdk_url = os.environ.get("LANGGRAPH_API_URL") + try: + client = get_client(url=sdk_url) + thread = await client.threads.get(thread_id) + if (thread.get("metadata") or {}).get("title"): + return {} + first_user = next( + (m for m in state["messages"] if getattr(m, "type", None) == "human"), + None, + ) + if not first_user or not isinstance(first_user.content, str): + return {} + if first_user.content.lstrip().startswith("{"): + return {} + llm = ChatOpenAI(model=_TITLE_MODEL, temperature=0) + response = await llm.ainvoke([ + SystemMessage(content=_TITLE_PROMPT), + HumanMessage(content=first_user.content), + ]) + title = (response.content or "").strip().strip('"').strip("'")[:80] + if title: + await client.threads.update(thread_id, metadata={"title": title}) + except Exception as e: # noqa: BLE001 — title is a UX nicety; never block + print( + f"[generate_title] failed for thread {thread_id}: " + f"{type(e).__name__}: {e}", + flush=True, + ) + return {} + def build_timeline_graph(): """ @@ -28,8 +80,10 @@ async def generate(state: MessagesState) -> dict: graph = StateGraph(MessagesState) graph.add_node("generate", generate) + graph.add_node("generate_title", generate_title) graph.set_entry_point("generate") - graph.add_edge("generate", END) + graph.add_edge("generate", "generate_title") + graph.add_edge("generate_title", END) return graph.compile() diff --git a/cockpit/chat/tool-calls/python/src/graph.py b/cockpit/chat/tool-calls/python/src/graph.py index ca0792bcd..d769e57d7 100644 --- a/cockpit/chat/tool-calls/python/src/graph.py +++ b/cockpit/chat/tool-calls/python/src/graph.py @@ -4,17 +4,69 @@ copied into this module. """ +import os from pathlib import Path -from langchain_core.messages import SystemMessage +from langchain_core.messages import HumanMessage, SystemMessage from langchain_openai import ChatOpenAI from langgraph.graph import StateGraph, MessagesState, END from langgraph.prebuilt import ToolNode +from langgraph_sdk import get_client from src.aviation_tools import ALL_TOOLS as AVIATION_TOOLS PROMPTS_DIR = Path(__file__).parent.parent / "prompts" +# ── generate_title node (inline; matches Pattern D from spec +# 2026-05-19-llm-generated-labels-design.md) ────────────────────────────── + +_TITLE_PROMPT = ( + "In 3-5 words, summarize what the user is asking about. " + "Output ONLY the title — no quotes, no period, no prefix." +) +_TITLE_MODEL = "gpt-5-mini" + + +async def generate_title(state: MessagesState, config) -> dict: + """Background title generation: on the first turn, summarize the user's + intent into 3-5 words and persist to LangGraph thread metadata. + + Idempotent — skips when metadata.title already exists. Errors are + swallowed because the title is a UX nicety, never a blocker. + """ + thread_id = (config.get("configurable") or {}).get("thread_id") + if not thread_id: + return {} + sdk_url = os.environ.get("LANGGRAPH_API_URL") + try: + client = get_client(url=sdk_url) + thread = await client.threads.get(thread_id) + if (thread.get("metadata") or {}).get("title"): + return {} + first_user = next( + (m for m in state["messages"] if getattr(m, "type", None) == "human"), + None, + ) + if not first_user or not isinstance(first_user.content, str): + return {} + if first_user.content.lstrip().startswith("{"): + return {} + llm = ChatOpenAI(model=_TITLE_MODEL, temperature=0) + response = await llm.ainvoke([ + SystemMessage(content=_TITLE_PROMPT), + HumanMessage(content=first_user.content), + ]) + title = (response.content or "").strip().strip('"').strip("'")[:80] + if title: + await client.threads.update(thread_id, metadata={"title": title}) + except Exception as e: # noqa: BLE001 — title is a UX nicety; never block + print( + f"[generate_title] failed for thread {thread_id}: " + f"{type(e).__name__}: {e}", + flush=True, + ) + return {} + def build_tool_calls_graph(): """Canonical agent ↔ ToolNode loop with aviation tools bound.""" @@ -35,9 +87,11 @@ def should_continue(state: MessagesState) -> str: graph = StateGraph(MessagesState) graph.add_node("agent", agent) graph.add_node("tools", ToolNode(AVIATION_TOOLS)) + graph.add_node("generate_title", generate_title) graph.set_entry_point("agent") - graph.add_conditional_edges("agent", should_continue, {"tools": "tools", END: END}) + graph.add_conditional_edges("agent", should_continue, {"tools": "tools", END: "generate_title"}) graph.add_edge("tools", "agent") + graph.add_edge("generate_title", END) return graph.compile() diff --git a/examples/chat/angular/src/app/shell/demo-shell.component.ts b/examples/chat/angular/src/app/shell/demo-shell.component.ts index da9bed0e2..ea9aabdbb 100644 --- a/examples/chat/angular/src/app/shell/demo-shell.component.ts +++ b/examples/chat/angular/src/app/shell/demo-shell.component.ts @@ -155,7 +155,7 @@ export class DemoShell { }); // Refresh threads list when an agent run completes. The backend writes - // metadata.title on the first user message via _maybe_write_thread_title; + // metadata.title on the first user message via generate_title; // a refresh after run-end picks up the new title in the drawer without // needing a manual thread switch or reload. refreshOnRunEnd(this.agent, () => this.threadsSvc.refresh()); @@ -315,7 +315,7 @@ export class DemoShell { /** Title of the currently-selected thread, or 'New chat' if none. The * Python graph writes thread.metadata.title from the first user message - * via _maybe_write_thread_title; threadsSvc surfaces it via threads(). */ + * via generate_title; threadsSvc surfaces it via threads(). */ readonly currentThreadTitle = computed(() => { const id = this.threadIdSignal(); if (!id) return 'New chat'; diff --git a/examples/chat/python/src/graph.py b/examples/chat/python/src/graph.py index d711cbc96..ff1b241a0 100644 --- a/examples/chat/python/src/graph.py +++ b/examples/chat/python/src/graph.py @@ -11,7 +11,7 @@ Topology: __start__ → generate ─┬─ [has tool_calls] ─→ tools ─→ generate (loop) - └─ [no tool_calls] ─→ attach_citations ─→ __end__ + └─ [no tool_calls] ─→ attach_citations ─→ generate_title ─→ __end__ The terminal ``attach_citations`` node walks back from the final AI message to the most recent ToolMessage, parses its JSON content, and @@ -22,7 +22,6 @@ """ import json import os -import re from typing import Annotated, Literal, Optional from typing_extensions import TypedDict @@ -49,43 +48,16 @@ from src.schemas.a2ui_v1 import A2UI_V1_SCHEMA_PROMPT -# Module-level singleton client; created lazily on first thread-title write. -_threads_client = None - +_TITLE_PROMPT = ( + "In 3-5 words, summarize what the user is asking about. " + "Output ONLY the title — no quotes, no period, no prefix." +) +_TITLE_MODEL = "gpt-5-mini" -def _slice_title(text: str, *, limit: int = 50) -> str: - """Trim a user message into a thread title. - Replaces internal whitespace runs with single spaces, strips leading - and trailing whitespace, then slices to `limit` codepoints. Regional - indicator pairs (flag emoji) that would be split at the boundary are - trimmed so the slice never ends with an orphaned indicator codepoint. - """ - cleaned = re.sub(r"\s+", " ", text).strip() - if len(cleaned) <= limit: - return cleaned - sliced = cleaned[:limit].rstrip() - # Regional indicators sit in U+1F1E6–U+1F1FF. A flag emoji is exactly - # two consecutive regional indicators. If the slice ends on a regional - # indicator that is the *first* of a pair (i.e. the next codepoint in - # the original string is also a regional indicator, forming a flag), we - # drop it so we never expose a half-flag. - _RI_START = 0x1F1E6 - _RI_END = 0x1F1FF - if sliced and _RI_START <= ord(sliced[-1]) <= _RI_END: - pos = len(sliced) - 1 - # Check whether the preceding character is also a regional indicator - # (which would make sliced[-1] the *second* of a pair — it's whole). - if pos == 0 or not (_RI_START <= ord(sliced[-2]) <= _RI_END): - # Orphaned first indicator — drop it. - sliced = sliced[:-1].rstrip() - return sliced - - -@traceable(name="_maybe_write_thread_title", run_type="tool") -async def _maybe_write_thread_title(state: "State", config: RunnableConfig) -> dict: - """Side effect: on the first user message in a thread, persist a - derived title to the thread's LangGraph metadata. +@traceable(name="generate_title", run_type="tool") +async def generate_title(state: "State", config: RunnableConfig) -> dict: + """Terminal side effect: generate and persist a short thread title. Idempotent — only writes when metadata.title is currently absent. Errors are NOT raised (title is a UX nicety, never a blocker) but @@ -95,7 +67,6 @@ async def _maybe_write_thread_title(state: "State", config: RunnableConfig) -> d decorator creates a LangSmith child run with these outputs visible in the trace UI / runs API. """ - global _threads_client thread_id = (config.get("configurable") or {}).get("thread_id") # url=None lets the SDK use its in-process ASGI transport when the # call originates from inside a LangGraph server graph (which is @@ -107,15 +78,14 @@ async def _maybe_write_thread_title(state: "State", config: RunnableConfig) -> d # when explicitly set, e.g. for cross-process callbacks. sdk_url = os.environ.get("LANGGRAPH_API_URL") if not isinstance(thread_id, str) or not thread_id: - return {"skipped": "no thread_id in config"} + return {} try: - if _threads_client is None: - _threads_client = get_client(url=sdk_url) - thread = await _threads_client.threads.get(thread_id) + client = get_client(url=sdk_url) + thread = await client.threads.get(thread_id) existing = (thread.get("metadata") or {}).get("title") if isinstance(existing, str) and existing.strip(): - return {"skipped": "already titled", "title": existing} + return {} # Find the first user message in the current state. first_user = None @@ -129,17 +99,24 @@ async def _maybe_write_thread_title(state: "State", config: RunnableConfig) -> d first_user = content break if not first_user: - return {"skipped": "no human message in state"} - - title = _slice_title(first_user) + return {} + if first_user.lstrip().startswith("{"): + return {} + + llm = ChatOpenAI(model=_TITLE_MODEL, temperature=0) + response = await llm.ainvoke([ + SystemMessage(content=_TITLE_PROMPT), + HumanMessage(content=first_user), + ]) + title = (response.content or "").strip().strip('"').strip("'")[:80] if not title: - return {"skipped": "title slice empty"} + return {} - await _threads_client.threads.update( + await client.threads.update( thread_id, metadata={"title": title}, ) - return {"wrote_title": title, "sdk_url": sdk_url} + return {} except Exception as e: # noqa: BLE001 — title is a UX nicety; never block # Don't break the run, but DO surface the failure. A bare swallow # has hidden a prod bug where every title write was throwing @@ -393,10 +370,6 @@ class State(TypedDict): async def generate(state: State, config: RunnableConfig) -> dict: - # Best-effort thread title write on the first user message. Idempotent; - # swallows errors so it never blocks the run. - await _maybe_write_thread_title(state, config) - model_name = state.get("model") or "gpt-5-mini" kwargs = {"model": model_name, "streaming": True} if _is_reasoning_model(model_name): @@ -688,6 +661,7 @@ async def attach_citations(state: State) -> dict: ])) _builder.add_node("emit_generated_surface", emit_generated_surface) _builder.add_node("attach_citations", attach_citations) +_builder.add_node("generate_title", generate_title) _builder.set_entry_point("generate") _builder.add_conditional_edges( "generate", @@ -705,8 +679,9 @@ async def attach_citations(state: State) -> dict: "generate": "generate", }, ) -_builder.add_edge("emit_generated_surface", END) -_builder.add_edge("attach_citations", END) +_builder.add_edge("emit_generated_surface", "generate_title") +_builder.add_edge("attach_citations", "generate_title") +_builder.add_edge("generate_title", END) # LangGraph API manages persistence for the deployed graph; keep the # exported graph free of a custom checkpointer. diff --git a/examples/chat/python/tests/test_graph_smoke.py b/examples/chat/python/tests/test_graph_smoke.py index a441b9b2c..a357bb921 100644 --- a/examples/chat/python/tests/test_graph_smoke.py +++ b/examples/chat/python/tests/test_graph_smoke.py @@ -32,6 +32,8 @@ def test_state_graph_has_tools_and_attach_citations_nodes(): assert "tools" in nodes, "State graph must add a tools node (Phase 2B)" assert "attach_citations" in nodes, \ "State graph must add an attach_citations terminal node (Phase 2B)" + assert "generate_title" in nodes, \ + "State graph must add a generate_title terminal node" @pytest.mark.smoke @@ -127,42 +129,85 @@ def test_phase4_artifacts_removed(): "emit_a2ui_surface node should be replaced by emit_generated_surface" -from src.graph import _slice_title +import asyncio +from langchain_core.messages import HumanMessage, AIMessage, ToolMessage -class TestSliceTitle: - def test_short_text_returned_as_is(self): - assert _slice_title("hello world") == "hello world" +class TestGenerateTitle: + def test_calls_llm_and_writes_metadata_title(self, monkeypatch): + import src.graph as graph_mod - def test_long_text_truncated_to_50(self): - text = "a" * 80 - result = _slice_title(text) - assert len(result) == 50 - assert result == "a" * 50 + updates = [] + title_messages = [] + title_model_kwargs = [] - def test_newlines_replaced_with_spaces(self): - assert _slice_title("hello\nworld") == "hello world" + class FakeThreads: + async def get(self, thread_id): + assert thread_id == "thread-1" + return {"metadata": {}} - def test_emoji_not_split_mid_grapheme(self): - # The flag-USA emoji is a 2-codepoint regional-indicator sequence. - # A naive [:50] could land between the two indicators if the - # 50-char boundary falls there. Slice on grapheme boundary so - # the flag stays intact. - text = "x" * 49 + "🇺🇸" - result = _slice_title(text) - # At grapheme boundary 50, the flag is either fully present (51 cps) - # or fully absent (49 'x' chars + truncation). Never mid-flag. - assert "🇺🇸" in result or result == "x" * 49 or result == "x" * 50 + async def update(self, thread_id, metadata): + updates.append((thread_id, metadata)) - def test_empty_string_returns_empty(self): - assert _slice_title("") == "" + class FakeClient: + threads = FakeThreads() - def test_strips_leading_trailing_whitespace(self): - assert _slice_title(" hello ") == "hello" + class FakeChatOpenAI: + def __init__(self, **kwargs): + title_model_kwargs.append(kwargs) + async def ainvoke(self, messages): + title_messages.extend(messages) + return AIMessage(content='"Plan Kyoto Trip."') -import asyncio -from langchain_core.messages import HumanMessage, AIMessage, ToolMessage + monkeypatch.setattr(graph_mod, "get_client", lambda url=None: FakeClient()) + monkeypatch.setattr(graph_mod, "ChatOpenAI", FakeChatOpenAI) + + result = asyncio.run( + graph_mod.generate_title( + {"messages": [HumanMessage(content="Help me plan a Kyoto trip in April")]}, + {"configurable": {"thread_id": "thread-1"}}, + ) + ) + + assert result == {} + assert updates == [("thread-1", {"title": "Plan Kyoto Trip."})] + assert title_model_kwargs == [{"model": "gpt-5-mini", "temperature": 0}] + assert title_messages[-1].content == "Help me plan a Kyoto trip in April" + + def test_skips_when_metadata_title_already_exists(self, monkeypatch): + import src.graph as graph_mod + + updates = [] + llm_calls = [] + + class FakeThreads: + async def get(self, thread_id): + return {"metadata": {"title": "Existing title"}} + + async def update(self, thread_id, metadata): + updates.append((thread_id, metadata)) + + class FakeClient: + threads = FakeThreads() + + class FakeChatOpenAI: + def __init__(self, **kwargs): + llm_calls.append(kwargs) + + monkeypatch.setattr(graph_mod, "get_client", lambda url=None: FakeClient()) + monkeypatch.setattr(graph_mod, "ChatOpenAI", FakeChatOpenAI) + + result = asyncio.run( + graph_mod.generate_title( + {"messages": [HumanMessage(content="Plan a trip")]}, + {"configurable": {"thread_id": "thread-1"}}, + ) + ) + + assert result == {} + assert updates == [] + assert llm_calls == [] class TestEmitGeneratedSurfaceCoalescing: diff --git a/examples/chat/smoke/CHECKLIST.md b/examples/chat/smoke/CHECKLIST.md index b9f8b2fdd..34620745c 100644 --- a/examples/chat/smoke/CHECKLIST.md +++ b/examples/chat/smoke/CHECKLIST.md @@ -400,7 +400,7 @@ Components NOT yet exercised by the demo (deferred to future media-focused sugge ## Thread titles -- [ ] First user message in a new thread triggers a server-side title write (derived from the message text, sliced to ~50 chars, emoji-safe boundary) +- [ ] First user message in a new thread triggers a server-side title write (LLM-generated 3-5 word summary from the message text) - [ ] Title appears in the sidenav row label after the run completes (refresh-driven) - [ ] Title is idempotent — a second message into the same thread does NOT overwrite the title - [ ] Manually renamed titles (via kebab → Rename) take precedence and are not overwritten by subsequent messages From ccf0db7dac871fad2dc8faf5897744a4037f4093 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Thu, 21 May 2026 17:54:45 +0000 Subject: [PATCH 2/2] chore(docs): regenerate api docs --- apps/website/content/docs/agent/api/api-docs.json | 8 +------- 1 file changed, 1 insertion(+), 7 deletions(-) diff --git a/apps/website/content/docs/agent/api/api-docs.json b/apps/website/content/docs/agent/api/api-docs.json index a3132bf8d..dd21d361e 100644 --- a/apps/website/content/docs/agent/api/api-docs.json +++ b/apps/website/content/docs/agent/api/api-docs.json @@ -1447,7 +1447,7 @@ { "name": "LangGraphThreadsConfig", "kind": "interface", - "description": "Configuration consumed by LangGraphThreadsAdapter. Provide\nvia LANGGRAPH_THREADS_CONFIG (typically in app.config.ts):\n\n```ts\nproviders: [\n { provide: LANGGRAPH_THREADS_CONFIG, useValue: {\n apiUrl: environment.langGraphApiUrl,\n titleMetadataKey: 'thread_title',\n }},\n],\n```", + "description": "Configuration consumed by LangGraphThreadsAdapter. Provide\nvia LANGGRAPH_THREADS_CONFIG (typically in app.config.ts):\n\n```ts\nproviders: [\n { provide: LANGGRAPH_THREADS_CONFIG, useValue: {\n apiUrl: environment.langGraphApiUrl,\n }},\n],\n```\n\nThe adapter expects backends to write the thread title to\n`metadata.title`. Spec 2026-05-19-llm-generated-labels-design.md\noriginally proposed `metadata.thread_title` for cockpit caps but\nwe converged on `title` to match the canonical demo and avoid a\nper-cap configuration knob.", "properties": [ { "name": "apiUrl", @@ -1460,12 +1460,6 @@ "type": "string", "description": "Fallback label for threads whose title hasn't been written yet\n (e.g. created but never sent). Defaults to `'Untitled'`.", "optional": true - }, - { - "name": "titleMetadataKey", - "type": "string", - "description": "Metadata key the backend writes the thread title to. Two\n conventions exist in the wild:\n - `'title'` — legacy / canonical demo\n - `'thread_title'` — spec 2026-05-19-llm-generated-labels-design\n Defaults to `'thread_title'`.", - "optional": true } ], "examples": []