@inline-chat/hermes-agent-adapter 1.0.1 → 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 +112 -13
- package/package.json +9 -8
- package/plugin/inline/README.md +23 -0
- package/plugin/inline/adapter.py +1080 -68
- package/plugin/inline/cli.py +1 -1
- package/plugin/inline/message_actions.py +78 -0
- package/plugin/inline/plugin.yaml +12 -1
- package/plugin/inline/sidecar/index.mjs +9054 -922
- package/plugin/inline/tools.py +82 -11
- package/plugin/inline/widgets.py +267 -0
package/plugin/inline/adapter.py
CHANGED
|
@@ -8,12 +8,17 @@ 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
|
|
15
|
+
import inspect
|
|
12
16
|
import json
|
|
13
17
|
import logging
|
|
14
18
|
import math
|
|
15
19
|
import mimetypes
|
|
16
20
|
import os
|
|
21
|
+
import queue
|
|
17
22
|
import re
|
|
18
23
|
import secrets
|
|
19
24
|
import shlex
|
|
@@ -22,8 +27,11 @@ import signal
|
|
|
22
27
|
import socket
|
|
23
28
|
import subprocess
|
|
24
29
|
import sys
|
|
30
|
+
import threading
|
|
25
31
|
import time
|
|
26
|
-
|
|
32
|
+
import urllib.error
|
|
33
|
+
import urllib.request
|
|
34
|
+
from collections import OrderedDict, deque
|
|
27
35
|
from datetime import datetime, timezone
|
|
28
36
|
from pathlib import Path
|
|
29
37
|
from typing import Any, Callable, Dict, List, NamedTuple, Optional
|
|
@@ -52,11 +60,14 @@ from .message_actions import (
|
|
|
52
60
|
build_inline_agent_action_input,
|
|
53
61
|
build_inline_agent_action_turn_id,
|
|
54
62
|
build_inline_system_action_id,
|
|
63
|
+
inline_message_widget_ids,
|
|
64
|
+
inline_widget_call,
|
|
55
65
|
parse_inline_agent_action_reply_target,
|
|
56
66
|
resolve_inline_message_action_ownership,
|
|
57
67
|
)
|
|
58
|
-
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
|
|
59
69
|
from .telemetry import capture_plugin_error
|
|
70
|
+
from .widgets import has_widget_markup, scan_widgets, split_widget_aware, streaming_preview, without_widgets
|
|
60
71
|
|
|
61
72
|
logger = logging.getLogger(__name__)
|
|
62
73
|
|
|
@@ -64,6 +75,7 @@ _DEFAULT_SIDECAR_PORT = 8794
|
|
|
64
75
|
_ACTION_EDIT_RETRY_DELAY_SECONDS = 0.5
|
|
65
76
|
_DEFAULT_SIDECAR_BIND = "127.0.0.1"
|
|
66
77
|
_MAX_MESSAGE_LENGTH = 4000
|
|
78
|
+
_APPROVAL_CHOICES = frozenset({"once", "session", "always", "deny"})
|
|
67
79
|
_MODEL_PAGE_SIZE = 8
|
|
68
80
|
_CHOICE_PICKER_MAX_CHOICES = 16
|
|
69
81
|
_CHOICE_PICKER_TTL_SECONDS = 2 * 60
|
|
@@ -168,6 +180,29 @@ _INLINE_ENTITY_TYPE_NAMES = {
|
|
|
168
180
|
14: "group_mention",
|
|
169
181
|
}
|
|
170
182
|
|
|
183
|
+
_APPROVAL_COMMAND_LIMIT = 2000
|
|
184
|
+
_APPROVAL_FENCE_PATTERN = re.compile(r"(`{3,})[^\n]*\n(.*?)\n\1", re.DOTALL)
|
|
185
|
+
# Same headings as OpenClaw's Inline, Slack and Discord approval cards.
|
|
186
|
+
_APPROVAL_DECISION_HEADINGS = {
|
|
187
|
+
"once": "✅ Allowed once",
|
|
188
|
+
"session": "✅ Allowed for this session",
|
|
189
|
+
"always": "✅ Always allowed",
|
|
190
|
+
"deny": "❌ Denied",
|
|
191
|
+
}
|
|
192
|
+
_APPROVAL_EXPIRED_HEADING = "⌛ Approval expired"
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
def _approval_command_block(command: str) -> str:
|
|
196
|
+
body = command if len(command) <= _APPROVAL_COMMAND_LIMIT else f"{command[:_APPROVAL_COMMAND_LIMIT]}…"
|
|
197
|
+
fence = "````" if "```" in body else "```"
|
|
198
|
+
return f"{fence}sh\n{body}\n{fence}"
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
def _approval_card_text(heading: str, command: str) -> str:
|
|
202
|
+
"""An answered or expired card: the outcome above the command it was for."""
|
|
203
|
+
return f"{heading}\n\n{_approval_command_block(command)}" if command else heading
|
|
204
|
+
|
|
205
|
+
|
|
171
206
|
_ENV_REF_PATTERN = re.compile(r"^\$\{([A-Za-z_][A-Za-z0-9_]*)\}$")
|
|
172
207
|
_SIDECAR_RETRYABLE_KINDS = {"transient"}
|
|
173
208
|
_INLINE_DISPLAY_DEFAULTS = {
|
|
@@ -175,7 +210,9 @@ _INLINE_DISPLAY_DEFAULTS = {
|
|
|
175
210
|
"tool_progress_grouping": "accumulate",
|
|
176
211
|
"cleanup_progress": True,
|
|
177
212
|
"streaming": False,
|
|
178
|
-
|
|
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,
|
|
179
216
|
"show_reasoning": False,
|
|
180
217
|
"tool_preview_length": 40,
|
|
181
218
|
"busy_ack_detail": False,
|
|
@@ -843,6 +880,7 @@ def _apply_yaml_config(yaml_cfg: dict, platform_cfg: dict) -> Optional[dict]:
|
|
|
843
880
|
"state_path",
|
|
844
881
|
"settings_path",
|
|
845
882
|
"parse_markdown",
|
|
883
|
+
"cron_mention_prefix",
|
|
846
884
|
"require_mention",
|
|
847
885
|
"strict_mention",
|
|
848
886
|
"allowed_chats",
|
|
@@ -878,17 +916,578 @@ def _apply_yaml_config(yaml_cfg: dict, platform_cfg: dict) -> Optional[dict]:
|
|
|
878
916
|
return extra
|
|
879
917
|
|
|
880
918
|
|
|
919
|
+
async def _wait_for_exit(proc: subprocess.Popen, timeout: float) -> bool:
|
|
920
|
+
"""Wait for ``proc`` without blocking the loop or taking an executor worker; False on timeout."""
|
|
921
|
+
deadline = time.monotonic() + timeout
|
|
922
|
+
while proc.poll() is None:
|
|
923
|
+
if time.monotonic() >= deadline:
|
|
924
|
+
return False
|
|
925
|
+
await asyncio.sleep(0.05)
|
|
926
|
+
return True
|
|
927
|
+
|
|
928
|
+
|
|
929
|
+
def _token_count(tokens: int) -> str:
|
|
930
|
+
""""850", "439k", "1.2M": the bridge's wording for context sizes."""
|
|
931
|
+
if tokens < 1_000:
|
|
932
|
+
return str(tokens)
|
|
933
|
+
if tokens < 1_000_000:
|
|
934
|
+
return f"{tokens // 1_000}k"
|
|
935
|
+
tenths = tokens // 100_000
|
|
936
|
+
return f"{tenths // 10}M" if tenths % 10 == 0 else f"{tenths // 10}.{tenths % 10}M"
|
|
937
|
+
|
|
938
|
+
|
|
939
|
+
def _accepts_keyword(fn: Callable[..., Any], name: str) -> bool:
|
|
940
|
+
"""Whether a host callable takes keyword ``name`` (host APIs grow optional keywords)."""
|
|
941
|
+
try:
|
|
942
|
+
params = inspect.signature(fn).parameters
|
|
943
|
+
except (TypeError, ValueError):
|
|
944
|
+
return False
|
|
945
|
+
return name in params or any(p.kind is inspect.Parameter.VAR_KEYWORD for p in params.values())
|
|
946
|
+
|
|
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
|
+
|
|
881
1478
|
class InlineAdapter(BasePlatformAdapter):
|
|
882
1479
|
MAX_MESSAGE_LENGTH = _MAX_MESSAGE_LENGTH
|
|
883
1480
|
supports_code_blocks = True
|
|
884
1481
|
splits_long_messages = True
|
|
1482
|
+
# Command preview on an approval card; Hermes sends a longer command whole before the card.
|
|
1483
|
+
_EA_CMD_BUDGET = 2000
|
|
885
1484
|
|
|
886
1485
|
def __init__(self, config: PlatformConfig, *, use_ephemeral_sidecar_port: bool = False):
|
|
887
1486
|
super().__init__(config, Platform("inline"))
|
|
888
1487
|
extra = config.extra or {}
|
|
889
1488
|
|
|
890
1489
|
if _truthy(inline_env("INLINE_EXPERIMENTAL_AGENT_ACTIVITY")):
|
|
891
|
-
logger.warning("[inline]
|
|
1490
|
+
logger.warning("[inline] INLINE_EXPERIMENTAL_AGENT_ACTIVITY is ignored; progress follows the bot's Output setting (Detailed or Friendly)")
|
|
892
1491
|
self._token = _config_token(config)
|
|
893
1492
|
self._base_url = inline_env("INLINE_BASE_URL") or extra.get("base_url") or "https://api.inline.chat"
|
|
894
1493
|
self._sidecar_bind = _normalize_sidecar_bind(extra.get("sidecar_bind") or inline_env("INLINE_SIDECAR_BIND"))
|
|
@@ -910,6 +1509,10 @@ class InlineAdapter(BasePlatformAdapter):
|
|
|
910
1509
|
_configure_tool_sidecar(bind=self._sidecar_bind, port=self._sidecar_port, token=self._sidecar_token)
|
|
911
1510
|
self._node_bin = _find_node_bin() or "node"
|
|
912
1511
|
self._parse_markdown = _truthy(extra.get("parse_markdown") if "parse_markdown" in extra else inline_env("INLINE_PARSE_MARKDOWN"), True)
|
|
1512
|
+
cron_mention_prefix = extra.get("cron_mention_prefix") if "cron_mention_prefix" in extra else inline_env("INLINE_CRON_MENTION_PREFIX")
|
|
1513
|
+
self._cron_mention_prefix = cron_mention_prefix.strip() if isinstance(cron_mention_prefix, str) else ""
|
|
1514
|
+
if self._cron_mention_prefix and not self._parse_markdown:
|
|
1515
|
+
logger.warning("[inline] cron_mention_prefix needs parse_markdown=true; scheduled mentions are disabled")
|
|
913
1516
|
self._media_max_bytes = int(
|
|
914
1517
|
_normalize_positive_float(
|
|
915
1518
|
extra.get("media_max_mb") if "media_max_mb" in extra else inline_env("INLINE_MEDIA_MAX_MB"),
|
|
@@ -1086,6 +1689,9 @@ class InlineAdapter(BasePlatformAdapter):
|
|
|
1086
1689
|
self._clarify_choices: "OrderedDict[str, List[str]]" = OrderedDict()
|
|
1087
1690
|
self._clarify_sessions: "OrderedDict[str, str]" = OrderedDict()
|
|
1088
1691
|
self._approval_sessions: "OrderedDict[str, str]" = OrderedDict()
|
|
1692
|
+
self._partial_sends: "OrderedDict[tuple, bool]" = OrderedDict()
|
|
1693
|
+
self._approval_request_ids: "OrderedDict[str, str]" = OrderedDict()
|
|
1694
|
+
self._approval_commands: "OrderedDict[str, str]" = OrderedDict()
|
|
1089
1695
|
self._slash_sessions: "OrderedDict[str, str]" = OrderedDict()
|
|
1090
1696
|
self._choice_picker_sessions: "OrderedDict[str, Dict[str, Any]]" = OrderedDict()
|
|
1091
1697
|
self._update_prompt_sessions: "OrderedDict[str, Dict[str, Any]]" = OrderedDict()
|
|
@@ -1109,6 +1715,12 @@ class InlineAdapter(BasePlatformAdapter):
|
|
|
1109
1715
|
self._reply_thread_overrides = self._load_reply_thread_overrides()
|
|
1110
1716
|
# Threads the agent made for the message it is answering: (chat, message) -> thread.
|
|
1111
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({}))
|
|
1112
1724
|
try:
|
|
1113
1725
|
from . import tools as _inline_tools
|
|
1114
1726
|
_inline_tools.configure_settings(self)
|
|
@@ -1369,6 +1981,7 @@ class InlineAdapter(BasePlatformAdapter):
|
|
|
1369
1981
|
}
|
|
1370
1982
|
if runner is None:
|
|
1371
1983
|
return context
|
|
1984
|
+
context["context_usage"] = self._session_context_usage(runner, source)
|
|
1372
1985
|
|
|
1373
1986
|
try:
|
|
1374
1987
|
from hermes_cli.config import load_config_readonly
|
|
@@ -1489,6 +2102,22 @@ class InlineAdapter(BasePlatformAdapter):
|
|
|
1489
2102
|
)
|
|
1490
2103
|
return context
|
|
1491
2104
|
|
|
2105
|
+
@staticmethod
|
|
2106
|
+
def _session_context_usage(runner: Any, source: Any) -> Optional[List[int]]:
|
|
2107
|
+
"""``[used, window]`` prompt tokens of the chat's resident Hermes agent, read like Hermes'
|
|
2108
|
+
own ``/usage``; None before its first turn or once the agent was evicted."""
|
|
2109
|
+
try:
|
|
2110
|
+
agent = runner._resident_agent_for(runner._session_key_for_source(source))
|
|
2111
|
+
compressor = getattr(agent, "context_compressor", None) if agent is not None else None
|
|
2112
|
+
used = int(getattr(compressor, "last_prompt_tokens", 0) or 0)
|
|
2113
|
+
window = int(getattr(compressor, "context_length", 0) or 0)
|
|
2114
|
+
except Exception as exc:
|
|
2115
|
+
_hermes_seam_unavailable(
|
|
2116
|
+
"GatewayRunner._resident_agent_for", "Inline agent settings omit the context window", exc,
|
|
2117
|
+
)
|
|
2118
|
+
return None
|
|
2119
|
+
return [used, window] if used > 0 and window > 0 else None
|
|
2120
|
+
|
|
1492
2121
|
@staticmethod
|
|
1493
2122
|
def _bot_settings_revision(context: Dict[str, Any]) -> str:
|
|
1494
2123
|
visible = {
|
|
@@ -1550,6 +2179,21 @@ class InlineAdapter(BasePlatformAdapter):
|
|
|
1550
2179
|
"options": [{**option, "disabled": False} for option in reasoning_options],
|
|
1551
2180
|
}},
|
|
1552
2181
|
})
|
|
2182
|
+
usage = context.get("context_usage")
|
|
2183
|
+
if usage:
|
|
2184
|
+
used, window = min(usage[0], usage[1]), usage[1]
|
|
2185
|
+
percent = used * 100 // window
|
|
2186
|
+
# The id carries the numbers: Inline's composer draws its context circle from
|
|
2187
|
+
# ``session.context:<used>:<window>``, as it does for coding-agent bridges.
|
|
2188
|
+
runtime_items.append({
|
|
2189
|
+
"id": f"session.context:{usage[0]}:{window}", "label": "Context window",
|
|
2190
|
+
"description": f"{_token_count(usage[0])} of {_token_count(window)} tokens used",
|
|
2191
|
+
"disabled": True,
|
|
2192
|
+
"control": {"oneofKind": "info", "info": {
|
|
2193
|
+
"text": f"{percent}% full",
|
|
2194
|
+
"tone": _INLINE_BOT_SETTINGS_TONE_WARNING if percent >= 90 else _INLINE_BOT_SETTINGS_TONE_NEUTRAL,
|
|
2195
|
+
}},
|
|
2196
|
+
})
|
|
1553
2197
|
if not runtime_items:
|
|
1554
2198
|
runtime_items.append({
|
|
1555
2199
|
"id": "runtime-unavailable", "label": "Runtime", "disabled": False,
|
|
@@ -1880,6 +2524,7 @@ class InlineAdapter(BasePlatformAdapter):
|
|
|
1880
2524
|
rows.append({"actions": [self._action(f"{prefix}reset", "Reset")]})
|
|
1881
2525
|
return {"rows": rows}
|
|
1882
2526
|
|
|
2527
|
+
@_activity_local_sends
|
|
1883
2528
|
async def _send_thread_status(
|
|
1884
2529
|
self,
|
|
1885
2530
|
*,
|
|
@@ -1896,6 +2541,7 @@ class InlineAdapter(BasePlatformAdapter):
|
|
|
1896
2541
|
actions=self._thread_status_actions(display_chat_id=chat_id, target_chat_id=target_chat_id),
|
|
1897
2542
|
)
|
|
1898
2543
|
|
|
2544
|
+
@_activity_local_sends
|
|
1899
2545
|
async def _handle_thread_command(
|
|
1900
2546
|
self,
|
|
1901
2547
|
*,
|
|
@@ -1938,6 +2584,7 @@ class InlineAdapter(BasePlatformAdapter):
|
|
|
1938
2584
|
)
|
|
1939
2585
|
return True
|
|
1940
2586
|
|
|
2587
|
+
@_activity_local_sends
|
|
1941
2588
|
async def _handle_follow_command(
|
|
1942
2589
|
self,
|
|
1943
2590
|
*,
|
|
@@ -1981,6 +2628,7 @@ class InlineAdapter(BasePlatformAdapter):
|
|
|
1981
2628
|
await self.send(chat_id, body, reply_to=msg_id, metadata=metadata)
|
|
1982
2629
|
return True
|
|
1983
2630
|
|
|
2631
|
+
@_activity_local_sends
|
|
1984
2632
|
async def _handle_inline_maintenance_command(
|
|
1985
2633
|
self,
|
|
1986
2634
|
*,
|
|
@@ -2037,6 +2685,7 @@ class InlineAdapter(BasePlatformAdapter):
|
|
|
2037
2685
|
return False
|
|
2038
2686
|
# Re-register under the profile scope the gateway connects this adapter in.
|
|
2039
2687
|
_configure_tool_sidecar(bind=self._sidecar_bind, port=self._sidecar_port, token=self._sidecar_token)
|
|
2688
|
+
self._activity_home = str(hermes_home())
|
|
2040
2689
|
node_error = _node_version_error(self._node_bin)
|
|
2041
2690
|
if node_error:
|
|
2042
2691
|
self._set_fatal_error("NODE_UNSUPPORTED", node_error, retryable=False)
|
|
@@ -2110,6 +2759,7 @@ class InlineAdapter(BasePlatformAdapter):
|
|
|
2110
2759
|
if self._bot_settings_tasks:
|
|
2111
2760
|
await asyncio.gather(*self._bot_settings_tasks, return_exceptions=True)
|
|
2112
2761
|
self._bot_settings_tasks.clear()
|
|
2762
|
+
self._activity_stop()
|
|
2113
2763
|
await self._stop_sidecar()
|
|
2114
2764
|
if self._http_client is not None:
|
|
2115
2765
|
await self._http_client.aclose()
|
|
@@ -2170,14 +2820,34 @@ class InlineAdapter(BasePlatformAdapter):
|
|
|
2170
2820
|
raise RuntimeError(f"Inline sidecar did not become ready within {timeout}: {last_error}")
|
|
2171
2821
|
|
|
2172
2822
|
async def _supervise_sidecar(self, proc: subprocess.Popen) -> None:
|
|
2173
|
-
|
|
2823
|
+
"""Log sidecar output. A quiet sidecar can go hours without a line, so its blocking
|
|
2824
|
+
read runs on a thread of its own: on the loop's shared executor every sidecar would pin
|
|
2825
|
+
a worker, and a gateway with more Inline bots than workers stalls every other
|
|
2826
|
+
``to_thread`` job (startup included)."""
|
|
2827
|
+
stdout = proc.stdout
|
|
2828
|
+
if stdout is None:
|
|
2174
2829
|
return
|
|
2175
|
-
loop = asyncio.
|
|
2176
|
-
|
|
2830
|
+
loop = asyncio.get_running_loop()
|
|
2831
|
+
lines: "asyncio.Queue[bytes]" = asyncio.Queue()
|
|
2832
|
+
|
|
2833
|
+
def hand_off(raw: bytes) -> None:
|
|
2177
2834
|
try:
|
|
2178
|
-
|
|
2835
|
+
loop.call_soon_threadsafe(lines.put_nowait, raw)
|
|
2836
|
+
except RuntimeError:
|
|
2837
|
+
pass # the loop closed first
|
|
2838
|
+
|
|
2839
|
+
def read() -> None:
|
|
2840
|
+
try:
|
|
2841
|
+
for raw in iter(stdout.readline, b""):
|
|
2842
|
+
hand_off(raw)
|
|
2179
2843
|
except Exception:
|
|
2180
|
-
|
|
2844
|
+
pass
|
|
2845
|
+
finally:
|
|
2846
|
+
hand_off(b"")
|
|
2847
|
+
|
|
2848
|
+
threading.Thread(target=read, name="inline-sidecar-stdout", daemon=True).start()
|
|
2849
|
+
while True:
|
|
2850
|
+
line = await lines.get()
|
|
2181
2851
|
if not line:
|
|
2182
2852
|
return
|
|
2183
2853
|
text = line.decode("utf-8", "replace").rstrip()
|
|
@@ -2206,9 +2876,7 @@ class InlineAdapter(BasePlatformAdapter):
|
|
|
2206
2876
|
)
|
|
2207
2877
|
except Exception:
|
|
2208
2878
|
pass
|
|
2209
|
-
|
|
2210
|
-
proc.wait(timeout=3.0)
|
|
2211
|
-
except subprocess.TimeoutExpired:
|
|
2879
|
+
if not await _wait_for_exit(proc, 3.0):
|
|
2212
2880
|
if sys.platform != "win32":
|
|
2213
2881
|
try:
|
|
2214
2882
|
os.killpg(os.getpgid(proc.pid), signal.SIGTERM)
|
|
@@ -2216,9 +2884,7 @@ class InlineAdapter(BasePlatformAdapter):
|
|
|
2216
2884
|
proc.terminate()
|
|
2217
2885
|
else:
|
|
2218
2886
|
proc.terminate()
|
|
2219
|
-
|
|
2220
|
-
proc.wait(timeout=2.0)
|
|
2221
|
-
except subprocess.TimeoutExpired:
|
|
2887
|
+
if not await _wait_for_exit(proc, 2.0):
|
|
2222
2888
|
proc.kill()
|
|
2223
2889
|
finally:
|
|
2224
2890
|
self._sidecar_proc = None
|
|
@@ -2519,10 +3185,12 @@ class InlineAdapter(BasePlatformAdapter):
|
|
|
2519
3185
|
self._schedule_bot_settings_task(self._handle_bot_settings_item(event))
|
|
2520
3186
|
return
|
|
2521
3187
|
if kind == "message.action.invoke":
|
|
2522
|
-
|
|
3188
|
+
# A widget call names its widget, never a native control, so it skips system handlers.
|
|
3189
|
+
widget_call = inline_widget_call(event) is not None
|
|
3190
|
+
if not widget_call and await self._handle_action(event):
|
|
2523
3191
|
return
|
|
2524
3192
|
ownership = resolve_inline_message_action_ownership(event.get("actionId"))
|
|
2525
|
-
if ownership.owner == "system" and ownership.explicit:
|
|
3193
|
+
if not widget_call and ownership.owner == "system" and ownership.explicit:
|
|
2526
3194
|
await self._answer_action(str(event.get("interactionId") or ""), "Action expired")
|
|
2527
3195
|
logger.info("[inline] dropped unhandled system action %s", event.get("actionId") or "")
|
|
2528
3196
|
return
|
|
@@ -2757,9 +3425,7 @@ class InlineAdapter(BasePlatformAdapter):
|
|
|
2757
3425
|
await self.handle_message(MessageEvent(
|
|
2758
3426
|
text=text or "[Inline message with no text]", message_type=MessageType.TEXT,
|
|
2759
3427
|
source=source, raw_message=event,
|
|
2760
|
-
message_id=
|
|
2761
|
-
agent_action.get("messageId"), agent_action.get("interactionId"),
|
|
2762
|
-
) if agent_action else msg_id,
|
|
3428
|
+
message_id=_agent_action_turn_message_id(agent_action, msg_id),
|
|
2763
3429
|
timestamp=self._timestamp(event.get("date") or msg.get("date")),
|
|
2764
3430
|
allow_gateway_control=not bool(agent_action),
|
|
2765
3431
|
))
|
|
@@ -2935,20 +3601,17 @@ class InlineAdapter(BasePlatformAdapter):
|
|
|
2935
3601
|
# A bot's answer coming back needs no reply: the agent may stay silent instead of Hermes
|
|
2936
3602
|
# posting its "needed a reply" warning. Hosts before reply_expected ignore this.
|
|
2937
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)
|
|
2938
3608
|
await self.handle_message(MessageEvent(
|
|
2939
3609
|
**({"reply_expected": False} if answer_from_bot and "reply_expected" in getattr(MessageEvent, "__dataclass_fields__", {}) else {}),
|
|
2940
3610
|
text=text,
|
|
2941
3611
|
message_type=message_type,
|
|
2942
3612
|
source=source,
|
|
2943
3613
|
raw_message=event,
|
|
2944
|
-
message_id=(
|
|
2945
|
-
build_inline_agent_action_turn_id(
|
|
2946
|
-
agent_action.get("messageId"),
|
|
2947
|
-
agent_action.get("interactionId"),
|
|
2948
|
-
)
|
|
2949
|
-
if agent_action
|
|
2950
|
-
else msg_id
|
|
2951
|
-
),
|
|
3614
|
+
message_id=_agent_action_turn_message_id(agent_action, msg_id),
|
|
2952
3615
|
platform_update_id=int(event.get("seq") or 0) if str(event.get("seq") or "").isdigit() else None,
|
|
2953
3616
|
media_urls=media_urls,
|
|
2954
3617
|
media_types=media_types,
|
|
@@ -2982,6 +3645,12 @@ class InlineAdapter(BasePlatformAdapter):
|
|
|
2982
3645
|
if not target:
|
|
2983
3646
|
await self._answer_action(interaction_id, "Action expired or no longer accessible.")
|
|
2984
3647
|
return
|
|
3648
|
+
widget_call = inline_widget_call(event)
|
|
3649
|
+
if widget_call is None and str(event.get("actionId") or "") in inline_message_widget_ids(target):
|
|
3650
|
+
# Older realtime SDKs drop the call's name and payload but keep the widget ID.
|
|
3651
|
+
widget_call = {}
|
|
3652
|
+
if widget_call is not None:
|
|
3653
|
+
event = {**event, "widgetCall": widget_call}
|
|
2985
3654
|
|
|
2986
3655
|
synthetic_message = dict(target)
|
|
2987
3656
|
for stale_key in ("actions", "attachments", "entities", "media", "reactions", "replies", "sender"):
|
|
@@ -4432,32 +5101,93 @@ class InlineAdapter(BasePlatformAdapter):
|
|
|
4432
5101
|
_, approval_id, choice = parts
|
|
4433
5102
|
choice = "once" if choice == "approve" else choice
|
|
4434
5103
|
session_key = self._approval_sessions.get(approval_id)
|
|
4435
|
-
if choice not in
|
|
5104
|
+
if choice not in _APPROVAL_CHOICES:
|
|
4436
5105
|
return False
|
|
4437
5106
|
if not session_key:
|
|
4438
|
-
|
|
5107
|
+
command = self._approval_commands.pop(approval_id, None) or await self._approval_command_from_message(event)
|
|
5108
|
+
await self._finish_action(event, "Approval expired", _approval_card_text(_APPROVAL_EXPIRED_HEADING, command))
|
|
4439
5109
|
return True
|
|
4440
5110
|
try:
|
|
4441
|
-
|
|
4442
|
-
|
|
5111
|
+
request_id = self._approval_request_ids.get(approval_id)
|
|
5112
|
+
actor_id = str(event.get("actorUserId") or "").strip()
|
|
5113
|
+
refusal = self._approval_refusal(session_key, actor_id, request_id)
|
|
5114
|
+
if refusal:
|
|
5115
|
+
# Another member's approval: leave the card live for the requester.
|
|
5116
|
+
await self._answer_action(str(event.get("interactionId") or ""), refusal)
|
|
5117
|
+
return True
|
|
5118
|
+
count = self._resolve_approval(session_key, choice, request_id, actor_id)
|
|
4443
5119
|
self._approval_sessions.pop(approval_id, None)
|
|
5120
|
+
self._approval_request_ids.pop(approval_id, None)
|
|
5121
|
+
command = self._approval_commands.pop(approval_id, None) or ""
|
|
4444
5122
|
if not count:
|
|
4445
|
-
await self._finish_action(event, "Approval expired",
|
|
5123
|
+
await self._finish_action(event, "Approval expired", _approval_card_text(_APPROVAL_EXPIRED_HEADING, command))
|
|
4446
5124
|
return True
|
|
4447
|
-
|
|
4448
|
-
"once":
|
|
4449
|
-
"session":
|
|
4450
|
-
"always":
|
|
4451
|
-
"deny":
|
|
5125
|
+
toasts = {
|
|
5126
|
+
"once": "Approved once",
|
|
5127
|
+
"session": "Approved for session",
|
|
5128
|
+
"always": "Always allowed",
|
|
5129
|
+
"deny": "Denied",
|
|
4452
5130
|
}
|
|
4453
|
-
|
|
4454
|
-
|
|
5131
|
+
# Slack, Discord and OpenClaw's Inline card say who answered; a bare id is noise.
|
|
5132
|
+
answered_by, _, _ = _inline_sender_identity(_inline_sender_profile(event))
|
|
5133
|
+
heading = _APPROVAL_DECISION_HEADINGS[choice] + (f" by {answered_by}" if answered_by else "")
|
|
5134
|
+
await self._finish_action(event, toasts[choice], _approval_card_text(heading, command))
|
|
4455
5135
|
return True
|
|
4456
5136
|
except Exception as exc:
|
|
4457
5137
|
self._report_error("action.approval", exc)
|
|
4458
5138
|
logger.exception("[inline] approval action failed")
|
|
4459
5139
|
return True
|
|
4460
5140
|
|
|
5141
|
+
async def _approval_command_from_message(self, event: Dict[str, Any]) -> str:
|
|
5142
|
+
"""Recover the command from the card itself when this process no longer has it (e.g. after a restart)."""
|
|
5143
|
+
chat_id = str(event.get("chatId") or "")
|
|
5144
|
+
message_id = str(event.get("messageId") or "")
|
|
5145
|
+
msg = await self._fetch_message(chat_id, message_id) if chat_id and message_id else None
|
|
5146
|
+
if not msg:
|
|
5147
|
+
return ""
|
|
5148
|
+
text = str(msg.get("message") or "")
|
|
5149
|
+
units = text.encode("utf-16-le")
|
|
5150
|
+
for entity in self._message_entities(msg):
|
|
5151
|
+
if self._entity_kind(entity) != "pre":
|
|
5152
|
+
continue
|
|
5153
|
+
offset = _to_int(entity.get("offset"))
|
|
5154
|
+
length = _to_int(entity.get("length"))
|
|
5155
|
+
if offset is None or length is None or offset < 0 or length <= 0:
|
|
5156
|
+
continue
|
|
5157
|
+
return units[offset * 2 : (offset + length) * 2].decode("utf-16-le", errors="ignore").strip()
|
|
5158
|
+
match = _APPROVAL_FENCE_PATTERN.search(text)
|
|
5159
|
+
return match.group(2).strip() if match else ""
|
|
5160
|
+
|
|
5161
|
+
def _approval_refusal(self, session_key: str, actor_id: str, request_id: Optional[str]) -> Optional[str]:
|
|
5162
|
+
"""Hermes' approver rule (``approvals.approvers``) for a tap, or None when it may answer.
|
|
5163
|
+
Hosts without the rule leave it to the chat access check every action already passed."""
|
|
5164
|
+
try:
|
|
5165
|
+
from gateway.approval_actor import approval_refusal
|
|
5166
|
+
except ImportError:
|
|
5167
|
+
return None
|
|
5168
|
+
requester = approval_refusal(session_key, actor_id, extra=self.config.extra, request_id=request_id)
|
|
5169
|
+
if not requester:
|
|
5170
|
+
return None
|
|
5171
|
+
try:
|
|
5172
|
+
from gateway.approval_actor import refusal_text
|
|
5173
|
+
return str(refusal_text(requester))
|
|
5174
|
+
except ImportError:
|
|
5175
|
+
return f"Only {requester} can answer this approval."
|
|
5176
|
+
|
|
5177
|
+
def _resolve_approval(self, session_key: str, choice: str, request_id: Optional[str], actor_id: str) -> int:
|
|
5178
|
+
"""Resolve only the request the card was sent for; once it settled (timeout, /approve)
|
|
5179
|
+
the tap resolves nothing rather than the next one queued. A card without a request id
|
|
5180
|
+
(hosts before 0.22.7) keeps the oldest-pending behaviour."""
|
|
5181
|
+
from tools import approval as approval_mod
|
|
5182
|
+
resolve_card = getattr(approval_mod, "resolve_gateway_approval_card", None)
|
|
5183
|
+
if resolve_card is not None:
|
|
5184
|
+
if _accepts_keyword(resolve_card, "actor_id"):
|
|
5185
|
+
return resolve_card(session_key, choice, request_id, actor_id=actor_id or None, extra=self.config.extra)
|
|
5186
|
+
return resolve_card(session_key, choice, request_id)
|
|
5187
|
+
if request_id:
|
|
5188
|
+
return approval_mod.resolve_gateway_approval(session_key, choice, request_id=request_id)
|
|
5189
|
+
return approval_mod.resolve_gateway_approval(session_key, choice)
|
|
5190
|
+
|
|
4461
5191
|
async def _handle_slash_action(self, event: Dict[str, Any]) -> bool:
|
|
4462
5192
|
action_id = str(event.get("actionId") or "")
|
|
4463
5193
|
parts = action_id.split(":", 2)
|
|
@@ -4851,6 +5581,7 @@ class InlineAdapter(BasePlatformAdapter):
|
|
|
4851
5581
|
logger.warning("[inline] expensive model check failed", exc_info=True)
|
|
4852
5582
|
return None
|
|
4853
5583
|
|
|
5584
|
+
@_activity_local_sends
|
|
4854
5585
|
async def _edit_action_message(self, event: Dict[str, Any], text: str, actions: Optional[Dict[str, Any]] = None) -> SendResult:
|
|
4855
5586
|
chat_id = str(event.get("chatId") or "")
|
|
4856
5587
|
message_id = str(event.get("messageId") or "")
|
|
@@ -4890,8 +5621,49 @@ class InlineAdapter(BasePlatformAdapter):
|
|
|
4890
5621
|
metadata: Optional[Dict[str, Any]] = None,
|
|
4891
5622
|
actions: Optional[Dict[str, Any]] = None,
|
|
4892
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,
|
|
5661
|
+
) -> SendResult:
|
|
5662
|
+
requested = content
|
|
5663
|
+
content = self._with_cron_mention(content, metadata)
|
|
4893
5664
|
metadata = self._with_turn_thread(chat_id, reply_to, metadata)
|
|
4894
5665
|
target = self._target_for(chat_id, metadata)
|
|
5666
|
+
send_options = {"sendMode": "silent"} if (metadata or {}).get("notify") is False else {}
|
|
4895
5667
|
agent_action_target = parse_inline_agent_action_reply_target(reply_to)
|
|
4896
5668
|
if agent_action_target:
|
|
4897
5669
|
chunks = self.truncate_message(self.format_message(content), self.MAX_MESSAGE_LENGTH)
|
|
@@ -4916,10 +5688,13 @@ class InlineAdapter(BasePlatformAdapter):
|
|
|
4916
5688
|
"text": chunk,
|
|
4917
5689
|
"parseMarkdown": self._parse_markdown,
|
|
4918
5690
|
"replyToMsgId": previous_id,
|
|
5691
|
+
**send_options,
|
|
4919
5692
|
})
|
|
4920
5693
|
raw_responses.append(continuation.raw_response)
|
|
4921
5694
|
if not continuation.success:
|
|
4922
|
-
return
|
|
5695
|
+
return self._partial_send_failure(
|
|
5696
|
+
chat_id, requested, continuation, previous_id, continuation_ids, len(chunks), raw_responses,
|
|
5697
|
+
)
|
|
4923
5698
|
if continuation.message_id:
|
|
4924
5699
|
previous_id = str(continuation.message_id)
|
|
4925
5700
|
continuation_ids.append(previous_id)
|
|
@@ -4945,6 +5720,7 @@ class InlineAdapter(BasePlatformAdapter):
|
|
|
4945
5720
|
"target": target,
|
|
4946
5721
|
"text": chunk,
|
|
4947
5722
|
"parseMarkdown": self._parse_markdown,
|
|
5723
|
+
**send_options,
|
|
4948
5724
|
}
|
|
4949
5725
|
if reply_to and index == 0:
|
|
4950
5726
|
body["replyToMsgId"] = str(reply_to)
|
|
@@ -4952,7 +5728,12 @@ class InlineAdapter(BasePlatformAdapter):
|
|
|
4952
5728
|
body["actions"] = actions
|
|
4953
5729
|
last_result = await self._send_sidecar("/send", body)
|
|
4954
5730
|
if not last_result.success:
|
|
4955
|
-
|
|
5731
|
+
if not message_ids:
|
|
5732
|
+
return last_result
|
|
5733
|
+
return self._partial_send_failure(
|
|
5734
|
+
chat_id, requested, last_result, message_ids[-1], message_ids[:-1], len(chunks),
|
|
5735
|
+
[*raw_responses, getattr(last_result, "raw_response", None)],
|
|
5736
|
+
)
|
|
4956
5737
|
if not marked_visible:
|
|
4957
5738
|
self._mark_reply_thread_visible(target)
|
|
4958
5739
|
marked_visible = True
|
|
@@ -5014,7 +5795,13 @@ class InlineAdapter(BasePlatformAdapter):
|
|
|
5014
5795
|
metadata: Optional[Dict[str, Any]] = None,
|
|
5015
5796
|
) -> SendResult:
|
|
5016
5797
|
text = self.format_message(content)
|
|
5017
|
-
|
|
5798
|
+
several_widgets = False
|
|
5799
|
+
if self._parse_markdown and has_widget_markup(text):
|
|
5800
|
+
if not finalize:
|
|
5801
|
+
# A streaming preview must not send a widget whose closing tag hasn't arrived.
|
|
5802
|
+
text = streaming_preview(text)
|
|
5803
|
+
several_widgets = len(scan_widgets(text).widgets) > 1
|
|
5804
|
+
if len(text) > self.MAX_MESSAGE_LENGTH or several_widgets:
|
|
5018
5805
|
if finalize:
|
|
5019
5806
|
return await self._edit_overflow_split(chat_id, message_id, content, metadata=metadata)
|
|
5020
5807
|
text = self.truncate_message(text, self.MAX_MESSAGE_LENGTH)[0]
|
|
@@ -5174,6 +5961,7 @@ class InlineAdapter(BasePlatformAdapter):
|
|
|
5174
5961
|
|
|
5175
5962
|
async def on_processing_start(self, event: MessageEvent) -> None:
|
|
5176
5963
|
"""Mark an inbound message while Hermes is actively processing it."""
|
|
5964
|
+
self._activity_begin(event)
|
|
5177
5965
|
reaction_target = self._processing_reaction_target(event)
|
|
5178
5966
|
if not reaction_target:
|
|
5179
5967
|
return
|
|
@@ -5191,6 +5979,7 @@ class InlineAdapter(BasePlatformAdapter):
|
|
|
5191
5979
|
|
|
5192
5980
|
async def on_processing_complete(self, event: MessageEvent, outcome: Any) -> None:
|
|
5193
5981
|
"""Replace the processing marker without affecting response delivery."""
|
|
5982
|
+
self._activity_finish(event, outcome)
|
|
5194
5983
|
reaction_target = self._processing_reaction_target(event)
|
|
5195
5984
|
if not reaction_target:
|
|
5196
5985
|
return
|
|
@@ -5205,6 +5994,140 @@ class InlineAdapter(BasePlatformAdapter):
|
|
|
5205
5994
|
elif outcome_name == "failure":
|
|
5206
5995
|
await self._set_processing_reaction(target, message_id, "❌")
|
|
5207
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
|
+
|
|
5208
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:
|
|
5209
6132
|
return await self._send_attachment(chat_id, image_path, "photo", caption, reply_to, metadata=metadata)
|
|
5210
6133
|
|
|
@@ -5232,6 +6155,10 @@ class InlineAdapter(BasePlatformAdapter):
|
|
|
5232
6155
|
return await self._send_attachment(chat_id, audio_path, "voice", caption, reply_to, metadata=metadata)
|
|
5233
6156
|
|
|
5234
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)
|
|
5235
6162
|
safe_path = self.validate_media_delivery_path(file_path)
|
|
5236
6163
|
if not safe_path:
|
|
5237
6164
|
return _send_result(success=False, error=f"unsafe or missing attachment path: {file_path}", error_kind="bad_format")
|
|
@@ -5281,7 +6208,12 @@ class InlineAdapter(BasePlatformAdapter):
|
|
|
5281
6208
|
if not choices:
|
|
5282
6209
|
from tools.clarify_gateway import mark_awaiting_text
|
|
5283
6210
|
mark_awaiting_text(clarify_id)
|
|
5284
|
-
|
|
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)
|
|
5285
6217
|
clean_choices = [str(c).strip() for c in choices if str(c).strip()][:10]
|
|
5286
6218
|
self._remember(self._clarify_sessions, clarify_id, session_key)
|
|
5287
6219
|
self._remember(self._clarify_choices, clarify_id, clean_choices)
|
|
@@ -5298,35 +6230,31 @@ class InlineAdapter(BasePlatformAdapter):
|
|
|
5298
6230
|
"actions": {"rows": self._action_rows(actions)},
|
|
5299
6231
|
})
|
|
5300
6232
|
|
|
5301
|
-
async def
|
|
5302
|
-
|
|
5303
|
-
|
|
5304
|
-
command
|
|
5305
|
-
|
|
5306
|
-
|
|
5307
|
-
|
|
5308
|
-
allow_permanent: bool = True,
|
|
5309
|
-
allow_session: bool = True,
|
|
5310
|
-
smart_denied: bool = False,
|
|
5311
|
-
) -> SendResult:
|
|
6233
|
+
async def _send_exec_approval_prompt(self, prompt: Any) -> SendResult:
|
|
6234
|
+
"""Draw Hermes' exec-approval prompt as an Inline button card.
|
|
6235
|
+
|
|
6236
|
+
Hermes' ``send_exec_approval`` owns the wording, a truncated command's full send and
|
|
6237
|
+
the Deny-only gate of newer hosts; overriding this hook is also how Hermes knows the
|
|
6238
|
+
adapter has native buttons. Inline only draws the offered choices and remembers which
|
|
6239
|
+
request the card answers, so a tap never resolves a different one."""
|
|
5312
6240
|
approval_id = secrets.token_hex(6)
|
|
5313
|
-
|
|
5314
|
-
|
|
5315
|
-
|
|
5316
|
-
|
|
5317
|
-
|
|
5318
|
-
actions.append(self._action(f"appr:{approval_id}:session", "Allow for Session"))
|
|
5319
|
-
if allow_permanent:
|
|
5320
|
-
actions.append(self._action(f"appr:{approval_id}:always", "Always Allow"))
|
|
5321
|
-
actions.append(self._action(f"appr:{approval_id}:deny", "Deny"))
|
|
6241
|
+
actions = [
|
|
6242
|
+
self._action(f"appr:{approval_id}:{choice}", str(label))
|
|
6243
|
+
for label, choice, _style in prompt.actions
|
|
6244
|
+
if choice in _APPROVAL_CHOICES
|
|
6245
|
+
]
|
|
5322
6246
|
result = await self._send_sidecar("/send", {
|
|
5323
|
-
"target": self._target_for(chat_id, metadata),
|
|
5324
|
-
"text": text,
|
|
6247
|
+
"target": self._target_for(prompt.chat_id, prompt.metadata),
|
|
6248
|
+
"text": prompt.text,
|
|
5325
6249
|
"parseMarkdown": self._parse_markdown,
|
|
5326
6250
|
"actions": {"rows": self._action_rows(actions)},
|
|
5327
6251
|
})
|
|
5328
6252
|
if result.success:
|
|
5329
|
-
self._remember(self._approval_sessions, approval_id, session_key)
|
|
6253
|
+
self._remember(self._approval_sessions, approval_id, prompt.session_key)
|
|
6254
|
+
self._remember(self._approval_commands, approval_id, str(getattr(prompt, "command", "") or ""))
|
|
6255
|
+
request_id = getattr(prompt, "request_id", None)
|
|
6256
|
+
if request_id:
|
|
6257
|
+
self._remember(self._approval_request_ids, approval_id, str(request_id))
|
|
5330
6258
|
return result
|
|
5331
6259
|
|
|
5332
6260
|
async def send_slash_confirm(self, chat_id: str, title: str, message: str, session_key: str, confirm_id: str, metadata: Optional[Dict[str, Any]] = None) -> SendResult:
|
|
@@ -5642,6 +6570,81 @@ class InlineAdapter(BasePlatformAdapter):
|
|
|
5642
6570
|
out["parent_message_id"] = parent_message_id
|
|
5643
6571
|
return out
|
|
5644
6572
|
|
|
6573
|
+
def _with_cron_mention(self, content: str, metadata: Optional[Dict[str, Any]]) -> str:
|
|
6574
|
+
# Hermes identifies scheduled text sends with job_id. notify also appears
|
|
6575
|
+
# on ordinary replies, so it cannot identify a cron on its own. Decorate
|
|
6576
|
+
# before chunking to mention once, while preserving the supplied body.
|
|
6577
|
+
meta = metadata or {}
|
|
6578
|
+
prefix = self._cron_mention_prefix
|
|
6579
|
+
if not prefix or not self._parse_markdown or not meta.get("job_id") or meta.get("notify") is False:
|
|
6580
|
+
return content
|
|
6581
|
+
if not content or not content.strip() or content.startswith(prefix):
|
|
6582
|
+
return content
|
|
6583
|
+
return f"{prefix}\n\n{content}"
|
|
6584
|
+
|
|
6585
|
+
def truncate_message(
|
|
6586
|
+
self,
|
|
6587
|
+
content: str,
|
|
6588
|
+
max_length: int = _MAX_MESSAGE_LENGTH,
|
|
6589
|
+
len_fn: Optional[Callable[[str], int]] = None,
|
|
6590
|
+
) -> List[str]:
|
|
6591
|
+
"""Hermes splitting, except a widget block is never cut or shares a message with another."""
|
|
6592
|
+
if getattr(self, "_parse_markdown", True):
|
|
6593
|
+
def split_plain(text: str, limit: int) -> List[str]:
|
|
6594
|
+
pieces = self._base_truncate(text, limit, len_fn)
|
|
6595
|
+
# Upstream numbers multi-part output; the parts here are pieces of one reply.
|
|
6596
|
+
return [piece.removesuffix(f" ({i + 1}/{len(pieces)})") for i, piece in enumerate(pieces)]
|
|
6597
|
+
|
|
6598
|
+
chunks = split_widget_aware(content, max_length, split_plain, len_fn or len)
|
|
6599
|
+
if chunks is not None:
|
|
6600
|
+
return chunks
|
|
6601
|
+
return self._base_truncate(content, max_length, len_fn)
|
|
6602
|
+
|
|
6603
|
+
def _base_truncate(self, content: str, max_length: int, len_fn: Optional[Callable[[str], int]]) -> List[str]:
|
|
6604
|
+
base = super().truncate_message
|
|
6605
|
+
if len_fn is not None and _accepts_keyword(base, "len_fn"):
|
|
6606
|
+
return list(base(content, max_length, len_fn=len_fn))
|
|
6607
|
+
return list(base(content, max_length))
|
|
6608
|
+
|
|
6609
|
+
def _partial_send_failure(
|
|
6610
|
+
self, chat_id: str, requested: str, failed: SendResult, last_delivered_id: str,
|
|
6611
|
+
delivered_continuations: List[str], total_chunks: int, raw_responses: List[Any],
|
|
6612
|
+
) -> SendResult:
|
|
6613
|
+
"""A later chunk failed after earlier ones were delivered. ``partial_overflow`` tells Hermes
|
|
6614
|
+
(0.22.7+) not to resend the visible head as plain text; older hosts are stopped in
|
|
6615
|
+
``_send_plain_fallback``. Either way the reply is not posted twice."""
|
|
6616
|
+
if getattr(BasePlatformAdapter, "_is_partial_delivery", None) is None:
|
|
6617
|
+
self._remember(self._partial_sends, (self._chat_key(chat_id), requested), True, limit=64)
|
|
6618
|
+
return _send_result(
|
|
6619
|
+
success=False,
|
|
6620
|
+
message_id=last_delivered_id,
|
|
6621
|
+
error=failed.error or "continuation failed",
|
|
6622
|
+
error_kind=getattr(failed, "error_kind", None),
|
|
6623
|
+
retryable=bool(getattr(failed, "retryable", False)),
|
|
6624
|
+
raw_response={
|
|
6625
|
+
"partial_overflow": True,
|
|
6626
|
+
"delivered_chunks": 1 + len(delivered_continuations),
|
|
6627
|
+
"total_chunks": total_chunks,
|
|
6628
|
+
"last_message_id": last_delivered_id,
|
|
6629
|
+
"continuation_message_ids": tuple(delivered_continuations),
|
|
6630
|
+
"responses": raw_responses,
|
|
6631
|
+
},
|
|
6632
|
+
continuation_message_ids=tuple(delivered_continuations),
|
|
6633
|
+
)
|
|
6634
|
+
|
|
6635
|
+
async def _send_plain_fallback(self, chat_id: str, content: str, *, reply_to: Optional[str], metadata: Any) -> SendResult:
|
|
6636
|
+
if self._partial_sends.pop((self._chat_key(chat_id), content), None):
|
|
6637
|
+
# Part of this reply is already in the chat: resending it would post that part twice.
|
|
6638
|
+
logger.warning("[inline] send failed after partial delivery; not resending it as plain text")
|
|
6639
|
+
return _send_result(success=False, error="send failed after partial delivery", error_kind="bad_format")
|
|
6640
|
+
# A widget the server rejected would fail the fallback too; keep the prose and say so.
|
|
6641
|
+
if getattr(self, "_parse_markdown", True) and has_widget_markup(content):
|
|
6642
|
+
content = without_widgets(content)
|
|
6643
|
+
base = getattr(super(), "_send_plain_fallback", None)
|
|
6644
|
+
if base is None:
|
|
6645
|
+
return await self.send(chat_id, content, reply_to=reply_to, metadata=metadata)
|
|
6646
|
+
return await base(chat_id, content, reply_to=reply_to, metadata=metadata)
|
|
6647
|
+
|
|
5645
6648
|
def format_message(self, content: str) -> str:
|
|
5646
6649
|
# parseMarkdown=false means literal input at every Inline API boundary.
|
|
5647
6650
|
# Preserve that contract instead of silently rewriting the caller's text.
|
|
@@ -5679,8 +6682,9 @@ class InlineAdapter(BasePlatformAdapter):
|
|
|
5679
6682
|
def _send_retry_is_final(self, result: SendResult) -> bool:
|
|
5680
6683
|
# The request reached Inline but its result was lost. A retry or plain-text
|
|
5681
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.
|
|
5682
6686
|
raw = getattr(result, "raw_response", None)
|
|
5683
|
-
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)
|
|
5684
6688
|
|
|
5685
6689
|
async def _send_sidecar(self, path: str, body: Dict[str, Any]) -> SendResult:
|
|
5686
6690
|
try:
|
|
@@ -5731,6 +6735,13 @@ class InlineAdapter(BasePlatformAdapter):
|
|
|
5731
6735
|
return _sidecar_base_url(self._sidecar_bind, self._sidecar_port)
|
|
5732
6736
|
|
|
5733
6737
|
|
|
6738
|
+
def _agent_action_turn_message_id(agent_action: Optional[Dict[str, Any]], msg_id: str) -> str:
|
|
6739
|
+
"""A button turn's answer replaces its card; a widget call's answer replies below the widget."""
|
|
6740
|
+
if not agent_action or inline_widget_call(agent_action) is not None:
|
|
6741
|
+
return msg_id
|
|
6742
|
+
return build_inline_agent_action_turn_id(agent_action.get("messageId"), agent_action.get("interactionId"))
|
|
6743
|
+
|
|
6744
|
+
|
|
5734
6745
|
def _send_result(**kwargs: Any) -> SendResult:
|
|
5735
6746
|
fields = getattr(SendResult, "__dataclass_fields__", {})
|
|
5736
6747
|
for optional in ("error_kind", "retry_after"):
|
|
@@ -6000,3 +7011,4 @@ def register(ctx) -> None:
|
|
|
6000
7011
|
handler_fn=_cli.dispatch,
|
|
6001
7012
|
)
|
|
6002
7013
|
_tools.register(ctx)
|
|
7014
|
+
_register_activity_hooks(ctx)
|