From 0d9c2157036bdc9a1c79e931217b1eeaca77bebb Mon Sep 17 00:00:00 2001 From: "carnie[bot]" Date: Sun, 5 Jul 2026 18:27:11 +0800 Subject: [PATCH 01/25] feat(slack): native Thinking Steps task cards for tool progress (opt-in) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Renders tool-call progress on Slack as native task cards (chat.startStream/appendStream/stopStream with task_update chunks) instead of markdown progress bubbles. Opt-in via display.platforms.slack.tool_progress_native; strictly additive. - gateway/slack_task_stream.py (new): SlackTaskStream lifecycle class + pure presentation helpers (labels, categories, output previews, completion summaries, source chips, MCP text-error sniff) - gateway/run.py: wiring β€” construct stream when flag on, route tool/subagent/reasoning events, drain futures + stop in finally - gateway/display_config.py: tool_progress_native (bool, default off) + tool_progress_native_mode (plan|timeline|dense, default plan) Landmines discovered live (docs claim these are optional β€” they are not): recipient_team_id and recipient_user_id are required for bot-token streams; task_update REPLACES the card wholesale (omitted fields vanish). Co-authored-by: Minh Nguyen --- gateway/display_config.py | 14 + gateway/run.py | 303 ++++++++++++++++ gateway/slack_task_stream.py | 657 +++++++++++++++++++++++++++++++++++ 3 files changed, 974 insertions(+) create mode 100644 gateway/slack_task_stream.py diff --git a/gateway/display_config.py b/gateway/display_config.py index b7d957a8f6cd..61ea8c2ced9b 100644 --- a/gateway/display_config.py +++ b/gateway/display_config.py @@ -33,6 +33,16 @@ _GLOBAL_DEFAULTS: dict[str, Any] = { "tool_progress": "all", "tool_progress_grouping": "accumulate", # "accumulate" = edit one bubble; "separate" = one msg per tool + # Opt-in: render tool progress as Slack's native "Thinking Steps" task + # cards (chat.startStream/appendStream/stopStream) instead of markdown + # progress bubbles. Only takes effect on Slack; every other platform + # ignores this key. Off by default β€” additive, not a behavior change. + "tool_progress_native": False, + # Card layout for native task cards: "plan" groups all tasks into one + # collapsible card (default β€” reads cleanest, prose lands below it), + # "timeline" gives each task its own separate card block, "dense" + # collapses consecutive tool calls. Fixed at stream start (Slack limit). + "tool_progress_native_mode": "plan", "show_reasoning": False, # How a reasoning/thinking summary is rendered when show_reasoning is on. # "code" -> πŸ’­ **Reasoning:** + fenced code block (legacy default) @@ -262,6 +272,7 @@ def _normalise(setting: str, value: Any) -> Any: "busy_ack_detail", "busy_steer_ack_enabled", "thinking_progress", + "tool_progress_native", }: if isinstance(value, str): val = value.strip().lower() @@ -288,6 +299,9 @@ def _normalise(setting: str, value: Any) -> Any: if setting == "tool_progress_grouping": val = str(value).lower() return val if val in ("accumulate", "separate") else "accumulate" + if setting == "tool_progress_native_mode": + val = str(value).lower() + return val if val in ("plan", "timeline", "dense") else "plan" if setting == "reasoning_style": val = str(value).lower() return val if val in ("code", "blockquote", "subtext") else "code" diff --git a/gateway/run.py b/gateway/run.py index f59e006f6249..ff9459a4436c 100644 --- a/gateway/run.py +++ b/gateway/run.py @@ -524,6 +524,29 @@ def _resolve_progress_thread_id(platform: Any, source_thread_id: Any, event_mess return None +def _resolve_slack_native_task_cards( + user_config: dict, + platform_key: str, + platform: Any, + tool_progress_enabled: bool, + thread_id: Optional[str], +) -> bool: + """True when Slack-native "Thinking Steps" task cards should replace the + markdown tool-progress bubbles for this turn. + + Opt-in (``display.platforms.slack.tool_progress_native``) and strictly + additive: requires tool progress to already be enabled, the platform to + be Slack, and a thread target (``chat.startStream`` requires + ``thread_ts``). Every other platform, and Slack installs that don't set + the flag, are unaffected. + """ + from gateway.config import Platform + if not tool_progress_enabled or platform != Platform.SLACK or not thread_id: + return False + from gateway.display_config import resolve_display_setting + return bool(resolve_display_setting(user_config, platform_key, "tool_progress_native")) + + def _has_platform_display_override(user_config: dict, platform_key: str, setting: str) -> bool: """Return True when display.platforms. explicitly sets setting.""" display = user_config.get("display") if isinstance(user_config, dict) else None @@ -19190,6 +19213,34 @@ def progress_callback(event_type: str, tool_name: str = None, preview: str = Non if not progress_queue or not _run_still_current(): return + # Slack-native task cards: route tool.started/tool.completed onto + # the SlackTaskStream INSTEAD of the markdown progress bubbles. + # MUST run before the onboarding-hint block below β€” that block + # unconditionally returns on every tool.completed event (the + # markdown path only renders tool.started), which would swallow + # completions and leave every card stuck at in_progress (Slack + # then closes the stream as "Something went wrong"). Any + # unexpected exception falls through to markdown (never + # propagate: this callback runs on every tool event). + try: + if ( + _slack_native_cards + and tool_progress_enabled + and _slack_task_stream is not None + and not _slack_task_stream.disabled + ): + if event_type in {"tool.started", "tool.completed"}: + _slack_task_event(event_type, tool_name, preview, args, kwargs) + return + if event_type in {"subagent.start", "subagent.tool", "subagent.complete"}: + _slack_subagent_event(event_type, tool_name, preview, kwargs) + return + except Exception: + logger.warning( + "Slack native task-card event failed; falling back to markdown progress", + exc_info=True, + ) + # First-touch onboarding: the first time a tool takes longer than # _LONG_TOOL_THRESHOLD_S during a run that's streaming every tool # (progress_mode == "all"), append a one-time hint suggesting @@ -19414,6 +19465,231 @@ def progress_callback(event_type: str, tool_name: str = None, preview: str = Non else None ) + # Slack-native "Thinking Steps" task cards (opt-in, additive β€” see + # gateway/slack_task_stream.py). When enabled, tool-start/finish + # events are routed to a SlackTaskStream instead of the markdown + # progress bubbles built above. Constructed lazily on the first tool + # event so a turn with no tool calls never opens a stream. + # + # SAFETY: this block executes on EVERY turn once the flag is on for a + # channel, so it must be impossible for it to raise β€” an exception + # here crash-loops the whole thread (2026-07-05 incident: an + # UnboundLocalError in this block took Slack down until a manual + # restore). Everything is wrapped; on any failure we fall back to the + # markdown progress path. + _slack_native_cards = False + _slack_task_stream = None # type: Optional["SlackTaskStream"] + try: + _slack_native_cards = _resolve_slack_native_task_cards( + user_config, platform_key, source.platform, tool_progress_enabled, _progress_thread_id, + ) + if _slack_native_cards: + _slack_adapter = self.adapters.get(source.platform) + _slack_client = getattr(_slack_adapter, "_get_client", None) + if callable(_slack_client): + try: + _slack_client = _slack_client(source.chat_id) + except Exception: + _slack_client = None + else: + _slack_client = None + if _slack_client is not None: + from gateway.slack_task_stream import SlackTaskStream + # Slack requires recipient_team_id for bot-token streams + # (missing_recipient_team_id otherwise). Resolution order: + # 1. adapter's chatβ†’workspace map (only populated on the + # assistant-thread metadata path, so often empty for + # plain channel messages), + # 2. the adapter's registered workspaces β€” when exactly + # one bot token is connected its team_id is + # unambiguous (the common single-workspace install), + # 3. the inbound message's workspace scope, if set. + # NOTE: _slack_adapter (fetched above) is the only + # adapter handle in scope here β€” _status_adapter is not + # assigned until later in this function and referencing + # it raises UnboundLocalError. + _team_clients = getattr(_slack_adapter, "_team_clients", None) or {} + _stream_team_id = ( + (getattr(_slack_adapter, "_channel_team", None) or {}).get(source.chat_id) + or (next(iter(_team_clients)) if len(_team_clients) == 1 else None) + or getattr(source, "scope_id", None) + ) + _slack_task_stream = SlackTaskStream( + _slack_client, source.chat_id, str(_progress_thread_id), + task_display_mode=str( + resolve_display_setting( + user_config, platform_key, "tool_progress_native_mode", + ) or "plan" + ), + recipient_team_id=_stream_team_id, + recipient_user_id=getattr(source, "user_id", None), + ) + else: + _slack_native_cards = False + except Exception: + logger.warning( + "Slack native task-card setup failed; falling back to markdown progress", + exc_info=True, + ) + _slack_native_cards = False + _slack_task_stream = None + # Monotonic per-turn tool-call index for correlating task_started with + # task_finished on the same SlackTaskStream card (mirrors + # stream_events.ToolCallChunk.index). Tool completions don't carry the + # index the start event got, so track a FIFO queue of assigned indices + # per tool name β€” good enough for the common case of at most one + # in-flight call per tool name; parallel duplicate-name calls may pair + # slightly out of order, which only affects which card updates first. + _slack_task_index = [0] + _slack_task_pending: Dict[str, List[int]] = {} + # Futures for scheduled card updates, drained before stop(): the + # events are fire-and-forget coroutines, so without an explicit + # drain the turn's finally can call chat.stopStream while the last + # task_finished updates are still queued β€” Slack then renders the + # stuck-in_progress tasks with warning icons. + _slack_task_futures: List[Any] = [] + + def _slack_task_event(event_type: str, tool_name: str, preview, args, kwargs) -> None: + """Route a tool lifecycle event onto the Slack native task stream. + + Presentation logic (labels, previews, summaries, sources, error + sniffing) lives in gateway.slack_task_stream helpers; this + function only correlates events and schedules the coroutines. + Each helper call is individually guarded β€” a presentation bug + must never lose the card update itself. + """ + if _slack_task_stream is None or _slack_task_stream.disabled: + return + from gateway import slack_task_stream as _sts + if event_type == "tool.started": + index = _slack_task_index[0] + _slack_task_index[0] += 1 + _slack_task_pending.setdefault(tool_name, []).append(index) + # Content-bearing tools (write_file/patch/execute_code) get a + # snippet of their payload in the collapsible card body. + try: + _details = _sts.tool_details_from_args(tool_name, args) + except Exception: + _details = None + _fut = safe_schedule_threadsafe( + _slack_task_stream.task_started(index, tool_name, preview, details=_details), + _voice_ack_loop, + logger=logger, + log_message="slack task_started scheduling error", + ) + if _fut is not None: + _slack_task_futures.append(_fut) + elif event_type == "tool.completed": + pending = _slack_task_pending.get(tool_name) + index = pending.pop(0) if pending else 0 + duration = kwargs.get("duration") or 0.0 + ok = not kwargs.get("is_error", False) + _result = kwargs.get("result") + # Short result preview for the expanded card body β€” unwrap + # the JSON envelope + collapse whitespace so it reads as a + # glanceable summary, not a raw {"output": "..."} dump. + try: + output = _sts.clean_output_preview(_result, limit=300) + except Exception: + output = _result.strip()[:300] if isinstance(_result, str) and _result.strip() else None + # MCP tools report failures as result TEXT with is_error + # False β€” sniff those so a 403 doesn't render with a βœ“. + if ok and _sts.result_looks_like_error(_result): + ok = False + # A descriptive completion title ("Web search β†’ 5 results + # for X") beats the raw arg echo. Falls back to the start + # title when the summarizer has nothing better. + try: + _summary = _sts.summarize_tool_title(tool_name, args, _result) + except Exception: + _summary = None + # Clickable URL chips for web tools (search hits, fetched + # pages) via the task_update ``sources`` field. + try: + _sources = _sts.tool_sources(tool_name, args, _result) + except Exception: + _sources = None + _fut = safe_schedule_threadsafe( + _slack_task_stream.task_finished( + index, tool_name, duration, ok, + output=output, sources=_sources, summary=_summary, + ), + _voice_ack_loop, + logger=logger, + log_message="slack task_finished scheduling error", + ) + if _fut is not None: + _slack_task_futures.append(_fut) + + def _slack_subagent_event(event_type: str, tool_name, preview, kwargs) -> None: + """Route delegate_task child lifecycle events onto subagent cards. + + Relayed by _build_child_progress_callback with identity kwargs + (subagent_id, goal, task_index...). tool_name carries the child's + tool for subagent.tool events. + """ + if _slack_task_stream is None or _slack_task_stream.disabled: + return + key = str( + kwargs.get("subagent_id") + or kwargs.get("task_index") + or "0" + ) + # Stable 1-based number for the card ("#1", "#2"): task_index is + # 0-based in a batch; +1 for display. Falls back to None (no + # number shown) when the relay didn't carry a task_index. + _ti = kwargs.get("task_index") + number = (_ti + 1) if isinstance(_ti, int) else None + goal = str(kwargs.get("goal") or preview or "") + ok = "error" not in str(kwargs.get("status") or "").lower() + _fut = safe_schedule_threadsafe( + _slack_task_stream.subagent_event( + event_type, key, goal=goal, tool_name=tool_name, ok=ok, number=number, + ), + _voice_ack_loop, + logger=logger, + log_message="slack subagent card scheduling error", + ) + if _fut is not None: + _slack_task_futures.append(_fut) + + # Reasoning β†’ card header: throttle raw reasoning deltas into a + # rolling "πŸ’­ " plan_update. Deltas arrive token-by- + # token and plan_update replaces the header wholesale, so we buffer + # and flush at most once per interval, showing the tail line. + _slack_reasoning_buf = [""] + _slack_reasoning_last = [0.0] + _SLACK_REASONING_INTERVAL_S = 2.0 + + def _slack_reasoning_event(text: str) -> None: + """agent.reasoning_callback consumer (worker thread, sync).""" + if _slack_task_stream is None or _slack_task_stream.disabled: + return + if not isinstance(text, str) or not text: + return + _slack_reasoning_buf[0] = (_slack_reasoning_buf[0] + text)[-600:] + now = time.monotonic() + if now - _slack_reasoning_last[0] < _SLACK_REASONING_INTERVAL_S: + return + _slack_reasoning_last[0] = now + # Render the last sentence-ish fragment of the buffer. + tail = _slack_reasoning_buf[0].replace("\n", " ").strip() + for sep in (". ", "! ", "? "): + if sep in tail: + tail = tail.rsplit(sep, 1)[-1] + if not tail: + return + _fut = safe_schedule_threadsafe( + _slack_task_stream.reasoning_update(tail), + _voice_ack_loop, + logger=logger, + log_message="slack reasoning_update scheduling error", + ) + # Tracked like task updates so the turn's finally drains it β€” + # an undrained reasoning append can race chat.stopStream. + if _fut is not None: + _slack_task_futures.append(_fut) + async def write_tool_log(): """Drain log_queue and append tool-call lines to tool_calls.log. @@ -20365,6 +20641,12 @@ def _interim_assistant_cb(text: str, *, already_streamed: bool = False) -> None: ) else None ) + # Slack native task cards: stream reasoning deltas into the card + # header ("πŸ’­ latest thought"). Only wired when cards are active + # for this turn β€” reasoning_callback is otherwise unused by the + # gateway, so this is strictly additive. + if _slack_native_cards and _slack_task_stream is not None: + agent.reasoning_callback = _slack_reasoning_event # Discord voice verbal-ack hook (fires once per turn on first tool # call; armed only when in a voice channel with the mixer running). agent.tool_start_callback = ( @@ -21983,6 +22265,27 @@ def _stream_confirmed_final_delivery( interrupt_monitor.cancel() _notify_task.cancel() + # Close the Slack native task-card stream (if one was opened for + # this turn). First drain any still-queued card updates β€” + # task_finished coroutines are scheduled fire-and-forget, so + # calling chat.stopStream before they land freezes tasks at + # in_progress and Slack renders them with warning icons. Never + # raises β€” SlackTaskStream.stop() swallows its own errors so a + # failed chat.stopStream can't break delivery. + if _slack_task_stream is not None: + try: + # NOTE: await via wrap_future β€” these futures resolve ON + # this same event loop, so a blocking .result() here + # would deadlock the loop against its own queue. + for _fut in _slack_task_futures: + try: + await asyncio.wait_for(asyncio.wrap_future(_fut), timeout=5) + except Exception: + pass + await _slack_task_stream.stop() + except Exception: + logger.debug("SlackTaskStream.stop() failed", exc_info=True) + # Wait for stream consumer to finish its final edit if stream_task: # If the agent never created a stream consumer (e.g. non- diff --git a/gateway/slack_task_stream.py b/gateway/slack_task_stream.py new file mode 100644 index 000000000000..faf8569109c2 --- /dev/null +++ b/gateway/slack_task_stream.py @@ -0,0 +1,657 @@ +"""Slack-native "Thinking Steps" task-card streaming for tool progress. + +Slack's chat.startStream / chat.appendStream / chat.stopStream trio lets a +bot render tool-call progress as a native, collapsible task-card timeline +inside a message β€” the same UX Slack's own AI features use β€” instead of the +plain markdown text bubbles the gateway edits by default. This module wraps +that lifecycle for a single streaming message so gateway/run.py can drive it +with the same tool-start/tool-finish events it already emits for the +markdown progress path. + +This is opt-in (``display.platforms.slack.tool_progress_native``) and +strictly additive: every other platform, and Slack installs that don't set +the flag, keep the existing markdown progress-bubble behavior untouched. + +Reference: + * https://docs.slack.dev/reference/methods/chat.startStream + * https://slack.dev/slack-thinking-steps-ai-agents/ +""" + +from __future__ import annotations + +import asyncio +import json +import logging +import re +import time +from typing import Any, List, Optional +from urllib.parse import urlparse + +logger = logging.getLogger("gateway.slack_task_stream") + +# Per-tool presentation metadata: (friendly verb, coarse category). +# The verb mirrors OpenClaw's progress lines ("Exec β€” which gog && gog +# --help", "Read β€” foo.py") instead of repeating raw tool names; the +# category feeds the auto-generated turn header ("Searched Β· edited files Β· +# ran commands"). Unlisted tools fall back to their raw name / no category. +_TOOL_META: dict = { + "terminal": ("Exec", "ran commands"), + "process": ("Process", "ran commands"), + "execute_code": ("Run code", "ran commands"), + "read_file": ("Read", "read files"), + "search_files": ("Search files", "read files"), + "write_file": ("Write", "edited files"), + "patch": ("Edit", "edited files"), + "web_search": ("Web search", "searched"), + "web_extract": ("Fetch", "searched"), + "x_search": ("X search", "searched"), + "session_search": ("Recall", "searched"), + "browser_navigate": ("Browse", "browsed"), + "browser_click": ("Browse", "browsed"), + "browser_type": ("Browse", "browsed"), + "browser_snapshot": ("Browse", "browsed"), + "browser_vision": ("Browse", "browsed"), + "browser_scroll": ("Browse", "browsed"), + "delegate_task": ("Delegate", "delegated"), + "image_generate": ("Generate image", "generated images"), + "vision_analyze": ("Analyze image", "analyzed images"), + "text_to_speech": ("Speak", None), + "todo": ("Plan", "planned"), + "memory": ("Memory", "updated memory"), + "skill_view": ("Load skill", "loaded skills"), + "skills_list": ("List skills", "loaded skills"), + "clarify": ("Ask", None), + "cronjob": ("Schedule", None), +} + +# Tools whose primary argument is file content β€” surface a snippet of it in +# the card's collapsible ``details`` field, mirroring mixlayer's Molly bot +# (each Write renders as a collapsible card previewing the file body). +_CONTENT_ARG_BY_TOOL = { + "write_file": "content", + "patch": "new_string", + "execute_code": "code", +} + + +def tool_label(tool_name: str) -> str: + """Friendly verb for a tool ("terminal" β†’ "Exec"). Falls back to name.""" + meta = _TOOL_META.get(tool_name) + return meta[0] if meta else tool_name + + +def clean_output_preview(result: Any, limit: int = 300) -> Optional[str]: + """Turn a raw tool result into a compact, human-readable card preview. + + Tool results arrive as JSON-wrapped strings ({"output": "..."}, + {"result": "..."}), often with escaped newlines and a lot of bulk. The + card ``output`` field is for a glanceable summary, not a data dump β€” + unwrap the common envelopes, collapse whitespace, and keep the head. + """ + if result is None: + return None + text = result + if isinstance(text, str): + s = text.strip() + # Unwrap {"output": "..."} / {"result": "..."} / {"content": "..."} + if s.startswith("{") and s.endswith("}"): + try: + obj = json.loads(s) + if isinstance(obj, dict): + for k in ("output", "result", "content", "text", "stdout"): + v = obj.get(k) + if isinstance(v, str) and v.strip(): + s = v.strip() + break + except Exception: + pass + text = s + else: + text = str(text) + # Collapse escaped + real whitespace runs into single spaces. + text = text.replace("\\n", " ").replace("\\t", " ") + text = " ".join(text.split()) + if not text: + return None + if len(text) > limit: + text = text[: limit - 1].rstrip() + "…" + return text + + +def summarize_tool_title(tool_name: str, args: Any, result: Any) -> Optional[str]: + """A short, human phrase describing what a tool call accomplished. + + Used to give collapsible cards a descriptive title beyond the raw + argument echo (e.g. "Web search β€” 5 results for 'intel stock'" instead + of the query string alone). Returns None to fall back to the arg preview. + """ + if tool_name in {"web_search", "x_search"} and isinstance(result, str): + n = result.count("\"url\":") or result.count("http") + q = "" + if isinstance(args, dict): + q = str(args.get("query") or "").strip() + if n: + base = f"{n} results" + (f" for β€œ{q[:40]}”" if q else "") + return base + if tool_name == "web_extract" and isinstance(args, dict): + urls = args.get("urls") + if isinstance(urls, list) and urls: + try: + dom = urlparse(str(urls[0])).netloc + except Exception: + dom = str(urls[0]) + extra = f" +{len(urls)-1}" if len(urls) > 1 else "" + return f"{dom}{extra}" + if tool_name in {"read_file", "write_file", "patch"} and isinstance(args, dict): + path = args.get("path") or args.get("file_path") + if path: + return str(path).split("/")[-1] + if tool_name == "terminal": + clean = clean_output_preview(result, limit=90) + if clean: + return clean[:60] + return None + + +def tool_category(tool_name: str) -> Optional[str]: + """Human bucket for a tool ("web_search" β†’ "searched"), or None.""" + meta = _TOOL_META.get(tool_name) + return meta[1] if meta else None + + +def tool_details_from_args(tool_name: str, args: Any) -> Optional[str]: + """Optional collapsible-body preview extracted from the tool's args.""" + key = _CONTENT_ARG_BY_TOOL.get(tool_name) + if not key or not isinstance(args, dict): + return None + val = args.get(key) + if not isinstance(val, str) or not val.strip(): + return None + return val.strip()[:500] + + +# Tools whose results naturally carry URL attributions worth surfacing as +# clickable ``sources`` on the task card. +_SOURCE_TOOLS = { + "web_search", "web_extract", "x_search", "browser_navigate", + # image_generate returns a hosted result URL β€” chip links to the image. + "image_generate", +} +_URL_RE = re.compile(r"https?://[^\s'\"\)\]>,\\]+") + +# MCP tools return failures as result TEXT without raising (is_error stays +# False on the lifecycle event), so a 403 would render with a βœ“. Cheap sniff +# on the head of the result for common error shapes. +_ERROR_TEXT_RE = re.compile( + r'^\s*(?:\{"result":\s*")?(?:Error\b|\[?Error\]?[:\s]|HTTP (?:4|5)\d\d)' +) + + +def result_looks_like_error(result: Any) -> bool: + """True when a tool result *string* reads as an error despite ok status.""" + if not isinstance(result, str): + return False + head = result.strip()[:80] + return bool(head and _ERROR_TEXT_RE.match(head)) + + +def tool_sources(tool_name: str, args: Any, result_text: Any, limit: int = 3) -> Optional[List[dict]]: + """Build task_update ``sources`` entries (clickable URL chips) for a call. + + URLs come from the args (web_extract's url list, browser_navigate's + target) and from the result body (search hits). Deduped by URL, capped + at ``limit``, labeled with the domain. + """ + if tool_name not in _SOURCE_TOOLS: + return None + urls: List[str] = [] + if isinstance(args, dict): + arg_urls = args.get("urls") + if isinstance(arg_urls, list): + urls.extend(u for u in arg_urls if isinstance(u, str)) + for key in ("url", "image_url"): + val = args.get(key) + if isinstance(val, str) and val.startswith("http"): + urls.append(val) + if isinstance(result_text, str): + urls.extend(_URL_RE.findall(result_text)) + out: List[dict] = [] + seen = set() + for u in urls: + u = u.rstrip(".,;:!?") + if not u.startswith("http"): + continue + try: + domain = urlparse(u).netloc or u + except Exception: + domain = u + # Dedupe by domain, not full URL β€” two pages from the same site + # render as identical-looking chips (observed: doubled www.intc.com). + if domain in seen: + continue + seen.add(domain) + out.append({"type": "url", "text": domain[:60], "url": u}) + if len(out) >= limit: + break + return out or None + + +class SlackTaskStream: + """Manage the lifecycle of one Slack native task-card streaming message. + + One instance covers a single agent turn: ``ensure_started()`` opens the + stream lazily on the first tool event, ``task_started``/``task_finished`` + push ``task_update`` chunks keyed by a stable per-tool-call id, and + ``stop()`` closes the stream when the turn completes. + + All public methods swallow their own errors (log at debug) and flip + ``self.disabled`` on failure so the caller can fall back to the markdown + progress path for the rest of the turn instead of retrying a broken + stream on every subsequent event. + """ + + def __init__( + self, + client: Any, + channel: str, + thread_ts: str, + task_display_mode: str = "plan", + recipient_team_id: Optional[str] = None, + recipient_user_id: Optional[str] = None, + ) -> None: + self.client = client + self.channel = channel + self.thread_ts = thread_ts + self.recipient_team_id = recipient_team_id + self.recipient_user_id = recipient_user_id + self.task_display_mode = task_display_mode + self.ts: Optional[str] = None + self.disabled = False + self._started = False + self._stopped = False + # Turn stats for the final header title ("4 steps Β· 12s"). + self._task_count = 0 + self._total_duration = 0.0 + # Distinct tool-category buckets seen this turn, in first-seen order, + # for the auto-generated header ("Searched Β· edited files Β· …"). + self._categories: list = [] + self._last_header: str = "" + # Descriptive title and details per task id, so the finish update can + # reuse them instead of wiping (a task_update with the same id + # REPLACES the card wholesale β€” omitted fields vanish). + self._titles: dict = {} + self._details: dict = {} + # Interleaved reasoning cards: each burst of thinking between tool + # calls gets its own πŸ’­ card in the timeline (updated in place while + # the burst continues, finalized when the next tool starts). + self._reasoning_open_id: Optional[str] = None + self._reasoning_title: str = "" + self._reasoning_count = 0 + # Per-subagent state (card id β†’ {tools, number, start-time}) for the + # numbered, timed delegate cards. + self._subagents: dict = {} + # Serializes the open: tool events arrive back-to-back and each + # task_started() awaits ensure_started(), so without a lock two + # coroutines can both pass the ``_started`` check before either + # completes chat.startStream β€” opening two streams. + self._start_lock = asyncio.Lock() + # Serializes appends (OpenClaw does the same via a promise chain): + # events are scheduled as independent coroutines, so without this a + # fast tool's "finished" append could overtake its own "started" + # append mid-HTTP and leave the card stuck showing in_progress. + # asyncio.Lock wakes waiters FIFO, so scheduling order is preserved. + self._send_lock = asyncio.Lock() + + async def ensure_started(self) -> bool: + """Open the stream once (idempotent). Returns True if usable.""" + if self.disabled: + return False + if self._started: + return True + async with self._start_lock: + return await self._start_locked() + + async def _start_locked(self) -> bool: + if self.disabled: + return False + if self._started: + return True + try: + kwargs: dict = { + "channel": self.channel, + "thread_ts": self.thread_ts, + "task_display_mode": self.task_display_mode, + } + # Slack requires BOTH recipient ids for streams opened by bot + # tokens, despite the API docs listing them as optional + # (empirically: omitting team β†’ missing_recipient_team_id, + # omitting user β†’ missing_recipient_user_id). Pass when known. + if self.recipient_team_id: + kwargs["recipient_team_id"] = self.recipient_team_id + if self.recipient_user_id: + kwargs["recipient_user_id"] = self.recipient_user_id + result = await self.client.chat_startStream(**kwargs) + self.ts = result.get("ts") if result else None + if not self.ts: + raise RuntimeError("chat.startStream returned no ts") + self._started = True + return True + except Exception as e: # SlackApiError or any transport failure + logger.info("chat.startStream failed, disabling native task cards: %s", e) + self.disabled = True + return False + + async def task_started( + self, + index: int, + tool_name: str, + preview: Optional[str] = None, + details: Optional[str] = None, + ) -> None: + """Emit/update a task_update chunk for a tool call that just started. + + The title carries a friendly verb plus the args preview + ("Exec β€” grep foo …") so each line is scannable without expanding β€” + mirroring OpenClaw's progress lines. ``details`` (optional) fills the + collapsible card body, e.g. file content for Write/Edit calls. + """ + if self.disabled: + return + if not await self.ensure_started(): + return + label = tool_label(tool_name) + title = f"{label} β€” {preview}" if preview else label + title = title[:250] + self._titles[index] = title + if details: + self._details[index] = details + self._task_count += 1 + # Track tool categories for the auto-generated turn header. + cat = tool_category(tool_name) + if cat and cat not in self._categories: + self._categories.append(cat) + # Close out any open πŸ’­ card first so the timeline reads + # thought βœ“ β†’ tool, in order. + await self._finalize_reasoning_card() + await self._append_task_update( + index, title, status="in_progress", details=details, + ) + # Header = an auto-generated summary of what the turn is doing, + # composed from the distinct tool categories seen so far + # ("Searched Β· edited files Β· ran commands"), refreshed as new + # categories appear. Beats echoing the first tool's raw args + # (the old behavior rendered "Exec β€” date +%H:%M + 1 command"). + await self._refresh_turn_header() + + async def _refresh_turn_header(self) -> None: + """Set the collapsible header to a phrase summarizing the turn.""" + if not self._categories: + return + # Capitalize the first bucket, join the rest with " Β· ". + cats = list(self._categories) + cats[0] = cats[0][:1].upper() + cats[0][1:] + header = " Β· ".join(cats) + if header != self._last_header: + self._last_header = header + await self.set_plan_title(header) + + async def task_finished( + self, + index: int, + tool_name: str, + duration: float = 0.0, + ok: bool = True, + output: Optional[str] = None, + sources: Optional[List[dict]] = None, + summary: Optional[str] = None, + ) -> None: + """Emit/update a task_update chunk for a tool call that just finished.""" + if self.disabled: + return + if not await self.ensure_started(): + return + # Prefer a descriptive summary ("Read β†’ 5 results for X") over the + # start-time arg echo; fall back to the stored start title. + if summary: + base = f"{tool_label(tool_name)} β†’ {summary}" + else: + base = self._titles.get(index) or tool_label(tool_name) + title = f"{base} Β· {duration:.1f}s"[:250] if duration else base[:250] + # Failed tool calls render as "complete" with a βœ— suffix instead of + # Slack's "error" status: the pink warning triangle reads as "the + # agent broke" when in reality a failed call is routine β€” the agent + # sees the error and adapts. Reserving the triangle for genuine + # breakage (stream abandoned mid-turn) keeps it meaningful. + if not ok: + title = f"{base} Β· βœ— failed"[:250] + self._total_duration += duration or 0.0 + out = str(output)[:400] if output else None + # Re-send the start-time details so the collapsible content preview + # (file body for Write/Edit, code for Run) survives the finish + # update instead of being wiped by the card replacement. + await self._append_task_update( + index, title, status="complete", + details=self._details.get(index), output=out, sources=sources, + ) + + async def subagent_event( + self, + event_type: str, + subagent_key: str, + goal: str = "", + tool_name: Optional[str] = None, + ok: bool = True, + number: Optional[int] = None, + ) -> None: + """Render delegated subagents as their own live cards. + + delegate_task relays child lifecycle events to the parent's progress + callback (subagent.start / subagent.tool / subagent.complete). Each + child gets one card keyed by subagent_id so parallel children update + independently. The card shows a stable number (#1, #2…), the goal, + a live tool count, and elapsed time. Time advances on each relayed + event rather than ticking continuously β€” Slack cards only redraw when + a chunk is sent, so "rolling" means "updates whenever the child does + something", which is the useful signal (a frozen count == stuck). + """ + if self.disabled: + return + if not await self.ensure_started(): + return + sid = f"sub_{subagent_key}" + label = (goal or "subagent").strip() + if len(label) > 70: + label = label[:67] + "…" + st = self._subagents.setdefault( + sid, {"tools": [], "n": number, "t0": time.monotonic()} + ) + if number is not None: + st["n"] = number + num = f"#{st['n']} " if st.get("n") is not None else "" + elapsed = time.monotonic() - st["t0"] + + def _title(status_suffix: str = "") -> str: + ntools = len(st["tools"]) + bits = [] + if ntools: + bits.append(f"{ntools} tool{'s' if ntools != 1 else ''}") + if elapsed >= 1: + bits.append(f"{elapsed:.0f}s") + meta = f" Β· {' Β· '.join(bits)}" if bits else "" + return f"πŸ”€ Delegate {num}β€” {label}{meta}{status_suffix}"[:250] + + if event_type == "subagent.start": + self._task_count += 1 + await self._append_raw_task(sid, _title(), status="in_progress") + elif event_type == "subagent.tool" and tool_name: + st["tools"].append(tool_label(tool_name)) + details = " β†’ ".join(st["tools"][-12:])[:500] + await self._append_raw_task( + sid, _title(), status="in_progress", details=details, + ) + elif event_type == "subagent.complete": + details = " β†’ ".join(st["tools"][-12:])[:500] if st["tools"] else None + suffix = "" if ok else " Β· βœ— failed" + await self._append_raw_task( + sid, _title(suffix), status="complete", details=details, + ) + + async def reasoning_update(self, text: str) -> None: + """Render the model's thinking as interleaved πŸ’­ cards in the timeline. + + Each burst of reasoning between tool calls gets its own card, + positioned exactly where the thinking happened (thought β†’ tool β†’ + thought β†’ tool, the Anthropic/OpenAI webapp rhythm). The open card + updates in place while the burst continues; the next task_started + finalizes it. Header (plan title) stays owned by the first tool call + β€” an earlier version routed reasoning through the header, which + wiped it (plan_update replaces the title wholesale). + + Before the stream opens (thinking that precedes the first tool call + β€” i.e. every turn's opening thought), the burst is BUFFERED rather + than dropped: state is updated but no API call is made, and the + pending πŸ’­ card is flushed by the first task_started, so the card + still reads thought βœ“ β†’ tool. A turn with zero tool calls never + opens a stream, so its reasoning is never rendered β€” intentional. + """ + if self.disabled: + return + line = " ".join(str(text).split()) + if not line: + return + if len(line) > 180: + line = line[:177] + "…" + if self._reasoning_open_id is None: + self._reasoning_count += 1 + self._reasoning_open_id = f"think{self._reasoning_count}" + self._reasoning_title = f"πŸ’­ {line}"[:250] + if not self._started: + return # buffered β€” flushed by the first task_started + await self._append_raw_task( + self._reasoning_open_id, self._reasoning_title, status="in_progress", + ) + + async def _finalize_reasoning_card(self) -> None: + """Mark the open πŸ’­ card complete (called when the next tool starts).""" + if self._reasoning_open_id is None: + return + rid, title = self._reasoning_open_id, self._reasoning_title + self._reasoning_open_id = None + await self._append_raw_task(rid, title, status="complete") + + async def set_plan_title(self, title: str) -> None: + """Set/update the card's collapsible header via a plan_update chunk. + + Slack's default header ("Thinking completed" / "Something went + wrong") is generic; a plan_update replaces it with our own text. + """ + if self.disabled or not self._started: + return + try: + async with self._send_lock: + if self.disabled: + return + await self.client.chat_appendStream( + channel=self.channel, + ts=self.ts, + chunks=[{"type": "plan_update", "title": str(title)[:250]}], + ) + except Exception as e: + logger.info("plan_update failed (non-fatal): %s", e) + + async def _append_task_update( + self, + index: int, + title: str, + *, + status: str, + details: Optional[str] = None, + output: Optional[str] = None, + sources: Optional[List[dict]] = None, + ) -> None: + await self._append_raw_task( + f"t{index}", title, status=status, + details=details, output=output, sources=sources, + ) + + async def _append_raw_task( + self, + task_id: str, + title: str, + *, + status: str, + details: Optional[str] = None, + output: Optional[str] = None, + sources: Optional[List[dict]] = None, + ) -> None: + try: + chunk: dict = { + "type": "task_update", + "id": task_id, + "title": title, + "status": status, + } + if details: + chunk["details"] = details + if output: + chunk["output"] = output + if sources: + chunk["sources"] = sources + async with self._send_lock: + if self.disabled: + return + await self.client.chat_appendStream( + channel=self.channel, + ts=self.ts, + chunks=[chunk], + ) + except Exception as e: + logger.info("chat.appendStream failed, disabling native task cards: %s", e) + self.disabled = True + + async def stop(self, final_text: Optional[str] = None) -> None: + """Close the streaming message. No-op if never started or already stopped.""" + if self._stopped or not self._started or self.disabled: + self._stopped = True + return + # Settle any open πŸ’­ card so nothing is left in_progress at close + # (Slack renders unfinished tasks as warnings + "Something went wrong"). + try: + await self._finalize_reasoning_card() + except Exception: + pass + self._stopped = True + try: + # Wait for any in-flight append to land before closing, so the + # last task's status update isn't racing chat.stopStream. + async with self._send_lock: + kwargs: dict = {"channel": self.channel, "ts": self.ts} + if final_text: + kwargs["markdown_text"] = final_text + # Footer: a context block with turn stats, attached to the + # final message via stopStream's blocks parameter. Plain + # context blocks need no interactivity handler (unlike + # buttons, which require an events endpoint to be useful). + if self._task_count: + _secs = ( + f" Β· {self._total_duration:.0f}s tool time" + if self._total_duration >= 1 else "" + ) + _plural = "tool call" if self._task_count == 1 else "tool calls" + kwargs["blocks"] = [ + { + "type": "context", + "elements": [ + { + "type": "mrkdwn", + "text": f"βš™ {self._task_count} {_plural}{_secs}", + } + ], + } + ] + await self.client.chat_stopStream(**kwargs) + except Exception as e: + logger.info("chat.stopStream failed: %s", e) + + +__all__ = ["SlackTaskStream"] From a754a3fa6c84f854dcdc2625e837a524674610e8 Mon Sep 17 00:00:00 2001 From: "carnie[bot]" Date: Sun, 5 Jul 2026 18:58:12 +0800 Subject: [PATCH 02/25] feat(slack): stream rollover + full-reasoning cards, rebalanced previews MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Rollover: Slack closes streamed messages after ~5 min (message_not_in_streaming_state) and caps cumulative message size (msg_too_long) β€” both observed live 2026-07-05. Instead of falling back to markdown mid-turn, close the current card ('‡ continued below') and continue on a fresh streaming message, replaying the turn header and any still-in-progress tasks. Proactive (age 240s / ~10k chars sent) so users never see an error, and reactive on either error. MAX_ROLLOVERS=20 guard; other API errors still disable cards entirely. Reasoning cards: full burst now accumulates in the card's collapsible details (tail-kept ~1500 chars); title shows the rolling word-boundary tail. run.py now flushes full unsent delta batches instead of the last sentence fragment. Rationale: reasoning is signal, tool previews are clutter β€” so tool output previews shrink 300β†’120 (finish cap 400β†’150, arg details 500β†’300), buying size budget for reasoning. Also: _word_trim helper fixes mid-word truncation ('run.py first.I've'). Co-authored-by: Minh Nguyen --- gateway/run.py | 30 ++--- gateway/slack_task_stream.py | 225 +++++++++++++++++++++++++++++------ 2 files changed, 202 insertions(+), 53 deletions(-) diff --git a/gateway/run.py b/gateway/run.py index ff9459a4436c..5a492c8e5b39 100644 --- a/gateway/run.py +++ b/gateway/run.py @@ -19587,11 +19587,13 @@ def _slack_task_event(event_type: str, tool_name: str, preview, args, kwargs) -> _result = kwargs.get("result") # Short result preview for the expanded card body β€” unwrap # the JSON envelope + collapse whitespace so it reads as a - # glanceable summary, not a raw {"output": "..."} dump. + # glanceable summary. Kept SHORT (Minh 2026-07-05: tool + # previews are mostly clutter; the reasoning cards are the + # signal) β€” also buys message-size budget for reasoning. try: - output = _sts.clean_output_preview(_result, limit=300) + output = _sts.clean_output_preview(_result, limit=120) except Exception: - output = _result.strip()[:300] if isinstance(_result, str) and _result.strip() else None + output = _result.strip()[:120] if isinstance(_result, str) and _result.strip() else None # MCP tools report failures as result TEXT with is_error # False β€” sniff those so a 403 doesn't render with a βœ“. if ok and _sts.result_looks_like_error(_result): @@ -19653,10 +19655,11 @@ def _slack_subagent_event(event_type: str, tool_name, preview, kwargs) -> None: if _fut is not None: _slack_task_futures.append(_fut) - # Reasoning β†’ card header: throttle raw reasoning deltas into a - # rolling "πŸ’­ " plan_update. Deltas arrive token-by- - # token and plan_update replaces the header wholesale, so we buffer - # and flush at most once per interval, showing the tail line. + # Reasoning β†’ πŸ’­ cards: batch raw reasoning deltas and flush the FULL + # unsent text to the stream at most once per interval. The module + # accumulates flushes into the card body (details) and shows the + # rolling tail in the title β€” full reasoning is signal, so nothing + # is dropped here beyond the module's own tail-keep cap. _slack_reasoning_buf = [""] _slack_reasoning_last = [0.0] _SLACK_REASONING_INTERVAL_S = 2.0 @@ -19667,20 +19670,17 @@ def _slack_reasoning_event(text: str) -> None: return if not isinstance(text, str) or not text: return - _slack_reasoning_buf[0] = (_slack_reasoning_buf[0] + text)[-600:] + _slack_reasoning_buf[0] += text now = time.monotonic() if now - _slack_reasoning_last[0] < _SLACK_REASONING_INTERVAL_S: return _slack_reasoning_last[0] = now - # Render the last sentence-ish fragment of the buffer. - tail = _slack_reasoning_buf[0].replace("\n", " ").strip() - for sep in (". ", "! ", "? "): - if sep in tail: - tail = tail.rsplit(sep, 1)[-1] - if not tail: + pending, _slack_reasoning_buf[0] = _slack_reasoning_buf[0], "" + pending = pending.strip() + if not pending: return _fut = safe_schedule_threadsafe( - _slack_task_stream.reasoning_update(tail), + _slack_task_stream.reasoning_update(pending), _voice_ack_loop, logger=logger, log_message="slack reasoning_update scheduling error", diff --git a/gateway/slack_task_stream.py b/gateway/slack_task_stream.py index faf8569109c2..41bba1a1c1d7 100644 --- a/gateway/slack_task_stream.py +++ b/gateway/slack_task_stream.py @@ -80,6 +80,21 @@ def tool_label(tool_name: str) -> str: return meta[0] if meta else tool_name +def _word_trim(text: str, limit: int) -> str: + """Trim to ``limit`` chars at a word boundary, appending an ellipsis. + + Slicing raw delta buffers mid-word rendered titles like "run.py first.I've" + β€” trim back to the last space when one exists reasonably close to the cap. + """ + if len(text) <= limit: + return text + cut = text[: limit - 1] + sp = cut.rfind(" ") + if sp > limit * 0.6: + cut = cut[:sp] + return cut.rstrip() + "…" + + def clean_output_preview(result: Any, limit: int = 300) -> Optional[str]: """Turn a raw tool result into a compact, human-readable card preview. @@ -113,9 +128,7 @@ def clean_output_preview(result: Any, limit: int = 300) -> Optional[str]: text = " ".join(text.split()) if not text: return None - if len(text) > limit: - text = text[: limit - 1].rstrip() + "…" - return text + return _word_trim(text, limit) def summarize_tool_title(tool_name: str, args: Any, result: Any) -> Optional[str]: @@ -167,7 +180,7 @@ def tool_details_from_args(tool_name: str, args: Any) -> Optional[str]: val = args.get(key) if not isinstance(val, str) or not val.strip(): return None - return val.strip()[:500] + return val.strip()[:300] # Tools whose results naturally carry URL attributions worth surfacing as @@ -244,12 +257,32 @@ class SlackTaskStream: push ``task_update`` chunks keyed by a stable per-tool-call id, and ``stop()`` closes the stream when the turn completes. - All public methods swallow their own errors (log at debug) and flip - ``self.disabled`` on failure so the caller can fall back to the markdown - progress path for the rest of the turn instead of retrying a broken - stream on every subsequent event. + Long turns outlive a single streamed message β€” Slack closes streams + after ~5 minutes (``message_not_in_streaming_state``) and caps the + cumulative message size (``msg_too_long``). Rather than falling back to + markdown, the stream ROLLS OVER: the current card is closed cleanly and + a fresh streaming message continues the timeline, replaying any + still-in-progress tasks so their completion lands on the new card. + Rollover happens proactively (age/size thresholds, so the user never + sees an error) and reactively (on either recoverable API error). + + All public methods swallow their own errors (log at info) and flip + ``self.disabled`` on unrecoverable failure so the caller can fall back + to the markdown progress path for the rest of the turn instead of + retrying a broken stream on every subsequent event. """ + # Proactive rollover thresholds: Slack kills streams at ~5 min and + # bounces appends once the message's cumulative content is too large + # (both observed live 2026-07-05). Roll over comfortably before both. + ROLLOVER_MAX_AGE_S = 240.0 + ROLLOVER_MAX_CHARS = 10_000 + # Runaway guard: a turn pathological enough to need more fresh streams + # than this should fall back to markdown instead. Sized generously β€” + # age-based rollover alone consumes one per ~4 min, so a legitimate + # 40-60 min turn can use 10-15 (observed turns run 20+ min). + MAX_ROLLOVERS = 20 + def __init__( self, client: Any, @@ -283,13 +316,25 @@ def __init__( self._details: dict = {} # Interleaved reasoning cards: each burst of thinking between tool # calls gets its own πŸ’­ card in the timeline (updated in place while - # the burst continues, finalized when the next tool starts). + # the burst continues, finalized when the next tool starts). The + # title carries the rolling tail of the thought; the card's DETAILS + # carries the full burst text (titles are capped ~255 by Slack, + # details can hold far more β€” that's where full reasoning lives). self._reasoning_open_id: Optional[str] = None self._reasoning_title: str = "" + self._reasoning_details: str = "" self._reasoning_count = 0 # Per-subagent state (card id β†’ {tools, number, start-time}) for the # numbered, timed delegate cards. self._subagents: dict = {} + # Rollover state: when the current streamed message ages/fills out, + # it's closed and a fresh one continues the timeline. Tasks still + # in_progress at rollover are tracked so they can be replayed onto + # the new card (their task ids don't exist there otherwise). + self._stream_opened_at = 0.0 + self._sent_chars = 0 + self._rollovers = 0 + self._in_progress: dict = {} # task_id β†’ last-sent chunk kwargs # Serializes the open: tool events arrive back-to-back and each # task_started() awaits ensure_started(), so without a lock two # coroutines can both pass the ``_started`` check before either @@ -317,23 +362,7 @@ async def _start_locked(self) -> bool: if self._started: return True try: - kwargs: dict = { - "channel": self.channel, - "thread_ts": self.thread_ts, - "task_display_mode": self.task_display_mode, - } - # Slack requires BOTH recipient ids for streams opened by bot - # tokens, despite the API docs listing them as optional - # (empirically: omitting team β†’ missing_recipient_team_id, - # omitting user β†’ missing_recipient_user_id). Pass when known. - if self.recipient_team_id: - kwargs["recipient_team_id"] = self.recipient_team_id - if self.recipient_user_id: - kwargs["recipient_user_id"] = self.recipient_user_id - result = await self.client.chat_startStream(**kwargs) - self.ts = result.get("ts") if result else None - if not self.ts: - raise RuntimeError("chat.startStream returned no ts") + await self._open_stream() self._started = True return True except Exception as e: # SlackApiError or any transport failure @@ -341,6 +370,32 @@ async def _start_locked(self) -> bool: self.disabled = True return False + async def _open_stream(self) -> None: + """Raw chat.startStream call β€” shared by first open and rollover. + + Raises on failure; callers decide whether that's fatal. Takes no + locks (callers already hold whichever lock is appropriate). + """ + kwargs: dict = { + "channel": self.channel, + "thread_ts": self.thread_ts, + "task_display_mode": self.task_display_mode, + } + # Slack requires BOTH recipient ids for streams opened by bot + # tokens, despite the API docs listing them as optional + # (empirically: omitting team β†’ missing_recipient_team_id, + # omitting user β†’ missing_recipient_user_id). Pass when known. + if self.recipient_team_id: + kwargs["recipient_team_id"] = self.recipient_team_id + if self.recipient_user_id: + kwargs["recipient_user_id"] = self.recipient_user_id + result = await self.client.chat_startStream(**kwargs) + self.ts = result.get("ts") if result else None + if not self.ts: + raise RuntimeError("chat.startStream returned no ts") + self._stream_opened_at = time.monotonic() + self._sent_chars = 0 + async def task_started( self, index: int, @@ -425,7 +480,7 @@ async def task_finished( if not ok: title = f"{base} Β· βœ— failed"[:250] self._total_duration += duration or 0.0 - out = str(output)[:400] if output else None + out = str(output)[:150] if output else None # Re-send the start-time details so the collapsible content preview # (file body for Write/Edit, code for Run) survives the finish # update instead of being wiped by the card replacement. @@ -507,6 +562,13 @@ async def reasoning_update(self, text: str) -> None: β€” an earlier version routed reasoning through the header, which wiped it (plan_update replaces the title wholesale). + Layout (per Minh 2026-07-05: full reasoning matters most β€” tool + previews are clutter, reasoning is signal): the TITLE shows the + rolling tail of the current thought at word-boundary (titles are + Slack-capped ~255); the collapsible DETAILS carries the full burst + text (tail-kept up to ~1500 chars), so expanding the πŸ’­ card reads + the whole thought, not a fragment. + Before the stream opens (thinking that precedes the first tool call β€” i.e. every turn's opening thought), the burst is BUFFERED rather than dropped: state is updated but no API call is made, and the @@ -519,25 +581,45 @@ async def reasoning_update(self, text: str) -> None: line = " ".join(str(text).split()) if not line: return - if len(line) > 180: - line = line[:177] + "…" if self._reasoning_open_id is None: self._reasoning_count += 1 self._reasoning_open_id = f"think{self._reasoning_count}" - self._reasoning_title = f"πŸ’­ {line}"[:250] + self._reasoning_details = "" + # Details accumulate the full burst; keep the tail if it overflows + # (the freshest thinking is the part worth reading). + self._reasoning_details = (self._reasoning_details + " " + line).strip() + if len(self._reasoning_details) > 1500: + clipped = self._reasoning_details[-1500:] + # Cut at the first word boundary inside the clip window. + sp = clipped.find(" ") + self._reasoning_details = "…" + clipped[sp + 1 if 0 <= sp < 40 else 0:] + # Title = the tail of the accumulated burst (not the raw delta, + # which can start mid-sentence), trimmed at a word boundary. + tail = self._reasoning_details[-245:] + if len(self._reasoning_details) > 245: + sp = tail.find(" ") + if 0 <= sp < 40: + tail = "…" + tail[sp + 1:] + self._reasoning_title = f"πŸ’­ {tail}"[:250] if not self._started: return # buffered β€” flushed by the first task_started await self._append_raw_task( - self._reasoning_open_id, self._reasoning_title, status="in_progress", + self._reasoning_open_id, self._reasoning_title, + status="in_progress", details=self._reasoning_details, ) async def _finalize_reasoning_card(self) -> None: """Mark the open πŸ’­ card complete (called when the next tool starts).""" if self._reasoning_open_id is None: return - rid, title = self._reasoning_open_id, self._reasoning_title + rid, title, details = ( + self._reasoning_open_id, self._reasoning_title, self._reasoning_details, + ) self._reasoning_open_id = None - await self._append_raw_task(rid, title, status="complete") + self._reasoning_details = "" + await self._append_raw_task( + rid, title, status="complete", details=details or None, + ) async def set_plan_title(self, title: str) -> None: """Set/update the card's collapsible header via a plan_update chunk. @@ -597,18 +679,85 @@ async def _append_raw_task( chunk["output"] = output if sources: chunk["sources"] = sources + # Track open tasks for rollover replay: an in_progress card must + # be re-created on the fresh stream or its completion update + # would reference a task id that doesn't exist there. + if status == "in_progress": + self._in_progress[task_id] = dict(chunk) + else: + self._in_progress.pop(task_id, None) async with self._send_lock: if self.disabled: return - await self.client.chat_appendStream( - channel=self.channel, - ts=self.ts, - chunks=[chunk], - ) + # Proactive rollover: refresh the stream *before* Slack's + # ~5-min stream lifetime or per-message size cap kill it, + # so the user never sees an error state. + if ( + time.monotonic() - self._stream_opened_at > self.ROLLOVER_MAX_AGE_S + or self._sent_chars > self.ROLLOVER_MAX_CHARS + ): + await self._rollover_locked() + try: + await self._send_chunk_locked(chunk) + except Exception as e: + # Reactive rollover: both errors mean "this message can't + # take more content" β€” recoverable with a fresh stream. + if any(m in str(e) for m in ("message_not_in_streaming_state", "msg_too_long")): + await self._rollover_locked() + await self._send_chunk_locked(chunk) + else: + raise except Exception as e: logger.info("chat.appendStream failed, disabling native task cards: %s", e) self.disabled = True + async def _send_chunk_locked(self, chunk: dict) -> None: + """Send one chunk on the current stream. Caller holds _send_lock.""" + await self.client.chat_appendStream( + channel=self.channel, + ts=self.ts, + chunks=[chunk], + ) + # Approximate the message-size budget by summing sent content. + self._sent_chars += sum(len(str(v)) for v in chunk.values()) + + async def _rollover_locked(self) -> None: + """Close the current stream and continue on a fresh one. + + Caller holds _send_lock. Raises if the fresh stream can't be opened + (caller's outer except then disables cards β€” correct: no stream to + write to). The old card is stopped best-effort with a continuation + footer; still-open tasks and the turn header are replayed onto the + new card so the timeline visually continues. + """ + self._rollovers += 1 + if self._rollovers > self.MAX_ROLLOVERS: + raise RuntimeError(f"exceeded {self.MAX_ROLLOVERS} stream rollovers this turn") + try: + await self.client.chat_stopStream( + channel=self.channel, + ts=self.ts, + blocks=[{ + "type": "context", + "elements": [{"type": "mrkdwn", "text": "‡ continued below"}], + }], + ) + except Exception as e: + # Old stream may already be dead (that's why we're here). + logger.info("rollover: closing old stream failed (non-fatal): %s", e) + await self._open_stream() + logger.info( + "rollover: continued task cards on fresh stream ts=%s (rollover #%d)", + self.ts, self._rollovers, + ) + # Replay the turn header and any in-flight tasks on the new card. + replay: List[dict] = [] + if self._last_header: + replay.append({"type": "plan_update", "title": self._last_header[:250]}) + replay.extend(dict(c) for c in self._in_progress.values()) + for chunk in replay: + await self._send_chunk_locked(chunk) + async def stop(self, final_text: Optional[str] = None) -> None: """Close the streaming message. No-op if never started or already stopped.""" if self._stopped or not self._started or self.disabled: From da6af76baf1f8e1d9829a20f16d3d4399da0a389 Mon Sep 17 00:00:00 2001 From: "carnie[bot]" Date: Sun, 5 Jul 2026 19:09:56 +0800 Subject: [PATCH 03/25] feat(slack): config knobs for native-card tuning + uncapped reasoning default MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Four new display settings, resolved like any other Hermes display param (display.platforms.slack. β†’ display. β†’ built-in default): tool_progress_native_rollover_age_s (default 240) tool_progress_native_rollover_chars (default 10000) tool_progress_native_reasoning_chars (default 0 = uncapped) tool_progress_native_output_chars (default 120) Reasoning cap now defaults to UNCAPPED β€” reasoning is the highest-value card content. An absolute ceiling of 11k (just under Slack's documented 12k markdown_text field limit) still applies so one card can't blow the message; rollover handles the cumulative budget. Size accounting fixed to net-delta per card id: task_update REPLACES its card, so only growth counts. Raw summing would explode on πŸ’­ cards (each update re-sends the whole burst) and trigger spurious rollovers. Co-authored-by: Minh Nguyen --- gateway/display_config.py | 29 ++++++++++++++++-- gateway/run.py | 24 +++++++++++++-- gateway/slack_task_stream.py | 59 +++++++++++++++++++++++++++++++----- 3 files changed, 99 insertions(+), 13 deletions(-) diff --git a/gateway/display_config.py b/gateway/display_config.py index 61ea8c2ced9b..a739d7e55c91 100644 --- a/gateway/display_config.py +++ b/gateway/display_config.py @@ -43,6 +43,23 @@ # "timeline" gives each task its own separate card block, "dense" # collapses consecutive tool calls. Fixed at stream start (Slack limit). "tool_progress_native_mode": "plan", + # Native-card tuning knobs (all Slack-only, honored when + # tool_progress_native is on). Defaults chosen from live measurement + # 2026-07-05; override like any display setting, globally or under + # display.platforms.slack. + # rollover_age_s: proactively continue on a fresh streamed message + # after this many seconds (Slack kills streams server-side β€” + # message_not_in_streaming_state β€” so stay under its lifetime). + # rollover_chars: proactively roll over after ~this much cumulative + # content on one message (Slack's msg_too_long cap). + # reasoning_chars: cap on the accumulated πŸ’­ reasoning text kept in a + # card's collapsible details. 0 = uncapped (a safety ceiling near + # Slack's 12k markdown_text field limit still applies). + # output_chars: per-tool result preview length on finished cards. + "tool_progress_native_rollover_age_s": 240, + "tool_progress_native_rollover_chars": 10_000, + "tool_progress_native_reasoning_chars": 0, + "tool_progress_native_output_chars": 120, "show_reasoning": False, # How a reasoning/thinking summary is rendered when show_reasoning is on. # "code" -> πŸ’­ **Reasoning:** + fenced code block (legacy default) @@ -305,9 +322,15 @@ def _normalise(setting: str, value: Any) -> Any: if setting == "reasoning_style": val = str(value).lower() return val if val in ("code", "blockquote", "subtext") else "code" - if setting == "tool_preview_length": + if setting in { + "tool_preview_length", + "tool_progress_native_rollover_age_s", + "tool_progress_native_rollover_chars", + "tool_progress_native_reasoning_chars", + "tool_progress_native_output_chars", + }: try: - return int(value) + return max(0, int(value)) except (TypeError, ValueError): - return 0 + return _GLOBAL_DEFAULTS.get(setting, 0) return value diff --git a/gateway/run.py b/gateway/run.py index 5a492c8e5b39..d95f5d4e96ee 100644 --- a/gateway/run.py +++ b/gateway/run.py @@ -19523,6 +19523,22 @@ def progress_callback(event_type: str, tool_name: str = None, preview: str = Non ), recipient_team_id=_stream_team_id, recipient_user_id=getattr(source, "user_id", None), + # Tuning knobs β€” standard display-config resolution + # (display.platforms.slack. β†’ display. β†’ + # built-in default), so users adjust them like any + # other Hermes display parameter. + rollover_age_s=resolve_display_setting( + user_config, platform_key, "tool_progress_native_rollover_age_s", + ), + rollover_chars=resolve_display_setting( + user_config, platform_key, "tool_progress_native_rollover_chars", + ), + reasoning_chars=resolve_display_setting( + user_config, platform_key, "tool_progress_native_reasoning_chars", + ), + output_chars=resolve_display_setting( + user_config, platform_key, "tool_progress_native_output_chars", + ), ) else: _slack_native_cards = False @@ -19589,11 +19605,13 @@ def _slack_task_event(event_type: str, tool_name: str, preview, args, kwargs) -> # the JSON envelope + collapse whitespace so it reads as a # glanceable summary. Kept SHORT (Minh 2026-07-05: tool # previews are mostly clutter; the reasoning cards are the - # signal) β€” also buys message-size budget for reasoning. + # signal). Cap is config-driven + # (display.*.tool_progress_native_output_chars). + _out_cap = getattr(_slack_task_stream, "OUTPUT_PREVIEW_CHARS", 120) try: - output = _sts.clean_output_preview(_result, limit=120) + output = _sts.clean_output_preview(_result, limit=_out_cap) except Exception: - output = _result.strip()[:120] if isinstance(_result, str) and _result.strip() else None + output = _result.strip()[:_out_cap] if isinstance(_result, str) and _result.strip() else None # MCP tools report failures as result TEXT with is_error # False β€” sniff those so a 403 doesn't render with a βœ“. if ok and _sts.result_looks_like_error(_result): diff --git a/gateway/slack_task_stream.py b/gateway/slack_task_stream.py index 41bba1a1c1d7..d2ca7f539a54 100644 --- a/gateway/slack_task_stream.py +++ b/gateway/slack_task_stream.py @@ -272,11 +272,24 @@ class SlackTaskStream: retrying a broken stream on every subsequent event. """ + # Tuning defaults (overridable per-instance via __init__, which run.py + # feeds from the display config keys tool_progress_native_*): + # # Proactive rollover thresholds: Slack kills streams at ~5 min and # bounces appends once the message's cumulative content is too large # (both observed live 2026-07-05). Roll over comfortably before both. ROLLOVER_MAX_AGE_S = 240.0 ROLLOVER_MAX_CHARS = 10_000 + # Cap on accumulated πŸ’­ reasoning text per card. 0 = uncapped, bounded + # only by SLACK_FIELD_CEILING below. + REASONING_MAX_CHARS = 0 + # Slack's documented limit for a markdown_text field is 12,000 chars; + # task_update details ride the same message budget. Absolute ceiling + # applied even when the user uncaps reasoning, so one card can't blow + # the whole message. (Rollover handles the cumulative budget.) + SLACK_FIELD_CEILING = 11_000 + # Per-tool result preview length on finished cards. + OUTPUT_PREVIEW_CHARS = 120 # Runaway guard: a turn pathological enough to need more fresh streams # than this should fall back to markdown instead. Sized generously β€” # age-based rollover alone consumes one per ~4 min, so a legitimate @@ -291,6 +304,10 @@ def __init__( task_display_mode: str = "plan", recipient_team_id: Optional[str] = None, recipient_user_id: Optional[str] = None, + rollover_age_s: Optional[float] = None, + rollover_chars: Optional[int] = None, + reasoning_chars: Optional[int] = None, + output_chars: Optional[int] = None, ) -> None: self.client = client self.channel = channel @@ -298,6 +315,16 @@ def __init__( self.recipient_team_id = recipient_team_id self.recipient_user_id = recipient_user_id self.task_display_mode = task_display_mode + # Config-driven tuning (None β†’ class default). reasoning cap of 0 + # means uncapped; it is still clamped to SLACK_FIELD_CEILING. + if rollover_age_s is not None and rollover_age_s > 0: + self.ROLLOVER_MAX_AGE_S = float(rollover_age_s) + if rollover_chars is not None and rollover_chars > 0: + self.ROLLOVER_MAX_CHARS = int(rollover_chars) + if reasoning_chars is not None: + self.REASONING_MAX_CHARS = max(0, int(reasoning_chars)) + if output_chars is not None and output_chars > 0: + self.OUTPUT_PREVIEW_CHARS = int(output_chars) self.ts: Optional[str] = None self.disabled = False self._started = False @@ -333,6 +360,9 @@ def __init__( # the new card (their task ids don't exist there otherwise). self._stream_opened_at = 0.0 self._sent_chars = 0 + # Last-sent size per card id, for net-delta size accounting (a + # task_update replaces its card, so only growth counts). + self._chunk_sizes: dict = {} self._rollovers = 0 self._in_progress: dict = {} # task_id β†’ last-sent chunk kwargs # Serializes the open: tool events arrive back-to-back and each @@ -395,6 +425,7 @@ async def _open_stream(self) -> None: raise RuntimeError("chat.startStream returned no ts") self._stream_opened_at = time.monotonic() self._sent_chars = 0 + self._chunk_sizes = {} async def task_started( self, @@ -480,7 +511,8 @@ async def task_finished( if not ok: title = f"{base} Β· βœ— failed"[:250] self._total_duration += duration or 0.0 - out = str(output)[:150] if output else None + # +30 slack vs the run.py-side preview cap so a summary suffix fits. + out = str(output)[: self.OUTPUT_PREVIEW_CHARS + 30] if output else None # Re-send the start-time details so the collapsible content preview # (file body for Write/Edit, code for Run) survives the finish # update instead of being wiped by the card replacement. @@ -585,11 +617,16 @@ async def reasoning_update(self, text: str) -> None: self._reasoning_count += 1 self._reasoning_open_id = f"think{self._reasoning_count}" self._reasoning_details = "" - # Details accumulate the full burst; keep the tail if it overflows - # (the freshest thinking is the part worth reading). + # Details accumulate the full burst; reasoning is the highest-value + # content on the card, so the default cap is 0 (uncapped). The + # effective ceiling is min(user cap or ∞, SLACK_FIELD_CEILING) β€” + # keep the tail on overflow (freshest thinking reads best). self._reasoning_details = (self._reasoning_details + " " + line).strip() - if len(self._reasoning_details) > 1500: - clipped = self._reasoning_details[-1500:] + cap = self.SLACK_FIELD_CEILING + if self.REASONING_MAX_CHARS > 0: + cap = min(self.REASONING_MAX_CHARS, cap) + if len(self._reasoning_details) > cap: + clipped = self._reasoning_details[-cap:] # Cut at the first word boundary inside the clip window. sp = clipped.find(" ") self._reasoning_details = "…" + clipped[sp + 1 if 0 <= sp < 40 else 0:] @@ -718,8 +755,16 @@ async def _send_chunk_locked(self, chunk: dict) -> None: ts=self.ts, chunks=[chunk], ) - # Approximate the message-size budget by summing sent content. - self._sent_chars += sum(len(str(v)) for v in chunk.values()) + # Approximate the message-size budget by NET RENDERED content: a + # task_update with a known id REPLACES that card, so count only the + # size delta vs what that card previously held. Summing raw appends + # would explode on πŸ’­ cards (each update re-sends the whole + # accumulated burst) and trigger spurious rollovers. + size = sum(len(str(v)) for v in chunk.values()) + key = chunk.get("id") or f"__{chunk.get('type', 'chunk')}__" + prev = self._chunk_sizes.get(key, 0) + self._chunk_sizes[key] = size + self._sent_chars += max(0, size - prev) async def _rollover_locked(self) -> None: """Close the current stream and continue on a fresh one. From 10bd0e0390db930f69a665516fdfdfce2c7300d4 Mon Sep 17 00:00:00 2001 From: "carnie[bot]" Date: Sun, 5 Jul 2026 19:16:27 +0800 Subject: [PATCH 04/25] =?UTF-8?q?tune(slack):=20rollover=20defaults=20from?= =?UTF-8?q?=20live=20probe=20=E2=80=94=20age=20240s=20confirmed=20(~306s?= =?UTF-8?q?=20absolute=20lifetime,=20heartbeats=20don't=20extend),=20size?= =?UTF-8?q?=20backstop=2010k=E2=86=9240k=20(probed=20clean=20past=2062k;?= =?UTF-8?q?=20msg=5Ftoo=5Flong=20was=20single-chunk,=20not=20cumulative)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Minh Nguyen --- gateway/display_config.py | 12 +++++++----- gateway/slack_task_stream.py | 12 ++++++++---- 2 files changed, 15 insertions(+), 9 deletions(-) diff --git a/gateway/display_config.py b/gateway/display_config.py index a739d7e55c91..f6ad0487066a 100644 --- a/gateway/display_config.py +++ b/gateway/display_config.py @@ -48,16 +48,18 @@ # 2026-07-05; override like any display setting, globally or under # display.platforms.slack. # rollover_age_s: proactively continue on a fresh streamed message - # after this many seconds (Slack kills streams server-side β€” - # message_not_in_streaming_state β€” so stay under its lifetime). - # rollover_chars: proactively roll over after ~this much cumulative - # content on one message (Slack's msg_too_long cap). + # after this many seconds. Measured 2026-07-05: Slack kills a + # stream ~306s after startStream even with active appends + # (absolute lifetime, not inactivity) β€” 240 β‰ˆ 80% with margin. + # rollover_chars: loose size backstop β€” probed clean past ~62k + # cumulative chars; per-field caps prevent the real msg_too_long + # trigger (single oversized chunk). # reasoning_chars: cap on the accumulated πŸ’­ reasoning text kept in a # card's collapsible details. 0 = uncapped (a safety ceiling near # Slack's 12k markdown_text field limit still applies). # output_chars: per-tool result preview length on finished cards. "tool_progress_native_rollover_age_s": 240, - "tool_progress_native_rollover_chars": 10_000, + "tool_progress_native_rollover_chars": 40_000, "tool_progress_native_reasoning_chars": 0, "tool_progress_native_output_chars": 120, "show_reasoning": False, diff --git a/gateway/slack_task_stream.py b/gateway/slack_task_stream.py index d2ca7f539a54..c52c99f6d0c0 100644 --- a/gateway/slack_task_stream.py +++ b/gateway/slack_task_stream.py @@ -275,11 +275,15 @@ class SlackTaskStream: # Tuning defaults (overridable per-instance via __init__, which run.py # feeds from the display config keys tool_progress_native_*): # - # Proactive rollover thresholds: Slack kills streams at ~5 min and - # bounces appends once the message's cumulative content is too large - # (both observed live 2026-07-05). Roll over comfortably before both. + # Proactive rollover thresholds, measured live 2026-07-05 via probe + # (ehoy scripts/carnie/slack_stream_probe.py): a stream dies ~306s after startStream + # even with appends every 20s β€” an ABSOLUTE lifetime, not inactivity β€” + # so roll at 240s (~80%, margin for jitter/slow appends). Cumulative + # size probed clean past 61,920 chars (earlier msg_too_long failures + # were single oversized chunks, since capped per-field), so the char + # threshold is a loose backstop, not the binding constraint. ROLLOVER_MAX_AGE_S = 240.0 - ROLLOVER_MAX_CHARS = 10_000 + ROLLOVER_MAX_CHARS = 40_000 # Cap on accumulated πŸ’­ reasoning text per card. 0 = uncapped, bounded # only by SLACK_FIELD_CEILING below. REASONING_MAX_CHARS = 0 From 296c7617db46b4be8c95656d248c7855af335dc2 Mon Sep 17 00:00:00 2001 From: "carnie[bot]" Date: Sun, 5 Jul 2026 20:41:47 +0800 Subject: [PATCH 05/25] fix(slack): reasoning dedup (overlap-aware merge) + settle tasks before rollover stop MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two live-observed rendering bugs: 1. Reasoning duplication: provider delivers reasoning flushes as cumulative snapshots / overlapping windows, not clean deltas β€” naive append rendered the same sentence 3-4x on the πŸ’­ card. New _merge_reasoning(): superset keeps, longest suffix-prefix overlap splices, plain append only for true deltas. 2. Red warning triangles on rolled-over cards: chat.stopStream with in_progress tasks makes Slack stamp them 'something went wrong'. Rollover now settles open tasks as complete + '‡' suffix on the old card before stopping; they replay as in_progress on the fresh card. Co-authored-by: Minh Nguyen --- gateway/slack_task_stream.py | 53 +++++++++++++++++++++++++++++++++++- 1 file changed, 52 insertions(+), 1 deletion(-) diff --git a/gateway/slack_task_stream.py b/gateway/slack_task_stream.py index c52c99f6d0c0..a0b0e1c57d52 100644 --- a/gateway/slack_task_stream.py +++ b/gateway/slack_task_stream.py @@ -95,6 +95,35 @@ def _word_trim(text: str, limit: int) -> str: return cut.rstrip() + "…" +def _merge_reasoning(acc: str, incoming: str, probe: int = 200) -> str: + """Merge a new reasoning flush into the accumulated burst, dedup-aware. + + Providers differ in what a "reasoning delta" contains: true deltas + (new tokens only), cumulative snapshots (entire reasoning so far), or + overlapping windows (tail of previous flush + new tokens). Naive + concatenation renders duplicated sentences on the πŸ’­ card for the + latter two (observed live 2026-07-05: same sentence 4Γ—). + + Strategy: (1) if one side contains the other, keep the superset; + (2) otherwise find the longest suffix of ``acc`` that prefixes + ``incoming`` (checked down from ``probe`` chars) and splice at the + overlap; (3) no overlap β†’ plain append. + """ + if not acc: + return incoming.strip() + if not incoming: + return acc + if incoming in acc: + return acc + if acc in incoming: + return incoming.strip() + max_k = min(len(acc), len(incoming), probe) + for k in range(max_k, 9, -1): # overlaps <10 chars are coincidence + if acc.endswith(incoming[:k]): + return acc + incoming[k:] + return (acc + " " + incoming).strip() + + def clean_output_preview(result: Any, limit: int = 300) -> Optional[str]: """Turn a raw tool result into a compact, human-readable card preview. @@ -625,7 +654,12 @@ async def reasoning_update(self, text: str) -> None: # content on the card, so the default cap is 0 (uncapped). The # effective ceiling is min(user cap or ∞, SLACK_FIELD_CEILING) β€” # keep the tail on overflow (freshest thinking reads best). - self._reasoning_details = (self._reasoning_details + " " + line).strip() + # + # Overlap-aware merge: some providers deliver reasoning as + # CUMULATIVE snapshots (full text so far) rather than deltas, and + # thread-timing can re-deliver overlapping windows. Naive append + # rendered the same sentences 3-4Γ— (observed live 2026-07-05). + self._reasoning_details = _merge_reasoning(self._reasoning_details, line) cap = self.SLACK_FIELD_CEILING if self.REASONING_MAX_CHARS > 0: cap = min(self.REASONING_MAX_CHARS, cap) @@ -783,6 +817,23 @@ async def _rollover_locked(self) -> None: if self._rollovers > self.MAX_ROLLOVERS: raise RuntimeError(f"exceeded {self.MAX_ROLLOVERS} stream rollovers this turn") try: + # Settle still-open tasks on the OLD card before closing it β€” + # stopping a stream with in_progress tasks makes Slack stamp + # them with red warning triangles ("something went wrong"), + # which reads as breakage when it's just a continuation + # (observed live 2026-07-05). Mark them complete with a ‡ + # suffix here; they're replayed as in_progress on the fresh + # card below. + for tid, chunk in list(self._in_progress.items()): + settled = dict(chunk) + settled["status"] = "complete" + settled["title"] = f"{str(chunk.get('title', ''))[:240]} ‡" + try: + await self.client.chat_appendStream( + channel=self.channel, ts=self.ts, chunks=[settled], + ) + except Exception: + break # old stream already dead β€” skip the rest await self.client.chat_stopStream( channel=self.channel, ts=self.ts, From cee732a1c33304e4b02c279a3322f08ec3670075 Mon Sep 17 00:00:00 2001 From: "carnie[bot]" Date: Sun, 5 Jul 2026 21:48:51 +0800 Subject: [PATCH 06/25] =?UTF-8?q?fix(agent):=20reasoning=5Fcallback=20doub?= =?UTF-8?q?le-fire=20on=20gateway=20platforms=20=E2=80=94=20root=20cause?= =?UTF-8?q?=20of=20duplicated=20reasoning?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit _build_assistant_message re-fired reasoning_callback with the FULL accumulated reasoning after streaming had already delivered it as deltas. Its guard tested stream_delta_callback/_stream_callback β€” the TEXT-streaming consumers, which are None on gateway platforms β€” so the 'skip when already streamed' intent never applied there and every gateway reasoning consumer received each burst 2x (stacking to 4x with multi-flush windows). CLI was unaffected because it always sets stream_delta_callback. Fix at the source: _fire_reasoning_delta latches _reasoning_streamed_this_response; the post-completion path reads-and-clears it and only fires when nothing streamed (batch/quiet/ non-streaming providers still get reasoning exactly once). Consequence: reasoning_callback inputs are now guaranteed true deltas, so the overlap-splice dedup in slack_task_stream (_merge_reasoning) is dead weight β€” removed, plain append restored. Symptom patch replaced by root-cause fix. Co-authored-by: Minh Nguyen --- agent/chat_completion_helpers.py | 25 ++++++++++++------- gateway/slack_task_stream.py | 41 +++++--------------------------- run_agent.py | 11 ++++++++- 3 files changed, 32 insertions(+), 45 deletions(-) diff --git a/agent/chat_completion_helpers.py b/agent/chat_completion_helpers.py index b2e5c8653a48..a850d1782a1a 100644 --- a/agent/chat_completion_helpers.py +++ b/agent/chat_completion_helpers.py @@ -1265,15 +1265,22 @@ def build_assistant_message(agent, assistant_message, finish_reason: str) -> dic logging.debug(f"Captured reasoning ({len(reasoning_text)} chars): {reasoning_text}") if reasoning_text and agent.reasoning_callback: - # Skip callback when streaming is active β€” reasoning was already - # displayed during the stream via one of two paths: - # (a) _fire_reasoning_delta (structured reasoning_content deltas) - # (b) _stream_delta tag extraction (/) - # When streaming is NOT active, always fire so non-streaming modes - # (gateway, batch, quiet) still get reasoning. - # Any reasoning that wasn't shown during streaming is caught by the - # CLI post-response display fallback (cli.py _reasoning_shown_this_turn). - if not agent.stream_delta_callback and not agent._stream_callback: + # Skip the callback when this response's reasoning was already + # delivered incrementally via _fire_reasoning_delta (structured + # reasoning_content deltas or streamed tag extraction) β€” + # read-and-clear the latch it sets. Re-firing here handed consumers + # the SAME reasoning twice: once as deltas, once as the full + # accumulated text (observed as 2-4x duplicated sentences on the + # Slack native task cards, 2026-07-05). The previous guard tested + # ``stream_delta_callback``/``_stream_callback``, but those are the + # *text*-streaming consumers β€” None on gateway platforms even while + # reasoning deltas stream fine β€” so the guard never tripped there. + # When nothing streamed (non-streaming modes: batch, quiet, + # streaming-disabled providers), the latch is unset and we fire so + # those consumers still get reasoning exactly once. + _already_streamed = getattr(agent, "_reasoning_streamed_this_response", False) + agent._reasoning_streamed_this_response = False + if not _already_streamed: try: agent.reasoning_callback(reasoning_text) except Exception: diff --git a/gateway/slack_task_stream.py b/gateway/slack_task_stream.py index a0b0e1c57d52..3b997adf5de3 100644 --- a/gateway/slack_task_stream.py +++ b/gateway/slack_task_stream.py @@ -95,35 +95,6 @@ def _word_trim(text: str, limit: int) -> str: return cut.rstrip() + "…" -def _merge_reasoning(acc: str, incoming: str, probe: int = 200) -> str: - """Merge a new reasoning flush into the accumulated burst, dedup-aware. - - Providers differ in what a "reasoning delta" contains: true deltas - (new tokens only), cumulative snapshots (entire reasoning so far), or - overlapping windows (tail of previous flush + new tokens). Naive - concatenation renders duplicated sentences on the πŸ’­ card for the - latter two (observed live 2026-07-05: same sentence 4Γ—). - - Strategy: (1) if one side contains the other, keep the superset; - (2) otherwise find the longest suffix of ``acc`` that prefixes - ``incoming`` (checked down from ``probe`` chars) and splice at the - overlap; (3) no overlap β†’ plain append. - """ - if not acc: - return incoming.strip() - if not incoming: - return acc - if incoming in acc: - return acc - if acc in incoming: - return incoming.strip() - max_k = min(len(acc), len(incoming), probe) - for k in range(max_k, 9, -1): # overlaps <10 chars are coincidence - if acc.endswith(incoming[:k]): - return acc + incoming[k:] - return (acc + " " + incoming).strip() - - def clean_output_preview(result: Any, limit: int = 300) -> Optional[str]: """Turn a raw tool result into a compact, human-readable card preview. @@ -654,12 +625,12 @@ async def reasoning_update(self, text: str) -> None: # content on the card, so the default cap is 0 (uncapped). The # effective ceiling is min(user cap or ∞, SLACK_FIELD_CEILING) β€” # keep the tail on overflow (freshest thinking reads best). - # - # Overlap-aware merge: some providers deliver reasoning as - # CUMULATIVE snapshots (full text so far) rather than deltas, and - # thread-timing can re-deliver overlapping windows. Naive append - # rendered the same sentences 3-4Γ— (observed live 2026-07-05). - self._reasoning_details = _merge_reasoning(self._reasoning_details, line) + # Inputs are true deltas: the agent fires reasoning_callback once + # per streamed chunk, and the post-completion full-text re-fire is + # latched off (agent/chat_completion_helpers.py, the + # _reasoning_streamed_this_response guard), so plain append is + # correct β€” no dedup needed. + self._reasoning_details = (self._reasoning_details + " " + line).strip() cap = self.SLACK_FIELD_CEILING if self.REASONING_MAX_CHARS > 0: cap = min(self.REASONING_MAX_CHARS, cap) diff --git a/run_agent.py b/run_agent.py index 6c13f737c861..d34947a1ba0a 100644 --- a/run_agent.py +++ b/run_agent.py @@ -5214,7 +5214,15 @@ def _fire_stream_delta(self, text: str) -> None: self._record_streamed_assistant_text(text) def _fire_reasoning_delta(self, text: str) -> None: - """Fire reasoning callback if registered.""" + """Fire reasoning callback if registered. + + Also latches ``_reasoning_streamed_this_response`` so the + post-completion path in ``_build_assistant_message`` can tell that + reasoning was already delivered incrementally and skip its full-text + re-fire (previously it guessed via ``stream_delta_callback``, which + is the *text*-streaming consumer β€” None on gateway platforms β€” so + gateway consumers received every reasoning burst twice). + """ # Single-writer guard (#65991): fence out a superseded stream's # reasoning deltas the same way as content deltas. if self._stream_writer_superseded(): @@ -5222,6 +5230,7 @@ def _fire_reasoning_delta(self, text: str) -> None: return cb = self.reasoning_callback if cb is not None: + self._reasoning_streamed_this_response = True try: cb(text) except Exception: From 6df6cc2a413eae6b2b75c904605f5d59edc2d898 Mon Sep 17 00:00:00 2001 From: "carnie[bot]" Date: Sun, 5 Jul 2026 22:00:55 +0800 Subject: [PATCH 07/25] cleanup(slack): stable-head reasoning titles + CONTRIBUTING-aligned polish MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reasoning card title now set ONCE from the head of the thought and never rewritten (user-reported: rolling-tail titles churned on every flush and read as confusing mid-sentence fragments; the full text already lives in the collapsible details, so the title only needs to identify the thought). Cleanup sweep per CONTRIBUTING.md code style + module layering: - module docstring: layering contract (module owns presentation, run.py only correlates/schedules β€” port-ready for GatewayEventDispatcher), measured API limits, config pointers - precise type annotations on all dict/list state (PEP 585) - self-disabling failures log at warning (was info) β€” a silent debug/info-level disable cost a diagnosis cycle earlier - explicit __all__ covering the public helper surface - stale comment fixes (reasoning no longer routes through the header) Co-authored-by: Minh Nguyen --- gateway/run.py | 8 +-- gateway/slack_task_stream.py | 109 ++++++++++++++++++++--------------- 2 files changed, 68 insertions(+), 49 deletions(-) diff --git a/gateway/run.py b/gateway/run.py index d95f5d4e96ee..1812d47c144d 100644 --- a/gateway/run.py +++ b/gateway/run.py @@ -20659,10 +20659,10 @@ def _interim_assistant_cb(text: str, *, already_streamed: bool = False) -> None: ) else None ) - # Slack native task cards: stream reasoning deltas into the card - # header ("πŸ’­ latest thought"). Only wired when cards are active - # for this turn β€” reasoning_callback is otherwise unused by the - # gateway, so this is strictly additive. + # Slack native task cards: stream reasoning deltas into + # interleaved πŸ’­ cards on the task stream. Only wired when cards + # are active for this turn β€” reasoning_callback is otherwise + # unused by the gateway, so this is strictly additive. if _slack_native_cards and _slack_task_stream is not None: agent.reasoning_callback = _slack_reasoning_event # Discord voice verbal-ack hook (fires once per turn on first tool diff --git a/gateway/slack_task_stream.py b/gateway/slack_task_stream.py index 3b997adf5de3..94d336c4ca5d 100644 --- a/gateway/slack_task_stream.py +++ b/gateway/slack_task_stream.py @@ -4,13 +4,29 @@ bot render tool-call progress as a native, collapsible task-card timeline inside a message β€” the same UX Slack's own AI features use β€” instead of the plain markdown text bubbles the gateway edits by default. This module wraps -that lifecycle for a single streaming message so gateway/run.py can drive it -with the same tool-start/tool-finish events it already emits for the -markdown progress path. - -This is opt-in (``display.platforms.slack.tool_progress_native``) and -strictly additive: every other platform, and Slack installs that don't set -the flag, keep the existing markdown progress-bubble behavior untouched. +that lifecycle for a single agent turn so gateway/run.py can drive it with +the same tool-start/tool-finish events it already emits for the markdown +progress path. + +Layering contract: this module owns ALL presentation logic (labels, +categories, previews, summaries, source chips, error sniffing) as pure +helpers plus the ``SlackTaskStream`` lifecycle class; gateway/run.py only +correlates tool events and schedules coroutines. Keep it that way β€” the +module is self-contained (no gateway imports) so it can be reused verbatim +if the wiring moves (e.g. onto GatewayEventDispatcher, upstream PR #54522). + +Opt-in (``display.platforms.slack.tool_progress_native``) and strictly +additive: every other platform, and Slack installs that don't set the flag, +keep the existing markdown progress-bubble behavior untouched. Tuning knobs +(rollover thresholds, reasoning/output caps) resolve like any other display +setting β€” see gateway/display_config.py. + +Empirically measured Slack API limits (2026-07-05, ehoy +scripts/carnie/slack_stream_probe.py): + * a streamed message dies ~306s after startStream even with active + appends (absolute lifetime, not inactivity) β†’ proactive rollover; + * msg_too_long is per-chunk (a >12k field), NOT cumulative β€” 62k chars + of cumulative task_update content passed clean β†’ per-field ceilings. Reference: * https://docs.slack.dev/reference/methods/chat.startStream @@ -34,7 +50,7 @@ # --help", "Read β€” foo.py") instead of repeating raw tool names; the # category feeds the auto-generated turn header ("Searched Β· edited files Β· # ran commands"). Unlisted tools fall back to their raw name / no category. -_TOOL_META: dict = { +_TOOL_META: dict[str, tuple[str, Optional[str]]] = { "terminal": ("Exec", "ran commands"), "process": ("Process", "ran commands"), "execute_code": ("Run code", "ran commands"), @@ -338,13 +354,13 @@ def __init__( self._total_duration = 0.0 # Distinct tool-category buckets seen this turn, in first-seen order, # for the auto-generated header ("Searched Β· edited files Β· …"). - self._categories: list = [] + self._categories: list[str] = [] self._last_header: str = "" # Descriptive title and details per task id, so the finish update can # reuse them instead of wiping (a task_update with the same id # REPLACES the card wholesale β€” omitted fields vanish). - self._titles: dict = {} - self._details: dict = {} + self._titles: dict[int, str] = {} + self._details: dict[int, str] = {} # Interleaved reasoning cards: each burst of thinking between tool # calls gets its own πŸ’­ card in the timeline (updated in place while # the burst continues, finalized when the next tool starts). The @@ -357,7 +373,7 @@ def __init__( self._reasoning_count = 0 # Per-subagent state (card id β†’ {tools, number, start-time}) for the # numbered, timed delegate cards. - self._subagents: dict = {} + self._subagents: dict[str, dict[str, Any]] = {} # Rollover state: when the current streamed message ages/fills out, # it's closed and a fresh one continues the timeline. Tasks still # in_progress at rollover are tracked so they can be replayed onto @@ -366,9 +382,9 @@ def __init__( self._sent_chars = 0 # Last-sent size per card id, for net-delta size accounting (a # task_update replaces its card, so only growth counts). - self._chunk_sizes: dict = {} + self._chunk_sizes: dict[str, int] = {} self._rollovers = 0 - self._in_progress: dict = {} # task_id β†’ last-sent chunk kwargs + self._in_progress: dict[str, dict[str, Any]] = {} # task_id β†’ last-sent chunk # Serializes the open: tool events arrive back-to-back and each # task_started() awaits ensure_started(), so without a lock two # coroutines can both pass the ``_started`` check before either @@ -400,7 +416,7 @@ async def _start_locked(self) -> bool: self._started = True return True except Exception as e: # SlackApiError or any transport failure - logger.info("chat.startStream failed, disabling native task cards: %s", e) + logger.warning("chat.startStream failed, disabling native task cards: %s", e) self.disabled = True return False @@ -410,7 +426,7 @@ async def _open_stream(self) -> None: Raises on failure; callers decide whether that's fatal. Takes no locks (callers already hold whichever lock is appropriate). """ - kwargs: dict = { + kwargs: dict[str, Any] = { "channel": self.channel, "thread_ts": self.thread_ts, "task_display_mode": self.task_display_mode, @@ -598,12 +614,14 @@ async def reasoning_update(self, text: str) -> None: β€” an earlier version routed reasoning through the header, which wiped it (plan_update replaces the title wholesale). - Layout (per Minh 2026-07-05: full reasoning matters most β€” tool - previews are clutter, reasoning is signal): the TITLE shows the - rolling tail of the current thought at word-boundary (titles are - Slack-capped ~255); the collapsible DETAILS carries the full burst - text (tail-kept up to ~1500 chars), so expanding the πŸ’­ card reads - the whole thought, not a fragment. + Field split (title vs details): a card's ``title`` is the + always-visible line and Slack hard-caps it (~255); ``details`` is + the collapsible body (~12k). The title is set ONCE per card from + the head of the thought and never rewritten β€” an earlier version + showed the rolling tail, which churned on every flush and read as + confusing mid-sentence fragments (user-reported 2026-07-05). + Everything overflowing the title lives in details, which carries + the full accumulated burst (uncapped by default). Before the stream opens (thinking that precedes the first tool call β€” i.e. every turn's opening thought), the burst is BUFFERED rather @@ -621,32 +639,24 @@ async def reasoning_update(self, text: str) -> None: self._reasoning_count += 1 self._reasoning_open_id = f"think{self._reasoning_count}" self._reasoning_details = "" - # Details accumulate the full burst; reasoning is the highest-value - # content on the card, so the default cap is 0 (uncapped). The - # effective ceiling is min(user cap or ∞, SLACK_FIELD_CEILING) β€” - # keep the tail on overflow (freshest thinking reads best). - # Inputs are true deltas: the agent fires reasoning_callback once - # per streamed chunk, and the post-completion full-text re-fire is - # latched off (agent/chat_completion_helpers.py, the - # _reasoning_streamed_this_response guard), so plain append is - # correct β€” no dedup needed. + self._reasoning_title = "" + # Details accumulate the full burst β€” inputs are true deltas (the + # agent's post-completion full-text re-fire is latched off; see + # _reasoning_streamed_this_response in agent/chat_completion_helpers) + # so plain append is correct. Reasoning is the highest-value card + # content: default cap 0 (uncapped), effective ceiling + # min(user cap or ∞, SLACK_FIELD_CEILING), tail kept on overflow. self._reasoning_details = (self._reasoning_details + " " + line).strip() cap = self.SLACK_FIELD_CEILING if self.REASONING_MAX_CHARS > 0: cap = min(self.REASONING_MAX_CHARS, cap) if len(self._reasoning_details) > cap: clipped = self._reasoning_details[-cap:] - # Cut at the first word boundary inside the clip window. sp = clipped.find(" ") self._reasoning_details = "…" + clipped[sp + 1 if 0 <= sp < 40 else 0:] - # Title = the tail of the accumulated burst (not the raw delta, - # which can start mid-sentence), trimmed at a word boundary. - tail = self._reasoning_details[-245:] - if len(self._reasoning_details) > 245: - sp = tail.find(" ") - if 0 <= sp < 40: - tail = "…" + tail[sp + 1:] - self._reasoning_title = f"πŸ’­ {tail}"[:250] + # Title: head of the thought, set once, stable thereafter. + if not self._reasoning_title: + self._reasoning_title = f"πŸ’­ {_word_trim(self._reasoning_details, 240)}"[:250] if not self._started: return # buffered β€” flushed by the first task_started await self._append_raw_task( @@ -713,7 +723,7 @@ async def _append_raw_task( sources: Optional[List[dict]] = None, ) -> None: try: - chunk: dict = { + chunk: dict[str, Any] = { "type": "task_update", "id": task_id, "title": title, @@ -754,10 +764,10 @@ async def _append_raw_task( else: raise except Exception as e: - logger.info("chat.appendStream failed, disabling native task cards: %s", e) + logger.warning("chat.appendStream failed, disabling native task cards: %s", e) self.disabled = True - async def _send_chunk_locked(self, chunk: dict) -> None: + async def _send_chunk_locked(self, chunk: dict[str, Any]) -> None: """Send one chunk on the current stream. Caller holds _send_lock.""" await self.client.chat_appendStream( channel=self.channel, @@ -845,7 +855,7 @@ async def stop(self, final_text: Optional[str] = None) -> None: # Wait for any in-flight append to land before closing, so the # last task's status update isn't racing chat.stopStream. async with self._send_lock: - kwargs: dict = {"channel": self.channel, "ts": self.ts} + kwargs: dict[str, Any] = {"channel": self.channel, "ts": self.ts} if final_text: kwargs["markdown_text"] = final_text # Footer: a context block with turn stats, attached to the @@ -874,4 +884,13 @@ async def stop(self, final_text: Optional[str] = None) -> None: logger.info("chat.stopStream failed: %s", e) -__all__ = ["SlackTaskStream"] +__all__ = [ + "SlackTaskStream", + "clean_output_preview", + "result_looks_like_error", + "summarize_tool_title", + "tool_category", + "tool_details_from_args", + "tool_label", + "tool_sources", +] From fc2e1f1837609879b47aabfc069ca684879ae59d Mon Sep 17 00:00:00 2001 From: "carnie[bot]" Date: Sun, 5 Jul 2026 23:07:33 +0800 Subject: [PATCH 08/25] fix(agent): normalize cumulative-echo reasoning deltas at the stream chokepoint; TLDR reasoning titles MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Duplication root cause #2 (the one the callback-latch fix didn't cover): the PROVIDER re-sends accumulated reasoning inside the delta stream itself β€” trailing cumulative echoes / overlapping windows after internal reconnects. Proof: state.db rows where the stored reasoning field is two byte-identical halves (id 71877) β€” storage sits downstream of reasoning_parts accumulation, upstream of all callbacks, so the dupe entered via the deltas. Normalize at the accumulation chokepoint: suffix-strip cumulative snapshots, drop exact echoes, pass true deltas through. Fixes storage AND every consumer in one place. Card titles: sub-sentence TLDR (Claude-app rhythm) β€” first sentence, ~80-char word-trim cap, set once. Full text stays in details. Co-authored-by: Minh Nguyen --- agent/chat_completion_helpers.py | 26 ++++++++++++++++++++++---- gateway/slack_task_stream.py | 12 ++++++++++-- 2 files changed, 32 insertions(+), 6 deletions(-) diff --git a/agent/chat_completion_helpers.py b/agent/chat_completion_helpers.py index a850d1782a1a..870a34423ef0 100644 --- a/agent/chat_completion_helpers.py +++ b/agent/chat_completion_helpers.py @@ -2816,12 +2816,30 @@ def _call_chat_completions(stream_attempt_id: int): if hasattr(chunk, "model") and chunk.model: model_name = chunk.model - # Accumulate reasoning content + # Accumulate reasoning content. Providers are not consistent + # about what a reasoning "delta" contains: most send true + # incremental tokens, but some re-send the ENTIRE accumulated + # reasoning as a trailing chunk (cumulative echo) or re-deliver + # an overlapping window after an internal reconnect. Naively + # appending stores the reasoning doubled (observed 2026-07-05: + # state.db rows with byte-identical doubled halves) and fires + # duplicated text at reasoning consumers. Normalize here β€” the + # single chokepoint every consumer (trajectory storage, + # reasoning_callback, CLI display) sits downstream of. reasoning_text = getattr(delta, "reasoning_content", None) or getattr(delta, "reasoning", None) if reasoning_text: - reasoning_parts.append(reasoning_text) - _fire_first_delta() - agent._fire_reasoning_delta(reasoning_text) + _acc = "".join(reasoning_parts) + if _acc and reasoning_text.startswith(_acc): + # Cumulative snapshot (full text so far + maybe new + # tokens): keep only the new suffix. + reasoning_text = reasoning_text[len(_acc):] + elif _acc and (reasoning_text in _acc): + # Exact echo of text already accumulated: drop. + reasoning_text = "" + if reasoning_text: + reasoning_parts.append(reasoning_text) + _fire_first_delta() + agent._fire_reasoning_delta(reasoning_text) # Accumulate text content β€” fire callback only when no tool calls if delta and delta.content: diff --git a/gateway/slack_task_stream.py b/gateway/slack_task_stream.py index 94d336c4ca5d..f44d616b0e7d 100644 --- a/gateway/slack_task_stream.py +++ b/gateway/slack_task_stream.py @@ -654,9 +654,17 @@ async def reasoning_update(self, text: str) -> None: clipped = self._reasoning_details[-cap:] sp = clipped.find(" ") self._reasoning_details = "…" + clipped[sp + 1 if 0 <= sp < 40 else 0:] - # Title: head of the thought, set once, stable thereafter. + # Title: short TLDR-style header (Claude-app rhythm β€” headers are + # sub-sentence). First sentence of the thought, capped ~80 chars, + # set once and never rewritten; the full text lives in details. if not self._reasoning_title: - self._reasoning_title = f"πŸ’­ {_word_trim(self._reasoning_details, 240)}"[:250] + head = self._reasoning_details + for sep in (". ", "! ", "? ", " β€” "): + idx = head.find(sep) + if 0 < idx < 120: + head = head[: idx + 1].rstrip(" β€”") + break + self._reasoning_title = f"πŸ’­ {_word_trim(head, 80)}"[:250] if not self._started: return # buffered β€” flushed by the first task_started await self._append_raw_task( From 0f327090c8388f23738c7ebe998b6797a43530ec Mon Sep 17 00:00:00 2001 From: "carnie[bot]" Date: Sun, 5 Jul 2026 23:40:15 +0800 Subject: [PATCH 09/25] =?UTF-8?q?fix(slack):=20task=5Fupdate=20details=20A?= =?UTF-8?q?PPEND=20server-side=20=E2=80=94=20send=20deltas,=20not=20accumu?= =?UTF-8?q?lated=20text=20(repetition=20root=20cause=20#3)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Measured live (probe #2): two task_update chunks with the same id and details 'AAA'/'BBB' render as 'AAABBB' β€” the details field APPENDS across updates; title/status REPLACE. Landmine #3 ('task_update replaces wholesale') was wrong for details specifically, and every details-sending site assumed REPLACE: - reasoning flushes sent the full accumulated burst each time β†’ burst1+(burst1+burst2)+... staircase (the user-visible repetition that survived both agent-side dedup fixes β€” state.db was clean, the dup was born in the card render) - tool finish re-sent start-time details β†’ doubled content previews - subagent updates re-sent the whole tool trail β†’ trail staircase All sites now send only the not-yet-sent delta and let Slack accumulate. Rollover replay substitutes the full local text on the fresh (empty) card. _reasoning_unsent tracks the pending tail. Co-authored-by: Minh Nguyen --- gateway/slack_task_stream.py | 74 +++++++++++++++++++++++++----------- 1 file changed, 51 insertions(+), 23 deletions(-) diff --git a/gateway/slack_task_stream.py b/gateway/slack_task_stream.py index f44d616b0e7d..5cf0d37d7f93 100644 --- a/gateway/slack_task_stream.py +++ b/gateway/slack_task_stream.py @@ -370,6 +370,7 @@ def __init__( self._reasoning_open_id: Optional[str] = None self._reasoning_title: str = "" self._reasoning_details: str = "" + self._reasoning_unsent: str = "" self._reasoning_count = 0 # Per-subagent state (card id β†’ {tools, number, start-time}) for the # numbered, timed delegate cards. @@ -533,12 +534,12 @@ async def task_finished( self._total_duration += duration or 0.0 # +30 slack vs the run.py-side preview cap so a summary suffix fits. out = str(output)[: self.OUTPUT_PREVIEW_CHARS + 30] if output else None - # Re-send the start-time details so the collapsible content preview - # (file body for Write/Edit, code for Run) survives the finish - # update instead of being wiped by the card replacement. + # details APPEND server-side (measured 2026-07-05), so the start-time + # content preview persists on its own β€” re-sending it here would + # duplicate it. Send no details on the finish update. await self._append_task_update( index, title, status="complete", - details=self._details.get(index), output=out, sources=sources, + output=out, sources=sources, ) async def subagent_event( @@ -592,16 +593,17 @@ def _title(status_suffix: str = "") -> str: await self._append_raw_task(sid, _title(), status="in_progress") elif event_type == "subagent.tool" and tool_name: st["tools"].append(tool_label(tool_name)) - details = " β†’ ".join(st["tools"][-12:])[:500] + # details APPEND server-side β€” send only the new tool label and + # let Slack accumulate the trail ("A β†’ B β†’ C" grows one arrow + # per update instead of re-sending the whole trail). + step = tool_label(tool_name) + delta = step if len(st["tools"]) == 1 else f" β†’ {step}" await self._append_raw_task( - sid, _title(), status="in_progress", details=details, + sid, _title(), status="in_progress", details=delta, ) elif event_type == "subagent.complete": - details = " β†’ ".join(st["tools"][-12:])[:500] if st["tools"] else None suffix = "" if ok else " Β· βœ— failed" - await self._append_raw_task( - sid, _title(suffix), status="complete", details=details, - ) + await self._append_raw_task(sid, _title(suffix), status="complete") async def reasoning_update(self, text: str) -> None: """Render the model's thinking as interleaved πŸ’­ cards in the timeline. @@ -640,13 +642,18 @@ async def reasoning_update(self, text: str) -> None: self._reasoning_open_id = f"think{self._reasoning_count}" self._reasoning_details = "" self._reasoning_title = "" - # Details accumulate the full burst β€” inputs are true deltas (the - # agent's post-completion full-text re-fire is latched off; see - # _reasoning_streamed_this_response in agent/chat_completion_helpers) - # so plain append is correct. Reasoning is the highest-value card - # content: default cap 0 (uncapped), effective ceiling - # min(user cap or ∞, SLACK_FIELD_CEILING), tail kept on overflow. + self._reasoning_unsent = "" + # ``details`` APPENDS across task_update chunks with the same id β€” + # measured live 2026-07-05 (probe: two updates "AAA"/"BBB" stored + # as "AAABBB"); title/status REPLACE. So each flush must send ONLY + # the not-yet-sent delta, and Slack accumulates server-side. + # Sending the full running text each flush rendered the + # burst₁+(burst₁+burstβ‚‚)+… staircase duplication. + # _reasoning_details keeps the full local copy (rollover replay + + # finalize-before-stream-opens need it); _reasoning_unsent is the + # pending tail. self._reasoning_details = (self._reasoning_details + " " + line).strip() + self._reasoning_unsent = (self._reasoning_unsent + " " + line).strip() cap = self.SLACK_FIELD_CEILING if self.REASONING_MAX_CHARS > 0: cap = min(self.REASONING_MAX_CHARS, cap) @@ -667,22 +674,30 @@ async def reasoning_update(self, text: str) -> None: self._reasoning_title = f"πŸ’­ {_word_trim(head, 80)}"[:250] if not self._started: return # buffered β€” flushed by the first task_started + delta, self._reasoning_unsent = self._reasoning_unsent, "" + if not delta: + return await self._append_raw_task( self._reasoning_open_id, self._reasoning_title, - status="in_progress", details=self._reasoning_details, + status="in_progress", details=" " + delta, ) async def _finalize_reasoning_card(self) -> None: - """Mark the open πŸ’­ card complete (called when the next tool starts).""" + """Mark the open πŸ’­ card complete (called when the next tool starts). + + Sends only the still-unsent tail of the burst (details APPEND + server-side; re-sending the full text would duplicate it). In the + buffered pre-stream case nothing has been sent yet, so the unsent + tail IS the full burst β€” correct either way. + """ if self._reasoning_open_id is None: return - rid, title, details = ( - self._reasoning_open_id, self._reasoning_title, self._reasoning_details, - ) + rid, title = self._reasoning_open_id, self._reasoning_title + tail, self._reasoning_unsent = self._reasoning_unsent, "" self._reasoning_open_id = None self._reasoning_details = "" await self._append_raw_task( - rid, title, status="complete", details=details or None, + rid, title, status="complete", details=(" " + tail) if tail else None, ) async def set_plan_title(self, title: str) -> None: @@ -840,10 +855,23 @@ async def _rollover_locked(self) -> None: self.ts, self._rollovers, ) # Replay the turn header and any in-flight tasks on the new card. + # The fresh card starts empty, so replayed chunks need FULL content: + # for the open πŸ’­ card the tracked chunk only carries the last sent + # delta (details append server-side) β€” substitute the full local + # accumulated burst. replay: List[dict] = [] if self._last_header: replay.append({"type": "plan_update", "title": self._last_header[:250]}) - replay.extend(dict(c) for c in self._in_progress.values()) + for c in self._in_progress.values(): + chunk = dict(c) + if ( + self._reasoning_open_id is not None + and chunk.get("id") == self._reasoning_open_id + and self._reasoning_details + ): + chunk["details"] = self._reasoning_details + self._reasoning_unsent = "" + replay.append(chunk) for chunk in replay: await self._send_chunk_locked(chunk) From 18b33916a7094cd418633e4631b324587e49e007 Mon Sep 17 00:00:00 2001 From: "carnie[bot]" Date: Mon, 6 Jul 2026 00:23:12 +0800 Subject: [PATCH 10/25] fix(slack): gate sub-threshold reasoning fragments + raise details ceiling to 30k (probed) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fragment cards: the 2s flush timer can slice a burst mid-word, and a tool call arriving right after emitted the fragment as its own πŸ’­ card (observed: a card containing just 'I', its sentence continuing in the next card). Bursts under REASONING_MIN_CHARS (40) are now held β€” flushed with the next update or carried into the next burst at finalize β€” so thoughts don't split across cards at timer boundaries. Ceiling: probe #3 pushed single details chunks of 4k/8k/12k/16k/24k/32k β€” ALL accepted (documented 12k limit is markdown_text, not task_update details). Local ceiling raised 11k β†’ 30k; effectively no cap on real reasoning bursts. Co-authored-by: Minh Nguyen --- gateway/slack_task_stream.py | 50 +++++++++++++++++++++++++++--------- 1 file changed, 38 insertions(+), 12 deletions(-) diff --git a/gateway/slack_task_stream.py b/gateway/slack_task_stream.py index 5cf0d37d7f93..58c5f1f00a73 100644 --- a/gateway/slack_task_stream.py +++ b/gateway/slack_task_stream.py @@ -303,11 +303,19 @@ class SlackTaskStream: # Cap on accumulated πŸ’­ reasoning text per card. 0 = uncapped, bounded # only by SLACK_FIELD_CEILING below. REASONING_MAX_CHARS = 0 - # Slack's documented limit for a markdown_text field is 12,000 chars; - # task_update details ride the same message budget. Absolute ceiling - # applied even when the user uncaps reasoning, so one card can't blow - # the whole message. (Rollover handles the cumulative budget.) - SLACK_FIELD_CEILING = 11_000 + # Ceiling on the locally-kept reasoning copy (used for rollover replay). + # Probed 2026-07-05: single details chunks up to 32k accepted with no + # error β€” the documented 12k limit applies to markdown_text, not + # task_update details. 30k keeps one card's replay chunk comfortably + # under the rollover size budget while being far beyond any realistic + # single thinking burst. + SLACK_FIELD_CEILING = 30_000 + # Minimum accumulated chars before a πŸ’­ card is opened/updated. + # The flush timer can cut a burst mid-word (observed: a card containing + # just "I"); tiny fragments carry no signal, so hold them until the + # burst has substance. A pending fragment below this at finalize time + # is carried into the next burst rather than emitted as its own card. + REASONING_MIN_CHARS = 40 # Per-tool result preview length on finished cards. OUTPUT_PREVIEW_CHARS = 120 # Runaway guard: a turn pathological enough to need more fresh streams @@ -371,6 +379,7 @@ def __init__( self._reasoning_title: str = "" self._reasoning_details: str = "" self._reasoning_unsent: str = "" + self._reasoning_carry: str = "" self._reasoning_count = 0 # Per-subagent state (card id β†’ {tools, number, start-time}) for the # numbered, timed delegate cards. @@ -640,9 +649,12 @@ async def reasoning_update(self, text: str) -> None: if self._reasoning_open_id is None: self._reasoning_count += 1 self._reasoning_open_id = f"think{self._reasoning_count}" - self._reasoning_details = "" + # Carry any sub-threshold fragment from the previous burst + # (see _finalize_reasoning_card) instead of starting empty. + self._reasoning_details = self._reasoning_carry + self._reasoning_unsent = self._reasoning_carry + self._reasoning_carry = "" self._reasoning_title = "" - self._reasoning_unsent = "" # ``details`` APPENDS across task_update chunks with the same id β€” # measured live 2026-07-05 (probe: two updates "AAA"/"BBB" stored # as "AAABBB"); title/status REPLACE. So each flush must send ONLY @@ -661,6 +673,12 @@ async def reasoning_update(self, text: str) -> None: clipped = self._reasoning_details[-cap:] sp = clipped.find(" ") self._reasoning_details = "…" + clipped[sp + 1 if 0 <= sp < 40 else 0:] + # Hold sub-threshold bursts: the flush timer can slice mid-word + # ("I" as a whole card). Don't open/update the card until the + # burst has substance; held text flushes with the next update or + # carries into the next burst at finalize. + if len(self._reasoning_details) < self.REASONING_MIN_CHARS: + return # Title: short TLDR-style header (Claude-app rhythm β€” headers are # sub-sentence). First sentence of the thought, capped ~80 chars, # set once and never rewritten; the full text lives in details. @@ -683,15 +701,23 @@ async def reasoning_update(self, text: str) -> None: ) async def _finalize_reasoning_card(self) -> None: - """Mark the open πŸ’­ card complete (called when the next tool starts). + """Settle the open πŸ’­ card when the next tool starts. - Sends only the still-unsent tail of the burst (details APPEND - server-side; re-sending the full text would duplicate it). In the - buffered pre-stream case nothing has been sent yet, so the unsent - tail IS the full burst β€” correct either way. + Sub-threshold bursts (< REASONING_MIN_CHARS, e.g. a mid-word "I" + sliced off by the flush timer) never made it onto the card β€” carry + them into the next burst instead of emitting a fragment card. + Otherwise mark complete, sending only the still-unsent tail + (details APPEND server-side; re-sending duplicates). """ if self._reasoning_open_id is None: return + if len(self._reasoning_details) < self.REASONING_MIN_CHARS: + # Nothing was ever sent for this burst β€” roll it forward. + self._reasoning_carry = self._reasoning_details + self._reasoning_open_id = None + self._reasoning_details = "" + self._reasoning_unsent = "" + return rid, title = self._reasoning_open_id, self._reasoning_title tail, self._reasoning_unsent = self._reasoning_unsent, "" self._reasoning_open_id = None From f60e0a6ca9738cb823d1f7cdf3eb7601c40eb0b4 Mon Sep 17 00:00:00 2001 From: "carnie[bot]" Date: Mon, 6 Jul 2026 00:35:16 +0800 Subject: [PATCH 11/25] fix(slack): flush reasoning at sentence boundaries; trailing-space chunk joins MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The flush timer and tool-call finalize land at arbitrary stream positions, so cards ended/started mid-sentence β€” reading as truncation ('…cron reminders.' / next card starting 'tasks are due…', the sentence split across cards). Flushes now send only the complete-sentence prefix of the unsent buffer; the incomplete tail is held for the next flush, and finalize sends the remainder to the same card (the burst is over β€” the text belongs there, not on the next card). Whitespace: probe #4 measured Slack's join behavior β€” trailing spaces at chunk boundaries are preserved, leading spaces can be stripped at element boundaries (the 'reminders.Both' jam). Deltas now join with a trailing space instead of a leading one. Co-authored-by: Minh Nguyen --- gateway/slack_task_stream.py | 50 +++++++++++++++++++++++++++++------- 1 file changed, 41 insertions(+), 9 deletions(-) diff --git a/gateway/slack_task_stream.py b/gateway/slack_task_stream.py index 58c5f1f00a73..908f31728328 100644 --- a/gateway/slack_task_stream.py +++ b/gateway/slack_task_stream.py @@ -188,6 +188,30 @@ def tool_category(tool_name: str) -> Optional[str]: return meta[1] if meta else None +def _split_complete_sentences(text: str) -> tuple[str, str]: + """Split ``text`` into (complete sentences, trailing incomplete tail). + + Used to flush reasoning at sentence boundaries: the flush timer and + tool-call finalize land at arbitrary stream positions, and cutting + there splits a sentence across two πŸ’­ cards (observed live: card N + ending "…cron reminders.Both" with the sentence continuing on card + N+1). The last sentence-terminating punctuation wins; everything + after it is the held tail. + """ + best = -1 + for sep in (". ", "! ", "? ", ".\n", "!\n", "?\n"): + idx = text.rfind(sep) + if idx > best: + best = idx + if best < 0: + # Terminal punctuation at the very end counts as complete. + if text.rstrip().endswith((".", "!", "?", ":")): + return text, "" + return "", text + cut = best + 1 # include the punctuation, not the following space + return text[:cut], text[cut:].lstrip() + + def tool_details_from_args(tool_name: str, args: Any) -> Optional[str]: """Optional collapsible-body preview extracted from the tool's args.""" key = _CONTENT_ARG_BY_TOOL.get(tool_name) @@ -692,22 +716,29 @@ async def reasoning_update(self, text: str) -> None: self._reasoning_title = f"πŸ’­ {_word_trim(head, 80)}"[:250] if not self._started: return # buffered β€” flushed by the first task_started - delta, self._reasoning_unsent = self._reasoning_unsent, "" - if not delta: + # Flush at sentence boundaries only: send the complete-sentence + # prefix of the unsent buffer, hold the incomplete tail for the + # next flush (or the finalize/carry path). Cutting at raw timer + # positions split sentences across cards. Join with a TRAILING + # space β€” probe #4: Slack preserves trailing whitespace at chunk + # joins but strips leading whitespace at some element boundaries + # (the "reminders.Both" jam). + sendable, tail = _split_complete_sentences(self._reasoning_unsent) + if not sendable: return + self._reasoning_unsent = tail await self._append_raw_task( self._reasoning_open_id, self._reasoning_title, - status="in_progress", details=" " + delta, + status="in_progress", details=sendable.rstrip() + " ", ) async def _finalize_reasoning_card(self) -> None: """Settle the open πŸ’­ card when the next tool starts. - Sub-threshold bursts (< REASONING_MIN_CHARS, e.g. a mid-word "I" - sliced off by the flush timer) never made it onto the card β€” carry - them into the next burst instead of emitting a fragment card. - Otherwise mark complete, sending only the still-unsent tail - (details APPEND server-side; re-sending duplicates). + Sends any still-unsent text (complete or not β€” the burst is over, + so the remainder belongs to THIS card; only sub-threshold bursts + that never rendered are carried into the next burst instead of + emitting a fragment card). """ if self._reasoning_open_id is None: return @@ -723,7 +754,8 @@ async def _finalize_reasoning_card(self) -> None: self._reasoning_open_id = None self._reasoning_details = "" await self._append_raw_task( - rid, title, status="complete", details=(" " + tail) if tail else None, + rid, title, status="complete", + details=(tail.rstrip() + " ") if tail.strip() else None, ) async def set_plan_title(self, title: str) -> None: From 58cb8b65511875ab54b5fc70ef2cb409940cc5c9 Mon Sep 17 00:00:00 2001 From: "carnie[bot]" Date: Mon, 6 Jul 2026 00:46:50 +0800 Subject: [PATCH 12/25] fix(slack): drain reasoning throttle buffer before tool cards render MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The model always completes its reasoning before emitting a tool call β€” strictly sequential generation β€” so when tool.started fires, the full thought already exists. But up to 2s of its tail could be sitting in run.py's flush-throttle buffer, and it flushed AFTER the tool card, landing on a fresh πŸ’­ card: the thought visually split across the Exec entry (user-diagnosed 2026-07-06: 'it's not possible for it to start exec before it finished writing its plan β€” our sequence is messing up'). tool.started now drains the pending buffer first; the FIFO send lock preserves schedule order, so the card reads thought βœ“ β†’ tool with the complete thought on one card. Co-authored-by: Minh Nguyen --- gateway/run.py | 22 ++++++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/gateway/run.py b/gateway/run.py index 1812d47c144d..3f6eeab7e762 100644 --- a/gateway/run.py +++ b/gateway/run.py @@ -19578,6 +19578,28 @@ def _slack_task_event(event_type: str, tool_name: str, preview, args, kwargs) -> return from gateway import slack_task_stream as _sts if event_type == "tool.started": + # Drain any reasoning still sitting in the 2s throttle + # buffer BEFORE scheduling the tool card. The model always + # finishes its reasoning before emitting a tool call, so + # by the time this event fires the full thought exists β€” + # but up to 2s of its tail can be waiting out the throttle + # window. Without this drain, that tail flushes AFTER the + # tool card and rides on a fresh πŸ’­ card, splitting the + # thought across the Exec entry (observed live 2026-07-06). + # The stream's FIFO send lock preserves schedule order, so + # draining first guarantees thought βœ“ β†’ tool on the card. + _pending = _slack_reasoning_buf[0].strip() + _slack_reasoning_buf[0] = "" + _slack_reasoning_last[0] = time.monotonic() + if _pending: + _fut = safe_schedule_threadsafe( + _slack_task_stream.reasoning_update(_pending), + _voice_ack_loop, + logger=logger, + log_message="slack reasoning drain scheduling error", + ) + if _fut is not None: + _slack_task_futures.append(_fut) index = _slack_task_index[0] _slack_task_index[0] += 1 _slack_task_pending.setdefault(tool_name, []).append(index) From dec509c69224fcafa1ae3caaae31e1a7b77157c9 Mon Sep 17 00:00:00 2001 From: "carnie[bot]" Date: Mon, 6 Jul 2026 01:10:10 +0800 Subject: [PATCH 13/25] cleanup: drop dead _details cache (finish updates no longer re-send details since append-semantics fix) Co-authored-by: Minh Nguyen --- gateway/slack_task_stream.py | 9 +++------ 1 file changed, 3 insertions(+), 6 deletions(-) diff --git a/gateway/slack_task_stream.py b/gateway/slack_task_stream.py index 908f31728328..fa2a05b7984c 100644 --- a/gateway/slack_task_stream.py +++ b/gateway/slack_task_stream.py @@ -388,11 +388,10 @@ def __init__( # for the auto-generated header ("Searched Β· edited files Β· …"). self._categories: list[str] = [] self._last_header: str = "" - # Descriptive title and details per task id, so the finish update can - # reuse them instead of wiping (a task_update with the same id - # REPLACES the card wholesale β€” omitted fields vanish). + # Start-time title per task id: the finish update falls back to it + # when no descriptive summary is available (title REPLACES on each + # task_update; details APPENDS β€” see module docstring). self._titles: dict[int, str] = {} - self._details: dict[int, str] = {} # Interleaved reasoning cards: each burst of thinking between tool # calls gets its own πŸ’­ card in the timeline (updated in place while # the burst continues, finalized when the next tool starts). The @@ -503,8 +502,6 @@ async def task_started( title = f"{label} β€” {preview}" if preview else label title = title[:250] self._titles[index] = title - if details: - self._details[index] = details self._task_count += 1 # Track tool categories for the auto-generated turn header. cat = tool_category(tool_name) From 9088767f45d65db8c9f24869e6eee2d0ac3db895 Mon Sep 17 00:00:00 2001 From: "carnie[bot]" Date: Mon, 6 Jul 2026 02:08:42 +0800 Subject: [PATCH 14/25] =?UTF-8?q?tune:=20rollover=20240=E2=86=92290s=20(ma?= =?UTF-8?q?tch=20PR)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Minh Nguyen --- gateway/slack_task_stream.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/gateway/slack_task_stream.py b/gateway/slack_task_stream.py index fa2a05b7984c..a5b9fa69e8fb 100644 --- a/gateway/slack_task_stream.py +++ b/gateway/slack_task_stream.py @@ -318,11 +318,12 @@ class SlackTaskStream: # Proactive rollover thresholds, measured live 2026-07-05 via probe # (ehoy scripts/carnie/slack_stream_probe.py): a stream dies ~306s after startStream # even with appends every 20s β€” an ABSOLUTE lifetime, not inactivity β€” - # so roll at 240s (~80%, margin for jitter/slow appends). Cumulative + # so roll at 290s (~95% β€” post drain-fix, tool events arrive densely + # enough; a proactive miss triggers the tested reactive path). Cumulative # size probed clean past 61,920 chars (earlier msg_too_long failures # were single oversized chunks, since capped per-field), so the char # threshold is a loose backstop, not the binding constraint. - ROLLOVER_MAX_AGE_S = 240.0 + ROLLOVER_MAX_AGE_S = 290.0 ROLLOVER_MAX_CHARS = 40_000 # Cap on accumulated πŸ’­ reasoning text per card. 0 = uncapped, bounded # only by SLACK_FIELD_CEILING below. From 59b3ae9c20483169a81b66086837d3799140c663 Mon Sep 17 00:00:00 2001 From: "carnie[bot]" Date: Mon, 6 Jul 2026 02:24:40 +0800 Subject: [PATCH 15/25] feat(slack): identity header labels + per-subagent dedicated streams (YOLO experiment) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Header: cards now titled 'callsign Β· sess6 Β· HH:MM β€” ' so multiple streams in one thread are attributable. Subagents: each delegate_task child opens its OWN streamed message (own SlackTaskStream, header 'πŸ”€ subagent #N Β· '), tools rendering as first-class task entries on its card β€” main card + one live card per child, updating in parallel. Falls back to the single-card-on-main- stream rendering if the child stream can't open. Turn finally closes any child streams left open (crashed children can't freeze cards). Co-authored-by: Minh Nguyen --- gateway/run.py | 106 +++++++++++++++++++++++++++++++---- gateway/slack_task_stream.py | 13 ++++- 2 files changed, 106 insertions(+), 13 deletions(-) diff --git a/gateway/run.py b/gateway/run.py index 3f6eeab7e762..682ed2602159 100644 --- a/gateway/run.py +++ b/gateway/run.py @@ -19514,6 +19514,25 @@ def progress_callback(event_type: str, tool_name: str = None, preview: str = Non or (next(iter(_team_clients)) if len(_team_clients) == 1 else None) or getattr(source, "scope_id", None) ) + # Identity prefix for the header: agent callsign (first + # Slack mention pattern), short session id, and turn + # start time β€” makes cards attributable when multiple + # streams (main + subagents) share a thread. Guarded: + # this whole setup block disables cards on exception, + # and a label must never cost us the cards. + try: + _plat_cfg = self.config.platforms.get(source.platform) + _patterns = (getattr(_plat_cfg, "extra", None) or {}).get("mention_patterns") or [] + _callsign = str(_patterns[0]) if _patterns else "agent" + except Exception: + _callsign = "agent" + try: + _hdr_label = ( + f"{_callsign} Β· {str(session_id)[-6:]}" + f" Β· {datetime.now().strftime('%H:%M')}" + ) + except Exception: + _hdr_label = _callsign _slack_task_stream = SlackTaskStream( _slack_client, source.chat_id, str(_progress_thread_id), task_display_mode=str( @@ -19523,6 +19542,7 @@ def progress_callback(event_type: str, tool_name: str = None, preview: str = Non ), recipient_team_id=_stream_team_id, recipient_user_id=getattr(source, "user_id", None), + header_label=_hdr_label, # Tuning knobs β€” standard display-config resolution # (display.platforms.slack. β†’ display. β†’ # built-in default), so users adjust them like any @@ -19564,6 +19584,12 @@ def progress_callback(event_type: str, tool_name: str = None, preview: str = Non # task_finished updates are still queued β€” Slack then renders the # stuck-in_progress tasks with warning icons. _slack_task_futures: List[Any] = [] + # Per-subagent dedicated streams (YOLO experiment 2026-07-06): each + # delegate_task child gets its own streamed message so parallel + # children render as separate live blocks. key β†’ SlackTaskStream, + # plus a per-child monotonic tool index for its card entries. + _slack_subagent_streams: Dict[str, Any] = {} + _slack_subagent_tool_idx: Dict[str, int] = {} def _slack_task_event(event_type: str, tool_name: str, preview, args, kwargs) -> None: """Route a tool lifecycle event onto the Slack native task stream. @@ -19664,11 +19690,14 @@ def _slack_task_event(event_type: str, tool_name: str, preview, args, kwargs) -> _slack_task_futures.append(_fut) def _slack_subagent_event(event_type: str, tool_name, preview, kwargs) -> None: - """Route delegate_task child lifecycle events onto subagent cards. - - Relayed by _build_child_progress_callback with identity kwargs - (subagent_id, goal, task_index...). tool_name carries the child's - tool for subagent.tool events. + """Route delegate_task child lifecycle events onto PER-SUBAGENT streams. + + Each child gets its own streamed message (own SlackTaskStream) + below the main card, so parallel subagents render as separate + live-updating blocks: main card + one card per child. Falls + back to a card on the MAIN stream if the child's stream can't + open. Relayed by _build_child_progress_callback with identity + kwargs (subagent_id, goal, task_index...). """ if _slack_task_stream is None or _slack_task_stream.disabled: return @@ -19677,20 +19706,65 @@ def _slack_subagent_event(event_type: str, tool_name, preview, kwargs) -> None: or kwargs.get("task_index") or "0" ) - # Stable 1-based number for the card ("#1", "#2"): task_index is - # 0-based in a batch; +1 for display. Falls back to None (no - # number shown) when the relay didn't carry a task_index. _ti = kwargs.get("task_index") number = (_ti + 1) if isinstance(_ti, int) else None goal = str(kwargs.get("goal") or preview or "") ok = "error" not in str(kwargs.get("status") or "").lower() + + # Dedicated stream per child, created lazily on its first event. + sub = _slack_subagent_streams.get(key) + if sub is None and event_type == "subagent.start": + from gateway.slack_task_stream import SlackTaskStream + _n = f"#{number} " if number is not None else "" + _short_goal = goal[:60] + ("…" if len(goal) > 60 else "") + sub = SlackTaskStream( + _slack_task_stream.client, + _slack_task_stream.channel, + _slack_task_stream.thread_ts, + task_display_mode=_slack_task_stream.task_display_mode, + recipient_team_id=_slack_task_stream.recipient_team_id, + recipient_user_id=_slack_task_stream.recipient_user_id, + header_label=f"πŸ”€ subagent {_n}Β· {_short_goal}", + ) + _slack_subagent_streams[key] = sub + if sub is None or sub.disabled: + # Child stream unavailable β†’ legacy single-card fallback on + # the main stream (keeps observability rather than dropping). + _fut = safe_schedule_threadsafe( + _slack_task_stream.subagent_event( + event_type, key, goal=goal, tool_name=tool_name, ok=ok, number=number, + ), + _voice_ack_loop, + logger=logger, + log_message="slack subagent card scheduling error", + ) + if _fut is not None: + _slack_task_futures.append(_fut) + return + + # Child stream path: render the child's tools as first-class + # task entries on ITS card, exactly like the main turn. + if event_type == "subagent.start": + coro = sub.task_started(0, "delegate_task", f"started β€” {goal[:120]}") + elif event_type == "subagent.tool" and tool_name: + idx = _slack_subagent_tool_idx.get(key, 0) + 1 + _slack_subagent_tool_idx[key] = idx + coro = sub.task_started(idx, str(tool_name), None) + elif event_type == "subagent.complete": + # Settle every entry on the child card, then close its stream. + async def _finish(s=sub, okv=ok, k=key): + n = _slack_subagent_tool_idx.get(k, 0) + for i in range(n + 1): + await s.task_finished(i, "step", 0.0, okv) + await s.stop() + coro = _finish() + else: + return _fut = safe_schedule_threadsafe( - _slack_task_stream.subagent_event( - event_type, key, goal=goal, tool_name=tool_name, ok=ok, number=number, - ), + coro, _voice_ack_loop, logger=logger, - log_message="slack subagent card scheduling error", + log_message="slack subagent stream scheduling error", ) if _fut is not None: _slack_task_futures.append(_fut) @@ -22323,6 +22397,14 @@ def _stream_confirmed_final_delivery( except Exception: pass await _slack_task_stream.stop() + # Close any per-subagent streams still open (child + # crashed / never emitted subagent.complete) so their + # cards don't freeze in Slack's error state. + for _sub in _slack_subagent_streams.values(): + try: + await _sub.stop() + except Exception: + pass except Exception: logger.debug("SlackTaskStream.stop() failed", exc_info=True) diff --git a/gateway/slack_task_stream.py b/gateway/slack_task_stream.py index a5b9fa69e8fb..6eadc784bf08 100644 --- a/gateway/slack_task_stream.py +++ b/gateway/slack_task_stream.py @@ -361,6 +361,7 @@ def __init__( rollover_chars: Optional[int] = None, reasoning_chars: Optional[int] = None, output_chars: Optional[int] = None, + header_label: Optional[str] = None, ) -> None: self.client = client self.channel = channel @@ -368,6 +369,8 @@ def __init__( self.recipient_team_id = recipient_team_id self.recipient_user_id = recipient_user_id self.task_display_mode = task_display_mode + # Identity prefix for the card header ("carnie Β· a3f2c1 Β· 14:32"). + self.header_label = header_label # Config-driven tuning (None β†’ class default). reasoning cap of 0 # means uncapped; it is still clamped to SLACK_FIELD_CEILING. if rollover_age_s is not None and rollover_age_s > 0: @@ -522,13 +525,21 @@ async def task_started( await self._refresh_turn_header() async def _refresh_turn_header(self) -> None: - """Set the collapsible header to a phrase summarizing the turn.""" + """Set the collapsible header to a phrase summarizing the turn. + + Prefixed with the identity label when one was provided + ("carnie Β· a3f2c1 Β· 14:32 β€” Searched Β· edited files") so multiple + cards in one thread β€” main turn + per-subagent streams β€” are + attributable at a glance. + """ if not self._categories: return # Capitalize the first bucket, join the rest with " Β· ". cats = list(self._categories) cats[0] = cats[0][:1].upper() + cats[0][1:] header = " Β· ".join(cats) + if self.header_label: + header = f"{self.header_label} β€” {header}" if header != self._last_header: self._last_header = header await self.set_plan_title(header) From 9d8eab588fa4a7f41109ec74cd98087f7179e1b1 Mon Sep 17 00:00:00 2001 From: "carnie[bot]" Date: Mon, 6 Jul 2026 02:39:37 +0800 Subject: [PATCH 16/25] =?UTF-8?q?feat(slack):=20subagent=20cards=20?= =?UTF-8?q?=E2=80=94=20result=20summary=20as=20output,=20CAPS=20identity-f?= =?UTF-8?q?irst=20headers?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Per Minh review of live test: (1) subagent.complete relay carries summary/duration/status β€” surface the child's result summary as the output field on a final 'result' entry (cards previously showed tool trail but no outcome); (2) header order now identity-first: 'πŸ”€ SUBAGENT #N Β· HH:MM Β· ' (caps callsign for children; main agent keeps its lowercase config callsign). Co-authored-by: Minh Nguyen --- gateway/run.py | 26 ++++++++++++++++++++++---- 1 file changed, 22 insertions(+), 4 deletions(-) diff --git a/gateway/run.py b/gateway/run.py index 682ed2602159..98d18642278e 100644 --- a/gateway/run.py +++ b/gateway/run.py @@ -19715,7 +19715,7 @@ def _slack_subagent_event(event_type: str, tool_name, preview, kwargs) -> None: sub = _slack_subagent_streams.get(key) if sub is None and event_type == "subagent.start": from gateway.slack_task_stream import SlackTaskStream - _n = f"#{number} " if number is not None else "" + _n = f"#{number}" if number is not None else "" _short_goal = goal[:60] + ("…" if len(goal) > 60 else "") sub = SlackTaskStream( _slack_task_stream.client, @@ -19724,7 +19724,12 @@ def _slack_subagent_event(event_type: str, tool_name, preview, kwargs) -> None: task_display_mode=_slack_task_stream.task_display_mode, recipient_team_id=_slack_task_stream.recipient_team_id, recipient_user_id=_slack_task_stream.recipient_user_id, - header_label=f"πŸ”€ subagent {_n}Β· {_short_goal}", + # Caps-lock identity + time first, then the goal β€” + # per Minh 2026-07-06: "SUBAGENT 2 - time - then text". + header_label=( + f"πŸ”€ SUBAGENT {_n} Β· {datetime.now().strftime('%H:%M')}" + f" Β· {_short_goal}" + ), ) _slack_subagent_streams[key] = sub if sub is None or sub.disabled: @@ -19751,11 +19756,24 @@ def _slack_subagent_event(event_type: str, tool_name, preview, kwargs) -> None: _slack_subagent_tool_idx[key] = idx coro = sub.task_started(idx, str(tool_name), None) elif event_type == "subagent.complete": - # Settle every entry on the child card, then close its stream. - async def _finish(s=sub, okv=ok, k=key): + # Settle every entry, put the child's result summary on the + # final entry (the relay carries summary/duration/status β€” + # user-visible output was the missing piece), then close. + _summary = str(kwargs.get("summary") or kwargs.get("preview") or "")[:400] + _dur = float(kwargs.get("duration_seconds") or 0.0) + + async def _finish(s=sub, okv=ok, k=key, summ=_summary, dur=_dur): n = _slack_subagent_tool_idx.get(k, 0) + # Settle the start entry (0) and each tool entry (1..n). for i in range(n + 1): await s.task_finished(i, "step", 0.0, okv) + # New final entry carries the child's result summary. + await s.task_started(n + 1, "delegate_task", "result") + await s.task_finished( + n + 1, "delegate_task", dur, okv, + output=summ or None, + summary="βœ… completed" if okv else "failed", + ) await s.stop() coro = _finish() else: From 7d89250ad25a800f8a0f9ffe8538801ace7201c0 Mon Sep 17 00:00:00 2001 From: "carnie[bot]" Date: Mon, 6 Jul 2026 02:45:37 +0800 Subject: [PATCH 17/25] =?UTF-8?q?feat(slack):=20subagent=20card=20detail?= =?UTF-8?q?=20parity=20=E2=80=94=20pass=20tool=20arg=20previews=20through?= =?UTF-8?q?=20(relay=20already=20carried=20them)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Minh Nguyen --- gateway/run.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/gateway/run.py b/gateway/run.py index 98d18642278e..cfe7e57c45c6 100644 --- a/gateway/run.py +++ b/gateway/run.py @@ -19754,7 +19754,9 @@ def _slack_subagent_event(event_type: str, tool_name, preview, kwargs) -> None: elif event_type == "subagent.tool" and tool_name: idx = _slack_subagent_tool_idx.get(key, 0) + 1 _slack_subagent_tool_idx[key] = idx - coro = sub.task_started(idx, str(tool_name), None) + # preview carries the child tool's arg summary (same string + # the main card shows) β€” full detail parity with main. + coro = sub.task_started(idx, str(tool_name), preview) elif event_type == "subagent.complete": # Settle every entry, put the child's result summary on the # final entry (the relay carries summary/duration/status β€” From 8fcaef4834d892818cba4407f6cd91889248f6d0 Mon Sep 17 00:00:00 2001 From: "carnie[bot]" Date: Mon, 6 Jul 2026 02:54:38 +0800 Subject: [PATCH 18/25] =?UTF-8?q?fix(slack):=20sanitize=20subagent=20summa?= =?UTF-8?q?ries=20=E2=80=94=20leading=20markdown=20headings=20rendered=20a?= =?UTF-8?q?s=20empty=20output=20bullets?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A/B from live round-2 test: summary starting '**bold**' rendered on the card; summary starting '## Summary' rendered an EMPTY bullet β€” Slack's rich-text output field swallows leading heading markup. Strip heading markers and collapse newlines before sending. Co-authored-by: Minh Nguyen --- gateway/run.py | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/gateway/run.py b/gateway/run.py index cfe7e57c45c6..07f45294c51c 100644 --- a/gateway/run.py +++ b/gateway/run.py @@ -19761,7 +19761,14 @@ def _slack_subagent_event(event_type: str, tool_name, preview, kwargs) -> None: # Settle every entry, put the child's result summary on the # final entry (the relay carries summary/duration/status β€” # user-visible output was the missing piece), then close. - _summary = str(kwargs.get("summary") or kwargs.get("preview") or "")[:400] + # Sanitize: summaries beginning with a markdown heading + # ("## Summary") rendered as an EMPTY output bullet on the + # card (observed A/B 2026-07-06 round 2: bold-first summary + # rendered, heading-first summary vanished) β€” strip heading + # markers and collapse newlines before sending. + _summary = str(kwargs.get("summary") or kwargs.get("preview") or "") + _summary = re.sub(r"^\s*#{1,6}\s*", "", _summary) + _summary = " ".join(_summary.split())[:400] _dur = float(kwargs.get("duration_seconds") or 0.0) async def _finish(s=sub, okv=ok, k=key, summ=_summary, dur=_dur): From 00ebd80b29956489230f1d84a71acde249226f70 Mon Sep 17 00:00:00 2001 From: "carnie[bot]" Date: Mon, 6 Jul 2026 03:04:03 +0800 Subject: [PATCH 19/25] tune(slack): output previews default off (0); subagent summaries ride details MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Output previews get skimmed past (Minh 2026-07-06) β€” reasoning is the signal. OUTPUT_PREVIEW_CHARS <= 0 now disables them; config default 0, re-enable via tool_progress_native_output_chars. Subagent result summaries move from output to details so they survive the disable. Co-authored-by: Minh Nguyen --- gateway/display_config.py | 2 +- gateway/run.py | 10 +++++++--- gateway/slack_task_stream.py | 13 ++++++++++--- 3 files changed, 18 insertions(+), 7 deletions(-) diff --git a/gateway/display_config.py b/gateway/display_config.py index f6ad0487066a..a1d29080f88a 100644 --- a/gateway/display_config.py +++ b/gateway/display_config.py @@ -61,7 +61,7 @@ "tool_progress_native_rollover_age_s": 240, "tool_progress_native_rollover_chars": 40_000, "tool_progress_native_reasoning_chars": 0, - "tool_progress_native_output_chars": 120, + "tool_progress_native_output_chars": 0, "show_reasoning": False, # How a reasoning/thinking summary is rendered when show_reasoning is on. # "code" -> πŸ’­ **Reasoning:** + fenced code block (legacy default) diff --git a/gateway/run.py b/gateway/run.py index 07f45294c51c..5e34c66fe9e4 100644 --- a/gateway/run.py +++ b/gateway/run.py @@ -19776,11 +19776,15 @@ async def _finish(s=sub, okv=ok, k=key, summ=_summary, dur=_dur): # Settle the start entry (0) and each tool entry (1..n). for i in range(n + 1): await s.task_finished(i, "step", 0.0, okv) - # New final entry carries the child's result summary. - await s.task_started(n + 1, "delegate_task", "result") + # New final entry carries the child's result summary in + # DETAILS (not output β€” output previews can be disabled + # via output_chars=0, and the summary must survive that). + await s.task_started( + n + 1, "delegate_task", "result", + details=summ or None, + ) await s.task_finished( n + 1, "delegate_task", dur, okv, - output=summ or None, summary="βœ… completed" if okv else "failed", ) await s.stop() diff --git a/gateway/slack_task_stream.py b/gateway/slack_task_stream.py index 6eadc784bf08..ee7abce0fe17 100644 --- a/gateway/slack_task_stream.py +++ b/gateway/slack_task_stream.py @@ -341,8 +341,11 @@ class SlackTaskStream: # burst has substance. A pending fragment below this at finalize time # is carried into the next burst rather than emitted as its own card. REASONING_MIN_CHARS = 40 - # Per-tool result preview length on finished cards. - OUTPUT_PREVIEW_CHARS = 120 + # Per-tool result preview length on finished cards. 0 (default) = + # no output previews β€” per Minh 2026-07-06: output previews get + # skimmed past; reasoning is the signal. Set + # tool_progress_native_output_chars to re-enable. + OUTPUT_PREVIEW_CHARS = 0 # Runaway guard: a turn pathological enough to need more fresh streams # than this should fall back to markdown instead. Sized generously β€” # age-based rollover alone consumes one per ~4 min, so a legitimate @@ -575,7 +578,11 @@ async def task_finished( title = f"{base} Β· βœ— failed"[:250] self._total_duration += duration or 0.0 # +30 slack vs the run.py-side preview cap so a summary suffix fits. - out = str(output)[: self.OUTPUT_PREVIEW_CHARS + 30] if output else None + # OUTPUT_PREVIEW_CHARS <= 0 disables output previews entirely. + if self.OUTPUT_PREVIEW_CHARS <= 0: + out = None + else: + out = str(output)[: self.OUTPUT_PREVIEW_CHARS + 30] if output else None # details APPEND server-side (measured 2026-07-05), so the start-time # content preview persists on its own β€” re-sending it here would # duplicate it. Send no details on the finish update. From 54b2b1fb10b9a1e9a7c5e9dc001189ceda394153 Mon Sep 17 00:00:00 2001 From: "carnie[bot]" Date: Mon, 6 Jul 2026 03:20:09 +0800 Subject: [PATCH 20/25] =?UTF-8?q?fix(slack):=20serialize=20stream=20opens?= =?UTF-8?q?=20=E2=80=94=20main=20card=20always=20renders=20above=20subagen?= =?UTF-8?q?t=20cards?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Thread position = chat.startStream COMPLETION order, and main + child streams raced their first HTTP calls (observed live: SUBAGENT #2 above main, #1 below). Child creation now schedules an ordered opener behind a FIFO asyncio.Lock: main's stream is ensured open first, then children open strictly in relay order (= task order). Deterministic layout: main, #1, #2, ... Co-authored-by: Minh Nguyen --- gateway/run.py | 23 +++++++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/gateway/run.py b/gateway/run.py index 5e34c66fe9e4..65bfca2b3792 100644 --- a/gateway/run.py +++ b/gateway/run.py @@ -19590,6 +19590,10 @@ def progress_callback(event_type: str, tool_name: str = None, preview: str = Non # plus a per-child monotonic tool index for its card entries. _slack_subagent_streams: Dict[str, Any] = {} _slack_subagent_tool_idx: Dict[str, int] = {} + # FIFO gate serializing stream OPENS (main first, then children in + # relay order) β€” thread position is startStream completion order, + # so unserialized opens race and children can render above main. + _slack_subagent_open_lock = asyncio.Lock() def _slack_task_event(event_type: str, tool_name: str, preview, args, kwargs) -> None: """Route a tool lifecycle event onto the Slack native task stream. @@ -19732,6 +19736,25 @@ def _slack_subagent_event(event_type: str, tool_name, preview, kwargs) -> None: ), ) _slack_subagent_streams[key] = sub + # Thread position = startStream COMPLETION order, and the + # main + child streams otherwise race their first HTTP + # calls (observed live: SUBAGENT #2 rendered above main, + # #1 below). Open this child's stream NOW, in relay order, + # gated on: main open first, then children strictly by + # arrival (task order). asyncio.Lock is FIFO, so awaiting + # openers in schedule order serializes thread placement. + async def _open_ordered(s=sub): + async with _slack_subagent_open_lock: + await _slack_task_stream.ensure_started() + await s.ensure_started() + _fut = safe_schedule_threadsafe( + _open_ordered(), + _voice_ack_loop, + logger=logger, + log_message="slack subagent stream open error", + ) + if _fut is not None: + _slack_task_futures.append(_fut) if sub is None or sub.disabled: # Child stream unavailable β†’ legacy single-card fallback on # the main stream (keeps observability rather than dropping). From 2e27c5f91d80369cdeb9d7474d4c7d800eeaec1f Mon Sep 17 00:00:00 2001 From: "carnie[bot]" Date: Mon, 6 Jul 2026 04:08:14 +0800 Subject: [PATCH 21/25] =?UTF-8?q?fix(slack):=20assign=20subagent=20numbers?= =?UTF-8?q?=20at=20open=20time=20=E2=80=94=20displayed=20#N=20always=20mat?= =?UTF-8?q?ches=20thread=20position?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Round 2 of the ordering race: the FIFO open gate put main first, but child-vs-child order still followed relay arrival (per-child worker thread scheduling β€” #2's thread can fire subagent.start before #1's, observed live). Numbering by task_index therefore couldn't match thread position. Now the card number is drawn from a counter INSIDE the open lock: open order == number order == thread order, by construction. Child task/finish coroutines gate on a per-child open Event so nothing lazily opens a stream around the ordering lock (self-open fallback after 15s so a failed open can't wedge rendering). Verified: 50-trial async simulation of racing opens+events β€” main first, numbers == thread positions in all trials. Co-authored-by: Minh Nguyen --- gateway/run.py | 68 +++++++++++++++++++++++++++++++++----------------- 1 file changed, 45 insertions(+), 23 deletions(-) diff --git a/gateway/run.py b/gateway/run.py index 65bfca2b3792..761df7a7a0f3 100644 --- a/gateway/run.py +++ b/gateway/run.py @@ -19591,9 +19591,14 @@ def progress_callback(event_type: str, tool_name: str = None, preview: str = Non _slack_subagent_streams: Dict[str, Any] = {} _slack_subagent_tool_idx: Dict[str, int] = {} # FIFO gate serializing stream OPENS (main first, then children in - # relay order) β€” thread position is startStream completion order, + # open order) β€” thread position is startStream completion order, # so unserialized opens race and children can render above main. + # Card numbers are assigned inside the gate (open order == display + # order == thread order); per-child Events let tool/complete + # coroutines wait for the ordered open before touching the stream. _slack_subagent_open_lock = asyncio.Lock() + _slack_subagent_open_done: Dict[str, asyncio.Event] = {} + _slack_subagent_seq = [0] def _slack_task_event(event_type: str, tool_name: str, preview, args, kwargs) -> None: """Route a tool lifecycle event onto the Slack native task stream. @@ -19719,7 +19724,6 @@ def _slack_subagent_event(event_type: str, tool_name, preview, kwargs) -> None: sub = _slack_subagent_streams.get(key) if sub is None and event_type == "subagent.start": from gateway.slack_task_stream import SlackTaskStream - _n = f"#{number}" if number is not None else "" _short_goal = goal[:60] + ("…" if len(goal) > 60 else "") sub = SlackTaskStream( _slack_task_stream.client, @@ -19728,25 +19732,28 @@ def _slack_subagent_event(event_type: str, tool_name, preview, kwargs) -> None: task_display_mode=_slack_task_stream.task_display_mode, recipient_team_id=_slack_task_stream.recipient_team_id, recipient_user_id=_slack_task_stream.recipient_user_id, - # Caps-lock identity + time first, then the goal β€” - # per Minh 2026-07-06: "SUBAGENT 2 - time - then text". - header_label=( - f"πŸ”€ SUBAGENT {_n} Β· {datetime.now().strftime('%H:%M')}" - f" Β· {_short_goal}" - ), ) _slack_subagent_streams[key] = sub - # Thread position = startStream COMPLETION order, and the - # main + child streams otherwise race their first HTTP - # calls (observed live: SUBAGENT #2 rendered above main, - # #1 below). Open this child's stream NOW, in relay order, - # gated on: main open first, then children strictly by - # arrival (task order). asyncio.Lock is FIFO, so awaiting - # openers in schedule order serializes thread placement. - async def _open_ordered(s=sub): - async with _slack_subagent_open_lock: - await _slack_task_stream.ensure_started() - await s.ensure_started() + _slack_subagent_open_done[key] = asyncio.Event() + # Thread position = startStream COMPLETION order. Opens are + # serialized behind a FIFO lock (main first), and the card + # NUMBER is assigned at open time from a counter β€” so the + # displayed #N always matches thread position by + # construction. (Numbering by task_index raced: relay + # arrival order is per-child worker-thread scheduling, and + # #2's thread can fire before #1's β€” observed live twice.) + async def _open_ordered(s=sub, k=key, g=_short_goal): + try: + async with _slack_subagent_open_lock: + await _slack_task_stream.ensure_started() + _slack_subagent_seq[0] += 1 + s.header_label = ( + f"πŸ”€ SUBAGENT #{_slack_subagent_seq[0]}" + f" Β· {datetime.now().strftime('%H:%M')} Β· {g}" + ) + await s.ensure_started() + finally: + _slack_subagent_open_done[k].set() _fut = safe_schedule_threadsafe( _open_ordered(), _voice_ack_loop, @@ -19771,15 +19778,30 @@ async def _open_ordered(s=sub): return # Child stream path: render the child's tools as first-class - # task entries on ITS card, exactly like the main turn. + # task entries on ITS card, exactly like the main turn. Every + # coroutine first waits for the ordered open (the task/finish + # paths would otherwise lazily open the stream themselves, + # bypassing the ordering gate). + _gate = _slack_subagent_open_done.get(key) + + async def _gated(coro_factory): + if _gate is not None: + try: + await asyncio.wait_for(_gate.wait(), timeout=15) + except asyncio.TimeoutError: + pass # open failed/slow β€” proceed, self-open fallback + await coro_factory() + if event_type == "subagent.start": - coro = sub.task_started(0, "delegate_task", f"started β€” {goal[:120]}") + coro = _gated(lambda s=sub, g=goal: s.task_started( + 0, "delegate_task", f"started β€” {g[:120]}")) elif event_type == "subagent.tool" and tool_name: idx = _slack_subagent_tool_idx.get(key, 0) + 1 _slack_subagent_tool_idx[key] = idx # preview carries the child tool's arg summary (same string # the main card shows) β€” full detail parity with main. - coro = sub.task_started(idx, str(tool_name), preview) + coro = _gated(lambda s=sub, i=idx, t=str(tool_name), p=preview: + s.task_started(i, t, p)) elif event_type == "subagent.complete": # Settle every entry, put the child's result summary on the # final entry (the relay carries summary/duration/status β€” @@ -19811,7 +19833,7 @@ async def _finish(s=sub, okv=ok, k=key, summ=_summary, dur=_dur): summary="βœ… completed" if okv else "failed", ) await s.stop() - coro = _finish() + coro = _gated(_finish) else: return _fut = safe_schedule_threadsafe( From 9cfc771bdaa2b5722d026f5944ed0f012563e962 Mon Sep 17 00:00:00 2001 From: "carnie[bot]" Date: Mon, 6 Jul 2026 04:28:06 +0800 Subject: [PATCH 22/25] docs(slack): clarify task_index number is fallback-only (child streams number at open time) Co-authored-by: Minh Nguyen --- gateway/run.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/gateway/run.py b/gateway/run.py index 761df7a7a0f3..ee0f66c99065 100644 --- a/gateway/run.py +++ b/gateway/run.py @@ -19716,6 +19716,9 @@ def _slack_subagent_event(event_type: str, tool_name, preview, kwargs) -> None: or "0" ) _ti = kwargs.get("task_index") + # task_index-based number: used ONLY by the single-card fallback + # below. Dedicated child streams number themselves at open time + # (inside the FIFO gate) so displayed #N matches thread position. number = (_ti + 1) if isinstance(_ti, int) else None goal = str(kwargs.get("goal") or preview or "") ok = "error" not in str(kwargs.get("status") or "").lower() From 544653155a370d18769f6d3541bf4812c8934337 Mon Sep 17 00:00:00 2001 From: "carnie[bot]" Date: Mon, 20 Jul 2026 02:36:54 +0800 Subject: [PATCH 23/25] =?UTF-8?q?fix(slack):=20background=20subagent=20str?= =?UTF-8?q?eams=20=E2=80=94=20no=20turn-end=20kill,=20=E2=A4=B5=20continua?= =?UTF-8?q?tion=20markers,=20tail-only=20reasoning=20replay?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three related fixes for subagent cards duplicating after stream rollover (user-reported 2026-07-20, thread 1784483152.731389): 1. Turn-end cleanup only stops streams whose child emitted subagent.complete. Background children outliving the turn keep their streams; stopping them mid-flight forced footer-less reactive rollovers β†’ orphan continuation cards below the final reply (log: 3x 'closing old stream failed' at 01:38:08-18). 2. Continuation cards self-identify: header gets a ‡ prefix after any rollover (both in _refresh_turn_header and the rollover header replay). header_label (SUBAGENT #N Β· HH:MM) is preserved, so numbers never change across continuations β€” out-of-order continuation messages stay attributable instead of reading as renumbering. 3. Rollover replays only the TAIL (500 chars) of an open πŸ’­ card β€” the full burst already lives on the closed card above; wholesale replay was the 'thinking blocks repeat' symptom. Local full copy kept. tests/gateway/test_slack.py: 216 passed. Takes effect on gateway restart. Co-authored-by: Minh Nguyen --- gateway/run.py | 33 +++++++++++++++++++++++++---- gateway/slack_task_stream.py | 40 ++++++++++++++++++++++++++++++++++-- 2 files changed, 67 insertions(+), 6 deletions(-) diff --git a/gateway/run.py b/gateway/run.py index ee0f66c99065..0f80159a78c0 100644 --- a/gateway/run.py +++ b/gateway/run.py @@ -19590,6 +19590,18 @@ def progress_callback(event_type: str, tool_name: str = None, preview: str = Non # plus a per-child monotonic tool index for its card entries. _slack_subagent_streams: Dict[str, Any] = {} _slack_subagent_tool_idx: Dict[str, int] = {} + # Children that emitted subagent.complete. Turn-end cleanup stops + # ONLY these (idempotent safety β€” their complete handler already + # stops the stream). Background children outliving the turn keep + # their streams open and stop themselves on completion + # (delegate_tool emits subagent.complete even on timeout/exception, + # so "never completes" β‰ˆ process death only). Stopping live + # children's streams at turn end killed them mid-flight: the + # child's next event hit a dead stream β†’ reactive rollover β†’ + # orphan continuation card below the final reply, with no + # "‡ continued below" footer attachable to the already-stopped + # old card (observed live 2026-07-20 01:38, 3 children). + _slack_subagent_completed: set = set() # FIFO gate serializing stream OPENS (main first, then children in # open order) β€” thread position is startStream completion order, # so unserialized opens race and children can render above main. @@ -19806,6 +19818,9 @@ async def _gated(coro_factory): coro = _gated(lambda s=sub, i=idx, t=str(tool_name), p=preview: s.task_started(i, t, p)) elif event_type == "subagent.complete": + # Mark done so turn-end cleanup knows this child's stream is + # safe to (re-)stop; live children are left alone there. + _slack_subagent_completed.add(key) # Settle every entry, put the child's result summary on the # final entry (the relay carries summary/duration/status β€” # user-visible output was the missing piece), then close. @@ -22476,10 +22491,20 @@ def _stream_confirmed_final_delivery( except Exception: pass await _slack_task_stream.stop() - # Close any per-subagent streams still open (child - # crashed / never emitted subagent.complete) so their - # cards don't freeze in Slack's error state. - for _sub in _slack_subagent_streams.values(): + # Close per-subagent streams β€” but ONLY for children + # that emitted subagent.complete (their own handler + # already stopped the stream; this is an idempotent + # safety net for scheduling races). Background children + # still running past turn end keep their streams open β€” + # stopping those here killed them mid-flight and forced + # footer-less orphan continuation cards below the final + # reply (observed 2026-07-20 01:38). They stop + # themselves via their complete handler; a child that + # dies without ever completing leaves one frozen card, + # which is the honest rendering of that failure. + for _key, _sub in _slack_subagent_streams.items(): + if _key not in _slack_subagent_completed: + continue try: await _sub.stop() except Exception: diff --git a/gateway/slack_task_stream.py b/gateway/slack_task_stream.py index ee7abce0fe17..59eb281f081c 100644 --- a/gateway/slack_task_stream.py +++ b/gateway/slack_task_stream.py @@ -341,6 +341,13 @@ class SlackTaskStream: # burst has substance. A pending fragment below this at finalize time # is carried into the next burst rather than emitted as its own card. REASONING_MIN_CHARS = 40 + # Max chars of an OPEN πŸ’­ card replayed onto a fresh stream at + # rollover. The full burst already lives on the closed card above + # ("‡ continued below"); replaying it wholesale re-posted the whole + # thought and read as duplication (user-reported 2026-07-20). Only + # the tail carries continuation context. The full local copy in + # _reasoning_details is unaffected. + ROLLOVER_REASONING_TAIL = 500 # Per-tool result preview length on finished cards. 0 (default) = # no output previews β€” per Minh 2026-07-06: output previews get # skimmed past; reasoning is the signal. Set @@ -543,6 +550,14 @@ async def _refresh_turn_header(self) -> None: header = " Β· ".join(cats) if self.header_label: header = f"{self.header_label} β€” {header}" + # Continuation marker: after a rollover this stream's card is a + # fresh message continuing an earlier one, so the header must + # self-identify as such β€” an unmarked replayed header reads as a + # duplicate card (user-reported 2026-07-20). The header_label + # (identity + number, e.g. "SUBAGENT #2 Β· 01:37") is preserved, + # so out-of-order continuation messages stay attributable. + if self._rollovers: + header = f"‡ {header}" if header != self._last_header: self._last_header = header await self.set_plan_title(header) @@ -935,7 +950,16 @@ async def _rollover_locked(self) -> None: # accumulated burst. replay: List[dict] = [] if self._last_header: - replay.append({"type": "plan_update", "title": self._last_header[:250]}) + # Stamp the replayed header as a continuation (see + # _refresh_turn_header) β€” the pre-rollover _last_header has no + # marker, and headers are only re-sent when a new tool category + # appears, so without stamping here the continuation card + # usually keeps an unmarked header and reads as a duplicate. + _title = self._last_header + if not _title.startswith("‡"): + _title = f"‡ {_title}" + self._last_header = _title + replay.append({"type": "plan_update", "title": _title[:250]}) for c in self._in_progress.values(): chunk = dict(c) if ( @@ -943,7 +967,19 @@ async def _rollover_locked(self) -> None: and chunk.get("id") == self._reasoning_open_id and self._reasoning_details ): - chunk["details"] = self._reasoning_details + # Replay only the TAIL of the open πŸ’­ card, not the full + # accumulated burst β€” the full text already lives on the + # closed card above, and re-posting it wholesale is what + # rendered as "thinking blocks repeat" (user-reported + # 2026-07-20). The local full copy is kept (future + # rollovers re-tail from it); only the replayed chunk is + # trimmed. Cut on a word boundary, mark with a leading … + tail = self._reasoning_details + if len(tail) > self.ROLLOVER_REASONING_TAIL: + tail = tail[-self.ROLLOVER_REASONING_TAIL:] + sp = tail.find(" ") + tail = "… " + tail[sp + 1 if 0 <= sp < 40 else 0:] + chunk["details"] = tail self._reasoning_unsent = "" replay.append(chunk) for chunk in replay: From 23ed6d152955ced569ff5f57298975387a4a196e Mon Sep 17 00:00:00 2001 From: "carnie[bot]" Date: Sun, 26 Jul 2026 23:54:00 +0800 Subject: [PATCH 24/25] fix(agent): sync reasoning-dedup fixes from #59009 onto the task-cards branch MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The task-cards branch carried an older revision of the shared agent-core reasoning fixes: extract_reasoning() used list-membership (`summary not in reasoning_parts`) instead of substring containment, so a streamed response whose reasoning arrives BOTH as the accumulated string AND chunked across reasoning_details blocks stored the reasoning doubled. #59009 fixed this during review; the two branches then diverged. Two tests in TestReasoningDeltasFiredFlag failed on this branch while passing on #59009 β€” that was the tell. Ports agent_runtime_helpers.py, chat_completion_helpers.py, run_agent.py and the dedup regression test from #59009 so both PRs share one implementation. 103 tests pass (test_reasoning_command + dedup_59009 + onepassword). Co-authored-by: Minh Nguyen --- agent/agent_runtime_helpers.py | 12 +- agent/chat_completion_helpers.py | 138 +++++-- run_agent.py | 14 +- .../test_reasoning_delivery_dedup_59009.py | 344 ++++++++++++++++++ 4 files changed, 480 insertions(+), 28 deletions(-) create mode 100644 tests/agent/test_reasoning_delivery_dedup_59009.py diff --git a/agent/agent_runtime_helpers.py b/agent/agent_runtime_helpers.py index 263cf1563a27..5746642ae708 100644 --- a/agent/agent_runtime_helpers.py +++ b/agent/agent_runtime_helpers.py @@ -1549,7 +1549,17 @@ def extract_reasoning(agent, assistant_message) -> Optional[str]: or detail.get('content') or detail.get('text') ) - if summary and summary not in reasoning_parts: + # Substring containment, NOT list membership: streamed + # responses carry the SAME reasoning twice β€” once as the + # accumulated ``reasoning``/``reasoning_content`` string and + # again chunked across reasoning_details thinking blocks. + # Each individual block != the accumulated string exactly, + # so an equality test appends every block on top of the + # full text and the stored reasoning comes out doubled + # (observed 2026-07-16: state.db rows byte-identical to + # blocks-joined Γ— 2). A block whose text already appears + # inside a collected part is a re-delivery, not new content. + if summary and not any(summary in part for part in reasoning_parts): reasoning_parts.append(summary) # Some providers embed reasoning directly inside assistant content diff --git a/agent/chat_completion_helpers.py b/agent/chat_completion_helpers.py index 870a34423ef0..55a46d285f5f 100644 --- a/agent/chat_completion_helpers.py +++ b/agent/chat_completion_helpers.py @@ -517,6 +517,14 @@ def interruptible_api_call(agent, api_kwargs: dict): the main retry loop can try again with backoff / credential rotation / provider fallback. """ + # New response starting β€” clear the per-response reasoning-delivery + # latch so a stale value from a previous (possibly aborted) response + # can never suppress this response's reasoning delivery. Consumed by + # build_assistant_message; set by _fire_reasoning_delta. Cleared + # before the direct-call branch so ALL paths (including cron/inline) + # start each response with a clean latch. + agent._reasoning_streamed_this_response = False + # Cron and other non-interactive, nested-pool contexts must not spawn the # interrupt worker β€” it wedges before the socket opens on the 2nd+ call # (#62151). Run inline instead. See should_use_direct_api_call. @@ -1265,22 +1273,42 @@ def build_assistant_message(agent, assistant_message, finish_reason: str) -> dic logging.debug(f"Captured reasoning ({len(reasoning_text)} chars): {reasoning_text}") if reasoning_text and agent.reasoning_callback: - # Skip the callback when this response's reasoning was already - # delivered incrementally via _fire_reasoning_delta (structured - # reasoning_content deltas or streamed tag extraction) β€” - # read-and-clear the latch it sets. Re-firing here handed consumers - # the SAME reasoning twice: once as deltas, once as the full - # accumulated text (observed as 2-4x duplicated sentences on the - # Slack native task cards, 2026-07-05). The previous guard tested - # ``stream_delta_callback``/``_stream_callback``, but those are the - # *text*-streaming consumers β€” None on gateway platforms even while - # reasoning deltas stream fine β€” so the guard never tripped there. - # When nothing streamed (non-streaming modes: batch, quiet, - # streaming-disabled providers), the latch is unset and we fire so - # those consumers still get reasoning exactly once. + # Deliver reasoning to the callback EXACTLY ONCE per response. + # Two independent suppression signals, and BOTH must be checked: + # + # (a) the per-response latch set by _fire_reasoning_delta β€” + # structured reasoning deltas were already delivered + # incrementally to THIS callback during streaming. Gateway + # platforms register reasoning_callback while the *text* + # stream callbacks are None, so signal (b) alone never trips + # there and consumers received every reasoning burst twice: + # once as deltas, once as this full accumulated re-fire + # (observed as 2-4x duplicated sentences on the Slack native + # task cards, 2026-07-05). + # + # (b) active text-stream consumers β€” reasoning that arrives + # inline as / tags in content is + # extracted and displayed by the CLI's tag-extraction path + # (cli.py _stream_reasoning_delta), which never touches the + # agent latch, so signal (a) alone would re-fire here and + # duplicate the CLI reasoning box. Regression contract: + # tests/cli/test_reasoning_command.py + # (TestReasoningDeltasFiredFlag streaming-active cases). + # + # When neither signal tripped (non-streaming modes: gateway turns + # without reasoning deltas, batch, quiet, streaming-disabled + # providers), fire so those consumers still get reasoning exactly + # once. The latch is read-and-cleared here and additionally reset + # at the start of every API call (interruptible_api_call / + # interruptible_streaming_api_call) so a stale value from an + # aborted response can never suppress a later delivery. _already_streamed = getattr(agent, "_reasoning_streamed_this_response", False) agent._reasoning_streamed_this_response = False - if not _already_streamed: + _text_stream_active = bool( + getattr(agent, "stream_delta_callback", None) + or getattr(agent, "_stream_callback", None) + ) + if not _already_streamed and not _text_stream_active: try: agent.reasoning_callback(reasoning_text) except Exception: @@ -2258,6 +2286,12 @@ def interruptible_streaming_api_call(agent, api_kwargs: dict, *, on_first_delta= if agent._interrupt_requested: raise InterruptedError("Agent interrupted before streaming API call") + # New response starting β€” clear the per-response reasoning-delivery + # latch (see the matching comment in interruptible_api_call). The + # delegating branches below (direct-call, codex) re-clear via + # _interruptible_api_call; clearing here covers the streaming paths. + agent._reasoning_streamed_this_response = False + # Cron and other non-interactive, nested-pool contexts deadlock on the # spawned worker thread (#62151). They also have no stream consumer, so the # deltas this path produces go nowhere. Delegate to the non-streaming entry @@ -2828,14 +2862,9 @@ def _call_chat_completions(stream_attempt_id: int): # reasoning_callback, CLI display) sits downstream of. reasoning_text = getattr(delta, "reasoning_content", None) or getattr(delta, "reasoning", None) if reasoning_text: - _acc = "".join(reasoning_parts) - if _acc and reasoning_text.startswith(_acc): - # Cumulative snapshot (full text so far + maybe new - # tokens): keep only the new suffix. - reasoning_text = reasoning_text[len(_acc):] - elif _acc and (reasoning_text in _acc): - # Exact echo of text already accumulated: drop. - reasoning_text = "" + reasoning_text = normalize_reasoning_delta( + "".join(reasoning_parts), reasoning_text + ) if reasoning_text: reasoning_parts.append(reasoning_text) _fire_first_delta() @@ -3854,6 +3883,70 @@ def _call(): _reset_stale_streak(agent) return result["response"] +# Minimum suffix/prefix overlap (chars) treated as a provider re-delivery +# rather than legitimate repetition. 24 chars β‰ˆ 6+ tokens β€” far beyond any +# plausible legitimate immediate repetition at a token boundary, while +# reconnect re-deliveries observed in practice overlap by hundreds+ chars. +_MIN_REASONING_OVERLAP = 24 + + +def normalize_reasoning_delta(accumulated: str, delta: str) -> str: + """Normalize one incoming reasoning delta against the accumulated text. + + Providers are not consistent about what a reasoning "delta" contains. + Three provider misbehaviors are corrected here (all observed in the + wild, 2026-07-05 β€” state.db rows with byte-identical doubled halves): + + 1. **Cumulative snapshot** β€” the chunk re-sends the ENTIRE accumulated + reasoning so far (optionally plus new tokens). Detected via + ``delta.startswith(accumulated)``; only the new suffix is kept. + 2. **Exact echo** β€” the chunk is a byte-identical re-send of the full + accumulated text. Dropped (this is case 1 with an empty suffix). + 3. **Overlapping re-delivery** β€” after an internal reconnect the + provider re-sends a trailing window of already-delivered text + followed by new tokens. Detected via a longest suffix(accumulated)/ + prefix(delta) match; the overlapped head is trimmed. + + A minimum-overlap gate (``_MIN_REASONING_OVERLAP`` chars) protects + legitimate repetition: real reasoning frequently repeats short + substrings ("the", " so ", word fragments at token boundaries), and a + naive containment test (``delta in accumulated``) silently discards + such valid tokens. Short overlaps are therefore treated as genuinely + new text and appended verbatim β€” for true incremental token streams + (deltas a few chars long) this function is a near-passthrough. + Duplicating a few chars in the pathological case is recoverable noise; + dropping real tokens is silent data loss, so the gate errs toward + appending. + + Returns the (possibly trimmed) text to append, or "" when the delta + carries nothing new. + """ + if not delta: + return "" + if not accumulated: + return delta + # Case 1+2: cumulative snapshot / exact echo β€” gated on the accumulated + # text being long enough to make a startswith match meaningful. Early in + # the stream a SHORT accumulated prefix ("the") is trivially also the + # prefix of a legitimate repeated token ("the" again), and an ungated + # test silently dropped such tokens (caught by randomized stress tests, + # 2026-07-16: streams starting with a repeated word lost the repeat). + # The gate is safe for the misbehaviors this function exists to fix: + # both observed modes (trailing full-text echo, post-reconnect window + # re-delivery) occur late in a stream, when the accumulated text is far + # past 24 chars and the gate has long since engaged. + if len(accumulated) >= _MIN_REASONING_OVERLAP and delta.startswith(accumulated): + return delta[len(accumulated):] + # Case 3: overlapping re-delivery β€” longest suffix of ``accumulated`` + # that is a prefix of ``delta``, gated to β‰₯ _MIN_REASONING_OVERLAP so + # legitimate short repetitions are never eaten. + max_probe = min(len(accumulated), len(delta)) + for probe in range(max_probe, _MIN_REASONING_OVERLAP - 1, -1): + if accumulated.endswith(delta[:probe]): + return delta[probe:] + return delta + + # ── Provider fallback ────────────────────────────────────────────────── @@ -3862,6 +3955,7 @@ def _call(): "interruptible_api_call", "build_api_kwargs", "build_assistant_message", + "normalize_reasoning_delta", "try_activate_fallback", "handle_max_iterations", "cleanup_task_resources", diff --git a/run_agent.py b/run_agent.py index d34947a1ba0a..2f63213d401d 100644 --- a/run_agent.py +++ b/run_agent.py @@ -5217,11 +5217,15 @@ def _fire_reasoning_delta(self, text: str) -> None: """Fire reasoning callback if registered. Also latches ``_reasoning_streamed_this_response`` so the - post-completion path in ``_build_assistant_message`` can tell that - reasoning was already delivered incrementally and skip its full-text - re-fire (previously it guessed via ``stream_delta_callback``, which - is the *text*-streaming consumer β€” None on gateway platforms β€” so - gateway consumers received every reasoning burst twice). + post-completion path in ``build_assistant_message`` can tell that + reasoning was already delivered incrementally to the reasoning + callback and skip its full-text re-fire. This latch is one of two + suppression signals checked there β€” the other is active text-stream + consumers, which covers the CLI's -tag extraction path + (cli.py _stream_reasoning_delta) that displays reasoning without + going through this method. The latch is cleared at the start of + every API call (interruptible_api_call / + interruptible_streaming_api_call), scoping it to a single response. """ # Single-writer guard (#65991): fence out a superseded stream's # reasoning deltas the same way as content deltas. diff --git a/tests/agent/test_reasoning_delivery_dedup_59009.py b/tests/agent/test_reasoning_delivery_dedup_59009.py new file mode 100644 index 000000000000..d8a1efcb909e --- /dev/null +++ b/tests/agent/test_reasoning_delivery_dedup_59009.py @@ -0,0 +1,344 @@ +"""Regression guards for #59009 β€” reasoning delivered duplicated (2-4x) to +gateway consumers. + +Two independent bugs produced duplicated reasoning: + +1. **Post-completion re-fire on gateway platforms.** Gateway consumers + register ``reasoning_callback`` while the *text*-stream callbacks + (``stream_delta_callback`` / ``_stream_callback``) are None. The old + guard in ``build_assistant_message`` only tested the text-stream + callbacks, so reasoning that had already been streamed incrementally + via ``_fire_reasoning_delta`` was re-fired again as one accumulated + blob β€” every reasoning burst arrived twice. + + The fix latches ``_reasoning_streamed_this_response`` inside + ``_fire_reasoning_delta`` and checks BOTH signals in + ``build_assistant_message``: the latch (gateway reasoning-delta path) + and active text-stream consumers (CLI -tag extraction path, + which displays reasoning without touching the latch β€” see + tests/cli/test_reasoning_command.py TestReasoningDeltasFiredFlag). + +2. **Provider delta misbehavior.** Some providers re-send the entire + accumulated reasoning as a trailing chunk (cumulative echo) or + re-deliver an overlapping window after an internal reconnect. Naive + appending stored the reasoning doubled. ``normalize_reasoning_delta`` + corrects both β€” with a minimum-overlap gate so legitimate short + repetitions ("the", token fragments) are never dropped. +""" + +import unittest +from types import SimpleNamespace + +from run_agent import AIAgent +from agent.chat_completion_helpers import ( + _MIN_REASONING_OVERLAP, + normalize_reasoning_delta, +) + + +def _make_agent(**overrides): + agent = AIAgent.__new__(AIAgent) + agent.reasoning_callback = None + agent.stream_delta_callback = None + agent._stream_callback = None + agent.verbose_logging = False + for key, value in overrides.items(): + setattr(agent, key, value) + return agent + + +class TestGatewayShapedCallbacks(unittest.TestCase): + """Gateway platforms: reasoning_callback set, text-stream callbacks None.""" + + def test_streamed_reasoning_not_refired_post_completion(self): + """The core #59009 duplication: deltas streamed to the reasoning + callback must NOT be re-fired as a full blob by + build_assistant_message, even though text-stream callbacks are None + (the exact callback shape the gateway configures).""" + agent = _make_agent() + captured = [] + agent.reasoning_callback = lambda t: captured.append(t) + + # Simulate the streaming loop delivering reasoning deltas. + agent._fire_reasoning_delta("Let me think ") + agent._fire_reasoning_delta("about merging.") + self.assertEqual(captured, ["Let me think ", "about merging."]) + + # Post-completion: the accumulated reasoning arrives on the message. + msg = SimpleNamespace( + content="I'll merge that.", + tool_calls=None, + reasoning_content="Let me think about merging.", + reasoning=None, + reasoning_details=None, + ) + agent._build_assistant_message(msg, "stop") + + # No third (duplicated, accumulated) delivery. + self.assertEqual(captured, ["Let me think ", "about merging."]) + + def test_non_streamed_reasoning_fires_exactly_once(self): + """When nothing streamed (non-streaming gateway turn), the + post-completion path must still deliver reasoning β€” exactly once.""" + agent = _make_agent() + captured = [] + agent.reasoning_callback = lambda t: captured.append(t) + + msg = SimpleNamespace( + content="Done.", + tool_calls=None, + reasoning_content="Reasoning that never streamed.", + reasoning=None, + reasoning_details=None, + ) + agent._build_assistant_message(msg, "stop") + self.assertEqual(captured, ["Reasoning that never streamed."]) + + def test_latch_is_read_and_cleared(self): + """The latch must be scoped to a single response: consumed by one + build_assistant_message call, so the NEXT response (no streaming) + still gets its reasoning delivered.""" + agent = _make_agent() + captured = [] + agent.reasoning_callback = lambda t: captured.append(t) + + agent._fire_reasoning_delta("First response reasoning.") + msg1 = SimpleNamespace( + content="One.", + tool_calls=None, + reasoning_content="First response reasoning.", + reasoning=None, + reasoning_details=None, + ) + agent._build_assistant_message(msg1, "stop") + + # Second response: nothing streamed. Must fire. + msg2 = SimpleNamespace( + content="Two.", + tool_calls=None, + reasoning_content="Second response reasoning.", + reasoning=None, + reasoning_details=None, + ) + agent._build_assistant_message(msg2, "stop") + + self.assertEqual( + captured, + ["First response reasoning.", "Second response reasoning."], + ) + + def test_stale_latch_does_not_leak_without_callback_delivery(self): + """_fire_reasoning_delta with no callback registered must NOT set + the latch β€” nothing was delivered, so build_assistant_message + (with a callback registered later, e.g. gateway wiring order) + must still fire.""" + agent = _make_agent() + agent._fire_reasoning_delta("dropped β€” no consumer") + + captured = [] + agent.reasoning_callback = lambda t: captured.append(t) + msg = SimpleNamespace( + content="Done.", + tool_calls=None, + reasoning_content="Now-visible reasoning.", + reasoning=None, + reasoning_details=None, + ) + agent._build_assistant_message(msg, "stop") + self.assertEqual(captured, ["Now-visible reasoning."]) + + +class TestCliShapedCallbacks(unittest.TestCase): + """CLI tag-extraction path: text-stream callbacks active, reasoning + arrives inline in content β€” the latch never trips, but the existing + text-stream suppression must be retained (regression contract also + pinned in tests/cli/test_reasoning_command.py).""" + + def test_text_stream_active_suppresses_post_completion_fire(self): + agent = _make_agent() + captured = [] + agent.reasoning_callback = lambda t: captured.append(t) + agent.stream_delta_callback = lambda t: None # streaming active + + # Reasoning came via content tag extraction (cli.py + # _stream_reasoning_delta) β€” agent latch untouched. + msg = SimpleNamespace( + content="I'll merge that.", + tool_calls=None, + reasoning_content="Let me merge the PR.", + reasoning=None, + reasoning_details=None, + ) + agent._build_assistant_message(msg, "stop") + self.assertEqual(captured, []) + + def test_internal_stream_callback_also_suppresses(self): + agent = _make_agent() + captured = [] + agent.reasoning_callback = lambda t: captured.append(t) + agent._stream_callback = lambda t: None + msg = SimpleNamespace( + content="Done.", + tool_calls=None, + reasoning_content="Reasoning.", + reasoning=None, + reasoning_details=None, + ) + agent._build_assistant_message(msg, "stop") + self.assertEqual(captured, []) + + +class TestNormalizeReasoningDelta(unittest.TestCase): + """Provider delta normalization: dedupe cumulative echoes and reconnect + overlaps WITHOUT dropping legitimate repeated tokens.""" + + def test_empty_delta(self): + self.assertEqual(normalize_reasoning_delta("abc", ""), "") + + def test_first_delta_passthrough(self): + self.assertEqual(normalize_reasoning_delta("", "Hello"), "Hello") + + def test_incremental_tokens_passthrough(self): + """Normal providers: true incremental tokens append verbatim.""" + acc = "Let me think" + self.assertEqual(normalize_reasoning_delta(acc, " about"), " about") + + def test_cumulative_snapshot_keeps_new_suffix(self): + acc = "Let me think about this problem carefully" # past the gate + delta = acc + " and then merge." + self.assertEqual( + normalize_reasoning_delta(acc, delta), " and then merge." + ) + + def test_early_stream_snapshot_below_gate_appends_verbatim(self): + """Contract: below _MIN_REASONING_OVERLAP accumulated chars the + snapshot branch does NOT engage β€” a short prefix match is + indistinguishable from legitimate repetition ("the" + "the quick"), + and dropping real tokens is worse than briefly duplicating a short + one. Cumulative providers self-heal via the overlap branch once + past the gate.""" + acc = "the " + delta = "the quick" + self.assertEqual(normalize_reasoning_delta(acc, delta), delta) + + def test_exact_echo_dropped(self): + """The doubled-halves bug: provider re-sends the full accumulated + reasoning as a trailing chunk. Must be dropped entirely.""" + acc = "Full reasoning text that already streamed." + self.assertEqual(normalize_reasoning_delta(acc, acc), "") + + def test_reconnect_overlap_trimmed(self): + """Overlapping re-delivery: delta re-sends a long tail of already + delivered text followed by new tokens β€” overlapped head trimmed.""" + overlap = "x" * (_MIN_REASONING_OVERLAP + 10) + acc = "prefix text " + overlap + delta = overlap + " NEW TOKENS" + self.assertEqual(normalize_reasoning_delta(acc, delta), " NEW TOKENS") + + def test_short_repeated_token_not_dropped(self): + """The reviewer-flagged over-broad case: a short delta that happens + to be a substring of earlier text is LEGITIMATE repetition and must + be appended, not discarded.""" + acc = "I think the answer is that the list" + # " the" appears twice in acc already β€” still a valid new token. + self.assertEqual(normalize_reasoning_delta(acc, " the"), " the") + + def test_short_suffix_overlap_not_trimmed(self): + """Suffix/prefix overlaps below the gate are treated as legitimate + text (e.g. 'so ' ending acc and starting delta), not re-delivery.""" + acc = "and so " + delta = "so what happens next" + self.assertEqual(normalize_reasoning_delta(acc, delta), delta) + + def test_repeated_phrase_verbatim_not_dropped(self): + """A repeated phrase that is NOT a suffix/prefix overlap (appears + mid-accumulated-text) must never be dropped, regardless of length.""" + phrase = "check the merge conflicts carefully before pushing" + acc = f"First I will {phrase} and then run tests. Next" + self.assertEqual(normalize_reasoning_delta(acc, phrase), phrase) + + def test_streaming_loop_dedupes_cumulative_echo_end_to_end(self): + """Wire the normalizer the way the streaming loop uses it and feed + the observed provider misbehavior: tokens then a full echo.""" + parts = [] + fired = [] + + def _consume(chunk_text): + text = normalize_reasoning_delta("".join(parts), chunk_text) + if text: + parts.append(text) + fired.append(text) + + _consume("Let me ") + _consume("think about ") + _consume("merging.") + _consume("Let me think about merging.") # trailing cumulative echo + + self.assertEqual("".join(parts), "Let me think about merging.") + self.assertEqual(fired, ["Let me ", "think about ", "merging."]) + + +class TestExtractReasoningDetailsDedup(unittest.TestCase): + """extract_reasoning must not double reasoning when a streamed response + carries BOTH the accumulated ``reasoning`` string AND reasoning_details + thinking blocks of the same content (dogfood-observed 2026-07-16: + state.db reasoning columns byte-identical to blocks-joined Γ— 2, because + the old dedup was exact list membership and each individual block never + equals the accumulated string).""" + + def _agent(self): + return SimpleNamespace() + + def test_accumulated_plus_matching_blocks_not_doubled(self): + from agent.agent_runtime_helpers import extract_reasoning + + blocks = [ + {"type": "thinking", "thinking": "First chunk of thinking."}, + {"type": "thinking", "thinking": "Second, different chunk."}, + ] + accumulated = "First chunk of thinking.\n\nSecond, different chunk." + msg = SimpleNamespace( + reasoning=accumulated, + reasoning_content=None, + reasoning_details=blocks, + content="final answer", + ) + self.assertEqual(extract_reasoning(self._agent(), msg), accumulated) + + def test_distinct_blocks_all_survive(self): + from agent.agent_runtime_helpers import extract_reasoning + + msg = SimpleNamespace( + reasoning=None, + reasoning_content=None, + reasoning_details=[ + {"type": "thinking", "thinking": "Alpha block."}, + {"type": "thinking", "thinking": "Beta block, different."}, + ], + content="x", + ) + self.assertEqual( + extract_reasoning(self._agent(), msg), + "Alpha block.\n\nBeta block, different.", + ) + + def test_genuinely_new_detail_content_kept(self): + from agent.agent_runtime_helpers import extract_reasoning + + msg = SimpleNamespace( + reasoning="Streamed text.", + reasoning_content=None, + reasoning_details=[ + {"type": "thinking", "thinking": "Streamed text."}, + {"type": "thinking", "thinking": "Novel unseen block."}, + ], + content="x", + ) + self.assertEqual( + extract_reasoning(self._agent(), msg), + "Streamed text.\n\nNovel unseen block.", + ) + + +if __name__ == "__main__": + unittest.main() From 893ca6b20f73c580ce59dae7f568a2d45675dadf Mon Sep 17 00:00:00 2001 From: "carnie[bot]" Date: Mon, 27 Jul 2026 04:05:17 +0800 Subject: [PATCH 25/25] chore: map carnie-bot@openclaw.local -> menhguin in contributors/ MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Unblocks the check-attribution CI job, which hard-fails on any author email lacking a contributors/emails/ mapping. Every commit on this branch is authored by carnie[bot] β€” Minh Nguyen's (@menhguin) agent identity, which authors commits on his behalf. Follows the existing precedent for agent/local-domain emails already mapped in that directory (agent@hermes.dev, agent@agents-Mac-mini.local, 87degrees@87ui-Macmini.local). Added via scripts/add_contributor.py as the file header instructs (not by editing the frozen AUTHOR_MAP in scripts/release.py). One file per email, so it cannot merge-conflict. Note the other failing check on this PR β€” 'Python lints / Windows footguns (blocking)' β€” is unrelated to this branch: it flags scripts/tool_search_livetest2.py:190, an upstream file absent from this branch, which teknium1 already fixed in 0e2808729 on 2026-07-26 after this PR's CI last ran. Verified locally by merging this branch into current upstream main and running the real checker: 'βœ“ No Windows footguns found (818 file(s) scanned)', exit 0. This push re-triggers CI, which should clear it. Co-authored-by: Minh Nguyen --- contributors/emails/carnie-bot@openclaw.local | 2 ++ 1 file changed, 2 insertions(+) create mode 100644 contributors/emails/carnie-bot@openclaw.local diff --git a/contributors/emails/carnie-bot@openclaw.local b/contributors/emails/carnie-bot@openclaw.local new file mode 100644 index 000000000000..7e8717b3ac39 --- /dev/null +++ b/contributors/emails/carnie-bot@openclaw.local @@ -0,0 +1,2 @@ +menhguin +# Carnie agent commits β€” PRs #59009 / #59010