@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.
- package/README.md +57 -11
- package/package.json +6 -6
- package/plugin/inline/adapter.py +746 -6
- package/plugin/inline/cli.py +1 -1
- package/plugin/inline/plugin.yaml +8 -1
- package/plugin/inline/sidecar/index.mjs +1844 -23
package/plugin/inline/adapter.py
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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]
|
|
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
|
-
|
|
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)
|