Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion agent/skill_bundles.py
Original file line number Diff line number Diff line change
Expand Up @@ -321,7 +321,7 @@ def build_bundle_invocation_message(

try:
from tools.skill_usage import bump_use
bump_use(skill_name)
bump_use(skill_name, task_id=task_id)
except Exception:
pass

Expand Down
6 changes: 3 additions & 3 deletions agent/skill_commands.py
Original file line number Diff line number Diff line change
Expand Up @@ -595,7 +595,7 @@ def build_skill_invocation_message(
# Track active usage for Curator lifecycle management (#17782)
try:
from tools.skill_usage import bump_use
bump_use(skill_name)
bump_use(skill_name, task_id=task_id)
except Exception:
pass # Non-critical β€” skill invocation proceeds regardless

Expand Down Expand Up @@ -703,7 +703,7 @@ def build_stacked_skill_invocation_message(
# Track active usage for Curator lifecycle management (#17782)
try:
from tools.skill_usage import bump_use
bump_use(skill_name)
bump_use(skill_name, task_id=task_id)
except Exception:
pass # Non-critical

Expand Down Expand Up @@ -790,7 +790,7 @@ def build_preloaded_skills_prompt(
# Track active usage for Curator lifecycle management (#17782)
try:
from tools.skill_usage import bump_use
bump_use(skill_name)
bump_use(skill_name, task_id=task_id)
except Exception:
pass # Non-critical

Expand Down
2 changes: 1 addition & 1 deletion cron/scheduler.py
Original file line number Diff line number Diff line change
Expand Up @@ -2616,7 +2616,7 @@ def _build_job_prompt(job: dict, prerun_script: Optional[tuple] = None) -> str:

# Bump usage so the curator sees this skill as actively used.
try:
bump_use(skill_name)
bump_use(skill_name, task_id=str(job.get("id") or "") or None)
except Exception:
logger.debug("Cron job: failed to bump skill usage for '%s'", skill_name, exc_info=True)

Expand Down
23 changes: 18 additions & 5 deletions docs/observability/relay-shared-metrics.md
Original file line number Diff line number Diff line change
Expand Up @@ -56,7 +56,7 @@ dependency does not change the collection or privacy policy.
## Current Slices

The current vertical slices record logical model calls, top-level task runs,
and tool and approval outcomes:
tool and approval outcomes, and skill lifecycle and reuse:

```text
Hermes turn, API, tool, and approval hooks
Expand Down Expand Up @@ -106,6 +106,17 @@ 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.

Successful skill mutations emit `hermes.skill.lifecycle` marks with only a
bounded action and provenance. Successful loads emit `hermes.skill.load`
marks with bounded provenance, first-use or reuse state, reuse-after-patch
state, and a use-count bucket. Hermes derives reuse and patch-generation
continuity transactionally in its existing `skills/.usage.json` state; skill
names and exact counts or generations never enter Relay metrics events,
SQLite dimensions, or packages. A use after a new patch is counted once as
`reused_after_patch`; later uses remain ordinary reuse until another patch.
Task-outcome attribution after a patch remains deferred until its window and
multi-skill semantics are defined.

Local state is written under:

```text
Expand Down Expand Up @@ -148,7 +159,9 @@ The script uses the installed `nemo-relay` dependency by default. Pass
binding.

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.
final response, then drives create, load, reuse, patch, edit, stale, archive,
restore, and install skill transitions through the installed Relay binding. It
verifies model, provider, task, tool, and skill counters in SQLite, validates
all exported delta packages against the closed schema, and checks that prompt,
response, tool-call ID, tool-result, and skill-name canaries are absent from the
packages.
83 changes: 77 additions & 6 deletions hermes_cli/observability/relay_shared_metrics.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,11 +20,15 @@
MODEL_CALL_SCOPE,
SCHEMA_KEY,
SCHEMA_VERSION,
SKILL_LIFECYCLE_MARK,
SKILL_LOAD_MARK,
SUBSCRIBER_NAME,
TASK_SCOPE,
TOOL_APPROVAL_MARK,
TOOL_CALL_SCOPE,
model_call_fields,
skill_lifecycle_fields,
skill_load_fields,
task_start_fields,
task_terminal_fields,
task_terminal_state,
Expand All @@ -48,6 +52,7 @@
"post_approval_response",
"post_api_request",
"api_request_error",
"on_skill_lifecycle",
"subagent_stop",
})

Expand Down Expand Up @@ -189,15 +194,19 @@ def start_task(self, event: dict[str, Any]) -> _TaskRun | None:
return None
task = owner.tasks.get(task_id)
if task is not None:
if not self._event_matches_task_turn(task, event):
return None
self._remember_turn(owner, task, event)
return task

session = self.ensure_session(event)
if session is None:
return None
with session.lock:
turn_id = str(event.get("turn_id") or "")
if (
session.closing
or (turn_id and turn_id in session.retired_turn_ids)
or session.relay_session.context is None
):
return None
Expand Down Expand Up @@ -258,6 +267,8 @@ def start_model_call(self, event: dict[str, Any]) -> None:
if task is None:
task = self.start_task(event)
session = self._task_session(event) if task is not None else None
if task_id and task is None:
return
if session is None:
session = self.ensure_session(event)
if session is None:
Expand All @@ -272,6 +283,11 @@ def start_model_call(self, event: dict[str, Any]) -> None:
if session.closing:
return
if task is not None:
if (
session.tasks.get(task.task_id) is not task
or not self._event_matches_task_turn(task, event)
):
return
self._remember_turn(session, task, event)
existing = session.model_calls.get(model_call_key)
if existing is not None:
Expand Down Expand Up @@ -465,6 +481,55 @@ def record_tool_call(self, event: dict[str, Any]) -> None:
tool_call = self._open_tool_call(task, event)
self._finish_tool_call(task, tool_call, event)

def record_skill_lifecycle(self, event: dict[str, Any]) -> None:
"""Emit one allowlisted skill fact without its local identity."""
action = str(event.get("action") or "").strip().lower()
if action == "loaded":
mark = SKILL_LOAD_MARK
fields = skill_load_fields(event)
else:
mark = SKILL_LIFECYCLE_MARK
fields = skill_lifecycle_fields(event)
if fields is None:
return

session_id = str(event.get("session_id") or "")
task_id = str(event.get("task_id") or "")
session = self._task_session(
event,
allow_task_id_fallback=not session_id,
)
task = session.tasks.get(task_id) if session is not None else None
if session is not None:
if task is None:
return
with session.lock:
if session.closing:
return
if (
session.tasks.get(task.task_id) is not task
or not self._event_matches_task_turn(task, event)
):
return
self._run_in_task(
task,
self.relay.scope.event,
mark,
handle=task.handle,
data=fields,
metadata=self._event_metadata(),
)
return
if session_id and task_id:
return

self.relay.get_scope_stack()
self.relay.scope.event(
mark,
data=fields,
metadata=self._event_metadata(),
)

def end_model_call(self, event: dict[str, Any]) -> None:
session = self._task_session(event, allow_task_id_fallback=True)
if session is None:
Expand Down Expand Up @@ -643,19 +708,23 @@ def _task_session(
*,
allow_task_id_fallback: bool = False,
) -> _MetricsSession | None:
task_key = self._task_key(event)
if task_key is None:
session_id = str(event.get("session_id") or "")
task_id = str(event.get("task_id") or "")
if not task_id:
return None
task_key = (session_id, task_id) if session_id else None
turn_key = self._turn_key(event)
with self._task_sessions_lock:
if turn_key is not None:
owner = self._turn_sessions.get(turn_key)
if owner is not None:
return owner
owner = self._task_sessions.get(task_key)
if owner is not None or not allow_task_id_fallback:
return owner
task_id = task_key[1]
if task_key is not None:
owner = self._task_sessions.get(task_key)
if owner is not None:
return owner
if not allow_task_id_fallback:
return None
candidates: list[_MetricsSession] = []
for (_, candidate_task_id), session in self._task_sessions.items():
if candidate_task_id != task_id:
Expand Down Expand Up @@ -1031,6 +1100,8 @@ def observe_lifecycle(hook_name: str, **kwargs: Any) -> None:
runtime.record_tool_call(_with_runtime_toolset(kwargs))
elif hook_name == "post_approval_response":
runtime.record_approval(kwargs)
elif hook_name == "on_skill_lifecycle":
runtime.record_skill_lifecycle(kwargs)
elif hook_name == "post_api_request":
runtime.end_model_call(kwargs)
elif hook_name == "api_request_error":
Expand Down
114 changes: 114 additions & 0 deletions hermes_cli/observability/schemas/hermes.shared_metrics.v2.schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,12 @@
},
{
"$ref": "#/$defs/tool_approval_counter"
},
{
"$ref": "#/$defs/skill_lifecycle_counter"
},
{
"$ref": "#/$defs/skill_load_counter"
}
]
}
Expand Down Expand Up @@ -539,6 +545,114 @@
"minimum": 1
}
}
},
"skill_lifecycle_counter": {
"type": "object",
"additionalProperties": false,
"required": [
"name",
"type",
"dimensions",
"value"
],
"properties": {
"name": {
"const": "hermes.skill.lifecycle.count"
},
"type": {
"const": "counter"
},
"dimensions": {
"type": "object",
"additionalProperties": false,
"required": [
"action",
"provenance"
],
"properties": {
"action": {
"enum": [
"archived",
"created",
"edited",
"installed",
"patched",
"restored",
"stale"
]
},
"provenance": {
"$ref": "#/$defs/skill_provenance"
}
}
},
"value": {
"type": "integer",
"minimum": 1
}
}
},
"skill_load_counter": {
"type": "object",
"additionalProperties": false,
"required": [
"name",
"type",
"dimensions",
"value"
],
"properties": {
"name": {
"const": "hermes.skill.load.count"
},
"type": {
"const": "counter"
},
"dimensions": {
"type": "object",
"additionalProperties": false,
"required": [
"post_patch_state",
"provenance",
"reuse_state",
"use_count_bucket"
],
"properties": {
"post_patch_state": {
"enum": [
"no_new_patch",
"not_applicable",
"reused_after_patch"
]
},
"provenance": {
"$ref": "#/$defs/skill_provenance"
},
"reuse_state": {
"enum": [
"first_use",
"reused"
]
},
"use_count_bucket": {
"$ref": "#/$defs/count_bucket"
}
}
},
"value": {
"type": "integer",
"minimum": 1
}
}
},
"skill_provenance": {
"enum": [
"agent_created",
"external",
"installed",
"local",
"unknown"
]
}
}
}
Loading
Loading