diff --git a/docs/adapters/handoff-standardization.md b/docs/adapters/handoff-standardization.md new file mode 100644 index 00000000..dbf255d5 --- /dev/null +++ b/docs/adapters/handoff-standardization.md @@ -0,0 +1,158 @@ +# Handoff Event Standardisation + +Every framework adapter that emits the cross-cutting `agent.handoff` +event must populate a small, stable set of metadata fields. This +contract makes handoffs from any adapter (LangGraph, CrewAI, AutoGen, +agno, openai_agents, llama_index, google_adk, ms_agent_framework, +Semantic Kernel, …) consumable by the same downstream replay, +attestation, and analytics pipelines. + +This document describes the contract and the shared helper that +implements it. + +## Why + +Handoff events without a sequence number are unorderable when +wall-clock timestamps collide (sub-microsecond collisions are common +when handoffs fire back-to-back in async loops). Without context +hashes, the replay engine cannot verify that re-execution received +the same payload that the original run did. Without bounded previews, +event sinks risk receiving multi-megabyte handoff payloads. + +The mature adapters (LangChain, LangGraph, CrewAI, AutoGen, +Agentforce, Semantic Kernel) already converged on the same +three-field shape, but the convergence was implementation-by- +implementation rather than via a shared helper. This module +consolidates the pattern. + +## The contract + +Every `agent.handoff` event emitted by a framework adapter MUST +include these fields in addition to the framework-required +`from_agent` / `to_agent`: + +| Field | Type | Description | +|-------------------|---------|--------------------------------------------------------------------------------------| +| `handoff_seq` | `int` | Monotonically increasing seq number, scoped to the adapter instance. Starts at 1. | +| `context_hash` | `str` | `"sha256:" + hex_digest` of canonical-JSON-encoded handoff context. | +| `context_preview` | `str` | Human-readable summary of the context, length-bounded (default 256 chars). | +| `timestamp` | `str` | ISO 8601 UTC timestamp emitted at handoff time. | + +Optional but recommended: + +| Field | Type | Description | +|--------------|--------|----------------------------------------------------------------------------| +| `framework` | `str` | The originating adapter's `FRAMEWORK` identifier (e.g. `"google_adk"`). | +| `reason` | `str` | Framework-specific cause (`"team_delegation"`, `"transfer_to_agent"`, …). | + +## The shared helper + +All helpers live in +`src/layerlens/instrument/adapters/_base/handoff.py` and are re- +exported from `layerlens.instrument.adapters._base`. + +### `HandoffSequencer` + +Thread-safe monotonic counter. **One instance per adapter** — +typically constructed in the adapter's `__init__` and held as +`self._handoff_sequencer`. Concurrent agent invocations (asyncio +gathers, threadpool workers, framework callbacks firing from multiple +OS threads) all draw from the same instance, so the lock is mandatory. + +```python +from layerlens.instrument.adapters._base import HandoffSequencer + +class MyAdapter(BaseAdapter): + def __init__(self) -> None: + super().__init__() + self._handoff_sequencer = HandoffSequencer() +``` + +The counter is **1-indexed**: `next()` returns 1 on first call so +downstream consumers can use `handoff_seq > 0` as a "have we +observed any handoffs yet?" predicate without an extra null check. + +### `compute_context_hash(state)` + +Returns a deterministic SHA-256 digest of the handoff context. +Canonicalises via `json.dumps(state, sort_keys=True, default=str)` +so two semantically-equal contexts always hash identically across +machines and Python versions. + +```python +from layerlens.instrument.adapters._base import compute_context_hash + +assert compute_context_hash({"a": 1, "b": 2}) == compute_context_hash({"b": 2, "a": 1}) +``` + +`None` and empty dicts both hash to the digest of `"{}"` so the +returned string is never empty. + +### `make_preview(content, max_chars=256)` + +Returns a length-bounded preview string. Truncates with a single +U+2026 ellipsis so the total length never exceeds `max_chars`. +Coerces non-string values via `str()` and falls back to +`""` if `__str__` raises. + +### `build_handoff_payload(...)` + +Convenience wrapper that allocates the seq, computes the hash, builds +the preview, and assembles a payload dict ready to pass to +`adapter.emit_dict_event("agent.handoff", payload)`. Use this in +preference to wiring the three primitives by hand. + +```python +from layerlens.instrument.adapters._base import build_handoff_payload + +def on_handoff(self, src: str, dst: str, ctx: dict) -> None: + payload = build_handoff_payload( + sequencer=self._handoff_sequencer, + from_agent=src, + to_agent=dst, + context=ctx, + extra={"reason": "delegation", "framework": self.FRAMEWORK}, + ) + self.emit_dict_event("agent.handoff", payload) +``` + +`extra` is merged AFTER the standard fields, but **standard fields +win** — passing `extra={"handoff_seq": 999}` will not override the +real seq. This prevents accidental shadowing of the contract. + +## Adapter authoring guide + +When you add a new framework adapter that emits `agent.handoff`: + +1. **Construct one `HandoffSequencer` per adapter instance** in + `__init__`. Do NOT use a module-level singleton — concurrent + adapter instances must have independent sequence numbers. +2. **Route every handoff emit site through `build_handoff_payload`.** + If your adapter has multiple detection paths (e.g. SDK-callback + AND a manual `on_handoff` hook), all paths must draw seqs from the + same sequencer instance. +3. **Include `framework` and `reason` in `extra`.** These are not + strictly required by the contract but they let dashboards filter + handoffs by source. +4. **Add tests** that verify the standardised metadata appears. See + `tests/instrument/adapters/_base/test_handoff.py` for a complete + helper test suite, and any of the five adapter test files (e.g. + `test_agno_adapter.py::test_team_delegation_emits_standardized_metadata`) + for per-adapter integration patterns. + +## Adapters following this contract + +As of 2026-04-25: + +| Adapter | File:Line | Notes | +|--------------------|--------------------------------------------------------|------------------------------------------------| +| agno | `frameworks/agno/lifecycle.py` | Two emit sites (team delegation + on_handoff). | +| openai_agents | `frameworks/openai_agents/lifecycle.py` | Two emit sites (HandoffSpanData + on_handoff). | +| llama_index | `frameworks/llama_index/lifecycle.py` | One emit site (on_handoff). | +| google_adk | `frameworks/google_adk/lifecycle.py` | One emit site (transfer_to_agent). | +| ms_agent_framework | `frameworks/ms_agent_framework/lifecycle.py` | Two emit sites (group-chat-turn + on_handoff). | + +The mature adapters (LangChain, LangGraph, CrewAI, AutoGen, Agentforce, +Semantic Kernel) currently use bespoke implementations of the same +contract — they will migrate to the shared helper in a follow-up PR +once their independent test suites are stable. diff --git a/src/layerlens/instrument/adapters/_base/__init__.py b/src/layerlens/instrument/adapters/_base/__init__.py index e1008fee..4492cecb 100644 --- a/src/layerlens/instrument/adapters/_base/__init__.py +++ b/src/layerlens/instrument/adapters/_base/__init__.py @@ -25,6 +25,14 @@ ALWAYS_ENABLED_EVENT_TYPES, CaptureConfig, ) +from layerlens.instrument.adapters._base.handoff import ( + DEFAULT_PREVIEW_MAX_CHARS, + HandoffMetadata, + HandoffSequencer, + make_preview, + compute_context_hash, + build_handoff_payload, +) from layerlens.instrument.adapters._base.registry import AdapterRegistry from layerlens.instrument.adapters._base.pydantic_compat import ( PydanticCompat, @@ -40,10 +48,16 @@ "AdapterStatus", "BaseAdapter", "CaptureConfig", + "DEFAULT_PREVIEW_MAX_CHARS", "EventSink", + "HandoffMetadata", + "HandoffSequencer", "IngestionPipelineSink", "PydanticCompat", "ReplayableTrace", "TraceStoreSink", + "build_handoff_payload", + "compute_context_hash", + "make_preview", "requires_pydantic", ] diff --git a/src/layerlens/instrument/adapters/_base/handoff.py b/src/layerlens/instrument/adapters/_base/handoff.py new file mode 100644 index 00000000..70cdbbe2 --- /dev/null +++ b/src/layerlens/instrument/adapters/_base/handoff.py @@ -0,0 +1,338 @@ +"""Shared handoff metadata helpers for LayerLens framework adapters. + +Provides standardised plumbing for ``agent.handoff`` event metadata so +every adapter that emits handoffs surfaces the same downstream contract +to the platform — regardless of which framework produced the event. + +The contract has three components: + +* ``handoff_seq`` — a monotonically increasing integer per adapter + instance. Disambiguates events that share a wall-clock timestamp + (sub-microsecond collisions are common when handoffs fire back-to-back + in async loops). Must be thread-safe because adapters can run multiple + agent invocations concurrently. +* ``context_hash`` — a SHA-256 digest of the serialised handoff context. + Lets the replay engine assert that re-execution received the same + payload that the original run did. Computed via canonical JSON so + semantically-equal contexts hash identically across runs. +* ``preview`` — a length-bounded human-readable summary of the + context. 256 chars by default; an ellipsis (``…``) marks truncation + so dashboards never silently misrepresent payload size. + +Adapter authors should use :func:`build_handoff_payload` to populate +all three fields in a single call. The lower-level helpers +(:func:`compute_context_hash`, :func:`make_preview`, +:class:`HandoffSequencer`) are exposed for cases that need finer +control (e.g. re-using the seq counter across multiple emit sites). + +Origin notes — this module consolidates patterns previously duplicated +across: + +* CrewAI ``delegation.py`` (delegation_seq + context_hash + 500-char preview) +* LangGraph ``handoff.py`` (canonical-JSON SHA-256) +* AutoGen ``lifecycle.py`` (message_seq) + +See ``A:/tmp/adapter-cross-pollination-audit.md`` §2.5 for the full +inventory of adapters that previously lacked one or more of these +fields. +""" + +from __future__ import annotations + +import json +import hashlib +import threading +from typing import Any, Dict, Mapping, Optional +from datetime import datetime, timezone +from dataclasses import field, dataclass + +__all__ = [ + "DEFAULT_PREVIEW_MAX_CHARS", + "HandoffMetadata", + "HandoffSequencer", + "build_handoff_payload", + "compute_context_hash", + "make_preview", +] + + +# Public constant — keep here so adapters can import the same default +# instead of redefining it. Chosen to match the existing OpenTelemetry / +# StreamingSpan convention of "small enough to fit in a Kafka record +# header, large enough to be useful in a dashboard tooltip." +DEFAULT_PREVIEW_MAX_CHARS: int = 256 + +# Hash prefix discriminator. Using ``sha256:`` instead of a bare hex +# string future-proofs the contract: if we ever rotate to BLAKE3 / SHA-3 +# the platform can branch on prefix without reading every emitter. +_HASH_PREFIX: str = "sha256:" + +# Truncation marker. Single-character ellipsis (U+2026) keeps the +# truncated string within the requested cap by exactly 1 char of +# overhead — using ``"..."`` would steal three chars instead. +_ELLIPSIS: str = "…" + + +@dataclass +class HandoffMetadata: + """Standardised metadata for a single ``agent.handoff`` event. + + Every adapter that emits ``agent.handoff`` should populate one of + these (typically via :func:`build_handoff_payload`) so the platform + receives a consistent shape regardless of upstream framework. + + Attributes: + seq: Monotonically increasing sequence number scoped to the + emitting adapter instance. Starts at 1. + context_hash: ``"sha256:" + hex_digest`` of the canonicalised + handoff context. Empty / ``None`` contexts hash to the + digest of ``"{}"`` so the field is never absent. + preview: Truncated, human-readable rendering of the context + (≤ ``DEFAULT_PREVIEW_MAX_CHARS`` by default). + from_agent: Identifier of the delegating agent. + to_agent: Identifier of the receiving agent. + timestamp: Wall-clock timestamp of the handoff. UTC, populated + via :func:`datetime.datetime.now` at construction. + """ + + seq: int + context_hash: str + preview: str + from_agent: str + to_agent: str + timestamp: datetime = field(default_factory=lambda: datetime.now(timezone.utc)) + + def to_payload(self) -> Dict[str, Any]: + """Render the metadata as a flat dict for ``emit_dict_event``. + + Returns a new dict each call (no aliasing). Uses ``isoformat`` + so the timestamp serialises cleanly through the JSON-based + event sinks downstream. + """ + return { + "handoff_seq": self.seq, + "context_hash": self.context_hash, + "context_preview": self.preview, + "from_agent": self.from_agent, + "to_agent": self.to_agent, + "timestamp": self.timestamp.isoformat(), + } + + +def compute_context_hash(state: Optional[Mapping[str, Any]]) -> str: + """Return a deterministic SHA-256 digest of ``state``. + + The state is serialised via canonical JSON (``sort_keys=True``, + ``default=str`` for non-JSON-native values) so: + + * Two semantically-equal contexts always hash identically across + machines, Python versions, and adapter instances. + * Non-serialisable values (datetimes, custom objects, sets) are + coerced to ``str`` via the JSON ``default=`` hook rather than + raising — handoff hashing must never break the emitting agent. + + Args: + state: The handoff context. ``None`` and empty dicts both hash + to the digest of ``"{}"`` so the contract returns a non- + empty string in every case. + + Returns: + A string of the form ``"sha256:<64 hex chars>"``. + """ + if state is None: + canonical = "{}" + else: + try: + canonical = json.dumps( + dict(state), + sort_keys=True, + default=str, + ensure_ascii=False, + separators=(",", ":"), + ) + except (TypeError, ValueError): + # Last-resort fallback: stringify the whole mapping. We + # still want a stable hash even for pathological inputs. + canonical = repr(sorted(state.items())) if hasattr(state, "items") else repr(state) + + digest = hashlib.sha256(canonical.encode("utf-8", errors="replace")).hexdigest() + return _HASH_PREFIX + digest + + +def make_preview(content: Any, max_chars: int = DEFAULT_PREVIEW_MAX_CHARS) -> str: + """Render ``content`` as a length-bounded preview string. + + Behaviour: + + * ``None`` returns the empty string. + * Non-string values are coerced via ``str()``; if coercion raises + (rare — typically a faulty ``__str__``) the function returns + ``""`` so callers never see an exception. + * Strings longer than ``max_chars`` are truncated and a U+2026 + ellipsis is appended. Total length of the returned string never + exceeds ``max_chars`` — the ellipsis displaces one char of + content rather than appending to it. + * ``max_chars <= 0`` returns the empty string (defensive — callers + that want "no preview" can pass ``0``). + + Args: + content: The value to summarise. + max_chars: Maximum length of the returned string, including the + ellipsis when truncation occurs. + + Returns: + A string of length ``min(len(stringified_content), max_chars)``. + """ + if content is None or max_chars <= 0: + return "" + + if isinstance(content, str): + text = content + else: + try: + text = str(content) + except Exception: + return "" + + if len(text) <= max_chars: + return text + + # Truncate to ``max_chars - 1`` and append the ellipsis so total + # length is exactly ``max_chars``. + return text[: max_chars - 1] + _ELLIPSIS + + +class HandoffSequencer: + """Thread-safe monotonic counter for adapter-scoped handoff seqs. + + One instance per adapter — typically constructed in the adapter's + ``__init__`` and held as ``self._handoff_sequencer``. Concurrent + agent invocations (asyncio gathers, threadpool workers, and + framework callbacks firing from multiple OS threads) all draw from + the same instance, so the lock is mandatory. + + The counter is 1-indexed: ``next()`` returns 1 on first call so + downstream consumers can use ``handoff_seq > 0`` as a "have we + observed any handoffs yet?" predicate without an extra null check. + + Example:: + + class MyAdapter(BaseAdapter): + def __init__(self) -> None: + super().__init__() + self._handoff_sequencer = HandoffSequencer() + + def on_handoff(self, src: str, dst: str, ctx: dict) -> None: + payload = build_handoff_payload( + sequencer=self._handoff_sequencer, + from_agent=src, + to_agent=dst, + context=ctx, + ) + self.emit_dict_event("agent.handoff", payload) + """ + + __slots__ = ("_counter", "_lock") + + def __init__(self) -> None: + self._counter: int = 0 + self._lock: threading.Lock = threading.Lock() + + def next(self) -> int: + """Return the next sequence number (1-indexed, monotonic). + + Holds the lock for the increment + read so two callers cannot + observe the same value. The lock is uncontended in the + single-threaded common case, so overhead is a single CAS. + """ + with self._lock: + self._counter += 1 + return self._counter + + @property + def current(self) -> int: + """The most-recently-issued seq value (0 before any ``next()``). + + Reads do not need the lock because integer assignment is + atomic in CPython and we tolerate the read returning a value + that was already stale by the time the caller used it. This + property exists for diagnostics / dashboards, not for issuing + new IDs. + """ + return self._counter + + def reset(self) -> None: + """Reset the counter back to zero. + + Intended for adapter ``disconnect()`` paths so a subsequent + ``connect()`` doesn't carry over seqs from the previous + session. + """ + with self._lock: + self._counter = 0 + + +def build_handoff_payload( + sequencer: HandoffSequencer, + from_agent: str, + to_agent: str, + context: Optional[Mapping[str, Any]] = None, + preview_text: Optional[str] = None, + preview_max_chars: int = DEFAULT_PREVIEW_MAX_CHARS, + extra: Optional[Mapping[str, Any]] = None, +) -> Dict[str, Any]: + """Assemble a fully-populated handoff event payload. + + Convenience wrapper that: + + 1. Allocates the next ``handoff_seq`` from ``sequencer``. + 2. Computes the canonical SHA-256 ``context_hash``. + 3. Builds the preview from ``preview_text`` if supplied, else from + a stringified ``context``. + 4. Merges ``extra`` (e.g. framework-specific ``reason``, + ``framework`` tag) on top of the standard fields. + + Args: + sequencer: The adapter's :class:`HandoffSequencer`. + from_agent: Source agent identifier. + to_agent: Destination agent identifier. + context: Handoff payload (used for hashing and, when + ``preview_text`` is omitted, for the preview). + preview_text: Optional explicit preview string. Use this when + the framework already provides a human-readable summary + (e.g. CrewAI delegation message) — the helper truncates + it to ``preview_max_chars``. + preview_max_chars: Maximum preview length in characters. + extra: Additional fields to merge into the payload. Keys that + collide with the standard schema are NOT overridden — the + standard fields win, so callers can't accidentally + shadow ``handoff_seq`` etc. + + Returns: + A dict ready to pass to ``adapter.emit_dict_event( + "agent.handoff", payload)``. Always contains the keys: + ``from_agent``, ``to_agent``, ``handoff_seq``, + ``context_hash``, ``context_preview``, ``timestamp``. + """ + seq = sequencer.next() + context_hash = compute_context_hash(context) + + if preview_text is not None: + preview = make_preview(preview_text, max_chars=preview_max_chars) + else: + preview = make_preview(context, max_chars=preview_max_chars) if context else "" + + payload: Dict[str, Any] = { + "from_agent": from_agent, + "to_agent": to_agent, + "handoff_seq": seq, + "context_hash": context_hash, + "context_preview": preview, + "timestamp": datetime.now(timezone.utc).isoformat(), + } + + if extra: + for key, value in extra.items(): + payload.setdefault(key, value) + + return payload diff --git a/src/layerlens/instrument/adapters/frameworks/agno/lifecycle.py b/src/layerlens/instrument/adapters/frameworks/agno/lifecycle.py index 047f2626..836120d1 100644 --- a/src/layerlens/instrument/adapters/frameworks/agno/lifecycle.py +++ b/src/layerlens/instrument/adapters/frameworks/agno/lifecycle.py @@ -14,7 +14,6 @@ import time import uuid -import hashlib import logging import threading from typing import Any @@ -27,6 +26,10 @@ ReplayableTrace, AdapterCapability, ) +from layerlens.instrument.adapters._base.handoff import ( + HandoffSequencer, + build_handoff_payload, +) from layerlens.instrument.adapters._base.pydantic_compat import PydanticCompat logger = logging.getLogger(__name__) @@ -57,6 +60,9 @@ def __init__( self._seen_agents: set[str] = set() self._framework_version: str | None = None self._run_starts: dict[int, int] = {} # thread_id -> start_ns + # Standardised handoff sequence counter (cross-pollination #7). + # Thread-safe — concurrent agent runs draw from one instance. + self._handoff_sequencer = HandoffSequencer() def connect(self) -> None: """Verify Agno availability and prepare the adapter.""" @@ -262,16 +268,17 @@ def _extract_run_details(self, agent: Any, result: Any) -> None: team = getattr(agent, "team", None) if team: members = getattr(team, "members", None) or getattr(team, "agents", None) or [] + leader_name = getattr(agent, "name", "leader") for member in members: member_name = getattr(member, "name", None) or str(member) - self.emit_dict_event( - "agent.handoff", - { - "from_agent": getattr(agent, "name", "leader"), - "to_agent": member_name, - "reason": "team_delegation", - }, + payload = build_handoff_payload( + sequencer=self._handoff_sequencer, + from_agent=leader_name, + to_agent=member_name, + context={"leader": leader_name, "member": member_name}, + extra={"reason": "team_delegation", "framework": "agno"}, ) + self.emit_dict_event("agent.handoff", payload) except Exception: logger.debug("Could not extract run details", exc_info=True) @@ -390,22 +397,27 @@ def on_llm_call( logger.warning("Error in on_llm_call", exc_info=True) def on_handoff(self, from_agent: str, to_agent: str, context: Any = None) -> None: - """Emit agent.handoff event for team delegation.""" + """Emit agent.handoff event for team delegation. + + Uses the shared :class:`HandoffSequencer` + canonical context + hash + bounded preview so agno handoffs follow the same + contract as the mature LangGraph / CrewAI / AutoGen adapters. + """ if not self._connected: return try: - context_str = str(context) if context else "" - self.emit_dict_event( - "agent.handoff", - { - "from_agent": from_agent, - "to_agent": to_agent, - "reason": "agno_team_delegation", - "context_hash": hashlib.sha256(context_str.encode()).hexdigest() - if context_str - else None, - }, + ctx_dict = context if isinstance(context, dict) else ( + {"context": str(context)} if context is not None else None + ) + payload = build_handoff_payload( + sequencer=self._handoff_sequencer, + from_agent=from_agent, + to_agent=to_agent, + context=ctx_dict, + preview_text=str(context) if context is not None else None, + extra={"reason": "agno_team_delegation", "framework": "agno"}, ) + self.emit_dict_event("agent.handoff", payload) except Exception: logger.warning("Error in on_handoff", exc_info=True) diff --git a/src/layerlens/instrument/adapters/frameworks/google_adk/lifecycle.py b/src/layerlens/instrument/adapters/frameworks/google_adk/lifecycle.py index 499e7d8f..2904730e 100644 --- a/src/layerlens/instrument/adapters/frameworks/google_adk/lifecycle.py +++ b/src/layerlens/instrument/adapters/frameworks/google_adk/lifecycle.py @@ -15,7 +15,6 @@ import time import uuid -import hashlib import logging import threading from typing import Any @@ -28,6 +27,10 @@ ReplayableTrace, AdapterCapability, ) +from layerlens.instrument.adapters._base.handoff import ( + HandoffSequencer, + build_handoff_payload, +) from layerlens.instrument.adapters._base.pydantic_compat import PydanticCompat logger = logging.getLogger(__name__) @@ -59,6 +62,8 @@ def __init__( self._model_call_starts: dict[int, int] = {} # thread_id -> start_ns self._tool_call_starts: dict[str, int] = {} self._agent_starts: dict[int, int] = {} # thread_id -> start_ns + # Standardised handoff sequence counter (cross-pollination #7). + self._handoff_sequencer = HandoffSequencer() def connect(self) -> None: try: @@ -326,19 +331,21 @@ def on_handoff(self, from_agent: str, to_agent: str, context: Any = None) -> Non if not self._connected: return try: - context_str = str(context) if context else "" - self.emit_dict_event( - "agent.handoff", - { - "from_agent": from_agent, - "to_agent": to_agent, + ctx_dict = context if isinstance(context, dict) else ( + {"context": str(context)} if context is not None else None + ) + payload = build_handoff_payload( + sequencer=self._handoff_sequencer, + from_agent=from_agent, + to_agent=to_agent, + context=ctx_dict, + preview_text=str(context) if context is not None else None, + extra={ "reason": "transfer_to_agent", - "context_hash": hashlib.sha256(context_str.encode()).hexdigest() - if context_str - else None, - "context_preview": context_str[:500] if context_str else None, + "framework": "google_adk", }, ) + self.emit_dict_event("agent.handoff", payload) except Exception: logger.warning("Error in on_handoff", exc_info=True) diff --git a/src/layerlens/instrument/adapters/frameworks/llama_index/lifecycle.py b/src/layerlens/instrument/adapters/frameworks/llama_index/lifecycle.py index 9c28bb30..feb18d39 100644 --- a/src/layerlens/instrument/adapters/frameworks/llama_index/lifecycle.py +++ b/src/layerlens/instrument/adapters/frameworks/llama_index/lifecycle.py @@ -15,7 +15,6 @@ import time import uuid -import hashlib import logging import threading from typing import Any @@ -28,6 +27,10 @@ ReplayableTrace, AdapterCapability, ) +from layerlens.instrument.adapters._base.handoff import ( + HandoffSequencer, + build_handoff_payload, +) from layerlens.instrument.adapters._base.pydantic_compat import PydanticCompat logger = logging.getLogger(__name__) @@ -58,6 +61,8 @@ def __init__( self._framework_version: str | None = None self._event_handler: Any | None = None self._agent_starts: dict[int, int] = {} # thread_id -> start_ns + # Standardised handoff sequence counter (cross-pollination #7). + self._handoff_sequencer = HandoffSequencer() def connect(self) -> None: try: @@ -396,18 +401,21 @@ def on_handoff(self, from_agent: str, to_agent: str, context: Any = None) -> Non if not self._connected: return try: - context_str = str(context) if context else "" - self.emit_dict_event( - "agent.handoff", - { - "from_agent": from_agent, - "to_agent": to_agent, + ctx_dict = context if isinstance(context, dict) else ( + {"context": str(context)} if context is not None else None + ) + payload = build_handoff_payload( + sequencer=self._handoff_sequencer, + from_agent=from_agent, + to_agent=to_agent, + context=ctx_dict, + preview_text=str(context) if context is not None else None, + extra={ "reason": "agent_workflow_handoff", - "context_hash": hashlib.sha256(context_str.encode()).hexdigest() - if context_str - else None, + "framework": "llama_index", }, ) + self.emit_dict_event("agent.handoff", payload) except Exception: logger.warning("Error in on_handoff", exc_info=True) diff --git a/src/layerlens/instrument/adapters/frameworks/ms_agent_framework/lifecycle.py b/src/layerlens/instrument/adapters/frameworks/ms_agent_framework/lifecycle.py index 838dde67..67ac4075 100644 --- a/src/layerlens/instrument/adapters/frameworks/ms_agent_framework/lifecycle.py +++ b/src/layerlens/instrument/adapters/frameworks/ms_agent_framework/lifecycle.py @@ -14,7 +14,6 @@ import time import uuid -import hashlib import logging import threading from typing import Any @@ -27,6 +26,10 @@ ReplayableTrace, AdapterCapability, ) +from layerlens.instrument.adapters._base.handoff import ( + HandoffSequencer, + build_handoff_payload, +) from layerlens.instrument.adapters._base.pydantic_compat import PydanticCompat logger = logging.getLogger(__name__) @@ -58,6 +61,11 @@ def __init__( self._seen_agents: set[str] = set() self._framework_version: str | None = None self._run_starts: dict[int, int] = {} # thread_id -> start_ns + # Standardised handoff sequence counter (cross-pollination #7). + # Both group-chat-turn detection in ``_process_message`` and the + # manual ``on_handoff`` hook draw from this single instance so + # seqs stay monotonic across detection paths. + self._handoff_sequencer = HandoffSequencer() def connect(self) -> None: """Verify Microsoft Agent Framework availability and prepare the adapter.""" @@ -220,14 +228,20 @@ def _process_message(self, chat: Any, message: Any, current_agent: str) -> None: # Detect agent turn transitions (handoffs in group chat) msg_agent_name = getattr(message, "agent_name", None) or getattr(message, "name", None) if msg_agent_name and msg_agent_name != current_agent: - self.emit_dict_event( - "agent.handoff", - { + msg_content = getattr(message, "content", None) + payload = build_handoff_payload( + sequencer=self._handoff_sequencer, + from_agent=current_agent, + to_agent=msg_agent_name, + context={ "from_agent": current_agent, "to_agent": msg_agent_name, - "reason": "group_chat_turn", + "message_content": msg_content, }, + preview_text=str(msg_content) if msg_content is not None else None, + extra={"reason": "group_chat_turn", "framework": "ms_agent_framework"}, ) + self.emit_dict_event("agent.handoff", payload) # Extract tool calls from message items = getattr(message, "items", None) or [] @@ -404,18 +418,21 @@ def on_handoff(self, from_agent: str, to_agent: str, context: Any = None) -> Non if not self._connected: return try: - context_str = str(context) if context else "" - self.emit_dict_event( - "agent.handoff", - { - "from_agent": from_agent, - "to_agent": to_agent, + ctx_dict = context if isinstance(context, dict) else ( + {"context": str(context)} if context is not None else None + ) + payload = build_handoff_payload( + sequencer=self._handoff_sequencer, + from_agent=from_agent, + to_agent=to_agent, + context=ctx_dict, + preview_text=str(context) if context is not None else None, + extra={ "reason": "group_chat_turn", - "context_hash": hashlib.sha256(context_str.encode()).hexdigest() - if context_str - else None, + "framework": "ms_agent_framework", }, ) + self.emit_dict_event("agent.handoff", payload) except Exception: logger.warning("Error in on_handoff", exc_info=True) diff --git a/src/layerlens/instrument/adapters/frameworks/openai_agents/lifecycle.py b/src/layerlens/instrument/adapters/frameworks/openai_agents/lifecycle.py index 0d664746..ac7fd73b 100644 --- a/src/layerlens/instrument/adapters/frameworks/openai_agents/lifecycle.py +++ b/src/layerlens/instrument/adapters/frameworks/openai_agents/lifecycle.py @@ -18,7 +18,6 @@ import time import uuid -import hashlib import logging import threading from typing import Any @@ -31,6 +30,10 @@ ReplayableTrace, AdapterCapability, ) +from layerlens.instrument.adapters._base.handoff import ( + HandoffSequencer, + build_handoff_payload, +) from layerlens.instrument.adapters._base.pydantic_compat import PydanticCompat logger = logging.getLogger(__name__) @@ -61,6 +64,10 @@ def __init__( self._framework_version: str | None = None self._trace_processor: Any | None = None self._run_starts: dict[int, int] = {} # thread_id -> start_ns + # Standardised handoff sequence counter (cross-pollination #7). + # Both the SDK-span path and the manual ``on_handoff`` hook + # draw from this single instance so seqs stay monotonic. + self._handoff_sequencer = HandoffSequencer() def connect(self) -> None: """Import openai-agents SDK and register trace processor.""" @@ -322,15 +329,20 @@ def _on_handoff_span_start(self, span: Any, data: Any) -> None: def _on_handoff_span_end(self, span: Any, data: Any) -> None: from_agent = getattr(data, "from_agent", None) or "unknown" to_agent = getattr(data, "to_agent", None) or "unknown" - self.emit_dict_event( - "agent.handoff", - { - "from_agent": from_agent, - "to_agent": to_agent, - "reason": "handoff", - "framework": "openai_agents", - }, + # Pull whatever context the SDK exposes on the span for hashing. + context: dict[str, Any] = {} + for attr in ("input", "output", "context", "reason"): + value = getattr(data, attr, None) + if value is not None: + context[attr] = value + payload = build_handoff_payload( + sequencer=self._handoff_sequencer, + from_agent=from_agent, + to_agent=to_agent, + context=context if context else None, + extra={"reason": "handoff", "framework": "openai_agents"}, ) + self.emit_dict_event("agent.handoff", payload) def _on_guardrail_span_end(self, span: Any, data: Any) -> None: guardrail_name = getattr(data, "name", None) or "unknown" @@ -456,20 +468,18 @@ def on_handoff( if not self._connected: return try: - context_str = str(context) if context else "" - context_hash = ( - hashlib.sha256(context_str.encode("utf-8")).hexdigest() if context_str else None + ctx_dict = context if isinstance(context, dict) else ( + {"context": str(context)} if context is not None else None ) - self.emit_dict_event( - "agent.handoff", - { - "from_agent": from_agent, - "to_agent": to_agent, - "reason": "handoff", - "context_hash": context_hash, - "context_preview": context_str[:500] if context_str else None, - }, + payload = build_handoff_payload( + sequencer=self._handoff_sequencer, + from_agent=from_agent, + to_agent=to_agent, + context=ctx_dict, + preview_text=str(context) if context is not None else None, + extra={"reason": "handoff", "framework": "openai_agents"}, ) + self.emit_dict_event("agent.handoff", payload) except Exception: logger.warning("Error in on_handoff", exc_info=True) diff --git a/tests/instrument/adapters/_base/__init__.py b/tests/instrument/adapters/_base/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/instrument/adapters/_base/test_handoff.py b/tests/instrument/adapters/_base/test_handoff.py new file mode 100644 index 00000000..8843bddd --- /dev/null +++ b/tests/instrument/adapters/_base/test_handoff.py @@ -0,0 +1,394 @@ +"""Unit tests for the shared handoff metadata helpers. + +Covers correctness of :func:`compute_context_hash`, +:func:`make_preview`, :class:`HandoffSequencer`, and +:func:`build_handoff_payload`. Together these power the standardised +``agent.handoff`` contract that the 5 lighter adapters (agno, +ms_agent_framework, openai_agents, llama_index, google_adk) emit. +""" + +from __future__ import annotations + +import threading +from typing import Any, Dict, List + +import pytest + +from layerlens.instrument.adapters._base.handoff import ( + DEFAULT_PREVIEW_MAX_CHARS, + HandoffMetadata, + HandoffSequencer, + make_preview, + compute_context_hash, + build_handoff_payload, +) + +# --------------------------------------------------------------------------- +# compute_context_hash +# --------------------------------------------------------------------------- + + +def test_compute_context_hash_returns_prefixed_sha256() -> None: + digest = compute_context_hash({"task": "summarise"}) + assert digest.startswith("sha256:") + # 64 hex chars after the prefix. + assert len(digest) == len("sha256:") + 64 + int(digest.split(":", 1)[1], 16) # Validates hex. + + +def test_compute_context_hash_canonicalises_key_order() -> None: + """Two semantically-equal contexts must hash to the same value.""" + a = {"task": "x", "agent": "alpha"} + b = {"agent": "alpha", "task": "x"} + assert compute_context_hash(a) == compute_context_hash(b) + + +def test_compute_context_hash_distinguishes_different_contexts() -> None: + a = compute_context_hash({"task": "x"}) + b = compute_context_hash({"task": "y"}) + assert a != b + + +def test_compute_context_hash_handles_none() -> None: + """``None`` and empty dict both hash to the same digest of ``{}``.""" + none_hash = compute_context_hash(None) + empty_hash = compute_context_hash({}) + assert none_hash == empty_hash + assert none_hash.startswith("sha256:") + + +def test_compute_context_hash_coerces_non_jsonable_values() -> None: + """Non-JSON-native values (sets, custom objects) must not raise.""" + + class Custom: + def __str__(self) -> str: + return "custom-repr" + + # ``set`` and ``Custom`` would normally break ``json.dumps``; the + # ``default=str`` hook saves us. + digest = compute_context_hash({"tags": {"a", "b"}, "obj": Custom()}) + assert digest.startswith("sha256:") + + +def test_compute_context_hash_is_deterministic_across_calls() -> None: + state = {"k": 1, "list": [1, 2, 3], "nested": {"x": "y"}} + h1 = compute_context_hash(state) + h2 = compute_context_hash(state) + h3 = compute_context_hash(dict(state)) + assert h1 == h2 == h3 + + +# --------------------------------------------------------------------------- +# make_preview +# --------------------------------------------------------------------------- + + +def test_make_preview_short_string_returned_as_is() -> None: + assert make_preview("hello") == "hello" + + +def test_make_preview_truncates_with_ellipsis() -> None: + text = "x" * 1000 + out = make_preview(text, max_chars=10) + assert len(out) == 10 + assert out.endswith("…") + # First nine chars are content, last char is the ellipsis. + assert out[:9] == "x" * 9 + + +def test_make_preview_default_cap_is_256() -> None: + text = "y" * 500 + out = make_preview(text) + assert len(out) == DEFAULT_PREVIEW_MAX_CHARS == 256 + assert out.endswith("…") + + +def test_make_preview_none_returns_empty_string() -> None: + assert make_preview(None) == "" + + +def test_make_preview_zero_max_returns_empty() -> None: + assert make_preview("nonempty", max_chars=0) == "" + assert make_preview("nonempty", max_chars=-1) == "" + + +def test_make_preview_coerces_non_strings() -> None: + assert make_preview(42) == "42" + assert make_preview({"k": "v"}) == "{'k': 'v'}" + + +def test_make_preview_handles_str_failure() -> None: + """A faulty ``__str__`` must not propagate.""" + + class Broken: + def __str__(self) -> str: + raise RuntimeError("nope") + + assert make_preview(Broken()) == "" + + +# --------------------------------------------------------------------------- +# HandoffSequencer +# --------------------------------------------------------------------------- + + +def test_sequencer_starts_at_one() -> None: + seq = HandoffSequencer() + assert seq.current == 0 + assert seq.next() == 1 + assert seq.current == 1 + + +def test_sequencer_is_monotonic() -> None: + seq = HandoffSequencer() + values = [seq.next() for _ in range(100)] + assert values == list(range(1, 101)) + + +def test_sequencer_reset() -> None: + seq = HandoffSequencer() + for _ in range(5): + seq.next() + assert seq.current == 5 + seq.reset() + assert seq.current == 0 + assert seq.next() == 1 + + +def test_sequencer_is_thread_safe() -> None: + """Concurrent ``next()`` calls produce a contiguous, unique set.""" + seq = HandoffSequencer() + n_threads = 20 + n_per_thread = 200 + results: List[int] = [] + results_lock = threading.Lock() + + def worker() -> None: + local: List[int] = [seq.next() for _ in range(n_per_thread)] + with results_lock: + results.extend(local) + + threads = [threading.Thread(target=worker) for _ in range(n_threads)] + for t in threads: + t.start() + for t in threads: + t.join() + + # Every value appears exactly once and the full set is contiguous. + assert len(results) == n_threads * n_per_thread + assert sorted(results) == list(range(1, n_threads * n_per_thread + 1)) + assert seq.current == n_threads * n_per_thread + + +def test_sequencer_independence_across_instances() -> None: + """Two sequencers must not share state.""" + a, b = HandoffSequencer(), HandoffSequencer() + a.next() + a.next() + a.next() + assert a.current == 3 + assert b.current == 0 + assert b.next() == 1 + + +# --------------------------------------------------------------------------- +# build_handoff_payload +# --------------------------------------------------------------------------- + + +def test_build_handoff_payload_populates_required_fields() -> None: + seq = HandoffSequencer() + payload = build_handoff_payload( + sequencer=seq, + from_agent="alpha", + to_agent="beta", + context={"task": "summarise"}, + ) + assert payload["from_agent"] == "alpha" + assert payload["to_agent"] == "beta" + assert payload["handoff_seq"] == 1 + assert payload["context_hash"].startswith("sha256:") + assert "context_preview" in payload + assert "timestamp" in payload + + +def test_build_handoff_payload_seq_advances() -> None: + seq = HandoffSequencer() + p1 = build_handoff_payload(sequencer=seq, from_agent="a", to_agent="b") + p2 = build_handoff_payload(sequencer=seq, from_agent="b", to_agent="c") + assert p1["handoff_seq"] == 1 + assert p2["handoff_seq"] == 2 + + +def test_build_handoff_payload_uses_explicit_preview_text() -> None: + seq = HandoffSequencer() + payload = build_handoff_payload( + sequencer=seq, + from_agent="a", + to_agent="b", + context={"task": "x"}, + preview_text="explicit preview wins", + ) + assert payload["context_preview"] == "explicit preview wins" + + +def test_build_handoff_payload_truncates_explicit_preview() -> None: + seq = HandoffSequencer() + payload = build_handoff_payload( + sequencer=seq, + from_agent="a", + to_agent="b", + preview_text="z" * 1000, + preview_max_chars=50, + ) + assert len(payload["context_preview"]) == 50 + + +def test_build_handoff_payload_preview_falls_back_to_context() -> None: + seq = HandoffSequencer() + payload = build_handoff_payload( + sequencer=seq, + from_agent="a", + to_agent="b", + context={"task": "summarise"}, + ) + # Falls back to stringified context. + assert "task" in payload["context_preview"] + + +def test_build_handoff_payload_empty_context_yields_empty_preview() -> None: + seq = HandoffSequencer() + payload = build_handoff_payload( + sequencer=seq, + from_agent="a", + to_agent="b", + context=None, + ) + assert payload["context_preview"] == "" + # Hash is still populated (digest of empty dict). + assert payload["context_hash"].startswith("sha256:") + + +def test_build_handoff_payload_extra_fields_merged_without_clobbering() -> None: + seq = HandoffSequencer() + payload = build_handoff_payload( + sequencer=seq, + from_agent="a", + to_agent="b", + extra={ + "reason": "delegation", + "framework": "ms_agent_framework", + # Standard keys must NOT be overridden by extras. + "handoff_seq": 999, + "context_hash": "sha256:00", + }, + ) + assert payload["reason"] == "delegation" + assert payload["framework"] == "ms_agent_framework" + assert payload["handoff_seq"] == 1 # Standard wins. + assert payload["context_hash"] != "sha256:00" # Standard wins. + + +def test_build_handoff_payload_same_context_yields_same_hash() -> None: + """Same context → same hash, even with different from/to/seq.""" + seq = HandoffSequencer() + p1 = build_handoff_payload( + sequencer=seq, from_agent="a", to_agent="b", context={"k": "v"} + ) + p2 = build_handoff_payload( + sequencer=seq, from_agent="x", to_agent="y", context={"k": "v"} + ) + assert p1["context_hash"] == p2["context_hash"] + assert p1["handoff_seq"] != p2["handoff_seq"] + + +# --------------------------------------------------------------------------- +# HandoffMetadata.to_payload +# --------------------------------------------------------------------------- + + +def test_handoff_metadata_to_payload_round_trip() -> None: + md = HandoffMetadata( + seq=7, + context_hash="sha256:abc", + preview="preview-text", + from_agent="alpha", + to_agent="beta", + ) + payload: Dict[str, Any] = md.to_payload() + assert payload["handoff_seq"] == 7 + assert payload["context_hash"] == "sha256:abc" + assert payload["context_preview"] == "preview-text" + assert payload["from_agent"] == "alpha" + assert payload["to_agent"] == "beta" + # Timestamp is ISO 8601. + assert "T" in payload["timestamp"] + + +def test_handoff_metadata_to_payload_returns_fresh_dict() -> None: + md = HandoffMetadata( + seq=1, + context_hash="sha256:xx", + preview="", + from_agent="a", + to_agent="b", + ) + p1 = md.to_payload() + p2 = md.to_payload() + assert p1 is not p2 + p1["mutated"] = True + assert "mutated" not in p2 + + +# --------------------------------------------------------------------------- +# Module surface +# --------------------------------------------------------------------------- + + +def test_public_constants_exposed() -> None: + assert DEFAULT_PREVIEW_MAX_CHARS == 256 + + +def test_helpers_importable_from_base_namespace() -> None: + """Re-exports from ``_base/__init__.py`` are wired correctly.""" + from layerlens.instrument.adapters._base import ( + DEFAULT_PREVIEW_MAX_CHARS as DPC, + HandoffMetadata as HM, + HandoffSequencer as HS, + make_preview as mp, + compute_context_hash as cch, + build_handoff_payload as bhp, + ) + + assert DPC == DEFAULT_PREVIEW_MAX_CHARS + assert HM is HandoffMetadata + assert HS is HandoffSequencer + assert bhp is build_handoff_payload + assert cch is compute_context_hash + assert mp is make_preview + + +# --------------------------------------------------------------------------- +# Pytest hygiene +# --------------------------------------------------------------------------- + + +@pytest.mark.parametrize( + "context,expected_substr", + [ + ({"a": 1}, "a"), + ({"long": "x" * 1000}, "x"), + ({}, ""), + ], +) +def test_build_handoff_payload_parametrised( + context: Dict[str, Any], expected_substr: str +) -> None: + seq = HandoffSequencer() + payload = build_handoff_payload( + sequencer=seq, from_agent="a", to_agent="b", context=context + ) + if expected_substr: + assert expected_substr in payload["context_preview"] + else: + assert payload["context_preview"] == "" diff --git a/tests/instrument/adapters/frameworks/test_agno_adapter.py b/tests/instrument/adapters/frameworks/test_agno_adapter.py index 6ea4bc61..332a4d23 100644 --- a/tests/instrument/adapters/frameworks/test_agno_adapter.py +++ b/tests/instrument/adapters/frameworks/test_agno_adapter.py @@ -177,6 +177,54 @@ def test_on_handoff_emits_event_with_context_hash() -> None: assert evt["payload"]["context_hash"] is not None +def test_on_handoff_emits_standardized_metadata() -> None: + """Standardised contract: seq + sha256 hash + bounded preview + timestamp.""" + stratix = _RecordingStratix() + adapter = AgnoAdapter(stratix=stratix, capture_config=CaptureConfig.full()) + adapter.connect() + + adapter.on_handoff(from_agent="leader", to_agent="worker_a", context={"task": "fetch"}) + adapter.on_handoff(from_agent="leader", to_agent="worker_b", context={"task": "summarise"}) + + handoffs = [e for e in stratix.events if e["event_type"] == "agent.handoff"] + assert len(handoffs) == 2 + + for h in handoffs: + assert h["payload"]["context_hash"].startswith("sha256:") + assert "context_preview" in h["payload"] + assert "timestamp" in h["payload"] + assert h["payload"]["framework"] == "agno" + + # Monotonic seqs. + assert handoffs[0]["payload"]["handoff_seq"] == 1 + assert handoffs[1]["payload"]["handoff_seq"] == 2 + # Different contexts → different hashes. + assert handoffs[0]["payload"]["context_hash"] != handoffs[1]["payload"]["context_hash"] + + +def test_team_delegation_emits_standardized_metadata() -> None: + """Team-delegation handoffs follow the same contract.""" + stratix = _RecordingStratix() + adapter = AgnoAdapter(stratix=stratix, capture_config=CaptureConfig.full()) + adapter.connect() + + team = SimpleNamespace(members=[SimpleNamespace(name="m1"), SimpleNamespace(name="m2")]) + agent = _FakeAgent(name="leader", team=team) + adapter.instrument_agent(agent) + agent.run("go") + + handoffs = [e for e in stratix.events if e["event_type"] == "agent.handoff"] + # Two members -> two handoffs. + assert len(handoffs) == 2 + seqs = [h["payload"]["handoff_seq"] for h in handoffs] + assert seqs == sorted(seqs) + assert all(s > 0 for s in seqs) + for h in handoffs: + assert h["payload"]["context_hash"].startswith("sha256:") + assert h["payload"]["from_agent"] == "leader" + assert h["payload"]["reason"] == "team_delegation" + + def test_capture_config_gates_l5a_tool_calls() -> None: """When l5a_tool_calls is disabled, tool.call events do NOT fire.""" stratix = _RecordingStratix() diff --git a/tests/instrument/adapters/frameworks/test_google_adk_adapter.py b/tests/instrument/adapters/frameworks/test_google_adk_adapter.py index 60506fce..7de8ca01 100644 --- a/tests/instrument/adapters/frameworks/test_google_adk_adapter.py +++ b/tests/instrument/adapters/frameworks/test_google_adk_adapter.py @@ -164,6 +164,30 @@ def test_on_handoff_emits_event_with_context_hash() -> None: assert evt["payload"]["context_hash"] is not None +def test_on_handoff_emits_standardized_metadata() -> None: + """transfer_to_agent path follows the same standardised contract.""" + stratix = _RecordingStratix() + adapter = GoogleADKAdapter(stratix=stratix, capture_config=CaptureConfig.full()) + adapter.connect() + + adapter.on_handoff(from_agent="root_agent", to_agent="search_agent", context={"query": "weather"}) + adapter.on_handoff(from_agent="root_agent", to_agent="planner_agent", context={"query": "trip"}) + + handoffs = [e for e in stratix.events if e["event_type"] == "agent.handoff"] + assert len(handoffs) == 2 + + for h in handoffs: + assert h["payload"]["context_hash"].startswith("sha256:") + assert "context_preview" in h["payload"] + assert "timestamp" in h["payload"] + assert h["payload"]["framework"] == "google_adk" + assert h["payload"]["reason"] == "transfer_to_agent" + + assert handoffs[0]["payload"]["handoff_seq"] == 1 + assert handoffs[1]["payload"]["handoff_seq"] == 2 + assert handoffs[0]["payload"]["context_hash"] != handoffs[1]["payload"]["context_hash"] + + def test_capture_config_gates_l3_model_metadata() -> None: """When l3_model_metadata is disabled, model.invoke does NOT fire (handoff still does).""" stratix = _RecordingStratix() diff --git a/tests/instrument/adapters/frameworks/test_llama_index_adapter.py b/tests/instrument/adapters/frameworks/test_llama_index_adapter.py index 6cf5053a..bc091633 100644 --- a/tests/instrument/adapters/frameworks/test_llama_index_adapter.py +++ b/tests/instrument/adapters/frameworks/test_llama_index_adapter.py @@ -156,6 +156,30 @@ def test_on_handoff_emits_event_with_context_hash() -> None: assert evt["payload"]["context_hash"] is not None +def test_on_handoff_emits_standardized_metadata() -> None: + """Standardised contract: seq + sha256 hash + bounded preview + timestamp.""" + stratix = _RecordingStratix() + adapter = LlamaIndexAdapter(stratix=stratix, capture_config=CaptureConfig.full()) + adapter.connect() + + adapter.on_handoff(from_agent="researcher", to_agent="writer", context={"task": "draft"}) + adapter.on_handoff(from_agent="writer", to_agent="editor", context={"task": "review"}) + + handoffs = [e for e in stratix.events if e["event_type"] == "agent.handoff"] + assert len(handoffs) == 2 + + for h in handoffs: + assert h["payload"]["context_hash"].startswith("sha256:") + assert "context_preview" in h["payload"] + assert "timestamp" in h["payload"] + assert h["payload"]["framework"] == "llama_index" + assert h["payload"]["reason"] == "agent_workflow_handoff" + + assert handoffs[0]["payload"]["handoff_seq"] == 1 + assert handoffs[1]["payload"]["handoff_seq"] == 2 + assert handoffs[0]["payload"]["context_hash"] != handoffs[1]["payload"]["context_hash"] + + def test_capture_config_gates_l5a_tool_calls() -> None: stratix = _RecordingStratix() cfg = CaptureConfig(l5a_tool_calls=False) diff --git a/tests/instrument/adapters/frameworks/test_ms_agent_framework_adapter.py b/tests/instrument/adapters/frameworks/test_ms_agent_framework_adapter.py index 24bd6c1b..dcc446b6 100644 --- a/tests/instrument/adapters/frameworks/test_ms_agent_framework_adapter.py +++ b/tests/instrument/adapters/frameworks/test_ms_agent_framework_adapter.py @@ -191,6 +191,33 @@ def test_on_handoff_emits_event_with_context_hash() -> None: assert evt["payload"]["context_hash"] is not None +def test_handoff_emits_standardized_metadata_across_paths() -> None: + """Group-chat-turn detection AND manual on_handoff share one seq counter.""" + stratix = _RecordingStratix() + adapter = MSAgentAdapter(stratix=stratix, capture_config=CaptureConfig.full()) + adapter.connect() + + # Group-chat path. + msg = SimpleNamespace(agent_name="bob", items=[], metadata={}, content="hello") + adapter._process_message(_FakeChat(), msg, current_agent="alice") + # Manual path. + adapter.on_handoff(from_agent="bob", to_agent="charlie", context={"task": "review"}) + + handoffs = [e for e in stratix.events if e["event_type"] == "agent.handoff"] + assert len(handoffs) == 2 + + for h in handoffs: + assert h["payload"]["context_hash"].startswith("sha256:") + assert "context_preview" in h["payload"] + assert "timestamp" in h["payload"] + assert h["payload"]["framework"] == "ms_agent_framework" + assert h["payload"]["reason"] == "group_chat_turn" + + # Single shared sequencer → monotonic seqs across detection paths. + assert handoffs[0]["payload"]["handoff_seq"] == 1 + assert handoffs[1]["payload"]["handoff_seq"] == 2 + + def test_instrument_agent_helper() -> None: chat = _FakeChat(name="helper") adapter = instrument_agent(chat) diff --git a/tests/instrument/adapters/frameworks/test_openai_agents_adapter.py b/tests/instrument/adapters/frameworks/test_openai_agents_adapter.py index 15efd7d2..5a6d509b 100644 --- a/tests/instrument/adapters/frameworks/test_openai_agents_adapter.py +++ b/tests/instrument/adapters/frameworks/test_openai_agents_adapter.py @@ -151,6 +151,36 @@ def test_handoff_span_emits_agent_handoff() -> None: assert evt["payload"]["to_agent"] == "b" +def test_handoff_emits_standardized_metadata() -> None: + """Span and manual handoff paths both produce seq+hash+preview.""" + stratix = _RecordingStratix() + adapter = OpenAIAgentsAdapter(stratix=stratix, capture_config=CaptureConfig.full()) + adapter.connect() + + # Span path. + adapter._on_span_end(_Span(HandoffSpanData(from_agent="a", to_agent="b"))) + # Manual path. + adapter.on_handoff("b", "c", context={"task": "summarise"}) + + handoffs = [e for e in stratix.events if e["event_type"] == "agent.handoff"] + assert len(handoffs) == 2 + + # Both share the standard contract. + for h in handoffs: + assert "handoff_seq" in h["payload"] + assert h["payload"]["context_hash"].startswith("sha256:") + assert "context_preview" in h["payload"] + assert "timestamp" in h["payload"] + assert h["payload"]["framework"] == "openai_agents" + + # Seqs are monotonic across detection paths. + assert handoffs[0]["payload"]["handoff_seq"] == 1 + assert handoffs[1]["payload"]["handoff_seq"] == 2 + + # Manual path's preview reflects the explicit context. + assert "summarise" in handoffs[1]["payload"]["context_preview"] + + def test_guardrail_span_emits_policy_violation() -> None: stratix = _RecordingStratix() adapter = OpenAIAgentsAdapter(stratix=stratix, capture_config=CaptureConfig.full())