Ctrl K
ComunidadGuías…

test/network/test_audit_and_lifecycle.py

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
# Copyright (c) 2026, AG2ai, Inc., AG2ai open-source projects maintainers and core contributors
#
# SPDX-License-Identifier: Apache-2.0

"""Audit log + lifecycle invariants.

Covers:

* ``delegation_depth`` enforcement at hub.
* Hydrate when an adapter is unregistered no longer raises ``KeyError``
  on the next ``post_envelope`` (clear ``ProtocolError`` instead).
* Sweeper ``auto_close`` does not bleed across channels in one tick.
* Channel lifecycle audit records (``channel_created`` / ``_closed``).
* Task lifecycle audit record (``task_terminated``).
"""

import pytest

from ag2 import Agent
from ag2.knowledge import MemoryKnowledgeStore
from ag2.network import (
    EV_TEXT,
    AccessDeniedError,
    Envelope,
    Hub,
    ProtocolError,
    Rule,
)
from ag2.network.adapters.conversation import ConversationAdapter
from ag2.network.channel import (
    ChannelManifest,
    Expectation,
    ParticipantSchema,
)
from ag2.network.hub.audit import (
    AUDIT_KIND_CHANNEL_CLOSED,
    AUDIT_KIND_CHANNEL_CREATED,
    AUDIT_KIND_CHANNEL_EXPIRED,
    AUDIT_KIND_EXPECTATION_VIOLATED,
    AUDIT_KIND_TASK_TERMINATED,
)
from ag2.network.rule import LimitsBlock
from ag2.task import TaskMetadata, TaskSpec, TaskState
from ag2.testing import TestConfig

from ._helpers import _MockClock


def _agent(name: str) -> Agent:
    return Agent(name=name, config=TestConfig())


# ── delegation_depth ────────────────────────────────────────────────────────


@pytest.mark.asyncio
async def test_post_envelope_rejects_envelope_above_delegation_depth() -> None:
    """Hub rejects an envelope whose ``depth`` exceeds the sender's
    ``Rule.limits.delegation_depth`` cap. ``0`` disables the check."""
    store = MemoryKnowledgeStore()
    hub = await Hub.open(store, ttl_sweep_interval=0, expectation_sweep_interval=0)

    capped_rule = Rule(limits=LimitsBlock(delegation_depth=2))
    alice = await hub.register(_agent("alice"), rule=capped_rule)
    bob = await hub.register(_agent("bob"))

    channel = await alice.open(type="conversation", target=bob.agent_id)

    # depth=3 exceeds cap of 2.
    too_deep = Envelope(
        channel_id=channel.channel_id,
        sender_id=alice.agent_id,
        audience=[bob.agent_id],
        event_type=EV_TEXT,
        event_data={"text": "hi"},
        depth=3,
    )
    with pytest.raises(AccessDeniedError):
        await hub.post_envelope(too_deep)

    # depth=2 is at the cap → accepted.
    at_cap = Envelope(
        channel_id=channel.channel_id,
        sender_id=alice.agent_id,
        audience=[bob.agent_id],
        event_type=EV_TEXT,
        event_data={"text": "hi"},
        depth=2,
    )
    await hub.post_envelope(at_cap)

    await hub.close()


@pytest.mark.asyncio
async def test_delegation_depth_zero_disables_cap() -> None:
    """``delegation_depth=0`` means no cap; arbitrary depth is allowed."""
    store = MemoryKnowledgeStore()
    hub = await Hub.open(store, ttl_sweep_interval=0, expectation_sweep_interval=0)

    no_cap = Rule(limits=LimitsBlock(delegation_depth=0))
    alice = await hub.register(_agent("alice"), rule=no_cap)
    bob = await hub.register(_agent("bob"))

    channel = await alice.open(type="conversation", target=bob.agent_id)
    deep = Envelope(
        channel_id=channel.channel_id,
        sender_id=alice.agent_id,
        audience=[bob.agent_id],
        event_type=EV_TEXT,
        event_data={"text": "hi"},
        depth=999,
    )
    await hub.post_envelope(deep)  # accepted

    await hub.close()


# ── Hydrate with missing adapter ────────────────────────────────────────────


@pytest.mark.asyncio
async def test_post_envelope_after_hydrate_without_adapter_state_raises_protocol_error() -> None:
    """If a channel is loaded by ``hydrate()`` while its adapter is
    missing, the channel is kept dormant — its metadata is read-only,
    not added to ``_active_channels``, and ``_adapter_states`` has no
    entry. If an adapter is later registered (so ``_adapter_for``
    succeeds) and a stale envelope arrives, the hub raises a clear
    ``ProtocolError`` instead of bare ``KeyError``."""
    store = MemoryKnowledgeStore()

    custom_manifest = ChannelManifest(
        type="custom_recovery",
        version=1,
        participants=ParticipantSchema(min=2),
        expectations=[],
    )

    class _CustomAdapter(ConversationAdapter):
        def __init__(self) -> None:
            super().__init__()
            self.manifest = custom_manifest

    # First boot: register custom adapter, open channel, persist.
    hub1 = await Hub.open(store, ttl_sweep_interval=0, expectation_sweep_interval=0)
    hub1.register_adapter(_CustomAdapter())
    alice = await hub1.register(_agent("alice"))
    bob = await hub1.register(_agent("bob"))
    channel = await alice.open(type="custom_recovery", target=bob.agent_id)
    channel_id = channel.channel_id
    alice_id = alice.agent_id

    await hub1.close()

    # Second boot: hydrate WITHOUT the custom adapter. Channel is
    # dormant: metadata loaded, but no adapter state, not active.
    hub2 = await Hub.open(store, ttl_sweep_interval=0, expectation_sweep_interval=0)
    assert channel_id in hub2._channels
    assert channel_id not in hub2._active_channels
    assert channel_id not in hub2._adapter_states

    # Now register the adapter post-hydrate (e.g. a recovery script).
    # ``_adapter_for`` will find it; ``_adapter_states`` is still empty.
    hub2.register_adapter(_CustomAdapter())

    # The channel metadata says it's ACTIVE (carried over from boot 1)
    # but no fold ran. ``post_envelope`` must surface a clear error.
    envelope = Envelope(
        channel_id=channel_id,
        sender_id=alice_id,
        audience=None,
        event_type=EV_TEXT,
        event_data={"text": "hi"},
    )
    with pytest.raises(ProtocolError, match="no adapter state"):
        await hub2.post_envelope(envelope)

    await hub2.close()


# ── Sweeper auto_close cross-channel bleed ──────────────────────────────────


@pytest.mark.asyncio
async def test_expectation_tick_processes_all_channels_when_one_auto_closes() -> None:
    """When channel A's expectation fires ``auto_close``, channel B's
    expectations on the same tick are still evaluated.

    Pre-fix: the sweeper ``return``ed out of the whole tick after the
    first ``auto_close``, so channels later in the iteration order
    waited for the next 10s tick.
    """
    clock = _MockClock("2026-01-01T00:00:00+00:00")
    store = MemoryKnowledgeStore()
    hub = await Hub.open(store, clock=clock, ttl_sweep_interval=0, expectation_sweep_interval=0)

    # Custom conversation manifest with an aggressive max_silence that
    # auto_closes — so two channels both violate at the same tick.
    aggressive_manifest = ChannelManifest(
        type="conversation_aggressive",
        version=1,
        participants=ParticipantSchema(min=2),
        expectations=[
            Expectation(
                name="max_silence",
                on_violation="auto_close",
                params={"seconds": 60},
            ),
        ],
    )

    class _AggressiveAdapter(ConversationAdapter):
        def __init__(self) -> None:
            super().__init__()
            self.manifest = aggressive_manifest

    hub.register_adapter(_AggressiveAdapter())

    alice = await hub.register(_agent("alice"))
    bob = await hub.register(_agent("bob"))
    carol = await hub.register(_agent("carol"))
    dave = await hub.register(_agent("dave"))

    sess_ab = await alice.open(type="conversation_aggressive", target=bob.agent_id)
    sess_cd = await carol.open(type="conversation_aggressive", target=dave.agent_id)

    # Advance past max_silence — both should violate.
    clock.advance(120)
    await hub._expectation_tick()

    audit = await hub._audit_log.read_all()
    closed_channel_ids = {r["channel_id"] for r in audit if r["kind"] == AUDIT_KIND_CHANNEL_CLOSED}
    # Both channels auto-closed in a single tick.
    assert sess_ab.channel_id in closed_channel_ids
    assert sess_cd.channel_id in closed_channel_ids

    # Both expectation violations were logged.
    violations = [r for r in audit if r["kind"] == AUDIT_KIND_EXPECTATION_VIOLATED]
    violation_channel_ids = {r["channel_id"] for r in violations}
    assert sess_ab.channel_id in violation_channel_ids
    assert sess_cd.channel_id in violation_channel_ids

    await hub.close()


# ── Channel + task lifecycle audit ──────────────────────────────────────────


@pytest.mark.asyncio
async def test_audit_log_records_channel_created_and_closed() -> None:
    store = MemoryKnowledgeStore()
    hub = await Hub.open(store, ttl_sweep_interval=0, expectation_sweep_interval=0)

    alice = await hub.register(_agent("alice"))
    bob = await hub.register(_agent("bob"))

    pre_audit = len(await hub._audit_log.read_all())
    channel = await alice.open(type="conversation", target=bob.agent_id)
    await hub.close_channel(channel.channel_id, reason="explicit")

    audit = await hub._audit_log.read_all()
    new_records = audit[pre_audit:]
    kinds = [r["kind"] for r in new_records]
    assert AUDIT_KIND_CHANNEL_CREATED in kinds
    assert AUDIT_KIND_CHANNEL_CLOSED in kinds

    closed_record = next(r for r in new_records if r["kind"] == AUDIT_KIND_CHANNEL_CLOSED)
    assert closed_record["channel_id"] == channel.channel_id
    assert closed_record["reason"] == "explicit"

    await hub.close()


@pytest.mark.asyncio
async def test_audit_log_records_channel_expired_on_ttl_sweep() -> None:
    """``EXPIRED`` transitions emit ``channel_expired`` (not ``channel_closed``)."""
    clock = _MockClock("2026-01-01T00:00:00+00:00")
    store = MemoryKnowledgeStore()
    hub = await Hub.open(store, clock=clock, ttl_sweep_interval=0, expectation_sweep_interval=0)

    alice = await hub.register(_agent("alice"))
    bob = await hub.register(_agent("bob"))

    channel = await alice.open(type="conversation", target=bob.agent_id, ttl="60s")
    pre_audit = len(await hub._audit_log.read_all())
    clock.advance(120)
    await hub.expire_due()

    audit = await hub._audit_log.read_all()
    new_records = audit[pre_audit:]
    expired = [
        r for r in new_records if r["kind"] == AUDIT_KIND_CHANNEL_EXPIRED and r["channel_id"] == channel.channel_id
    ]
    assert len(expired) == 1
    assert expired[0]["reason"] == "ttl_expired"

    await hub.close()


@pytest.mark.asyncio
async def test_audit_log_records_task_terminated_on_channel_cascade() -> None:
    """Tasks under a closing channel cascade to ``EXPIRED`` and emit
    ``task_terminated`` audit records carrying ``capability``."""

    store = MemoryKnowledgeStore()
    hub = await Hub.open(store, ttl_sweep_interval=0, expectation_sweep_interval=0)

    alice = await hub.register(_agent("alice"))
    bob = await hub.register(_agent("bob"))
    channel = await alice.open(type="conversation", target=bob.agent_id)

    # Plant a non-terminal capability-tagged task under the channel.
    task_meta = TaskMetadata(
        task_id="task-cap-1",
        owner_id=bob.agent_id,
        spec=TaskSpec(title="analysing", capability="analysis"),
        state=TaskState.RUNNING,
        created_at="2026-01-01T00:00:00+00:00",
        channel_id=channel.channel_id,
    )
    hub._tasks[task_meta.task_id] = task_meta
    hub._channel_tasks.setdefault(channel.channel_id, set()).add(task_meta.task_id)

    pre_audit = len(await hub._audit_log.read_all())
    await hub.close_channel(channel.channel_id, reason="explicit")

    audit = await hub._audit_log.read_all()
    new_records = audit[pre_audit:]
    task_records = [r for r in new_records if r["kind"] == AUDIT_KIND_TASK_TERMINATED]
    assert len(task_records) == 1
    rec = task_records[0]
    assert rec["task_id"] == task_meta.task_id
    assert rec["capability"] == "analysis"
    assert rec["outcome"] == TaskState.EXPIRED.value

    await hub.close()