Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 1 addition & 7 deletions apps/website/content/docs/agent/api/api-docs.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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": []
Expand Down
58 changes: 56 additions & 2 deletions cockpit/chat/debug/python/src/graph.py
Original file line number Diff line number Diff line change
Expand Up @@ -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():
"""
Expand Down Expand Up @@ -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()

Expand Down
12 changes: 12 additions & 0 deletions cockpit/chat/footprint.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)');
}
});
});
58 changes: 56 additions & 2 deletions cockpit/chat/generative-ui/python/src/graph.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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."""
Expand Down Expand Up @@ -250,13 +302,15 @@ 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)
_builder.add_edge("tools", "wrap_spec_into_ai")
_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()
58 changes: 56 additions & 2 deletions cockpit/chat/input/python/src/graph.py
Original file line number Diff line number Diff line change
Expand Up @@ -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():
"""
Expand All @@ -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()

Expand Down
58 changes: 56 additions & 2 deletions cockpit/chat/interrupts/python/src/graph.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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:
Expand Down Expand Up @@ -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()


Expand Down
Loading