mirror of
https://github.com/hansjone/oclaw.git
synced 2026-10-12 00:10:44 +08:00
Send throttled WhatsApp progress during long tool turns.
Wire channel inbound on_progress/on_tool_ui to outbound queue so group users see wait signals for CLI/UME work without sharing per-speaker session context. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
parent
d245e463c0
commit
0ab5ab8c5a
6 changed files with 497 additions and 2 deletions
|
|
@ -915,8 +915,9 @@ def _enqueue_whatsapp_inbound_reply(
|
|||
reply_text: str,
|
||||
reply_attachments: list[dict[str, Any]] | None,
|
||||
reply_metadata: dict[str, Any] | None,
|
||||
kind: str = "inbound_reply",
|
||||
) -> str:
|
||||
"""Persist final WhatsApp reply on the outbound queue (sidecar poller delivers)."""
|
||||
"""Persist WhatsApp reply/progress on the outbound queue (sidecar poller delivers)."""
|
||||
from runtime.scheduler.whatsapp_mentions import encode_whatsapp_outbound_source
|
||||
|
||||
text = str(reply_text or "").strip()
|
||||
|
|
@ -948,7 +949,7 @@ def _enqueue_whatsapp_inbound_reply(
|
|||
if str(meta.get(k) or "").strip() or meta.get(k) is True
|
||||
}
|
||||
source = encode_whatsapp_outbound_source(
|
||||
kind="inbound_reply",
|
||||
kind=str(kind or "inbound_reply").strip() or "inbound_reply",
|
||||
mention_jids=mention_jids,
|
||||
mention_names=mention_names,
|
||||
mention_text_ready=bool(mention_jids),
|
||||
|
|
@ -1436,6 +1437,41 @@ def process_inbound_payload(payload: dict[str, Any]) -> dict[str, Any]:
|
|||
)
|
||||
job_lang = str(turn_job.get("lang") or lang or "zh")
|
||||
job_inbound = turn_job.get("inbound") or inbound
|
||||
progress_cb = None
|
||||
tool_ui_cb = None
|
||||
if wa_queue_delivery:
|
||||
from runtime.application.gateway.whatsapp_progress import (
|
||||
WhatsappTurnProgressPublisher,
|
||||
whatsapp_turn_progress_enabled,
|
||||
)
|
||||
|
||||
if whatsapp_turn_progress_enabled():
|
||||
|
||||
def _enqueue_progress(
|
||||
progress_text: str,
|
||||
progress_meta: dict[str, Any] | None,
|
||||
*,
|
||||
_inbound: Any = job_inbound,
|
||||
) -> None:
|
||||
_enqueue_whatsapp_inbound_reply(
|
||||
store,
|
||||
inbound=_inbound,
|
||||
account_id=account_id,
|
||||
tenant_id=str(tenant_id or ""),
|
||||
reply_text=progress_text,
|
||||
reply_attachments=None,
|
||||
reply_metadata=progress_meta,
|
||||
kind="inbound_progress",
|
||||
)
|
||||
|
||||
publisher = WhatsappTurnProgressPublisher(
|
||||
enqueue=_enqueue_progress,
|
||||
lang=job_lang,
|
||||
is_group=bool(getattr(job_inbound, "is_group", False)),
|
||||
inbound=job_inbound,
|
||||
)
|
||||
progress_cb = publisher.on_progress
|
||||
tool_ui_cb = publisher.on_tool_ui
|
||||
try:
|
||||
turn_result = gw.handle_turn(
|
||||
msg=StandardMessage(
|
||||
|
|
@ -1451,6 +1487,8 @@ def process_inbound_payload(payload: dict[str, Any]) -> dict[str, Any]:
|
|||
lang=job_lang,
|
||||
executor=manager,
|
||||
specialist_executor_factory=specialist_factory,
|
||||
on_progress=progress_cb,
|
||||
on_tool_ui=tool_ui_cb,
|
||||
)
|
||||
channel_turn_uuid = str(turn_result.turn_uuid or "").strip()
|
||||
turn_reply = str(turn_result.reply_text or "").strip()
|
||||
|
|
|
|||
227
runtime/application/gateway/whatsapp_progress.py
Normal file
227
runtime/application/gateway/whatsapp_progress.py
Normal file
|
|
@ -0,0 +1,227 @@
|
|||
"""Rate-limited WhatsApp interim progress during long channel turns."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import re
|
||||
import threading
|
||||
import time
|
||||
from typing import Any, Callable
|
||||
|
||||
_TOOLS_DONE_RE = re.compile(r"tools done\s*\((\d+)\s*ms\)", re.IGNORECASE)
|
||||
|
||||
# Tools that commonly exceed ~10s on production WA turns.
|
||||
_LONG_TOOL_LABELS_ZH: dict[str, str] = {
|
||||
"execmanagedne": "正在设备上执行命令,请稍候…",
|
||||
"listclitargets": "正在枚举 CLI 目标,请稍候…",
|
||||
"umealarmxlsxreport": "正在生成告警 Excel,请稍候…",
|
||||
"queryumealarms": "正在查询 UME 告警,请稍候…",
|
||||
"queryumealarmsraw": "正在查询 UME 告警,请稍候…",
|
||||
"aggregateumealarms": "正在汇总 UME 告警,请稍候…",
|
||||
"aggregateumealarmsraw": "正在汇总 UME 告警,请稍候…",
|
||||
"runumediagnostics": "正在跑 UME 诊断,请稍候…",
|
||||
"sqlqueryume": "正在执行 UME SQL,请稍候…",
|
||||
"findtopologypaths": "正在查拓扑路径,请稍候…",
|
||||
"writexlsx": "正在写 Excel,请稍候…",
|
||||
"getmanagedne": "正在读取纳管网元信息,请稍候…",
|
||||
"getumene": "正在读取 UME 网元详情,请稍候…",
|
||||
}
|
||||
|
||||
_LONG_TOOL_LABELS_EN: dict[str, str] = {
|
||||
"execmanagedne": "Running device CLI, please wait…",
|
||||
"listclitargets": "Listing CLI targets, please wait…",
|
||||
"umealarmxlsxreport": "Building alarm Excel, please wait…",
|
||||
"queryumealarms": "Querying UME alarms, please wait…",
|
||||
"queryumealarmsraw": "Querying UME alarms, please wait…",
|
||||
"aggregateumealarms": "Aggregating UME alarms, please wait…",
|
||||
"aggregateumealarmsraw": "Aggregating UME alarms, please wait…",
|
||||
"runumediagnostics": "Running UME diagnostics, please wait…",
|
||||
"sqlqueryume": "Running UME SQL, please wait…",
|
||||
"findtopologypaths": "Finding topology paths, please wait…",
|
||||
"writexlsx": "Writing Excel, please wait…",
|
||||
"getmanagedne": "Loading managed NE, please wait…",
|
||||
"getumene": "Loading UME NE detail, please wait…",
|
||||
}
|
||||
|
||||
|
||||
def whatsapp_turn_progress_enabled() -> bool:
|
||||
raw = str(os.environ.get("OCLAW_WHATSAPP_TURN_PROGRESS") or "").strip().lower()
|
||||
if not raw:
|
||||
return True
|
||||
return raw not in {"0", "false", "no", "off"}
|
||||
|
||||
|
||||
def progress_min_interval_sec() -> float:
|
||||
raw = str(os.environ.get("OCLAW_WHATSAPP_PROGRESS_MIN_INTERVAL_SEC") or "").strip()
|
||||
try:
|
||||
return max(3.0, min(float(raw), 120.0))
|
||||
except Exception:
|
||||
return 12.0
|
||||
|
||||
|
||||
def normalize_tool_key(name: str) -> str:
|
||||
s = str(name or "").strip()
|
||||
if not s:
|
||||
return ""
|
||||
low = s.lower()
|
||||
if low.startswith("mcp__"):
|
||||
# mcp__netx__execManagedNe -> execmanagedne
|
||||
parts = low.split("__")
|
||||
if len(parts) >= 3:
|
||||
low = parts[-1]
|
||||
elif low.startswith("netx_"):
|
||||
low = low[len("netx_") :]
|
||||
return low.replace("_", "")
|
||||
|
||||
|
||||
def humanize_long_tool(*, tool_name: str, lang: str = "zh") -> str | None:
|
||||
key = normalize_tool_key(tool_name)
|
||||
if not key:
|
||||
return None
|
||||
table = _LONG_TOOL_LABELS_EN if str(lang or "").startswith("en") else _LONG_TOOL_LABELS_ZH
|
||||
# Keys in tables are already underscore-free lower names.
|
||||
return table.get(key)
|
||||
|
||||
|
||||
def should_forward_progress_text(text: str) -> bool:
|
||||
"""Filter noisy think/retry ticks; keep meaningful wait signals."""
|
||||
t = str(text or "").strip()
|
||||
if not t:
|
||||
return False
|
||||
low = t.lower()
|
||||
if low in {"oclaw: running…", "oclaw: running...", "oclaw: finalize…", "oclaw: finalize..."}:
|
||||
return False
|
||||
if "think (" in low or low.startswith("oclaw: think"):
|
||||
return False
|
||||
if "retry-empty" in low or "retry-native-tool-calls" in low:
|
||||
return False
|
||||
m = _TOOLS_DONE_RE.search(t)
|
||||
if m:
|
||||
try:
|
||||
return int(m.group(1)) >= 8000
|
||||
except Exception:
|
||||
return False
|
||||
# Specialist / other explicit progress lines
|
||||
if low.startswith("oclaw:"):
|
||||
return True
|
||||
return len(t) >= 8
|
||||
|
||||
|
||||
def humanize_progress_text(*, text: str, lang: str = "zh") -> str:
|
||||
t = str(text or "").strip()
|
||||
m = _TOOLS_DONE_RE.search(t)
|
||||
if m:
|
||||
if str(lang or "").startswith("en"):
|
||||
return "Tools finished; composing the reply…"
|
||||
return "工具已完成,正在整理回复…"
|
||||
if t.lower().startswith("oclaw:"):
|
||||
body = t.split(":", 1)[-1].strip()
|
||||
if str(lang or "").startswith("en"):
|
||||
return body or t
|
||||
# Keep short Chinese-friendly wait copy for unknown oclaw:* lines.
|
||||
return f"处理中:{body}" if body else "处理中,请稍候…"
|
||||
return t
|
||||
|
||||
|
||||
def build_whatsapp_group_progress_metadata(*, inbound: Any) -> dict[str, Any]:
|
||||
"""@sender without quoting (avoids sticky quote spam on interim ticks)."""
|
||||
from runtime.orchestration.group_ingest import build_whatsapp_group_reply_metadata
|
||||
|
||||
meta = build_whatsapp_group_reply_metadata(inbound=inbound)
|
||||
for key in (
|
||||
"quote_remote_jid",
|
||||
"quote_stanza_id",
|
||||
"quote_participant",
|
||||
"quote_text",
|
||||
"quote_push_name",
|
||||
):
|
||||
meta.pop(key, None)
|
||||
return meta
|
||||
|
||||
|
||||
class WhatsappTurnProgressPublisher:
|
||||
"""Enqueue throttled interim WhatsApp texts during a channel turn."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
enqueue: Callable[[str, dict[str, Any] | None], None],
|
||||
lang: str = "zh",
|
||||
is_group: bool = False,
|
||||
inbound: Any = None,
|
||||
min_interval_sec: float | None = None,
|
||||
enabled: bool | None = None,
|
||||
clock: Callable[[], float] | None = None,
|
||||
) -> None:
|
||||
self._enqueue = enqueue
|
||||
self._lang = str(lang or "zh")
|
||||
self._is_group = bool(is_group)
|
||||
self._inbound = inbound
|
||||
self._min_interval = float(min_interval_sec if min_interval_sec is not None else progress_min_interval_sec())
|
||||
self._enabled = bool(whatsapp_turn_progress_enabled() if enabled is None else enabled)
|
||||
self._clock = clock or time.monotonic
|
||||
self._lock = threading.Lock()
|
||||
self._last_sent_at = 0.0
|
||||
self._last_text = ""
|
||||
self._sent_count = 0
|
||||
|
||||
@property
|
||||
def sent_count(self) -> int:
|
||||
return int(self._sent_count)
|
||||
|
||||
def on_progress(self, text: str) -> None:
|
||||
if not self._enabled:
|
||||
return
|
||||
if not should_forward_progress_text(text):
|
||||
return
|
||||
msg = humanize_progress_text(text=text, lang=self._lang)
|
||||
self._maybe_send(msg)
|
||||
|
||||
def on_tool_ui(self, event: str, payload: dict[str, Any] | None) -> None:
|
||||
if not self._enabled:
|
||||
return
|
||||
if str(event or "").strip() != "tool_use_call":
|
||||
return
|
||||
pl = payload if isinstance(payload, dict) else {}
|
||||
label = humanize_long_tool(tool_name=str(pl.get("tool_name") or ""), lang=self._lang)
|
||||
if not label:
|
||||
return
|
||||
self._maybe_send(label)
|
||||
|
||||
def _reply_metadata(self) -> dict[str, Any] | None:
|
||||
if not self._is_group or self._inbound is None:
|
||||
return None
|
||||
try:
|
||||
return build_whatsapp_group_progress_metadata(inbound=self._inbound)
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
def _maybe_send(self, text: str) -> None:
|
||||
msg = str(text or "").strip()
|
||||
if not msg:
|
||||
return
|
||||
with self._lock:
|
||||
now = float(self._clock())
|
||||
if msg == self._last_text and self._sent_count > 0:
|
||||
return
|
||||
if self._sent_count > 0 and (now - self._last_sent_at) < self._min_interval:
|
||||
return
|
||||
try:
|
||||
self._enqueue(msg, self._reply_metadata())
|
||||
except Exception:
|
||||
return
|
||||
self._last_sent_at = now
|
||||
self._last_text = msg
|
||||
self._sent_count += 1
|
||||
|
||||
|
||||
__all__ = [
|
||||
"WhatsappTurnProgressPublisher",
|
||||
"build_whatsapp_group_progress_metadata",
|
||||
"humanize_long_tool",
|
||||
"humanize_progress_text",
|
||||
"normalize_tool_key",
|
||||
"progress_min_interval_sec",
|
||||
"should_forward_progress_text",
|
||||
"whatsapp_turn_progress_enabled",
|
||||
]
|
||||
Loading…
Add table
Add a link
Reference in a new issue