# Copyright (c) 2026, AG2ai, Inc., AG2ai open-source projects maintainers and core contributors # # SPDX-License-Identifier: Apache-2.0 """Resume observation, capability index, skill render, mutation tests. Three layers covered: * **Skill render** (unit) — ``parse_skill_frontmatter`` + ``render_fallback_skill``. * **Capability index + record_observation** (integration) — register populates the index; ``record_observation`` updates ``Resume.observed`` and adds the agent to the capability bucket; ``unregister`` removes the agent and prunes empty buckets; the index round-trips through ``Hub.hydrate()``; the on-disk JSON cache reflects the in-memory state. * **AgentClient mutation** — ``set_resume`` / ``add_example`` go through the hub and refresh the local cache. * **TaskMirror end-to-end** — ``Task(spec=TaskSpec(title="...", capability="X"), ...)`` inside a notify-handler turn drives ``Hub.record_observation`` automatically. """ import json import tempfile from pathlib import Path import pytest from ag2 import Agent, Context from ag2.knowledge import DiskKnowledgeStore, MemoryKnowledgeStore from ag2.network import ( Hub, HubClient, LocalLink, Passport, ResumeExample, ) from ag2.network.client.skill_render import ( parse_skill_frontmatter, render_fallback_skill, ) from ag2.network.hub.audit import ( AUDIT_KIND_RESUME_SET, RESUME_SOURCE_OBSERVED, RESUME_SOURCE_TENANT, ) from ag2.network.hub.layout import by_capability_path from ag2.network.identity import ( ObservedStat, Resume, ) from ag2.network.task_mirror import TaskMirror from ag2.stream import MemoryStream from ag2.task import Task, TaskSpec, TaskState from ag2.testing import TestConfig from ._helpers import ScriptedConfig def _agent(name: str, *events: object) -> Agent: return Agent(name=name, config=TestConfig(*events)) # ── Skill render unit tests ───────────────────────────────────────────────── class TestParseSkillFrontmatter: def test_parses_basic_frontmatter_and_body(self) -> None: md = "---\nname: alice\ndescription: Senior policy analyst.\n---\n\n## What I do\n\nCost-benefit framing.\n" parsed = parse_skill_frontmatter(md) assert parsed.frontmatter == { "name": "alice", "description": "Senior policy analyst.", } assert "## What I do" in parsed.body assert parsed.body.startswith("\n## What I do") or parsed.body.startswith("## What I do") def test_no_frontmatter_returns_full_body(self) -> None: md = "# alice\n\njust a body\n" parsed = parse_skill_frontmatter(md) assert parsed.frontmatter == {} assert parsed.body == md def test_unterminated_frontmatter_returns_full_body(self) -> None: md = "---\nname: alice\nbut no closing fence" parsed = parse_skill_frontmatter(md) assert parsed.frontmatter == {} assert parsed.body == md def test_skips_empty_and_comment_lines(self) -> None: md = "---\nname: alice\n\n# a comment\nrole: analyst\n---\nbody" parsed = parse_skill_frontmatter(md) assert parsed.frontmatter == {"name": "alice", "role": "analyst"} class TestRenderFallbackSkill: def test_includes_capabilities_domains_and_summary(self) -> None: passport = Passport(name="alice") resume = Resume( claimed_capabilities=["debate", "analysis"], domains=["policy", "economics"], summary="Senior policy analyst.", ) rendered = render_fallback_skill(passport, resume) assert rendered.startswith("---\n") assert "name: alice" in rendered assert "description: Senior policy analyst." in rendered assert "## Capabilities" in rendered assert "- debate" in rendered assert "## Domains" in rendered def test_includes_observed_track_record(self) -> None: passport = Passport(name="bob") resume = Resume( claimed_capabilities=["analysis"], observed={ "analysis": ObservedStat(n=3, completed=2, failed=1), }, ) rendered = render_fallback_skill(passport, resume) assert "## Track record" in rendered assert "analysis" in rendered assert "n=3" in rendered def test_minimal_resume_renders_default_description(self) -> None: passport = Passport(name="solo") resume = Resume() rendered = render_fallback_skill(passport, resume) assert "name: solo" in rendered assert "Network-registered agent." in rendered # ── Capability index integration tests ────────────────────────────────────── @pytest.mark.asyncio async def test_register_populates_capability_index() -> None: store = MemoryKnowledgeStore() hub = await Hub.open(store, ttl_sweep_interval=0, expectation_sweep_interval=0) alice = await hub.register( _agent("alice"), resume=Resume(claimed_capabilities=["debate", "analysis"]), ) assert hub.agents_with_capability("debate") == [alice.agent_id] assert hub.agents_with_capability("analysis") == [alice.agent_id] assert hub.agents_with_capability("missing") == [] await hub.close() @pytest.mark.asyncio async def test_unregister_removes_from_capability_index() -> None: store = MemoryKnowledgeStore() hub = await Hub.open(store, ttl_sweep_interval=0, expectation_sweep_interval=0) alice = await hub.register(_agent("alice"), resume=Resume(claimed_capabilities=["debate"])) bob = await hub.register(_agent("bob"), resume=Resume(claimed_capabilities=["debate"])) assert set(hub.agents_with_capability("debate")) == {alice.agent_id, bob.agent_id} await hub.unregister(alice.agent_id) assert hub.agents_with_capability("debate") == [bob.agent_id] await hub.unregister(bob.agent_id) assert hub.agents_with_capability("debate") == [] # Empty bucket pruned from the index entirely. assert "debate" not in hub._capability_index await hub.close() @pytest.mark.asyncio async def test_capability_index_persisted_to_disk(tmp_path) -> None: store = DiskKnowledgeStore(str(tmp_path)) hub = await Hub.open(store, ttl_sweep_interval=0, expectation_sweep_interval=0) alice = await hub.register( _agent("alice"), resume=Resume(claimed_capabilities=["debate"]), ) raw = await store.read(by_capability_path()) assert raw is not None snapshot = json.loads(raw) assert snapshot == {"debate": [alice.agent_id]} await hub.close() @pytest.mark.asyncio async def test_capability_index_rebuilt_on_hydrate(tmp_path) -> None: store = DiskKnowledgeStore(str(tmp_path)) hub1 = await Hub.open(store, ttl_sweep_interval=0, expectation_sweep_interval=0) alice = await hub1.register( _agent("alice"), resume=Resume( claimed_capabilities=["debate"], observed={"reviews": ObservedStat(n=5, completed=4, failed=1)}, ), ) await hub1.close() store2 = DiskKnowledgeStore(str(tmp_path)) hub2 = await Hub.open(store2, ttl_sweep_interval=0, expectation_sweep_interval=0) # Both claimed and observed capabilities show up in the rebuilt index. assert hub2.agents_with_capability("debate") == [alice.agent_id] assert hub2.agents_with_capability("reviews") == [alice.agent_id] await hub2.close() # ── record_observation tests ──────────────────────────────────────────────── @pytest.mark.asyncio async def test_record_observation_updates_resume_observed_counters() -> None: store = MemoryKnowledgeStore() hub = await Hub.open(store, ttl_sweep_interval=0, expectation_sweep_interval=0) alice = await hub.register( _agent("alice"), resume=Resume(claimed_capabilities=["analysis"]), ) await hub.record_observation( owner_id=alice.agent_id, capability="analysis", outcome=TaskState.COMPLETED, latency_ms=420, ) await hub.record_observation( owner_id=alice.agent_id, capability="analysis", outcome=TaskState.FAILED, ) await hub.record_observation( owner_id=alice.agent_id, capability="analysis", outcome=TaskState.EXPIRED, ) resume = await hub.get_resume(alice.agent_id) stat = resume.observed["analysis"] assert stat.n == 3 assert stat.completed == 1 assert stat.failed == 1 assert stat.expired == 1 assert stat.p50_latency_ms == 420 # last observed value (V1 placeholder) await hub.close() @pytest.mark.asyncio async def test_record_observation_adds_unclaimed_capability_to_index() -> None: store = MemoryKnowledgeStore() hub = await Hub.open(store, ttl_sweep_interval=0, expectation_sweep_interval=0) alice = await hub.register(_agent("alice")) # no claims assert hub.agents_with_capability("emergent") == [] await hub.record_observation( owner_id=alice.agent_id, capability="emergent", outcome=TaskState.COMPLETED, ) assert hub.agents_with_capability("emergent") == [alice.agent_id] await hub.close() @pytest.mark.asyncio async def test_record_observation_ignores_non_terminal_state() -> None: store = MemoryKnowledgeStore() hub = await Hub.open(store, ttl_sweep_interval=0, expectation_sweep_interval=0) alice = await hub.register(_agent("alice")) await hub.record_observation( owner_id=alice.agent_id, capability="x", outcome=TaskState.RUNNING, ) resume = await hub.get_resume(alice.agent_id) assert "x" not in resume.observed await hub.close() # ── AgentClient mutation tests ────────────────────────────────────────────── @pytest.mark.asyncio async def test_agent_client_set_resume_refreshes_local_cache() -> None: store = MemoryKnowledgeStore() hub = await Hub.open(store, ttl_sweep_interval=0, expectation_sweep_interval=0) alice = await hub.register(_agent("alice"), resume=Resume(summary="initial")) await alice.set_resume(Resume(summary="updated", claimed_capabilities=["x"])) assert alice.resume.summary == "updated" assert "x" in alice.resume.claimed_capabilities # ``set_resume`` re-indexes claimed_capabilities so newly-claimed # caps surface under ``peers(action="find", capability=...)``. assert hub.agents_with_capability("x") == [alice.agent_id] # Removing a claim drops the agent from that bucket so the index # stays consistent with the current resume. await alice.set_resume(Resume(summary="updated2", claimed_capabilities=[])) assert hub.agents_with_capability("x") == [] await hub.close() @pytest.mark.asyncio async def test_agent_client_add_example_appends() -> None: store = MemoryKnowledgeStore() hub = await Hub.open(store, ttl_sweep_interval=0, expectation_sweep_interval=0) alice = await hub.register(_agent("alice")) assert alice.resume.examples == [] await alice.add_example(ResumeExample(title="reviewed PR #42", outcome="completed")) await alice.add_example(ResumeExample(title="triaged incident #7", outcome="completed")) fresh = await hub.get_resume(alice.agent_id) titles = [e.title for e in fresh.examples] assert titles == ["reviewed PR #42", "triaged incident #7"] await hub.close() # ── TaskMirror end-to-end via Task lifecycle ──────────────────────────────── @pytest.mark.asyncio async def test_task_mirror_records_observation_on_capability_tagged_task() -> None: """Running ``Task(spec=TaskSpec(title="...", capability=X), ...)`` to completion through a mirror auto-calls ``Hub.record_observation`` and updates ``Resume.observed[X]``. Exercises the mirror plumbing directly (without the LLM tool surface) so this contract is verified independently of the end-to-end notify-handler integration covered in test_tools.py. """ store = MemoryKnowledgeStore() hub = await Hub.open(store, ttl_sweep_interval=0, expectation_sweep_interval=0) bob_agent = Agent(name="bob", config=ScriptedConfig("ack")) bob = await hub.register( bob_agent, resume=Resume(claimed_capabilities=["analysis"]), ) stream = MemoryStream() mirror = TaskMirror(hub=hub, owner_id=bob.agent_id) sub_ids = mirror.attach(stream) try: async with Task( owner_id=bob_agent.name, spec=TaskSpec(title="analysing alice's question", capability="analysis"), context=Context(stream=stream), ) as task: await task.complete(result="done") finally: mirror.detach(stream, sub_ids) fresh = await hub.get_resume(bob.agent_id) assert "analysis" in fresh.observed stat = fresh.observed["analysis"] assert stat.n == 1 assert stat.completed == 1 assert bob.agent_id in hub.agents_with_capability("analysis") await hub.close() @pytest.mark.asyncio async def test_record_observation_writes_audit_with_observed_source() -> None: """Hub-side observation mutations are auditable as ``resume_set`` records with ``source="observed"``, distinct from tenant-driven ``set_resume`` calls (``source="tenant"``).""" store = MemoryKnowledgeStore() hub = await Hub.open(store, ttl_sweep_interval=0, expectation_sweep_interval=0) link = LocalLink(hub) bob_hc = HubClient(link, hub=hub) bob = await bob_hc.register( _agent("bob"), Passport(name="bob"), Resume(claimed_capabilities=["analysis"]), ) pre_audit = len(await hub._audit_log.read_all()) # Tenant-driven update. await bob_hc._hub.set_resume(bob.agent_id, Resume(summary="updated by tenant")) # Hub-driven observation. await hub.record_observation( owner_id=bob.agent_id, capability="analysis", outcome=TaskState.COMPLETED, latency_ms=42, ) audit = await hub._audit_log.read_all() new_records = audit[pre_audit:] resume_records = [r for r in new_records if r["kind"] == AUDIT_KIND_RESUME_SET] sources = [r.get("source") for r in resume_records] assert RESUME_SOURCE_TENANT in sources assert RESUME_SOURCE_OBSERVED in sources observed = next(r for r in resume_records if r.get("source") == RESUME_SOURCE_OBSERVED) assert observed["agent_id"] == bob.agent_id assert observed["capability"] == "analysis" assert observed["outcome"] == TaskState.COMPLETED.value await bob_hc.close() await hub.close() @pytest.mark.asyncio async def test_task_capability_survives_hub_hydrate() -> None: """``TaskSpec.capability`` round-trips through hub persistence so an observation can fire on a terminal event after a hub restart. Pre-fix: ``_task_metadata_to_dict`` dropped ``capability``; on hydrate the spec came back with ``capability=None`` and ``record_observation`` wouldn't fire even after the task terminated. """ with tempfile.TemporaryDirectory() as tmpdir: store = DiskKnowledgeStore(Path(tmpdir)) hub = await Hub.open(store, ttl_sweep_interval=0, expectation_sweep_interval=0) bob_agent = Agent(name="bob", config=ScriptedConfig("ack")) bob = await hub.register( bob_agent, resume=Resume(claimed_capabilities=["analysis"]), ) # Start a capability-tagged task; mirror persists metadata. stream = MemoryStream() mirror = TaskMirror(hub=hub, owner_id=bob.agent_id) sub_ids = mirror.attach(stream) async with Task( owner_id=bob_agent.name, spec=TaskSpec(title="analysing", capability="analysis"), context=Context(stream=stream), ) as task: task_id = task.task_id await task.complete(result="done") mirror.detach(stream, sub_ids) await hub.close() # Restart: new Hub, same store. ``_load_task`` rehydrates # TaskMetadata; the spec must preserve ``capability``. hub2 = await Hub.open(store, ttl_sweep_interval=0, expectation_sweep_interval=0) rehydrated = hub2._tasks[task_id] assert rehydrated.spec.capability == "analysis" await hub2.close() @pytest.mark.asyncio async def test_task_mirror_no_observation_when_capability_absent() -> None: """Untagged tasks emit lifecycle events but don't touch ``observed``.""" store = MemoryKnowledgeStore() hub = await Hub.open(store, ttl_sweep_interval=0, expectation_sweep_interval=0) bob_agent = Agent(name="bob", config=ScriptedConfig("ack")) bob = await hub.register(bob_agent) stream = MemoryStream() mirror = TaskMirror(hub=hub, owner_id=bob.agent_id) sub_ids = mirror.attach(stream) try: async with Task( owner_id=bob_agent.name, spec=TaskSpec(title="untagged work"), context=Context(stream=stream) ) as task: await task.complete(result="ok") finally: mirror.detach(stream, sub_ids) fresh = await hub.get_resume(bob.agent_id) assert fresh.observed == {} await hub.close()