feat(observability): aggregate bounded tool metrics
This commit is contained in:
parent
a0476b3605
commit
8b0c3da8c0
|
|
@ -299,6 +299,7 @@ class _ManagedToolResult:
|
|||
args: dict[str, Any]
|
||||
middleware_trace: list[dict[str, Any]]
|
||||
blocked: bool
|
||||
dispatched: bool
|
||||
|
||||
|
||||
class _ConcurrentToolAuthorizationGate:
|
||||
|
|
@ -343,12 +344,13 @@ class _ConcurrentToolAuthorizationGate:
|
|||
|
||||
def _managed_values(
|
||||
outcome: _ManagedToolResult,
|
||||
) -> tuple[Any, dict[str, Any], list[dict[str, Any]], bool]:
|
||||
) -> tuple[Any, dict[str, Any], list[dict[str, Any]], bool, bool]:
|
||||
return (
|
||||
outcome.result,
|
||||
outcome.args,
|
||||
outcome.middleware_trace,
|
||||
outcome.blocked,
|
||||
outcome.dispatched,
|
||||
)
|
||||
|
||||
|
||||
|
|
@ -531,6 +533,7 @@ def _run_agent_tool_execution_middleware(
|
|||
args=state["args"],
|
||||
middleware_trace=state["middleware_trace"],
|
||||
blocked=bool(state["blocked"]),
|
||||
dispatched=bool(state["dispatched"]),
|
||||
)
|
||||
|
||||
|
||||
|
|
@ -648,12 +651,27 @@ def execute_tool_calls_concurrent(agent, assistant_message, messages: list, effe
|
|||
if agent._interrupt_requested:
|
||||
print(f"{agent.log_prefix}⚡ Interrupt: skipping {num_tools} tool call(s)")
|
||||
for tc in tool_calls:
|
||||
cancelled_result = (
|
||||
f"[Tool execution cancelled — {tc.function.name} was skipped "
|
||||
"due to user interrupt]"
|
||||
)
|
||||
messages.append(make_tool_result_message(
|
||||
tc.function.name,
|
||||
f"[Tool execution cancelled — {tc.function.name} was skipped due to user interrupt]",
|
||||
cancelled_result,
|
||||
tc.id,
|
||||
effect_disposition="none",
|
||||
))
|
||||
_emit_terminal_post_tool_call(
|
||||
agent,
|
||||
function_name=tc.function.name,
|
||||
function_args={},
|
||||
result=cancelled_result,
|
||||
effective_task_id=effective_task_id,
|
||||
tool_call_id=getattr(tc, "id", "") or "",
|
||||
status="cancelled",
|
||||
error_type="user_interrupt",
|
||||
error_message="Tool execution skipped due to user interrupt",
|
||||
)
|
||||
_flush_session_db_after_tool_progress(
|
||||
agent,
|
||||
messages,
|
||||
|
|
@ -800,6 +818,7 @@ def execute_tool_calls_concurrent(agent, assistant_message, messages: list, effe
|
|||
# submit site below (GHSA-qg5c-hvr5-hjgr, #13617).
|
||||
start = time.time()
|
||||
blocked = False
|
||||
dispatched = False
|
||||
start_advanced = False
|
||||
|
||||
def _advance_start(callback=None) -> None:
|
||||
|
|
@ -843,6 +862,7 @@ def execute_tool_calls_concurrent(agent, assistant_message, messages: list, effe
|
|||
function_args = managed.args
|
||||
middleware_trace = managed.middleware_trace
|
||||
blocked = managed.blocked
|
||||
dispatched = managed.dispatched
|
||||
except KeyboardInterrupt:
|
||||
try:
|
||||
agent.interrupt("keyboard interrupt")
|
||||
|
|
@ -873,6 +893,17 @@ def execute_tool_calls_concurrent(agent, assistant_message, messages: list, effe
|
|||
result = f"Error executing tool '{function_name}': {tool_error}"
|
||||
logger.error("_invoke_tool raised for %s: %s", function_name, tool_error, exc_info=True)
|
||||
duration = time.time() - start
|
||||
if not blocked and not dispatched:
|
||||
_emit_terminal_post_tool_call(
|
||||
agent,
|
||||
function_name=function_name,
|
||||
function_args=function_args,
|
||||
result=result,
|
||||
effective_task_id=effective_task_id,
|
||||
tool_call_id=getattr(tool_call, "id", "") or "",
|
||||
duration_ms=int(duration * 1000),
|
||||
middleware_trace=list(middleware_trace),
|
||||
)
|
||||
is_error, _ = _detect_tool_failure(function_name, result)
|
||||
if is_error:
|
||||
logger.info("tool %s failed (%.2fs): %s", function_name, duration, result[:200])
|
||||
|
|
@ -1122,6 +1153,7 @@ def execute_tool_calls_concurrent(agent, assistant_message, messages: list, effe
|
|||
result=function_result,
|
||||
effective_task_id=effective_task_id,
|
||||
tool_call_id=getattr(tc, "id", "") or "",
|
||||
duration_ms=int((timeout_s or 0.0) * 1000),
|
||||
status="timeout",
|
||||
error_type="tool_timeout",
|
||||
error_message=function_result,
|
||||
|
|
@ -1164,6 +1196,19 @@ def execute_tool_calls_concurrent(agent, assistant_message, messages: list, effe
|
|||
name = function_name
|
||||
args = function_args
|
||||
progress_function_name = function_name
|
||||
if _parse_error is not None:
|
||||
_emit_terminal_post_tool_call(
|
||||
agent,
|
||||
function_name=function_name,
|
||||
function_args=function_args,
|
||||
result=function_result,
|
||||
effective_task_id=effective_task_id,
|
||||
tool_call_id=getattr(tc, "id", "") or "",
|
||||
status="error",
|
||||
error_type="invalid_tool_arguments",
|
||||
error_message="Tool arguments must be a valid JSON object",
|
||||
middleware_trace=list(middleware_trace),
|
||||
)
|
||||
if blocked:
|
||||
effect_disposition = "none"
|
||||
|
||||
|
|
@ -1336,12 +1381,27 @@ def execute_tool_calls_sequential(agent, assistant_message, messages: list, effe
|
|||
agent._vprint(f"{agent.log_prefix}⚡ Interrupt: skipping {len(remaining_calls)} tool call(s)", force=True)
|
||||
for skipped_tc in remaining_calls:
|
||||
skipped_name = skipped_tc.function.name
|
||||
cancelled_result = (
|
||||
f"[Tool execution cancelled — {skipped_name} was skipped "
|
||||
"due to user interrupt]"
|
||||
)
|
||||
messages.append(make_tool_result_message(
|
||||
skipped_name,
|
||||
f"[Tool execution cancelled — {skipped_name} was skipped due to user interrupt]",
|
||||
cancelled_result,
|
||||
skipped_tc.id,
|
||||
effect_disposition="none",
|
||||
))
|
||||
_emit_terminal_post_tool_call(
|
||||
agent,
|
||||
function_name=skipped_name,
|
||||
function_args={},
|
||||
result=cancelled_result,
|
||||
effective_task_id=effective_task_id,
|
||||
tool_call_id=getattr(skipped_tc, "id", "") or "",
|
||||
status="cancelled",
|
||||
error_type="user_interrupt",
|
||||
error_message="Tool execution skipped due to user interrupt",
|
||||
)
|
||||
if not _flush_session_db_after_tool_progress(
|
||||
agent,
|
||||
messages,
|
||||
|
|
@ -1356,6 +1416,17 @@ def execute_tool_calls_sequential(agent, assistant_message, messages: list, effe
|
|||
tool_call.function.arguments
|
||||
)
|
||||
if malformed_args_result is not None:
|
||||
_emit_terminal_post_tool_call(
|
||||
agent,
|
||||
function_name=function_name,
|
||||
function_args=function_args,
|
||||
result=malformed_args_result,
|
||||
effective_task_id=effective_task_id,
|
||||
tool_call_id=getattr(tool_call, "id", "") or "",
|
||||
status="error",
|
||||
error_type="invalid_tool_arguments",
|
||||
error_message="Tool arguments must be a valid JSON object",
|
||||
)
|
||||
messages.append(
|
||||
make_tool_result_message(
|
||||
function_name,
|
||||
|
|
@ -1411,6 +1482,7 @@ def execute_tool_calls_sequential(agent, assistant_message, messages: list, effe
|
|||
|
||||
middleware_trace: list[dict[str, Any]] = []
|
||||
_execution_blocked = False
|
||||
_execution_dispatched = False
|
||||
|
||||
tool_start_time = time.time()
|
||||
|
||||
|
|
@ -1422,7 +1494,7 @@ def execute_tool_calls_sequential(agent, assistant_message, messages: list, effe
|
|||
merge=next_args.get("merge", False),
|
||||
store=agent._todo_store,
|
||||
)
|
||||
function_result, function_args, middleware_trace, _execution_blocked = _managed_values(_run_agent_tool_execution_middleware(
|
||||
function_result, function_args, middleware_trace, _execution_blocked, _execution_dispatched = _managed_values(_run_agent_tool_execution_middleware(
|
||||
agent,
|
||||
function_name=function_name,
|
||||
function_args=function_args,
|
||||
|
|
@ -1453,7 +1525,7 @@ def execute_tool_calls_sequential(agent, assistant_message, messages: list, effe
|
|||
db=session_db,
|
||||
current_session_id=agent.session_id,
|
||||
)
|
||||
function_result, function_args, middleware_trace, _execution_blocked = _managed_values(_run_agent_tool_execution_middleware(
|
||||
function_result, function_args, middleware_trace, _execution_blocked, _execution_dispatched = _managed_values(_run_agent_tool_execution_middleware(
|
||||
agent,
|
||||
function_name=function_name,
|
||||
function_args=function_args,
|
||||
|
|
@ -1492,7 +1564,7 @@ def execute_tool_calls_sequential(agent, assistant_message, messages: list, effe
|
|||
),
|
||||
)
|
||||
return result
|
||||
function_result, function_args, middleware_trace, _execution_blocked = _managed_values(_run_agent_tool_execution_middleware(
|
||||
function_result, function_args, middleware_trace, _execution_blocked, _execution_dispatched = _managed_values(_run_agent_tool_execution_middleware(
|
||||
agent,
|
||||
function_name=function_name,
|
||||
function_args=function_args,
|
||||
|
|
@ -1514,7 +1586,7 @@ def execute_tool_calls_sequential(agent, assistant_message, messages: list, effe
|
|||
multi_select=next_args.get("multi_select", False),
|
||||
callback=agent.clarify_callback,
|
||||
)
|
||||
function_result, function_args, middleware_trace, _execution_blocked = _managed_values(_run_agent_tool_execution_middleware(
|
||||
function_result, function_args, middleware_trace, _execution_blocked, _execution_dispatched = _managed_values(_run_agent_tool_execution_middleware(
|
||||
agent,
|
||||
function_name=function_name,
|
||||
function_args=function_args,
|
||||
|
|
@ -1535,7 +1607,7 @@ def execute_tool_calls_sequential(agent, assistant_message, messages: list, effe
|
|||
count=next_args.get("count"),
|
||||
callback=getattr(agent, "read_terminal_callback", None),
|
||||
)
|
||||
function_result, function_args, middleware_trace, _execution_blocked = _managed_values(_run_agent_tool_execution_middleware(
|
||||
function_result, function_args, middleware_trace, _execution_blocked, _execution_dispatched = _managed_values(_run_agent_tool_execution_middleware(
|
||||
agent,
|
||||
function_name=function_name,
|
||||
function_args=function_args,
|
||||
|
|
@ -1569,7 +1641,7 @@ def execute_tool_calls_sequential(agent, assistant_message, messages: list, effe
|
|||
try:
|
||||
def _execute(next_args: dict) -> Any:
|
||||
return agent._dispatch_delegate_task(next_args)
|
||||
function_result, function_args, middleware_trace, _execution_blocked = _managed_values(_run_agent_tool_execution_middleware(
|
||||
function_result, function_args, middleware_trace, _execution_blocked, _execution_dispatched = _managed_values(_run_agent_tool_execution_middleware(
|
||||
agent,
|
||||
function_name=function_name,
|
||||
function_args=function_args,
|
||||
|
|
@ -1602,7 +1674,7 @@ def execute_tool_calls_sequential(agent, assistant_message, messages: list, effe
|
|||
try:
|
||||
def _execute(next_args: dict) -> Any:
|
||||
return agent.context_compressor.handle_tool_call(function_name, next_args, messages=messages)
|
||||
function_result, function_args, middleware_trace, _execution_blocked = _managed_values(_run_agent_tool_execution_middleware(
|
||||
function_result, function_args, middleware_trace, _execution_blocked, _execution_dispatched = _managed_values(_run_agent_tool_execution_middleware(
|
||||
agent,
|
||||
function_name=function_name,
|
||||
function_args=function_args,
|
||||
|
|
@ -1638,7 +1710,7 @@ def execute_tool_calls_sequential(agent, assistant_message, messages: list, effe
|
|||
try:
|
||||
def _execute(next_args: dict) -> Any:
|
||||
return agent._memory_manager.handle_tool_call(function_name, next_args)
|
||||
function_result, function_args, middleware_trace, _execution_blocked = _managed_values(_run_agent_tool_execution_middleware(
|
||||
function_result, function_args, middleware_trace, _execution_blocked, _execution_dispatched = _managed_values(_run_agent_tool_execution_middleware(
|
||||
agent,
|
||||
function_name=function_name,
|
||||
function_args=function_args,
|
||||
|
|
@ -1698,6 +1770,7 @@ def execute_tool_calls_sequential(agent, assistant_message, messages: list, effe
|
|||
function_args,
|
||||
middleware_trace,
|
||||
_execution_blocked,
|
||||
_execution_dispatched,
|
||||
) = _managed_values(
|
||||
_run_agent_tool_execution_middleware(
|
||||
agent,
|
||||
|
|
@ -1768,6 +1841,7 @@ def execute_tool_calls_sequential(agent, assistant_message, messages: list, effe
|
|||
function_args,
|
||||
middleware_trace,
|
||||
_execution_blocked,
|
||||
_execution_dispatched,
|
||||
) = _managed_values(
|
||||
_run_agent_tool_execution_middleware(
|
||||
agent,
|
||||
|
|
@ -1823,7 +1897,10 @@ def execute_tool_calls_sequential(agent, assistant_message, messages: list, effe
|
|||
from agent.agent_runtime_helpers import agent_runtime_owns_post_tool_hook
|
||||
_executor_must_emit_post_hook = (
|
||||
not _execution_blocked
|
||||
and agent_runtime_owns_post_tool_hook(agent, function_name)
|
||||
and (
|
||||
not _execution_dispatched
|
||||
or agent_runtime_owns_post_tool_hook(agent, function_name)
|
||||
)
|
||||
)
|
||||
if _executor_must_emit_post_hook:
|
||||
_emit_terminal_post_tool_call(
|
||||
|
|
|
|||
|
|
@ -55,11 +55,12 @@ dependency does not change the collection or privacy policy.
|
|||
|
||||
## Current Slices
|
||||
|
||||
The current vertical slices record logical model calls and top-level task runs:
|
||||
The current vertical slices record logical model calls, top-level task runs,
|
||||
and tool and approval outcomes:
|
||||
|
||||
```text
|
||||
Hermes turn, API, and tool hooks
|
||||
-> Relay session, task, and LLM lifecycle
|
||||
Hermes turn, API, tool, and approval hooks
|
||||
-> Relay session, task, LLM, tool, and mark lifecycle
|
||||
-> Hermes shared-metrics subscriber
|
||||
-> SQLite counters
|
||||
-> immutable JSON delta package
|
||||
|
|
@ -87,6 +88,18 @@ boundary closes the task for normal returns, early returns, exceptions, and
|
|||
cancellations. Active task ownership follows the task ID if Hermes rotates its
|
||||
conversation session during context compression.
|
||||
|
||||
Each tool invocation is represented by a Relay tool lifecycle named
|
||||
`hermes.tool_call`. The terminal counter contains only bounded tool category,
|
||||
outcome, approval outcome, latency, and explicit retry-count buckets. Hermes
|
||||
does not infer retries from repeated tool names or adjacent calls; when the
|
||||
hook does not provide an explicit retry relationship, the retry bucket is
|
||||
`unknown`. Approval decisions are emitted as `hermes.tool_approval` marks and
|
||||
recorded as attributed to a tool call or explicitly `unattributed`. Tool names,
|
||||
call IDs, arguments, results, commands, descriptions, and error text are not
|
||||
included in shared-metrics events or packages. A started tool that is still
|
||||
open when its task terminates is closed as failed, timed out, or cancelled and
|
||||
remains in the task's tool-count bucket.
|
||||
|
||||
Local state is written under:
|
||||
|
||||
```text
|
||||
|
|
@ -125,6 +138,8 @@ The script uses the installed `nemo-relay` dependency by default. Pass
|
|||
`--relay-python ../nemo-relay/python` only when testing a locally built Relay
|
||||
binding.
|
||||
|
||||
The smoke verifies the model request reached the local server, model and task
|
||||
counters were stored with the expected model and provider, one package was
|
||||
exported, and prompt and response canaries are absent from the package.
|
||||
The smoke has the local model request a real `read_file` tool call before its
|
||||
final response. It verifies model, provider, task, and bounded tool counters in
|
||||
SQLite, validates the exported package against the closed schema, and checks
|
||||
that prompt, response, tool-call ID, and tool-result canaries are absent from
|
||||
the package.
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ import atexit
|
|||
import contextvars
|
||||
import logging
|
||||
import threading
|
||||
from collections import deque
|
||||
from dataclasses import dataclass, field
|
||||
from time import monotonic_ns
|
||||
from typing import Any, Callable
|
||||
|
|
@ -21,9 +22,15 @@ from .shared_metrics_contract import (
|
|||
SCHEMA_VERSION,
|
||||
SUBSCRIBER_NAME,
|
||||
TASK_SCOPE,
|
||||
TOOL_APPROVAL_MARK,
|
||||
TOOL_CALL_SCOPE,
|
||||
model_call_fields,
|
||||
task_start_fields,
|
||||
task_terminal_fields,
|
||||
task_terminal_state,
|
||||
tool_approval_outcome,
|
||||
tool_category,
|
||||
tool_terminal_fields,
|
||||
)
|
||||
from .shared_metrics_subscriber import SharedMetricsSubscriber
|
||||
|
||||
|
|
@ -36,7 +43,9 @@ HANDLED_HOOKS = frozenset({
|
|||
"on_session_reset",
|
||||
"pre_llm_call",
|
||||
"pre_api_request",
|
||||
"pre_tool_call",
|
||||
"post_tool_call",
|
||||
"post_approval_response",
|
||||
"post_api_request",
|
||||
"api_request_error",
|
||||
"subagent_stop",
|
||||
|
|
@ -62,15 +71,27 @@ class _ModelCall:
|
|||
retry_ordinal: int | None = None
|
||||
|
||||
|
||||
@dataclass
|
||||
class _ToolCall:
|
||||
handle: Any
|
||||
task_id: str
|
||||
category: str
|
||||
started_ns: int
|
||||
approval_outcome: str = "not_required"
|
||||
|
||||
|
||||
@dataclass
|
||||
class _TaskRun:
|
||||
task_id: str
|
||||
handle: Any
|
||||
context: contextvars.Context
|
||||
started_ns: int
|
||||
start_fields: dict[str, str]
|
||||
model_call_ids: set[str] = field(default_factory=set)
|
||||
tool_call_ids: set[str] = field(default_factory=set)
|
||||
tool_call_ids: set[tuple[str, str, str]] = field(default_factory=set)
|
||||
turn_ids: set[str] = field(default_factory=set)
|
||||
retired_turn_ids: frozenset[str] = field(default_factory=frozenset)
|
||||
completed_tool_call_ids: set[tuple[str, str, str]] = field(default_factory=set)
|
||||
unidentified_tool_calls: int = 0
|
||||
retry_count: int = 0
|
||||
|
||||
|
|
@ -83,6 +104,12 @@ class _MetricsSession:
|
|||
closing: bool = False
|
||||
model_calls: dict[tuple[str, str], _ModelCall] = field(default_factory=dict)
|
||||
tasks: dict[str, _TaskRun] = field(default_factory=dict)
|
||||
tool_calls: dict[tuple[str, str, str, str], _ToolCall] = field(
|
||||
default_factory=dict
|
||||
)
|
||||
retired_turn_ids: deque[str] = field(
|
||||
default_factory=lambda: deque(maxlen=256),
|
||||
)
|
||||
|
||||
|
||||
class _Runtime:
|
||||
|
|
@ -169,7 +196,10 @@ class _Runtime:
|
|||
if session is None:
|
||||
return None
|
||||
with session.lock:
|
||||
if session.closing or session.relay_session.context is None:
|
||||
if (
|
||||
session.closing
|
||||
or session.relay_session.context is None
|
||||
):
|
||||
return None
|
||||
task_context = session.relay_session.context.copy()
|
||||
start_fields = task_start_fields(event)
|
||||
|
|
@ -195,10 +225,12 @@ class _Runtime:
|
|||
|
||||
handle = task_context.run(push_task)
|
||||
task = _TaskRun(
|
||||
task_id=task_id,
|
||||
handle=handle,
|
||||
context=task_context,
|
||||
started_ns=monotonic_ns(),
|
||||
start_fields=start_fields,
|
||||
retired_turn_ids=frozenset(session.retired_turn_ids),
|
||||
)
|
||||
session.tasks[task_id] = task
|
||||
with self._task_sessions_lock:
|
||||
|
|
@ -306,8 +338,8 @@ class _Runtime:
|
|||
return
|
||||
model_call.fields = model_call_fields(event)
|
||||
|
||||
def record_tool_call(self, event: dict[str, Any]) -> None:
|
||||
"""Count one unique tool invocation under its owning task."""
|
||||
def start_tool_call(self, event: dict[str, Any]) -> None:
|
||||
"""Open one privacy-safe Relay tool lifecycle under its task."""
|
||||
task_id = str(event.get("task_id") or "")
|
||||
session = self._task_session(event, allow_task_id_fallback=True)
|
||||
task = session.tasks.get(task_id) if session is not None else None
|
||||
|
|
@ -317,14 +349,85 @@ class _Runtime:
|
|||
if session is None or task is None:
|
||||
return
|
||||
tool_call_id = str(event.get("tool_call_id") or "")
|
||||
if not tool_call_id:
|
||||
return
|
||||
identity = self._tool_call_identity(event)
|
||||
with session.lock:
|
||||
if session.closing:
|
||||
return
|
||||
if not self._event_matches_task_turn(task, event):
|
||||
return
|
||||
self._remember_turn(session, task, event)
|
||||
key = (task_id, *identity)
|
||||
if identity in task.completed_tool_call_ids or key in session.tool_calls:
|
||||
return
|
||||
task.tool_call_ids.add(identity)
|
||||
session.tool_calls[key] = self._open_tool_call(task, event)
|
||||
|
||||
def record_approval(self, event: dict[str, Any]) -> None:
|
||||
"""Record one bounded approval result without approval text or commands."""
|
||||
session, task = self._approval_task(event)
|
||||
if session is None or task is None:
|
||||
return
|
||||
outcome = tool_approval_outcome(event)
|
||||
tool_call_id = str(event.get("tool_call_id") or "")
|
||||
attribution = "tool_call" if tool_call_id else "unattributed"
|
||||
with session.lock:
|
||||
if session.closing:
|
||||
return
|
||||
if not self._event_matches_task_turn(task, event):
|
||||
return
|
||||
if tool_call_id:
|
||||
identity = self._tool_call_identity(event)
|
||||
tool_call = session.tool_calls.get((task.task_id, *identity))
|
||||
if tool_call is None:
|
||||
matches = [
|
||||
candidate
|
||||
for key, candidate in session.tool_calls.items()
|
||||
if key[0] == task.task_id and key[-1] == tool_call_id
|
||||
]
|
||||
tool_call = matches[0] if len(matches) == 1 else None
|
||||
if tool_call is not None:
|
||||
tool_call.approval_outcome = outcome
|
||||
self._run_in_task(
|
||||
task,
|
||||
self.relay.scope.event,
|
||||
TOOL_APPROVAL_MARK,
|
||||
handle=task.handle,
|
||||
data={"attribution": attribution, "outcome": outcome},
|
||||
metadata=self._event_metadata(),
|
||||
)
|
||||
|
||||
def record_tool_call(self, event: dict[str, Any]) -> None:
|
||||
"""Close and count one unique privacy-safe tool lifecycle."""
|
||||
task_id = str(event.get("task_id") or "")
|
||||
session = self._task_session(event, allow_task_id_fallback=True)
|
||||
task = session.tasks.get(task_id) if session is not None else None
|
||||
if session is None or task is None:
|
||||
return
|
||||
tool_call_id = str(event.get("tool_call_id") or "")
|
||||
with session.lock:
|
||||
if session.closing:
|
||||
return
|
||||
if not self._event_matches_task_turn(task, event):
|
||||
return
|
||||
self._remember_turn(session, task, event)
|
||||
if tool_call_id:
|
||||
task.tool_call_ids.add(tool_call_id)
|
||||
identity = self._tool_call_identity(event)
|
||||
if identity in task.completed_tool_call_ids:
|
||||
return
|
||||
task.completed_tool_call_ids.add(identity)
|
||||
task.tool_call_ids.add(identity)
|
||||
tool_call = session.tool_calls.pop(
|
||||
(task_id, *identity),
|
||||
None,
|
||||
)
|
||||
else:
|
||||
task.unidentified_tool_calls += 1
|
||||
tool_call = None
|
||||
if tool_call is None:
|
||||
tool_call = self._open_tool_call(task, event)
|
||||
self._finish_tool_call(task, tool_call, event)
|
||||
|
||||
def end_model_call(self, event: dict[str, Any]) -> None:
|
||||
session = self._task_session(event, allow_task_id_fallback=True)
|
||||
|
|
@ -546,6 +649,143 @@ class _Runtime:
|
|||
with self._task_sessions_lock:
|
||||
self._turn_sessions[(session.session_id, turn_id)] = session
|
||||
|
||||
@staticmethod
|
||||
def _tool_call_identity(event: dict[str, Any]) -> tuple[str, str, str]:
|
||||
"""Identify one provider-local tool call without exporting its IDs."""
|
||||
return (
|
||||
str(event.get("api_request_id") or ""),
|
||||
str(event.get("turn_id") or ""),
|
||||
str(event.get("tool_call_id") or ""),
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _event_matches_task_turn(
|
||||
task: _TaskRun,
|
||||
event: dict[str, Any],
|
||||
) -> bool:
|
||||
"""Reject delayed hooks from a prior run that reused the task ID."""
|
||||
turn_id = str(event.get("turn_id") or "")
|
||||
if not turn_id:
|
||||
return True
|
||||
if turn_id in task.retired_turn_ids:
|
||||
return False
|
||||
return not task.turn_ids or turn_id in task.turn_ids
|
||||
|
||||
def _approval_task(
|
||||
self,
|
||||
event: dict[str, Any],
|
||||
) -> tuple[_MetricsSession | None, _TaskRun | None]:
|
||||
"""Resolve approval correlation without guessing across ambiguous turns."""
|
||||
active = relay_runtime.active_turn()
|
||||
if active is not None:
|
||||
correlated = {
|
||||
**event,
|
||||
"session_id": active.lease.session_id,
|
||||
"task_id": active.task_id,
|
||||
}
|
||||
session = self._task_session(correlated)
|
||||
task = session.tasks.get(active.task_id) if session is not None else None
|
||||
if task is not None:
|
||||
return session, task
|
||||
|
||||
session = self._task_session(event)
|
||||
task_id = str(event.get("task_id") or "")
|
||||
task = session.tasks.get(task_id) if session is not None else None
|
||||
if task is not None:
|
||||
return session, task
|
||||
|
||||
turn_id = str(event.get("turn_id") or "")
|
||||
if not turn_id:
|
||||
return None, None
|
||||
with self._task_sessions_lock:
|
||||
candidates = [
|
||||
candidate
|
||||
for (
|
||||
candidate_session_id,
|
||||
candidate_turn_id,
|
||||
), candidate in self._turn_sessions.items()
|
||||
if candidate_turn_id == turn_id
|
||||
and self._sessions.get(candidate_session_id) is candidate
|
||||
]
|
||||
unique_sessions = {id(candidate): candidate for candidate in candidates}
|
||||
if len(unique_sessions) != 1:
|
||||
return None, None
|
||||
session = next(iter(unique_sessions.values()))
|
||||
matching_tasks = [
|
||||
candidate
|
||||
for candidate in session.tasks.values()
|
||||
if turn_id in candidate.turn_ids
|
||||
]
|
||||
if len(matching_tasks) != 1:
|
||||
return None, None
|
||||
return session, matching_tasks[0]
|
||||
|
||||
def _open_tool_call(
|
||||
self,
|
||||
task: _TaskRun,
|
||||
event: dict[str, Any],
|
||||
) -> _ToolCall:
|
||||
handle = self._run_in_task(
|
||||
task,
|
||||
self.relay.tools.call,
|
||||
TOOL_CALL_SCOPE,
|
||||
{},
|
||||
handle=task.handle,
|
||||
metadata=self._event_metadata(),
|
||||
)
|
||||
return _ToolCall(
|
||||
handle=handle,
|
||||
task_id=task.task_id,
|
||||
category=tool_category(event),
|
||||
started_ns=monotonic_ns(),
|
||||
)
|
||||
|
||||
def _finish_tool_call(
|
||||
self,
|
||||
task: _TaskRun,
|
||||
tool_call: _ToolCall,
|
||||
event: dict[str, Any],
|
||||
) -> None:
|
||||
fields = tool_terminal_fields(
|
||||
event,
|
||||
category=tool_call.category,
|
||||
approval_outcome=tool_call.approval_outcome,
|
||||
fallback_duration_ms=max(
|
||||
0,
|
||||
(monotonic_ns() - tool_call.started_ns) // 1_000_000,
|
||||
),
|
||||
)
|
||||
try:
|
||||
self._run_in_task(
|
||||
task,
|
||||
self.relay.tools.call_end,
|
||||
tool_call.handle,
|
||||
fields,
|
||||
metadata=self._event_metadata(),
|
||||
)
|
||||
except Exception:
|
||||
logger.warning(
|
||||
"Hermes shared-metrics tool call close failed",
|
||||
exc_info=True,
|
||||
)
|
||||
|
||||
def _end_pending_tool_calls(
|
||||
self,
|
||||
session: _MetricsSession,
|
||||
task: _TaskRun,
|
||||
event: dict[str, Any],
|
||||
) -> None:
|
||||
pending_keys = [key for key in session.tool_calls if key[0] == task.task_id]
|
||||
task_outcome, _, _ = task_terminal_state(event)
|
||||
status = {
|
||||
"cancelled": "cancelled",
|
||||
"timed_out": "timeout",
|
||||
}.get(task_outcome, "error")
|
||||
for key in pending_keys:
|
||||
tool_call = session.tool_calls.pop(key, None)
|
||||
if tool_call is not None:
|
||||
self._finish_tool_call(task, tool_call, {**event, "status": status})
|
||||
|
||||
def _finish_model_call(
|
||||
self,
|
||||
session: _MetricsSession,
|
||||
|
|
@ -628,6 +868,7 @@ class _Runtime:
|
|||
task = session.tasks.get(task_id)
|
||||
if task is None:
|
||||
return False
|
||||
self._end_pending_tool_calls(session, task, event)
|
||||
self._end_pending_model_calls(session, {**event, "task_id": task_id})
|
||||
fields = task_terminal_fields(
|
||||
{**task.start_fields, **event},
|
||||
|
|
@ -648,6 +889,7 @@ class _Runtime:
|
|||
logger.warning("Hermes shared-metrics task close failed", exc_info=True)
|
||||
finally:
|
||||
session.tasks.pop(task_id, None)
|
||||
session.retired_turn_ids.extend(task.turn_ids)
|
||||
with self._task_sessions_lock:
|
||||
task_key = (session.session_id, task_id)
|
||||
if self._task_sessions.get(task_key) is session:
|
||||
|
|
@ -695,8 +937,7 @@ def enabled() -> bool:
|
|||
telemetry.get("shared_metrics") if isinstance(telemetry, dict) else None
|
||||
)
|
||||
value = (
|
||||
isinstance(shared_metrics, dict)
|
||||
and shared_metrics.get("enabled") is True
|
||||
isinstance(shared_metrics, dict) and shared_metrics.get("enabled") is True
|
||||
)
|
||||
if value:
|
||||
return True
|
||||
|
|
@ -725,8 +966,12 @@ def observe_lifecycle(hook_name: str, **kwargs: Any) -> None:
|
|||
runtime.start_task(kwargs)
|
||||
elif hook_name == "pre_api_request":
|
||||
runtime.start_model_call(kwargs)
|
||||
elif hook_name == "pre_tool_call":
|
||||
runtime.start_tool_call(kwargs)
|
||||
elif hook_name == "post_tool_call":
|
||||
runtime.record_tool_call(kwargs)
|
||||
elif hook_name == "post_approval_response":
|
||||
runtime.record_approval(kwargs)
|
||||
elif hook_name == "post_api_request":
|
||||
runtime.end_model_call(kwargs)
|
||||
elif hook_name == "api_request_error":
|
||||
|
|
|
|||
|
|
@ -64,6 +64,12 @@
|
|||
},
|
||||
{
|
||||
"$ref": "#/$defs/task_finished_counter"
|
||||
},
|
||||
{
|
||||
"$ref": "#/$defs/tool_call_counter"
|
||||
},
|
||||
{
|
||||
"$ref": "#/$defs/tool_approval_counter"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
@ -74,6 +80,31 @@
|
|||
"type": "string",
|
||||
"pattern": "^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$"
|
||||
},
|
||||
"tool_latency_bucket": {
|
||||
"enum": [
|
||||
"100ms_to_250ms",
|
||||
"10s_to_30s",
|
||||
"1s_to_2s",
|
||||
"250ms_to_500ms",
|
||||
"2s_to_5s",
|
||||
"500ms_to_1s",
|
||||
"5s_to_10s",
|
||||
"gte_30s",
|
||||
"lt_100ms",
|
||||
"unknown"
|
||||
]
|
||||
},
|
||||
"tool_retry_bucket": {
|
||||
"enum": [
|
||||
"0",
|
||||
"1",
|
||||
"2",
|
||||
"3_to_5",
|
||||
"6_to_10",
|
||||
"gte_11",
|
||||
"unknown"
|
||||
]
|
||||
},
|
||||
"model_call_counter": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
|
|
@ -118,6 +149,134 @@
|
|||
}
|
||||
}
|
||||
},
|
||||
"tool_call_counter": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": [
|
||||
"name",
|
||||
"type",
|
||||
"dimensions",
|
||||
"value"
|
||||
],
|
||||
"properties": {
|
||||
"name": {
|
||||
"const": "hermes.tool_call.count"
|
||||
},
|
||||
"type": {
|
||||
"const": "counter"
|
||||
},
|
||||
"dimensions": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": [
|
||||
"approval_outcome",
|
||||
"latency_bucket",
|
||||
"outcome",
|
||||
"retry_count_bucket",
|
||||
"tool_category"
|
||||
],
|
||||
"properties": {
|
||||
"approval_outcome": {
|
||||
"enum": [
|
||||
"approved",
|
||||
"denied",
|
||||
"not_required",
|
||||
"timed_out",
|
||||
"unknown"
|
||||
]
|
||||
},
|
||||
"latency_bucket": {
|
||||
"$ref": "#/$defs/tool_latency_bucket"
|
||||
},
|
||||
"outcome": {
|
||||
"enum": [
|
||||
"blocked",
|
||||
"cancelled",
|
||||
"failed",
|
||||
"success",
|
||||
"timed_out",
|
||||
"unknown"
|
||||
]
|
||||
},
|
||||
"retry_count_bucket": {
|
||||
"$ref": "#/$defs/tool_retry_bucket"
|
||||
},
|
||||
"tool_category": {
|
||||
"enum": [
|
||||
"browser",
|
||||
"code_execution",
|
||||
"communication",
|
||||
"computer_use",
|
||||
"delegation",
|
||||
"file",
|
||||
"home_automation",
|
||||
"mcp",
|
||||
"media",
|
||||
"memory",
|
||||
"other",
|
||||
"planning",
|
||||
"project",
|
||||
"scheduler",
|
||||
"skill",
|
||||
"terminal",
|
||||
"unknown",
|
||||
"web"
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
"value": {
|
||||
"type": "integer",
|
||||
"minimum": 1
|
||||
}
|
||||
}
|
||||
},
|
||||
"tool_approval_counter": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": [
|
||||
"name",
|
||||
"type",
|
||||
"dimensions",
|
||||
"value"
|
||||
],
|
||||
"properties": {
|
||||
"name": {
|
||||
"const": "hermes.tool_approval.count"
|
||||
},
|
||||
"type": {
|
||||
"const": "counter"
|
||||
},
|
||||
"dimensions": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": [
|
||||
"attribution",
|
||||
"outcome"
|
||||
],
|
||||
"properties": {
|
||||
"attribution": {
|
||||
"enum": [
|
||||
"tool_call",
|
||||
"unattributed"
|
||||
]
|
||||
},
|
||||
"outcome": {
|
||||
"enum": [
|
||||
"approved",
|
||||
"denied",
|
||||
"timed_out",
|
||||
"unknown"
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
"value": {
|
||||
"type": "integer",
|
||||
"minimum": 1
|
||||
}
|
||||
}
|
||||
},
|
||||
"task_started_counter": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@
|
|||
|
||||
from __future__ import annotations
|
||||
|
||||
from math import isfinite
|
||||
from typing import Any
|
||||
|
||||
from agent.relay_runtime import RUNTIME_INSTANCE_KEY
|
||||
|
|
@ -11,10 +12,14 @@ SCHEMA_VERSION = "hermes.metrics.event.v1"
|
|||
MODEL_CALL_SCOPE = "hermes.model_call"
|
||||
MODEL_CALL_PROFILE_MODEL = "unknown"
|
||||
TASK_SCOPE = "hermes.task_run"
|
||||
TOOL_CALL_SCOPE = "hermes.tool_call"
|
||||
TOOL_APPROVAL_MARK = "hermes.tool_approval"
|
||||
SUBSCRIBER_NAME = "hermes.nemo_relay.shared_metrics"
|
||||
MODEL_CALL_METRIC = "hermes.model_call.count"
|
||||
TASK_STARTED_METRIC = "hermes.task_run.started"
|
||||
TASK_FINISHED_METRIC = "hermes.task_run.finished"
|
||||
TOOL_CALL_METRIC = "hermes.tool_call.count"
|
||||
TOOL_APPROVAL_METRIC = "hermes.tool_approval.count"
|
||||
MODEL_IDENTIFIER_MAX_LENGTH = 256
|
||||
PROVIDER_IDENTIFIER_MAX_LENGTH = 64
|
||||
_METRIC_IDENTIFIER_CHARACTERS = frozenset(
|
||||
|
|
@ -89,6 +94,72 @@ COUNT_BUCKETS: frozenset[str] = frozenset({
|
|||
"6_to_10",
|
||||
"gte_11",
|
||||
})
|
||||
TOOL_CATEGORIES: frozenset[str] = frozenset({
|
||||
"browser",
|
||||
"code_execution",
|
||||
"communication",
|
||||
"computer_use",
|
||||
"delegation",
|
||||
"file",
|
||||
"home_automation",
|
||||
"mcp",
|
||||
"media",
|
||||
"memory",
|
||||
"other",
|
||||
"planning",
|
||||
"project",
|
||||
"scheduler",
|
||||
"skill",
|
||||
"terminal",
|
||||
"unknown",
|
||||
"web",
|
||||
})
|
||||
TOOL_OUTCOMES: frozenset[str] = frozenset({
|
||||
"blocked",
|
||||
"cancelled",
|
||||
"failed",
|
||||
"success",
|
||||
"timed_out",
|
||||
"unknown",
|
||||
})
|
||||
TOOL_APPROVAL_OUTCOMES: frozenset[str] = frozenset({
|
||||
"approved",
|
||||
"denied",
|
||||
"not_required",
|
||||
"timed_out",
|
||||
"unknown",
|
||||
})
|
||||
TOOL_APPROVAL_ATTRIBUTIONS: frozenset[str] = frozenset({
|
||||
"tool_call",
|
||||
"unattributed",
|
||||
})
|
||||
TOOL_LATENCY_BUCKETS: frozenset[str] = frozenset({
|
||||
"100ms_to_250ms",
|
||||
"10s_to_30s",
|
||||
"1s_to_2s",
|
||||
"250ms_to_500ms",
|
||||
"2s_to_5s",
|
||||
"500ms_to_1s",
|
||||
"5s_to_10s",
|
||||
"gte_30s",
|
||||
"lt_100ms",
|
||||
"unknown",
|
||||
})
|
||||
TOOL_RETRY_BUCKETS: frozenset[str] = COUNT_BUCKETS | frozenset({"unknown"})
|
||||
|
||||
_TOOL_NAMES_BY_CATEGORY: dict[str, frozenset[str]] = {
|
||||
"code_execution": frozenset({"execute_code"}),
|
||||
"communication": frozenset({"discord", "email", "meet"}),
|
||||
"computer_use": frozenset({"computer_use"}),
|
||||
"delegation": frozenset({"delegate_task"}),
|
||||
"file": frozenset({"patch", "read_file", "search_files", "write_file"}),
|
||||
"memory": frozenset({"memory", "session_search"}),
|
||||
"planning": frozenset({"clarify", "todo"}),
|
||||
"scheduler": frozenset({"cronjob"}),
|
||||
"skill": frozenset({"skill_manage", "skill_view", "skills_list"}),
|
||||
"terminal": frozenset({"close_terminal", "process", "read_terminal", "terminal"}),
|
||||
"web": frozenset({"web_extract", "web_search", "x_search"}),
|
||||
}
|
||||
|
||||
_COUNTER_DIMENSION_VALUES: dict[str, dict[str, frozenset[str]]] = {
|
||||
TASK_STARTED_METRIC: {
|
||||
|
|
@ -106,6 +177,17 @@ _COUNTER_DIMENSION_VALUES: dict[str, dict[str, frozenset[str]]] = {
|
|||
"termination": TASK_TERMINATIONS,
|
||||
"tool_call_count_bucket": COUNT_BUCKETS,
|
||||
},
|
||||
TOOL_CALL_METRIC: {
|
||||
"approval_outcome": TOOL_APPROVAL_OUTCOMES,
|
||||
"latency_bucket": TOOL_LATENCY_BUCKETS,
|
||||
"outcome": TOOL_OUTCOMES,
|
||||
"retry_count_bucket": TOOL_RETRY_BUCKETS,
|
||||
"tool_category": TOOL_CATEGORIES,
|
||||
},
|
||||
TOOL_APPROVAL_METRIC: {
|
||||
"attribution": TOOL_APPROVAL_ATTRIBUTIONS,
|
||||
"outcome": TOOL_APPROVAL_OUTCOMES - {"not_required"},
|
||||
},
|
||||
}
|
||||
COUNTER_METRICS: frozenset[str] = frozenset(
|
||||
{*_COUNTER_DIMENSION_VALUES, MODEL_CALL_METRIC}
|
||||
|
|
@ -135,21 +217,24 @@ def counter_dimensions_are_valid(
|
|||
if contract is None or set(dimensions) != set(contract):
|
||||
return False
|
||||
return all(
|
||||
isinstance(dimensions[field], str)
|
||||
and dimensions[field] in allowed_values
|
||||
isinstance(dimensions[field], str) and dimensions[field] in allowed_values
|
||||
for field, allowed_values in contract.items()
|
||||
)
|
||||
|
||||
|
||||
def model_call_dimensions(event: Any) -> dict[str, str] | None:
|
||||
"""Return package dimensions for one valid primary model-call end event."""
|
||||
def _event_metadata_is_valid(event: Any) -> bool:
|
||||
metadata = getattr(event, "metadata", None)
|
||||
if not isinstance(metadata, dict) or metadata.get(SCHEMA_KEY) != SCHEMA_VERSION:
|
||||
return None
|
||||
return False
|
||||
relay_metadata = set(metadata) - {SCHEMA_KEY, RUNTIME_INSTANCE_KEY}
|
||||
if relay_metadata - {"otel.status_code"} or metadata.get(
|
||||
return not relay_metadata - {"otel.status_code"} and metadata.get(
|
||||
"otel.status_code", "OK"
|
||||
) not in {"OK", "ERROR"}:
|
||||
) in {"OK", "ERROR"}
|
||||
|
||||
|
||||
def model_call_dimensions(event: Any) -> dict[str, str] | None:
|
||||
"""Return package dimensions for one valid primary model-call end event."""
|
||||
if not _event_metadata_is_valid(event):
|
||||
return None
|
||||
if (
|
||||
str(getattr(event, "kind", "") or "") != "scope"
|
||||
|
|
@ -179,13 +264,7 @@ def model_call_dimensions(event: Any) -> dict[str, str] | None:
|
|||
|
||||
def task_counter(event: Any) -> tuple[str, dict[str, str]] | None:
|
||||
"""Return one validated task counter from a task scope event."""
|
||||
metadata = getattr(event, "metadata", None)
|
||||
if not isinstance(metadata, dict) or metadata.get(SCHEMA_KEY) != SCHEMA_VERSION:
|
||||
return None
|
||||
relay_metadata = set(metadata) - {SCHEMA_KEY, RUNTIME_INSTANCE_KEY}
|
||||
if relay_metadata - {"otel.status_code"} or metadata.get(
|
||||
"otel.status_code", "OK"
|
||||
) not in {"OK", "ERROR"}:
|
||||
if not _event_metadata_is_valid(event):
|
||||
return None
|
||||
if (
|
||||
str(getattr(event, "kind", "") or "") != "scope"
|
||||
|
|
@ -233,6 +312,56 @@ def task_counter(event: Any) -> tuple[str, dict[str, str]] | None:
|
|||
return TASK_FINISHED_METRIC, dimensions
|
||||
|
||||
|
||||
def tool_call_dimensions(event: Any) -> dict[str, str] | None:
|
||||
"""Return package dimensions for one allowlisted tool lifecycle end event."""
|
||||
if not _event_metadata_is_valid(event):
|
||||
return None
|
||||
if (
|
||||
str(getattr(event, "kind", "") or "") != "scope"
|
||||
or str(getattr(event, "category", "") or "") != "tool"
|
||||
or str(getattr(event, "name", "") or "") != TOOL_CALL_SCOPE
|
||||
or str(getattr(event, "scope_category", "") or "") != "end"
|
||||
or getattr(event, "category_profile", None) != {}
|
||||
):
|
||||
return None
|
||||
data = getattr(event, "data", None)
|
||||
expected_fields = {
|
||||
"approval_outcome",
|
||||
"latency_bucket",
|
||||
"outcome",
|
||||
"retry_count_bucket",
|
||||
"tool_category",
|
||||
}
|
||||
if not isinstance(data, dict) or set(data) != expected_fields:
|
||||
return None
|
||||
dimensions = {field: data.get(field) for field in sorted(expected_fields)}
|
||||
if not counter_dimensions_are_valid(TOOL_CALL_METRIC, dimensions):
|
||||
return None
|
||||
return dimensions
|
||||
|
||||
|
||||
def tool_approval_counter(event: Any) -> tuple[str, dict[str, str]] | None:
|
||||
"""Return one validated approval counter from a safe Relay mark event."""
|
||||
if not _event_metadata_is_valid(event):
|
||||
return None
|
||||
if (
|
||||
str(getattr(event, "kind", "") or "") != "mark"
|
||||
or str(getattr(event, "name", "") or "") != TOOL_APPROVAL_MARK
|
||||
or getattr(event, "category", None) is not None
|
||||
or getattr(event, "scope_category", None) is not None
|
||||
or getattr(event, "category_profile", None) is not None
|
||||
):
|
||||
return None
|
||||
data = getattr(event, "data", None)
|
||||
expected_fields = {"attribution", "outcome"}
|
||||
if not isinstance(data, dict) or set(data) != expected_fields:
|
||||
return None
|
||||
dimensions = {field: data.get(field) for field in sorted(expected_fields)}
|
||||
if not counter_dimensions_are_valid(TOOL_APPROVAL_METRIC, dimensions):
|
||||
return None
|
||||
return TOOL_APPROVAL_METRIC, dimensions
|
||||
|
||||
|
||||
def execution_surface(kwargs: dict[str, Any]) -> str:
|
||||
"""Normalize the safe session surface carried by the parent Relay scope."""
|
||||
value = (
|
||||
|
|
@ -361,6 +490,131 @@ def count_bucket(count: int) -> str:
|
|||
return "gte_11"
|
||||
|
||||
|
||||
def tool_category(kwargs: dict[str, Any]) -> str:
|
||||
"""Map a raw Hermes tool name to a stable low-cardinality category."""
|
||||
name = str(kwargs.get("tool_name") or "").strip().lower()
|
||||
if not name:
|
||||
return "unknown"
|
||||
if name.startswith(("mcp.", "mcp_", "mcp__")):
|
||||
return "mcp"
|
||||
for category, names in _TOOL_NAMES_BY_CATEGORY.items():
|
||||
if name in names:
|
||||
return category
|
||||
if name.startswith("browser_"):
|
||||
return "browser"
|
||||
if name.startswith(("vision_", "image_", "video_", "text_to_speech")):
|
||||
return "media"
|
||||
if name.startswith("ha_"):
|
||||
return "home_automation"
|
||||
if name.startswith("kanban_"):
|
||||
return "planning"
|
||||
if name.startswith("project_"):
|
||||
return "project"
|
||||
if name.startswith(("discord_", "email_", "feishu_", "slack_", "sms_", "yb_")):
|
||||
return "communication"
|
||||
return "other"
|
||||
|
||||
|
||||
def tool_outcome(kwargs: dict[str, Any]) -> str:
|
||||
"""Normalize the terminal Hermes tool status without inspecting its result."""
|
||||
status = str(kwargs.get("status") or "").strip().lower()
|
||||
return {
|
||||
"blocked": "blocked",
|
||||
"cancelled": "cancelled",
|
||||
"error": "failed",
|
||||
"failed": "failed",
|
||||
"ok": "success",
|
||||
"success": "success",
|
||||
"timed_out": "timed_out",
|
||||
"timeout": "timed_out",
|
||||
}.get(status, "unknown")
|
||||
|
||||
|
||||
def tool_approval_outcome(kwargs: dict[str, Any]) -> str:
|
||||
"""Normalize a terminal approval choice to a bounded outcome."""
|
||||
choice = str(kwargs.get("choice") or "").strip().lower()
|
||||
if choice in {"always", "approve", "approved", "once", "session", "smart_approve"}:
|
||||
return "approved"
|
||||
if choice in {"deny", "denied", "smart_deny"}:
|
||||
return "denied"
|
||||
if choice in {"timed_out", "timeout"}:
|
||||
return "timed_out"
|
||||
return "unknown"
|
||||
|
||||
|
||||
def tool_terminal_fields(
|
||||
kwargs: dict[str, Any],
|
||||
*,
|
||||
category: str | None = None,
|
||||
approval_outcome: str = "not_required",
|
||||
fallback_duration_ms: int | None = None,
|
||||
) -> dict[str, str]:
|
||||
"""Build one bounded tool-call terminal payload."""
|
||||
return {
|
||||
"approval_outcome": (
|
||||
approval_outcome
|
||||
if approval_outcome in TOOL_APPROVAL_OUTCOMES
|
||||
else "unknown"
|
||||
),
|
||||
"latency_bucket": tool_latency_bucket(
|
||||
kwargs.get("duration_ms"),
|
||||
fallback_duration_ms=fallback_duration_ms,
|
||||
),
|
||||
"outcome": tool_outcome(kwargs),
|
||||
"retry_count_bucket": tool_retry_bucket(kwargs.get("retry_count")),
|
||||
"tool_category": (
|
||||
category if category in TOOL_CATEGORIES else tool_category(kwargs)
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
def tool_latency_bucket(
|
||||
value: Any,
|
||||
*,
|
||||
fallback_duration_ms: int | None = None,
|
||||
) -> str:
|
||||
"""Bucket a tool duration reported in milliseconds."""
|
||||
duration_ms = _non_negative_number(value)
|
||||
if duration_ms is None:
|
||||
duration_ms = _non_negative_number(fallback_duration_ms)
|
||||
if duration_ms is None:
|
||||
return "unknown"
|
||||
if duration_ms < 100:
|
||||
return "lt_100ms"
|
||||
if duration_ms < 250:
|
||||
return "100ms_to_250ms"
|
||||
if duration_ms < 500:
|
||||
return "250ms_to_500ms"
|
||||
if duration_ms < 1_000:
|
||||
return "500ms_to_1s"
|
||||
if duration_ms < 2_000:
|
||||
return "1s_to_2s"
|
||||
if duration_ms < 5_000:
|
||||
return "2s_to_5s"
|
||||
if duration_ms < 10_000:
|
||||
return "5s_to_10s"
|
||||
if duration_ms < 30_000:
|
||||
return "10s_to_30s"
|
||||
return "gte_30s"
|
||||
|
||||
|
||||
def tool_retry_bucket(value: Any) -> str:
|
||||
"""Bucket only explicit tool retries; missing relationships stay unknown."""
|
||||
if isinstance(value, bool) or not isinstance(value, int) or value < 0:
|
||||
return "unknown"
|
||||
return count_bucket(value)
|
||||
|
||||
|
||||
def _non_negative_number(value: Any) -> float | None:
|
||||
if isinstance(value, bool) or not isinstance(value, (int, float)):
|
||||
return None
|
||||
try:
|
||||
number = float(value)
|
||||
except (OverflowError, TypeError, ValueError):
|
||||
return None
|
||||
return number if isfinite(number) and number >= 0 else None
|
||||
|
||||
|
||||
def model_call_fields(kwargs: dict[str, Any]) -> dict[str, str]:
|
||||
"""Return the terminal model identity and provider route known to Hermes."""
|
||||
model = _metric_identifier(
|
||||
|
|
|
|||
|
|
@ -9,7 +9,14 @@ from typing import Any
|
|||
from agent.relay_runtime import RUNTIME_INSTANCE_KEY
|
||||
|
||||
from .shared_metrics import SharedMetricsStore
|
||||
from .shared_metrics_contract import MODEL_CALL_METRIC, model_call_dimensions, task_counter
|
||||
from .shared_metrics_contract import (
|
||||
MODEL_CALL_METRIC,
|
||||
TOOL_CALL_METRIC,
|
||||
model_call_dimensions,
|
||||
task_counter,
|
||||
tool_approval_counter,
|
||||
tool_call_dimensions,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
|
@ -46,10 +53,13 @@ class SharedMetricsSubscriber:
|
|||
dimensions = model_call_dimensions(event)
|
||||
metric_name = MODEL_CALL_METRIC
|
||||
if dimensions is None:
|
||||
task_metric = task_counter(event)
|
||||
if task_metric is None:
|
||||
dimensions = tool_call_dimensions(event)
|
||||
metric_name = TOOL_CALL_METRIC
|
||||
if dimensions is None:
|
||||
metric = task_counter(event) or tool_approval_counter(event)
|
||||
if metric is None:
|
||||
return
|
||||
metric_name, dimensions = task_metric
|
||||
metric_name, dimensions = metric
|
||||
with self._lock:
|
||||
if not self._active:
|
||||
return
|
||||
|
|
|
|||
|
|
@ -2161,7 +2161,9 @@ def _get_pre_tool_call_directive_details(
|
|||
message=fmt.format(tool_name=tool_name),
|
||||
)
|
||||
|
||||
hook_results = invoke_hook(
|
||||
from hermes_cli.lifecycle import invoke_hook as invoke_lifecycle_hook
|
||||
|
||||
hook_results = invoke_lifecycle_hook(
|
||||
"pre_tool_call",
|
||||
tool_name=tool_name,
|
||||
args=args if isinstance(args, dict) else {},
|
||||
|
|
@ -2277,12 +2279,32 @@ def resolve_pre_tool_block(
|
|||
return details.message
|
||||
if details.action == "approve":
|
||||
try:
|
||||
from tools.approval import request_tool_approval
|
||||
result = request_tool_approval(
|
||||
tool_name,
|
||||
details.message or "",
|
||||
rule_key=details.rule_key or tool_name,
|
||||
from tools.approval import (
|
||||
request_tool_approval,
|
||||
reset_current_observability_context,
|
||||
set_current_observability_context,
|
||||
)
|
||||
|
||||
approval_tokens = None
|
||||
try:
|
||||
approval_tokens = set_current_observability_context(
|
||||
turn_id=turn_id,
|
||||
tool_call_id=tool_call_id,
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
result = request_tool_approval(
|
||||
tool_name,
|
||||
details.message or "",
|
||||
rule_key=details.rule_key or tool_name,
|
||||
)
|
||||
finally:
|
||||
if approval_tokens is not None:
|
||||
try:
|
||||
reset_current_observability_context(approval_tokens)
|
||||
except Exception:
|
||||
pass
|
||||
except Exception:
|
||||
# Fail-closed: if the gate itself errors, block rather than
|
||||
# silently execute an action a plugin flagged for approval.
|
||||
|
|
|
|||
124
model_tools.py
124
model_tools.py
|
|
@ -1020,13 +1020,24 @@ def _coerce_boolean(value: str):
|
|||
return value
|
||||
|
||||
|
||||
def _tool_result_observer_fields(result: Any) -> tuple[str, Optional[str], Optional[str]]:
|
||||
def _tool_result_observer_fields(
|
||||
tool_name: str,
|
||||
result: Any,
|
||||
) -> tuple[str, Optional[str], Optional[str]]:
|
||||
try:
|
||||
parsed_result = json.loads(result) if isinstance(result, str) else result
|
||||
if isinstance(parsed_result, dict) and parsed_result.get("error"):
|
||||
return "error", "tool_error", str(parsed_result.get("error"))
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
from agent.display import _detect_tool_failure
|
||||
|
||||
failed, suffix = _detect_tool_failure(tool_name, result)
|
||||
if failed:
|
||||
return "error", "tool_error", suffix.strip().strip("[]") or None
|
||||
except Exception:
|
||||
pass
|
||||
return "ok", None, None
|
||||
|
||||
|
||||
|
|
@ -1060,7 +1071,10 @@ def _emit_post_tool_call_hook(
|
|||
if not has_hook("post_tool_call"):
|
||||
return
|
||||
if status is None:
|
||||
status, error_type, error_message = _tool_result_observer_fields(result)
|
||||
status, error_type, error_message = _tool_result_observer_fields(
|
||||
function_name,
|
||||
result,
|
||||
)
|
||||
invoke_hook(
|
||||
"post_tool_call",
|
||||
tool_name=function_name,
|
||||
|
|
@ -1133,6 +1147,23 @@ def handle_function_call(
|
|||
# inline. tool_call is unwrapped to the underlying tool so that every
|
||||
# downstream hook (pre/post, edit approval, guardrails) sees the real
|
||||
# tool name, not the bridge.
|
||||
_dispatch_start = time.monotonic()
|
||||
|
||||
def _return_bridge_result(result: Any) -> Any:
|
||||
_emit_post_tool_call_hook(
|
||||
function_name=function_name,
|
||||
function_args=function_args,
|
||||
result=result,
|
||||
task_id=task_id,
|
||||
session_id=session_id,
|
||||
tool_call_id=tool_call_id,
|
||||
turn_id=turn_id,
|
||||
api_request_id=api_request_id,
|
||||
duration_ms=int((time.monotonic() - _dispatch_start) * 1000),
|
||||
middleware_trace=list(_tool_middleware_trace),
|
||||
)
|
||||
return result
|
||||
|
||||
_ts_mod = None
|
||||
try:
|
||||
from tools import tool_search as _ts_mod # noqa: F401
|
||||
|
|
@ -1162,15 +1193,25 @@ def handle_function_call(
|
|||
except Exception:
|
||||
current_defs = []
|
||||
if function_name == _ts_mod.TOOL_SEARCH_NAME:
|
||||
return _ts_mod.dispatch_tool_search(function_args or {},
|
||||
current_tool_defs=current_defs)
|
||||
return _return_bridge_result(
|
||||
_ts_mod.dispatch_tool_search(
|
||||
function_args or {},
|
||||
current_tool_defs=current_defs,
|
||||
)
|
||||
)
|
||||
if function_name == _ts_mod.TOOL_DESCRIBE_NAME:
|
||||
return _ts_mod.dispatch_tool_describe(function_args or {},
|
||||
current_tool_defs=current_defs)
|
||||
return _return_bridge_result(
|
||||
_ts_mod.dispatch_tool_describe(
|
||||
function_args or {},
|
||||
current_tool_defs=current_defs,
|
||||
)
|
||||
)
|
||||
if function_name == _ts_mod.TOOL_CALL_NAME:
|
||||
underlying_name, underlying_args, err = _ts_mod.resolve_underlying_call(function_args or {})
|
||||
if err or not underlying_name:
|
||||
return tool_error(err or "tool_call could not be resolved")
|
||||
return _return_bridge_result(
|
||||
tool_error(err or "tool_call could not be resolved")
|
||||
)
|
||||
# Defense in depth: the underlying tool MUST be in the session's
|
||||
# scoped deferrable catalog. resolve_underlying_call() only checks
|
||||
# that the name is deferrable in the global registry; this gate
|
||||
|
|
@ -1179,16 +1220,18 @@ def handle_function_call(
|
|||
# the bridge even if the catalog scoping above regressed.
|
||||
_scoped_deferrable = _ts_mod.scoped_deferrable_names(current_defs)
|
||||
if underlying_name not in _scoped_deferrable:
|
||||
return tool_error(
|
||||
f"'{underlying_name}' is not available in this session. "
|
||||
"Use tool_search to find tools you can call."
|
||||
return _return_bridge_result(
|
||||
tool_error(
|
||||
f"'{underlying_name}' is not available in this session. "
|
||||
"Use tool_search to find tools you can call."
|
||||
)
|
||||
)
|
||||
# Probe-validate against the deferred tool's schema (ironclaw#5149):
|
||||
# a blind call missing required arguments returns the parameter
|
||||
# schema instead of dispatching into an opaque downstream failure.
|
||||
_probe_err = _ts_mod.validate_deferred_call_args(underlying_name, underlying_args)
|
||||
if _probe_err is not None:
|
||||
return _probe_err
|
||||
return _return_bridge_result(_probe_err)
|
||||
# Recurse with the underlying tool. All hooks fire against the
|
||||
# real tool name. The bridge is invisible to hooks by design.
|
||||
return handle_function_call(
|
||||
|
|
@ -1197,6 +1240,8 @@ def handle_function_call(
|
|||
task_id=task_id,
|
||||
tool_call_id=tool_call_id,
|
||||
session_id=session_id,
|
||||
turn_id=turn_id,
|
||||
api_request_id=api_request_id,
|
||||
user_task=user_task,
|
||||
enabled_tools=enabled_tools,
|
||||
skip_pre_tool_call_hook=skip_pre_tool_call_hook,
|
||||
|
|
@ -1285,11 +1330,38 @@ def handle_function_call(
|
|||
|
||||
edit_block_message = maybe_require_edit_approval(function_name, function_args)
|
||||
if edit_block_message is not None:
|
||||
_emit_post_tool_call_hook(
|
||||
function_name=function_name,
|
||||
function_args=function_args,
|
||||
result=edit_block_message,
|
||||
task_id=task_id,
|
||||
session_id=session_id,
|
||||
tool_call_id=tool_call_id,
|
||||
turn_id=turn_id,
|
||||
api_request_id=api_request_id,
|
||||
status="blocked",
|
||||
error_type="edit_approval_denied",
|
||||
middleware_trace=list(_tool_middleware_trace),
|
||||
)
|
||||
return edit_block_message
|
||||
except Exception as _edit_approval_err:
|
||||
logger.debug("ACP edit approval guard error: %s", _edit_approval_err)
|
||||
if function_name in {"write_file", "patch"}:
|
||||
return tool_error("Edit approval denied: approval guard failed")
|
||||
result = tool_error("Edit approval denied: approval guard failed")
|
||||
_emit_post_tool_call_hook(
|
||||
function_name=function_name,
|
||||
function_args=function_args,
|
||||
result=result,
|
||||
task_id=task_id,
|
||||
session_id=session_id,
|
||||
tool_call_id=tool_call_id,
|
||||
turn_id=turn_id,
|
||||
api_request_id=api_request_id,
|
||||
status="blocked",
|
||||
error_type="edit_approval_error",
|
||||
middleware_trace=list(_tool_middleware_trace),
|
||||
)
|
||||
return result
|
||||
|
||||
# Notify the read-loop tracker when a non-read/search tool runs,
|
||||
# so the *consecutive* counter resets (reads after other work are fine).
|
||||
|
|
@ -1388,7 +1460,10 @@ def handle_function_call(
|
|||
try:
|
||||
from hermes_cli.lifecycle import has_hook, invoke_hook
|
||||
if has_hook("transform_tool_result"):
|
||||
status, error_type, error_message = _tool_result_observer_fields(result)
|
||||
status, error_type, error_message = _tool_result_observer_fields(
|
||||
function_name,
|
||||
result,
|
||||
)
|
||||
hook_results = invoke_hook(
|
||||
"transform_tool_result",
|
||||
tool_name=function_name,
|
||||
|
|
@ -1416,7 +1491,28 @@ def handle_function_call(
|
|||
except Exception as e:
|
||||
error_msg = f"Error executing {function_name}: {str(e)}"
|
||||
logger.exception(error_msg)
|
||||
return tool_error(_sanitize_tool_error(error_msg))
|
||||
result = tool_error(_sanitize_tool_error(error_msg))
|
||||
duration_ms = (
|
||||
int((time.monotonic() - _dispatch_start) * 1000)
|
||||
if _dispatch_start is not None
|
||||
else 0
|
||||
)
|
||||
_emit_post_tool_call_hook(
|
||||
function_name=function_name,
|
||||
function_args=function_args,
|
||||
result=result,
|
||||
task_id=task_id,
|
||||
session_id=session_id,
|
||||
tool_call_id=tool_call_id,
|
||||
turn_id=turn_id,
|
||||
api_request_id=api_request_id,
|
||||
duration_ms=duration_ms,
|
||||
status="error",
|
||||
error_type=type(e).__name__,
|
||||
error_message=str(e),
|
||||
middleware_trace=list(_tool_middleware_trace),
|
||||
)
|
||||
return result
|
||||
|
||||
|
||||
# =============================================================================
|
||||
|
|
|
|||
|
|
@ -20,6 +20,9 @@ from typing import Any
|
|||
PROMPT_CANARY = "relay-smoke-sensitive-prompt"
|
||||
MODEL_CANARY = "gpt-relay-smoke-sensitive-model"
|
||||
RESPONSE_CANARY = "relay-smoke-sensitive-response"
|
||||
TOOL_CALL_CANARY = "relay-smoke-sensitive-tool-call"
|
||||
TOOL_RESULT_CANARY = "relay-smoke-sensitive-tool-result"
|
||||
TOOL_FILE = "relay-smoke-input.txt"
|
||||
|
||||
|
||||
def _resolve_hermes_executable(hermes_repo: Path) -> Path:
|
||||
|
|
@ -68,30 +71,51 @@ class _ModelHandler(BaseHTTPRequestHandler):
|
|||
length = int(self.headers.get("Content-Length", "0"))
|
||||
request = json.loads(self.rfile.read(length) or b"{}")
|
||||
type(self).requests.append(request)
|
||||
request_tool = not any(
|
||||
message.get("role") == "tool"
|
||||
for message in request.get("messages") or []
|
||||
if isinstance(message, dict)
|
||||
)
|
||||
if request.get("stream"):
|
||||
self._write_stream()
|
||||
self._write_stream(request_tool=request_tool)
|
||||
else:
|
||||
self._write_json({
|
||||
"id": "chatcmpl-relay-smoke",
|
||||
"object": "chat.completion",
|
||||
"created": int(time.time()),
|
||||
"model": MODEL_CANARY,
|
||||
"choices": [
|
||||
{
|
||||
"index": 0,
|
||||
"message": {
|
||||
"role": "assistant",
|
||||
"content": RESPONSE_CANARY,
|
||||
},
|
||||
"finish_reason": "stop",
|
||||
}
|
||||
],
|
||||
"usage": {
|
||||
"prompt_tokens": 10,
|
||||
"completion_tokens": 1,
|
||||
"total_tokens": 11,
|
||||
},
|
||||
})
|
||||
self._write_json(self._completion(request_tool=request_tool))
|
||||
|
||||
def _completion(self, *, request_tool: bool) -> dict[str, Any]:
|
||||
message: dict[str, Any] = {
|
||||
"role": "assistant",
|
||||
"content": "" if request_tool else RESPONSE_CANARY,
|
||||
}
|
||||
finish_reason = "tool_calls" if request_tool else "stop"
|
||||
if request_tool:
|
||||
message["tool_calls"] = [
|
||||
{
|
||||
"id": TOOL_CALL_CANARY,
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "read_file",
|
||||
"arguments": json.dumps({"path": TOOL_FILE}),
|
||||
},
|
||||
}
|
||||
]
|
||||
return {
|
||||
"id": "chatcmpl-relay-smoke",
|
||||
"object": "chat.completion",
|
||||
"created": int(time.time()),
|
||||
"model": MODEL_CANARY,
|
||||
"choices": [
|
||||
{
|
||||
"index": 0,
|
||||
"message": message,
|
||||
"finish_reason": finish_reason,
|
||||
}
|
||||
],
|
||||
"usage": {
|
||||
"prompt_tokens": 10,
|
||||
"completion_tokens": 1,
|
||||
"total_tokens": 11,
|
||||
},
|
||||
}
|
||||
|
||||
def log_message(self, format: str, *args: Any) -> None:
|
||||
return
|
||||
|
|
@ -106,9 +130,9 @@ class _ModelHandler(BaseHTTPRequestHandler):
|
|||
self.wfile.write(body)
|
||||
self.close_connection = True
|
||||
|
||||
def _write_stream(self) -> None:
|
||||
def _write_stream(self, *, request_tool: bool) -> None:
|
||||
now = int(time.time())
|
||||
chunks = [
|
||||
chunks: list[dict[str, Any]] = [
|
||||
{
|
||||
"id": "chatcmpl-relay-smoke",
|
||||
"object": "chat.completion.chunk",
|
||||
|
|
@ -119,18 +143,66 @@ class _ModelHandler(BaseHTTPRequestHandler):
|
|||
"index": 0,
|
||||
"delta": {
|
||||
"role": "assistant",
|
||||
"content": RESPONSE_CANARY,
|
||||
"content": "",
|
||||
},
|
||||
"finish_reason": None,
|
||||
}
|
||||
],
|
||||
},
|
||||
}
|
||||
]
|
||||
if request_tool:
|
||||
chunks.append({
|
||||
"id": "chatcmpl-relay-smoke",
|
||||
"object": "chat.completion.chunk",
|
||||
"created": now,
|
||||
"model": MODEL_CANARY,
|
||||
"choices": [
|
||||
{
|
||||
"index": 0,
|
||||
"delta": {
|
||||
"tool_calls": [
|
||||
{
|
||||
"index": 0,
|
||||
"id": TOOL_CALL_CANARY,
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "read_file",
|
||||
"arguments": json.dumps({"path": TOOL_FILE}),
|
||||
},
|
||||
}
|
||||
]
|
||||
},
|
||||
"finish_reason": None,
|
||||
}
|
||||
],
|
||||
})
|
||||
else:
|
||||
chunks.append({
|
||||
"id": "chatcmpl-relay-smoke",
|
||||
"object": "chat.completion.chunk",
|
||||
"created": now,
|
||||
"model": MODEL_CANARY,
|
||||
"choices": [
|
||||
{
|
||||
"index": 0,
|
||||
"delta": {"content": RESPONSE_CANARY},
|
||||
"finish_reason": None,
|
||||
}
|
||||
],
|
||||
})
|
||||
chunks.extend([
|
||||
{
|
||||
"id": "chatcmpl-relay-smoke",
|
||||
"object": "chat.completion.chunk",
|
||||
"created": now,
|
||||
"model": MODEL_CANARY,
|
||||
"choices": [{"index": 0, "delta": {}, "finish_reason": "stop"}],
|
||||
"choices": [
|
||||
{
|
||||
"index": 0,
|
||||
"delta": {},
|
||||
"finish_reason": "tool_calls" if request_tool else "stop",
|
||||
}
|
||||
],
|
||||
},
|
||||
{
|
||||
"id": "chatcmpl-relay-smoke",
|
||||
|
|
@ -144,7 +216,7 @@ class _ModelHandler(BaseHTTPRequestHandler):
|
|||
"total_tokens": 11,
|
||||
},
|
||||
},
|
||||
]
|
||||
])
|
||||
self.send_response(200)
|
||||
self.send_header("Content-Type", "text/event-stream")
|
||||
self.send_header("Cache-Control", "no-cache")
|
||||
|
|
@ -220,25 +292,29 @@ def _validate_store(database_path: Path) -> list[dict[str, Any]]:
|
|||
}
|
||||
for name, dimensions, value, packaged_value in rows
|
||||
]
|
||||
by_name = {counter["name"]: counter for counter in counters}
|
||||
by_name: dict[str, list[dict[str, Any]]] = {}
|
||||
for counter in counters:
|
||||
by_name.setdefault(counter["name"], []).append(counter)
|
||||
if set(by_name) != {
|
||||
"hermes.model_call.count",
|
||||
"hermes.task_run.finished",
|
||||
"hermes.task_run.started",
|
||||
"hermes.tool_call.count",
|
||||
}:
|
||||
raise AssertionError(
|
||||
f"Unexpected SQLite counters:\n{json.dumps(counters, indent=2)}"
|
||||
)
|
||||
[model] = by_name["hermes.model_call.count"]
|
||||
expected_model = {
|
||||
"name": "hermes.model_call.count",
|
||||
"dimensions": {
|
||||
"model": MODEL_CANARY,
|
||||
"provider": "custom",
|
||||
},
|
||||
"value": 1,
|
||||
"packaged_value": 1,
|
||||
"value": 2,
|
||||
"packaged_value": 2,
|
||||
}
|
||||
if by_name["hermes.model_call.count"] != expected_model:
|
||||
if model != expected_model:
|
||||
raise AssertionError(
|
||||
f"Unexpected model counter: {by_name['hermes.model_call.count']}"
|
||||
)
|
||||
|
|
@ -251,21 +327,21 @@ def _validate_store(database_path: Path) -> list[dict[str, Any]]:
|
|||
"value": 1,
|
||||
"packaged_value": 1,
|
||||
}
|
||||
if by_name["hermes.task_run.started"] != expected_start:
|
||||
if by_name["hermes.task_run.started"] != [expected_start]:
|
||||
raise AssertionError(
|
||||
f"Unexpected task start: {by_name['hermes.task_run.started']}"
|
||||
)
|
||||
terminal = by_name["hermes.task_run.finished"]
|
||||
[terminal] = by_name["hermes.task_run.finished"]
|
||||
expected_terminal_dimensions = {
|
||||
"duration_bucket": terminal["dimensions"].get("duration_bucket"),
|
||||
"end_reason": "completed",
|
||||
"entrypoint": "interactive",
|
||||
"execution_surface": "cli",
|
||||
"model_call_count_bucket": "1",
|
||||
"model_call_count_bucket": "2",
|
||||
"outcome": "success",
|
||||
"retry_count_bucket": "0",
|
||||
"termination": "none",
|
||||
"tool_call_count_bucket": "0",
|
||||
"tool_call_count_bucket": "1",
|
||||
}
|
||||
if (
|
||||
terminal["dimensions"] != expected_terminal_dimensions
|
||||
|
|
@ -273,6 +349,21 @@ def _validate_store(database_path: Path) -> list[dict[str, Any]]:
|
|||
or terminal["packaged_value"] != 1
|
||||
):
|
||||
raise AssertionError(f"Unexpected task terminal counter: {terminal}")
|
||||
[tool] = by_name["hermes.tool_call.count"]
|
||||
expected_tool_dimensions = {
|
||||
"approval_outcome": "not_required",
|
||||
"latency_bucket": tool["dimensions"].get("latency_bucket"),
|
||||
"outcome": "success",
|
||||
"retry_count_bucket": "unknown",
|
||||
"tool_category": "file",
|
||||
}
|
||||
if (
|
||||
tool["dimensions"] != expected_tool_dimensions
|
||||
or tool["dimensions"]["latency_bucket"] == "unknown"
|
||||
or tool["value"] != 1
|
||||
or tool["packaged_value"] != 1
|
||||
):
|
||||
raise AssertionError(f"Unexpected tool counter: {tool}")
|
||||
return counters
|
||||
|
||||
|
||||
|
|
@ -292,41 +383,62 @@ def _validate_package(outbox: Path, schema_path: Path) -> tuple[Path, dict[str,
|
|||
jsonschema.validate(package, schema)
|
||||
|
||||
serialized = json.dumps(package)
|
||||
for prohibited in (PROMPT_CANARY, RESPONSE_CANARY):
|
||||
for prohibited in (
|
||||
PROMPT_CANARY,
|
||||
RESPONSE_CANARY,
|
||||
TOOL_CALL_CANARY,
|
||||
TOOL_RESULT_CANARY,
|
||||
):
|
||||
if prohibited in serialized:
|
||||
raise AssertionError(
|
||||
f"Exported package leaked prohibited value: {prohibited!r}"
|
||||
)
|
||||
metrics = {metric["name"]: metric for metric in package.get("metrics", [])}
|
||||
metrics: dict[str, list[dict[str, Any]]] = {}
|
||||
for metric in package.get("metrics", []):
|
||||
metrics.setdefault(metric["name"], []).append(metric)
|
||||
if set(metrics) != {
|
||||
"hermes.model_call.count",
|
||||
"hermes.task_run.finished",
|
||||
"hermes.task_run.started",
|
||||
"hermes.tool_call.count",
|
||||
}:
|
||||
raise AssertionError(
|
||||
f"Unexpected package metrics:\n{json.dumps(package.get('metrics'), indent=2)}"
|
||||
)
|
||||
model_dimensions = metrics["hermes.model_call.count"]["dimensions"]
|
||||
if model_dimensions != {
|
||||
[model] = metrics["hermes.model_call.count"]
|
||||
if model["dimensions"] != {
|
||||
"model": MODEL_CANARY,
|
||||
"provider": "custom",
|
||||
}:
|
||||
} or model["value"] != 2:
|
||||
raise AssertionError(
|
||||
f"Unexpected model metric: {metrics['hermes.model_call.count']}"
|
||||
)
|
||||
terminal = metrics["hermes.task_run.finished"]
|
||||
[terminal] = metrics["hermes.task_run.finished"]
|
||||
if terminal["dimensions"] != {
|
||||
"duration_bucket": terminal["dimensions"].get("duration_bucket"),
|
||||
"end_reason": "completed",
|
||||
"entrypoint": "interactive",
|
||||
"execution_surface": "cli",
|
||||
"model_call_count_bucket": "1",
|
||||
"model_call_count_bucket": "2",
|
||||
"outcome": "success",
|
||||
"retry_count_bucket": "0",
|
||||
"termination": "none",
|
||||
"tool_call_count_bucket": "0",
|
||||
"tool_call_count_bucket": "1",
|
||||
}:
|
||||
raise AssertionError(f"Unexpected task terminal metric: {terminal}")
|
||||
[tool] = metrics["hermes.tool_call.count"]
|
||||
if (
|
||||
tool["dimensions"]
|
||||
!= {
|
||||
"approval_outcome": "not_required",
|
||||
"latency_bucket": tool["dimensions"].get("latency_bucket"),
|
||||
"outcome": "success",
|
||||
"retry_count_bucket": "unknown",
|
||||
"tool_category": "file",
|
||||
}
|
||||
or tool["dimensions"]["latency_bucket"] == "unknown"
|
||||
):
|
||||
raise AssertionError(f"Unexpected tool metric: {tool}")
|
||||
return package_path, package
|
||||
|
||||
|
||||
|
|
@ -353,6 +465,7 @@ def main() -> int:
|
|||
home = root / "hermes-home"
|
||||
workdir = root / "workspace"
|
||||
workdir.mkdir()
|
||||
(workdir / TOOL_FILE).write_text(TOOL_RESULT_CANARY, encoding="utf-8")
|
||||
home.mkdir()
|
||||
(home / ".no-bundled-skills").touch()
|
||||
|
||||
|
|
@ -382,7 +495,7 @@ def main() -> int:
|
|||
"--quiet",
|
||||
"--ignore-rules",
|
||||
"--toolsets",
|
||||
"search",
|
||||
"file",
|
||||
"--max-turns",
|
||||
"2",
|
||||
],
|
||||
|
|
@ -404,13 +517,18 @@ def main() -> int:
|
|||
f"Hermes exited with {result.returncode}\n"
|
||||
f"stdout:\n{result.stdout}\nstderr:\n{result.stderr}"
|
||||
)
|
||||
if not _ModelHandler.requests:
|
||||
raise AssertionError("Hermes did not call the local model endpoint")
|
||||
if len(_ModelHandler.requests) != 2:
|
||||
raise AssertionError(
|
||||
f"Expected two model requests, got {len(_ModelHandler.requests)}"
|
||||
)
|
||||
request = _ModelHandler.requests[0]
|
||||
if request.get("model") != MODEL_CANARY:
|
||||
raise AssertionError(f"Unexpected model request: {request.get('model')!r}")
|
||||
if PROMPT_CANARY not in json.dumps(request.get("messages", [])):
|
||||
raise AssertionError("Hermes model request did not contain the prompt canary")
|
||||
follow_up = json.dumps(_ModelHandler.requests[1].get("messages", []))
|
||||
if TOOL_CALL_CANARY not in follow_up or TOOL_RESULT_CANARY not in follow_up:
|
||||
raise AssertionError("Hermes did not return the tool result to the model")
|
||||
if RESPONSE_CANARY not in result.stdout:
|
||||
raise AssertionError("Hermes did not print the mock model response")
|
||||
|
||||
|
|
|
|||
|
|
@ -888,6 +888,44 @@ class TestPreToolCallBlocking:
|
|||
class TestPreToolCallDirective:
|
||||
"""Tests for the extended (block | approve) directive helper."""
|
||||
|
||||
def test_first_party_observer_receives_pre_tool_call(self, monkeypatch):
|
||||
from hermes_cli import observability
|
||||
from hermes_cli.plugins import get_pre_tool_call_directive
|
||||
|
||||
observed = []
|
||||
monkeypatch.setattr(
|
||||
observability,
|
||||
"observe_lifecycle",
|
||||
lambda hook_name, **kwargs: observed.append((hook_name, kwargs)),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"hermes_cli.plugins.invoke_hook",
|
||||
lambda hook_name, **kwargs: [],
|
||||
)
|
||||
|
||||
assert get_pre_tool_call_directive(
|
||||
"write_file",
|
||||
{"path": "README.md"},
|
||||
task_id="task-1",
|
||||
session_id="session-1",
|
||||
tool_call_id="call-1",
|
||||
) == (None, None)
|
||||
assert observed == [
|
||||
(
|
||||
"pre_tool_call",
|
||||
{
|
||||
"tool_name": "write_file",
|
||||
"args": {"path": "README.md"},
|
||||
"task_id": "task-1",
|
||||
"session_id": "session-1",
|
||||
"tool_call_id": "call-1",
|
||||
"turn_id": "",
|
||||
"api_request_id": "",
|
||||
"middleware_trace": [],
|
||||
},
|
||||
)
|
||||
]
|
||||
|
||||
def test_approve_directive_returned(self, monkeypatch):
|
||||
from hermes_cli.plugins import get_pre_tool_call_directive
|
||||
monkeypatch.setattr(
|
||||
|
|
@ -981,6 +1019,33 @@ class TestResolvePreToolBlock:
|
|||
)
|
||||
assert resolve_pre_tool_block("write_file", {}) is None
|
||||
|
||||
def test_approve_gate_receives_tool_observability_context(self, monkeypatch):
|
||||
from hermes_cli.plugins import resolve_pre_tool_block
|
||||
from tools import approval
|
||||
|
||||
seen = {}
|
||||
monkeypatch.setattr(
|
||||
"hermes_cli.plugins.invoke_hook",
|
||||
lambda hook_name, **kwargs: [
|
||||
{"action": "approve", "message": "why"}
|
||||
],
|
||||
)
|
||||
|
||||
def _approve(*args, **kwargs):
|
||||
seen["turn_id"] = approval._approval_turn_id.get()
|
||||
seen["tool_call_id"] = approval._approval_tool_call_id.get()
|
||||
return {"approved": True, "message": None}
|
||||
|
||||
monkeypatch.setattr("tools.approval.request_tool_approval", _approve)
|
||||
|
||||
assert resolve_pre_tool_block(
|
||||
"write_file",
|
||||
{},
|
||||
turn_id="turn-1",
|
||||
tool_call_id="call-1",
|
||||
) is None
|
||||
assert seen == {"turn_id": "turn-1", "tool_call_id": "call-1"}
|
||||
|
||||
def test_approve_passes_plugin_rule_key_to_gate(self, monkeypatch):
|
||||
from hermes_cli.plugins import resolve_pre_tool_block
|
||||
|
||||
|
|
|
|||
|
|
@ -31,6 +31,12 @@ from hermes_cli.observability.shared_metrics_contract import (
|
|||
TASK_ENTRYPOINTS,
|
||||
TASK_OUTCOMES,
|
||||
TASK_TERMINATIONS,
|
||||
TOOL_APPROVAL_ATTRIBUTIONS,
|
||||
TOOL_APPROVAL_OUTCOMES,
|
||||
TOOL_CATEGORIES,
|
||||
TOOL_LATENCY_BUCKETS,
|
||||
TOOL_OUTCOMES,
|
||||
TOOL_RETRY_BUCKETS,
|
||||
count_bucket,
|
||||
duration_bucket,
|
||||
execution_surface,
|
||||
|
|
@ -40,6 +46,14 @@ from hermes_cli.observability.shared_metrics_contract import (
|
|||
task_start_fields,
|
||||
task_terminal_fields,
|
||||
task_terminal_state,
|
||||
tool_approval_counter,
|
||||
tool_approval_outcome,
|
||||
tool_call_dimensions,
|
||||
tool_category,
|
||||
tool_latency_bucket,
|
||||
tool_outcome,
|
||||
tool_retry_bucket,
|
||||
tool_terminal_fields,
|
||||
)
|
||||
|
||||
|
||||
|
|
@ -72,6 +86,11 @@ def _task_dimension_schema(kind: str) -> dict[str, object]:
|
|||
return schema["$defs"][kind]["properties"]["dimensions"]
|
||||
|
||||
|
||||
def _tool_dimension_schema(kind: str) -> dict[str, object]:
|
||||
schema = json.loads(SCHEMA_PATH.read_text(encoding="utf-8"))
|
||||
return schema["$defs"][kind]["properties"]["dimensions"]
|
||||
|
||||
|
||||
def _dimensions() -> dict[str, str]:
|
||||
return {
|
||||
"model": "anthropic/claude-sonnet-4.6",
|
||||
|
|
@ -197,6 +216,125 @@ def test_package_schema_matches_the_task_contract():
|
|||
assert set(terminal["termination"]["enum"]) == TASK_TERMINATIONS
|
||||
|
||||
|
||||
def test_package_schema_matches_the_tool_contract():
|
||||
schema = json.loads(SCHEMA_PATH.read_text(encoding="utf-8"))
|
||||
tool = _tool_dimension_schema("tool_call_counter")["properties"]
|
||||
approval = _tool_dimension_schema("tool_approval_counter")["properties"]
|
||||
|
||||
assert set(tool["tool_category"]["enum"]) == TOOL_CATEGORIES
|
||||
assert set(tool["outcome"]["enum"]) == TOOL_OUTCOMES
|
||||
assert set(tool["approval_outcome"]["enum"]) == TOOL_APPROVAL_OUTCOMES
|
||||
assert tool["latency_bucket"] == {"$ref": "#/$defs/tool_latency_bucket"}
|
||||
assert tool["retry_count_bucket"] == {"$ref": "#/$defs/tool_retry_bucket"}
|
||||
assert set(schema["$defs"]["tool_latency_bucket"]["enum"]) == (
|
||||
TOOL_LATENCY_BUCKETS
|
||||
)
|
||||
assert set(schema["$defs"]["tool_retry_bucket"]["enum"]) == TOOL_RETRY_BUCKETS
|
||||
assert set(approval["attribution"]["enum"]) == TOOL_APPROVAL_ATTRIBUTIONS
|
||||
assert set(approval["outcome"]["enum"]) == (
|
||||
TOOL_APPROVAL_OUTCOMES - {"not_required"}
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("name", "expected"),
|
||||
[
|
||||
("", "unknown"),
|
||||
("read_file", "file"),
|
||||
("terminal", "terminal"),
|
||||
("execute_code", "code_execution"),
|
||||
("delegate_task", "delegation"),
|
||||
("skill_manage", "skill"),
|
||||
("browser_navigate", "browser"),
|
||||
("image_generate", "media"),
|
||||
("ha_call_service", "home_automation"),
|
||||
("kanban_create", "planning"),
|
||||
("project_switch", "project"),
|
||||
("discord", "communication"),
|
||||
("feishu_doc_read", "communication"),
|
||||
("mcp__github__get_issue", "mcp"),
|
||||
("private_plugin_tool", "other"),
|
||||
],
|
||||
)
|
||||
def test_tool_category_is_bounded(name, expected):
|
||||
assert tool_category({"tool_name": name}) == expected
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("status", "expected"),
|
||||
[
|
||||
("ok", "success"),
|
||||
("error", "failed"),
|
||||
("blocked", "blocked"),
|
||||
("cancelled", "cancelled"),
|
||||
("timeout", "timed_out"),
|
||||
("private", "unknown"),
|
||||
(None, "unknown"),
|
||||
],
|
||||
)
|
||||
def test_tool_outcome_is_bounded(status, expected):
|
||||
assert tool_outcome({"status": status}) == expected
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("choice", "expected"),
|
||||
[
|
||||
("once", "approved"),
|
||||
("session", "approved"),
|
||||
("always", "approved"),
|
||||
("smart_approve", "approved"),
|
||||
("deny", "denied"),
|
||||
("smart_deny", "denied"),
|
||||
("timeout", "timed_out"),
|
||||
(None, "unknown"),
|
||||
],
|
||||
)
|
||||
def test_tool_approval_outcome_is_bounded(choice, expected):
|
||||
assert tool_approval_outcome({"choice": choice}) == expected
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("duration_ms", "expected"),
|
||||
[
|
||||
(0, "lt_100ms"),
|
||||
(100, "100ms_to_250ms"),
|
||||
(250, "250ms_to_500ms"),
|
||||
(500, "500ms_to_1s"),
|
||||
(1_000, "1s_to_2s"),
|
||||
(2_000, "2s_to_5s"),
|
||||
(5_000, "5s_to_10s"),
|
||||
(10_000, "10s_to_30s"),
|
||||
(30_000, "gte_30s"),
|
||||
(-1, "unknown"),
|
||||
(True, "unknown"),
|
||||
("100", "unknown"),
|
||||
],
|
||||
)
|
||||
def test_tool_latency_bucket_is_bounded(duration_ms, expected):
|
||||
assert tool_latency_bucket(duration_ms) == expected
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("retry_count", "expected"),
|
||||
[
|
||||
(0, "0"),
|
||||
(1, "1"),
|
||||
(2, "2"),
|
||||
(3, "3_to_5"),
|
||||
(6, "6_to_10"),
|
||||
(11, "gte_11"),
|
||||
(None, "unknown"),
|
||||
(-1, "unknown"),
|
||||
(True, "unknown"),
|
||||
],
|
||||
)
|
||||
def test_tool_retry_bucket_requires_an_explicit_non_negative_count(
|
||||
retry_count,
|
||||
expected,
|
||||
):
|
||||
assert tool_retry_bucket(retry_count) == expected
|
||||
|
||||
|
||||
def test_model_call_fields_report_terminal_model_and_provider_without_a_catalog():
|
||||
assert model_call_fields({
|
||||
"model": "fallback/model",
|
||||
|
|
@ -449,6 +587,50 @@ def test_task_subscriber_contract_accepts_only_bounded_scope_events():
|
|||
assert task_counter(end) is None
|
||||
|
||||
|
||||
def test_tool_subscriber_contract_accepts_only_bounded_events():
|
||||
terminal = SimpleNamespace(
|
||||
kind="scope",
|
||||
category="tool",
|
||||
category_profile={},
|
||||
name="hermes.tool_call",
|
||||
scope_category="end",
|
||||
metadata={"hermes.metrics.schema_version": "hermes.metrics.event.v1"},
|
||||
data={
|
||||
"approval_outcome": "approved",
|
||||
"latency_bucket": "250ms_to_500ms",
|
||||
"outcome": "success",
|
||||
"retry_count_bucket": "0",
|
||||
"tool_category": "terminal",
|
||||
},
|
||||
)
|
||||
assert tool_call_dimensions(terminal) == terminal.data
|
||||
|
||||
terminal.data["result"] = "must-not-pass"
|
||||
assert tool_call_dimensions(terminal) is None
|
||||
terminal.data.pop("result")
|
||||
terminal.data["tool_category"] = "private-tool-name"
|
||||
assert tool_call_dimensions(terminal) is None
|
||||
terminal.data["tool_category"] = "terminal"
|
||||
terminal.category_profile["tool_name"] = "must-not-pass"
|
||||
assert tool_call_dimensions(terminal) is None
|
||||
|
||||
approval = SimpleNamespace(
|
||||
kind="mark",
|
||||
category=None,
|
||||
category_profile=None,
|
||||
name="hermes.tool_approval",
|
||||
scope_category=None,
|
||||
metadata={"hermes.metrics.schema_version": "hermes.metrics.event.v1"},
|
||||
data={"attribution": "unattributed", "outcome": "denied"},
|
||||
)
|
||||
assert tool_approval_counter(approval) == (
|
||||
"hermes.tool_approval.count",
|
||||
approval.data,
|
||||
)
|
||||
approval.data["command"] = "must-not-pass"
|
||||
assert tool_approval_counter(approval) is None
|
||||
|
||||
|
||||
def test_store_rejects_an_unsupported_schema_version(tmp_path):
|
||||
database_path = tmp_path / "metrics.sqlite3"
|
||||
with sqlite3.connect(database_path) as connection:
|
||||
|
|
|
|||
|
|
@ -30,10 +30,13 @@ class _Relay:
|
|||
self.events: list[tuple[Any, ...]] = []
|
||||
self._callbacks: dict[str, Any] = {}
|
||||
self._starts: dict[Any, dict[str, Any]] = {}
|
||||
self._tool_starts: dict[Any, dict[str, Any]] = {}
|
||||
self._scope_starts: dict[Any, dict[str, Any]] = {}
|
||||
self._scope = contextvars.ContextVar("relay_scope", default=None)
|
||||
self._scope_serial = 0
|
||||
self.ScopeType = SimpleNamespace(Agent="agent", Function="function")
|
||||
self.ScopeType = SimpleNamespace(
|
||||
Agent="agent", Function="function", Tool="tool"
|
||||
)
|
||||
self.LLMRequest = _Request
|
||||
self.scope = SimpleNamespace(
|
||||
push=self._scope_push,
|
||||
|
|
@ -41,6 +44,7 @@ class _Relay:
|
|||
event=self._scope_event,
|
||||
)
|
||||
self.llm = SimpleNamespace(call=self._llm_call, call_end=self._llm_call_end)
|
||||
self.tools = SimpleNamespace(call=self._tool_call, call_end=self._tool_call_end)
|
||||
self.subscribers = SimpleNamespace(
|
||||
register=self._register,
|
||||
deregister=self._deregister,
|
||||
|
|
@ -89,6 +93,17 @@ class _Relay:
|
|||
|
||||
def _scope_event(self, name: str, **kwargs: Any) -> None:
|
||||
self.events.append(("scope.event", name, kwargs))
|
||||
event = SimpleNamespace(
|
||||
kind="mark",
|
||||
category=None,
|
||||
name=name,
|
||||
scope_category=None,
|
||||
category_profile=None,
|
||||
metadata=kwargs.get("metadata"),
|
||||
data=kwargs.get("data"),
|
||||
)
|
||||
for callback in list(self._callbacks.values()):
|
||||
callback(event)
|
||||
|
||||
def _get_scope_stack(self) -> Any:
|
||||
current = self._scope.get()
|
||||
|
|
@ -130,6 +145,41 @@ class _Relay:
|
|||
for callback in list(self._callbacks.values()):
|
||||
callback(event)
|
||||
|
||||
def _tool_call(
|
||||
self,
|
||||
name: str,
|
||||
args: dict[str, Any],
|
||||
**kwargs: Any,
|
||||
) -> Any:
|
||||
handle = ("tool", name, len(self._tool_starts))
|
||||
self._tool_starts[handle] = kwargs
|
||||
self.events.append(("tool.call", name, args, kwargs))
|
||||
return handle
|
||||
|
||||
def _tool_call_end(
|
||||
self,
|
||||
handle: Any,
|
||||
result: dict[str, Any],
|
||||
**kwargs: Any,
|
||||
) -> None:
|
||||
start = self._tool_starts.pop(handle)
|
||||
self.events.append(("tool.call_end", handle, result, kwargs))
|
||||
event = SimpleNamespace(
|
||||
kind="scope",
|
||||
category="tool",
|
||||
name=handle[1],
|
||||
scope_category="end",
|
||||
category_profile={},
|
||||
metadata={
|
||||
**start["metadata"],
|
||||
**kwargs["metadata"],
|
||||
"otel.status_code": "OK",
|
||||
},
|
||||
data=result,
|
||||
)
|
||||
for callback in list(self._callbacks.values()):
|
||||
callback(event)
|
||||
|
||||
def _register(self, name: str, callback: Any) -> None:
|
||||
self._callbacks[name] = callback
|
||||
self.events.append(("subscribers.register", name))
|
||||
|
|
@ -181,6 +231,7 @@ def test_direct_runtime_records_without_enabling_a_plugin(direct_runtime, tmp_pa
|
|||
base = {
|
||||
"session_id": "sensitive-session",
|
||||
"task_id": "task-1",
|
||||
"turn_id": "turn-1",
|
||||
"api_request_id": "request-1",
|
||||
"platform": "cli",
|
||||
"provider": "custom",
|
||||
|
|
@ -196,6 +247,21 @@ def test_direct_runtime_records_without_enabling_a_plugin(direct_runtime, tmp_pa
|
|||
**base,
|
||||
request={"body": {"messages": ["sensitive-prompt"]}},
|
||||
)
|
||||
lifecycle.invoke_hook(
|
||||
"pre_tool_call",
|
||||
**base,
|
||||
tool_call_id="sensitive-tool-call",
|
||||
tool_name="terminal",
|
||||
args={"command": "sensitive-command"},
|
||||
)
|
||||
lifecycle.invoke_hook(
|
||||
"post_approval_response",
|
||||
turn_id=base["turn_id"],
|
||||
tool_call_id="sensitive-tool-call",
|
||||
choice="once",
|
||||
command="sensitive-command",
|
||||
description="sensitive-approval-description",
|
||||
)
|
||||
lifecycle.invoke_hook(
|
||||
"post_tool_call",
|
||||
**base,
|
||||
|
|
@ -204,6 +270,8 @@ def test_direct_runtime_records_without_enabling_a_plugin(direct_runtime, tmp_pa
|
|||
args={"command": "sensitive-command"},
|
||||
result={"output": "sensitive-tool-result"},
|
||||
status="ok",
|
||||
duration_ms=275,
|
||||
retry_count=0,
|
||||
)
|
||||
lifecycle.invoke_hook(
|
||||
"api_request_error",
|
||||
|
|
@ -243,6 +311,10 @@ def test_direct_runtime_records_without_enabling_a_plugin(direct_runtime, tmp_pa
|
|||
|
||||
starts = [event for event in direct_runtime.events if event[0] == "llm.call"]
|
||||
ends = [event for event in direct_runtime.events if event[0] == "llm.call_end"]
|
||||
tool_starts = [event for event in direct_runtime.events if event[0] == "tool.call"]
|
||||
tool_ends = [
|
||||
event for event in direct_runtime.events if event[0] == "tool.call_end"
|
||||
]
|
||||
scope_starts = [
|
||||
event for event in direct_runtime.events if event[0] == "scope.push"
|
||||
]
|
||||
|
|
@ -257,6 +329,17 @@ def test_direct_runtime_records_without_enabling_a_plugin(direct_runtime, tmp_pa
|
|||
}
|
||||
assert len(starts) == 1
|
||||
assert len(ends) == 1
|
||||
assert len(tool_starts) == 1
|
||||
assert len(tool_ends) == 1
|
||||
assert tool_starts[0][1] == "hermes.tool_call"
|
||||
assert tool_starts[0][2] == {}
|
||||
assert tool_ends[0][2] == {
|
||||
"approval_outcome": "approved",
|
||||
"latency_bucket": "250ms_to_500ms",
|
||||
"outcome": "success",
|
||||
"retry_count_bucket": "0",
|
||||
"tool_category": "terminal",
|
||||
}
|
||||
assert starts[0][2] == {}
|
||||
assert starts[0][3]["model_name"] == "unknown"
|
||||
assert ends[0][2] == {
|
||||
|
|
@ -270,6 +353,7 @@ def test_direct_runtime_records_without_enabling_a_plugin(direct_runtime, tmp_pa
|
|||
assert "sensitive-command" not in serialized_events
|
||||
assert "sensitive-tool-result" not in serialized_events
|
||||
assert "sensitive-tool-call" not in serialized_events
|
||||
assert "sensitive-approval-description" not in serialized_events
|
||||
assert "gpt-sensitive-model-id" not in serialized_events
|
||||
assert plugins.get_plugin_manager().list_plugins() == []
|
||||
|
||||
|
|
@ -282,12 +366,35 @@ def test_direct_runtime_records_without_enabling_a_plugin(direct_runtime, tmp_pa
|
|||
"hermes.model_call.count",
|
||||
"hermes.task_run.finished",
|
||||
"hermes.task_run.started",
|
||||
"hermes.tool_approval.count",
|
||||
"hermes.tool_call.count",
|
||||
}
|
||||
assert metrics["hermes.model_call.count"]["dimensions"] == {
|
||||
"model": "claude-sonnet",
|
||||
"provider": "anthropic",
|
||||
}
|
||||
assert metrics["hermes.model_call.count"]["value"] == 1
|
||||
assert metrics["hermes.tool_call.count"] == {
|
||||
"name": "hermes.tool_call.count",
|
||||
"type": "counter",
|
||||
"dimensions": {
|
||||
"approval_outcome": "approved",
|
||||
"latency_bucket": "250ms_to_500ms",
|
||||
"outcome": "success",
|
||||
"retry_count_bucket": "0",
|
||||
"tool_category": "terminal",
|
||||
},
|
||||
"value": 1,
|
||||
}
|
||||
assert metrics["hermes.tool_approval.count"] == {
|
||||
"name": "hermes.tool_approval.count",
|
||||
"type": "counter",
|
||||
"dimensions": {
|
||||
"attribution": "tool_call",
|
||||
"outcome": "approved",
|
||||
},
|
||||
"value": 1,
|
||||
}
|
||||
assert metrics["hermes.task_run.started"] == {
|
||||
"name": "hermes.task_run.started",
|
||||
"type": "counter",
|
||||
|
|
@ -355,6 +462,21 @@ def test_real_binding_drives_lifecycle_aggregation_export_and_snapshot(
|
|||
error={"message": prompt_canary},
|
||||
)
|
||||
lifecycle.invoke_hook("pre_api_request", **success, retry_count=1)
|
||||
lifecycle.invoke_hook(
|
||||
"pre_tool_call",
|
||||
**success,
|
||||
tool_call_id="sensitive-tool-call",
|
||||
tool_name="terminal",
|
||||
args={"command": prompt_canary},
|
||||
)
|
||||
lifecycle.invoke_hook(
|
||||
"post_approval_response",
|
||||
turn_id=success["turn_id"],
|
||||
tool_call_id="sensitive-tool-call",
|
||||
choice="session",
|
||||
command=prompt_canary,
|
||||
description="sensitive-approval-description",
|
||||
)
|
||||
lifecycle.invoke_hook(
|
||||
"post_tool_call",
|
||||
**success,
|
||||
|
|
@ -363,6 +485,8 @@ def test_real_binding_drives_lifecycle_aggregation_export_and_snapshot(
|
|||
args={"command": prompt_canary},
|
||||
result={"output": tool_canary},
|
||||
status="ok",
|
||||
duration_ms=125,
|
||||
retry_count=0,
|
||||
)
|
||||
lifecycle.invoke_hook(
|
||||
"post_api_request",
|
||||
|
|
@ -384,6 +508,23 @@ def test_real_binding_drives_lifecycle_aggregation_export_and_snapshot(
|
|||
lifecycle.invoke_hook("on_session_start", **failed)
|
||||
lifecycle.invoke_hook("pre_llm_call", **failed, messages=[prompt_canary])
|
||||
lifecycle.invoke_hook("pre_api_request", **failed, retry_count=0)
|
||||
lifecycle.invoke_hook(
|
||||
"pre_tool_call",
|
||||
**failed,
|
||||
tool_call_id="sensitive-failed-tool-call",
|
||||
tool_name="read_file",
|
||||
args={"path": prompt_canary},
|
||||
)
|
||||
lifecycle.invoke_hook(
|
||||
"post_tool_call",
|
||||
**failed,
|
||||
tool_call_id="sensitive-failed-tool-call",
|
||||
tool_name="read_file",
|
||||
args={"path": prompt_canary},
|
||||
result={"error": tool_canary},
|
||||
status="error",
|
||||
duration_ms=750,
|
||||
)
|
||||
lifecycle.invoke_hook(
|
||||
"api_request_error",
|
||||
**failed,
|
||||
|
|
@ -405,6 +546,23 @@ def test_real_binding_drives_lifecycle_aggregation_export_and_snapshot(
|
|||
lifecycle.invoke_hook("on_session_start", **cancelled)
|
||||
lifecycle.invoke_hook("pre_llm_call", **cancelled, messages=[prompt_canary])
|
||||
lifecycle.invoke_hook("pre_api_request", **cancelled, retry_count=0)
|
||||
lifecycle.invoke_hook(
|
||||
"pre_tool_call",
|
||||
**cancelled,
|
||||
tool_call_id="sensitive-cancelled-tool-call",
|
||||
tool_name="browser_navigate",
|
||||
args={"url": prompt_canary},
|
||||
)
|
||||
lifecycle.invoke_hook(
|
||||
"post_tool_call",
|
||||
**cancelled,
|
||||
tool_call_id="sensitive-cancelled-tool-call",
|
||||
tool_name="browser_navigate",
|
||||
args={"url": prompt_canary},
|
||||
result={"error": tool_canary},
|
||||
status="cancelled",
|
||||
duration_ms=31_000,
|
||||
)
|
||||
lifecycle.invoke_hook(
|
||||
"on_session_end",
|
||||
**cancelled,
|
||||
|
|
@ -439,6 +597,43 @@ def test_real_binding_drives_lifecycle_aggregation_export_and_snapshot(
|
|||
"provider": "custom",
|
||||
}
|
||||
assert model_counter["value"] == 3
|
||||
assert {
|
||||
counter["dimensions"]["outcome"]
|
||||
for counter in by_metric["hermes.tool_call.count"]
|
||||
} == {"success", "failed", "cancelled"}
|
||||
tool_by_outcome = {
|
||||
counter["dimensions"]["outcome"]: counter["dimensions"]
|
||||
for counter in by_metric["hermes.tool_call.count"]
|
||||
}
|
||||
assert tool_by_outcome["success"] == {
|
||||
"approval_outcome": "approved",
|
||||
"latency_bucket": "100ms_to_250ms",
|
||||
"outcome": "success",
|
||||
"retry_count_bucket": "0",
|
||||
"tool_category": "terminal",
|
||||
}
|
||||
assert tool_by_outcome["failed"] == {
|
||||
"approval_outcome": "not_required",
|
||||
"latency_bucket": "500ms_to_1s",
|
||||
"outcome": "failed",
|
||||
"retry_count_bucket": "unknown",
|
||||
"tool_category": "file",
|
||||
}
|
||||
assert tool_by_outcome["cancelled"] == {
|
||||
"approval_outcome": "not_required",
|
||||
"latency_bucket": "gte_30s",
|
||||
"outcome": "cancelled",
|
||||
"retry_count_bucket": "unknown",
|
||||
"tool_category": "browser",
|
||||
}
|
||||
assert len(by_metric["hermes.tool_approval.count"]) == 1
|
||||
approval_counter = by_metric["hermes.tool_approval.count"][0]
|
||||
assert approval_counter["dimensions"] == {
|
||||
"attribution": "tool_call",
|
||||
"outcome": "approved",
|
||||
}
|
||||
assert approval_counter["value"] == 1
|
||||
assert approval_counter["packaged_value"] == 1
|
||||
terminal_by_outcome = {
|
||||
counter["dimensions"]["outcome"]: counter
|
||||
for counter in by_metric["hermes.task_run.finished"]
|
||||
|
|
@ -487,10 +682,185 @@ def test_real_binding_drives_lifecycle_aggregation_export_and_snapshot(
|
|||
"sensitive-task",
|
||||
"sensitive-request",
|
||||
"sensitive-tool-call",
|
||||
"sensitive-failed-tool-call",
|
||||
"sensitive-cancelled-tool-call",
|
||||
"sensitive-approval-description",
|
||||
):
|
||||
assert canary not in serialized_analytics
|
||||
|
||||
|
||||
def test_real_binding_correlates_plugin_approval_denial_to_tool_metric(
|
||||
real_binding_runtime,
|
||||
tmp_path,
|
||||
monkeypatch,
|
||||
):
|
||||
from hermes_cli.observability.shared_metrics import SharedMetricsStore
|
||||
from tools import approval
|
||||
|
||||
assert real_binding_runtime._native is not None
|
||||
base = {
|
||||
"session_id": "sensitive-session",
|
||||
"task_id": "sensitive-task",
|
||||
"turn_id": "sensitive-turn",
|
||||
"api_request_id": "sensitive-request",
|
||||
"platform": "cli",
|
||||
}
|
||||
|
||||
def plugin_hook(hook_name: str, **kwargs: Any) -> list[dict[str, str]]:
|
||||
if hook_name == "pre_tool_call":
|
||||
return [{"action": "approve", "message": "sensitive-rule"}]
|
||||
return []
|
||||
|
||||
monkeypatch.setattr(plugins, "invoke_hook", plugin_hook)
|
||||
monkeypatch.setattr(approval, "_YOLO_MODE_FROZEN", False)
|
||||
monkeypatch.setattr(approval, "is_current_session_yolo_enabled", lambda: False)
|
||||
monkeypatch.setattr(approval, "is_approved", lambda *args: False)
|
||||
monkeypatch.setattr(approval, "get_current_session_key", lambda: "session-key")
|
||||
monkeypatch.setattr(approval, "_is_interactive_cli", lambda: True)
|
||||
monkeypatch.setattr(approval, "_is_gateway_approval_context", lambda: False)
|
||||
monkeypatch.setattr(approval, "prompt_dangerous_approval", lambda *args, **kwargs: "deny")
|
||||
|
||||
lifecycle.invoke_hook("on_session_start", **base)
|
||||
lifecycle.invoke_hook("pre_llm_call", **base, messages=["sensitive-prompt"])
|
||||
block_message = plugins.resolve_pre_tool_block(
|
||||
"write_file",
|
||||
{"path": "sensitive-path"},
|
||||
task_id=base["task_id"],
|
||||
session_id=base["session_id"],
|
||||
turn_id=base["turn_id"],
|
||||
api_request_id=base["api_request_id"],
|
||||
tool_call_id="sensitive-tool-call",
|
||||
)
|
||||
assert block_message is not None
|
||||
assert "User denied" in block_message
|
||||
|
||||
lifecycle.invoke_hook(
|
||||
"post_tool_call",
|
||||
**base,
|
||||
tool_call_id="sensitive-tool-call",
|
||||
tool_name="write_file",
|
||||
args={"path": "sensitive-path"},
|
||||
result={"error": block_message},
|
||||
status="blocked",
|
||||
duration_ms=12,
|
||||
)
|
||||
lifecycle.invoke_hook(
|
||||
"on_session_end",
|
||||
**base,
|
||||
completed=False,
|
||||
failed=True,
|
||||
interrupted=False,
|
||||
turn_exit_reason="approval_denied",
|
||||
)
|
||||
lifecycle.finalize_session(session_id=base["session_id"])
|
||||
|
||||
root = tmp_path / "hermes-home" / "telemetry" / "shared_metrics"
|
||||
store = SharedMetricsStore(root / "metrics.sqlite3", root / "outbox")
|
||||
snapshot = store.counter_snapshot()
|
||||
tool_metrics = [
|
||||
counter
|
||||
for counter in snapshot
|
||||
if counter["metric_name"] == "hermes.tool_call.count"
|
||||
]
|
||||
assert len(tool_metrics) == 1
|
||||
assert tool_metrics[0]["dimensions"] == {
|
||||
"approval_outcome": "denied",
|
||||
"latency_bucket": "lt_100ms",
|
||||
"outcome": "blocked",
|
||||
"retry_count_bucket": "unknown",
|
||||
"tool_category": "file",
|
||||
}
|
||||
approval_metrics = [
|
||||
counter
|
||||
for counter in snapshot
|
||||
if counter["metric_name"] == "hermes.tool_approval.count"
|
||||
]
|
||||
assert len(approval_metrics) == 1
|
||||
assert approval_metrics[0]["dimensions"] == {
|
||||
"attribution": "tool_call",
|
||||
"outcome": "denied",
|
||||
}
|
||||
assert "sensitive" not in json.dumps(snapshot)
|
||||
|
||||
|
||||
def test_real_binding_aggregates_tool_and_approval_timeouts(
|
||||
real_binding_runtime,
|
||||
tmp_path,
|
||||
):
|
||||
from hermes_cli.observability.shared_metrics import SharedMetricsStore
|
||||
|
||||
assert real_binding_runtime._native is not None
|
||||
base = {
|
||||
"session_id": "timeout-sensitive-session",
|
||||
"task_id": "timeout-sensitive-task",
|
||||
"turn_id": "timeout-sensitive-turn",
|
||||
"platform": "cli",
|
||||
}
|
||||
|
||||
lifecycle.invoke_hook("on_session_start", **base)
|
||||
lifecycle.invoke_hook("pre_llm_call", **base, messages=["timeout-sensitive-prompt"])
|
||||
lifecycle.invoke_hook(
|
||||
"pre_tool_call",
|
||||
**base,
|
||||
tool_call_id="timeout-sensitive-tool-call",
|
||||
tool_name="terminal",
|
||||
args={"command": "timeout-sensitive-command"},
|
||||
)
|
||||
lifecycle.invoke_hook(
|
||||
"post_approval_response",
|
||||
**base,
|
||||
tool_call_id="timeout-sensitive-tool-call",
|
||||
choice="timeout",
|
||||
command="timeout-sensitive-command",
|
||||
)
|
||||
lifecycle.invoke_hook(
|
||||
"post_tool_call",
|
||||
**base,
|
||||
tool_call_id="timeout-sensitive-tool-call",
|
||||
tool_name="terminal",
|
||||
result={"error": "timeout-sensitive-result"},
|
||||
status="timeout",
|
||||
duration_ms=30_000,
|
||||
)
|
||||
lifecycle.invoke_hook(
|
||||
"on_session_end",
|
||||
**base,
|
||||
completed=False,
|
||||
failed=True,
|
||||
interrupted=False,
|
||||
turn_exit_reason="provider_timeout",
|
||||
)
|
||||
lifecycle.finalize_session(session_id=base["session_id"])
|
||||
|
||||
root = tmp_path / "hermes-home" / "telemetry" / "shared_metrics"
|
||||
snapshot = SharedMetricsStore(
|
||||
root / "metrics.sqlite3",
|
||||
root / "outbox",
|
||||
).counter_snapshot()
|
||||
[tool_metric] = [
|
||||
counter
|
||||
for counter in snapshot
|
||||
if counter["metric_name"] == "hermes.tool_call.count"
|
||||
]
|
||||
assert tool_metric["dimensions"] == {
|
||||
"approval_outcome": "timed_out",
|
||||
"latency_bucket": "gte_30s",
|
||||
"outcome": "timed_out",
|
||||
"retry_count_bucket": "unknown",
|
||||
"tool_category": "terminal",
|
||||
}
|
||||
[approval_metric] = [
|
||||
counter
|
||||
for counter in snapshot
|
||||
if counter["metric_name"] == "hermes.tool_approval.count"
|
||||
]
|
||||
assert approval_metric["dimensions"] == {
|
||||
"attribution": "tool_call",
|
||||
"outcome": "timed_out",
|
||||
}
|
||||
assert "timeout-sensitive" not in json.dumps(snapshot)
|
||||
|
||||
|
||||
def test_direct_runtime_is_disabled_by_default(tmp_path, monkeypatch):
|
||||
fake = _Relay()
|
||||
monkeypatch.setenv("HERMES_HOME", str(tmp_path / "hermes-home"))
|
||||
|
|
@ -1061,9 +1431,7 @@ def test_shared_metrics_policy_and_store_are_profile_scoped(tmp_path, monkeypatc
|
|||
monkeypatch.setattr(
|
||||
"hermes_cli.config.read_raw_config",
|
||||
lambda: {
|
||||
"telemetry": {
|
||||
"shared_metrics": {"enabled": get_hermes_home() == profile_a}
|
||||
}
|
||||
"telemetry": {"shared_metrics": {"enabled": get_hermes_home() == profile_a}}
|
||||
},
|
||||
)
|
||||
relay_shared_metrics._reset_for_tests()
|
||||
|
|
@ -1135,9 +1503,7 @@ def test_shared_metrics_subscribers_isolate_two_enabled_profiles(tmp_path, monke
|
|||
platform="cli",
|
||||
result={"completed": True},
|
||||
)
|
||||
relay_shared_metrics._get_runtime().close_session(
|
||||
{"session_id": "shared"}
|
||||
)
|
||||
relay_shared_metrics._get_runtime().close_session({"session_id": "shared"})
|
||||
finally:
|
||||
reset_hermes_home_override(token)
|
||||
|
||||
|
|
@ -1269,24 +1635,29 @@ def test_disabling_shared_metrics_stops_collection_and_shutdown_export(
|
|||
|
||||
assert not relay_shared_metrics.enabled()
|
||||
counters_before_stale_event = runtime.subscriber.store.counter_snapshot()
|
||||
runtime.subscriber(SimpleNamespace(
|
||||
kind="scope",
|
||||
category="function",
|
||||
category_profile=None,
|
||||
name="hermes.task_run",
|
||||
scope_category="start",
|
||||
metadata={
|
||||
"hermes.metrics.schema_version": "hermes.metrics.event.v1",
|
||||
relay_runtime.RUNTIME_INSTANCE_KEY: runtime.host.runtime_id,
|
||||
},
|
||||
data={"entrypoint": "interactive", "execution_surface": "cli"},
|
||||
))
|
||||
runtime.subscriber(
|
||||
SimpleNamespace(
|
||||
kind="scope",
|
||||
category="function",
|
||||
category_profile=None,
|
||||
name="hermes.task_run",
|
||||
scope_category="start",
|
||||
metadata={
|
||||
"hermes.metrics.schema_version": "hermes.metrics.event.v1",
|
||||
relay_runtime.RUNTIME_INSTANCE_KEY: runtime.host.runtime_id,
|
||||
},
|
||||
data={"entrypoint": "interactive", "execution_surface": "cli"},
|
||||
)
|
||||
)
|
||||
assert runtime.subscriber.store.counter_snapshot() == counters_before_stale_event
|
||||
assert runtime.start_task({
|
||||
"session_id": "session",
|
||||
"task_id": "stale-runtime-task",
|
||||
"platform": "cli",
|
||||
}) is None
|
||||
assert (
|
||||
runtime.start_task({
|
||||
"session_id": "session",
|
||||
"task_id": "stale-runtime-task",
|
||||
"platform": "cli",
|
||||
})
|
||||
is None
|
||||
)
|
||||
relay_shared_metrics.finish_task_run(
|
||||
session_id="session",
|
||||
task_id="task",
|
||||
|
|
@ -1709,12 +2080,10 @@ def test_core_runtime_parents_subagent_session_without_exposing_ids(
|
|||
def test_subagent_stop_hook_does_not_own_child_session_lifetime(direct_runtime):
|
||||
runtime = relay_runtime.get_runtime()
|
||||
assert runtime is not None
|
||||
child = runtime.register_subagent(
|
||||
{
|
||||
"parent_session_id": "parent",
|
||||
"child_session_id": "child",
|
||||
}
|
||||
)
|
||||
child = runtime.register_subagent({
|
||||
"parent_session_id": "parent",
|
||||
"child_session_id": "child",
|
||||
})
|
||||
assert child is not None
|
||||
|
||||
lifecycle.invoke_hook(
|
||||
|
|
@ -1779,6 +2148,7 @@ def test_subagent_agent_boundary_closes_its_own_scope(
|
|||
)
|
||||
AIAgent.run_conversation(child_agent, "private", task_id="child-task")
|
||||
elif terminal == "exception":
|
||||
|
||||
def fail(*_args, **_kwargs):
|
||||
raise RuntimeError("child failed")
|
||||
|
||||
|
|
@ -1786,6 +2156,7 @@ def test_subagent_agent_boundary_closes_its_own_scope(
|
|||
with pytest.raises(RuntimeError, match="child failed"):
|
||||
AIAgent.run_conversation(child_agent, "private", task_id="child-task")
|
||||
elif terminal == "cancelled":
|
||||
|
||||
def cancel(*_args, **_kwargs):
|
||||
raise KeyboardInterrupt
|
||||
|
||||
|
|
@ -1793,6 +2164,7 @@ def test_subagent_agent_boundary_closes_its_own_scope(
|
|||
with pytest.raises(KeyboardInterrupt):
|
||||
AIAgent.run_conversation(child_agent, "private", task_id="child-task")
|
||||
else:
|
||||
|
||||
def time_out(*_args, **_kwargs):
|
||||
raise TimeoutError("child timed out")
|
||||
|
||||
|
|
@ -2171,6 +2543,11 @@ def test_task_terminal_counts_logical_calls_retries_and_unique_tools(direct_runt
|
|||
"model": "nvidia/nemotron-3-super-120b-a12b",
|
||||
"provider": "nvidia",
|
||||
}
|
||||
tool_ends = [
|
||||
event for event in direct_runtime.events if event[0] == "tool.call_end"
|
||||
]
|
||||
assert len(tool_ends) == 2
|
||||
assert all(event[2]["tool_category"] == "terminal" for event in tool_ends)
|
||||
[task_end] = [
|
||||
event
|
||||
for event in direct_runtime.events
|
||||
|
|
@ -2189,6 +2566,317 @@ def test_task_terminal_counts_logical_calls_retries_and_unique_tools(direct_runt
|
|||
}
|
||||
|
||||
|
||||
def test_reused_tool_call_id_is_counted_for_each_provider_request(direct_runtime):
|
||||
base = {
|
||||
"session_id": "s1",
|
||||
"task_id": "t1",
|
||||
"turn_id": "turn-1",
|
||||
"platform": "cli",
|
||||
}
|
||||
lifecycle.invoke_hook("pre_llm_call", **base)
|
||||
|
||||
for api_request_id in ("request-1", "request-2"):
|
||||
call = {
|
||||
**base,
|
||||
"api_request_id": api_request_id,
|
||||
"tool_call_id": "provider-reused-id",
|
||||
"tool_name": "terminal",
|
||||
}
|
||||
lifecycle.invoke_hook("pre_tool_call", **call, args={"command": "private"})
|
||||
lifecycle.invoke_hook(
|
||||
"post_tool_call",
|
||||
**call,
|
||||
result={"output": "private"},
|
||||
status="ok",
|
||||
)
|
||||
|
||||
lifecycle.invoke_hook(
|
||||
"on_session_end",
|
||||
**base,
|
||||
completed=True,
|
||||
failed=False,
|
||||
interrupted=False,
|
||||
turn_exit_reason="text_response(stop)",
|
||||
)
|
||||
lifecycle.finalize_session(session_id="s1")
|
||||
|
||||
tool_ends = [
|
||||
event for event in direct_runtime.events if event[0] == "tool.call_end"
|
||||
]
|
||||
assert len(tool_ends) == 2
|
||||
[task_end] = [
|
||||
event
|
||||
for event in direct_runtime.events
|
||||
if event[0] == "scope.pop" and event[1][1] == "hermes.task_run"
|
||||
]
|
||||
assert task_end[2]["output"]["tool_call_count_bucket"] == "2"
|
||||
|
||||
|
||||
def test_reused_task_id_starts_a_new_run_for_each_turn(direct_runtime):
|
||||
for turn_id in ("turn-1", "turn-2"):
|
||||
base = {
|
||||
"session_id": "reused-session",
|
||||
"task_id": "reused-session",
|
||||
"turn_id": turn_id,
|
||||
"platform": "api",
|
||||
}
|
||||
lifecycle.invoke_hook("pre_llm_call", **base)
|
||||
lifecycle.invoke_hook(
|
||||
"post_tool_call",
|
||||
**base,
|
||||
api_request_id=f"request-{turn_id}",
|
||||
tool_call_id=f"tool-{turn_id}",
|
||||
tool_name="read_file",
|
||||
result={"output": "private"},
|
||||
status="ok",
|
||||
)
|
||||
lifecycle.invoke_hook(
|
||||
"on_session_end",
|
||||
**base,
|
||||
completed=True,
|
||||
failed=False,
|
||||
interrupted=False,
|
||||
turn_exit_reason="text_response(stop)",
|
||||
)
|
||||
|
||||
lifecycle.finalize_session(session_id="reused-session")
|
||||
|
||||
task_starts = [
|
||||
event
|
||||
for event in direct_runtime.events
|
||||
if event[0] == "scope.push" and event[1] == "hermes.task_run"
|
||||
]
|
||||
task_ends = [
|
||||
event
|
||||
for event in direct_runtime.events
|
||||
if event[0] == "scope.pop" and event[1][1] == "hermes.task_run"
|
||||
]
|
||||
tool_ends = [
|
||||
event for event in direct_runtime.events if event[0] == "tool.call_end"
|
||||
]
|
||||
assert len(task_starts) == 2
|
||||
assert len(task_ends) == 2
|
||||
assert len(tool_ends) == 2
|
||||
assert all(
|
||||
event[2]["output"]["tool_call_count_bucket"] == "1" for event in task_ends
|
||||
)
|
||||
|
||||
|
||||
def test_late_tool_result_does_not_attach_to_reused_task_id(direct_runtime):
|
||||
first = {
|
||||
"session_id": "reused-session",
|
||||
"task_id": "reused-session",
|
||||
"turn_id": "turn-1",
|
||||
"platform": "api",
|
||||
}
|
||||
lifecycle.invoke_hook("pre_llm_call", **first)
|
||||
lifecycle.invoke_hook(
|
||||
"pre_tool_call",
|
||||
**first,
|
||||
api_request_id="request-1",
|
||||
tool_call_id="tool-1",
|
||||
tool_name="terminal",
|
||||
)
|
||||
lifecycle.invoke_hook(
|
||||
"on_session_end",
|
||||
**first,
|
||||
completed=False,
|
||||
failed=True,
|
||||
interrupted=False,
|
||||
turn_exit_reason="timed_out",
|
||||
)
|
||||
|
||||
second = {**first, "turn_id": "turn-2"}
|
||||
runtime = relay_shared_metrics._get_runtime()
|
||||
assert runtime is not None
|
||||
assert runtime.start_task({
|
||||
"session_id": second["session_id"],
|
||||
"task_id": second["task_id"],
|
||||
"platform": second["platform"],
|
||||
})
|
||||
lifecycle.invoke_hook(
|
||||
"post_tool_call",
|
||||
**first,
|
||||
api_request_id="request-1",
|
||||
tool_call_id="tool-1",
|
||||
tool_name="terminal",
|
||||
result={"output": "late-private-result"},
|
||||
status="ok",
|
||||
)
|
||||
lifecycle.invoke_hook("pre_llm_call", **second)
|
||||
lifecycle.invoke_hook(
|
||||
"post_tool_call",
|
||||
**second,
|
||||
api_request_id="request-2",
|
||||
tool_call_id="tool-2",
|
||||
tool_name="read_file",
|
||||
result={"output": "current-private-result"},
|
||||
status="ok",
|
||||
)
|
||||
lifecycle.invoke_hook(
|
||||
"on_session_end",
|
||||
**second,
|
||||
completed=True,
|
||||
failed=False,
|
||||
interrupted=False,
|
||||
turn_exit_reason="text_response(stop)",
|
||||
)
|
||||
lifecycle.finalize_session(session_id="reused-session")
|
||||
|
||||
tool_ends = [
|
||||
event for event in direct_runtime.events if event[0] == "tool.call_end"
|
||||
]
|
||||
assert len(tool_ends) == 2
|
||||
assert [event[2]["outcome"] for event in tool_ends] == [
|
||||
"timed_out",
|
||||
"success",
|
||||
]
|
||||
assert [event[2]["tool_category"] for event in tool_ends] == [
|
||||
"terminal",
|
||||
"file",
|
||||
]
|
||||
task_ends = [
|
||||
event
|
||||
for event in direct_runtime.events
|
||||
if event[0] == "scope.pop" and event[1][1] == "hermes.task_run"
|
||||
]
|
||||
assert [
|
||||
event[2]["output"]["tool_call_count_bucket"] for event in task_ends
|
||||
] == ["1", "1"]
|
||||
|
||||
|
||||
def test_pending_tool_is_closed_and_counted_when_task_is_interrupted(direct_runtime):
|
||||
base = {
|
||||
"session_id": "s1",
|
||||
"task_id": "t1",
|
||||
"turn_id": "turn-1",
|
||||
"platform": "cli",
|
||||
}
|
||||
|
||||
lifecycle.invoke_hook("on_session_start", **base)
|
||||
lifecycle.invoke_hook(
|
||||
"pre_tool_call",
|
||||
**base,
|
||||
tool_call_id="tool-1",
|
||||
tool_name="terminal",
|
||||
args={"command": "must-not-pass"},
|
||||
)
|
||||
lifecycle.invoke_hook(
|
||||
"on_session_end",
|
||||
**base,
|
||||
completed=False,
|
||||
failed=False,
|
||||
interrupted=True,
|
||||
turn_exit_reason="interrupted_by_user",
|
||||
)
|
||||
lifecycle.invoke_hook(
|
||||
"post_tool_call",
|
||||
**base,
|
||||
tool_call_id="tool-1",
|
||||
tool_name="terminal",
|
||||
result={"output": "late-result-must-not-pass"},
|
||||
status="ok",
|
||||
)
|
||||
lifecycle.finalize_session(session_id="s1")
|
||||
|
||||
[tool_end] = [
|
||||
event for event in direct_runtime.events if event[0] == "tool.call_end"
|
||||
]
|
||||
assert tool_end[2] == {
|
||||
"approval_outcome": "not_required",
|
||||
"latency_bucket": tool_end[2]["latency_bucket"],
|
||||
"outcome": "cancelled",
|
||||
"retry_count_bucket": "unknown",
|
||||
"tool_category": "terminal",
|
||||
}
|
||||
[task_end] = [
|
||||
event
|
||||
for event in direct_runtime.events
|
||||
if event[0] == "scope.pop" and event[1][1] == "hermes.task_run"
|
||||
]
|
||||
assert task_end[2]["output"]["tool_call_count_bucket"] == "1"
|
||||
task_starts = [
|
||||
event
|
||||
for event in direct_runtime.events
|
||||
if event[0] == "scope.push" and event[1] == "hermes.task_run"
|
||||
]
|
||||
assert len(task_starts) == 1
|
||||
|
||||
|
||||
def test_pending_tool_uses_the_outer_task_timeout_outcome(direct_runtime):
|
||||
base = {
|
||||
"session_id": "s1",
|
||||
"task_id": "t1",
|
||||
"turn_id": "turn-1",
|
||||
"platform": "api",
|
||||
}
|
||||
lifecycle.invoke_hook("pre_llm_call", **base)
|
||||
lifecycle.invoke_hook(
|
||||
"pre_tool_call",
|
||||
**base,
|
||||
tool_call_id="tool-1",
|
||||
tool_name="web_search",
|
||||
)
|
||||
|
||||
relay_shared_metrics.finish_task_run(
|
||||
session_id="s1",
|
||||
task_id="t1",
|
||||
platform="api",
|
||||
error=TimeoutError("private timeout detail"),
|
||||
)
|
||||
lifecycle.finalize_session(session_id="s1")
|
||||
|
||||
[tool_end] = [
|
||||
event for event in direct_runtime.events if event[0] == "tool.call_end"
|
||||
]
|
||||
assert tool_end[2]["outcome"] == "timed_out"
|
||||
[task_end] = [
|
||||
event
|
||||
for event in direct_runtime.events
|
||||
if event[0] == "scope.pop" and event[1][1] == "hermes.task_run"
|
||||
]
|
||||
assert task_end[2]["output"]["outcome"] == "timed_out"
|
||||
assert task_end[2]["output"]["tool_call_count_bucket"] == "1"
|
||||
assert "private timeout detail" not in repr(direct_runtime.events)
|
||||
|
||||
|
||||
def test_approval_without_tool_context_is_counted_as_unattributed(direct_runtime):
|
||||
base = {
|
||||
"session_id": "s1",
|
||||
"task_id": "t1",
|
||||
"turn_id": "turn-1",
|
||||
"platform": "cli",
|
||||
}
|
||||
|
||||
lifecycle.invoke_hook("on_session_start", **base)
|
||||
lifecycle.invoke_hook("pre_llm_call", **base)
|
||||
lifecycle.invoke_hook(
|
||||
"post_approval_response",
|
||||
turn_id="turn-1",
|
||||
choice="deny",
|
||||
command="must-not-pass",
|
||||
)
|
||||
lifecycle.invoke_hook(
|
||||
"on_session_end",
|
||||
**base,
|
||||
completed=False,
|
||||
failed=True,
|
||||
interrupted=False,
|
||||
turn_exit_reason="approval_denied",
|
||||
)
|
||||
lifecycle.finalize_session(session_id="s1")
|
||||
|
||||
[approval] = [
|
||||
event
|
||||
for event in direct_runtime.events
|
||||
if event[0] == "scope.event" and event[1] == "hermes.tool_approval"
|
||||
]
|
||||
assert approval[2]["data"] == {
|
||||
"attribution": "unattributed",
|
||||
"outcome": "denied",
|
||||
}
|
||||
|
||||
|
||||
def test_task_terminal_counts_explicit_retry_with_new_request_id(direct_runtime):
|
||||
base = {
|
||||
"session_id": "s1",
|
||||
|
|
|
|||
|
|
@ -2466,11 +2466,18 @@ class TestExecuteToolCalls:
|
|||
assert post_calls[0]["error_type"] == "keyboard_interrupt"
|
||||
assert json.loads(post_calls[0]["result"])["status"] == "cancelled"
|
||||
|
||||
def test_interrupt_skips_remaining(self, agent):
|
||||
def test_interrupt_skips_remaining(self, agent, monkeypatch):
|
||||
tc1 = _mock_tool_call(name="web_search", arguments="{}", call_id="c1")
|
||||
tc2 = _mock_tool_call(name="web_search", arguments="{}", call_id="c2")
|
||||
mock_msg = _mock_assistant_msg(content="", tool_calls=[tc1, tc2])
|
||||
messages = []
|
||||
hook_calls = []
|
||||
|
||||
monkeypatch.setattr("hermes_cli.lifecycle.has_hook", lambda name: True)
|
||||
monkeypatch.setattr(
|
||||
"hermes_cli.lifecycle.invoke_hook",
|
||||
lambda hook_name, **kwargs: hook_calls.append((hook_name, kwargs)) or [],
|
||||
)
|
||||
|
||||
with patch("run_agent._set_interrupt"):
|
||||
agent.interrupt()
|
||||
|
|
@ -2482,13 +2489,22 @@ class TestExecuteToolCalls:
|
|||
"cancelled" in messages[0]["content"].lower()
|
||||
or "interrupted" in messages[0]["content"].lower()
|
||||
)
|
||||
post_calls = [kwargs for name, kwargs in hook_calls if name == "post_tool_call"]
|
||||
assert [call["tool_call_id"] for call in post_calls] == ["c1", "c2"]
|
||||
assert all(call["status"] == "cancelled" for call in post_calls)
|
||||
|
||||
def test_invalid_json_args_are_rejected_without_dispatch(self, agent):
|
||||
def test_invalid_json_args_are_rejected_without_dispatch(self, agent, monkeypatch):
|
||||
tc = _mock_tool_call(
|
||||
name="web_search", arguments="not valid json", call_id="c1"
|
||||
)
|
||||
mock_msg = _mock_assistant_msg(content="", tool_calls=[tc])
|
||||
messages = []
|
||||
hook_calls = []
|
||||
monkeypatch.setattr("hermes_cli.lifecycle.has_hook", lambda name: True)
|
||||
monkeypatch.setattr(
|
||||
"hermes_cli.lifecycle.invoke_hook",
|
||||
lambda hook_name, **kwargs: hook_calls.append((hook_name, kwargs)) or [],
|
||||
)
|
||||
with patch("run_agent.handle_function_call", return_value="ok") as mock_hfc:
|
||||
agent._execute_tool_calls(mock_msg, messages, "task-1")
|
||||
mock_hfc.assert_not_called()
|
||||
|
|
@ -2497,6 +2513,34 @@ class TestExecuteToolCalls:
|
|||
assert messages[0]["tool_call_id"] == "c1"
|
||||
assert "valid json object" in messages[0]["content"].lower()
|
||||
assert "tool was not executed" in messages[0]["content"].lower()
|
||||
[post_call] = [
|
||||
kwargs for name, kwargs in hook_calls if name == "post_tool_call"
|
||||
]
|
||||
assert post_call["tool_call_id"] == "c1"
|
||||
assert post_call["status"] == "error"
|
||||
assert post_call["error_type"] == "invalid_tool_arguments"
|
||||
|
||||
def test_concurrent_invalid_json_args_emit_terminal_hook(self, agent, monkeypatch):
|
||||
tc = _mock_tool_call(
|
||||
name="web_search", arguments="not valid json", call_id="c1"
|
||||
)
|
||||
mock_msg = _mock_assistant_msg(content="", tool_calls=[tc])
|
||||
messages = []
|
||||
hook_calls = []
|
||||
monkeypatch.setattr("hermes_cli.lifecycle.has_hook", lambda name: True)
|
||||
monkeypatch.setattr(
|
||||
"hermes_cli.lifecycle.invoke_hook",
|
||||
lambda hook_name, **kwargs: hook_calls.append((hook_name, kwargs)) or [],
|
||||
)
|
||||
|
||||
agent._execute_tool_calls_concurrent(mock_msg, messages, "task-1")
|
||||
|
||||
[post_call] = [
|
||||
kwargs for name, kwargs in hook_calls if name == "post_tool_call"
|
||||
]
|
||||
assert post_call["tool_call_id"] == "c1"
|
||||
assert post_call["status"] == "error"
|
||||
assert post_call["error_type"] == "invalid_tool_arguments"
|
||||
|
||||
def test_none_args_rejected_without_dispatch(self, agent):
|
||||
"""None arguments must not crash the dispatch path. Current contract:
|
||||
|
|
@ -2982,10 +3026,17 @@ class TestConcurrentToolExecution:
|
|||
flushed.append([m.copy() for m in flush_messages if m.get("role") == "tool"])
|
||||
|
||||
agent._flush_messages_to_session_db = MagicMock(side_effect=record_flush)
|
||||
terminal_hook = MagicMock()
|
||||
|
||||
start = _time.monotonic()
|
||||
try:
|
||||
with patch("run_agent.handle_function_call", side_effect=fake_handle):
|
||||
with (
|
||||
patch("run_agent.handle_function_call", side_effect=fake_handle),
|
||||
patch(
|
||||
"model_tools._emit_post_tool_call_hook",
|
||||
terminal_hook,
|
||||
),
|
||||
):
|
||||
agent._execute_tool_calls_concurrent(mock_msg, messages, "task-1")
|
||||
finally:
|
||||
blocker.set()
|
||||
|
|
@ -3000,6 +3051,13 @@ class TestConcurrentToolExecution:
|
|||
assert [batch[-1]["tool_call_id"] for batch in flushed] == ["c1", "c2"]
|
||||
assert "fast-result" in flushed[0][-1]["content"]
|
||||
assert "timed out after" in flushed[1][-1]["content"]
|
||||
[timeout_hook] = [
|
||||
call
|
||||
for call in terminal_hook.call_args_list
|
||||
if call.kwargs["tool_call_id"] == "c2"
|
||||
]
|
||||
assert timeout_hook.kwargs["status"] == "timeout"
|
||||
assert timeout_hook.kwargs["duration_ms"] == 100
|
||||
|
||||
def test_concurrent_timeout_prefers_late_real_result_over_timeout_message(self, agent, monkeypatch):
|
||||
"""A worker that finishes in the window between the deadline snapshot
|
||||
|
|
@ -3475,6 +3533,55 @@ class TestConcurrentToolExecution:
|
|||
assert post_call[1]["args"] == {"todos": [], "request_rewritten": True, "merge": True}
|
||||
assert post_call[1]["middleware_trace"] == [{"source": "request-test"}]
|
||||
|
||||
@pytest.mark.parametrize("concurrent", [False, True])
|
||||
def test_tool_execution_middleware_replacement_emits_one_terminal_hook(
|
||||
self,
|
||||
agent,
|
||||
monkeypatch,
|
||||
concurrent,
|
||||
):
|
||||
"""A middleware replacement owns the result but not lifecycle closure."""
|
||||
tool_call = _mock_tool_call(
|
||||
name="terminal",
|
||||
arguments='{"command":"must-not-run"}',
|
||||
call_id="terminal-1",
|
||||
)
|
||||
mock_msg = _mock_assistant_msg(content="", tool_calls=[tool_call])
|
||||
messages = []
|
||||
hook_calls = []
|
||||
|
||||
def execution_middleware(**kwargs):
|
||||
return '{"intercepted":true}'
|
||||
|
||||
manager = SimpleNamespace(_middleware={
|
||||
"tool_request": [],
|
||||
"tool_execution": [execution_middleware],
|
||||
})
|
||||
monkeypatch.setattr("hermes_cli.plugins.get_plugin_manager", lambda: manager)
|
||||
monkeypatch.setattr(
|
||||
"hermes_cli.lifecycle.invoke_hook",
|
||||
lambda hook_name, **kwargs: hook_calls.append((hook_name, kwargs)) or [],
|
||||
)
|
||||
monkeypatch.setattr("hermes_cli.lifecycle.has_hook", lambda name: True)
|
||||
|
||||
with patch(
|
||||
"run_agent.handle_function_call",
|
||||
side_effect=AssertionError("middleware replacement must not dispatch"),
|
||||
):
|
||||
if concurrent:
|
||||
agent._execute_tool_calls_concurrent(mock_msg, messages, "task-1")
|
||||
else:
|
||||
agent._execute_tool_calls_sequential(mock_msg, messages, "task-1")
|
||||
|
||||
post_calls = [
|
||||
payload for name, payload in hook_calls if name == "post_tool_call"
|
||||
]
|
||||
assert len(post_calls) == 1
|
||||
assert post_calls[0]["tool_name"] == "terminal"
|
||||
assert post_calls[0]["tool_call_id"] == "terminal-1"
|
||||
assert post_calls[0]["status"] == "ok"
|
||||
assert post_calls[0]["result"] == '{"intercepted":true}'
|
||||
|
||||
def test_concurrent_agent_level_tool_preserves_request_middleware_trace(self, agent, monkeypatch):
|
||||
tool_call = _mock_tool_call(name="todo", arguments='{"todos":[]}', call_id="todo-1")
|
||||
mock_msg = _mock_assistant_msg(content="", tool_calls=[tool_call])
|
||||
|
|
|
|||
|
|
@ -127,6 +127,23 @@ class TestHandleFunctionCall:
|
|||
# pre_tool_call does NOT get duration_ms (nothing has run yet).
|
||||
assert "duration_ms" not in kwargs_by_hook["pre_tool_call"]
|
||||
|
||||
def test_terminal_nonzero_exit_is_reported_as_error(self):
|
||||
result = json.dumps({"output": "", "exit_code": 1, "error": None})
|
||||
with (
|
||||
patch("model_tools.registry.dispatch", return_value=result),
|
||||
patch("hermes_cli.plugins.has_hook", return_value=True),
|
||||
patch("hermes_cli.plugins.invoke_hook") as mock_invoke_hook,
|
||||
):
|
||||
assert handle_function_call("terminal", {"command": "false"}) == result
|
||||
|
||||
kwargs_by_hook = {
|
||||
hook.args[0]: hook.kwargs for hook in mock_invoke_hook.call_args_list
|
||||
}
|
||||
for hook_name in ("post_tool_call", "transform_tool_result"):
|
||||
assert kwargs_by_hook[hook_name]["status"] == "error"
|
||||
assert kwargs_by_hook[hook_name]["error_type"] == "tool_error"
|
||||
assert kwargs_by_hook[hook_name]["error_message"] == "exit 1"
|
||||
|
||||
def test_no_listener_skips_post_and_transform_emit(self):
|
||||
"""When no plugin is registered for post_tool_call /
|
||||
transform_tool_result, the emit path must short-circuit on
|
||||
|
|
@ -201,6 +218,75 @@ class TestHandleFunctionCall:
|
|||
assert pre_call[1]["middleware_trace"] == expected_trace
|
||||
assert post_call[1]["middleware_trace"] == expected_trace
|
||||
|
||||
def test_registry_exception_emits_terminal_tool_hook(self, monkeypatch):
|
||||
from hermes_cli import lifecycle
|
||||
|
||||
hook_calls = []
|
||||
monkeypatch.setattr("hermes_cli.plugins.invoke_hook", lambda *_args, **_kwargs: [])
|
||||
monkeypatch.setattr(lifecycle, "has_hook", lambda name: name == "post_tool_call")
|
||||
monkeypatch.setattr(
|
||||
lifecycle,
|
||||
"invoke_hook",
|
||||
lambda name, **kwargs: hook_calls.append((name, kwargs)) or [],
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"model_tools.registry.dispatch",
|
||||
lambda *_args, **_kwargs: (_ for _ in ()).throw(RuntimeError("boom")),
|
||||
)
|
||||
|
||||
result = json.loads(
|
||||
handle_function_call(
|
||||
"web_search",
|
||||
{"q": "test"},
|
||||
task_id="task-1",
|
||||
session_id="session-1",
|
||||
tool_call_id="tool-1",
|
||||
)
|
||||
)
|
||||
|
||||
assert "error" in result
|
||||
[post_call] = [call for call in hook_calls if call[0] == "post_tool_call"]
|
||||
assert post_call[1]["status"] == "error"
|
||||
assert post_call[1]["error_type"] == "RuntimeError"
|
||||
assert post_call[1]["duration_ms"] >= 0
|
||||
|
||||
def test_acp_edit_denial_emits_blocked_terminal_tool_hook(self, monkeypatch):
|
||||
from hermes_cli import lifecycle
|
||||
|
||||
hook_calls = []
|
||||
monkeypatch.setattr("hermes_cli.plugins.invoke_hook", lambda *_args, **_kwargs: [])
|
||||
monkeypatch.setattr(lifecycle, "has_hook", lambda name: name == "post_tool_call")
|
||||
monkeypatch.setattr(
|
||||
lifecycle,
|
||||
"invoke_hook",
|
||||
lambda name, **kwargs: hook_calls.append((name, kwargs)) or [],
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"acp_adapter.edit_approval.maybe_require_edit_approval",
|
||||
lambda *_args, **_kwargs: json.dumps({"error": "Edit approval denied"}),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"model_tools.registry.dispatch",
|
||||
lambda *_args, **_kwargs: (_ for _ in ()).throw(
|
||||
AssertionError("denied edit must not dispatch")
|
||||
),
|
||||
)
|
||||
|
||||
result = json.loads(
|
||||
handle_function_call(
|
||||
"write_file",
|
||||
{"path": "private.txt", "content": "private"},
|
||||
task_id="task-1",
|
||||
session_id="session-1",
|
||||
tool_call_id="tool-1",
|
||||
)
|
||||
)
|
||||
|
||||
assert result == {"error": "Edit approval denied"}
|
||||
[post_call] = [call for call in hook_calls if call[0] == "post_tool_call"]
|
||||
assert post_call[1]["status"] == "blocked"
|
||||
assert post_call[1]["error_type"] == "edit_approval_denied"
|
||||
|
||||
|
||||
# =========================================================================
|
||||
# Agent loop tools
|
||||
|
|
|
|||
|
|
@ -50,13 +50,35 @@ class TestRequestToolApproval:
|
|||
assert res["approved"] is True
|
||||
|
||||
def test_cli_deny_blocks(self, monkeypatch):
|
||||
from hermes_cli import lifecycle
|
||||
|
||||
monkeypatch.setattr(approval, "_is_interactive_cli", lambda: True)
|
||||
monkeypatch.setattr(approval, "_is_gateway_approval_context", lambda: False)
|
||||
monkeypatch.setattr(approval, "prompt_dangerous_approval", lambda *a, **k: "deny")
|
||||
res = request_tool_approval("terminal", "curl PUT to external API")
|
||||
events = []
|
||||
monkeypatch.setattr(
|
||||
lifecycle,
|
||||
"invoke_hook",
|
||||
lambda hook_name, **kwargs: events.append((hook_name, kwargs)) or [],
|
||||
)
|
||||
tokens = approval.set_current_observability_context(
|
||||
turn_id="turn-1",
|
||||
tool_call_id="call-1",
|
||||
)
|
||||
try:
|
||||
res = request_tool_approval("terminal", "curl PUT to external API")
|
||||
finally:
|
||||
approval.reset_current_observability_context(tokens)
|
||||
assert res["approved"] is False
|
||||
assert "denied" in res["message"].lower()
|
||||
assert res["pattern_key"].startswith("plugin_rule:")
|
||||
assert [name for name, _ in events] == [
|
||||
"pre_approval_request",
|
||||
"post_approval_response",
|
||||
]
|
||||
assert all(event["turn_id"] == "turn-1" for _, event in events)
|
||||
assert all(event["tool_call_id"] == "call-1" for _, event in events)
|
||||
assert events[-1][1]["choice"] == "deny"
|
||||
|
||||
def test_cli_session_persists_session_only(self, monkeypatch):
|
||||
monkeypatch.setattr(approval, "_is_interactive_cli", lambda: True)
|
||||
|
|
|
|||
|
|
@ -456,6 +456,48 @@ class TestHandleFunctionCallIntegration:
|
|||
# dispatch path completed without error.
|
||||
assert "matches" in parsed or "error" in parsed
|
||||
|
||||
def test_tool_search_emits_one_terminal_hook(self, monkeypatch):
|
||||
"""Inline bridge results still complete the tool lifecycle."""
|
||||
import model_tools
|
||||
from hermes_cli import lifecycle
|
||||
from tools import tool_search
|
||||
|
||||
events = []
|
||||
monkeypatch.setattr(
|
||||
lifecycle,
|
||||
"has_hook",
|
||||
lambda name: name == "post_tool_call",
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
lifecycle,
|
||||
"invoke_hook",
|
||||
lambda name, **kwargs: events.append((name, kwargs)),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
tool_search,
|
||||
"dispatch_tool_search",
|
||||
lambda *args, **kwargs: json.dumps({"matches": []}),
|
||||
)
|
||||
|
||||
result = model_tools.handle_function_call(
|
||||
function_name="tool_search",
|
||||
function_args={"query": "private-query"},
|
||||
session_id="private-session",
|
||||
task_id="private-task",
|
||||
turn_id="private-turn",
|
||||
api_request_id="private-request",
|
||||
tool_call_id="private-call",
|
||||
)
|
||||
|
||||
assert json.loads(result) == {"matches": []}
|
||||
assert len(events) == 1
|
||||
hook_name, payload = events[0]
|
||||
assert hook_name == "post_tool_call"
|
||||
assert payload["status"] == "ok"
|
||||
assert payload["turn_id"] == "private-turn"
|
||||
assert payload["api_request_id"] == "private-request"
|
||||
assert payload["tool_call_id"] == "private-call"
|
||||
|
||||
|
||||
class TestRegression_OpenClawCron84141:
|
||||
"""Regression guard for the OpenClaw cron-tool-loss class of bug.
|
||||
|
|
|
|||
|
|
@ -2990,8 +2990,27 @@ def _run_approval_gate(
|
|||
),
|
||||
}
|
||||
|
||||
_fire_approval_hook(
|
||||
"pre_approval_request",
|
||||
command=display_target,
|
||||
description=description,
|
||||
pattern_key=pattern_key,
|
||||
pattern_keys=[pattern_key],
|
||||
session_key=session_key,
|
||||
surface="cli",
|
||||
)
|
||||
choice = prompt_dangerous_approval(display_target, description,
|
||||
approval_callback=approval_callback)
|
||||
_fire_approval_hook(
|
||||
"post_approval_response",
|
||||
command=display_target,
|
||||
description=description,
|
||||
pattern_key=pattern_key,
|
||||
pattern_keys=[pattern_key],
|
||||
session_key=session_key,
|
||||
surface="cli",
|
||||
choice=choice,
|
||||
)
|
||||
|
||||
if choice == "deny":
|
||||
return {
|
||||
|
|
|
|||
Loading…
Reference in New Issue