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:
co-authored by
Copilot
parent
e5a6e35843
commit
36ce0950e4
@@ -10,7 +10,6 @@ from typing import Any
|
||||
|
||||
from agent_framework_hosting import (
|
||||
AgentFrameworkHost,
|
||||
ChannelIdentity,
|
||||
HostedRunResult,
|
||||
)
|
||||
from starlette.testclient import TestClient
|
||||
@@ -70,22 +69,6 @@ class _FakeAgent:
|
||||
return _coro()
|
||||
|
||||
|
||||
class _RecordingPushChannel:
|
||||
name = "telegram"
|
||||
path = "/telegram"
|
||||
|
||||
def __init__(self) -> None:
|
||||
self.pushes: list[tuple[ChannelIdentity, HostedRunResult]] = []
|
||||
|
||||
def contribute(self, _ctx: Any) -> Any:
|
||||
from agent_framework_hosting import ChannelContribution
|
||||
|
||||
return ChannelContribution()
|
||||
|
||||
async def push(self, identity: ChannelIdentity, payload: HostedRunResult) -> None:
|
||||
self.pushes.append((identity, payload))
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Tests #
|
||||
# --------------------------------------------------------------------------- #
|
||||
@@ -151,7 +134,17 @@ class TestResponsesChannelNonStreaming:
|
||||
# _FakeAgent.create_session stashes the session_id on the dict it returns.
|
||||
assert sess["session_id"] == "resp_42"
|
||||
|
||||
def test_chat_isolation_header_creates_session_when_no_prev_id(self) -> None:
|
||||
def test_chat_isolation_header_ignored_outside_foundry(self) -> None:
|
||||
client, _host, agent = _make_client()
|
||||
with client:
|
||||
client.post(
|
||||
"/responses",
|
||||
json={"input": "x"},
|
||||
headers={"x-agent-chat-isolation-key": "chat-abc"},
|
||||
)
|
||||
assert "session" not in agent.calls[0]["kwargs"]
|
||||
|
||||
def test_chat_isolation_header_creates_session_in_foundry(self, monkeypatch: Any) -> None:
|
||||
"""Foundry-style ``x-agent-chat-isolation-key`` falls back to a session anchor.
|
||||
|
||||
First-turn requests have no ``previous_response_id`` (the client
|
||||
@@ -160,6 +153,7 @@ class TestResponsesChannelNonStreaming:
|
||||
chat key so the host can build a stable per-conversation session
|
||||
that history providers persist under.
|
||||
"""
|
||||
monkeypatch.setenv("FOUNDRY_HOSTING_ENVIRONMENT", "1")
|
||||
client, _host, agent = _make_client()
|
||||
with client:
|
||||
client.post(
|
||||
@@ -171,13 +165,14 @@ class TestResponsesChannelNonStreaming:
|
||||
assert sess is not None
|
||||
assert sess["session_id"] == "chat-abc"
|
||||
|
||||
def test_prev_response_id_wins_over_chat_isolation_header(self) -> None:
|
||||
def test_prev_response_id_wins_over_chat_isolation_header(self, monkeypatch: Any) -> None:
|
||||
"""When both anchors are present, ``previous_response_id`` wins.
|
||||
|
||||
``previous_response_id`` is the protocol-native chain anchor; the
|
||||
header fallback is only meant to bootstrap when no protocol
|
||||
anchor exists.
|
||||
"""
|
||||
monkeypatch.setenv("FOUNDRY_HOSTING_ENVIRONMENT", "1")
|
||||
client, _host, agent = _make_client()
|
||||
with client:
|
||||
client.post(
|
||||
@@ -189,31 +184,11 @@ class TestResponsesChannelNonStreaming:
|
||||
assert sess is not None
|
||||
assert sess["session_id"] == "resp_99"
|
||||
|
||||
def test_response_target_channel_returns_ack_text_when_pushed(self) -> None:
|
||||
agent = _FakeAgent(reply="real reply")
|
||||
push_ch = _RecordingPushChannel()
|
||||
host = AgentFrameworkHost(target=agent, channels=[ResponsesChannel(), push_ch])
|
||||
|
||||
with TestClient(host.app) as client:
|
||||
r = client.post(
|
||||
"/responses",
|
||||
json={
|
||||
"input": "hi",
|
||||
"response_target": "telegram:42",
|
||||
},
|
||||
)
|
||||
assert r.status_code == 200
|
||||
body = r.json()
|
||||
text = body["output"][0]["content"][0]["text"]
|
||||
assert "delivered out-of-band" in text
|
||||
assert push_ch.pushes and push_ch.pushes[0][1].result.text == "real reply"
|
||||
assert push_ch.pushes[0][0].native_id == "42"
|
||||
|
||||
def test_response_hook_can_rewrite_originating_reply(self) -> None:
|
||||
contexts: list[Any] = []
|
||||
seen_kwargs: list[dict[str, Any]] = []
|
||||
|
||||
def hook(result: HostedRunResult, **kwargs: Any) -> HostedRunResult:
|
||||
contexts.append(kwargs["context"])
|
||||
seen_kwargs.append(dict(kwargs))
|
||||
return HostedRunResult(_FakeAgentResponse(text=result.result.text.upper()), session=result.session)
|
||||
|
||||
agent = _FakeAgent(reply="hooked")
|
||||
@@ -225,10 +200,8 @@ class TestResponsesChannelNonStreaming:
|
||||
assert r.status_code == 200
|
||||
body = r.json()
|
||||
assert body["output"][0]["content"][0]["text"] == "HOOKED"
|
||||
assert contexts
|
||||
assert contexts[0].channel_name == "responses"
|
||||
assert contexts[0].originating is True
|
||||
assert contexts[0].destination_identity is None
|
||||
assert seen_kwargs
|
||||
assert seen_kwargs[0]["channel_name"] == "responses"
|
||||
|
||||
|
||||
class TestResponsesChannelStreaming:
|
||||
@@ -252,14 +225,15 @@ class TestResponsesChannelStreaming:
|
||||
def transform(update: _FakeUpdate) -> _FakeUpdate:
|
||||
return _FakeUpdate(text=update.text.upper())
|
||||
|
||||
host = AgentFrameworkHost(target=agent, channels=[ResponsesChannel(stream_transform_hook=transform)])
|
||||
host = AgentFrameworkHost(target=agent, channels=[ResponsesChannel(stream_update_hook=transform)])
|
||||
with TestClient(host.app) as client:
|
||||
r = client.post("/responses", json={"input": "hi", "stream": True})
|
||||
|
||||
assert r.status_code == 200
|
||||
assert '"delta":"HE"' in r.text
|
||||
assert '"delta":"LLO"' in r.text
|
||||
assert '"text":"HELLO"' in r.text
|
||||
# Stream update hooks are update-only; they do not rewrite get_final_response().
|
||||
assert '"text":"hello"' in r.text
|
||||
|
||||
def test_sse_emits_failed_when_stream_raises(self) -> None:
|
||||
# Regression: ResponseOutputMessage.status only accepts in_progress/
|
||||
|
||||
@@ -5,11 +5,9 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
from agent_framework_hosting import ResponseTarget, ResponseTargetKind
|
||||
|
||||
from agent_framework_hosting_responses import (
|
||||
messages_from_responses_input,
|
||||
parse_response_target,
|
||||
parse_responses_identity,
|
||||
parse_responses_request,
|
||||
)
|
||||
@@ -127,64 +125,6 @@ class TestParseResponsesRequest:
|
||||
assert sess.isolation_key == "resp_42"
|
||||
|
||||
|
||||
class TestParseResponseTarget:
|
||||
def test_default_originating_when_missing(self) -> None:
|
||||
assert parse_response_target({}).kind is ResponseTargetKind.ORIGINATING
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"value,expected_kind",
|
||||
[
|
||||
("originating", ResponseTargetKind.ORIGINATING),
|
||||
("active", ResponseTargetKind.ACTIVE),
|
||||
("all_linked", ResponseTargetKind.ALL_LINKED),
|
||||
("none", ResponseTargetKind.NONE),
|
||||
],
|
||||
)
|
||||
def test_bare_string_kinds(self, value: str, expected_kind: ResponseTargetKind) -> None:
|
||||
assert parse_response_target({"response_target": value}).kind is expected_kind
|
||||
|
||||
def test_bare_string_other_becomes_channel(self) -> None:
|
||||
target = parse_response_target({"response_target": "telegram"})
|
||||
assert target == ResponseTarget.channel("telegram")
|
||||
|
||||
def test_bare_string_with_native_id_becomes_channel(self) -> None:
|
||||
target = parse_response_target({"response_target": "telegram:42"})
|
||||
assert target.kind is ResponseTargetKind.CHANNELS
|
||||
assert target.targets == ("telegram:42",)
|
||||
|
||||
def test_list_form(self) -> None:
|
||||
target = parse_response_target({"response_target": ["telegram:42", "originating"]})
|
||||
assert target == ResponseTarget.channels(["telegram:42", "originating"])
|
||||
|
||||
def test_list_drops_non_strings(self) -> None:
|
||||
target = parse_response_target({"response_target": ["telegram", 42, ""]})
|
||||
assert target.targets == ("telegram",)
|
||||
|
||||
def test_empty_list_falls_back_to_originating(self) -> None:
|
||||
target = parse_response_target({"response_target": []})
|
||||
assert target.kind is ResponseTargetKind.ORIGINATING
|
||||
|
||||
def test_dict_with_channels(self) -> None:
|
||||
target = parse_response_target({"response_target": {"channels": ["a", "b"]}})
|
||||
assert target == ResponseTarget.channels(["a", "b"])
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"kind,expected",
|
||||
[
|
||||
("active", ResponseTargetKind.ACTIVE),
|
||||
("all_linked", ResponseTargetKind.ALL_LINKED),
|
||||
("none", ResponseTargetKind.NONE),
|
||||
("originating", ResponseTargetKind.ORIGINATING),
|
||||
],
|
||||
)
|
||||
def test_dict_kind(self, kind: str, expected: ResponseTargetKind) -> None:
|
||||
assert parse_response_target({"response_target": {"kind": kind}}).kind is expected
|
||||
|
||||
def test_malformed_falls_back_to_originating(self) -> None:
|
||||
target = parse_response_target({"response_target": 42})
|
||||
assert target.kind is ResponseTargetKind.ORIGINATING
|
||||
|
||||
|
||||
class TestParseResponsesIdentity:
|
||||
def test_safety_identifier_preferred(self) -> None:
|
||||
ident = parse_responses_identity({"safety_identifier": "abc", "user": "legacy"}, "responses")
|
||||
|
||||
Reference in New Issue
Block a user