diff --git a/flexus_client_kit/ckit_integrations_db.py b/flexus_client_kit/ckit_integrations_db.py index fa8bd926..b12294e7 100644 --- a/flexus_client_kit/ckit_integrations_db.py +++ b/flexus_client_kit/ckit_integrations_db.py @@ -76,6 +76,22 @@ async def _init_pdoc(rcx, setup): integr_prompt=fi_pdoc.POLICY_DOCUMENT_PROMPT, )) + elif name == "escalate_to_human": + from flexus_client_kit.integrations import fi_escalate + async def _init_escalate(rcx, setup): + return None + result.append(IntegrationRecord( + integr_name=name, + integr_tools=[fi_escalate.ESCALATE_TO_HUMAN_TOOL], + integr_init=_init_escalate, + integr_setup_handlers=lambda obj, rcx: [ + rcx.on_tool_call(fi_escalate.ESCALATE_TO_HUMAN_TOOL.name)( + lambda tc, args, _rcx=rcx: fi_escalate.handle_escalate_to_human(_rcx, tc, args) + ) + ], + integr_prompt=fi_escalate.ESCALATE_TO_HUMAN_PROMPT, + )) + elif name == "print_widget": from flexus_client_kit.integrations import fi_widget async def _init_widget(rcx, setup): @@ -510,9 +526,9 @@ async def _emessage_handler(emsg, _m=obj): if rcx.messengers: @rcx.on_updated_message async def _messenger_updated_message(msg: ckit_ask_model.FThreadMessageOutput): - # Don't worry, you can override it. The default reaction to assistant messages is to get it past messengers: + # Don't worry, you can override it. The default reaction is to relay assistant messages and flexus-user messages past messengers: for m in rcx.messengers: - await m.look_assistant_might_have_posted_something(msg) + await m.look_assistant_or_fuser_might_have_posted(msg) await m.look_user_message_got_confirmed(msg) return result diff --git a/flexus_client_kit/integrations/fi_discord2.py b/flexus_client_kit/integrations/fi_discord2.py index d2eabdbb..9bfedb42 100644 --- a/flexus_client_kit/integrations/fi_discord2.py +++ b/flexus_client_kit/integrations/fi_discord2.py @@ -778,8 +778,12 @@ async def post_into_captured_thread_as_user(self, activity: ActivityDiscord) -> return False return bool(ft_id) - async def look_assistant_might_have_posted_something(self, msg: ckit_ask_model.FThreadMessageOutput) -> bool: - if msg.ftm_role != "assistant" or not msg.ftm_content: + async def look_assistant_or_fuser_might_have_posted(self, msg: ckit_ask_model.FThreadMessageOutput) -> bool: + if msg.ftm_role not in ("assistant", "user"): + return False + if msg.ftm_role == "user" and (msg.ftm_author_label1 or "").startswith("discord:"): + return False + if not msg.ftm_content: return False if not msg.ft_app_searchable: return False diff --git a/flexus_client_kit/integrations/fi_escalate.py b/flexus_client_kit/integrations/fi_escalate.py new file mode 100644 index 00000000..9ad71798 --- /dev/null +++ b/flexus_client_kit/integrations/fi_escalate.py @@ -0,0 +1,65 @@ +from typing import Any, Dict + +import gql + +from flexus_client_kit import ckit_bot_exec, ckit_cloudtool + + +ESCALATE_TO_HUMAN_PROMPT = """ +## Escalation to Human + +Before escalating to a human, confirm with the client that they will wait for a human to respond. +NEVER call escalate_to_human without doing so. + +After calling escalate_to_human, staff is being notified. Once staff speaks in the thread, +say NOTHING_TO_SAY while they and the client talk. Resume if staff asks. + +If staff hasn't arrived and the client keeps writing, a brief calming reply is fine — +but don't promise anything or handle what was escalated. + +Don't resolve an escalated task until staff has handled it or tells you to. +""".strip() + + +ESCALATE_TO_HUMAN_TOOL = ckit_cloudtool.CloudTool( + strict=True, + name="escalate_to_human", + description=( + "Hand off the current thread to a human operator. Call this when the user explicitly asks for a human, " + "on legal/fraud/compliance mentions, obvious frustration, or any situation beyond your authority. " + "Before calling, confirm with the client that they will wait for a human to respond." + ), + parameters={ + "type": "object", + "properties": { + "reason": { + "type": "string", + "description": "Short explanation of why a human is needed (one sentence).", + }, + }, + "required": ["reason"], + "additionalProperties": False, + }, +) + + +async def handle_escalate_to_human( + rcx: ckit_bot_exec.RobotContext, + toolcall: ckit_cloudtool.FCloudtoolCall, + model_produced_args: Dict[str, Any], +) -> str: + reason = (model_produced_args.get("reason") or "").strip() + if not reason: + return "Error: reason is required" + ktask_id = next((t.ktask_id for t in rcx.latest_tasks.values() if t.ktask_inprogress_ft_id == toolcall.fcall_ft_id), None) + if not ktask_id: + return "No task assigned to this thread — escalate_to_human only works within a task context." + http = await rcx.fclient.use_http_on_behalf(toolcall.connected_persona_id, toolcall.fcall_untrusted_key) + async with http as h: + await h.execute(gql.gql(""" + mutation BotRequestHumanEscalation($ktask_id: String!, $status: String!, $reason: String) { + kanban_task_update_escalation_status(ktask_id: $ktask_id, ktask_escalation_status: $status, ktask_escalation_reason: $reason) + }"""), + variable_values={"ktask_id": ktask_id, "status": "ESCALATION_REQUESTED", "reason": reason}, + ) + return "⏸️ESCALATED_TO_HUMAN\nReason: %s\nA human operator has been requested. Stop acting on this thread." % reason diff --git a/flexus_client_kit/integrations/fi_magic_desk.py b/flexus_client_kit/integrations/fi_magic_desk.py index c3daedbe..f2d6318a 100644 --- a/flexus_client_kit/integrations/fi_magic_desk.py +++ b/flexus_client_kit/integrations/fi_magic_desk.py @@ -191,8 +191,12 @@ async def look_user_message_got_confirmed(self, msg: ckit_ask_model.FThreadMessa ) return True - async def look_assistant_might_have_posted_something(self, msg: ckit_ask_model.FThreadMessageOutput) -> bool: - if msg.ftm_role != "assistant" or not msg.ftm_content: + async def look_assistant_or_fuser_might_have_posted(self, msg: ckit_ask_model.FThreadMessageOutput) -> bool: + if msg.ftm_role not in ("assistant", "user"): + return False + if msg.ftm_role == "user" and (msg.ftm_author_label1 or "").startswith("magic_desk:"): + return False + if not msg.ftm_content: return False searchable = msg.ft_app_searchable or "" if not searchable.startswith("magic_desk/"): diff --git a/flexus_client_kit/integrations/fi_messenger.py b/flexus_client_kit/integrations/fi_messenger.py index 8bc6732c..a1923b9c 100644 --- a/flexus_client_kit/integrations/fi_messenger.py +++ b/flexus_client_kit/integrations/fi_messenger.py @@ -53,7 +53,7 @@ def accept_outside_messages_only_to_expert(self, fexp_name: str): async def handle_emessage(self, emsg: ckit_bot_query.FExternalMessageOutput) -> None: raise NotImplementedError - async def look_assistant_might_have_posted_something(self, msg: ckit_ask_model.FThreadMessageOutput) -> bool: + async def look_assistant_or_fuser_might_have_posted(self, msg: ckit_ask_model.FThreadMessageOutput) -> bool: raise NotImplementedError async def close(self) -> None: diff --git a/flexus_client_kit/integrations/fi_slack.py b/flexus_client_kit/integrations/fi_slack.py index 355f4e66..e93989d4 100644 --- a/flexus_client_kit/integrations/fi_slack.py +++ b/flexus_client_kit/integrations/fi_slack.py @@ -583,8 +583,10 @@ async def post_into_captured_thread_as_user(self, a: ActivitySlack, channel_id: logger.info("Captured slack->db ft_id=%s ft_app_searchable=%s sending=%d parts", ft_id, searchable, len(content)) return True - async def look_assistant_might_have_posted_something(self, msg: ckit_ask_model.FThreadMessageOutput) -> bool: - if msg.ftm_role != "assistant": + async def look_assistant_or_fuser_might_have_posted(self, msg: ckit_ask_model.FThreadMessageOutput) -> bool: + if msg.ftm_role not in ("assistant", "user"): + return False + if msg.ftm_role == "user" and (msg.ftm_author_label1 or "").startswith("slack:"): return False if not msg.ftm_content: return False @@ -613,12 +615,14 @@ async def look_assistant_might_have_posted_something(self, msg: ckit_ask_model.F if not self.web_client: return False - text = msg.ftm_content + text = fi_messenger.ftm_content_to_text(msg.ftm_content) + if not text: + return False if "TASK_COMPLETED" in text and len(text) <= len("TASK_COMPLETED") + 6: - logger.info("look_assistant_might_have_posted_something: ftm_content has TASK_COMPLETED, not posting to slack") + logger.info("look_assistant_or_fuser_might_have_posted: ftm_content has TASK_COMPLETED, not posting to slack") return False if "NOTHING_TO_SAY" in text and len(text) <= len("NOTHING_TO_SAY") + 6: - logger.info("look_assistant_might_have_posted_something: ftm_content has NOTHING_TO_SAY, not posting to slack") + logger.info("look_assistant_or_fuser_might_have_posted: ftm_content has NOTHING_TO_SAY, not posting to slack") return False text = text.replace("TASK_COMPLETED", "") text = text.replace("NOTHING_TO_SAY", "") @@ -656,7 +660,7 @@ async def look_assistant_might_have_posted_something(self, msg: ckit_ask_model.F await ckit_ask_model.thread_app_capture_patch(http, msg.ftm_belongs_to_ft_id, ft_app_specific=json.dumps({ "last_posted_assistant_ts": msg.ftm_created_ts })) - logger.info("/look_assistant_might_have_posted_something() success") + logger.info("/look_assistant_or_fuser_might_have_posted() success") return True async def load_workspace_maps(self): diff --git a/flexus_client_kit/integrations/fi_telegram.py b/flexus_client_kit/integrations/fi_telegram.py index ec4935ef..46249615 100644 --- a/flexus_client_kit/integrations/fi_telegram.py +++ b/flexus_client_kit/integrations/fi_telegram.py @@ -433,8 +433,12 @@ async def post_into_captured_thread_as_user(self, activity: ActivityTelegram) -> activity.message_author_name, activity.message_author_id, msg_text[:120] or "(empty)") return True - async def look_assistant_might_have_posted_something(self, msg: ckit_ask_model.FThreadMessageOutput) -> bool: - if msg.ftm_role != "assistant" or not msg.ftm_content: + async def look_assistant_or_fuser_might_have_posted(self, msg: ckit_ask_model.FThreadMessageOutput) -> bool: + if msg.ftm_role not in ("assistant", "user"): + return False + if msg.ftm_role == "user" and (msg.ftm_author_label1 or "").startswith("telegram:"): + return False + if not msg.ftm_content: return False searchable = msg.ft_app_searchable or "" @@ -458,18 +462,17 @@ async def look_assistant_might_have_posted_something(self, msg: ckit_ask_model.F chat_id = int(searchable[len("telegram/"):]) except ValueError: return False - if not isinstance(msg.ftm_content, str): - logger.warning("telegram look_assistant_might_have_posted_something: ftm_content is not a string: %r" % msg.ftm_content) - return False if not self.tg_app: return False - text = msg.ftm_content + text = fi_messenger.ftm_content_to_text(msg.ftm_content) + if not text: + return False if "TASK_COMPLETED" in text and len(text) <= len("TASK_COMPLETED") + 6: - logger.info("telegram look_assistant_might_have_posted_something: ftm_content has TASK_COMPLETED, not posting to the captured chat") + logger.info("telegram look_assistant_or_fuser_might_have_posted: ftm_content has TASK_COMPLETED, not posting to the captured chat") return False if "NOTHING_TO_SAY" in text and len(text) <= len("NOTHING_TO_SAY") + 6: - logger.info("telegram look_assistant_might_have_posted_something: ftm_content has NOTHING_TO_SAY, not posting to the captured chat") + logger.info("telegram look_assistant_or_fuser_might_have_posted: ftm_content has NOTHING_TO_SAY, not posting to the captured chat") return False text = text.replace("TASK_COMPLETED", "") # yes, sometimes the model writes it anyway text = text.replace("NOTHING_TO_SAY", "") diff --git a/flexus_simple_bots/karen/karen_bot.py b/flexus_simple_bots/karen/karen_bot.py index d38b3968..ff0ee664 100644 --- a/flexus_simple_bots/karen/karen_bot.py +++ b/flexus_simple_bots/karen/karen_bot.py @@ -61,6 +61,7 @@ "skills", "flexus_policy_document", "print_widget", + "escalate_to_human", "erp[meta, data, crud, csv_import]", "crm[contact_info, manage_deal, verify_email]", "magic_desk", diff --git a/flexus_simple_bots/karen/karen_install.py b/flexus_simple_bots/karen/karen_install.py index eaad33c3..25f70405 100644 --- a/flexus_simple_bots/karen/karen_install.py +++ b/flexus_simple_bots/karen/karen_install.py @@ -41,6 +41,7 @@ "product_catalog", "shopify_cart", "crm_contact_info", "verify_email", "email_reply", + "escalate_to_human", "magic_desk", "slack", "telegram", "discord", } | ckit_cloudtool.KANBAN_PUBLIC | ckit_cloudtool.CLOUDTOOLS_VECDB | ckit_cloudtool.CLOUDTOOLS_MCP diff --git a/flexus_simple_bots/karen/karen_prompts.py b/flexus_simple_bots/karen/karen_prompts.py index 3f49ec60..e9e309f6 100644 --- a/flexus_simple_bots/karen/karen_prompts.py +++ b/flexus_simple_bots/karen/karen_prompts.py @@ -153,7 +153,7 @@ * Don't reveal task IDs, budget, internal processes * Disclose your AI nature at the start of the conversation. Never pretend to be human. * Never give legal/medical/financial advice, guarantee outcomes, collect SSN/passwords, or use high-pressure tactics -* Escalate to a human on: legal/fraud mentions, cancellation/refund requests, explicit requests for a human, or if frustration is obvious +* Escalate to a human via escalate_to_human(reason=...) on: legal/fraud mentions, cancellation/refund requests, explicit requests for a human, or if frustration is obvious — then stop acting on the thread You handle support (existing customers with questions) and sales (prospects exploring the product). Detect which from context. diff --git a/flexus_simple_bots/telegram_groupmod/telegram_groupmod_bot.py b/flexus_simple_bots/telegram_groupmod/telegram_groupmod_bot.py index f2f097fa..3ef29db5 100644 --- a/flexus_simple_bots/telegram_groupmod/telegram_groupmod_bot.py +++ b/flexus_simple_bots/telegram_groupmod/telegram_groupmod_bot.py @@ -456,7 +456,7 @@ async def handle_mongo_store(toolcall: ckit_cloudtool.FCloudtoolCall, args: Dict @rcx.on_updated_message async def handle_message(msg): if tg: - await tg.look_assistant_might_have_posted_something(msg) + await tg.look_assistant_or_fuser_might_have_posted(msg) @rcx.on_emessage("TELEGRAM") async def handle_emessage(emsg):