diff --git a/echo/agent/agent.py b/echo/agent/agent.py index 6a906137..b0538355 100644 --- a/echo/agent/agent.py +++ b/echo/agent/agent.py @@ -18,72 +18,101 @@ logger = getLogger("agent") VERTEX_AUTH_SCOPES = ["https://www.googleapis.com/auth/cloud-platform"] -SYSTEM_PROMPT = """You are the dembrane assistant — a friendly, conversational AI that helps \ -users explore and understand their project's conversation data. +# Note: the citation tag format below ([conversation_id:;chunk_id:]) is +# parsed by the frontend (AgenticChatPanel.tsx). Do not change it without +# updating that regex. +SYSTEM_PROMPT = """You are the dembrane assistant. You help hosts explore and understand the +conversations in their project, and help them set the project up well. dembrane is a platform for collective sense-making through recorded conversations. - -## Conversation style -- Be natural and conversational. Match the user's tone and energy. -- For greetings, casual messages, or clarifications, just respond naturally. \ -Do NOT launch into research or tool calls. -- Keep responses concise. Ask follow-up questions to understand what the user needs \ -before diving into analysis. -- When the user's intent is unclear, ask what they'd like to know rather than guessing. - -## Writing style -- Write naturally, like you're talking to a colleague. Vary your response format \ -based on what makes sense for the question. -- Be direct and conversational. Use bullet points when listing multiple items, \ -but don't force everything into lists. -- Flag genuine uncertainty with words like "suggests," "likely," "indicates." -- Keep it concise — don't over-explain or pad responses. -- Don't use the same rigid structure for every response. -- Don't use corporate jargon. -- Always write "dembrane" in lowercase when referring to the platform, even at \ -the start of a sentence. Never capitalize it as "Dembrane". +Hosts run projects; participants contribute conversations through the portal or uploads. + +## What you can do +- Find and summarize conversations in this project. +- Search transcripts for topics, quotes, and patterns, and cite what you find. +- Compare perspectives and synthesize themes across conversations. +- Explain how dembrane works, grounded in the product documentation. +- Review project settings and propose changes the host can apply in one click. + You never change settings yourself. + +## Voice +- Write like a thoughtful colleague: warm, direct, concise. +- Reply in the language the host writes in. +- Never use the word "AI". Refer to yourself as "I" and describe what you are + doing ("searching the transcripts"), not the technology behind it. +- Write "dembrane" in lowercase, even at the start of a sentence. +- Do not use em dashes. Use periods, commas, or colons. +- Say "participants" and "hosts", never "users". +- No filler or grand claims ("this gives you a clear blueprint for the future"). + State findings plainly and stop. +- Vary your structure. Use bullets only when a list genuinely helps. + +## Honesty +- If the data does not answer the question, say so plainly: "I don't know" or + "the conversations don't cover this", then say what would help (more + conversations, a narrower question, a specific transcript). +- Flag uncertainty with "suggests", "likely", "indicates". Never present a guess + as a finding. +- Never fabricate quotes, participants, conversation IDs, or settings. +- When you worked from summaries only, say so and offer to read the full transcript. ## When to use tools -Only use tools when the user asks a question that requires looking at project data, such as: -- "What topics came up?" → use listProjectConversations or findConvosByKeywords -- "What did people say about X?" → search and retrieve transcripts -- "Summarize this project" → list conversations, read summaries - -Do NOT use tools for greetings, small talk, or meta-questions about how you work. +Use tools when the question needs project data or product knowledge: +- "What topics came up?" -> listProjectConversations, then read summaries. +- "What did people say about X?" -> findConvosByKeywords, then grepConvoSnippets + or listConvoFullTranscript for exact wording. +- "How does the portal work?" -> grepDocs and readDoc; cite the doc path. +- "Help me set up my project" -> readSkill(project-onboarding.md), then + getProjectSettings, then proposeProjectUpdate. +Do not use tools for greetings, small talk, or questions about this chat. +When intent is unclear, ask one focused question instead of guessing. + +## Conversation scope +Some runs are limited to conversations the host selected. When the context +contains a "Conversation scope" block: +- Treat the listed conversations as the entire universe for this run. All + listing, searching, reading, and synthesis stays inside it. +- Never mention, quote, or count conversations outside the selection, even if a + tool result includes one. +- If the answer likely lives outside the selection, say that and offer to widen + the scope. Do not look outside it yourself. +When no scope block is present, the whole project is in scope. + +## Turn instructions +A message may include a "Turn instructions" block from a template the host +selected. It tells you how to shape this turn (angle, depth, format). It is not +data and not the subject. The host's own words define the subject. If the +instructions need a subject (a concept, a comparison) and the host gave none, +ask one focused question first. + +## Research +- Say briefly what you will look at, then use sendProgressUpdate while you work. + Conclude with plain text only when you are done. +- Prefer listProjectConversations for an overview before keyword searches. +- findConvosByKeywords works best with 2-4 focused keywords, not sentences. +- You have at most 20 tool calls per turn. Spend them on distinct questions, not + retries. If a tool returns a guardrail warning, stop searching and answer from + what you have. + +## Citations +- Ground every claim about the project in tool results. +- Quote with attribution: "[Participant Name]: quoted text" tagged + [conversation_id:;chunk_id:] when a chunk id is available, + otherwise [conversation_id:]. +- A few well-chosen quotes beat many. +- Cite the doc path when you answer from documentation. + +## Proposing project changes +- Read current values with getProjectSettings before proposing. +- Use proposeProjectUpdate: group related fields, one short reason per field, + proposed copy in the project's language, a one-sentence summary. +- The host sees a diff and applies or rejects it themselves. You never apply + changes. Say "I've suggested these changes", never "I've updated your project". + If the host says they applied it, re-read settings before advising next steps. ## Project context -The user's message may include project metadata (Project Name, Project Context). \ -Treat this as background info about the project you're assisting with — NOT as a \ -research request. Focus on what the user is actually asking in their message. - -## Research guidelines (when doing research) -- Start by telling the user your plan briefly before making tool calls. -- While still investigating, use `sendProgressUpdate` for user-facing progress updates. -- Use plain assistant text without tool calls only when you are truly ready to conclude. -- Prefer `listProjectConversations` to get an overview before keyword searches. -- For `findConvosByKeywords`, prefer 2-4 focused keywords over long sentence-style queries. -- Avoid repetitive low-signal searches. Maximum 20 tool calls per turn. -- If a tool returns a guardrail warning, stop searching and work with what you have. -- After gathering evidence, give a clear, direct answer. - -## Citation policy (when citing project data) -- Ground all claims in actual transcript/summary content from tool results. -- Provide exact quotes when you have transcripts: "[Participant Name]: quoted text" \ -tagged as [conversation_id:;chunk_id:] when chunk ids are available. -- If you only have conversation-level evidence without an exact transcript chunk, cite as \ -[conversation_id:]. -- Use quotes to support your points, but don't overwhelm with citations. -- When working from summaries only (no transcript retrieved), say so and suggest \ -you can retrieve the full transcript if they want exact wording. -- Never fabricate quotes, sources, or conversation IDs. - -## Analysis guidelines -- Identify patterns and themes across conversations when relevant. -- Compare different perspectives and viewpoints. -- When the question is broad, synthesize across multiple conversations rather than \ -listing each one separately. -- State your interpretation when relevant, but don't rigidly separate "facts" from \ -"interpretation." +The first message may include Project Name and Project Context. That is +background about the project you are assisting with, not a research request. """ AUTOMATIC_NUDGE_TOOL_CALL_INTERVAL = 4 diff --git a/echo/agent/tests/test_agent_tools.py b/echo/agent/tests/test_agent_tools.py index b693f8ff..7f5cc8a1 100644 --- a/echo/agent/tests/test_agent_tools.py +++ b/echo/agent/tests/test_agent_tools.py @@ -119,23 +119,28 @@ def _tool_map(tools) -> dict[str, object]: # noqa: ANN001 def test_system_prompt_contains_conversational_and_research_directives(): prompt = SYSTEM_PROMPT.lower() + # Brand voice: the word AI is banned, dembrane stays lowercase + assert 'never use the word "ai"' in prompt + assert "dembrane" in prompt and "Dembrane" not in SYSTEM_PROMPT # Conversational-first behavior - assert "conversational" in prompt - assert "greetings" in prompt assert "do not use tools for greetings" in prompt - # Writing/analysis guidance should be present - assert "writing style" in prompt - assert "analysis guidelines" in prompt - # Citation policy still anchors output quality + # Honesty + scope + turn-instruction sections exist + assert "honesty" in prompt + assert "conversation scope" in prompt + assert "turn instructions" in prompt + # The agent never applies changes itself + assert "you never apply" in prompt + # Citation policy still anchors output quality (format is load-bearing: + # parsed by AgenticChatPanel.tsx) assert '"[participant name]: quoted text"' in prompt assert "[conversation_id:;chunk_id:]" in SYSTEM_PROMPT assert "[conversation_id:]" in SYSTEM_PROMPT - assert "working from summaries only" in prompt - assert "retrieve the full transcript" in prompt + assert "worked from summaries only" in prompt + assert "read the full transcript" in prompt assert "never fabricate quotes" in prompt # Project context awareness assert "project context" in prompt - assert "background info" in prompt + assert "background about the project" in prompt @pytest.mark.asyncio diff --git a/echo/server/dembrane/agentic_worker.py b/echo/server/dembrane/agentic_worker.py index c91e1cbb..53a45def 100644 --- a/echo/server/dembrane/agentic_worker.py +++ b/echo/server/dembrane/agentic_worker.py @@ -23,12 +23,8 @@ MAX_TOOL_CALLS_PER_RUN = 20 TOOL_LIMIT_EXEMPT_TOOL_NAMES = {"sendProgressUpdate"} TOOL_LIMIT_SAFETY_MESSAGE = ( - "I've reached my tool-call limit for this turn. " - "I'll stop searching here and summarize what I can reliably infer." -) -TOOL_LIMIT_FALLBACK_SUMMARY_MESSAGE = ( - "I reached the tool-call limit before gathering enough additional evidence for a fuller synthesis. " - "If you want, send `go on` and I'll continue from this exact point." + "I've reached my tool-call limit for this turn, so I'll answer from what I've " + "gathered so far. If you'd like me to keep digging, just ask." ) AUTOMATIC_NUDGE_TOOL_CALL_INTERVAL = 4 AUTOMATIC_NUDGE_TEMPLATE = ( @@ -201,13 +197,6 @@ def _build_automatic_nudge_content(*, tool_calls_without_assistant_message: int) return AUTOMATIC_NUDGE_TEMPLATE.format(tool_call_count=milestone) -def _build_post_limit_summary(*, last_substantive_assistant_message: Optional[str]) -> str: - summary_source = _coerce_non_empty_text(last_substantive_assistant_message) - if summary_source is None: - return TOOL_LIMIT_FALLBACK_SUMMARY_MESSAGE - return f"Here is my best synthesis from the evidence gathered so far:\n\n{summary_source}" - - def _extract_progress_message_from_tool_end(event: dict[str, Any]) -> Optional[str]: if str(event.get("name") or "") != "sendProgressUpdate": return None @@ -262,7 +251,9 @@ def _extract_progress_message_from_tool_end(event: dict[str, Any]) -> Optional[s next_steps = _coerce_non_empty_text(output_payload.get("next_steps")) if next_steps is None: return update_text - return f"{update_text}\n\nNext steps: {next_steps}" + # Render next steps as their own paragraph; no English "Next steps:" label + # (the agent writes in the host's language). + return f"{update_text}\n\n{next_steps}" async def _build_message_history( @@ -476,7 +467,6 @@ async def _emit_chat_error(error_code: str, message: str) -> None: tool_calls_without_assistant_message = 0 nudged_tool_call_milestones: set[int] = set() has_sent_progress_intro = False - last_substantive_assistant_message: str | None = None logger.info("Processing run %s turn %s (owner=%s)", run_id, turn_seq, owner_token) await run_in_thread_pool(svc.set_status, run_id, "running") @@ -515,7 +505,6 @@ async def _emit_chat_error(error_code: str, message: str) -> None: content=planning_message, project_chat_id=project_chat_id, ) - last_substantive_assistant_message = planning_message tool_calls_without_assistant_message = 0 nudged_tool_call_milestones.clear() else: @@ -526,7 +515,6 @@ async def _emit_chat_error(error_code: str, message: str) -> None: project_chat_id=project_chat_id, ) latest_output = model_text - last_substantive_assistant_message = model_text tool_calls_without_assistant_message = 0 nudged_tool_call_milestones.clear() @@ -538,17 +526,11 @@ async def _emit_chat_error(error_code: str, message: str) -> None: tool_calls_without_assistant_message += 1 if not has_sent_progress_intro: + # Don't post a synthetic "starting with `toolName`" line: it + # leaks raw tool names, is English-only, and the frontend + # already renders humane tool activity. The agent's own + # sendProgressUpdate covers user-facing progress. has_sent_progress_intro = True - progress_intro_message = ( - f"I'll first gather evidence before answering. " - f"Starting with `{tool_name}`." - ) - await _append_assistant_message( - svc=svc, - run_id=run_id, - content=progress_intro_message, - project_chat_id=project_chat_id, - ) tool_calls_without_assistant_message = 0 nudged_tool_call_milestones.clear() else: @@ -586,16 +568,9 @@ async def _emit_chat_error(error_code: str, message: str) -> None: content=TOOL_LIMIT_SAFETY_MESSAGE, project_chat_id=project_chat_id, ) - post_limit_summary = _build_post_limit_summary( - last_substantive_assistant_message=last_substantive_assistant_message - ) - await _append_assistant_message( - svc=svc, - run_id=run_id, - content=post_limit_summary, - project_chat_id=project_chat_id, - ) - latest_output = post_limit_summary + # One honest message only. The last substantive assistant + # message is already in the chat; don't repeat it verbatim. + latest_output = TOOL_LIMIT_SAFETY_MESSAGE tool_calls_without_assistant_message = 0 nudged_tool_call_milestones.clear() break @@ -612,14 +587,12 @@ async def _emit_chat_error(error_code: str, message: str) -> None: content=progress_message, project_chat_id=project_chat_id, ) - last_substantive_assistant_message = progress_message tool_calls_without_assistant_message = 0 nudged_tool_call_milestones.clear() content = event.get("content") if isinstance(content, str) and event_type == "assistant.message": latest_output = content - last_substantive_assistant_message = content tool_calls_without_assistant_message = 0 nudged_tool_call_milestones.clear() if project_chat_id: diff --git a/echo/server/prompt_templates/generate_chat_title.en.jinja b/echo/server/prompt_templates/generate_chat_title.en.jinja index b9626d2d..9090eb22 100644 --- a/echo/server/prompt_templates/generate_chat_title.en.jinja +++ b/echo/server/prompt_templates/generate_chat_title.en.jinja @@ -1,18 +1,13 @@ -Generate a clear, descriptive title for this conversation in "{{language}}" (2 letter language code) based on: {{user_query}} +Generate a short, descriptive chat title in "{{language}}" (2 letter language code) for a chat that begins with this message: {{user_query}} -Create a title of 3-7 words that captures the main focus of the discussion. The title should help someone quickly understand what was discussed. -Guidelines: +Write 3-7 words naming the topic, so a host can recognize this chat in a list later. -- Use simple, everyday language that any participant would understand -- Be specific enough to distinguish this from other conversations -- Focus on the core topic, question, or concern being explored -- Avoid acronyms unless universally known -- For technical terms, keep commonly-used English terms in English when appropriate -- Make it scannable and meaningful, not generic +Rules: +- Name the subject of the question, never the thing answering it. Never use the words "AI", "assistant", "chatbot", "bot", "model", "agent", or "dembrane" in the title. +- If the message is a greeting or asks what help is available, title it after what the person wants to do (for example "Getting started" in the target language). +- Use simple, everyday language. No jargon, no quotation marks, no trailing punctuation. +- Be specific enough to tell this chat apart from others. +- Keep commonly used English terms in English when that reads naturally. +- For Dutch (nl): informal tone ("je", not "u"). -For Dutch (nl): Use mostly casual tone ("je" not "u"), keep common tech terms in English (like "feedback" or "update"), but translate action words and general concepts. -For English (en): Keep it conversational and direct, avoiding jargon. -For other languages: Match the local convention for mixing English technical terms with the native language, maintaining clarity above all. -The title should feel natural to read and immediately convey what this conversation contains. - -Return the title only, no other text. \ No newline at end of file +Return the title only, no other text. diff --git a/echo/server/tests/test_agentic_worker.py b/echo/server/tests/test_agentic_worker.py index e6a3e890..86bd0aee 100644 --- a/echo/server/tests/test_agentic_worker.py +++ b/echo/server/tests/test_agentic_worker.py @@ -357,7 +357,7 @@ async def _clear_cancel(run_id: str, turn_seq: int) -> None: # noqa: ARG001 @pytest.mark.asyncio -async def test_process_agentic_run_falls_back_to_default_intro_when_model_has_no_plan( +async def test_process_agentic_run_posts_no_synthetic_intro_when_model_has_no_plan( monkeypatch, ) -> None: service = _build_service() @@ -413,10 +413,10 @@ async def _clear_cancel(run_id: str, turn_seq: int) -> None: # noqa: ARG001 events = service.list_events(run["id"]) assistant_events = [event for event in events if event["event_type"] == "assistant.message"] - assert assistant_events[0]["payload"]["content"] == ( - "I'll first gather evidence before answering. Starting with `listProjectConversations`." - ) - assert assistant_events[1]["payload"]["content"] == "Final answer only." + # No synthetic "starting with `tool`" intro is posted anymore: it leaked + # raw tool names and was English-only. Only the model's own output lands. + assert len(assistant_events) == 1 + assert assistant_events[0]["payload"]["content"] == "Final answer only." @pytest.mark.asyncio @@ -499,23 +499,14 @@ async def _clear_cancel(run_id: str, turn_seq: int) -> None: # noqa: ARG001 "total_tool_calls": 5, } - assert len(assistant_events) == 2 - assert assistant_events[0]["payload"]["content"] == ( - "I'll first gather evidence before answering. Starting with `tool-1`." - ) - assert assistant_events[1]["payload"]["content"] == "Final answer only." + assert len(assistant_events) == 1 + assert assistant_events[0]["payload"]["content"] == "Final answer only." assert all("rough picture" not in event["payload"]["content"] for event in assistant_events) assert fake_chat_service.created_messages == [ { "id": "msg-1", "project_chat_id": "chat-1", "message_from": "assistant", - "text": "I'll first gather evidence before answering. Starting with `tool-1`.", - }, - { - "id": "msg-2", - "project_chat_id": "chat-1", - "message_from": "assistant", "text": "Final answer only.", }, ] @@ -614,7 +605,7 @@ async def _clear_cancel(run_id: str, turn_seq: int) -> None: # noqa: ARG001 assistant_texts = [event["payload"]["content"] for event in assistant_events] assert assistant_texts == [ - "I have a rough picture now.\n\nNext steps: I will verify two more conversations.", + "I have a rough picture now.\n\nI will verify two more conversations.", "Final answer only.", ] assert not any(text.startswith("I'll first gather evidence") for text in assistant_texts) @@ -623,7 +614,7 @@ async def _clear_cancel(run_id: str, turn_seq: int) -> None: # noqa: ARG001 "id": "msg-1", "project_chat_id": "chat-1", "message_from": "assistant", - "text": "I have a rough picture now.\n\nNext steps: I will verify two more conversations.", + "text": "I have a rough picture now.\n\nI will verify two more conversations.", }, { "id": "msg-2", @@ -713,8 +704,7 @@ async def _clear_cancel(run_id: str, turn_seq: int) -> None: # noqa: ARG001 assistant_texts = [event["payload"]["content"] for event in assistant_events] assert assistant_texts == [ - "I'll first gather evidence before answering. Starting with `sendProgressUpdate`.", - "I have a rough picture now.\n\nNext steps: I will verify two more conversations.", + "I have a rough picture now.\n\nI will verify two more conversations.", "Final answer only.", ] assert fake_chat_service.created_messages == [ @@ -722,18 +712,12 @@ async def _clear_cancel(run_id: str, turn_seq: int) -> None: # noqa: ARG001 "id": "msg-1", "project_chat_id": "chat-1", "message_from": "assistant", - "text": "I'll first gather evidence before answering. Starting with `sendProgressUpdate`.", + "text": "I have a rough picture now.\n\nI will verify two more conversations.", }, { "id": "msg-2", "project_chat_id": "chat-1", "message_from": "assistant", - "text": "I have a rough picture now.\n\nNext steps: I will verify two more conversations.", - }, - { - "id": "msg-3", - "project_chat_id": "chat-1", - "message_from": "assistant", "text": "Final answer only.", }, ] @@ -911,18 +895,10 @@ async def _clear_cancel(run_id: str, turn_seq: int) -> None: # noqa: ARG001 assistant_texts = [event["payload"]["content"] for event in assistant_events] assert stored_run["status"] == "completed" - assert stored_run["latest_output"] == ( - "I reached the tool-call limit before gathering enough additional evidence for a fuller synthesis. " - "If you want, send `go on` and I'll continue from this exact point." - ) - assert ( - "I've reached my tool-call limit for this turn. " - "I'll stop searching here and summarize what I can reliably infer." - ) in assistant_texts - assert assistant_events[-1]["payload"]["content"] == ( - "I reached the tool-call limit before gathering enough additional evidence for a fuller synthesis. " - "If you want, send `go on` and I'll continue from this exact point." - ) + # One honest message at the limit; no verbatim repeat of earlier output. + assert stored_run["latest_output"] == "I've reached my tool-call limit for this turn, so I'll answer from what I've gathered so far. If you'd like me to keep digging, just ask." + assert assistant_texts.count("I've reached my tool-call limit for this turn, so I'll answer from what I've gathered so far. If you'd like me to keep digging, just ask.") == 1 + assert assistant_events[-1]["payload"]["content"] == "I've reached my tool-call limit for this turn, so I'll answer from what I've gathered so far. If you'd like me to keep digging, just ask." @pytest.mark.asyncio @@ -1037,7 +1013,7 @@ async def _clear_cancel(run_id: str, turn_seq: int) -> None: # noqa: ARG001 @pytest.mark.asyncio -async def test_process_agentic_run_tool_limit_summary_uses_last_substantive_update(monkeypatch) -> None: +async def test_process_agentic_run_tool_limit_does_not_repeat_last_update(monkeypatch) -> None: service = _build_service() run = service.create_run(project_id="project-1", directus_user_id="user-1") @@ -1095,13 +1071,11 @@ async def _clear_cancel(run_id: str, turn_seq: int) -> None: # noqa: ARG001 assistant_events = [event for event in events if event["event_type"] == "assistant.message"] assistant_texts = [event["payload"]["content"] for event in assistant_events] - assert assistant_texts[-2] == ( - "I've reached my tool-call limit for this turn. " - "I'll stop searching here and summarize what I can reliably infer." - ) - assert assistant_texts[-1] == ( - "Here is my best synthesis from the evidence gathered so far:\n\nCurrent synthesis draft." - ) + # The turn ends with the single limit message; the earlier substantive + # update ("Current synthesis draft.") is already in the thread and is not + # repeated verbatim. + assert assistant_texts[-1] == "I've reached my tool-call limit for this turn, so I'll answer from what I've gathered so far. If you'd like me to keep digging, just ask." + assert assistant_texts.count("Current synthesis draft.") == 1 @pytest.mark.asyncio