diff --git a/backend/apps/agents/agent_manager.py b/backend/apps/agents/agent_manager.py index 038eb8a1..10b8e60a 100644 --- a/backend/apps/agents/agent_manager.py +++ b/backend/apps/agents/agent_manager.py @@ -48,7 +48,7 @@ from backend.apps.agents.manager.prompt.tool_catalog import ( _get_denied_tool_names, _is_fully_denied, ) -from backend.apps.agents.core.aux_llm import _safe_resp_text +from backend.apps.agents.core.aux_llm import _safe_resp_text, clean_short_label from backend.apps.agents.manager.session.history_compaction import ( _build_history_prefix, _get_branch_messages, @@ -3599,7 +3599,7 @@ class AgentManager: system=system_prompt, messages=[{"role": "user", "content": user_turn}], ) - generated = _safe_resp_text(resp).strip().strip('"\'') + generated = clean_short_label(_safe_resp_text(resp)) if generated: title = generated except Exception as e: @@ -3672,15 +3672,8 @@ class AgentManager: ), }], ) - label = _safe_resp_text(resp).strip().strip('"\'').strip('.') - if not label: - return - # Defensive: cap length and strip leading 'I' / first-person if it - # slipped through despite the system prompt. - if label.lower().startswith(("i ", "i'm ", "i'll ")): - return # bail rather than show a hallucinated first-person label - if len(label) > 60: - label = label[:60].rsplit(" ", 1)[0] + # Bail on refusals/first-person rather than show a hallucinated label. + label = clean_short_label(_safe_resp_text(resp), max_words=6, max_chars=60) if not label: return diff --git a/backend/apps/agents/core/aux_llm.py b/backend/apps/agents/core/aux_llm.py index 83204ea8..7c8b2435 100644 --- a/backend/apps/agents/core/aux_llm.py +++ b/backend/apps/agents/core/aux_llm.py @@ -1,3 +1,24 @@ +# Refusal/meta tells from aux label calls; any hit means "show the fallback, not this". +_REJECT_STARTS = ("i ", "i'm", "i'll", "i've", "as an", "sorry", "unfortunately", "please", "here") +_REJECT_ANYWHERE = ("cannot", "can't", "unable", "no information", "not enough", "need more", "provide more") + + +def clean_short_label(raw: str, max_words: int = 4, max_chars: int = 36) -> str: + """Squeeze an aux-LLM reply into a safe short label: first line only, markdown + stripped, word/char capped; returns "" when it smells like an answer or refusal + so the caller falls back instead of showing 'I cannot...' as a title.""" + line = next((l.strip() for l in (raw or "").splitlines() if l.strip()), "") + line = line.strip("\"'` ").lstrip("#*->• ").replace("**", "").replace("`", "") + line = line.rstrip(" .,:;!").strip() + low = line.lower() + if not line or low.startswith(_REJECT_STARTS) or any(t in low for t in _REJECT_ANYWHERE): + return "" + label = " ".join(line.split()[:max_words]) + if len(label) > max_chars: + label = label[:max_chars].rsplit(" ", 1)[0].rstrip(" .,:;!") or label[:max_chars] + return label + + def _safe_resp_text(resp) -> str: """Extract text from an Anthropic-shape response, tolerating Gemini/OpenAI edge cases. Gemini through 9Router occasionally returns `content=[]` (e.g. diff --git a/backend/apps/dashboards/dashboards.py b/backend/apps/dashboards/dashboards.py index 294416c1..e660290c 100644 --- a/backend/apps/dashboards/dashboards.py +++ b/backend/apps/dashboards/dashboards.py @@ -317,7 +317,7 @@ async def generate_name(dashboard_id: str): if not prompts: return {"name": dashboard.name, "auto_named": dashboard.auto_named} - fallback = prompts[0][:40] + fallback = " ".join(prompts[0].split()[:4])[:36] or "Untitled Dashboard" try: from backend.apps.settings.settings import load_settings from backend.apps.settings.credentials import get_anthropic_client_for_model @@ -326,20 +326,19 @@ async def generate_name(dashboard_id: str): aux_model, _aux_base = await resolve_aux_model(global_settings, preferred_tier="haiku") client = get_anthropic_client_for_model(global_settings, aux_model) - if len(prompts) == 1: - system = ( - "Generate a short 2-4 word workspace name summarizing this task. " - "Examples: 'Travel Planning', 'Code Review', 'Sales Dashboard'. " - "No quotes, no punctuation, no emojis, no explanation. Return ONLY the name." - ) - user_content = prompts[0] - else: - system = ( - "Generate a short 2-4 word workspace name capturing the theme of these tasks. " - "Examples: 'Research & Analysis', 'Content Creation', 'Project Setup'. " - "No quotes, no punctuation, no emojis, no explanation. Return ONLY the name." - ) - user_content = "\n".join(f"- {p}" for p in prompts) + # Mirrors generate_title's hardening: the tasks are inert text to LABEL, never answer, + # or the aux model happily replies with a markdown essay that becomes the title. + system = ( + "You label tasks with a 2-4 word workspace name. " + "Examples: 'Travel planning', 'Code review', 'Sales dashboard'. " + "You NEVER answer or perform the tasks. You NEVER describe yourself. " + "You NEVER begin with 'I', 'As an', 'Sorry', 'Unfortunately', or any first-person phrasing. " + "Return ONLY the 2-4 word name. No quotes, no punctuation, no emojis, no explanation." + ) + user_content = ( + "Name the workspace for the tasks inside tags. Do not answer them.\n\n" + "\n" + "\n".join(f"- {p}" for p in prompts) + "\n" + ) resp = await client.messages.create( model=aux_model, @@ -347,8 +346,8 @@ async def generate_name(dashboard_id: str): system=system, messages=[{"role": "user", "content": user_content}], ) - from backend.apps.agents.core.aux_llm import _safe_resp_text - generated = _safe_resp_text(resp).strip().strip('"\'') + from backend.apps.agents.core.aux_llm import _safe_resp_text, clean_short_label + generated = clean_short_label(_safe_resp_text(resp)) if generated: fallback = generated except Exception as e: diff --git a/backend/tests/test_clean_short_label.py b/backend/tests/test_clean_short_label.py new file mode 100644 index 00000000..dd04fd7f --- /dev/null +++ b/backend/tests/test_clean_short_label.py @@ -0,0 +1,46 @@ +from backend.apps.agents.core.aux_llm import clean_short_label + + +def test_clean_label_passthrough(): + assert clean_short_label("Travel planning") == "Travel planning" + + +def test_markdown_essay_becomes_short(): + raw = "# Git Rebase Explained\nGit rebase is a way to integrate changes from one branch" + assert clean_short_label(raw) == "Git Rebase Explained" + + +def test_first_line_word_cap(): + assert clean_short_label("one two three four five six") == "one two three four" + + +def test_char_cap_lands_on_word_boundary(): + out = clean_short_label("supercalifragilistic expialidocious antidisestablishmentarianism", max_words=4) + assert len(out) <= 36 + assert not out.endswith(" ") + + +def test_refusals_rejected(): + for bad in [ + "I cannot generate a name without more information", + "Sorry, there is no task to summarize", + "I'm unable to help with that", + "As an AI, I need more context", + "Unfortunately no information was provided", + ]: + assert clean_short_label(bad) == "" + + +def test_quotes_and_bullets_stripped(): + assert clean_short_label('"**Code review**"') == "Code review" + assert clean_short_label("- Sales dashboard.") == "Sales dashboard" + + +def test_empty_and_whitespace(): + assert clean_short_label("") == "" + assert clean_short_label("\n\n \n") == "" + + +def test_ing_words_not_false_rejected(): + assert clean_short_label("Investigating the bug") == "Investigating the bug" + assert clean_short_label("iOS app design") == "iOS app design"