145 lines
5.5 KiB
Python
145 lines
5.5 KiB
Python
"""Runtime hooks for Agent Observability.
|
|
|
|
OpenClaw/Hermes/sub-agents can call these hooks at start/end/delegation points.
|
|
The hooks store sanitized metadata only: no prompts, no secrets, no private message
|
|
content.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
from dataclasses import dataclass, field
|
|
from datetime import datetime, timezone
|
|
from typing import Any
|
|
from uuid import uuid4
|
|
|
|
from .agent_observability import AgentExecutionRecord
|
|
from .agent_observability_store import AgentObservabilityStore
|
|
from .trace_tool_credential_governance import (
|
|
GovernanceActor,
|
|
TraceContext,
|
|
authorize_scope,
|
|
)
|
|
|
|
|
|
def utc_now() -> str:
|
|
return datetime.now(timezone.utc).isoformat()
|
|
|
|
|
|
def parse_time(value: str) -> datetime:
|
|
return datetime.fromisoformat(value.replace("Z", "+00:00"))
|
|
|
|
|
|
@dataclass
|
|
class AgentRunContext:
|
|
execution_id: str
|
|
user_id: str
|
|
visible_scope: str
|
|
entry_agent: str
|
|
path: list[str] = field(default_factory=list)
|
|
used_hermes: bool = False
|
|
started_at: str = field(default_factory=utc_now)
|
|
input_tokens: int = 0
|
|
output_tokens: int = 0
|
|
status: str = "running"
|
|
trace: TraceContext | None = None
|
|
|
|
def delegate(self, agent_name: str) -> None:
|
|
if agent_name not in self.path:
|
|
self.path.append(agent_name)
|
|
if agent_name.lower() == "hermes":
|
|
self.used_hermes = True
|
|
|
|
def add_tokens(self, input_tokens: int = 0, output_tokens: int = 0) -> None:
|
|
self.input_tokens += max(input_tokens, 0)
|
|
self.output_tokens += max(output_tokens, 0)
|
|
|
|
|
|
class AgentObservabilityHooks:
|
|
def __init__(self, store: AgentObservabilityStore | None = None):
|
|
self.store = store or AgentObservabilityStore()
|
|
self.active: dict[str, AgentRunContext] = {}
|
|
|
|
def start(
|
|
self,
|
|
user_id: str,
|
|
visible_scope: str,
|
|
entry_agent: str,
|
|
execution_id: str | None = None,
|
|
*,
|
|
actor: GovernanceActor | dict[str, Any] | None = None,
|
|
trace: TraceContext | None = None,
|
|
) -> dict[str, Any]:
|
|
if execution_id is not None and execution_id in self.active:
|
|
raise RuntimeError("OBSERVABILITY_EXECUTION_CONFLICT")
|
|
if trace is not None:
|
|
if actor is None:
|
|
raise RuntimeError("TRACE_ACTOR_CONTEXT_REQUIRED")
|
|
trusted_actor = GovernanceActor.from_value(actor)
|
|
authorize_scope(trusted_actor, trace.scope)
|
|
if trace.scope.user_id is not None and trace.scope.user_id != user_id:
|
|
raise RuntimeError("TRACE_SCOPE_MISMATCH_DENY:user_id")
|
|
run = AgentRunContext(
|
|
execution_id=execution_id or f"exec_{uuid4().hex[:12]}",
|
|
user_id=user_id,
|
|
visible_scope=visible_scope,
|
|
entry_agent=entry_agent,
|
|
path=[entry_agent],
|
|
used_hermes=entry_agent.lower() == "hermes",
|
|
trace=trace,
|
|
)
|
|
self.active[run.execution_id] = run
|
|
return {"status": "started", "execution_id": run.execution_id, "run": run.__dict__}
|
|
|
|
def delegate(self, execution_id: str, agent_name: str) -> dict[str, Any]:
|
|
run = self.active[execution_id]
|
|
run.delegate(agent_name)
|
|
return {"status": "delegated", "execution_id": execution_id, "path": run.path}
|
|
|
|
def tokens(self, execution_id: str, input_tokens: int = 0, output_tokens: int = 0) -> dict[str, Any]:
|
|
run = self.active[execution_id]
|
|
run.add_tokens(input_tokens, output_tokens)
|
|
return {
|
|
"status": "tokens_recorded",
|
|
"execution_id": execution_id,
|
|
"input_tokens": run.input_tokens,
|
|
"output_tokens": run.output_tokens,
|
|
"total_tokens": run.input_tokens + run.output_tokens,
|
|
}
|
|
|
|
def finish(self, execution_id: str, closed_by: str | None = None, status: str = "completed") -> dict[str, Any]:
|
|
run = self.active.pop(execution_id)
|
|
ended_at = utc_now()
|
|
duration_ms = int((parse_time(ended_at) - parse_time(run.started_at)).total_seconds() * 1000)
|
|
record = AgentExecutionRecord(
|
|
execution_id=run.execution_id,
|
|
user_id=run.user_id,
|
|
visible_scope=run.visible_scope,
|
|
entry_agent=run.entry_agent,
|
|
path=run.path,
|
|
used_hermes=run.used_hermes,
|
|
closed_by=closed_by or run.path[-1],
|
|
duration_ms=duration_ms,
|
|
input_tokens=run.input_tokens,
|
|
output_tokens=run.output_tokens,
|
|
total_tokens=run.input_tokens + run.output_tokens,
|
|
status=status,
|
|
started_at=run.started_at,
|
|
ended_at=ended_at,
|
|
trace_id=run.trace.trace_id if run.trace else None,
|
|
span_id=run.trace.span_id if run.trace else None,
|
|
parent_span_id=run.trace.parent_span_id if run.trace else None,
|
|
correlation_id=run.trace.correlation_id if run.trace else None,
|
|
causation_id=run.trace.causation_id if run.trace else None,
|
|
tenant_id=run.trace.scope.tenant_id if run.trace else None,
|
|
project_id=run.trace.scope.project_id if run.trace else None,
|
|
conversation_id=run.trace.scope.conversation_id if run.trace else None,
|
|
task_id=run.trace.task_id if run.trace else None,
|
|
claim_id=run.trace.claim_id if run.trace else None,
|
|
run_id=run.trace.scope.run_id if run.trace else None,
|
|
message_id=run.trace.message_id if run.trace else None,
|
|
)
|
|
return {"status": "stored", "record": self.store.append(record)}
|
|
|
|
|
|
__all__ = ["AgentRunContext", "AgentObservabilityHooks"]
|