e0ef0eb9c3
* feat(connections): manage_connections covers local MCP servers; setup_mcp leaves the schema
One model tool now connects the user to apps of both kinds. A target
`{"name": "linear", "mcp": true}` is a locally configured MCP server;
`install` / `enable` / `authorize` are its verbs. Bare strings and
`{"name": ...}` stay managed connectors and that leg is unchanged.
MCP targets run through one backend-owned connection operation
(tools/connections_tool_operation.py): created with a server-side
deadline from the new config key `connections.wait_timeout_seconds`
(default 120, floor 5, no ceiling), per-target state, and exactly-once
settlement (all resolved / Continue / deadline / interrupt). Unresolved
targets freeze as `not_connected` with the settle reason.
Why the fold works now: the approval card is reached through
`agent.connection_callback` via the agent-level inline executor table,
which is the only path that carries a GUI callback. Registry dispatch
(every non-GUI surface) settles MCP targets as `unavailable` with the
`hermes mcp install / login` hint; managed targets in the same call
are unaffected.
`setup_mcp` is removed from every advertised toolset and from the
deferral list; an inline-table shim keeps calls from conversations
opened before this change dispatching (prompt-cache protection).
`_LEGACY_TOOL_ALIASES` is not the mechanism: inline tools bypass it.
Gateway: `mcp.setup.request/respond` are replaced by
`connection.request/respond/expire` (no wire compat; desktop ships
with this). The bridge waits exactly the operation's deadline. The
`session.resume` snapshot gains `pending_connection` so a reopened
window restores the card with the original deadline.
`manage_connections` joins `_SEQUENTIAL_DEADLINE_EXEMPT_TOOLS`: the
operation owns its wait; the 420s guard must not report `tool_timeout`
while the card is live.
The portal `check_fn` on the tool is dropped in favour of a
handler-level gate on the managed leg, so signed-out sessions can still
approve local MCPs.
* wip(desktop): connection.request store, resume restore, card routing for MCP targets
Renderer half of the setup_mcp fold, first slice: connection-request store
(mirrors clarify), connection.request/expire handling, pending_connection
resume restore, mcpTargets() + isCardTool(name, args) so MCP-target
manage_connections calls classify as cards. Not yet: the card component
rewrite (mcp-setup-tool.tsx), mcp-directory.ts removal, vitest, docs.
Does not typecheck until the card rewrite lands.
* fix(config): hermes update turns on the connections toolset for saved toolset lists
`hermes tools` writes an explicit `platform_toolsets.<platform>` list, and the
resolver reads absence from that list as "unchecked". The `connections`
toolset (#106842) shipped after most users last saved, so `manage_connections`
is stripped from the schema on every install that ever opened the picker.
The Nous entitlement gate never runs; the agent reports the tool as missing.
Migration 44 -> 45 (renumbered when folded into #109517; main was already at 44) appends `connections` to each explicit per-platform list
that lacks it and records the offer in `known_builtin_toolsets` where that
record exists, so a later uncheck reads as a decline. It skips: platforms
whose record already holds `connections` (the user saw the checkbox and left
it off), bare composite lists ([hermes-cli]) that already inherit it, platforms
where the toolset is not allowed, and any config whose `agent.disabled_toolsets`
names `connections` (Blank Slate, `hermes tools --disable`), because the
resolver subtracts that list last and the enable would never take effect.
The explicit-list test is the resolver's own: any configurable or plugin key.
`hermes update` runs migrations post-pull for the active profile and every
sibling, so one update is enough. Fresh installs and composite users were
never affected.
* refactor: anti-slop pass on the desktop slice; shorten added comments
Parse connection.request at the boundary with a typed wire interface instead of
unknown + typeof; mcpTargets reuses connectorText; comments cut to one or two
lines. slop-ratchet: no net-new findings in 13 touched files.
* feat(desktop): the MCP approval card answers manage_connections; MCP Directory removed
The existing card (mcp-setup-tool.tsx) now reads the connection-request store,
renders for manage_connections calls with mcp:true targets, answers through
connection.respond with a per-target outcome, and no longer calls reload.mcp
after Install; the new server's tools arrive on the between-turns refresh.
A settled operation renders the first target's frozen state.
session.resume restores a pending card with its original deadline on both the
activate and cold-resume paths.
lib/mcp-directory.ts is deleted along with its two fallback branches
(suggestion provider, card install). The catalog was already primary in both;
a catalog miss now yields no suggestion / a notInCatalog error. The GitHub
never-suggest test is rewritten on catalog-shaped data.
vitest: connection-request store (6), suggestion provider, clarify restore.
slop-ratchet: no net-new findings in 19 touched files.
* chore: drop __pycache__ files swept in by an over-broad git add
* fix(desktop): correlate the connection.request row with the model's tool call by reason
The synthetic row from connection.request and the tool.start row carried
different ids and no shared match value (op_id is not in the model's args),
so the card mounted twice. reason is the arg both sides carry.
* docs: manage_connections covers local MCP servers; connections.wait_timeout_seconds
* fix(connections): settle reason derives from target state, never from the renderer
A card that answers one of two targets and claims all_resolved must settle as
continue with the other target not_connected; found live with a two-target call.
* fix(desktop): a pending connection card re-arms on resume and activate
The store entry was restored but the transcript row was not, so navigating
away and back (or reloading) lost the card while the backend kept waiting.
restorePendingClarifyToolCall's core is generalized to any blocking tool
name and both resume paths project the connection row through it.
Verified live: card restored after navigate-away and after a full renderer
reload, deadline_at unchanged, approve settles connected.
* style: literal wording in added comments, docstrings and docs
* fix: shared gateway-event contract and config-schema category for the connection events
connection.request/expire replace mcp.setup.* in apps/shared gateway-events
(json list, BACKEND_EVENT_NAMES, GatewayEventMap) so the renderer's event
union includes them and the tui_gateway contract test passes. The new
`connections` config section folds into the agent tab like the other
single-field sections.
* style: import order (perfectionist) in the desktop and shared files this PR touches
* chore: retrigger CI (zero-job dispatch failure, auto-heal)
241 lines
10 KiB
Python
241 lines
10 KiB
Python
"""Agent-level ("inline") tool executors shared by the sequential and concurrent tool paths.
|
|
|
|
These tools need live ``AIAgent`` state (stores, callbacks, session DB) and therefore
|
|
bypass the tool registry. Each executor is ``fn(agent, args, ctx) -> result``; the
|
|
table replaces two hand-maintained if/elif chains (``invoke_tool`` and
|
|
``execute_tool_calls_sequential``) that had drifted apart. Tool modules are imported
|
|
lazily at call time so ``patch("tools.x.y")`` in tests keeps working.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
from dataclasses import dataclass
|
|
from importlib import import_module
|
|
from typing import Any, Callable, Dict, Optional, Tuple
|
|
|
|
|
|
def tool_hook_ids(agent, effective_task_id: str, tool_call_id: Optional[str]) -> Dict[str, str]:
|
|
"""Identity kwargs every tool hook/middleware call carries (all coerced to ``""``)."""
|
|
return {
|
|
"task_id": effective_task_id or "",
|
|
"session_id": getattr(agent, "session_id", "") or "",
|
|
"tool_call_id": tool_call_id or "",
|
|
"turn_id": getattr(agent, "_current_turn_id", "") or "",
|
|
"api_request_id": getattr(agent, "_current_api_request_id", "") or "",
|
|
}
|
|
|
|
|
|
def emit_terminal_post_tool_call(
|
|
agent,
|
|
*,
|
|
function_name: str,
|
|
function_args: dict,
|
|
result: Any,
|
|
effective_task_id: str,
|
|
tool_call_id: Optional[str],
|
|
duration_ms: int = 0,
|
|
status: Optional[str] = None,
|
|
error_type: Optional[str] = None,
|
|
error_message: Optional[str] = None,
|
|
middleware_trace: Optional[list] = None,
|
|
) -> None:
|
|
"""Emit the one terminal ``post_tool_call`` hook for a tool_call_id (best-effort)."""
|
|
try:
|
|
from model_tools import _emit_post_tool_call_hook
|
|
_emit_post_tool_call_hook(
|
|
function_name=function_name,
|
|
function_args=function_args,
|
|
result=result,
|
|
**tool_hook_ids(agent, effective_task_id, tool_call_id),
|
|
duration_ms=duration_ms,
|
|
status=status,
|
|
error_type=error_type,
|
|
error_message=error_message,
|
|
middleware_trace=list(middleware_trace or []),
|
|
)
|
|
except Exception:
|
|
pass
|
|
|
|
|
|
@dataclass
|
|
class InlineToolContext:
|
|
"""Per-call state an inline executor may need beyond its arguments."""
|
|
|
|
effective_task_id: str
|
|
tool_call_id: Optional[str] = None
|
|
messages: Optional[list] = None
|
|
|
|
|
|
InlineToolExecutor = Callable[[Any, dict, InlineToolContext], Any]
|
|
|
|
# ``(kwarg, args_key)`` → ``args.get(key)``; ``(kwarg, args_key, default)`` → ``args.get(key, default)``.
|
|
_ArgSpec = Tuple[Any, ...]
|
|
|
|
|
|
def _call_tool(module: str, func: str, args: dict, arg_specs: Tuple[_ArgSpec, ...], **fixed: Any) -> Any:
|
|
"""Import ``module.func`` lazily and call it with args mapped per ``arg_specs`` plus ``fixed``."""
|
|
fn = getattr(import_module(module), func)
|
|
return fn(**{spec[0]: args.get(*spec[1:]) for spec in arg_specs}, **fixed)
|
|
|
|
|
|
def _tool(
|
|
module: str, func: str, *arg_specs: _ArgSpec, **fixed: Callable[[Any, InlineToolContext], Any],
|
|
) -> InlineToolExecutor:
|
|
"""Executor calling ``module.func`` with mapped args plus ``fixed`` kwargs computed from ``(agent, ctx)``."""
|
|
def _exec(agent, args: dict, ctx: InlineToolContext) -> Any:
|
|
return _call_tool(module, func, args, arg_specs, **{k: f(agent, ctx) for k, f in fixed.items()})
|
|
return _exec
|
|
|
|
|
|
def _callback_tool(module: str, func: str, callback_attr: str, *arg_specs: _ArgSpec) -> InlineToolExecutor:
|
|
"""Executor for a GUI-callback tool: mapped args plus ``callback=getattr(agent, callback_attr, None)``."""
|
|
return _tool(module, func, *arg_specs, callback=lambda agent, ctx: getattr(agent, callback_attr, None))
|
|
|
|
|
|
def _session_search(agent, args: dict, ctx: InlineToolContext) -> Any:
|
|
session_db = agent._get_session_db_for_recall()
|
|
if not session_db:
|
|
from hermes_state import format_session_db_unavailable
|
|
|
|
return json.dumps({"success": False, "error": format_session_db_unavailable()})
|
|
return _call_tool(
|
|
"tools.session_search_tool", "session_search", args,
|
|
(
|
|
("query", "query", ""), ("role_filter", "role_filter"), ("limit", "limit", 3),
|
|
("session_id", "session_id"), ("around_message_id", "around_message_id"),
|
|
("window", "window", 5), ("sort", "sort"), ("profile", "profile"),
|
|
("detail", "detail", "adaptive"),
|
|
),
|
|
db=session_db, current_session_id=agent.session_id,
|
|
)
|
|
|
|
|
|
def _memory(agent, args: dict, ctx: InlineToolContext) -> Any:
|
|
result = _call_tool(
|
|
"tools.memory_tool", "memory_tool", args,
|
|
(
|
|
("action", "action"), ("target", "target", "memory"), ("content", "content"),
|
|
("old_text", "old_text"), ("new_text", "new_text"), ("operations", "operations"),
|
|
),
|
|
store=agent._memory_store,
|
|
)
|
|
# Mirror built-in memory writes to external providers; gating lives in
|
|
# MemoryManager.notify_memory_tool_write.
|
|
if agent._memory_manager:
|
|
agent._memory_manager.notify_memory_tool_write(
|
|
result,
|
|
args,
|
|
build_metadata=lambda: agent._build_memory_write_metadata(
|
|
task_id=ctx.effective_task_id,
|
|
tool_call_id=ctx.tool_call_id,
|
|
),
|
|
)
|
|
return result
|
|
|
|
|
|
_read_preview = _callback_tool(
|
|
"tools.read_preview_tool", "read_preview_tool", "read_preview_callback",
|
|
("start", "start"), ("count", "count"),
|
|
)
|
|
|
|
|
|
def _desktop_preview(agent, args: dict, ctx: InlineToolContext) -> Any:
|
|
# action=read needs the GUI callback (agent-level); open/close go through the
|
|
# registry handler like any other tool.
|
|
if (args.get("action") or "").strip() == "read":
|
|
return _read_preview(agent, args, ctx)
|
|
from tools.preview_tool import _handle_preview
|
|
|
|
return _handle_preview(args)
|
|
|
|
|
|
def _manage_connections(agent, args: dict, ctx: InlineToolContext) -> Any:
|
|
# The GUI callback lives on the agent; registry dispatch never forwards it.
|
|
from tools.connections_tool import _connectors_available, manage_connections
|
|
|
|
return manage_connections(
|
|
args, session_id=getattr(agent, "session_id", None),
|
|
connection_callback=getattr(agent, "connection_callback", None),
|
|
connectors_available=_connectors_available,
|
|
)
|
|
|
|
|
|
def _setup_mcp_shim(agent, args: dict, ctx: InlineToolContext) -> Any:
|
|
# Replay shim for conversations whose cached prompt still names setup_mcp.
|
|
# Not in _LEGACY_TOOL_ALIASES: inline tools bypass handle_function_call.
|
|
return _manage_connections(agent, {
|
|
"action": args.get("action", "install"),
|
|
"connectors": [{"name": args.get("server", ""), "mcp": True}],
|
|
"reason": args.get("reason", ""),
|
|
}, ctx)
|
|
|
|
|
|
# Order is the historical if/elif order of ``execute_tool_calls_sequential``.
|
|
INLINE_TOOL_EXECUTORS: Dict[str, InlineToolExecutor] = {
|
|
"todo_list": _tool(
|
|
"tools.todo_tool", "todo_tool", ("todos", "todos"), ("merge", "merge", False),
|
|
store=lambda agent, ctx: agent._todo_store,
|
|
),
|
|
# Bot Mode teammate DM is injected, not registered: only a canonical Bot
|
|
# Chat session carries the schema, and the tool re-gates on the title.
|
|
"message_agent": _tool(
|
|
"tools.bot_mode_dm", "message_agent_tool", ("target", "target", ""), ("message", "message", ""),
|
|
task_id=lambda agent, ctx: ctx.effective_task_id, agent=lambda agent, ctx: agent,
|
|
),
|
|
"session_search": _session_search,
|
|
"memory": _memory,
|
|
"clarify": _tool(
|
|
"tools.clarify_tool", "clarify_tool",
|
|
("question", "question", ""), ("choices", "choices"), ("multi_select", "multi_select", False),
|
|
("questions", "questions"),
|
|
callback=lambda agent, ctx: agent.clarify_callback,
|
|
),
|
|
"read_terminal": _callback_tool(
|
|
"tools.read_terminal_tool", "read_terminal_tool", "read_terminal_callback",
|
|
("start_line", "start_line"), ("count", "count"),
|
|
),
|
|
"desktop_preview": _desktop_preview,
|
|
"drive_preview": _callback_tool(
|
|
"tools.drive_preview_tool", "drive_preview_tool", "drive_preview_callback",
|
|
("action", "action", ""), ("ref", "ref"), ("selector", "selector"), ("text", "text"),
|
|
("key", "key"), ("submit", "submit"), ("amount", "amount"), ("to", "to"), ("limit", "max"),
|
|
),
|
|
"annotate_preview": _callback_tool(
|
|
"tools.annotate_preview_tool", "annotate_preview_tool", "drive_preview_callback",
|
|
("action", "action", "add"), ("ref", "ref"), ("selector", "selector"), ("label", "label"),
|
|
),
|
|
"read_window_below": _callback_tool(
|
|
"tools.read_window_tool", "read_window_below_tool", "read_window_below_callback",
|
|
),
|
|
"gui_tour": _callback_tool(
|
|
"tools.tour_tool", "tour_tool", "tour_callback",
|
|
("action", "action", ""), ("surface", "surface"), ("selector", "selector"), ("title", "title"),
|
|
("text", "text"), ("side", "side"), ("steps", "steps"), ("step_index", "step_index"),
|
|
),
|
|
"manage_connections": _manage_connections,
|
|
"setup_mcp": _setup_mcp_shim,
|
|
"delegate_task": lambda agent, args, ctx: agent._dispatch_delegate_task(args),
|
|
}
|
|
|
|
# ``invoke_tool`` (concurrent path) consults the memory manager right after these three
|
|
# names and before the remaining inline tools; ``message_agent`` falls through to the
|
|
# registry there (Bot Mode DM is only injected into the sequential path's schema).
|
|
INVOKE_TOOL_PRE_MEMORY_MANAGER_NAMES = frozenset({"todo_list", "session_search", "memory"})
|
|
|
|
|
|
def resolve_invoke_tool_executor(agent, function_name: str) -> Optional[InlineToolExecutor]:
|
|
"""Inline executor for ``invoke_tool`` (concurrent path), or None for registry dispatch.
|
|
|
|
Precedence: todo_list/session_search/memory, then memory-manager tools, then the
|
|
remaining inline tools (``message_agent`` excluded).
|
|
"""
|
|
if function_name in INVOKE_TOOL_PRE_MEMORY_MANAGER_NAMES:
|
|
return INLINE_TOOL_EXECUTORS[function_name]
|
|
memory_manager = agent._memory_manager
|
|
if memory_manager and memory_manager.has_tool(function_name):
|
|
return lambda agent, args, ctx: agent._memory_manager.handle_tool_call(function_name, args)
|
|
if function_name == "message_agent":
|
|
return None
|
|
return INLINE_TOOL_EXECUTORS.get(function_name)
|