mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-11-06 13:26:40 +00:00
An install while a chat runs (the onboarding plugins card, Settings, a terminal install through the serve backend) made the plugin's MCP tools callable at once, but its notice waited on the one-shot turn-note channel for the next USER message. Onboarding is one long turn, so in a live run the notice arrived six minutes late, after the model had already failed to use the plugin. Notices are now queued per plugin key (agent/plugin_live_notes.py; a reinstall or update replaces the plugin's older notice) and popped onto the next tool result before it is appended and flushed: a new row, so no cached byte moves. The notice goes after the content, outside the untrusted-data wrapper web, MCP and browser results get. A turn-budget spill of that result keeps the notice on the inline row. When no tool runs again this turn, the next user message still carries it, and a delivered notice is never sent twice. (cherry picked from commit 41cbb879a98a5bbcf57018a5c03d6207b57b819c)
78 lines
3.2 KiB
Python
78 lines
3.2 KiB
Python
"""Tell an open chat about a plugin that went live while it runs, once, without breaking its cache.
|
|
|
|
An install from any surface (the onboarding plugins card, Settings, ``hermes plugins install`` with a
|
|
serve backend) makes the plugin's MCP tools and skills usable in this profile's open chats at once
|
|
(``hermes_cli.plugins_activation.load_and_go_live``) and queues the notice here, one per plugin key: a
|
|
second install or update of the same plugin replaces its notice. The notice rides the next tool result
|
|
of the running turn (``agent/tool_executor.py``), after its content: a new row not yet flushed, so no
|
|
cached byte moves. When no tool runs again this turn, the next user message carries it instead
|
|
(``agent/turn_context.py::_merge_gateway_notes``). Either way it is popped, so it is delivered once.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import threading
|
|
from typing import Any
|
|
|
|
from agent.message_content import flatten_message_text
|
|
|
|
_QUEUED = "_plugin_live_notes"
|
|
_IN_BATCH = "_plugin_notes_in_tool_batch"
|
|
# The install RPC thread queues while the agent thread delivers.
|
|
_LOCK = threading.Lock()
|
|
|
|
|
|
def _queued(agent: Any) -> dict[str, str]:
|
|
notes = getattr(agent, _QUEUED, None)
|
|
return notes if isinstance(notes, dict) else {}
|
|
|
|
|
|
def queue_plugin_notice(agent: Any, plugin_key: str, note: str) -> None:
|
|
"""Queue ``note`` for ``agent``'s next tool result or user turn; it replaces this plugin's older one."""
|
|
with _LOCK:
|
|
notes = {key: text for key, text in _queued(agent).items() if key != plugin_key}
|
|
notes[plugin_key] = note
|
|
setattr(agent, _QUEUED, notes)
|
|
|
|
|
|
def pop_plugin_notices(agent: Any) -> str:
|
|
"""Every queued notice, oldest plugin first, and clear the queue."""
|
|
with _LOCK:
|
|
notes = _queued(agent)
|
|
if notes:
|
|
setattr(agent, _QUEUED, {})
|
|
return "\n\n".join(notes.values())
|
|
|
|
|
|
def attach_to_tool_message(agent: Any, message: dict) -> None:
|
|
"""Append every queued notice to ``message``, the tool result about to be appended and flushed. It
|
|
goes after the content, so a result wrapped as untrusted data keeps the notice outside the wrapper."""
|
|
notes = pop_plugin_notices(agent)
|
|
if not notes:
|
|
return
|
|
_append(message, notes)
|
|
in_batch = getattr(agent, _IN_BATCH, None)
|
|
in_batch = in_batch if isinstance(in_batch, dict) else {}
|
|
setattr(agent, _IN_BATCH, {**in_batch, message.get("tool_call_id"): notes})
|
|
|
|
|
|
def keep_after_turn_budget(agent: Any, batch: list[dict]) -> None:
|
|
"""Put a notice back on a batch row whose content the turn budget spilled to a file: the inline
|
|
preview keeps only the head, and the notice sits at the tail. The flushed row already holds it."""
|
|
in_batch = getattr(agent, _IN_BATCH, None)
|
|
if not isinstance(in_batch, dict) or not in_batch:
|
|
return
|
|
setattr(agent, _IN_BATCH, {})
|
|
for message in batch:
|
|
notes = in_batch.get(message.get("tool_call_id"))
|
|
if notes and notes not in flatten_message_text(message.get("content")):
|
|
_append(message, notes)
|
|
|
|
|
|
def _append(message: dict, notes: str) -> None:
|
|
content = message.get("content")
|
|
if isinstance(content, list):
|
|
message["content"] = [*content, {"type": "text", "text": notes}]
|
|
else:
|
|
message["content"] = f"{content or ''}\n\n{notes}"
|