mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-05-01 08:21:50 +08:00
Compare commits
4 Commits
fix/plugin
...
hermes/her
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
70e962c84a | ||
|
|
8bf539acd9 | ||
|
|
23a1b86124 | ||
|
|
0afd252a65 |
@@ -56,6 +56,8 @@ VALID_HOOKS: Set[str] = {
|
|||||||
"post_tool_call",
|
"post_tool_call",
|
||||||
"pre_llm_call",
|
"pre_llm_call",
|
||||||
"post_llm_call",
|
"post_llm_call",
|
||||||
|
"pre_api_request",
|
||||||
|
"post_api_request",
|
||||||
"on_session_start",
|
"on_session_start",
|
||||||
"on_session_end",
|
"on_session_end",
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -460,6 +460,8 @@ def handle_function_call(
|
|||||||
function_name: str,
|
function_name: str,
|
||||||
function_args: Dict[str, Any],
|
function_args: Dict[str, Any],
|
||||||
task_id: Optional[str] = None,
|
task_id: Optional[str] = None,
|
||||||
|
tool_call_id: Optional[str] = None,
|
||||||
|
session_id: Optional[str] = None,
|
||||||
user_task: Optional[str] = None,
|
user_task: Optional[str] = None,
|
||||||
enabled_tools: Optional[List[str]] = None,
|
enabled_tools: Optional[List[str]] = None,
|
||||||
) -> str:
|
) -> str:
|
||||||
@@ -497,7 +499,14 @@ def handle_function_call(
|
|||||||
|
|
||||||
try:
|
try:
|
||||||
from hermes_cli.plugins import invoke_hook
|
from hermes_cli.plugins import invoke_hook
|
||||||
invoke_hook("pre_tool_call", tool_name=function_name, args=function_args, task_id=task_id or "")
|
invoke_hook(
|
||||||
|
"pre_tool_call",
|
||||||
|
tool_name=function_name,
|
||||||
|
args=function_args,
|
||||||
|
task_id=task_id or "",
|
||||||
|
session_id=session_id or "",
|
||||||
|
tool_call_id=tool_call_id or "",
|
||||||
|
)
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
@@ -519,7 +528,15 @@ def handle_function_call(
|
|||||||
|
|
||||||
try:
|
try:
|
||||||
from hermes_cli.plugins import invoke_hook
|
from hermes_cli.plugins import invoke_hook
|
||||||
invoke_hook("post_tool_call", tool_name=function_name, args=function_args, result=result, task_id=task_id or "")
|
invoke_hook(
|
||||||
|
"post_tool_call",
|
||||||
|
tool_name=function_name,
|
||||||
|
args=function_args,
|
||||||
|
result=result,
|
||||||
|
task_id=task_id or "",
|
||||||
|
session_id=session_id or "",
|
||||||
|
tool_call_id=tool_call_id or "",
|
||||||
|
)
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
|||||||
73
run_agent.py
73
run_agent.py
@@ -2424,6 +2424,22 @@ class AIAgent:
|
|||||||
|
|
||||||
return context
|
return context
|
||||||
|
|
||||||
|
def _usage_summary_for_api_request_hook(self, response: Any) -> Optional[Dict[str, Any]]:
|
||||||
|
"""Token buckets for ``post_api_request`` plugins (no raw ``response`` object)."""
|
||||||
|
if response is None:
|
||||||
|
return None
|
||||||
|
raw_usage = getattr(response, "usage", None)
|
||||||
|
if not raw_usage:
|
||||||
|
return None
|
||||||
|
from dataclasses import asdict
|
||||||
|
|
||||||
|
cu = normalize_usage(raw_usage, provider=self.provider, api_mode=self.api_mode)
|
||||||
|
summary = asdict(cu)
|
||||||
|
summary.pop("raw_usage", None)
|
||||||
|
summary["prompt_tokens"] = cu.prompt_tokens
|
||||||
|
summary["total_tokens"] = cu.total_tokens
|
||||||
|
return summary
|
||||||
|
|
||||||
def _dump_api_request_debug(
|
def _dump_api_request_debug(
|
||||||
self,
|
self,
|
||||||
api_kwargs: Dict[str, Any],
|
api_kwargs: Dict[str, Any],
|
||||||
@@ -5965,7 +5981,8 @@ class AIAgent:
|
|||||||
finally:
|
finally:
|
||||||
self._executing_tools = False
|
self._executing_tools = False
|
||||||
|
|
||||||
def _invoke_tool(self, function_name: str, function_args: dict, effective_task_id: str) -> str:
|
def _invoke_tool(self, function_name: str, function_args: dict, effective_task_id: str,
|
||||||
|
tool_call_id: Optional[str] = None) -> str:
|
||||||
"""Invoke a single tool and return the result string. No display logic.
|
"""Invoke a single tool and return the result string. No display logic.
|
||||||
|
|
||||||
Handles both agent-level tools (todo, memory, etc.) and registry-dispatched
|
Handles both agent-level tools (todo, memory, etc.) and registry-dispatched
|
||||||
@@ -6033,6 +6050,8 @@ class AIAgent:
|
|||||||
else:
|
else:
|
||||||
return handle_function_call(
|
return handle_function_call(
|
||||||
function_name, function_args, effective_task_id,
|
function_name, function_args, effective_task_id,
|
||||||
|
tool_call_id=tool_call_id,
|
||||||
|
session_id=self.session_id or "",
|
||||||
enabled_tools=list(self.valid_tool_names) if self.valid_tool_names else None,
|
enabled_tools=list(self.valid_tool_names) if self.valid_tool_names else None,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -6134,7 +6153,7 @@ class AIAgent:
|
|||||||
"""Worker function executed in a thread."""
|
"""Worker function executed in a thread."""
|
||||||
start = time.time()
|
start = time.time()
|
||||||
try:
|
try:
|
||||||
result = self._invoke_tool(function_name, function_args, effective_task_id)
|
result = self._invoke_tool(function_name, function_args, effective_task_id, tool_call.id)
|
||||||
except Exception as tool_error:
|
except Exception as tool_error:
|
||||||
result = f"Error executing tool '{function_name}': {tool_error}"
|
result = f"Error executing tool '{function_name}': {tool_error}"
|
||||||
logger.error("_invoke_tool raised for %s: %s", function_name, tool_error, exc_info=True)
|
logger.error("_invoke_tool raised for %s: %s", function_name, tool_error, exc_info=True)
|
||||||
@@ -6452,6 +6471,8 @@ class AIAgent:
|
|||||||
try:
|
try:
|
||||||
function_result = handle_function_call(
|
function_result = handle_function_call(
|
||||||
function_name, function_args, effective_task_id,
|
function_name, function_args, effective_task_id,
|
||||||
|
tool_call_id=tool_call.id,
|
||||||
|
session_id=self.session_id or "",
|
||||||
enabled_tools=list(self.valid_tool_names) if self.valid_tool_names else None,
|
enabled_tools=list(self.valid_tool_names) if self.valid_tool_names else None,
|
||||||
)
|
)
|
||||||
_spinner_result = function_result
|
_spinner_result = function_result
|
||||||
@@ -6469,6 +6490,8 @@ class AIAgent:
|
|||||||
try:
|
try:
|
||||||
function_result = handle_function_call(
|
function_result = handle_function_call(
|
||||||
function_name, function_args, effective_task_id,
|
function_name, function_args, effective_task_id,
|
||||||
|
tool_call_id=tool_call.id,
|
||||||
|
session_id=self.session_id or "",
|
||||||
enabled_tools=list(self.valid_tool_names) if self.valid_tool_names else None,
|
enabled_tools=list(self.valid_tool_names) if self.valid_tool_names else None,
|
||||||
)
|
)
|
||||||
except Exception as tool_error:
|
except Exception as tool_error:
|
||||||
@@ -7273,6 +7296,27 @@ class AIAgent:
|
|||||||
if self.api_mode == "codex_responses":
|
if self.api_mode == "codex_responses":
|
||||||
api_kwargs = self._preflight_codex_api_kwargs(api_kwargs, allow_stream=False)
|
api_kwargs = self._preflight_codex_api_kwargs(api_kwargs, allow_stream=False)
|
||||||
|
|
||||||
|
try:
|
||||||
|
from hermes_cli.plugins import invoke_hook as _invoke_hook
|
||||||
|
_invoke_hook(
|
||||||
|
"pre_api_request",
|
||||||
|
task_id=effective_task_id,
|
||||||
|
session_id=self.session_id or "",
|
||||||
|
platform=self.platform or "",
|
||||||
|
model=self.model,
|
||||||
|
provider=self.provider,
|
||||||
|
base_url=self.base_url,
|
||||||
|
api_mode=self.api_mode,
|
||||||
|
api_call_count=api_call_count,
|
||||||
|
message_count=len(api_messages),
|
||||||
|
tool_count=len(self.tools or []),
|
||||||
|
approx_input_tokens=approx_tokens,
|
||||||
|
request_char_count=total_chars,
|
||||||
|
max_tokens=self.max_tokens,
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
if env_var_enabled("HERMES_DUMP_REQUESTS"):
|
if env_var_enabled("HERMES_DUMP_REQUESTS"):
|
||||||
self._dump_api_request_debug(api_kwargs, reason="preflight")
|
self._dump_api_request_debug(api_kwargs, reason="preflight")
|
||||||
|
|
||||||
@@ -8359,6 +8403,31 @@ class AIAgent:
|
|||||||
else:
|
else:
|
||||||
assistant_message.content = str(raw)
|
assistant_message.content = str(raw)
|
||||||
|
|
||||||
|
try:
|
||||||
|
from hermes_cli.plugins import invoke_hook as _invoke_hook
|
||||||
|
_assistant_tool_calls = getattr(assistant_message, "tool_calls", None) or []
|
||||||
|
_assistant_text = assistant_message.content or ""
|
||||||
|
_invoke_hook(
|
||||||
|
"post_api_request",
|
||||||
|
task_id=effective_task_id,
|
||||||
|
session_id=self.session_id or "",
|
||||||
|
platform=self.platform or "",
|
||||||
|
model=self.model,
|
||||||
|
provider=self.provider,
|
||||||
|
base_url=self.base_url,
|
||||||
|
api_mode=self.api_mode,
|
||||||
|
api_call_count=api_call_count,
|
||||||
|
api_duration=api_duration,
|
||||||
|
finish_reason=finish_reason,
|
||||||
|
message_count=len(api_messages),
|
||||||
|
response_model=getattr(response, "model", None),
|
||||||
|
usage=self._usage_summary_for_api_request_hook(response),
|
||||||
|
assistant_content_chars=len(_assistant_text),
|
||||||
|
assistant_tool_call_count=len(_assistant_tool_calls),
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
# Handle assistant response
|
# Handle assistant response
|
||||||
if assistant_message.content and not self.quiet_mode:
|
if assistant_message.content and not self.quiet_mode:
|
||||||
if self.verbose_logging:
|
if self.verbose_logging:
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
"""Tests for model_tools.py — function call dispatch, agent-loop interception, legacy toolsets."""
|
"""Tests for model_tools.py — function call dispatch, agent-loop interception, legacy toolsets."""
|
||||||
|
|
||||||
import json
|
import json
|
||||||
|
from unittest.mock import call, patch
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from model_tools import (
|
from model_tools import (
|
||||||
@@ -38,6 +40,40 @@ class TestHandleFunctionCall:
|
|||||||
assert len(parsed["error"]) > 0
|
assert len(parsed["error"]) > 0
|
||||||
assert "error" in parsed["error"].lower() or "failed" in parsed["error"].lower()
|
assert "error" in parsed["error"].lower() or "failed" in parsed["error"].lower()
|
||||||
|
|
||||||
|
def test_tool_hooks_receive_session_and_tool_call_ids(self):
|
||||||
|
with (
|
||||||
|
patch("model_tools.registry.dispatch", return_value='{"ok":true}'),
|
||||||
|
patch("hermes_cli.plugins.invoke_hook") as mock_invoke_hook,
|
||||||
|
):
|
||||||
|
result = handle_function_call(
|
||||||
|
"web_search",
|
||||||
|
{"q": "test"},
|
||||||
|
task_id="task-1",
|
||||||
|
tool_call_id="call-1",
|
||||||
|
session_id="session-1",
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result == '{"ok":true}'
|
||||||
|
assert mock_invoke_hook.call_args_list == [
|
||||||
|
call(
|
||||||
|
"pre_tool_call",
|
||||||
|
tool_name="web_search",
|
||||||
|
args={"q": "test"},
|
||||||
|
task_id="task-1",
|
||||||
|
session_id="session-1",
|
||||||
|
tool_call_id="call-1",
|
||||||
|
),
|
||||||
|
call(
|
||||||
|
"post_tool_call",
|
||||||
|
tool_name="web_search",
|
||||||
|
args={"q": "test"},
|
||||||
|
result='{"ok":true}',
|
||||||
|
task_id="task-1",
|
||||||
|
session_id="session-1",
|
||||||
|
tool_call_id="call-1",
|
||||||
|
),
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
# =========================================================================
|
# =========================================================================
|
||||||
# Agent loop tools
|
# Agent loop tools
|
||||||
|
|||||||
@@ -196,6 +196,10 @@ class TestPluginLoading:
|
|||||||
class TestPluginHooks:
|
class TestPluginHooks:
|
||||||
"""Tests for lifecycle hook registration and invocation."""
|
"""Tests for lifecycle hook registration and invocation."""
|
||||||
|
|
||||||
|
def test_valid_hooks_include_request_scoped_api_hooks(self):
|
||||||
|
assert "pre_api_request" in VALID_HOOKS
|
||||||
|
assert "post_api_request" in VALID_HOOKS
|
||||||
|
|
||||||
def test_register_and_invoke_hook(self, tmp_path, monkeypatch):
|
def test_register_and_invoke_hook(self, tmp_path, monkeypatch):
|
||||||
"""Registered hooks are called on invoke_hook()."""
|
"""Registered hooks are called on invoke_hook()."""
|
||||||
plugins_dir = tmp_path / "hermes_test" / "plugins"
|
plugins_dir = tmp_path / "hermes_test" / "plugins"
|
||||||
@@ -262,6 +266,35 @@ class TestPluginHooks:
|
|||||||
user_message="hi", assistant_response="bye", model="test")
|
user_message="hi", assistant_response="bye", model="test")
|
||||||
assert results == []
|
assert results == []
|
||||||
|
|
||||||
|
def test_request_hooks_are_invokeable(self, tmp_path, monkeypatch):
|
||||||
|
plugins_dir = tmp_path / "hermes_test" / "plugins"
|
||||||
|
_make_plugin_dir(
|
||||||
|
plugins_dir, "request_hook",
|
||||||
|
register_body=(
|
||||||
|
'ctx.register_hook("pre_api_request", '
|
||||||
|
'lambda **kw: {"seen": kw.get("api_call_count"), '
|
||||||
|
'"mc": kw.get("message_count"), "tc": kw.get("tool_count")})'
|
||||||
|
),
|
||||||
|
)
|
||||||
|
monkeypatch.setenv("HERMES_HOME", str(tmp_path / "hermes_test"))
|
||||||
|
|
||||||
|
mgr = PluginManager()
|
||||||
|
mgr.discover_and_load()
|
||||||
|
|
||||||
|
results = mgr.invoke_hook(
|
||||||
|
"pre_api_request",
|
||||||
|
session_id="s1",
|
||||||
|
task_id="t1",
|
||||||
|
model="test",
|
||||||
|
api_call_count=2,
|
||||||
|
message_count=5,
|
||||||
|
tool_count=3,
|
||||||
|
approx_input_tokens=100,
|
||||||
|
request_char_count=400,
|
||||||
|
max_tokens=8192,
|
||||||
|
)
|
||||||
|
assert results == [{"seen": 2, "mc": 5, "tc": 3}]
|
||||||
|
|
||||||
def test_invalid_hook_name_warns(self, tmp_path, monkeypatch, caplog):
|
def test_invalid_hook_name_warns(self, tmp_path, monkeypatch, caplog):
|
||||||
"""Registering an unknown hook name logs a warning."""
|
"""Registering an unknown hook name logs a warning."""
|
||||||
plugins_dir = tmp_path / "hermes_test" / "plugins"
|
plugins_dir = tmp_path / "hermes_test" / "plugins"
|
||||||
|
|||||||
@@ -1258,6 +1258,8 @@ class TestConcurrentToolExecution:
|
|||||||
result = agent._invoke_tool("web_search", {"q": "test"}, "task-1")
|
result = agent._invoke_tool("web_search", {"q": "test"}, "task-1")
|
||||||
mock_hfc.assert_called_once_with(
|
mock_hfc.assert_called_once_with(
|
||||||
"web_search", {"q": "test"}, "task-1",
|
"web_search", {"q": "test"}, "task-1",
|
||||||
|
tool_call_id=None,
|
||||||
|
session_id=agent.session_id,
|
||||||
enabled_tools=list(agent.valid_tool_names),
|
enabled_tools=list(agent.valid_tool_names),
|
||||||
|
|
||||||
)
|
)
|
||||||
@@ -1441,7 +1443,7 @@ class TestRunConversation:
|
|||||||
resp2 = _mock_response(content="Done searching", finish_reason="stop")
|
resp2 = _mock_response(content="Done searching", finish_reason="stop")
|
||||||
agent.client.chat.completions.create.side_effect = [resp1, resp2]
|
agent.client.chat.completions.create.side_effect = [resp1, resp2]
|
||||||
with (
|
with (
|
||||||
patch("run_agent.handle_function_call", return_value="search result"),
|
patch("run_agent.handle_function_call", return_value="search result") as mock_handle_function_call,
|
||||||
patch.object(agent, "_persist_session"),
|
patch.object(agent, "_persist_session"),
|
||||||
patch.object(agent, "_save_trajectory"),
|
patch.object(agent, "_save_trajectory"),
|
||||||
patch.object(agent, "_cleanup_task_resources"),
|
patch.object(agent, "_cleanup_task_resources"),
|
||||||
@@ -1449,6 +1451,41 @@ class TestRunConversation:
|
|||||||
result = agent.run_conversation("search something")
|
result = agent.run_conversation("search something")
|
||||||
assert result["final_response"] == "Done searching"
|
assert result["final_response"] == "Done searching"
|
||||||
assert result["api_calls"] == 2
|
assert result["api_calls"] == 2
|
||||||
|
assert mock_handle_function_call.call_args.kwargs["tool_call_id"] == "c1"
|
||||||
|
assert mock_handle_function_call.call_args.kwargs["session_id"] == agent.session_id
|
||||||
|
|
||||||
|
def test_request_scoped_api_hooks_fire_for_each_api_call(self, agent):
|
||||||
|
self._setup_agent(agent)
|
||||||
|
tc = _mock_tool_call(name="web_search", arguments="{}", call_id="c1")
|
||||||
|
resp1 = _mock_response(content="", finish_reason="tool_calls", tool_calls=[tc])
|
||||||
|
resp2 = _mock_response(content="Done searching", finish_reason="stop")
|
||||||
|
agent.client.chat.completions.create.side_effect = [resp1, resp2]
|
||||||
|
|
||||||
|
hook_calls = []
|
||||||
|
|
||||||
|
def _record_hook(name, **kwargs):
|
||||||
|
hook_calls.append((name, kwargs))
|
||||||
|
return []
|
||||||
|
|
||||||
|
with (
|
||||||
|
patch("run_agent.handle_function_call", return_value="search result"),
|
||||||
|
patch("hermes_cli.plugins.invoke_hook", side_effect=_record_hook),
|
||||||
|
patch.object(agent, "_persist_session"),
|
||||||
|
patch.object(agent, "_save_trajectory"),
|
||||||
|
patch.object(agent, "_cleanup_task_resources"),
|
||||||
|
):
|
||||||
|
result = agent.run_conversation("search something")
|
||||||
|
|
||||||
|
assert result["final_response"] == "Done searching"
|
||||||
|
pre_request_calls = [kw for name, kw in hook_calls if name == "pre_api_request"]
|
||||||
|
post_request_calls = [kw for name, kw in hook_calls if name == "post_api_request"]
|
||||||
|
assert len(pre_request_calls) == 2
|
||||||
|
assert len(post_request_calls) == 2
|
||||||
|
assert [call["api_call_count"] for call in pre_request_calls] == [1, 2]
|
||||||
|
assert [call["api_call_count"] for call in post_request_calls] == [1, 2]
|
||||||
|
assert all(call["session_id"] == agent.session_id for call in pre_request_calls)
|
||||||
|
assert all("message_count" in c and "messages" not in c for c in pre_request_calls)
|
||||||
|
assert all("usage" in c and "response" not in c for c in post_request_calls)
|
||||||
|
|
||||||
def test_interrupt_breaks_loop(self, agent):
|
def test_interrupt_breaks_loop(self, agent):
|
||||||
self._setup_agent(agent)
|
self._setup_agent(agent)
|
||||||
|
|||||||
Reference in New Issue
Block a user