@inline-chat/hermes-agent-adapter 1.1.0 → 1.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -8,6 +8,9 @@ from __future__ import annotations
8
8
 
9
9
  import asyncio
10
10
  import base64
11
+ import concurrent.futures
12
+ import contextvars
13
+ import functools
11
14
  import hashlib
12
15
  import inspect
13
16
  import json
@@ -15,6 +18,7 @@ import logging
15
18
  import math
16
19
  import mimetypes
17
20
  import os
21
+ import queue
18
22
  import re
19
23
  import secrets
20
24
  import shlex
@@ -25,7 +29,9 @@ import subprocess
25
29
  import sys
26
30
  import threading
27
31
  import time
28
- from collections import OrderedDict
32
+ import urllib.error
33
+ import urllib.request
34
+ from collections import OrderedDict, deque
29
35
  from datetime import datetime, timezone
30
36
  from pathlib import Path
31
37
  from typing import Any, Callable, Dict, List, NamedTuple, Optional
@@ -59,7 +65,7 @@ from .message_actions import (
59
65
  parse_inline_agent_action_reply_target,
60
66
  resolve_inline_message_action_ownership,
61
67
  )
62
- from .profile import bot_state_key, inline_env, legacy_state_dir, may_adopt_legacy_state, state_dir
68
+ from .profile import bot_state_key, hermes_home, inline_env, legacy_state_dir, may_adopt_legacy_state, state_dir
63
69
  from .telemetry import capture_plugin_error
64
70
  from .widgets import has_widget_markup, scan_widgets, split_widget_aware, streaming_preview, without_widgets
65
71
 
@@ -204,7 +210,9 @@ _INLINE_DISPLAY_DEFAULTS = {
204
210
  "tool_progress_grouping": "accumulate",
205
211
  "cleanup_progress": True,
206
212
  "streaming": False,
207
- "interim_assistant_messages": False,
213
+ # Hermes' own between-tool-calls messages: Friendly posts them as short updates and
214
+ # Detailed folds them into the progress message (see InlineAdapter.send).
215
+ "interim_assistant_messages": True,
208
216
  "show_reasoning": False,
209
217
  "tool_preview_length": 40,
210
218
  "busy_ack_detail": False,
@@ -937,6 +945,536 @@ def _accepts_keyword(fn: Callable[..., Any], name: str) -> bool:
937
945
  return name in params or any(p.kind is inspect.Parameter.VAR_KEYWORD for p in params.values())
938
946
 
939
947
 
948
+ # ── Detailed progress (Output: Detailed / Friendly) ──────────────────────────────────────
949
+ # Hermes reports its work to plugins through public hooks: pre/post_tool_call,
950
+ # subagent_start/stop and pre_approval_request/post_approval_response. Each hook finds the
951
+ # Inline turn its session belongs to (gateway.session_context) and only enqueues in memory:
952
+ # pre_tool_call blocks the tool while it runs. One worker thread per adapter posts the
953
+ # events, in order, to the sidecar, which draws them with @inline-chat/agent-activity.
954
+
955
+ _ACTIVITY_POST_TIMEOUT_SECONDS = 10.0
956
+ _ACTIVITY_FLUSH_TIMEOUT_SECONDS = 30.0
957
+ # The answer waits at most this long for "Worked for" to land above it.
958
+ _ACTIVITY_FINAL_WAIT_SECONDS = 15.0
959
+ # An interim message waits at most this long for the chat's mode before it posts as is.
960
+ _ACTIVITY_MODE_WAIT_SECONDS = 5.0
961
+ _ACTIVITY_OUTPUT_LIMIT = 2_000
962
+ _ACTIVITY_DIFF_LIMIT = 8_000
963
+ _ACTIVITY_TITLE_LIMIT = 100
964
+ _ACTIVITY_PLAN_LIMIT = 50
965
+ _ACTIVITY_HOOKS = (
966
+ "pre_tool_call", "post_tool_call", "subagent_start", "subagent_stop",
967
+ "pre_approval_request", "post_approval_response",
968
+ )
969
+ # Tools whose work is visible on its own: a question card, a message the agent sent.
970
+ _ACTIVITY_SKIPPED_TOOLS = frozenset({"clarify"})
971
+ _ACTIVITY_VISIBLE_INLINE_ACTIONS = frozenset({"send_message", "send_widget"})
972
+ _ACTIVITY_COMMAND_TOOLS = frozenset({"terminal", "exec", "bash", "shell"})
973
+ _ACTIVITY_READ_TOOLS = frozenset({"read_file", "file_read"})
974
+ _ACTIVITY_SEARCH_TOOLS = frozenset({"search_files", "grep"})
975
+ _ACTIVITY_EDIT_TOOLS = frozenset({"write_file", "file_write", "file_edit", "patch", "apply_patch"})
976
+ _ACTIVITY_WEB_SEARCH_TOOLS = frozenset({"web_search", "x_search"})
977
+ _ACTIVITY_PLAN_TOOLS = frozenset({"todo", "todo_list"})
978
+ # Titles never come from a command, output or reasoning: only the tool and its explicit intent.
979
+ _ACTIVITY_TOOL_TITLES = {
980
+ "execute_code": "Ran code",
981
+ "browser": "Used the browser",
982
+ "browser_exec": "Used the browser",
983
+ "browser_cdp": "Used the browser",
984
+ "browser_dialog": "Used the browser",
985
+ "browser_vault": "Used the browser",
986
+ "web_extract": "Read a web page",
987
+ "tool_describe": "Checked available tools",
988
+ "skill_view": "Read instructions",
989
+ "skills_list": "Listed skills",
990
+ "delegate_task": "Delegated a task",
991
+ "image_generate": "Generated an image",
992
+ "video_generate": "Generated a video",
993
+ "vision_analyze": "Looked at an image",
994
+ "video_analyze": "Watched a video",
995
+ "text_to_speech": "Generated speech",
996
+ "session_search": "Searched past sessions",
997
+ "memory": "Updated memory",
998
+ "read_terminal": "Read terminal output",
999
+ "close_terminal": "Closed a terminal",
1000
+ "process_manage": "Managed a background process",
1001
+ "cronjob_manage": "Managed scheduled jobs",
1002
+ "skill_manage": "Managed skills",
1003
+ }
1004
+
1005
+ _ACTIVITY_LOCK = threading.Lock()
1006
+ # Set while the adapter answers on its own (a /threads reply, a button's fallback, a question):
1007
+ # those sends are neither Hermes' answer nor its commentary, so a turn's progress ignores them.
1008
+ _ACTIVITY_LOCAL_SEND: contextvars.ContextVar[bool] = contextvars.ContextVar("inline_activity_local_send", default=False)
1009
+ # (chat_id, thread_id) as Hermes binds them for the turn's session -> open turns, oldest first.
1010
+ _ACTIVITY_TURNS: Dict[tuple[str, str], List["_ActivityTurn"]] = {}
1011
+ _ACTIVITY_LOGGED_FAILURES: set[str] = set()
1012
+ # gateway.session_context.get_session_env, resolved off the hook path.
1013
+ _ACTIVITY_SESSION_ENV: Optional[Callable[..., Any]] = None
1014
+
1015
+
1016
+ def _is_hermes_heartbeat(content: Any) -> bool:
1017
+ """Hermes' long-running "⏳ Working — N min" notice; every locale starts it with ⏳."""
1018
+ return isinstance(content, str) and content.lstrip().startswith("⏳")
1019
+
1020
+
1021
+ def _activity_local_sends(fn: Callable[..., Any]) -> Callable[..., Any]:
1022
+ @functools.wraps(fn)
1023
+ async def wrapper(*args: Any, **kwargs: Any) -> Any:
1024
+ token = _ACTIVITY_LOCAL_SEND.set(True)
1025
+ try:
1026
+ return await fn(*args, **kwargs)
1027
+ finally:
1028
+ _ACTIVITY_LOCAL_SEND.reset(token)
1029
+
1030
+ return wrapper
1031
+
1032
+
1033
+ def _activity_log_failure(operation: str, exc: BaseException) -> None:
1034
+ """Progress never breaks a turn: log the first failure of each kind, then stay quiet."""
1035
+ if operation in _ACTIVITY_LOGGED_FAILURES:
1036
+ logger.debug("[inline] progress %s failed: %s", operation, exc)
1037
+ return
1038
+ _ACTIVITY_LOGGED_FAILURES.add(operation)
1039
+ logger.warning("[inline] progress %s failed: %s: %s; the turn goes on without it", operation, type(exc).__name__, exc)
1040
+
1041
+
1042
+ def _activity_session_env_reader() -> Optional[Callable[..., Any]]:
1043
+ global _ACTIVITY_SESSION_ENV
1044
+ if _ACTIVITY_SESSION_ENV is None:
1045
+ try:
1046
+ from gateway.session_context import get_session_env
1047
+ _ACTIVITY_SESSION_ENV = get_session_env
1048
+ except Exception:
1049
+ return None
1050
+ return _ACTIVITY_SESSION_ENV
1051
+
1052
+
1053
+ def _activity_str(value: Any) -> str:
1054
+ return value.strip() if isinstance(value, str) else ""
1055
+
1056
+
1057
+ def _activity_tail(text: str, limit: int) -> str:
1058
+ return text if len(text) <= limit else text[-limit:]
1059
+
1060
+
1061
+ def _activity_json(value: Any) -> Any:
1062
+ if isinstance(value, (dict, list)):
1063
+ return value
1064
+ if isinstance(value, str) and value[:1] in {"{", "["}:
1065
+ try:
1066
+ return json.loads(value)
1067
+ except ValueError:
1068
+ return None
1069
+ return None
1070
+
1071
+
1072
+ def _activity_plan_steps(todos: Any) -> List[Dict[str, str]]:
1073
+ steps: List[Dict[str, str]] = []
1074
+ for todo in todos if isinstance(todos, list) else []:
1075
+ if not isinstance(todo, dict):
1076
+ continue
1077
+ text = _activity_str(todo.get("content"))
1078
+ if not text:
1079
+ continue
1080
+ status = _activity_str(todo.get("status"))
1081
+ if status == "cancelled":
1082
+ steps.append({"text": f"{text} (cancelled)", "status": "completed"})
1083
+ else:
1084
+ steps.append({"text": text, "status": status if status in {"in_progress", "completed"} else "pending"})
1085
+ if len(steps) >= _ACTIVITY_PLAN_LIMIT:
1086
+ break
1087
+ return steps
1088
+
1089
+
1090
+ def _activity_tool_body(name: str, args: Any) -> Optional[Dict[str, Any]]:
1091
+ """The row a Hermes tool call starts, or None for tools that show no row."""
1092
+ args = args if isinstance(args, dict) else {}
1093
+ if name in _ACTIVITY_SKIPPED_TOOLS or name == "delegate_task":
1094
+ # Sub-agents draw their own rows through subagent_start/stop.
1095
+ return None
1096
+ if name == "inline":
1097
+ action = _activity_str(args.get("action"))
1098
+ if action in _ACTIVITY_VISIBLE_INLINE_ACTIONS:
1099
+ return None
1100
+ return {"kind": "tool", "name": "inline", "title": f"Inline: {action.replace('_', ' ')}" if action else "Used Inline"}
1101
+ if name in _ACTIVITY_COMMAND_TOOLS:
1102
+ command = _activity_str(args.get("command"))
1103
+ return {"kind": "command", "command": command} if command else {"kind": "tool", "name": name, "title": "Ran a command"}
1104
+ path = _activity_str(args.get("path")) or _activity_str(args.get("file_path"))
1105
+ if name in _ACTIVITY_READ_TOOLS and path:
1106
+ return {"kind": "read", "path": path}
1107
+ if name in _ACTIVITY_SEARCH_TOOLS:
1108
+ query = _activity_str(args.get("pattern")) or _activity_str(args.get("query"))
1109
+ body: Dict[str, Any] = {"kind": "search"}
1110
+ if query:
1111
+ body["query"] = query
1112
+ if path and path != ".":
1113
+ body["path"] = path
1114
+ return body
1115
+ if name in _ACTIVITY_EDIT_TOOLS:
1116
+ if path:
1117
+ return {"kind": "edit", "files": [{"path": path}]}
1118
+ return {"kind": "tool", "name": name, "title": "Edited files"}
1119
+ if name in _ACTIVITY_WEB_SEARCH_TOOLS:
1120
+ query = _activity_str(args.get("query"))
1121
+ return {"kind": "web", "query": query} if query else {"kind": "tool", "name": name, "title": "Searched the web"}
1122
+ if name == "web_extract" or name.startswith("browser"):
1123
+ urls = args.get("urls") if isinstance(args.get("urls"), list) else []
1124
+ url = _activity_str(args.get("url")) or next((_activity_str(u) for u in urls if _activity_str(u)), "")
1125
+ if url:
1126
+ return {"kind": "web", "url": url}
1127
+ if name in _ACTIVITY_PLAN_TOOLS:
1128
+ steps = _activity_plan_steps(args.get("todos"))
1129
+ # Reading the list (no todos) changes nothing worth a row.
1130
+ return {"kind": "plan", "steps": steps} if steps else None
1131
+ description = " ".join(_activity_str(args.get("description")).split())
1132
+ title = description or _ACTIVITY_TOOL_TITLES.get(name) or f"Used {name}"
1133
+ return {"kind": "tool", "name": name, "title": title[:_ACTIVITY_TITLE_LIMIT]}
1134
+
1135
+
1136
+ def _activity_tool_result(name: str, args: Any, body: Dict[str, Any], result: Any, status: str, error: str) -> Dict[str, Any]:
1137
+ """The row's body once the tool finished: output tails, exit codes, diffs, plans, errors."""
1138
+ args = args if isinstance(args, dict) else {}
1139
+ parsed = _activity_json(result)
1140
+ record = parsed if isinstance(parsed, dict) else {}
1141
+ kind = body.get("kind")
1142
+ if kind == "command":
1143
+ merged = dict(body)
1144
+ output = record.get("output")
1145
+ if isinstance(output, str) and output.strip():
1146
+ merged["output"] = _activity_tail(output.rstrip(), _ACTIVITY_OUTPUT_LIMIT)
1147
+ exit_code = record.get("exit_code")
1148
+ if isinstance(exit_code, int) and not isinstance(exit_code, bool):
1149
+ merged["exitCode"] = exit_code
1150
+ return merged
1151
+ if name in _ACTIVITY_EDIT_TOOLS:
1152
+ files: List[Dict[str, Any]] = []
1153
+ for key, change in (("files_created", "added"), ("files_deleted", "deleted"), ("files_modified", "modified")):
1154
+ for path in record.get(key) or []:
1155
+ if isinstance(path, str) and path.strip() and all(f["path"] != path for f in files):
1156
+ files.append({"path": path, "change": change})
1157
+ path = _activity_str(args.get("path")) or _activity_str(args.get("file_path"))
1158
+ if not files and path:
1159
+ files.append({"path": path})
1160
+ diff = record.get("diff")
1161
+ if len(files) == 1 and isinstance(diff, str) and diff.strip():
1162
+ files[0]["diff"] = _activity_tail(diff.rstrip(), _ACTIVITY_DIFF_LIMIT)
1163
+ if files:
1164
+ return {"kind": "edit", "files": files}
1165
+ return body
1166
+ if kind == "plan":
1167
+ steps = _activity_plan_steps(record.get("todos"))
1168
+ return {"kind": "plan", "steps": steps} if steps else body
1169
+ if kind == "tool" and status == "error" and error:
1170
+ return {**body, "error": error.splitlines()[0][:500]}
1171
+ return body
1172
+
1173
+
1174
+ def _activity_agent_state(status: Any) -> str:
1175
+ value = _activity_str(status).lower()
1176
+ if value in {"interrupted", "cancelled", "canceled", "stopped"}:
1177
+ return "cancelled"
1178
+ if value in {"failed", "error", "timeout"}:
1179
+ return "failed"
1180
+ return "done"
1181
+
1182
+
1183
+ def _activity_same_text(left: Optional[str], right: Optional[str]) -> bool:
1184
+ return bool(left) and bool(right) and " ".join(str(left).split()) == " ".join(str(right).split())
1185
+
1186
+
1187
+ class _ActivityTurn:
1188
+ """One Hermes turn in one Inline chat, from on_processing_start to its answer."""
1189
+
1190
+ def __init__(self, *, pump: "_ActivityPump", turn_id: str, target: Dict[str, str], key: tuple[str, str], user_id: str, home: str):
1191
+ self.pump = pump
1192
+ self.turn_id = turn_id
1193
+ self.target = target
1194
+ self.key = key
1195
+ self.user_id = user_id
1196
+ self.home = home
1197
+ # "detailed", "friendly", or "off" when the sidecar could not be reached; None until known.
1198
+ self.mode: Optional[str] = None
1199
+ self.mode_ready = threading.Event()
1200
+ self.closed = False
1201
+ # People's messages that arrived mid-turn: Hermes' busy notice replies to them, and
1202
+ # that send is not this turn's answer.
1203
+ self.later_messages: set[str] = set()
1204
+ # Worker-thread state below.
1205
+ self.rows: Dict[str, Dict[str, Any]] = {}
1206
+ # (session, tool call) -> row; None for calls that show no row.
1207
+ self.calls: Dict[tuple[str, str], Optional[str]] = {}
1208
+ self.unmatched: Dict[tuple[str, str], deque] = {}
1209
+ self.children: Dict[str, str] = {}
1210
+ self.held: Optional[str] = None
1211
+ self.next_row = 0
1212
+
1213
+
1214
+ def _activity_register(turn: _ActivityTurn) -> None:
1215
+ with _ACTIVITY_LOCK:
1216
+ _ACTIVITY_TURNS.setdefault(turn.key, []).append(turn)
1217
+
1218
+
1219
+ def _activity_unregister(turn: _ActivityTurn) -> None:
1220
+ with _ACTIVITY_LOCK:
1221
+ turns = _ACTIVITY_TURNS.get(turn.key)
1222
+ if turns and turn in turns:
1223
+ turns.remove(turn)
1224
+ if not turns:
1225
+ _ACTIVITY_TURNS.pop(turn.key, None)
1226
+
1227
+
1228
+ def _activity_turns_for(key: tuple[str, str]) -> List[_ActivityTurn]:
1229
+ with _ACTIVITY_LOCK:
1230
+ return list(_ACTIVITY_TURNS.get(key, ()))
1231
+
1232
+
1233
+ def _activity_turn_for_session() -> Optional[_ActivityTurn]:
1234
+ """The open Inline turn the calling hook's session belongs to. In memory only: no I/O."""
1235
+ get_env = _ACTIVITY_SESSION_ENV
1236
+ if get_env is None or str(get_env("HERMES_SESSION_PLATFORM", "") or "") != "inline":
1237
+ return None
1238
+ key = (str(get_env("HERMES_SESSION_CHAT_ID", "") or ""), str(get_env("HERMES_SESSION_THREAD_ID", "") or ""))
1239
+ candidates = _activity_turns_for(key)
1240
+ if len(candidates) > 1:
1241
+ # Per-user sessions share a group chat; several profiles' bots can share one too.
1242
+ user_id = str(get_env("HERMES_SESSION_USER_ID", "") or "")
1243
+ candidates = [turn for turn in candidates if turn.user_id == user_id] or candidates
1244
+ if len(candidates) > 1:
1245
+ home = str(hermes_home())
1246
+ candidates = [turn for turn in candidates if turn.home == home] or candidates
1247
+ return candidates[-1] if candidates else None
1248
+
1249
+
1250
+ def _activity_hook(kind: str) -> Callable[..., None]:
1251
+ def callback(**kwargs: Any) -> None:
1252
+ # pre_tool_call is fail-closed and blocks the tool while it runs: find the turn,
1253
+ # copy the payload and enqueue it, nothing else. Never raise, never return a directive.
1254
+ try:
1255
+ turn = _activity_turn_for_session()
1256
+ if turn is not None and not turn.closed and turn.mode not in {"friendly", "off"}:
1257
+ payload = dict(kwargs)
1258
+ if isinstance(payload.get("args"), dict):
1259
+ payload["args"] = dict(payload["args"])
1260
+ turn.pump.put(kind, turn, payload)
1261
+ except Exception as exc:
1262
+ _activity_log_failure(f"hook {kind}", exc)
1263
+ return None
1264
+
1265
+ callback.__name__ = f"inline_activity_{kind}"
1266
+ return callback
1267
+
1268
+
1269
+ def _register_activity_hooks(ctx: Any) -> None:
1270
+ register_hook = getattr(ctx, "register_hook", None)
1271
+ if not callable(register_hook):
1272
+ logger.info("[inline] this Hermes has no plugin hooks; Detailed output shows only the turn's time")
1273
+ return
1274
+ for name in _ACTIVITY_HOOKS:
1275
+ try:
1276
+ register_hook(name, _activity_hook(name))
1277
+ except Exception as exc:
1278
+ _activity_log_failure(f"register {name}", exc)
1279
+
1280
+
1281
+ class _ActivityPump:
1282
+ """Posts one adapter's progress events to its sidecar, in order, off the agent's threads."""
1283
+
1284
+ def __init__(self, post: Callable[[str, Dict[str, Any], float], Dict[str, Any]]):
1285
+ self._post = post
1286
+ self._queue: "queue.SimpleQueue[Any]" = queue.SimpleQueue()
1287
+ self._thread = threading.Thread(target=self._run, name="inline-activity", daemon=True)
1288
+ self._thread.start()
1289
+
1290
+ def put(self, kind: str, turn: _ActivityTurn, payload: Any = None) -> None:
1291
+ self._queue.put((kind, turn, payload, time.time()))
1292
+
1293
+ def flush(self, turn: _ActivityTurn) -> "concurrent.futures.Future[None]":
1294
+ """Resolves once every event queued before it has reached Inline."""
1295
+ future: "concurrent.futures.Future[None]" = concurrent.futures.Future()
1296
+ self.put("flush", turn, future)
1297
+ return future
1298
+
1299
+ def stop(self) -> None:
1300
+ self._queue.put(None)
1301
+
1302
+ def _run(self) -> None:
1303
+ while True:
1304
+ item = self._queue.get()
1305
+ if item is None:
1306
+ return
1307
+ kind, turn, payload, at = item
1308
+ try:
1309
+ self._handle(kind, turn, payload, at)
1310
+ except Exception as exc:
1311
+ _activity_log_failure(kind, exc)
1312
+ finally:
1313
+ if kind == "flush" and isinstance(payload, concurrent.futures.Future) and not payload.done():
1314
+ payload.set_result(None)
1315
+
1316
+ def _send(self, turn: _ActivityTurn, event: Dict[str, Any], at: float) -> Dict[str, Any]:
1317
+ return self._post("/activity", {
1318
+ "target": turn.target, "turnId": turn.turn_id, "event": event, "at": int(at * 1000),
1319
+ }, _ACTIVITY_POST_TIMEOUT_SECONDS)
1320
+
1321
+ def _handle(self, kind: str, turn: _ActivityTurn, payload: Any, at: float) -> None:
1322
+ if kind == "start":
1323
+ try:
1324
+ data = self._post("/activity", {
1325
+ "target": turn.target, "turnId": turn.turn_id, "event": {"type": "turn.start"},
1326
+ "at": int(at * 1000), "lazy": bool((payload or {}).get("lazy")),
1327
+ }, _ACTIVITY_POST_TIMEOUT_SECONDS)
1328
+ mode = str((data.get("result") or {}).get("mode") or "")
1329
+ turn.mode = mode if mode in {"detailed", "friendly"} else "detailed"
1330
+ except Exception:
1331
+ turn.mode = "off"
1332
+ raise
1333
+ finally:
1334
+ turn.mode_ready.set()
1335
+ return
1336
+ if turn.mode != "detailed":
1337
+ return
1338
+ if kind == "flush":
1339
+ self._post("/activity/flush", {"target": turn.target, "turnId": turn.turn_id}, _ACTIVITY_FLUSH_TIMEOUT_SECONDS)
1340
+ return
1341
+ for event in self._events(kind, turn, payload):
1342
+ self._send(turn, event, at)
1343
+
1344
+ def _events(self, kind: str, turn: _ActivityTurn, payload: Any) -> List[Dict[str, Any]]:
1345
+ if kind == "commentary":
1346
+ text = str(payload or "").strip()
1347
+ if not text:
1348
+ return []
1349
+ # Held until the next event: an interim message that turns out to be the answer
1350
+ # (Hermes then sends the same text as the final) is not drawn twice.
1351
+ events = self._release_held(turn)
1352
+ turn.held = text
1353
+ return events
1354
+ if kind == "end":
1355
+ final = (payload or {}).get("final")
1356
+ if turn.held and _activity_same_text(turn.held, final):
1357
+ turn.held = None
1358
+ return [*self._release_held(turn), {"type": "turn.end", "outcome": (payload or {}).get("outcome") or "success"}]
1359
+ events = self._release_held(turn)
1360
+ if kind == "split":
1361
+ events.append({"type": "split"})
1362
+ elif kind in {"pre_approval_request", "post_approval_response"}:
1363
+ # Smart approval is decided by a model, not a person: nothing waits.
1364
+ if _activity_str((payload or {}).get("surface")) != "smart":
1365
+ events.append({"type": "waiting", "waiting": kind == "pre_approval_request"})
1366
+ elif kind == "pre_tool_call":
1367
+ events.extend(self._tool_start(turn, payload or {}))
1368
+ elif kind == "post_tool_call":
1369
+ events.extend(self._tool_end(turn, payload or {}))
1370
+ elif kind == "subagent_start":
1371
+ events.extend(self._agent_start(turn, payload or {}))
1372
+ elif kind == "subagent_stop":
1373
+ events.extend(self._agent_stop(turn, payload or {}))
1374
+ return events
1375
+
1376
+ @staticmethod
1377
+ def _release_held(turn: _ActivityTurn) -> List[Dict[str, Any]]:
1378
+ held, turn.held = turn.held, None
1379
+ return [{"type": "commentary", "text": held}] if held else []
1380
+
1381
+ @staticmethod
1382
+ def _new_row(turn: _ActivityTurn, call_id: str) -> str:
1383
+ if call_id and f"tool:{call_id}" not in turn.rows:
1384
+ return f"tool:{call_id}"
1385
+ turn.next_row += 1
1386
+ return f"tool:#{turn.next_row}"
1387
+
1388
+ def _tool_start(self, turn: _ActivityTurn, payload: Dict[str, Any]) -> List[Dict[str, Any]]:
1389
+ name = _activity_str(payload.get("tool_name"))
1390
+ call_id = _activity_str(payload.get("tool_call_id"))
1391
+ session_id = _activity_str(payload.get("session_id"))
1392
+ body = _activity_tool_body(name, payload.get("args"))
1393
+ if body is None:
1394
+ if call_id:
1395
+ turn.calls[(session_id, call_id)] = None
1396
+ return []
1397
+ # One plan per agent: later updates redraw the same row.
1398
+ row_id = f"plan:{session_id or 'main'}" if body.get("kind") == "plan" else self._new_row(turn, call_id)
1399
+ parent = turn.children.get(session_id)
1400
+ turn.rows[row_id] = {"name": name, "args": payload.get("args"), "body": body}
1401
+ if call_id:
1402
+ turn.calls[(session_id, call_id)] = row_id
1403
+ else:
1404
+ turn.unmatched.setdefault((session_id, name), deque()).append(row_id)
1405
+ event: Dict[str, Any] = {"type": "item", "id": row_id, "body": body, "state": "running"}
1406
+ if parent:
1407
+ event["parent"] = parent
1408
+ return [event]
1409
+
1410
+ def _tool_end(self, turn: _ActivityTurn, payload: Dict[str, Any]) -> List[Dict[str, Any]]:
1411
+ name = _activity_str(payload.get("tool_name"))
1412
+ call_id = _activity_str(payload.get("tool_call_id"))
1413
+ session_id = _activity_str(payload.get("session_id"))
1414
+ status = _activity_str(payload.get("status")) or "ok"
1415
+ error = _activity_str(payload.get("error_message"))
1416
+ row_id: Optional[str] = None
1417
+ if call_id and (session_id, call_id) in turn.calls:
1418
+ row_id = turn.calls.pop((session_id, call_id))
1419
+ if row_id is None and not (name == "delegate_task" and status == "error"):
1420
+ return []
1421
+ elif not call_id:
1422
+ pending = turn.unmatched.get((session_id, name))
1423
+ row_id = pending.popleft() if pending else None
1424
+ event: Dict[str, Any] = {"type": "item", "state": "failed" if status == "error" else "done"}
1425
+ row = turn.rows.get(row_id) if row_id else None
1426
+ if row is None:
1427
+ # No start seen (or a sub-agent hand-off that failed before it began): draw it now.
1428
+ body = _activity_tool_body(name, payload.get("args"))
1429
+ if body is None and name == "delegate_task" and status == "error":
1430
+ body = {"kind": "tool", "name": name, "title": _ACTIVITY_TOOL_TITLES["delegate_task"]}
1431
+ if body is None:
1432
+ return []
1433
+ row_id = self._new_row(turn, call_id)
1434
+ row = {"name": name, "args": payload.get("args"), "body": body}
1435
+ turn.rows[row_id] = row
1436
+ parent = turn.children.get(session_id)
1437
+ if parent:
1438
+ event["parent"] = parent
1439
+ event["id"] = row_id
1440
+ event["body"] = _activity_tool_result(name, row.get("args"), row["body"], payload.get("result"), status, error)
1441
+ duration = payload.get("duration_ms")
1442
+ if isinstance(duration, (int, float)) and not isinstance(duration, bool) and duration >= 0:
1443
+ event["durationMs"] = int(duration)
1444
+ return [event]
1445
+
1446
+ @staticmethod
1447
+ def _agent_start(turn: _ActivityTurn, payload: Dict[str, Any]) -> List[Dict[str, Any]]:
1448
+ child = _activity_str(payload.get("child_subagent_id")) or _activity_str(payload.get("child_session_id"))
1449
+ if not child:
1450
+ return []
1451
+ row_id = f"agent:{child}"
1452
+ goal = _activity_str(payload.get("child_goal"))
1453
+ body: Dict[str, Any] = {"kind": "agent"}
1454
+ if goal:
1455
+ body["title"] = " ".join(goal.splitlines()[0].split())[:_ACTIVITY_TITLE_LIMIT]
1456
+ child_session = _activity_str(payload.get("child_session_id"))
1457
+ if child_session:
1458
+ turn.children[child_session] = row_id
1459
+ event: Dict[str, Any] = {"type": "item", "id": row_id, "body": body, "state": "running"}
1460
+ parent_subagent = _activity_str(payload.get("parent_subagent_id"))
1461
+ parent = f"agent:{parent_subagent}" if parent_subagent else turn.children.get(_activity_str(payload.get("parent_session_id")))
1462
+ if parent and parent != row_id:
1463
+ event["parent"] = parent
1464
+ return [event]
1465
+
1466
+ @staticmethod
1467
+ def _agent_stop(turn: _ActivityTurn, payload: Dict[str, Any]) -> List[Dict[str, Any]]:
1468
+ row_id = turn.children.get(_activity_str(payload.get("child_session_id")))
1469
+ if not row_id:
1470
+ return []
1471
+ event: Dict[str, Any] = {"type": "item", "id": row_id, "state": _activity_agent_state(payload.get("child_status"))}
1472
+ duration = payload.get("duration_ms")
1473
+ if isinstance(duration, (int, float)) and not isinstance(duration, bool) and duration > 0:
1474
+ event["durationMs"] = int(duration)
1475
+ return [event]
1476
+
1477
+
940
1478
  class InlineAdapter(BasePlatformAdapter):
941
1479
  MAX_MESSAGE_LENGTH = _MAX_MESSAGE_LENGTH
942
1480
  supports_code_blocks = True
@@ -949,7 +1487,7 @@ class InlineAdapter(BasePlatformAdapter):
949
1487
  extra = config.extra or {}
950
1488
 
951
1489
  if _truthy(inline_env("INLINE_EXPERIMENTAL_AGENT_ACTIVITY")):
952
- logger.warning("[inline] experimental tool timelines are unavailable; ignoring INLINE_EXPERIMENTAL_AGENT_ACTIVITY and using ordinary typing")
1490
+ logger.warning("[inline] INLINE_EXPERIMENTAL_AGENT_ACTIVITY is ignored; progress follows the bot's Output setting (Detailed or Friendly)")
953
1491
  self._token = _config_token(config)
954
1492
  self._base_url = inline_env("INLINE_BASE_URL") or extra.get("base_url") or "https://api.inline.chat"
955
1493
  self._sidecar_bind = _normalize_sidecar_bind(extra.get("sidecar_bind") or inline_env("INLINE_SIDECAR_BIND"))
@@ -1177,6 +1715,12 @@ class InlineAdapter(BasePlatformAdapter):
1177
1715
  self._reply_thread_overrides = self._load_reply_thread_overrides()
1178
1716
  # Threads the agent made for the message it is answering: (chat, message) -> thread.
1179
1717
  self._turn_threads: "OrderedDict[tuple[str, str], str]" = OrderedDict()
1718
+ # Detailed progress: the worker that posts this adapter's turn events, and each
1719
+ # processing event's turn (on_processing_start/complete receive the same event).
1720
+ self._activity_pump: Optional[_ActivityPump] = None
1721
+ self._activity_events: "OrderedDict[int, tuple[Any, _ActivityTurn]]" = OrderedDict()
1722
+ self._activity_home = str(hermes_home())
1723
+ self._activity_opener = urllib.request.build_opener(urllib.request.ProxyHandler({}))
1180
1724
  try:
1181
1725
  from . import tools as _inline_tools
1182
1726
  _inline_tools.configure_settings(self)
@@ -1980,6 +2524,7 @@ class InlineAdapter(BasePlatformAdapter):
1980
2524
  rows.append({"actions": [self._action(f"{prefix}reset", "Reset")]})
1981
2525
  return {"rows": rows}
1982
2526
 
2527
+ @_activity_local_sends
1983
2528
  async def _send_thread_status(
1984
2529
  self,
1985
2530
  *,
@@ -1996,6 +2541,7 @@ class InlineAdapter(BasePlatformAdapter):
1996
2541
  actions=self._thread_status_actions(display_chat_id=chat_id, target_chat_id=target_chat_id),
1997
2542
  )
1998
2543
 
2544
+ @_activity_local_sends
1999
2545
  async def _handle_thread_command(
2000
2546
  self,
2001
2547
  *,
@@ -2038,6 +2584,7 @@ class InlineAdapter(BasePlatformAdapter):
2038
2584
  )
2039
2585
  return True
2040
2586
 
2587
+ @_activity_local_sends
2041
2588
  async def _handle_follow_command(
2042
2589
  self,
2043
2590
  *,
@@ -2081,6 +2628,7 @@ class InlineAdapter(BasePlatformAdapter):
2081
2628
  await self.send(chat_id, body, reply_to=msg_id, metadata=metadata)
2082
2629
  return True
2083
2630
 
2631
+ @_activity_local_sends
2084
2632
  async def _handle_inline_maintenance_command(
2085
2633
  self,
2086
2634
  *,
@@ -2137,6 +2685,7 @@ class InlineAdapter(BasePlatformAdapter):
2137
2685
  return False
2138
2686
  # Re-register under the profile scope the gateway connects this adapter in.
2139
2687
  _configure_tool_sidecar(bind=self._sidecar_bind, port=self._sidecar_port, token=self._sidecar_token)
2688
+ self._activity_home = str(hermes_home())
2140
2689
  node_error = _node_version_error(self._node_bin)
2141
2690
  if node_error:
2142
2691
  self._set_fatal_error("NODE_UNSUPPORTED", node_error, retryable=False)
@@ -2210,6 +2759,7 @@ class InlineAdapter(BasePlatformAdapter):
2210
2759
  if self._bot_settings_tasks:
2211
2760
  await asyncio.gather(*self._bot_settings_tasks, return_exceptions=True)
2212
2761
  self._bot_settings_tasks.clear()
2762
+ self._activity_stop()
2213
2763
  await self._stop_sidecar()
2214
2764
  if self._http_client is not None:
2215
2765
  await self._http_client.aclose()
@@ -3051,6 +3601,10 @@ class InlineAdapter(BasePlatformAdapter):
3051
3601
  # A bot's answer coming back needs no reply: the agent may stay silent instead of Hermes
3052
3602
  # posting its "needed a reply" warning. Hosts before reply_expected ignore this.
3053
3603
  answer_from_bot = sender_is_bot and not explicitly_mentions_me and not command_addressed_to_me
3604
+ if not edit and sender_profile.get("bot") is not True:
3605
+ # A person's message mid-turn steers the work: the progress message ends here and
3606
+ # later work starts a new one. Hermes' steer mode reports nothing itself.
3607
+ self._activity_split(session_chat_id, str(thread_id or ""), msg_id)
3054
3608
  await self.handle_message(MessageEvent(
3055
3609
  **({"reply_expected": False} if answer_from_bot and "reply_expected" in getattr(MessageEvent, "__dataclass_fields__", {}) else {}),
3056
3610
  text=text,
@@ -5027,6 +5581,7 @@ class InlineAdapter(BasePlatformAdapter):
5027
5581
  logger.warning("[inline] expensive model check failed", exc_info=True)
5028
5582
  return None
5029
5583
 
5584
+ @_activity_local_sends
5030
5585
  async def _edit_action_message(self, event: Dict[str, Any], text: str, actions: Optional[Dict[str, Any]] = None) -> SendResult:
5031
5586
  chat_id = str(event.get("chatId") or "")
5032
5587
  message_id = str(event.get("messageId") or "")
@@ -5065,6 +5620,44 @@ class InlineAdapter(BasePlatformAdapter):
5065
5620
  reply_to: Optional[str] = None,
5066
5621
  metadata: Optional[Dict[str, Any]] = None,
5067
5622
  actions: Optional[Dict[str, Any]] = None,
5623
+ ) -> SendResult:
5624
+ """Hermes' send. Within a turn, the first send not marked ``_interim_send`` is the
5625
+ answer: Detailed progress closes ("Worked for") above it first. A Detailed turn's
5626
+ interim messages (Hermes' commentary between tool calls) go into the progress message
5627
+ instead of the chat. ``_interim_send`` and every other metadata key stay off the wire."""
5628
+ turn = None if _ACTIVITY_LOCAL_SEND.get() else self._activity_turn_for_send(chat_id, metadata)
5629
+ if turn is not None:
5630
+ if (metadata or {}).get("_interim_send"):
5631
+ if self._activity_foldable(content, metadata, actions) and await self._activity_is_detailed(turn):
5632
+ if _is_hermes_heartbeat(content):
5633
+ # The progress message's live timer already says this.
5634
+ return _send_result(
5635
+ success=False,
5636
+ error="the Inline progress message shows the elapsed time",
5637
+ raw_response={"inlineActivity": "heartbeat"},
5638
+ retryable=False,
5639
+ )
5640
+ turn.pump.put("commentary", turn, str(content))
5641
+ # Not a chat message: reported undelivered so Hermes never counts it as the
5642
+ # answer (its final-reply dedup matches delivered interim text) and the answer
5643
+ # still posts. Nothing is retried; the text shows in the progress message.
5644
+ return _send_result(
5645
+ success=False,
5646
+ error="shown in the Inline progress message",
5647
+ raw_response={"inlineActivity": "commentary"},
5648
+ retryable=False,
5649
+ )
5650
+ elif not (reply_to and str(reply_to) in turn.later_messages):
5651
+ await self._activity_close_before_answer(turn, content)
5652
+ return await self._send_message(chat_id, content, reply_to=reply_to, metadata=metadata, actions=actions)
5653
+
5654
+ async def _send_message(
5655
+ self,
5656
+ chat_id: str,
5657
+ content: str,
5658
+ reply_to: Optional[str] = None,
5659
+ metadata: Optional[Dict[str, Any]] = None,
5660
+ actions: Optional[Dict[str, Any]] = None,
5068
5661
  ) -> SendResult:
5069
5662
  requested = content
5070
5663
  content = self._with_cron_mention(content, metadata)
@@ -5368,6 +5961,7 @@ class InlineAdapter(BasePlatformAdapter):
5368
5961
 
5369
5962
  async def on_processing_start(self, event: MessageEvent) -> None:
5370
5963
  """Mark an inbound message while Hermes is actively processing it."""
5964
+ self._activity_begin(event)
5371
5965
  reaction_target = self._processing_reaction_target(event)
5372
5966
  if not reaction_target:
5373
5967
  return
@@ -5385,6 +5979,7 @@ class InlineAdapter(BasePlatformAdapter):
5385
5979
 
5386
5980
  async def on_processing_complete(self, event: MessageEvent, outcome: Any) -> None:
5387
5981
  """Replace the processing marker without affecting response delivery."""
5982
+ self._activity_finish(event, outcome)
5388
5983
  reaction_target = self._processing_reaction_target(event)
5389
5984
  if not reaction_target:
5390
5985
  return
@@ -5399,6 +5994,140 @@ class InlineAdapter(BasePlatformAdapter):
5399
5994
  elif outcome_name == "failure":
5400
5995
  await self._set_processing_reaction(target, message_id, "❌")
5401
5996
 
5997
+ # ── Detailed progress ───────────────────────────────────────────────────────────────
5998
+
5999
+ def _activity_post(self, path: str, body: Dict[str, Any], timeout: float) -> Dict[str, Any]:
6000
+ """The activity worker's own blocking sidecar call (it runs on its own thread)."""
6001
+ request = urllib.request.Request(
6002
+ f"{self._sidecar_base_url()}{path}",
6003
+ data=json.dumps(body).encode("utf-8"),
6004
+ headers={"content-type": "application/json; charset=utf-8", "x-hermes-sidecar-token": self._sidecar_token},
6005
+ method="POST",
6006
+ )
6007
+ try:
6008
+ # Loopback IPC: never through a configured HTTP proxy.
6009
+ with self._activity_opener.open(request, timeout=timeout) as response:
6010
+ raw = response.read().decode("utf-8")
6011
+ status = response.status
6012
+ except urllib.error.HTTPError as exc:
6013
+ raw = exc.read().decode("utf-8", errors="replace")
6014
+ status = exc.code
6015
+ data = json.loads(raw or "{}")
6016
+ if status != 200 or not data.get("ok"):
6017
+ raise InlineSidecarError(path, status, str(data.get("error") or f"Inline sidecar {path} failed"), str(data.get("errorKind") or "unknown"), data)
6018
+ return data
6019
+
6020
+ def _activity_begin(self, event: Any) -> None:
6021
+ """Opens Detailed progress for a turn; the sidecar resolves the chat's mode."""
6022
+ try:
6023
+ if self._http_client is None:
6024
+ return # Not connected (or a one-shot sender): no turns to draw.
6025
+ source = getattr(event, "source", None)
6026
+ chat_id = str(getattr(source, "chat_id", None) or "").strip()
6027
+ if not chat_id:
6028
+ return
6029
+ thread_id = str(getattr(source, "thread_id", None) or "").strip()
6030
+ if _activity_session_env_reader() is None:
6031
+ return
6032
+ if self._activity_pump is None:
6033
+ self._activity_pump = _ActivityPump(self._activity_post)
6034
+ turn = _ActivityTurn(
6035
+ pump=self._activity_pump,
6036
+ turn_id=secrets.token_hex(8),
6037
+ target=self._target_for(chat_id, {"thread_id": thread_id} if thread_id else None),
6038
+ key=(chat_id, thread_id),
6039
+ user_id=str(getattr(source, "user_id", None) or ""),
6040
+ home=self._activity_home,
6041
+ )
6042
+ is_command = getattr(event, "is_command", None)
6043
+ # A slash command usually answers without working: show "Working" only once work starts.
6044
+ lazy = bool(is_command()) if callable(is_command) else False
6045
+ self._remember(self._activity_events, id(event), (event, turn))
6046
+ _activity_register(turn)
6047
+ turn.pump.put("start", turn, {"lazy": lazy})
6048
+ except Exception as exc:
6049
+ _activity_log_failure("turn start", exc)
6050
+
6051
+ def _activity_finish(self, event: Any, outcome: Any) -> None:
6052
+ """The fallback close for a turn that never sent an answer (empty, failed, stopped)."""
6053
+ try:
6054
+ entry = self._activity_events.pop(id(event), None)
6055
+ if entry is None or entry[0] is not event:
6056
+ return
6057
+ turn = entry[1]
6058
+ if turn.closed:
6059
+ return
6060
+ name = str(getattr(outcome, "value", outcome) or "").strip().lower()
6061
+ self._activity_close(turn, {"outcome": name if name in {"success", "failure", "cancelled"} else "success"})
6062
+ except Exception as exc:
6063
+ _activity_log_failure("turn end", exc)
6064
+
6065
+ def _activity_close(self, turn: _ActivityTurn, payload: Dict[str, Any]) -> None:
6066
+ turn.closed = True
6067
+ _activity_unregister(turn)
6068
+ turn.pump.put("end", turn, payload)
6069
+
6070
+ async def _activity_close_before_answer(self, turn: _ActivityTurn, final_text: Optional[str]) -> None:
6071
+ """Closes the progress message and waits (bounded) until it reached Inline."""
6072
+ if turn.closed:
6073
+ return
6074
+ try:
6075
+ self._activity_close(turn, {"outcome": "success", "final": final_text})
6076
+ await asyncio.wait_for(asyncio.wrap_future(turn.pump.flush(turn)), timeout=_ACTIVITY_FINAL_WAIT_SECONDS)
6077
+ except Exception as exc:
6078
+ _activity_log_failure("answer ordering", exc)
6079
+
6080
+ def _activity_turn_for_send(self, chat_id: Any, metadata: Optional[Dict[str, Any]]) -> Optional[_ActivityTurn]:
6081
+ """This adapter's open turn for a Hermes send, matched as Hermes addresses the turn."""
6082
+ if self._activity_pump is None:
6083
+ return None
6084
+ thread_id = str((metadata or {}).get("thread_id") or "").strip()
6085
+ turns = [
6086
+ turn for turn in _activity_turns_for((str(chat_id or "").strip(), thread_id))
6087
+ if turn.pump is self._activity_pump and not turn.closed
6088
+ ]
6089
+ return turns[-1] if turns else None
6090
+
6091
+ @staticmethod
6092
+ def _activity_foldable(content: Any, metadata: Optional[Dict[str, Any]], actions: Optional[Dict[str, Any]]) -> bool:
6093
+ meta = metadata or {}
6094
+ if meta.get("non_conversational") or meta.get("is_approval_prompt") or actions is not None:
6095
+ return False
6096
+ text = content if isinstance(content, str) else ""
6097
+ return bool(text.strip()) and not has_widget_markup(text)
6098
+
6099
+ async def _activity_is_detailed(self, turn: _ActivityTurn) -> bool:
6100
+ if not turn.mode_ready.is_set():
6101
+ await asyncio.get_running_loop().run_in_executor(None, turn.mode_ready.wait, _ACTIVITY_MODE_WAIT_SECONDS)
6102
+ return turn.mode == "detailed"
6103
+
6104
+ def _activity_split(self, chat_id: str, thread_id: str, message_id: str) -> None:
6105
+ try:
6106
+ if self._activity_pump is None:
6107
+ return
6108
+ for turn in _activity_turns_for((str(chat_id or ""), thread_id)):
6109
+ if turn.pump is self._activity_pump and not turn.closed:
6110
+ if message_id and len(turn.later_messages) < 64:
6111
+ turn.later_messages.add(str(message_id))
6112
+ if turn.mode != "friendly":
6113
+ turn.pump.put("split", turn)
6114
+ except Exception as exc:
6115
+ _activity_log_failure("split", exc)
6116
+
6117
+ def _activity_stop(self) -> None:
6118
+ pump, self._activity_pump = self._activity_pump, None
6119
+ if pump is None:
6120
+ return
6121
+ with _ACTIVITY_LOCK:
6122
+ for key in list(_ACTIVITY_TURNS):
6123
+ kept = [turn for turn in _ACTIVITY_TURNS[key] if turn.pump is not pump]
6124
+ if kept:
6125
+ _ACTIVITY_TURNS[key] = kept
6126
+ else:
6127
+ _ACTIVITY_TURNS.pop(key, None)
6128
+ self._activity_events.clear()
6129
+ pump.stop()
6130
+
5402
6131
  async def send_image_file(self, chat_id: str, image_path: str, caption: Optional[str] = None, reply_to: Optional[str] = None, metadata: Optional[Dict[str, Any]] = None, **kwargs) -> SendResult:
5403
6132
  return await self._send_attachment(chat_id, image_path, "photo", caption, reply_to, metadata=metadata)
5404
6133
 
@@ -5426,6 +6155,10 @@ class InlineAdapter(BasePlatformAdapter):
5426
6155
  return await self._send_attachment(chat_id, audio_path, "voice", caption, reply_to, metadata=metadata)
5427
6156
 
5428
6157
  async def _send_attachment(self, chat_id: str, file_path: str, kind: str, caption: Optional[str], reply_to: Optional[str], file_name: Optional[str] = None, metadata: Optional[Dict[str, Any]] = None) -> SendResult:
6158
+ turn = None if _ACTIVITY_LOCAL_SEND.get() else self._activity_turn_for_send(chat_id, metadata)
6159
+ if turn is not None and not (metadata or {}).get("_interim_send"):
6160
+ # An answer that is only media (or TTS) also lands below "Worked for".
6161
+ await self._activity_close_before_answer(turn, None)
5429
6162
  safe_path = self.validate_media_delivery_path(file_path)
5430
6163
  if not safe_path:
5431
6164
  return _send_result(success=False, error=f"unsafe or missing attachment path: {file_path}", error_kind="bad_format")
@@ -5475,7 +6208,12 @@ class InlineAdapter(BasePlatformAdapter):
5475
6208
  if not choices:
5476
6209
  from tools.clarify_gateway import mark_awaiting_text
5477
6210
  mark_awaiting_text(clarify_id)
5478
- return await self.send(chat_id, f"Clarify: {question}", metadata=metadata)
6211
+ # A question, not the answer: the turn's progress stays open below it.
6212
+ token = _ACTIVITY_LOCAL_SEND.set(True)
6213
+ try:
6214
+ return await self.send(chat_id, f"Clarify: {question}", metadata=metadata)
6215
+ finally:
6216
+ _ACTIVITY_LOCAL_SEND.reset(token)
5479
6217
  clean_choices = [str(c).strip() for c in choices if str(c).strip()][:10]
5480
6218
  self._remember(self._clarify_sessions, clarify_id, session_key)
5481
6219
  self._remember(self._clarify_choices, clarify_id, clean_choices)
@@ -5944,8 +6682,9 @@ class InlineAdapter(BasePlatformAdapter):
5944
6682
  def _send_retry_is_final(self, result: SendResult) -> bool:
5945
6683
  # The request reached Inline but its result was lost. A retry or plain-text
5946
6684
  # fallback would post the message twice; Hermes treats timeouts the same way.
6685
+ # Commentary folded into a progress message is shown already: never resend it.
5947
6686
  raw = getattr(result, "raw_response", None)
5948
- return isinstance(raw, dict) and raw.get("outcomeUnknown") is True
6687
+ return isinstance(raw, dict) and (raw.get("outcomeUnknown") is True or "inlineActivity" in raw)
5949
6688
 
5950
6689
  async def _send_sidecar(self, path: str, body: Dict[str, Any]) -> SendResult:
5951
6690
  try:
@@ -6272,3 +7011,4 @@ def register(ctx) -> None:
6272
7011
  handler_fn=_cli.dispatch,
6273
7012
  )
6274
7013
  _tools.register(ctx)
7014
+ _register_activity_hooks(ctx)