|
21 | 21 | track-by-id stable. |
22 | 22 | """ |
23 | 23 | import json |
| 24 | +import os |
| 25 | +import re |
24 | 26 | from typing import Annotated, Literal, Optional |
25 | 27 | from typing_extensions import TypedDict |
26 | 28 |
|
|
36 | 38 | SystemMessage, |
37 | 39 | ToolMessage, |
38 | 40 | ) |
| 41 | +from langchain_core.runnables import RunnableConfig |
39 | 42 | from langchain_core.tools import tool |
| 43 | +from langgraph_sdk import get_client |
| 44 | + |
| 45 | + |
| 46 | +# Module-level singleton client; created lazily on first thread-title write. |
| 47 | +_threads_client = None |
| 48 | + |
| 49 | + |
| 50 | +def _slice_title(text: str, *, limit: int = 50) -> str: |
| 51 | + """Trim a user message into a thread title. |
| 52 | +
|
| 53 | + Replaces internal whitespace runs with single spaces, strips leading |
| 54 | + and trailing whitespace, then slices to `limit` codepoints. Regional |
| 55 | + indicator pairs (flag emoji) that would be split at the boundary are |
| 56 | + trimmed so the slice never ends with an orphaned indicator codepoint. |
| 57 | + """ |
| 58 | + cleaned = re.sub(r"\s+", " ", text).strip() |
| 59 | + if len(cleaned) <= limit: |
| 60 | + return cleaned |
| 61 | + sliced = cleaned[:limit].rstrip() |
| 62 | + # Regional indicators sit in U+1F1E6–U+1F1FF. A flag emoji is exactly |
| 63 | + # two consecutive regional indicators. If the slice ends on a regional |
| 64 | + # indicator that is the *first* of a pair (i.e. the next codepoint in |
| 65 | + # the original string is also a regional indicator, forming a flag), we |
| 66 | + # drop it so we never expose a half-flag. |
| 67 | + _RI_START = 0x1F1E6 |
| 68 | + _RI_END = 0x1F1FF |
| 69 | + if sliced and _RI_START <= ord(sliced[-1]) <= _RI_END: |
| 70 | + pos = len(sliced) - 1 |
| 71 | + # Check whether the preceding character is also a regional indicator |
| 72 | + # (which would make sliced[-1] the *second* of a pair — it's whole). |
| 73 | + if pos == 0 or not (_RI_START <= ord(sliced[-2]) <= _RI_END): |
| 74 | + # Orphaned first indicator — drop it. |
| 75 | + sliced = sliced[:-1].rstrip() |
| 76 | + return sliced |
| 77 | + |
| 78 | + |
| 79 | +async def _maybe_write_thread_title(state: "State", config: RunnableConfig) -> None: |
| 80 | + """Side effect: on the first user message in a thread, persist a |
| 81 | + derived title to the thread's LangGraph metadata. |
| 82 | +
|
| 83 | + Idempotent — only writes when metadata.title is currently absent. |
| 84 | + Errors are swallowed; the title is a UX nicety, never a blocker. |
| 85 | + """ |
| 86 | + global _threads_client |
| 87 | + thread_id = (config.get("configurable") or {}).get("thread_id") |
| 88 | + if not isinstance(thread_id, str) or not thread_id: |
| 89 | + return |
| 90 | + |
| 91 | + try: |
| 92 | + if _threads_client is None: |
| 93 | + _threads_client = get_client( |
| 94 | + url=os.environ.get("LANGGRAPH_API_URL", "http://localhost:2024"), |
| 95 | + ) |
| 96 | + thread = await _threads_client.threads.get(thread_id) |
| 97 | + existing = (thread.get("metadata") or {}).get("title") |
| 98 | + if isinstance(existing, str) and existing.strip(): |
| 99 | + return # Already titled; don't overwrite. |
| 100 | + |
| 101 | + # Find the first user message in the current state. |
| 102 | + first_user = None |
| 103 | + for m in state.get("messages", []): |
| 104 | + type_attr = getattr(m, "type", None) |
| 105 | + getter = getattr(m, "_getType", None) |
| 106 | + msg_type = type_attr if type_attr else (getter() if callable(getter) else None) |
| 107 | + if msg_type == "human": |
| 108 | + content = getattr(m, "content", None) |
| 109 | + if isinstance(content, str) and content.strip(): |
| 110 | + first_user = content |
| 111 | + break |
| 112 | + if not first_user: |
| 113 | + return |
| 114 | + |
| 115 | + title = _slice_title(first_user) |
| 116 | + if not title: |
| 117 | + return |
| 118 | + |
| 119 | + await _threads_client.threads.update( |
| 120 | + thread_id, |
| 121 | + metadata={"title": title}, |
| 122 | + ) |
| 123 | + except Exception: |
| 124 | + # Title write must never break the run. Swallow. |
| 125 | + return |
40 | 126 |
|
41 | 127 |
|
42 | 128 | SYSTEM_PROMPT = ( |
@@ -294,7 +380,11 @@ class State(TypedDict): |
294 | 380 | gen_ui_mode: Optional[str] |
295 | 381 |
|
296 | 382 |
|
297 | | -async def generate(state: State) -> dict: |
| 383 | +async def generate(state: State, config: RunnableConfig) -> dict: |
| 384 | + # Best-effort thread title write on the first user message. Idempotent; |
| 385 | + # swallows errors so it never blocks the run. |
| 386 | + await _maybe_write_thread_title(state, config) |
| 387 | + |
298 | 388 | model_name = state.get("model") or "gpt-5-mini" |
299 | 389 | kwargs = {"model": model_name, "streaming": True} |
300 | 390 | if _is_reasoning_model(model_name): |
|
0 commit comments