mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-31 19:16:29 +00:00
Merge upstream main into feat/hermes-relay-shared-metrics
# Conflicts: # MANIFEST.in # pyproject.toml # tests/test_project_metadata.py
This commit is contained in:
commit
b4e105031a
506 changed files with 33840 additions and 5678 deletions
168
plugins/kanban/dashboard/dist/index.js
vendored
168
plugins/kanban/dashboard/dist/index.js
vendored
|
|
@ -3579,6 +3579,7 @@
|
|||
h(MetaRow, { label: tx(i18n, "status", "Status"), value: t.status }),
|
||||
h(AssigneeEditor, { task: t, onPatch: props.onPatch }),
|
||||
h(PriorityEditor, { task: t, onPatch: props.onPatch }),
|
||||
h(ModelEditor, { task: t, onPatch: props.onPatch }),
|
||||
t.tenant ? h(MetaRow, { label: tx(i18n, "tenant", "Tenant"), value: t.tenant }) : null,
|
||||
h(MetaRow, {
|
||||
label: tx(i18n, "workspace", "Workspace"),
|
||||
|
|
@ -3985,6 +3986,173 @@
|
|||
);
|
||||
}
|
||||
|
||||
// Module-level cache for the model-options catalog so opening several
|
||||
// task drawers doesn't refetch. { providers: [{slug,label,models}] }
|
||||
let _modelCatalogCache = null;
|
||||
let _modelCatalogPromise = null;
|
||||
function fetchModelCatalog() {
|
||||
if (_modelCatalogCache) return Promise.resolve(_modelCatalogCache);
|
||||
if (_modelCatalogPromise) return _modelCatalogPromise;
|
||||
_modelCatalogPromise = SDK.fetchJSON(`${API}/model-options`)
|
||||
.then(function (data) {
|
||||
_modelCatalogCache = data && Array.isArray(data.providers) ? data : { providers: [] };
|
||||
return _modelCatalogCache;
|
||||
})
|
||||
.catch(function () {
|
||||
_modelCatalogPromise = null; // allow retry on next open
|
||||
return { providers: [] };
|
||||
});
|
||||
return _modelCatalogPromise;
|
||||
}
|
||||
|
||||
// Per-task model override dropdown. Value encoding: "" = profile
|
||||
// default; "<slug>\u0000<model>" = provider+model pair (the separator
|
||||
// can't appear in either half). A catalog fetch failure degrades to a
|
||||
// free-text input so the override is still settable.
|
||||
function ModelEditor(props) {
|
||||
const { t } = useI18n();
|
||||
const task = props.task;
|
||||
const [editing, setEditing] = useState(false);
|
||||
const [catalog, setCatalog] = useState(_modelCatalogCache);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [freeText, setFreeText] = useState("");
|
||||
|
||||
useEffect(function () {
|
||||
if (!editing || catalog) return;
|
||||
let alive = true;
|
||||
fetchModelCatalog().then(function (data) {
|
||||
if (alive) setCatalog(data);
|
||||
});
|
||||
return function () { alive = false; };
|
||||
}, [editing, catalog]);
|
||||
|
||||
const current = task.model_override
|
||||
? (task.provider_override
|
||||
? `${task.provider_override}: ${task.model_override}`
|
||||
: task.model_override)
|
||||
: tx(t, "modelProfileDefault", "profile default");
|
||||
|
||||
if (!editing) {
|
||||
return h("div", { className: "hermes-kanban-meta-row" },
|
||||
h("span", { className: "hermes-kanban-meta-label" }, tx(t, "model", "Model")),
|
||||
h("span", {
|
||||
className: cn(
|
||||
"hermes-kanban-meta-value hermes-kanban-editable",
|
||||
!task.model_override ? "text-muted-foreground" : "",
|
||||
),
|
||||
onClick: function () { setEditing(true); },
|
||||
title: tx(t, "clickToEditModel",
|
||||
"Click to override the model for this task's next run"),
|
||||
}, current),
|
||||
);
|
||||
}
|
||||
|
||||
const apply = function (patch) {
|
||||
setBusy(true);
|
||||
props.onPatch(patch).then(function () {
|
||||
setEditing(false);
|
||||
}).catch(function () {
|
||||
// onPatch surfaces its own toast; just re-enable the control.
|
||||
}).then(function () { setBusy(false); });
|
||||
};
|
||||
|
||||
const onPick = function (value) {
|
||||
if (value === "") {
|
||||
apply({ clear_model_override: true });
|
||||
return;
|
||||
}
|
||||
const sep = value.indexOf("\u0000");
|
||||
if (sep === -1) {
|
||||
apply({ model_override: value });
|
||||
return;
|
||||
}
|
||||
apply({
|
||||
provider_override: value.slice(0, sep),
|
||||
model_override: value.slice(sep + 1),
|
||||
});
|
||||
};
|
||||
|
||||
const providers = (catalog && catalog.providers) || [];
|
||||
const loading = editing && !catalog;
|
||||
const currentValue = task.model_override
|
||||
? (task.provider_override
|
||||
? `${task.provider_override}\u0000${task.model_override}`
|
||||
: task.model_override)
|
||||
: "";
|
||||
|
||||
// Free-text fallback when the catalog is empty (inventory unavailable
|
||||
// or zero authenticated providers).
|
||||
if (!loading && providers.length === 0) {
|
||||
const saveFree = function () {
|
||||
const v = freeText.trim();
|
||||
if (!v) { apply({ clear_model_override: true }); return; }
|
||||
apply({ model_override: v });
|
||||
};
|
||||
return h("div", { className: "hermes-kanban-meta-row" },
|
||||
h("span", { className: "hermes-kanban-meta-label" }, tx(t, "model", "Model")),
|
||||
h(Input, {
|
||||
value: freeText, autoFocus: true, disabled: busy,
|
||||
placeholder: tx(t, "modelFreeTextPlaceholder", "model name (empty = profile default)"),
|
||||
onChange: function (e) { setFreeText(e.target.value); },
|
||||
onKeyDown: function (e) {
|
||||
if (e.key === "Enter") { e.preventDefault(); saveFree(); }
|
||||
if (e.key === "Escape") setEditing(false);
|
||||
},
|
||||
className: "h-7 text-xs flex-1",
|
||||
style: { textTransform: "none" },
|
||||
autoCapitalize: "none", autoCorrect: "off", spellCheck: false,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
// Ensure the current override is selectable even when it's not in the
|
||||
// catalog (e.g. set from the CLI with a model the catalog doesn't list).
|
||||
let currentInCatalog = currentValue === "";
|
||||
for (let i = 0; i < providers.length && !currentInCatalog; i++) {
|
||||
const p = providers[i];
|
||||
for (let j = 0; j < p.models.length; j++) {
|
||||
const enc = `${p.slug}\u0000${p.models[j]}`;
|
||||
if (enc === currentValue || p.models[j] === currentValue) {
|
||||
currentInCatalog = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return h("div", { className: "hermes-kanban-meta-row" },
|
||||
h("span", { className: "hermes-kanban-meta-label" }, tx(t, "model", "Model")),
|
||||
loading
|
||||
? h("span", { className: "hermes-kanban-meta-value text-muted-foreground" },
|
||||
tx(t, "modelLoading", "loading models…"))
|
||||
: h("select", {
|
||||
className: "hermes-kanban-recovery-select",
|
||||
value: currentValue,
|
||||
disabled: busy,
|
||||
autoFocus: true,
|
||||
onChange: function (e) { onPick(e.target.value); },
|
||||
onKeyDown: function (e) {
|
||||
if (e.key === "Escape") setEditing(false);
|
||||
},
|
||||
},
|
||||
h("option", { value: "" },
|
||||
tx(t, "modelProfileDefaultOption", "(profile default)")),
|
||||
!currentInCatalog
|
||||
? h("option", { value: currentValue }, current)
|
||||
: null,
|
||||
providers.map(function (p) {
|
||||
return h("optgroup", { key: p.slug, label: p.label || p.slug },
|
||||
p.models.map(function (m) {
|
||||
return h("option", {
|
||||
key: `${p.slug}\u0000${m}`,
|
||||
value: `${p.slug}\u0000${m}`,
|
||||
}, m);
|
||||
}),
|
||||
);
|
||||
}),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
function BodyEditor(props) {
|
||||
const { t } = useI18n();
|
||||
const [editing, setEditing] = useState(false);
|
||||
|
|
|
|||
|
|
@ -608,6 +608,8 @@ class CreateTaskBody(BaseModel):
|
|||
skills: Optional[list[str]] = None
|
||||
goal_mode: bool = False
|
||||
goal_max_turns: Optional[int] = None
|
||||
model_override: Optional[str] = None
|
||||
provider_override: Optional[str] = None
|
||||
|
||||
|
||||
@router.post("/tasks")
|
||||
|
|
@ -632,6 +634,8 @@ def create_task(payload: CreateTaskBody, board: Optional[str] = Query(None)):
|
|||
skills=payload.skills,
|
||||
goal_mode=payload.goal_mode,
|
||||
goal_max_turns=payload.goal_max_turns,
|
||||
model_override=payload.model_override,
|
||||
provider_override=payload.provider_override,
|
||||
)
|
||||
task = kanban_db.get_task(conn, task_id)
|
||||
body: dict[str, Any] = {"task": _task_dict(task) if task else None}
|
||||
|
|
@ -815,6 +819,13 @@ class UpdateTaskBody(BaseModel):
|
|||
# complete --summary ... --metadata ...``.
|
||||
summary: Optional[str] = None
|
||||
metadata: Optional[dict] = None
|
||||
# Per-task model/provider override (the board's model dropdown).
|
||||
# ``model_override=""`` clears both. ``clear_model_override=True`` is
|
||||
# the explicit clear signal — needed because Optional[str]=None means
|
||||
# "field not sent" in a PATCH, not "set to NULL".
|
||||
model_override: Optional[str] = None
|
||||
provider_override: Optional[str] = None
|
||||
clear_model_override: bool = False
|
||||
|
||||
|
||||
@router.patch("/tasks/{task_id}")
|
||||
|
|
@ -894,6 +905,22 @@ def update_task(task_id: str, payload: UpdateTaskBody, board: Optional[str] = Qu
|
|||
detail=f"status transition to {s!r} not valid from current state",
|
||||
)
|
||||
|
||||
# --- model/provider override ---------------------------------------
|
||||
if payload.clear_model_override or payload.model_override is not None:
|
||||
new_model = (
|
||||
None if payload.clear_model_override
|
||||
else (payload.model_override or "").strip() or None
|
||||
)
|
||||
try:
|
||||
ok = kanban_db.set_model_override(
|
||||
conn, task_id, new_model,
|
||||
provider=payload.provider_override,
|
||||
)
|
||||
except (ValueError, RuntimeError) as e:
|
||||
raise HTTPException(status_code=400, detail=str(e))
|
||||
if not ok:
|
||||
raise HTTPException(status_code=404, detail="task not found")
|
||||
|
||||
# --- priority -----------------------------------------------------
|
||||
if payload.priority is not None:
|
||||
with kanban_db.write_txn(conn):
|
||||
|
|
@ -1155,6 +1182,10 @@ class BulkTaskBody(BaseModel):
|
|||
summary: Optional[str] = None
|
||||
metadata: Optional[dict] = None
|
||||
reclaim_first: bool = False
|
||||
# Bulk model/provider override — same semantics as UpdateTaskBody.
|
||||
model_override: Optional[str] = None
|
||||
provider_override: Optional[str] = None
|
||||
clear_model_override: bool = False
|
||||
|
||||
|
||||
@router.post("/tasks/bulk")
|
||||
|
|
@ -1246,6 +1277,20 @@ def bulk_update(payload: BulkTaskBody, board: Optional[str] = Query(None)):
|
|||
(tid, json.dumps({"priority": int(payload.priority)}),
|
||||
int(time.time())),
|
||||
)
|
||||
if payload.clear_model_override or payload.model_override is not None:
|
||||
new_model = (
|
||||
None if payload.clear_model_override
|
||||
else (payload.model_override or "").strip() or None
|
||||
)
|
||||
try:
|
||||
ok = kanban_db.set_model_override(
|
||||
conn, tid, new_model,
|
||||
provider=payload.provider_override,
|
||||
)
|
||||
if not ok:
|
||||
entry.update(ok=False, error="model override refused")
|
||||
except (ValueError, RuntimeError) as e:
|
||||
entry.update(ok=False, error=str(e))
|
||||
except Exception as e: # defensive — one bad id shouldn't kill the batch
|
||||
entry.update(ok=False, error=str(e))
|
||||
results.append(entry)
|
||||
|
|
@ -1971,6 +2016,49 @@ def dispatch(
|
|||
conn.close()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Model options (the board's per-task model-override dropdown)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@router.get("/model-options")
|
||||
def model_options():
|
||||
"""Authenticated providers + curated model lists for the task drawer's
|
||||
model-override dropdown.
|
||||
|
||||
Thin wrapper around ``hermes_cli.inventory.build_models_payload`` — the
|
||||
same substrate the dashboard Models page and the TUI picker use, so the
|
||||
dropdown can never offer a model/provider pair the rest of Hermes
|
||||
wouldn't accept. Deliberately skips pricing/capability enrichment and
|
||||
custom-provider probes: the dropdown needs names fast, not $/Mtok
|
||||
columns (a slow/offline local endpoint must not hang the drawer).
|
||||
"""
|
||||
try:
|
||||
from hermes_cli.inventory import build_models_payload, load_picker_context
|
||||
|
||||
payload = build_models_payload(
|
||||
load_picker_context(),
|
||||
explicit_only=True,
|
||||
canonical_order=True,
|
||||
probe_custom_providers=False,
|
||||
)
|
||||
return {
|
||||
"providers": [
|
||||
{
|
||||
"slug": row.get("slug", ""),
|
||||
"label": row.get("label") or row.get("slug", ""),
|
||||
"models": list(row.get("models") or []),
|
||||
}
|
||||
for row in payload.get("providers", [])
|
||||
if row.get("models")
|
||||
],
|
||||
}
|
||||
except Exception:
|
||||
log.exception("kanban model-options failed")
|
||||
# Degrade to an empty catalog — the UI falls back to a free-text
|
||||
# input so the feature still works without the inventory module.
|
||||
return {"providers": []}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Boards CRUD (multi-project support)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
|
|
|||
|
|
@ -26,6 +26,7 @@ import time
|
|||
from collections import defaultdict
|
||||
from contextlib import suppress
|
||||
from typing import Callable, Dict, List, Optional, Any, Tuple
|
||||
from urllib.parse import urljoin
|
||||
|
||||
from agent.async_utils import (
|
||||
consume_detached_task_result as _consume_background_task_result,
|
||||
|
|
@ -67,6 +68,8 @@ _DISCORD_NONCONVERSATIONAL_METADATA_KEYS = frozenset({
|
|||
"non_conversational",
|
||||
"non_conversational_history",
|
||||
})
|
||||
_DISCORD_IMAGE_REDIRECT_STATUSES = {301, 302, 303, 307, 308}
|
||||
_DISCORD_IMAGE_MAX_REDIRECTS = 10
|
||||
# Upgrade-bridge fallback only. The primary mechanism is the persisted
|
||||
# non-conversational message-ID set populated from explicitly marked sends
|
||||
# (metadata["non_conversational"]). These regexes exist solely to recognize
|
||||
|
|
@ -135,6 +138,43 @@ from gateway.platforms.base import (
|
|||
from tools.url_safety import is_safe_url
|
||||
|
||||
|
||||
async def _read_url_image_with_redirect_guard(
|
||||
session: Any,
|
||||
url: str,
|
||||
*,
|
||||
timeout: Any,
|
||||
request_kwargs: Dict[str, Any],
|
||||
) -> Tuple[int, bytes, Dict[str, str]]:
|
||||
"""Read an image URL while re-checking every redirect target for SSRF."""
|
||||
current_url = url
|
||||
for _ in range(_DISCORD_IMAGE_MAX_REDIRECTS + 1):
|
||||
if not is_safe_url(current_url):
|
||||
raise ValueError("Blocked unsafe image URL redirect")
|
||||
|
||||
async with session.get(
|
||||
current_url,
|
||||
timeout=timeout,
|
||||
allow_redirects=False,
|
||||
**request_kwargs,
|
||||
) as resp:
|
||||
raw_headers = getattr(resp, "headers", {}) or {}
|
||||
headers = {str(key).lower(): value for key, value in dict(raw_headers).items()}
|
||||
status = int(getattr(resp, "status", 0))
|
||||
if status in _DISCORD_IMAGE_REDIRECT_STATUSES:
|
||||
location = headers.get("location")
|
||||
if not location:
|
||||
return status, b"", headers
|
||||
next_url = urljoin(current_url, str(location))
|
||||
if not is_safe_url(next_url):
|
||||
raise ValueError("Blocked redirect to private/internal address")
|
||||
current_url = next_url
|
||||
continue
|
||||
|
||||
return status, await resp.read(), headers
|
||||
|
||||
raise ValueError("Too many image URL redirects")
|
||||
|
||||
|
||||
def _truncate_discord_component_text(text: str, limit: int) -> str:
|
||||
"""Return text within Discord's UTF-16 component field budget."""
|
||||
return _prefix_within_utf16_limit(str(text or ""), max(0, limit))
|
||||
|
|
@ -3396,25 +3436,27 @@ class DiscordAdapter(BasePlatformAdapter):
|
|||
_sess_kw, _req_kw = proxy_kwargs_for_aiohttp(_proxy)
|
||||
if aiohttp_session is None:
|
||||
aiohttp_session = _aiohttp.ClientSession(**_sess_kw)
|
||||
async with aiohttp_session.get(
|
||||
image_url, timeout=_aiohttp.ClientTimeout(total=30), **_req_kw,
|
||||
) as resp:
|
||||
if resp.status != 200:
|
||||
logger.warning(
|
||||
"[%s] Failed to download image (HTTP %d) in batch: %s",
|
||||
self.name, resp.status, image_url[:80],
|
||||
)
|
||||
continue
|
||||
data = await resp.read()
|
||||
ct = resp.headers.get("content-type", "image/png")
|
||||
ext = "png"
|
||||
if "jpeg" in ct or "jpg" in ct:
|
||||
ext = "jpg"
|
||||
elif "gif" in ct:
|
||||
ext = "gif"
|
||||
elif "webp" in ct:
|
||||
ext = "webp"
|
||||
files.append(_discord_mod.File(_io.BytesIO(data), filename=f"image_{len(files)}.{ext}"))
|
||||
status, data, headers = await _read_url_image_with_redirect_guard(
|
||||
aiohttp_session,
|
||||
image_url,
|
||||
timeout=_aiohttp.ClientTimeout(total=30),
|
||||
request_kwargs=_req_kw,
|
||||
)
|
||||
if status != 200:
|
||||
logger.warning(
|
||||
"[%s] Failed to download image (HTTP %d) in batch: %s",
|
||||
self.name, status, image_url[:80],
|
||||
)
|
||||
continue
|
||||
ct = headers.get("content-type", "image/png")
|
||||
ext = "png"
|
||||
if "jpeg" in ct or "jpg" in ct:
|
||||
ext = "jpg"
|
||||
elif "gif" in ct:
|
||||
ext = "gif"
|
||||
elif "webp" in ct:
|
||||
ext = "webp"
|
||||
files.append(_discord_mod.File(_io.BytesIO(data), filename=f"image_{len(files)}.{ext}"))
|
||||
except Exception as dl_err:
|
||||
logger.warning("[%s] Download failed for %s: %s", self.name, image_url[:80], dl_err)
|
||||
continue
|
||||
|
|
@ -4531,37 +4573,40 @@ class DiscordAdapter(BasePlatformAdapter):
|
|||
_proxy = resolve_proxy_url(platform_env_var="DISCORD_PROXY")
|
||||
_sess_kw, _req_kw = proxy_kwargs_for_aiohttp(_proxy)
|
||||
async with aiohttp.ClientSession(**_sess_kw) as session:
|
||||
async with session.get(image_url, timeout=aiohttp.ClientTimeout(total=30), **_req_kw) as resp:
|
||||
if resp.status != 200:
|
||||
raise Exception(f"Failed to download image: HTTP {resp.status}")
|
||||
status, image_data, headers = await _read_url_image_with_redirect_guard(
|
||||
session,
|
||||
image_url,
|
||||
timeout=aiohttp.ClientTimeout(total=30),
|
||||
request_kwargs=_req_kw,
|
||||
)
|
||||
if status != 200:
|
||||
raise Exception(f"Failed to download image: HTTP {status}")
|
||||
|
||||
image_data = await resp.read()
|
||||
# Determine filename from URL or content type
|
||||
content_type = headers.get("content-type", "image/png")
|
||||
ext = "png"
|
||||
if "jpeg" in content_type or "jpg" in content_type:
|
||||
ext = "jpg"
|
||||
elif "gif" in content_type:
|
||||
ext = "gif"
|
||||
elif "webp" in content_type:
|
||||
ext = "webp"
|
||||
|
||||
# Determine filename from URL or content type
|
||||
content_type = resp.headers.get("content-type", "image/png")
|
||||
ext = "png"
|
||||
if "jpeg" in content_type or "jpg" in content_type:
|
||||
ext = "jpg"
|
||||
elif "gif" in content_type:
|
||||
ext = "gif"
|
||||
elif "webp" in content_type:
|
||||
ext = "webp"
|
||||
import io
|
||||
file = discord.File(io.BytesIO(image_data), filename=f"image.{ext}")
|
||||
|
||||
import io
|
||||
file = discord.File(io.BytesIO(image_data), filename=f"image.{ext}")
|
||||
|
||||
if self._is_forum_parent(channel):
|
||||
return await self._forum_post_file(
|
||||
channel,
|
||||
content=(caption or "").strip(),
|
||||
file=file,
|
||||
)
|
||||
|
||||
msg = await channel.send(
|
||||
content=caption if caption else None,
|
||||
if self._is_forum_parent(channel):
|
||||
return await self._forum_post_file(
|
||||
channel,
|
||||
content=(caption or "").strip(),
|
||||
file=file,
|
||||
)
|
||||
return SendResult(success=True, message_id=str(msg.id))
|
||||
|
||||
msg = await channel.send(
|
||||
content=caption if caption else None,
|
||||
file=file,
|
||||
)
|
||||
return SendResult(success=True, message_id=str(msg.id))
|
||||
|
||||
except ImportError:
|
||||
logger.warning(
|
||||
|
|
@ -4610,27 +4655,30 @@ class DiscordAdapter(BasePlatformAdapter):
|
|||
_proxy = resolve_proxy_url(platform_env_var="DISCORD_PROXY")
|
||||
_sess_kw, _req_kw = proxy_kwargs_for_aiohttp(_proxy)
|
||||
async with aiohttp.ClientSession(**_sess_kw) as session:
|
||||
async with session.get(animation_url, timeout=aiohttp.ClientTimeout(total=30), **_req_kw) as resp:
|
||||
if resp.status != 200:
|
||||
raise Exception(f"Failed to download animation: HTTP {resp.status}")
|
||||
status, animation_data, _headers = await _read_url_image_with_redirect_guard(
|
||||
session,
|
||||
animation_url,
|
||||
timeout=aiohttp.ClientTimeout(total=30),
|
||||
request_kwargs=_req_kw,
|
||||
)
|
||||
if status != 200:
|
||||
raise Exception(f"Failed to download animation: HTTP {status}")
|
||||
|
||||
animation_data = await resp.read()
|
||||
import io
|
||||
file = discord.File(io.BytesIO(animation_data), filename="animation.gif")
|
||||
|
||||
import io
|
||||
file = discord.File(io.BytesIO(animation_data), filename="animation.gif")
|
||||
|
||||
if self._is_forum_parent(channel):
|
||||
return await self._forum_post_file(
|
||||
channel,
|
||||
content=(caption or "").strip(),
|
||||
file=file,
|
||||
)
|
||||
|
||||
msg = await channel.send(
|
||||
content=caption if caption else None,
|
||||
if self._is_forum_parent(channel):
|
||||
return await self._forum_post_file(
|
||||
channel,
|
||||
content=(caption or "").strip(),
|
||||
file=file,
|
||||
)
|
||||
return SendResult(success=True, message_id=str(msg.id))
|
||||
|
||||
msg = await channel.send(
|
||||
content=caption if caption else None,
|
||||
file=file,
|
||||
)
|
||||
return SendResult(success=True, message_id=str(msg.id))
|
||||
|
||||
except ImportError:
|
||||
logger.warning(
|
||||
|
|
@ -9474,7 +9522,7 @@ def register(ctx) -> None:
|
|||
check_fn=check_discord_requirements,
|
||||
is_connected=_is_connected,
|
||||
required_env=["DISCORD_BOT_TOKEN"],
|
||||
install_hint="pip install 'hermes-agent[messaging]'",
|
||||
install_hint="Run `hermes setup` to install Discord support.",
|
||||
# Interactive setup wizard — replaces the central
|
||||
# hermes_cli/setup.py::_setup_discord function. Same shape as Teams.
|
||||
setup_fn=interactive_setup,
|
||||
|
|
|
|||
|
|
@ -53,7 +53,9 @@ class DiscordRecoveryStore:
|
|||
return default
|
||||
|
||||
def _initialize(self, conn: sqlite3.Connection) -> None:
|
||||
conn.execute("PRAGMA journal_mode=WAL")
|
||||
from hermes_state import apply_wal_with_fallback
|
||||
|
||||
apply_wal_with_fallback(conn, db_label="discord_recovery.db")
|
||||
conn.execute("""
|
||||
CREATE TABLE IF NOT EXISTS discord_messages (
|
||||
message_id TEXT PRIMARY KEY,
|
||||
|
|
|
|||
|
|
@ -5465,7 +5465,7 @@ async def _standalone_send(
|
|||
(images, video, voice, documents). Replaces the legacy _send_feishu helper.
|
||||
"""
|
||||
if not FEISHU_AVAILABLE:
|
||||
return {"error": "Feishu dependencies not installed. Run: pip install 'hermes-agent[feishu]'"}
|
||||
return {"error": "Feishu dependencies not installed. Run `hermes setup` to install Feishu support."}
|
||||
|
||||
media_files = media_files or []
|
||||
try:
|
||||
|
|
@ -5713,7 +5713,7 @@ def register(ctx) -> None:
|
|||
is_connected=_is_connected,
|
||||
validate_config=_is_connected,
|
||||
required_env=["FEISHU_APP_ID", "FEISHU_APP_SECRET"],
|
||||
install_hint="pip install 'hermes-agent[feishu]'",
|
||||
install_hint="Run `hermes setup` to install Feishu support.",
|
||||
setup_fn=interactive_setup,
|
||||
apply_yaml_config_fn=_apply_yaml_config,
|
||||
allowed_users_env="FEISHU_ALLOWED_USERS",
|
||||
|
|
|
|||
|
|
@ -3689,7 +3689,7 @@ def register(ctx) -> None:
|
|||
required_env=[
|
||||
"GOOGLE_CHAT_SERVICE_ACCOUNT_JSON",
|
||||
],
|
||||
install_hint="pip install 'hermes-agent[google_chat]'",
|
||||
install_hint="Run `hermes setup` to install Google Chat support.",
|
||||
setup_fn=interactive_setup,
|
||||
# Env-driven auto-configuration — the core env-populator hook calls
|
||||
# this during ``_apply_env_overrides`` and seeds
|
||||
|
|
|
|||
|
|
@ -199,7 +199,7 @@ def load_user_credentials(email: Optional[str] = None) -> Optional[Any]:
|
|||
except ImportError:
|
||||
logger.warning(
|
||||
"[google_chat_user_oauth] google-auth not installed; user-OAuth "
|
||||
"attachment delivery is disabled. Install hermes-agent[google_chat]."
|
||||
"attachment delivery is disabled. Run `hermes setup` to install Google Chat support."
|
||||
)
|
||||
return None
|
||||
|
||||
|
|
@ -388,8 +388,7 @@ def install_deps() -> bool:
|
|||
return True
|
||||
except Exception as exc:
|
||||
print(f"ERROR: Failed to install dependencies: {exc}")
|
||||
print("Or install via the optional extra:")
|
||||
print(" pip install 'hermes-agent[google_chat]'")
|
||||
print("Run `hermes setup` to repair the managed installation, then retry.")
|
||||
return False
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -44,12 +44,24 @@ _CHANNEL_TYPE_MAP = {
|
|||
"O": "channel",
|
||||
}
|
||||
|
||||
_MATTERMOST_DISABLE_MENTIONS_PROPS = {"disable_mentions": True}
|
||||
|
||||
# Reconnect parameters (exponential backoff).
|
||||
_RECONNECT_BASE_DELAY = 2.0
|
||||
_RECONNECT_MAX_DELAY = 60.0
|
||||
_RECONNECT_JITTER = 0.2
|
||||
|
||||
|
||||
def _with_mentions_disabled(payload: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""Return a post payload that prevents Mattermost from firing mentions."""
|
||||
props = payload.get("props")
|
||||
if isinstance(props, dict):
|
||||
payload["props"] = {**props, **_MATTERMOST_DISABLE_MENTIONS_PROPS}
|
||||
else:
|
||||
payload["props"] = dict(_MATTERMOST_DISABLE_MENTIONS_PROPS)
|
||||
return payload
|
||||
|
||||
|
||||
def check_mattermost_requirements() -> bool:
|
||||
"""Return True if the Mattermost adapter runtime dependency is available."""
|
||||
try:
|
||||
|
|
@ -361,10 +373,10 @@ class MattermostAdapter(BasePlatformAdapter):
|
|||
|
||||
last_id = None
|
||||
for chunk in chunks:
|
||||
payload: Dict[str, Any] = {
|
||||
payload: Dict[str, Any] = _with_mentions_disabled({
|
||||
"channel_id": chat_id,
|
||||
"message": chunk,
|
||||
}
|
||||
})
|
||||
# Thread support: reply_to or metadata["thread_id"] is the root post ID.
|
||||
resolved_root = await self._thread_root_for_send(reply_to, metadata)
|
||||
if resolved_root:
|
||||
|
|
@ -407,7 +419,7 @@ class MattermostAdapter(BasePlatformAdapter):
|
|||
formatted = self.format_message(content)
|
||||
data = await self._api_put(
|
||||
f"posts/{message_id}/patch",
|
||||
{"message": formatted},
|
||||
_with_mentions_disabled({"message": formatted}),
|
||||
)
|
||||
if not data or "id" not in data:
|
||||
return SendResult(success=False, error="Failed to edit post")
|
||||
|
|
@ -543,11 +555,11 @@ class MattermostAdapter(BasePlatformAdapter):
|
|||
if not file_id:
|
||||
return await self.send(chat_id, f"{caption or ''}\n{url}".strip(), reply_to, metadata=metadata)
|
||||
|
||||
payload: Dict[str, Any] = {
|
||||
payload: Dict[str, Any] = _with_mentions_disabled({
|
||||
"channel_id": chat_id,
|
||||
"message": caption or "",
|
||||
"file_ids": [file_id],
|
||||
}
|
||||
})
|
||||
resolved_root = await self._thread_root_for_send(reply_to, metadata)
|
||||
if resolved_root:
|
||||
payload["root_id"] = resolved_root
|
||||
|
|
@ -584,11 +596,11 @@ class MattermostAdapter(BasePlatformAdapter):
|
|||
if not file_id:
|
||||
return SendResult(success=False, error="File upload failed")
|
||||
|
||||
payload: Dict[str, Any] = {
|
||||
payload: Dict[str, Any] = _with_mentions_disabled({
|
||||
"channel_id": chat_id,
|
||||
"message": caption or "",
|
||||
"file_ids": [file_id],
|
||||
}
|
||||
})
|
||||
resolved_root = await self._thread_root_for_send(reply_to, metadata)
|
||||
if resolved_root:
|
||||
payload["root_id"] = resolved_root
|
||||
|
|
@ -672,11 +684,11 @@ class MattermostAdapter(BasePlatformAdapter):
|
|||
if not file_ids:
|
||||
continue
|
||||
|
||||
payload: Dict[str, Any] = {
|
||||
payload: Dict[str, Any] = _with_mentions_disabled({
|
||||
"channel_id": chat_id,
|
||||
"message": "\n".join(caption_parts),
|
||||
"file_ids": file_ids,
|
||||
}
|
||||
})
|
||||
resolved_root = await self._thread_root_for_send(None, metadata)
|
||||
if resolved_root:
|
||||
payload["root_id"] = resolved_root
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -37,3 +37,9 @@ optional_env:
|
|||
description: "Display name for the Slack home channel"
|
||||
prompt: "Home channel display name"
|
||||
password: false
|
||||
- name: SLACK_THREAD_REQUIRE_MENTION
|
||||
description: >-
|
||||
Require an explicit @mention for Slack thread replies while preserving
|
||||
top-level free response channels
|
||||
prompt: "Require mentions in Slack threads? (true/false)"
|
||||
password: false
|
||||
|
|
|
|||
|
|
@ -9449,7 +9449,7 @@ def register(ctx) -> None:
|
|||
check_fn=check_telegram_requirements,
|
||||
is_connected=_is_connected,
|
||||
required_env=["TELEGRAM_BOT_TOKEN"],
|
||||
install_hint="pip install 'hermes-agent[telegram]'",
|
||||
install_hint="Run `hermes setup` to install Telegram support.",
|
||||
setup_fn=interactive_setup,
|
||||
apply_yaml_config_fn=_apply_yaml_config,
|
||||
allowed_users_env="TELEGRAM_ALLOWED_USERS",
|
||||
|
|
|
|||
|
|
@ -1861,7 +1861,7 @@ def register(ctx) -> None:
|
|||
is_connected=_is_connected,
|
||||
validate_config=_is_connected,
|
||||
required_env=["WECOM_BOT_ID", "WECOM_SECRET"],
|
||||
install_hint="pip install 'hermes-agent[wecom]'",
|
||||
install_hint="Run `hermes setup` to install WeCom support.",
|
||||
setup_fn=interactive_setup,
|
||||
allowed_users_env="WECOM_ALLOWED_USERS",
|
||||
allow_all_env="WECOM_ALLOW_ALL_USERS",
|
||||
|
|
@ -1881,7 +1881,7 @@ def register(ctx) -> None:
|
|||
is_connected=_callback_is_connected,
|
||||
validate_config=_callback_is_connected,
|
||||
required_env=["WECOM_CALLBACK_CORP_ID", "WECOM_CALLBACK_CORP_SECRET"],
|
||||
install_hint="pip install 'hermes-agent[wecom]'",
|
||||
install_hint="Run `hermes setup` to install WeCom support.",
|
||||
allowed_users_env="WECOM_CALLBACK_ALLOWED_USERS",
|
||||
allow_all_env="WECOM_CALLBACK_ALLOW_ALL_USERS",
|
||||
emoji="💼",
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue