mirror of
https://github.com/microsoft/agent-framework.git
synced 2026-06-16 21:04:09 +08:00
Simplify Python hosting core (#6492)
Remove linking, multicast, durable delivery, and host push machinery from the v1 hosting core. Keep those scenarios in a proposed follow-up ADR and update channel packages, samples, docs, tests, and workspace metadata around the smaller host/channel contract. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
committed by
GitHub
Unverified
parent
e5a6e35843
commit
36ce0950e4
@@ -1,32 +1,19 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
"""Tests for ``state_dir`` wired through :class:`AgentFrameworkHost`."""
|
||||
"""Tests for narrowed ``state_dir`` support in :class:`AgentFrameworkHost`."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
import pytest
|
||||
|
||||
from agent_framework_hosting import (
|
||||
AgentFrameworkHost,
|
||||
ChannelContext,
|
||||
ChannelContribution,
|
||||
ChannelIdentity,
|
||||
LinkChallenge,
|
||||
)
|
||||
from agent_framework_hosting import AgentFrameworkHost, ChannelContext, ChannelContribution
|
||||
|
||||
# Skip the whole module when the optional disk extra isn't installed.
|
||||
pytest.importorskip("diskcache")
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Test helpers #
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
|
||||
class _AgentStub:
|
||||
"""Bare-minimum SupportsAgentRun stub for host construction."""
|
||||
|
||||
@@ -42,65 +29,22 @@ class _ChannelStub:
|
||||
return ChannelContribution()
|
||||
|
||||
|
||||
class _NonConfigurableLinker:
|
||||
async def resolve(self, _identity: ChannelIdentity) -> LinkChallenge:
|
||||
return LinkChallenge("link")
|
||||
|
||||
|
||||
class _ConfigurableLinker:
|
||||
def __init__(self) -> None:
|
||||
self.configured_path: Path | None = None
|
||||
|
||||
def configure_link_store_path(self, path: str | Path) -> None:
|
||||
self.configured_path = Path(path)
|
||||
|
||||
async def resolve(self, _identity: ChannelIdentity) -> LinkChallenge:
|
||||
return LinkChallenge("link")
|
||||
|
||||
|
||||
def _close_host_disk(host: AgentFrameworkHost) -> None:
|
||||
"""Mirror the lifespan shutdown ordering for tests that simulate restart.
|
||||
|
||||
The real shutdown order is ``runner.shutdown()`` → ``sessions_store.close()``;
|
||||
both release their advisory file locks so a second host can take ownership.
|
||||
"""
|
||||
runner = host._durable_task_runner
|
||||
try:
|
||||
asyncio.get_event_loop().run_until_complete(runner.shutdown(timeout=1.0))
|
||||
except RuntimeError:
|
||||
# No running loop; spin up a throw-away one.
|
||||
asyncio.run(runner.shutdown(timeout=1.0))
|
||||
"""Release any session-alias store held by ``host``."""
|
||||
if host._sessions_store is not None:
|
||||
host._sessions_store.close()
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# state_dir=None preserves the in-memory contract #
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
|
||||
def test_state_dir_none_keeps_plain_dicts(tmp_path: Path) -> None:
|
||||
"""No store, no sessions persistence, no files written."""
|
||||
def test_state_dir_none_keeps_plain_alias_dict(tmp_path: Path) -> None:
|
||||
"""No store, no alias persistence, no files written."""
|
||||
host = AgentFrameworkHost(target=_AgentStub(), channels=[_ChannelStub()])
|
||||
try:
|
||||
assert host._sessions_store is None
|
||||
assert isinstance(host._session_aliases, dict)
|
||||
assert isinstance(host._active, dict)
|
||||
assert isinstance(host._identities, dict)
|
||||
# No accidental disk writes anywhere under tmp_path.
|
||||
assert list(tmp_path.iterdir()) == []
|
||||
finally:
|
||||
# Nothing to close.
|
||||
pass
|
||||
assert host._sessions_store is None
|
||||
assert isinstance(host._session_aliases, dict)
|
||||
assert list(tmp_path.iterdir()) == []
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Single string state_dir creates default subfolders #
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
|
||||
def test_string_state_dir_creates_subfolders(tmp_path: Path) -> None:
|
||||
"""Passing a single path expands to ``runner/`` and ``sessions/``."""
|
||||
def test_string_state_dir_creates_sessions_subfolder_only(tmp_path: Path) -> None:
|
||||
"""Passing a single path expands to ``sessions/`` plus lazy checkpoint path."""
|
||||
host = AgentFrameworkHost(
|
||||
target=_AgentStub(),
|
||||
channels=[_ChannelStub()],
|
||||
@@ -108,100 +52,42 @@ def test_string_state_dir_creates_subfolders(tmp_path: Path) -> None:
|
||||
)
|
||||
try:
|
||||
assert host._sessions_store is not None
|
||||
assert (tmp_path / "runner").is_dir()
|
||||
assert (tmp_path / "sessions").is_dir()
|
||||
assert not (tmp_path / "runner").exists()
|
||||
assert not (tmp_path / "links").exists()
|
||||
# Checkpoint path is derived but not created for agent targets.
|
||||
assert not (tmp_path / "checkpoints").exists()
|
||||
finally:
|
||||
_close_host_disk(host)
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Per-component override via HostStatePaths-shaped dict #
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
|
||||
def test_per_component_paths(tmp_path: Path) -> None:
|
||||
"""Dict form lets the caller route components to different roots."""
|
||||
runner_dir = tmp_path / "tasks"
|
||||
def test_per_component_session_path(tmp_path: Path) -> None:
|
||||
"""Dict form lets callers route session aliases to a specific root."""
|
||||
sessions_dir = tmp_path / "state"
|
||||
host = AgentFrameworkHost(
|
||||
target=_AgentStub(),
|
||||
channels=[_ChannelStub()],
|
||||
state_dir={"runner": runner_dir, "sessions": sessions_dir},
|
||||
state_dir={"sessions": sessions_dir},
|
||||
)
|
||||
try:
|
||||
assert runner_dir.is_dir()
|
||||
assert sessions_dir.is_dir()
|
||||
# Default subfolders should NOT exist when the caller provides
|
||||
# explicit overrides.
|
||||
assert not (tmp_path / "runner").is_dir() or runner_dir == (tmp_path / "runner")
|
||||
assert not (tmp_path / "sessions").is_dir() or sessions_dir == (tmp_path / "sessions")
|
||||
assert host._sessions_store is not None
|
||||
assert host._checkpoint_location is None
|
||||
finally:
|
||||
_close_host_disk(host)
|
||||
|
||||
|
||||
def test_unknown_component_key_raises(tmp_path: Path) -> None:
|
||||
"""Misspelled keys should fail loudly so the user catches typos."""
|
||||
@pytest.mark.parametrize("key", ["runner", "links", "active", "identities"])
|
||||
def test_removed_state_dir_component_keys_raise(tmp_path: Path, key: str) -> None:
|
||||
"""Obsolete follow-up components should fail loudly instead of becoming no-ops."""
|
||||
with pytest.raises(ValueError, match="unknown"):
|
||||
AgentFrameworkHost(
|
||||
target=_AgentStub(),
|
||||
channels=[_ChannelStub()],
|
||||
state_dir={"runnerr": tmp_path / "x"}, # type: ignore[dict-item]
|
||||
state_dir={key: tmp_path / key}, # type: ignore[dict-item]
|
||||
)
|
||||
|
||||
|
||||
def test_links_state_path_configures_compatible_identity_linker(tmp_path: Path) -> None:
|
||||
"""``state_dir['links']`` is offered to linkers that accept host-owned persistence."""
|
||||
linker = _ConfigurableLinker()
|
||||
host = AgentFrameworkHost(
|
||||
target=_AgentStub(),
|
||||
channels=[_ChannelStub()],
|
||||
identity_linker=linker,
|
||||
state_dir=tmp_path,
|
||||
)
|
||||
try:
|
||||
assert linker.configured_path == tmp_path / "links"
|
||||
finally:
|
||||
_close_host_disk(host)
|
||||
|
||||
|
||||
def test_explicit_links_state_path_without_linker_warns(tmp_path: Path, caplog: pytest.LogCaptureFixture) -> None:
|
||||
"""Explicit ``links`` path with no linker is almost certainly dead config."""
|
||||
with caplog.at_level("WARNING", logger="agent_framework.hosting"):
|
||||
host = AgentFrameworkHost(
|
||||
target=_AgentStub(),
|
||||
channels=[_ChannelStub()],
|
||||
state_dir={"links": tmp_path / "links"},
|
||||
)
|
||||
try:
|
||||
assert any(
|
||||
"state_dir['links']" in rec.message and "no identity_linker" in rec.message for rec in caplog.records
|
||||
)
|
||||
finally:
|
||||
_close_host_disk(host)
|
||||
|
||||
|
||||
def test_links_state_path_with_nonconfigurable_linker_warns(tmp_path: Path, caplog: pytest.LogCaptureFixture) -> None:
|
||||
"""A linker that owns its persistence directly gets a clear warning."""
|
||||
with caplog.at_level("WARNING", logger="agent_framework.hosting"):
|
||||
host = AgentFrameworkHost(
|
||||
target=_AgentStub(),
|
||||
channels=[_ChannelStub()],
|
||||
identity_linker=_NonConfigurableLinker(),
|
||||
state_dir={"links": tmp_path / "links"},
|
||||
)
|
||||
try:
|
||||
assert any(
|
||||
"state_dir['links']" in rec.message and "SupportsLinkStorePath" in rec.message for rec in caplog.records
|
||||
)
|
||||
finally:
|
||||
_close_host_disk(host)
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Session bookkeeping survives a host restart #
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
|
||||
def test_session_aliases_survive_restart(tmp_path: Path) -> None:
|
||||
"""Aliases written on host #1 must be visible to host #2."""
|
||||
state_dir = tmp_path / "state"
|
||||
@@ -219,84 +105,6 @@ def test_session_aliases_survive_restart(tmp_path: Path) -> None:
|
||||
_close_host_disk(host2)
|
||||
|
||||
|
||||
def test_active_channel_survives_restart(tmp_path: Path) -> None:
|
||||
"""``_active`` must round-trip through the store."""
|
||||
state_dir = tmp_path / "state"
|
||||
|
||||
host1 = AgentFrameworkHost(target=_AgentStub(), channels=[_ChannelStub()], state_dir=state_dir)
|
||||
host1._active["user-1"] = "telegram"
|
||||
host1._active["user-2"] = "responses"
|
||||
_close_host_disk(host1)
|
||||
|
||||
host2 = AgentFrameworkHost(target=_AgentStub(), channels=[_ChannelStub()], state_dir=state_dir)
|
||||
try:
|
||||
assert host2._active["user-1"] == "telegram"
|
||||
assert host2._active["user-2"] == "responses"
|
||||
finally:
|
||||
_close_host_disk(host2)
|
||||
|
||||
|
||||
def test_identities_nested_mutation_survives_restart(tmp_path: Path) -> None:
|
||||
"""Setting ``self._identities[ik][channel] = identity`` must persist.
|
||||
|
||||
This exercises the proxy-inner-dict ``__setitem__`` write-through path,
|
||||
not just the outer-key replacement path.
|
||||
"""
|
||||
state_dir = tmp_path / "state"
|
||||
|
||||
host1 = AgentFrameworkHost(target=_AgentStub(), channels=[_ChannelStub()], state_dir=state_dir)
|
||||
ident_tg = ChannelIdentity("telegram", "tg-123", {"username": "alice"})
|
||||
ident_rsp = ChannelIdentity("responses", "rsp-456")
|
||||
# Mirrors the host-internal path in ``_register_identity``.
|
||||
host1._identities.setdefault("user-1", {})["telegram"] = ident_tg
|
||||
host1._identities.setdefault("user-1", {})["responses"] = ident_rsp
|
||||
host1._identities.setdefault("user-2", {})["telegram"] = ChannelIdentity("telegram", "tg-789")
|
||||
_close_host_disk(host1)
|
||||
|
||||
host2 = AgentFrameworkHost(target=_AgentStub(), channels=[_ChannelStub()], state_dir=state_dir)
|
||||
try:
|
||||
u1 = host2._identities["user-1"]
|
||||
assert set(u1.keys()) == {"telegram", "responses"}
|
||||
assert u1["telegram"].native_id == "tg-123"
|
||||
assert u1["telegram"].attributes["username"] == "alice"
|
||||
assert u1["responses"].native_id == "rsp-456"
|
||||
assert host2._identities["user-2"]["telegram"].native_id == "tg-789"
|
||||
finally:
|
||||
_close_host_disk(host2)
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Explicit durable_task_runner + state_dir['runner'] warns #
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
|
||||
def test_explicit_runner_with_runner_state_warns(tmp_path: Path, caplog: pytest.LogCaptureFixture) -> None:
|
||||
"""Caller-owned runner + state_dir['runner'] → ignore + warn."""
|
||||
from agent_framework_hosting import InProcessTaskRunner
|
||||
|
||||
user_runner = InProcessTaskRunner()
|
||||
try:
|
||||
with caplog.at_level("WARNING"):
|
||||
host = AgentFrameworkHost(
|
||||
target=_AgentStub(),
|
||||
channels=[_ChannelStub()],
|
||||
durable_task_runner=user_runner,
|
||||
allow_in_process_runner=True,
|
||||
state_dir={"runner": tmp_path / "runner"},
|
||||
)
|
||||
assert any("state_dir['runner']" in rec.message for rec in caplog.records)
|
||||
# Sessions store wasn't requested, so still None.
|
||||
assert host._sessions_store is None
|
||||
finally:
|
||||
# user_runner has no disk state, so nothing else to clean up.
|
||||
pass
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Workflow checkpoint integration #
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
|
||||
def _build_simple_workflow() -> Any:
|
||||
"""Build a no-op workflow for checkpoint-wiring tests."""
|
||||
from tests._workflow_fixtures import build_upper_workflow
|
||||
@@ -313,7 +121,6 @@ def test_single_path_state_dir_wires_workflow_checkpoints(tmp_path: Path) -> Non
|
||||
state_dir=tmp_path,
|
||||
)
|
||||
try:
|
||||
# Checkpoint location is derived from the single state_dir.
|
||||
assert host._checkpoint_location == tmp_path / "checkpoints"
|
||||
finally:
|
||||
_close_host_disk(host)
|
||||
@@ -330,7 +137,6 @@ def test_mapping_state_dir_checkpoints_key_wires_workflow_checkpoints(tmp_path:
|
||||
)
|
||||
try:
|
||||
assert host._checkpoint_location == ckpt_dir
|
||||
# No diskcache components were requested.
|
||||
assert host._sessions_store is None
|
||||
finally:
|
||||
_close_host_disk(host)
|
||||
@@ -342,9 +148,7 @@ def test_mapping_state_dir_omits_checkpoints_for_workflow(tmp_path: Path) -> Non
|
||||
host = AgentFrameworkHost(
|
||||
target=workflow,
|
||||
channels=[_ChannelStub()],
|
||||
# No 'checkpoints' key → no checkpoint persistence even though
|
||||
# other components are persisted.
|
||||
state_dir={"runner": tmp_path / "r", "sessions": tmp_path / "s"},
|
||||
state_dir={"sessions": tmp_path / "s"},
|
||||
)
|
||||
try:
|
||||
assert host._checkpoint_location is None
|
||||
@@ -381,7 +185,6 @@ def test_state_dir_checkpoints_for_agent_target_silent_for_single_path(tmp_path:
|
||||
)
|
||||
try:
|
||||
assert host._checkpoint_location is None
|
||||
# ``checkpoints/`` subfolder is not eagerly created (no consumer).
|
||||
assert not (tmp_path / "checkpoints").exists()
|
||||
finally:
|
||||
_close_host_disk(host)
|
||||
@@ -390,7 +193,7 @@ def test_state_dir_checkpoints_for_agent_target_silent_for_single_path(tmp_path:
|
||||
def test_state_dir_checkpoints_for_agent_target_warns_when_explicit(
|
||||
tmp_path: Path, caplog: pytest.LogCaptureFixture
|
||||
) -> None:
|
||||
"""Mapping form with ``checkpoints`` + agent target → warn (dead config)."""
|
||||
"""Mapping form with ``checkpoints`` + agent target → warn."""
|
||||
with caplog.at_level("WARNING", logger="agent_framework.hosting"):
|
||||
host = AgentFrameworkHost(
|
||||
target=_AgentStub(),
|
||||
|
||||
Reference in New Issue
Block a user