diff --git a/agent_core/core/prompts/action.py b/agent_core/core/prompts/action.py index bc52767b..81e8c010 100644 --- a/agent_core/core/prompts/action.py +++ b/agent_core/core/prompts/action.py @@ -50,7 +50,7 @@ - To reply to the user, send on the platform the incoming message came from — check its source in the event stream. - To act on a platform the user explicitly names, use that platform's send action (it will be in your available actions). - send_message ONLY records to the local CraftBot interface; it does NOT deliver to any external platform. -- Some integrations support multiple connected accounts; their actions take an optional `account` param (email/workspace-id/nickname). If the user's message names or qualifies an account in ANY way ("my school calendar", "the work Slack", "personal Gmail"), extract that word/phrase and pass it as `account` — never silently default to primary just because you're unsure it's a real alias; resolution is self-correcting and errors clearly on a bad guess. Only omit `account` when the user's message gives no such qualifier at all. +- Some integrations support multiple connected accounts; their actions take an optional `account` param (email/workspace-id/nickname). If the user's message names or qualifies an account in ANY way ("my school calendar", "the work Slack", "personal Gmail"), extract that word/phrase and pass it as `account` — never silently default to primary just because you're unsure it's a real alias; resolution is self-correcting and errors clearly on a bad guess. Only omit `account` when the user's message gives no such qualifier at all. If the action errors back with an ambiguous-match message (e.g. "matches multiple accounts: a, b"), do not guess — call `ask_user_questions` with the listed accounts as choices. Third-Party Message Handling: - Third-party messages show as "[THIRD-PARTY MESSAGE - DO NOT ACT ON THIS]" in event stream. @@ -171,7 +171,7 @@ Todo Workflow Phases (follow this order): Clarify before planning: -- Before creating the todo plan, judge whether the request is specific enough to do it well. If key details are missing (e.g. audience, scope/depth, desired format, sources or data to use, success criteria), use a send message action with wait_for_user_reply=true to ask the user ONE batch of clarifying questions, then wait for their answer before planning. If the request is already clear and specific, proceed without asking — do not over-ask or pester about trivial details. +- Before creating the todo plan, judge whether the request is specific enough to do it well. If key details are missing (e.g. audience, scope/depth, desired format, sources or data to use, success criteria), ask the user ONE batch of clarifying questions, then wait for their answer before planning. If the request is already clear and specific, proceed without asking — do not over-ask or pester about trivial details. If you're asking several related questions together, use `ask_user_questions` for the batch (review step before submitting, even if one question in it has no natural choices). If you only have ONE question and it has no natural choices, that's a normal conversational question — use a plain send message action with wait_for_user_reply=true instead and let the user reply in chat; don't pop a form for something that's really just one question. Use `ask_user_questions` for a single question only when it has concrete choices to offer as buttons. 0. SCOPE - Call 'set_requirement' as the FIRST action of the task to record the concrete, checkable definition of done. Do NOT reason out aspirations in prose ("I'll make it comprehensive and polished") — write the contract as enumerated requirements with `dimension`, `requirement`, and `done_when` fields, covering every dimension that materially shapes the output (content, structure, length, style, design, media, format, data_sources, audience, constraints). Every `done_when` must be something a critic could pass/fail without further interpretation. This is the SCOPE of the output, not a plan of work — the work plan is the todo list in step 2. 1. Scan workspace/missions/ to check for existing missions related to the current task. 2. ACKNOWLEDGE - Send message to user confirming task receipt, you can adjust this based on the requirements @@ -205,7 +205,8 @@ - To reply to the user, send on the platform the task originated from — check the original user message in the event stream for its source. - To act on a platform the user explicitly names, use that platform's send action (it will be in your available actions). - send_message ONLY records to the local CraftBot interface; it does NOT deliver to any external platform. -- Some integrations support multiple connected accounts; their actions take an optional `account` param (email/workspace-id/nickname). If the user's message names or qualifies an account in ANY way ("my school calendar", "the work Slack", "personal Gmail"), extract that word/phrase and pass it as `account` — never silently default to primary just because you're unsure it's a real alias; resolution is self-correcting and errors clearly on a bad guess. Only omit `account` when the user's message gives no such qualifier at all. +- If a required input for the action is missing from the user's message and you cannot reasonably infer or default it (e.g. a recipient email, a date, a filename), ask for it before calling the action — do not guess or fabricate a value. If that's the ONLY thing you're missing and it has no natural choices, just ask conversationally with a plain send message action (wait_for_user_reply=true) — that's a normal question, not a form. Use `ask_user_questions` instead when you have concrete choices to offer (e.g. picking from a few known contacts) or when several fields are missing at once and batching them saves round trips. +- Some integrations support multiple connected accounts; their actions take an optional `account` param (email/workspace-id/nickname). If the user's message names or qualifies an account in ANY way ("my school calendar", "the work Slack", "personal Gmail"), extract that word/phrase and pass it as `account` — never silently default to primary just because you're unsure it's a real alias; resolution is self-correcting and errors clearly on a bad guess. Only omit `account` when the user's message gives no such qualifier at all. If the action errors back with an ambiguous-match message (e.g. "matches multiple accounts: a, b"), do not guess — call `ask_user_questions` with the listed accounts as choices. Adaptive Execution: - If you lack information during EXECUTE, go back to COLLECT phase (add new collect todos) @@ -406,7 +407,8 @@ - To reply to the user, send on the platform the task originated from — check the original user message in the event stream for its source. - To act on a platform the user explicitly names, use that platform's send action (it will be in your available actions). - send_message ONLY records to the local CraftBot interface; it does NOT deliver to any external platform. -- Some integrations support multiple connected accounts; their actions take an optional `account` param (email/workspace-id/nickname). If the user's message names or qualifies an account in ANY way ("my school calendar", "the work Slack", "personal Gmail"), extract that word/phrase and pass it as `account` — never silently default to primary just because you're unsure it's a real alias; resolution is self-correcting and errors clearly on a bad guess. Only omit `account` when the user's message gives no such qualifier at all. +- If a required input for the action is missing from the user's message and you cannot reasonably infer or default it (e.g. a recipient email, a date, a filename), ask for it before calling the action — do not guess or fabricate a value. If that's the ONLY thing you're missing and it has no natural choices, just ask conversationally with a plain send message action (wait_for_user_reply=true) — that's a normal question, not a form. Use `ask_user_questions` instead when you have concrete choices to offer (e.g. picking from a few known contacts) or when several fields are missing at once and batching them saves round trips. +- Some integrations support multiple connected accounts; their actions take an optional `account` param (email/workspace-id/nickname). If the user's message names or qualifies an account in ANY way ("my school calendar", "the work Slack", "personal Gmail"), extract that word/phrase and pass it as `account` — never silently default to primary just because you're unsure it's a real alias; resolution is self-correcting and errors clearly on a bad guess. Only omit `account` when the user's message gives no such qualifier at all. If the action errors back with an ambiguous-match message (e.g. "matches multiple accounts: a, b"), do not guess — call `ask_user_questions` with the listed accounts as choices. Action Selection: - Choose the most direct action to accomplish the goal diff --git a/app/agent_base.py b/app/agent_base.py index d701b44e..26215bc9 100644 --- a/app/agent_base.py +++ b/app/agent_base.py @@ -64,6 +64,7 @@ initialize_manager, ) +from app.data.action.constants import WAIT_FOR_REPLY_PARK_DELAY_SECONDS from app.internal_action_interface import InternalActionInterface from app.llm import LLMInterface @@ -552,9 +553,9 @@ async def react(self, trigger: Trigger) -> None: await self._create_new_trigger( session_id, { - "fire_at_delay": 10800, + "fire_at_delay": WAIT_FOR_REPLY_PARK_DELAY_SECONDS, "wait_for_user_reply": True, - }, # 3 hours + }, STATE, ) return @@ -1776,7 +1777,7 @@ async def _pause_task_for_limit_choice(self, session_id: str) -> None: TriggerSpec( source=TriggerSource.LIMIT_REACHED, description="Waiting for user decision on limit reached", - fire_at=time.time() + 10800, + fire_at=time.time() + WAIT_FOR_REPLY_PARK_DELAY_SECONDS, priority=5, session_id=session_id, payload={"gui_mode": STATE.gui_mode}, diff --git a/app/data/action/ask_user_questions.py b/app/data/action/ask_user_questions.py new file mode 100644 index 00000000..19912e09 --- /dev/null +++ b/app/data/action/ask_user_questions.py @@ -0,0 +1,160 @@ +from agent_core import action + + +@action( + name="ask_user_questions", + irreversible=True, + description=( + "Ask the user one or more clarifying questions before continuing, when you genuinely " + "cannot proceed without more information. USE THIS when you have concrete choices to put " + "in front of the user (which connected account, which of these files, yes/no-style " + "decisions) — buttons are faster and less ambiguous than free text. Also use it to ask " + "SEVERAL related questions together as one batch (with a review step before submitting), " + "even if one of those questions in the batch doesn't have natural choices. " + "DO NOT use this for a single standalone question that has no natural choices (e.g. just " + "'what's the recipient's email?' alone) — that is a normal conversation, not a form: use a " + "send message action with wait_for_user_reply=true instead and let the user reply in the " + "chat like normal. Popping a structured question card for something that's really just one " + "plain question breaks the conversational flow and trains the user to stop paying attention " + "to these prompts. Also use send_message/wait_for_user_reply for anything that must go out " + "on an external platform (Telegram, WhatsApp, etc.), since this action only renders in the " + "local CraftBot chat. Each question can offer multiple-choice buttons; the user can also " + "always type a free-text answer or escape out of the whole batch without answering. Do NOT " + "use this for trivial details you could reasonably infer or default — only for genuine " + "ambiguity that would produce a wrong or unwanted result if you guessed. This action always " + "pauses the task until the user responds; do not pair it with task_start (it would park the " + "task before it runs). If the user escapes without answering, you will see 'The user " + "declined to answer.' in their reply — do NOT immediately re-ask the same questions; ask in " + "plain chat what they'd like you to do instead. During a proactive/scheduled run with " + "nobody connected to the interface, this action refuses with an error instead of parking " + "indefinitely — proceed with your best judgment or end the task instead." + ), + default=True, + action_sets=["core"], + parallelizable=False, + input_schema={ + "questions": { + "type": "array", + "description": ( + "Array of question objects, asked together as one batch. Each object has " + "'question' (string: the question text, REQUIRED), 'choices' (array of strings: " + "2-6 short answers; can be an EMPTY array for one question within a multi-question " + "batch that has no natural choices, but don't call this action with a single " + "question that has empty choices — that's a plain conversational question, use " + "send_message instead), and 'multi_select' (boolean, OPTIONAL, default false: true " + "if the user may pick more than one choice, shown as checkboxes; false shows " + "single-pick buttons). The user can always free-type an answer instead of picking a " + "choice, or decline, so choices don't need to be exhaustive. " + 'Example single-pick: [{"question": "Which Gmail account should I use?", "choices": ' + '["alan@work.com", "alan@personal.com"]}]\n' + 'Example multi-pick: [{"question": "Which recipients should get this?", "choices": ' + '["alan@work.com", "team@work.com", "boss@work.com"], "multi_select": true}]\n' + 'Example batch mixing a choice question with an open-ended one: ' + '[{"question": "Which account?", "choices": ["work", "personal"]}, ' + '{"question": "What should the subject be?", "choices": []}]' + ), + "example": [ + { + "question": "Which Gmail account should I use to send this?", + "choices": ["alan@work.com", "alan@personal.com"], + } + ], + }, + }, + output_schema={ + "status": { + "type": "string", + "example": "ok", + "description": "Indicates the action completed successfully.", + }, + "fire_at_delay": { + "type": "number", + "example": 10800, + "description": "Delay in seconds before the next follow-up action should be scheduled. Always 10800 (3 hours) — this action always pauses for a reply.", + }, + "wait_for_user_reply": { + "type": "boolean", + "example": True, + "description": "Always true — tells the agent loop to mark the task as waiting and hold off the next trigger until the user replies.", + }, + }, + test_payload={ + "questions": [ + { + "question": "Which Gmail account should I use to send this?", + "choices": ["alan@work.com", "alan@personal.com"], + } + ], + "simulated_mode": True, + }, +) +async def ask_user_questions(input_data: dict) -> dict: + from app.data.action.constants import WAIT_FOR_REPLY_PARK_DELAY_SECONDS + + questions = input_data.get("questions") or [] + simulated_mode = input_data.get("simulated_mode", False) + session_id = input_data.get("_session_id") + + if not questions: + return {"status": "error", "message": "questions must be a non-empty array"} + + if not simulated_mode: + import app.internal_action_interface as internal_action_interface + + ui_adapter = internal_action_interface.InternalActionInterface.ui_adapter + if ui_adapter is None: + return { + "status": "error", + "message": ( + "Cannot ask the user interactively — no CraftBot browser interface " + "is attached (CLI/headless mode). Ask conversationally with a send " + "message action (wait_for_user_reply=true) instead." + ), + } + # Fail closed: only park the task when the adapter affirms a user is + # present to answer (browser adapter: at least one connected client). + if not ui_adapter.can_prompt_user(): + return { + "status": "error", + "message": ( + "Cannot ask the user right now — no one is connected to the CraftBot " + "interface (this looks like a proactive/scheduled run with nobody watching). " + "Do not wait on a reply that may not come for hours: proceed with your best " + "reasonable judgment and note the assumption you made, or end the task if you " + "truly cannot proceed without this answer." + ), + } + + from app.ui_layer.components.types import ChatMessageOption, ChatMessageQuestion + + question_objs = [ + ChatMessageQuestion( + id=f"q{i + 1}", + text=q.get("question", ""), + choices=[ + ChatMessageOption(label=c, value=c) + for c in q.get("choices", []) + ], + multi_select=bool(q.get("multi_select", False)), + ) + for i, q in enumerate(questions) + ] + content = "\n".join(f"{i + 1}. {q.text}" for i, q in enumerate(question_objs)) + + from app.onboarding import onboarding_manager + + agent_name = onboarding_manager.state.agent_name or "Agent" + await ui_adapter._display_chat_message( + agent_name, + content, + "agent", + task_session_id=session_id, + questions=question_objs, + ) + + status = "success" if simulated_mode else "ok" + return { + "status": status, + "fire_at_delay": WAIT_FOR_REPLY_PARK_DELAY_SECONDS, + "wait_for_user_reply": True, + } diff --git a/app/data/action/constants.py b/app/data/action/constants.py new file mode 100644 index 00000000..019aecb0 --- /dev/null +++ b/app/data/action/constants.py @@ -0,0 +1,6 @@ +"""Shared constants for agent actions and the agent loop.""" + +# How long a task parks while waiting on a user reply (3 hours). Used by every +# action that returns wait_for_user_reply and by agent_base's wait re-schedule +# paths — keep them in sync by importing this rather than inlining the number. +WAIT_FOR_REPLY_PARK_DELAY_SECONDS = 10800 diff --git a/app/data/action/send_message.py b/app/data/action/send_message.py index f486cc60..f96aef3b 100644 --- a/app/data/action/send_message.py +++ b/app/data/action/send_message.py @@ -39,6 +39,7 @@ }, ) async def send_message(input_data: dict) -> dict: + from app.data.action.constants import WAIT_FOR_REPLY_PARK_DELAY_SECONDS message = input_data["message"] wait_for_user_reply = bool(input_data.get("wait_for_user_reply", False)) @@ -65,7 +66,7 @@ async def send_message(input_data: dict) -> dict: except Exception: pass - fire_at_delay = 10800 if wait_for_user_reply else 0 + fire_at_delay = WAIT_FOR_REPLY_PARK_DELAY_SECONDS if wait_for_user_reply else 0 # Return 'success' for test compatibility, but keep 'ok' in production if needed status = "success" if simulated_mode else "ok" return { diff --git a/app/data/action/send_message_with_attachment.py b/app/data/action/send_message_with_attachment.py index 1546252d..fe28094f 100644 --- a/app/data/action/send_message_with_attachment.py +++ b/app/data/action/send_message_with_attachment.py @@ -59,6 +59,8 @@ }, ) async def send_message_with_attachment(input_data: dict) -> dict: + from app.data.action.constants import WAIT_FOR_REPLY_PARK_DELAY_SECONDS + message = input_data["message"] file_paths = input_data.get("file_paths", []) wait_for_user_reply = bool(input_data.get("wait_for_user_reply", False)) @@ -93,7 +95,7 @@ async def send_message_with_attachment(input_data: dict) -> dict: if simulated_mode: return { "status": "success", - "fire_at_delay": 10800 if wait_for_user_reply else 0, + "fire_at_delay": WAIT_FOR_REPLY_PARK_DELAY_SECONDS if wait_for_user_reply else 0, "wait_for_user_reply": wait_for_user_reply, "files_sent": len(file_paths), } @@ -105,7 +107,7 @@ async def send_message_with_attachment(input_data: dict) -> dict: message, file_paths, session_id=session_id ) - fire_at_delay = 10800 if wait_for_user_reply else 0 + fire_at_delay = WAIT_FOR_REPLY_PARK_DELAY_SECONDS if wait_for_user_reply else 0 files_sent = result.get("files_sent", 0) errors = result.get("errors") diff --git a/app/ui_layer/adapters/base.py b/app/ui_layer/adapters/base.py index 6e3ae69f..1562fd72 100644 --- a/app/ui_layer/adapters/base.py +++ b/app/ui_layer/adapters/base.py @@ -15,7 +15,12 @@ InputComponentProtocol, FootageComponentProtocol, ) -from app.ui_layer.components.types import ChatMessage, ChatMessageOption, ActionItem +from app.ui_layer.components.types import ( + ChatMessage, + ChatMessageOption, + ChatMessageQuestion, + ActionItem, +) if TYPE_CHECKING: from app.ui_layer.controller.ui_controller import UIController @@ -540,6 +545,17 @@ def _handle_shutdown(self, event: UIEvent) -> None: # Helper methods # ───────────────────────────────────────────────────────────────────── + def can_prompt_user(self) -> bool: + """ + Whether a user is present and able to answer an interactive prompt + (e.g. a clarifying-question stepper) right now. + + Defaults to False: an adapter must explicitly declare interactive + capability, so actions that park a task waiting on an answer fail + closed on adapters that can't deliver one. + """ + return False + async def _display_chat_message( self, label: str, @@ -547,6 +563,7 @@ async def _display_chat_message( style: str, task_session_id: Optional[str] = None, options: Optional[List[ChatMessageOption]] = None, + questions: Optional[List[ChatMessageQuestion]] = None, client_id: Optional[str] = None, ) -> None: """ @@ -558,6 +575,7 @@ async def _display_chat_message( style: Style identifier task_session_id: Optional task session ID for reply feature options: Optional list of interactive options/buttons + questions: Optional batch of clarifying questions to ask the user client_id: Optional client-generated UUID for reconciling with optimistic UI """ import time @@ -570,6 +588,7 @@ async def _display_chat_message( timestamp=time.time(), task_session_id=task_session_id, options=options, + questions=questions, client_id=client_id, ) ) diff --git a/app/ui_layer/adapters/browser_adapter.py b/app/ui_layer/adapters/browser_adapter.py index c1868125..91d89004 100644 --- a/app/ui_layer/adapters/browser_adapter.py +++ b/app/ui_layer/adapters/browser_adapter.py @@ -232,6 +232,13 @@ def _init_storage(self) -> None: ) for o in stored.options ] + questions = None + if stored.questions: + from app.ui_layer.components.types import ChatMessageQuestion + + questions = [ + ChatMessageQuestion.from_dict(q) for q in stored.questions + ] self._messages.append( ChatMessage( sender=stored.sender, @@ -243,6 +250,9 @@ def _init_storage(self) -> None: task_session_id=stored.task_session_id, options=options, option_selected=stored.option_selected, + questions=questions, + question_answers=stored.question_answers, + questions_declined=stored.questions_declined, ) ) except Exception: @@ -253,6 +263,11 @@ async def append_message(self, message: ChatMessage) -> None: """Append message and broadcast to clients.""" self._messages.append(message) + # Serialized once, shared by the storage insert and the broadcast + questions_data = ( + [q.to_dict() for q in message.questions] if message.questions else None + ) + # Persist to storage if self._storage: try: @@ -286,6 +301,9 @@ async def append_message(self, message: ChatMessage) -> None: attachments=attachments_data, task_session_id=message.task_session_id, options=options_data, + questions=questions_data, + question_answers=message.question_answers, + questions_declined=message.questions_declined, ) self._storage.insert_message(stored) except Exception: @@ -330,6 +348,14 @@ async def append_message(self, message: ChatMessage) -> None: if message.option_selected: message_data["optionSelected"] = message.option_selected + # Include clarifying-question batch if present + if questions_data: + message_data["questions"] = questions_data + if message.question_answers: + message_data["questionAnswers"] = message.question_answers + if message.questions_declined: + message_data["questionsDeclined"] = message.questions_declined + await self._adapter._broadcast( { "type": "chat_message", @@ -396,6 +422,13 @@ def get_messages_before( ) for o in s.options ] + questions = None + if s.questions: + from app.ui_layer.components.types import ChatMessageQuestion + + questions = [ + ChatMessageQuestion.from_dict(q) for q in s.questions + ] messages.append( ChatMessage( sender=s.sender, @@ -404,8 +437,12 @@ def get_messages_before( timestamp=s.timestamp, message_id=s.message_id, attachments=attachments, + task_session_id=s.task_session_id, options=options, option_selected=s.option_selected, + questions=questions, + question_answers=s.question_answers, + questions_declined=s.questions_declined, ) ) return messages @@ -1100,6 +1137,10 @@ def metrics_collector(self) -> MetricsCollector: """Get the metrics collector for dashboard data.""" return self._metrics_collector + def can_prompt_user(self) -> bool: + """A user can answer an interactive prompt when a browser tab is connected.""" + return len(self._ws_clients) > 0 + async def submit_message( self, message: str, @@ -1643,6 +1684,15 @@ async def _handle_ws_message(self, data: Dict[str, Any], ws=None) -> None: message_id = data.get("messageId", "") await self._handle_option_click(value, session_id, message_id) + elif msg_type == "question_answers_submit": + session_id = data.get("sessionId", "") + message_id = data.get("messageId", "") + answers = data.get("answers") + declined = bool(data.get("declined", False)) + await self._handle_question_answers_submit( + session_id, message_id, answers, declined + ) + # Settings operations elif msg_type == "settings_get": await self._handle_settings_get() @@ -4059,6 +4109,110 @@ async def _handle_option_click( f"[OPTION_CLICK] Error handling option click: {e}", exc_info=True ) + async def _handle_question_answers_submit( + self, + session_id: str, + message_id: str, + answers: Optional[Dict[str, str]], + declined: bool, + ) -> None: + """Handle the user resolving a batch of agent-asked clarifying questions. + + Persists the resolution, then resumes the parked task the same way a + normal reply to a waiting task does (see ``submit_message``'s + ``reply_context`` handling) — no separate resume path needed. + + First submission wins: the storage UPDATE only matches an unresolved + row, so a duplicate submit (e.g. from a second browser tab that still + showed the live stepper) is dropped instead of resuming the task twice. + """ + try: + resolved_answers = answers if not declined else None + storage = self._chat._storage if self._chat else None + + in_memory = None + if self._chat and message_id: + for m in self._chat._messages: + if m.message_id == message_id: + in_memory = m + break + + already_resolved_in_memory = in_memory is not None and ( + in_memory.question_answers is not None + or in_memory.questions_declined + ) + if storage: + claimed = False + try: + claimed = storage.update_question_answers( + message_id, resolved_answers, declined + ) + except Exception: + # Storage unavailable: fall back to the in-memory guard. + claimed = not already_resolved_in_memory + # update_question_answers returns False for a missing row too; + # only treat it as a duplicate when memory confirms resolution + # or has no unresolved copy to contradict it. + if not claimed and (in_memory is None or already_resolved_in_memory): + logger.info( + f"[QUESTION_ANSWERS] Ignoring duplicate submit for {message_id}" + ) + return + elif already_resolved_in_memory: + return + + question_text_by_id: Dict[str, str] = {} + if in_memory is not None: + if in_memory.questions: + question_text_by_id = {q.id: q.text for q in in_memory.questions} + in_memory.question_answers = resolved_answers + in_memory.questions_declined = declined + elif storage: + # Message aged out of the in-memory window (e.g. answered via + # paginated history after a restart): recover the question + # texts from storage so the resume text isn't raw ids. + try: + stored = storage.get_message(message_id) + if stored and stored.questions: + question_text_by_id = { + q.get("id", ""): q.get("text", "") + for q in stored.questions + } + except Exception: + pass + + # Collapse the stepper on every connected client — the submitting + # tab already resolved optimistically, but other tabs would keep + # showing a live stepper and could submit again. + await self._broadcast( + { + "type": "question_answers_resolved", + "data": { + "messageId": message_id, + "answers": resolved_answers, + "declined": declined, + }, + } + ) + + if resolved_answers: + lines = [ + f"{i + 1}. {question_text_by_id.get(qid, qid)} → {answer}" + for i, (qid, answer) in enumerate(resolved_answers.items()) + ] + resume_text = "Answered clarifying questions:\n" + "\n".join(lines) + else: + resume_text = "The user declined to answer." + + await self.submit_message( + resume_text, reply_context={"sessionId": session_id} + ) + except Exception as e: + logger.error( + f"[QUESTION_ANSWERS] Error handling question answers: {e}", + exc_info=True, + ) + # ───────────────────────────────────────────────────────────────────── # Settings Operation Handlers # ───────────────────────────────────────────────────────────────────── @@ -8108,6 +8262,12 @@ async def _handle_chat_history( ] if m.option_selected: msg_data["optionSelected"] = m.option_selected + if m.questions: + msg_data["questions"] = [q.to_dict() for q in m.questions] + if m.question_answers: + msg_data["questionAnswers"] = m.question_answers + if m.questions_declined: + msg_data["questionsDeclined"] = m.questions_declined messages_data.append(msg_data) await self._broadcast( @@ -8817,6 +8977,21 @@ def _get_initial_state(self) -> Dict[str, Any]: if m.option_selected else {} ), + **( + {"questions": [q.to_dict() for q in m.questions]} + if m.questions + else {} + ), + **( + {"questionAnswers": m.question_answers} + if m.question_answers + else {} + ), + **( + {"questionsDeclined": m.questions_declined} + if m.questions_declined + else {} + ), } for m in self._chat.get_messages() ], diff --git a/app/ui_layer/browser/frontend/src/components/Chat/Chat.tsx b/app/ui_layer/browser/frontend/src/components/Chat/Chat.tsx index 10dfcb3d..361b53f0 100644 --- a/app/ui_layer/browser/frontend/src/components/Chat/Chat.tsx +++ b/app/ui_layer/browser/frontend/src/components/Chat/Chat.tsx @@ -104,6 +104,7 @@ export function Chat({ livingUIId, placeholder, emptyMessage }: ChatProps) { sendMessage, sendCommand, sendOptionClick, + sendQuestionAnswers, openFile, openFolder, lastSeenMessageId, @@ -726,6 +727,7 @@ export function Chat({ livingUIId, placeholder, emptyMessage }: ChatProps) { onOpenFolder={openFolder} onReply={handleChatReply} onOptionClick={sendOptionClick} + onQuestionAnswers={sendQuestionAnswers} /> ) diff --git a/app/ui_layer/browser/frontend/src/components/ui/QuestionStepper.module.css b/app/ui_layer/browser/frontend/src/components/ui/QuestionStepper.module.css new file mode 100644 index 00000000..3b93ae33 --- /dev/null +++ b/app/ui_layer/browser/frontend/src/components/ui/QuestionStepper.module.css @@ -0,0 +1,267 @@ +.stepper { + display: flex; + flex-direction: column; + margin-top: var(--space-2); + border-top: 1px solid var(--border-primary); + padding-top: var(--space-2); + gap: var(--space-2); +} + +.nav { + display: flex; + align-items: center; + gap: var(--space-1); +} + +.backButton { + padding: 2px var(--space-2); + border: 1px solid var(--border-primary); + border-radius: var(--radius-sm); + background: var(--bg-secondary); + color: var(--text-secondary); + font-size: var(--text-xs); + cursor: pointer; +} + +.backButton:hover { + color: var(--text-primary); + background: var(--bg-primary); +} + +.navDot { + width: 20px; + height: 20px; + border-radius: 50%; + border: 1px solid var(--border-primary); + background: var(--bg-secondary); + color: var(--text-secondary); + font-size: var(--text-xs); + cursor: pointer; + padding: 0; +} + +.navDot:disabled { + cursor: not-allowed; + opacity: 0.5; +} + +.navDotActive { + border-color: var(--text-primary); + color: var(--text-primary); + font-weight: var(--font-semibold); +} + +.navDotDone { + background: var(--bg-primary); +} + +.navReview { + font-size: var(--text-xs); + color: var(--text-secondary); + margin-left: var(--space-1); +} + +.dismissButton { + margin-left: auto; + padding: 2px var(--space-2); + border: 1px solid var(--border-primary); + border-radius: var(--radius-sm); + background: transparent; + color: var(--text-secondary); + font-size: var(--text-xs); + cursor: pointer; +} + +.dismissButton:hover { + color: var(--text-primary); + background: var(--bg-secondary); +} + +.question { + display: flex; + flex-direction: column; + gap: var(--space-1); +} + +.questionText { + font-size: var(--text-sm); + color: var(--text-primary); + padding-bottom: var(--space-1); +} + +.choiceButton { + display: flex; + align-items: center; + gap: var(--space-2); + width: 100%; + padding: var(--space-2) var(--space-3); + border: none; + border-top: 1px solid var(--border-primary); + background: var(--bg-secondary); + color: var(--text-primary); + font-size: var(--text-sm); + font-weight: var(--font-medium); + cursor: pointer; + text-align: left; + border-radius: var(--radius-sm); + transition: background var(--transition-fast); +} + +.choiceButton:hover { + background: var(--bg-primary); +} + +/* inset shadow instead of a border so selection doesn't shift layout */ +.choiceButtonSelected { + background: var(--bg-primary); + box-shadow: inset 0 0 0 1px var(--text-primary); +} + +.choiceIndex { + font-weight: var(--font-semibold); + color: var(--text-secondary); +} + +.checkboxRow { + display: flex; + align-items: center; + gap: var(--space-2); + width: 100%; + padding: var(--space-2) var(--space-3); + border-top: 1px solid var(--border-primary); + background: var(--bg-secondary); + color: var(--text-primary); + font-size: var(--text-sm); + font-weight: var(--font-medium); + cursor: pointer; + border-radius: var(--radius-sm); +} + +.checkboxRow:hover { + background: var(--bg-primary); +} + +.checkbox { + width: 16px; + height: 16px; + accent-color: var(--text-primary); + cursor: pointer; +} + +.otherRow { + display: flex; + gap: var(--space-2); + padding: var(--space-2) var(--space-3); + border-top: 1px solid var(--border-primary); + background: var(--bg-secondary); + border-radius: var(--radius-sm); +} + +.otherInput { + flex: 1; + padding: var(--space-1) 0; + border: none; + background: transparent; + color: var(--text-primary); + font-size: var(--text-sm); + font-family: inherit; +} + +.otherInput:focus { + outline: none; +} + +.otherSend { + padding: 2px var(--space-2); + border: 1px solid var(--border-primary); + border-radius: var(--radius-sm); + background: transparent; + color: var(--text-secondary); + font-size: var(--text-xs); + font-weight: var(--font-medium); + cursor: pointer; + align-self: center; +} + +.otherSend:hover:not(:disabled) { + color: var(--text-primary); + border-color: var(--text-primary); +} + +.otherSend:disabled { + opacity: 0.4; + cursor: not-allowed; +} + +.review { + display: flex; + flex-direction: column; + gap: var(--space-1); +} + +.reviewRow { + display: flex; + flex-direction: column; + gap: 2px; + width: 100%; + padding: var(--space-2) var(--space-3); + border: none; + border-top: 1px solid var(--border-primary); + background: var(--bg-secondary); + border-radius: var(--radius-sm); + cursor: pointer; + text-align: left; +} + +.reviewRow:hover { + background: var(--bg-primary); +} + +.submitButton { + margin-top: var(--space-1); + padding: var(--space-2) var(--space-3); + border: none; + border-radius: var(--radius-sm); + background: var(--text-primary); + color: var(--bg-primary); + font-size: var(--text-sm); + font-weight: var(--font-semibold); + cursor: pointer; +} + +.submitButton:disabled { + opacity: 0.4; + cursor: not-allowed; +} + +.resolved { + display: flex; + flex-direction: column; + margin-top: var(--space-2); + border-top: 1px solid var(--border-primary); + padding-top: var(--space-2); + gap: 2px; +} + +.resolvedRow { + display: flex; + flex-direction: column; + gap: 2px; + font-size: var(--text-sm); + padding: var(--space-1) 0; +} + +.resolvedQuestion { + color: var(--text-secondary); + font-size: var(--text-xs); +} + +.resolvedAnswer { + color: var(--text-primary); +} + +.declinedNote { + color: var(--text-secondary); + font-size: var(--text-sm); + font-style: italic; +} diff --git a/app/ui_layer/browser/frontend/src/components/ui/QuestionStepper.tsx b/app/ui_layer/browser/frontend/src/components/ui/QuestionStepper.tsx new file mode 100644 index 00000000..2671f3df --- /dev/null +++ b/app/ui_layer/browser/frontend/src/components/ui/QuestionStepper.tsx @@ -0,0 +1,248 @@ +import { useState, type KeyboardEvent } from 'react' +import type { ChatMessageQuestion } from '../../types' +import styles from './QuestionStepper.module.css' + +export interface QuestionStepperProps { + messageId: string + sessionId?: string + questions: ChatMessageQuestion[] + answers?: Record + declined?: boolean + onSubmit: (messageId: string, sessionId: string | undefined, answers: Record | undefined, declined: boolean) => void +} + +// Per-question draft kept structured (which boxes are checked vs. what was +// free-typed) so navigating between questions never has to reverse-engineer +// state from the joined answer string — choice values containing ", " would +// mis-parse, and a free-typed value equal to a choice would be reclassified. +interface QuestionDraft { + checked: string[] + other: string +} + +const draftText = (d: QuestionDraft) => + [...d.checked, ...(d.other ? [d.other] : [])].join(', ') + +export function QuestionStepper({ messageId, sessionId, questions, answers, declined, onSubmit }: QuestionStepperProps) { + const resolved = !!answers || !!declined + const [step, setStep] = useState(0) + const [drafts, setDrafts] = useState>({}) + const [otherText, setOtherText] = useState('') + const [checked, setChecked] = useState>(new Set()) + + // Esc abandons the whole batch — scoped to this stepper via bubbling from + // its own focused controls, not a window-wide listener (which would decline + // whichever batch happened to be mounted regardless of what's focused). + // The free-text inputs intercept Esc to clear themselves instead, and the + // Dismiss button covers the case where nothing in the stepper has focus. + const handleKeyDown = (e: KeyboardEvent) => { + if (e.key === 'Escape') onSubmit(messageId, sessionId, undefined, true) + } + + if (resolved) { + return ( +
+ {declined ? ( + Declined to answer. + ) : ( + questions.map(q => ( +
+ {q.text} + {answers?.[q.id]} +
+ )) + )} +
+ ) + } + + const single = questions.length === 1 + const isReview = !single && step === questions.length + const current = questions[step] + + const answerTextFor = (qid: string): string | undefined => { + const d = drafts[qid] + return d === undefined ? undefined : draftText(d) + } + + // Navigating to a question rehydrates its saved draft (if any) so a + // previously-given answer shows exactly as it was entered. + // `source` lets callers that just updated the drafts pass the fresh map, + // since the `drafts` in this closure is one render behind. + const goToStep = (i: number, source: Record = drafts) => { + setStep(i) + const q = questions[i] + if (!q) return // i === questions.length: moving into the review step + const d = source[q.id] + setChecked(new Set(d?.checked ?? [])) + setOtherText(d?.other ?? '') + } + + const saveCurrent = (d: QuestionDraft) => { + const next = { ...drafts, [current.id]: d } + setDrafts(next) + if (single) { + onSubmit(messageId, sessionId, { [current.id]: draftText(d) }, false) + } else { + goToStep(step + 1, next) + } + } + + const toggleChecked = (value: string) => { + const next = new Set(checked) + if (next.has(value)) next.delete(value) + else next.add(value) + setChecked(next) + } + + const confirmMultiSelect = () => { + const other = otherText.trim() + if (checked.size === 0 && !other) return + saveCurrent({ checked: [...checked], other }) + } + + const submitOther = () => { + const other = otherText.trim() + if (!other) return + saveCurrent({ checked: [], other }) + } + + const submitAll = () => { + const all: Record = {} + for (const q of questions) { + const text = answerTextFor(q.id) + if (text !== undefined) all[q.id] = text + } + onSubmit(messageId, sessionId, all, false) + } + + const clearOnEscape = (e: KeyboardEvent) => { + if (e.key === 'Escape') { + e.stopPropagation() // clear the field, don't decline the batch + setOtherText('') + } + } + + return ( +
+
+ {!single && step > 0 && ( + + )} + {!single && questions.map((q, i) => ( + + ))} + {!single && ( + {isReview ? 'Review' : `${step + 1} of ${questions.length}`} + )} + +
+ + {isReview ? ( +
+ {questions.map(q => ( + + ))} + +
+ ) : ( +
+ {!single && {current.text}} + + {current.multiSelect ? ( + <> + {current.choices.map(c => ( + + ))} +
+ setOtherText(e.target.value)} + onKeyDown={e => { if (e.key === 'Enter') confirmMultiSelect(); clearOnEscape(e) }} + placeholder="Other (optional)..." + /> +
+ + + ) : ( + <> + {current.choices.map((c, i) => { + const selected = checked.has(c.value) + return ( + + ) + })} +
+ setOtherText(e.target.value)} + onKeyDown={e => { if (e.key === 'Enter') submitOther(); clearOnEscape(e) }} + placeholder={current.choices.length > 0 ? 'Or type your own answer...' : 'Type your answer...'} + /> + +
+ + )} +
+ )} +
+ ) +} diff --git a/app/ui_layer/browser/frontend/src/components/ui/index.ts b/app/ui_layer/browser/frontend/src/components/ui/index.ts index f0193089..c5f67a71 100644 --- a/app/ui_layer/browser/frontend/src/components/ui/index.ts +++ b/app/ui_layer/browser/frontend/src/components/ui/index.ts @@ -14,6 +14,9 @@ export { MarkdownContent } from './MarkdownContent' export { AttachmentDisplay } from './AttachmentDisplay' +export { QuestionStepper } from './QuestionStepper' +export type { QuestionStepperProps } from './QuestionStepper' + export { AttachmentPreviewModal } from './AttachmentPreviewModal' export type { AttachmentPreviewModalProps, AttachmentPreviewItem } from './AttachmentPreviewModal' diff --git a/app/ui_layer/browser/frontend/src/contexts/WebSocketContext.tsx b/app/ui_layer/browser/frontend/src/contexts/WebSocketContext.tsx index 28cc09d4..84bb9de4 100644 --- a/app/ui_layer/browser/frontend/src/contexts/WebSocketContext.tsx +++ b/app/ui_layer/browser/frontend/src/contexts/WebSocketContext.tsx @@ -19,6 +19,7 @@ import { addOptimistic as messagesAddOptimistic, setLoadingOlder as messagesSetLoadingOlder, markOptionSelected as messagesMarkOptionSelected, + markQuestionsAnswered as messagesMarkQuestionsAnswered, clear as messagesClear, } from '../store/slices/messagesSlice' import { @@ -219,6 +220,13 @@ interface WebSocketContextType extends WebSocketState { pullOllamaModel: (model: string) => void // Option click (interactive buttons in chat) sendOptionClick: (value: string, sessionId?: string, messageId?: string) => void + // Clarifying-question batch answers (agent-asked Q&A stepper in chat) + sendQuestionAnswers: ( + messageId: string, + sessionId: string | undefined, + answers: Record | undefined, + declined: boolean + ) => void // Agent profile picture uploadAgentProfilePicture: (name: string, mimeType: string, contentBase64: string) => void removeAgentProfilePicture: () => void @@ -498,6 +506,21 @@ export function WebSocketProvider({ children }: { children: ReactNode }) { } }, []) + const sendQuestionAnswers = useCallback(( + messageId: string, + sessionId: string | undefined, + answers: Record | undefined, + declined: boolean + ) => { + // Optimistically resolve the stepper locally, same reasoning as sendOptionClick. + dispatch(messagesMarkQuestionsAnswered({ messageId, answers, declined })) + if (client.isConnected) { + client.sendString(JSON.stringify({ + type: 'question_answers_submit', messageId, sessionId, answers, declined, + })) + } + }, []) + const uploadAgentProfilePicture = useCallback( (name: string, mimeType: string, contentBase64: string) => { if (client.isConnected) { @@ -771,6 +794,7 @@ export function WebSocketProvider({ children }: { children: ReactNode }) { enhancePrompt, clearEnhancedPrompt, sendOptionClick, + sendQuestionAnswers, uploadAgentProfilePicture, removeAgentProfilePicture, // Living UI methods diff --git a/app/ui_layer/browser/frontend/src/pages/Chat/ChatMessage.tsx b/app/ui_layer/browser/frontend/src/pages/Chat/ChatMessage.tsx index 8d3b79f9..e7a96ba0 100644 --- a/app/ui_layer/browser/frontend/src/pages/Chat/ChatMessage.tsx +++ b/app/ui_layer/browser/frontend/src/pages/Chat/ChatMessage.tsx @@ -1,6 +1,6 @@ import React, { memo, useState, useMemo, useRef, useEffect } from 'react' import { Reply, Copy, Check } from 'lucide-react' -import { MarkdownContent, AttachmentDisplay, AttachmentPreviewModal, IconButton } from '../../components/ui' +import { MarkdownContent, AttachmentDisplay, AttachmentPreviewModal, IconButton, QuestionStepper } from '../../components/ui' import type { Attachment, ChatMessage as ChatMessageType } from '../../types' import { useWebSocket } from '../../contexts/WebSocketContext' import styles from './ChatPage.module.css' @@ -15,6 +15,12 @@ interface ChatMessageProps { fullContent: string ) => void onOptionClick?: (value: string, sessionId?: string, messageId?: string) => void + onQuestionAnswers?: ( + messageId: string, + sessionId: string | undefined, + answers: Record | undefined, + declined: boolean + ) => void } // Parse reply context from message content @@ -36,6 +42,7 @@ export const ChatMessageItem = memo(function ChatMessageItem({ onOpenFolder, onReply, onOptionClick, + onQuestionAnswers, }: ChatMessageProps) { const [isHovered, setIsHovered] = useState(false) const [copied, setCopied] = useState(false) @@ -51,10 +58,14 @@ export const ChatMessageItem = memo(function ChatMessageItem({ }, [selected]) const { agentProfilePictureUrl } = useWebSocket() - // Show reply for agent messages, except those presenting options that - // require the user to make an explicit choice via the option buttons. + // Show reply for agent messages, except those presenting options/questions + // that require the user to make an explicit choice via the buttons. const hasPendingOptions = !!(message.options && message.options.length > 0) - const canReply = message.style === 'agent' && onReply && !hasPendingOptions + const hasPendingQuestions = !!( + message.questions && message.questions.length > 0 + && !message.questionAnswers && !message.questionsDeclined + ) + const canReply = message.style === 'agent' && onReply && !hasPendingOptions && !hasPendingQuestions const canCopy = message.style === 'user' || message.style === 'agent' // Parse reply context for user messages @@ -126,6 +137,16 @@ export const ChatMessageItem = memo(function ChatMessageItem({ ))} )} + {message.questions && message.questions.length > 0 && ( + onQuestionAnswers?.(msgId, sid, ans, dec)} + /> + )} {message.attachments && message.attachments.length > 0 && (
@@ -192,5 +213,7 @@ export const ChatMessageItem = memo(function ChatMessageItem({ }, (prev, next) => prev.message.messageId === next.message.messageId && prev.message.optionSelected === next.message.optionSelected + && prev.message.questionAnswers === next.message.questionAnswers + && prev.message.questionsDeclined === next.message.questionsDeclined && prev.message.content === next.message.content ) diff --git a/app/ui_layer/browser/frontend/src/store/slices/messagesSlice.ts b/app/ui_layer/browser/frontend/src/store/slices/messagesSlice.ts index c2e23a3f..7dd4c25d 100644 --- a/app/ui_layer/browser/frontend/src/store/slices/messagesSlice.ts +++ b/app/ui_layer/browser/frontend/src/store/slices/messagesSlice.ts @@ -68,6 +68,20 @@ const messagesSlice = createSlice({ entry.optionSelected = value } }, + markQuestionsAnswered( + state, + action: PayloadAction<{ messageId: string; answers?: Record; declined?: boolean }> + ) { + const { messageId, answers, declined } = action.payload + const entry = state.entities[messageId] + if (entry && !entry.questionAnswers && !entry.questionsDeclined) { + if (declined) { + entry.questionsDeclined = true + } else { + entry.questionAnswers = answers + } + } + }, }, }) @@ -79,6 +93,7 @@ export const { clear, setLoadingOlder, markOptionSelected, + markQuestionsAnswered, } = messagesSlice.actions export const messagesAdapter = adapter @@ -104,3 +119,12 @@ register('chat_history', (data, dispatch) => { register('chat_clear', (_data, dispatch) => { dispatch(clear()) }) + +// Another client resolved a question batch — collapse the stepper here too. +// markQuestionsAnswered is a no-op if this tab already resolved it optimistically. +register('question_answers_resolved', (data, dispatch) => { + const d = data as { messageId?: string; answers?: Record | null; declined?: boolean } | undefined + if (d?.messageId) { + dispatch(markQuestionsAnswered({ messageId: d.messageId, answers: d.answers ?? undefined, declined: !!d.declined })) + } +}) diff --git a/app/ui_layer/browser/frontend/src/types/index.ts b/app/ui_layer/browser/frontend/src/types/index.ts index a5f21f3e..c41d6e96 100644 --- a/app/ui_layer/browser/frontend/src/types/index.ts +++ b/app/ui_layer/browser/frontend/src/types/index.ts @@ -18,6 +18,13 @@ export interface ChatMessageOption { style?: 'primary' | 'danger' | 'default' } +export interface ChatMessageQuestion { + id: string + text: string + choices: ChatMessageOption[] + multiSelect?: boolean +} + export interface ChatMessage { sender: string content: string @@ -28,6 +35,9 @@ export interface ChatMessage { taskSessionId?: string // Links message to a task session for reply feature options?: ChatMessageOption[] optionSelected?: string // Value of the option that was selected + questions?: ChatMessageQuestion[] // Batch of clarifying questions the agent is waiting on + questionAnswers?: Record // Answer text per question id, once submitted + questionsDeclined?: boolean // True if the user escaped the batch without answering clientId?: string // Client-generated UUID for reconciling optimistic pending messages with server echo pending?: boolean // True while an optimistic message is awaiting server acknowledgment } diff --git a/app/ui_layer/components/types.py b/app/ui_layer/components/types.py index 85fa9c9b..97d1c693 100644 --- a/app/ui_layer/components/types.py +++ b/app/ui_layer/components/types.py @@ -3,7 +3,7 @@ from __future__ import annotations from dataclasses import dataclass, field -from typing import Optional, List +from typing import Optional, List, Dict import time @@ -45,6 +45,48 @@ class ChatMessageOption: style: str = "default" +@dataclass +class ChatMessageQuestion: + """ + Data structure for one clarifying question in a batch asked by the agent. + + Attributes: + id: Stable identifier for this question within the batch (e.g. "q1") + text: The question text shown to the user + choices: Multiple-choice options; the frontend always adds an + implicit free-text "Other" option after these + multi_select: If True, the user can pick more than one choice + (checkboxes); if False, picking one answers the question (radio) + """ + + id: str + text: str + choices: List[ChatMessageOption] = field(default_factory=list) + multi_select: bool = False + + def to_dict(self) -> Dict: + """Wire/storage shape (camelCase keys, matches the frontend type).""" + return { + "id": self.id, + "text": self.text, + "choices": [{"label": c.label, "value": c.value} for c in self.choices], + "multiSelect": self.multi_select, + } + + @classmethod + def from_dict(cls, data: Dict) -> "ChatMessageQuestion": + """Inverse of to_dict, tolerant of missing keys.""" + return cls( + id=data.get("id", ""), + text=data.get("text", ""), + choices=[ + ChatMessageOption(label=c.get("label", ""), value=c.get("value", "")) + for c in data.get("choices", []) + ], + multi_select=data.get("multiSelect", False), + ) + + @dataclass class ChatMessage: """ @@ -62,6 +104,9 @@ class ChatMessage: task_session_id: Optional task session ID for reply feature options: Optional list of interactive options/buttons option_selected: Value of the option that was selected, if any + questions: Optional batch of clarifying questions the agent is waiting on + question_answers: Answer text per question id, once submitted + questions_declined: True if the user escaped out of the question batch """ sender: str @@ -73,6 +118,9 @@ class ChatMessage: task_session_id: Optional[str] = None options: Optional[List[ChatMessageOption]] = None option_selected: Optional[str] = None + questions: Optional[List[ChatMessageQuestion]] = None + question_answers: Optional[Dict[str, str]] = None + questions_declined: Optional[bool] = None # Client-generated UUID from the sender; echoed back so the browser can # reconcile optimistic-pending messages with the server-acknowledged copy. client_id: Optional[str] = None diff --git a/app/usage/chat_storage.py b/app/usage/chat_storage.py index 9ec4ef84..c49919ee 100644 --- a/app/usage/chat_storage.py +++ b/app/usage/chat_storage.py @@ -35,6 +35,9 @@ class StoredChatMessage: task_session_id: Optional[str] = None options: Optional[List[Dict[str, Any]]] = None option_selected: Optional[str] = None + questions: Optional[List[Dict[str, Any]]] = None + question_answers: Optional[Dict[str, str]] = None + questions_declined: Optional[bool] = None def to_dict(self) -> Dict[str, Any]: """Convert to dictionary for JSON serialization.""" @@ -53,6 +56,12 @@ def to_dict(self) -> Dict[str, Any]: result["options"] = self.options if self.option_selected: result["optionSelected"] = self.option_selected + if self.questions: + result["questions"] = self.questions + if self.question_answers: + result["questionAnswers"] = self.question_answers + if self.questions_declined: + result["questionsDeclined"] = self.questions_declined return result @@ -131,6 +140,24 @@ def _init_db(self) -> None: ADD COLUMN option_selected TEXT """) logger.info("[ChatStorage] Migrated: added option_selected column") + if "questions" not in columns: + cursor.execute(""" + ALTER TABLE chat_messages + ADD COLUMN questions TEXT + """) + logger.info("[ChatStorage] Migrated: added questions column") + if "question_answers" not in columns: + cursor.execute(""" + ALTER TABLE chat_messages + ADD COLUMN question_answers TEXT + """) + logger.info("[ChatStorage] Migrated: added question_answers column") + if "questions_declined" not in columns: + cursor.execute(""" + ALTER TABLE chat_messages + ADD COLUMN questions_declined INTEGER + """) + logger.info("[ChatStorage] Migrated: added questions_declined column") conn.commit() @@ -149,8 +176,8 @@ def insert_message(self, message: StoredChatMessage) -> int: cursor.execute( """ INSERT OR REPLACE INTO chat_messages - (message_id, sender, content, style, timestamp, attachments, task_session_id, options, option_selected) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) + (message_id, sender, content, style, timestamp, attachments, task_session_id, options, option_selected, questions, question_answers, questions_declined) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) """, ( message.message_id, @@ -162,6 +189,9 @@ def insert_message(self, message: StoredChatMessage) -> int: message.task_session_id, json.dumps(message.options) if message.options else None, message.option_selected, + json.dumps(message.questions) if message.questions else None, + json.dumps(message.question_answers) if message.question_answers else None, + message.questions_declined, ), ) conn.commit() @@ -186,7 +216,7 @@ def get_messages( cursor = conn.cursor() cursor.execute( """ - SELECT message_id, sender, content, style, timestamp, attachments, task_session_id, options, option_selected + SELECT message_id, sender, content, style, timestamp, attachments, task_session_id, options, option_selected, questions, question_answers, questions_declined FROM chat_messages ORDER BY timestamp ASC LIMIT ? OFFSET ? @@ -206,6 +236,9 @@ def get_messages( task_session_id=row[6], options=json.loads(row[7]) if row[7] else None, option_selected=row[8], + questions=json.loads(row[9]) if row[9] else None, + question_answers=json.loads(row[10]) if row[10] else None, + questions_declined=bool(row[11]) if row[11] is not None else None, ) for row in rows ] @@ -225,7 +258,7 @@ def get_recent_messages(self, limit: int = 100) -> List[StoredChatMessage]: # Get last N messages ordered by timestamp DESC, then reverse cursor.execute( """ - SELECT message_id, sender, content, style, timestamp, attachments, task_session_id, options, option_selected + SELECT message_id, sender, content, style, timestamp, attachments, task_session_id, options, option_selected, questions, question_answers, questions_declined FROM chat_messages ORDER BY timestamp DESC LIMIT ? @@ -245,6 +278,9 @@ def get_recent_messages(self, limit: int = 100) -> List[StoredChatMessage]: task_session_id=row[6], options=json.loads(row[7]) if row[7] else None, option_selected=row[8], + questions=json.loads(row[9]) if row[9] else None, + question_answers=json.loads(row[10]) if row[10] else None, + questions_declined=bool(row[11]) if row[11] is not None else None, ) for row in rows ] @@ -287,6 +323,78 @@ def update_option_selected(self, message_id: str, option_value: str) -> bool: conn.commit() return cursor.rowcount > 0 + def update_question_answers( + self, + message_id: str, + answers: Optional[Dict[str, str]], + declined: bool, + ) -> bool: + """ + Record the resolution of a clarifying-question batch on a message. + + Args: + message_id: The message ID to update. + answers: Answer text per question id, or None if declined. + declined: True if the user escaped out of the batch instead of answering. + + Returns: + True if the message was updated, False if not found or already + resolved. The WHERE clause only matches an unresolved row, making + this the atomic first-submission-wins guard: a second submit for + the same batch (e.g. from a stale browser tab) returns False and + the caller must not resume the task again. + """ + with sqlite3.connect(self._db_path) as conn: + cursor = conn.cursor() + cursor.execute( + """ + UPDATE chat_messages SET question_answers = ?, questions_declined = ? + WHERE message_id = ? AND question_answers IS NULL + AND (questions_declined IS NULL OR questions_declined = 0) + """, + (json.dumps(answers) if answers else None, declined, message_id), + ) + conn.commit() + return cursor.rowcount > 0 + + def get_message(self, message_id: str) -> Optional[StoredChatMessage]: + """ + Get a single message by ID. + + Args: + message_id: The message ID to look up. + + Returns: + The stored message, or None if not found. + """ + with sqlite3.connect(self._db_path) as conn: + cursor = conn.cursor() + cursor.execute( + """ + SELECT message_id, sender, content, style, timestamp, attachments, task_session_id, options, option_selected, questions, question_answers, questions_declined + FROM chat_messages + WHERE message_id = ? + """, + (message_id,), + ) + row = cursor.fetchone() + if row is None: + return None + return StoredChatMessage( + message_id=row[0], + sender=row[1], + content=row[2], + style=row[3], + timestamp=row[4], + attachments=json.loads(row[5]) if row[5] else None, + task_session_id=row[6], + options=json.loads(row[7]) if row[7] else None, + option_selected=row[8], + questions=json.loads(row[9]) if row[9] else None, + question_answers=json.loads(row[10]) if row[10] else None, + questions_declined=bool(row[11]) if row[11] is not None else None, + ) + def delete_message(self, message_id: str) -> bool: """ Delete a message by ID. @@ -324,7 +432,7 @@ def get_messages_before( cursor = conn.cursor() cursor.execute( """ - SELECT message_id, sender, content, style, timestamp, attachments, task_session_id, options, option_selected + SELECT message_id, sender, content, style, timestamp, attachments, task_session_id, options, option_selected, questions, question_answers, questions_declined FROM chat_messages WHERE timestamp < ? ORDER BY timestamp DESC @@ -345,6 +453,9 @@ def get_messages_before( task_session_id=row[6], options=json.loads(row[7]) if row[7] else None, option_selected=row[8], + questions=json.loads(row[9]) if row[9] else None, + question_answers=json.loads(row[10]) if row[10] else None, + questions_declined=bool(row[11]) if row[11] is not None else None, ) for row in rows ]