mirror of
https://github.com/microsoft/agent-framework.git
synced 2026-06-16 21:04:09 +08:00
Python: add agent-framework-hosting-discord channel (#6081)
* Add Discord hosting channel Add an alpha agent-framework-hosting-discord package backed by Discord HTTP Interactions. The channel verifies signed slash-command requests, registers commands, runs hosted agents and ChannelCommand handlers, supports originating response hooks, streams by editing the original interaction response, and can push through Discord channel ids. Factor standard channel response-hook context application into hosting core so both host fan-out and originating channel replies use one helper. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Address Discord review chunking feedback Ensure Discord command replies are chunked and streaming preview edits stay under Discord's content limit while final streamed replies continue through the chunked reply path. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * small fix in init * updated lock --------- Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
committed by
GitHub
Unverified
parent
6b822853eb
commit
e8c22caaeb
@@ -71,6 +71,7 @@ from ._types import (
|
||||
RetryPolicy,
|
||||
TaskHandle,
|
||||
TaskStatus,
|
||||
apply_channel_response_hook,
|
||||
apply_response_hook,
|
||||
apply_run_hook,
|
||||
)
|
||||
@@ -134,6 +135,7 @@ __all__ = [
|
||||
"TaskHandle",
|
||||
"TaskStatus",
|
||||
"__version__",
|
||||
"apply_channel_response_hook",
|
||||
"apply_response_hook",
|
||||
"apply_run_hook",
|
||||
"get_current_isolation_keys",
|
||||
|
||||
@@ -81,15 +81,13 @@ from ._types import (
|
||||
ChannelPush,
|
||||
ChannelPushCodec,
|
||||
ChannelRequest,
|
||||
ChannelResponseContext,
|
||||
ChannelResponseHook,
|
||||
DurableTaskPayloadMode,
|
||||
DurableTaskRunner,
|
||||
HostedRunResult,
|
||||
HostStatePaths,
|
||||
PushPayloadNotSerializable,
|
||||
ResponseTargetKind,
|
||||
apply_response_hook,
|
||||
apply_channel_response_hook,
|
||||
)
|
||||
|
||||
if TYPE_CHECKING:
|
||||
@@ -1896,17 +1894,15 @@ class AgentFrameworkHost:
|
||||
contract; richer surfaces stay attribute-level so adding hook
|
||||
support to a new channel does not require updating the Protocol.
|
||||
"""
|
||||
shaped: HostedRunResult[Any] = payload.replace()
|
||||
hook = cast(ChannelResponseHook | None, getattr(channel, "response_hook", None))
|
||||
if callable(hook):
|
||||
ctx = ChannelResponseContext(
|
||||
request=request,
|
||||
channel_name=channel.name,
|
||||
destination_identity=identity,
|
||||
originating=False,
|
||||
is_echo=is_echo,
|
||||
)
|
||||
shaped = await apply_response_hook(hook, shaped, context=ctx)
|
||||
shaped = await apply_channel_response_hook(
|
||||
channel,
|
||||
payload,
|
||||
request=request,
|
||||
destination_identity=identity,
|
||||
originating=False,
|
||||
is_echo=is_echo,
|
||||
clone=True,
|
||||
)
|
||||
await channel.push(identity, shaped)
|
||||
return shaped
|
||||
|
||||
|
||||
@@ -25,7 +25,7 @@ import os
|
||||
from collections.abc import Awaitable, Callable, Mapping, Sequence
|
||||
from dataclasses import dataclass, field
|
||||
from enum import Enum
|
||||
from typing import TYPE_CHECKING, Any, Generic, Literal, Protocol, TypedDict, TypeVar, runtime_checkable
|
||||
from typing import TYPE_CHECKING, Any, Generic, Literal, Protocol, TypedDict, TypeVar, cast, runtime_checkable
|
||||
|
||||
from agent_framework import (
|
||||
AgentResponse,
|
||||
@@ -760,6 +760,52 @@ class ChannelPush(Protocol):
|
||||
async def push(self, identity: ChannelIdentity, payload: HostedRunResult[Any]) -> None: ...
|
||||
|
||||
|
||||
async def apply_channel_response_hook(
|
||||
channel: Channel | ChannelPush,
|
||||
result: HostedRunResult[Any],
|
||||
*,
|
||||
request: ChannelRequest,
|
||||
originating: bool,
|
||||
destination_identity: ChannelIdentity | None = None,
|
||||
is_echo: bool = False,
|
||||
clone: bool = False,
|
||||
) -> HostedRunResult[Any]:
|
||||
"""Apply a channel's optional response hook with the standard context.
|
||||
|
||||
Channels and the host call this helper when they need to shape a
|
||||
:class:`HostedRunResult` for one destination. The helper centralizes the
|
||||
response-hook convention: hooks are discovered from a duck-typed
|
||||
``response_hook`` attribute, called through :func:`apply_response_hook`,
|
||||
and receive a :class:`ChannelResponseContext` that identifies the channel,
|
||||
destination identity, originating-vs-push phase, and echo phase.
|
||||
|
||||
Args:
|
||||
channel: Channel whose ``response_hook`` attribute may shape the payload.
|
||||
result: Hosted run result to pass to the hook.
|
||||
request: Originating channel request.
|
||||
originating: Whether this is the originating channel's synchronous reply.
|
||||
destination_identity: Destination identity for non-originating pushes, or
|
||||
``None`` for originating replies.
|
||||
is_echo: Whether the payload is an echo of the user input.
|
||||
clone: Whether to shallow-clone ``result`` before applying the hook.
|
||||
|
||||
Returns:
|
||||
The original, cloned, or hook-shaped hosted run result.
|
||||
"""
|
||||
shaped = result.replace() if clone else result
|
||||
hook = cast(ChannelResponseHook | None, getattr(channel, "response_hook", None))
|
||||
if not callable(hook):
|
||||
return shaped
|
||||
context = ChannelResponseContext(
|
||||
request=request,
|
||||
channel_name=channel.name,
|
||||
destination_identity=destination_identity,
|
||||
originating=originating,
|
||||
is_echo=is_echo,
|
||||
)
|
||||
return await apply_response_hook(hook, shaped, context=context)
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Durable task runner — pluggable seam for non-originating push fan-out and
|
||||
# (in v1 fast-follow) background runs. See spec §"Durable task runner".
|
||||
@@ -910,6 +956,7 @@ __all__ = [
|
||||
"RetryPolicy",
|
||||
"TaskHandle",
|
||||
"TaskStatus",
|
||||
"apply_channel_response_hook",
|
||||
"apply_response_hook",
|
||||
"apply_run_hook",
|
||||
]
|
||||
|
||||
@@ -7,12 +7,16 @@ from __future__ import annotations
|
||||
from typing import Any
|
||||
|
||||
from agent_framework_hosting import (
|
||||
ChannelContribution,
|
||||
ChannelIdentity,
|
||||
ChannelRequest,
|
||||
ChannelResponseContext,
|
||||
ChannelSession,
|
||||
DurableTaskPayloadMode,
|
||||
HostedRunResult,
|
||||
ResponseTarget,
|
||||
ResponseTargetKind,
|
||||
apply_channel_response_hook,
|
||||
apply_run_hook,
|
||||
)
|
||||
|
||||
@@ -117,6 +121,88 @@ class _DummyTarget:
|
||||
"""
|
||||
|
||||
|
||||
class _DummyChannel:
|
||||
name = "dummy"
|
||||
path = "/dummy"
|
||||
|
||||
def contribute(self, _context: Any) -> ChannelContribution:
|
||||
return ChannelContribution()
|
||||
|
||||
|
||||
class TestApplyChannelResponseHook:
|
||||
async def test_originating_hook_receives_standard_context(self) -> None:
|
||||
request = ChannelRequest(channel="discord", operation="message.create", input="hi")
|
||||
payload = HostedRunResult("original")
|
||||
captured: list[ChannelResponseContext] = []
|
||||
|
||||
async def hook(
|
||||
result: HostedRunResult[Any],
|
||||
*,
|
||||
context: ChannelResponseContext,
|
||||
) -> HostedRunResult[Any]:
|
||||
captured.append(context)
|
||||
return result.replace(result="hooked")
|
||||
|
||||
channel = _DummyChannel()
|
||||
channel.response_hook = hook # type: ignore[attr-defined]
|
||||
|
||||
shaped = await apply_channel_response_hook(channel, payload, request=request, originating=True)
|
||||
|
||||
assert shaped.result == "hooked"
|
||||
assert captured[0].request is request
|
||||
assert captured[0].channel_name == "dummy"
|
||||
assert captured[0].destination_identity is None
|
||||
assert captured[0].originating is True
|
||||
assert captured[0].is_echo is False
|
||||
|
||||
async def test_non_originating_hook_can_clone_before_shaping(self) -> None:
|
||||
request = ChannelRequest(channel="responses", operation="message.create", input="hi")
|
||||
identity = ChannelIdentity(channel="dummy", native_id="user-1")
|
||||
payload = HostedRunResult("original")
|
||||
seen_payloads: list[HostedRunResult[Any]] = []
|
||||
seen_contexts: list[ChannelResponseContext] = []
|
||||
|
||||
def hook(
|
||||
result: HostedRunResult[Any],
|
||||
*,
|
||||
context: ChannelResponseContext,
|
||||
) -> HostedRunResult[Any]:
|
||||
seen_payloads.append(result)
|
||||
seen_contexts.append(context)
|
||||
return result.replace(result="hooked")
|
||||
|
||||
channel = _DummyChannel()
|
||||
channel.response_hook = hook # type: ignore[attr-defined]
|
||||
|
||||
shaped = await apply_channel_response_hook(
|
||||
channel,
|
||||
payload,
|
||||
request=request,
|
||||
destination_identity=identity,
|
||||
originating=False,
|
||||
is_echo=True,
|
||||
clone=True,
|
||||
)
|
||||
|
||||
assert seen_payloads[0] is not payload
|
||||
assert shaped.result == "hooked"
|
||||
assert seen_contexts[0].destination_identity is identity
|
||||
assert seen_contexts[0].originating is False
|
||||
assert seen_contexts[0].is_echo is True
|
||||
|
||||
async def test_missing_hook_returns_payload_or_clone(self) -> None:
|
||||
request = ChannelRequest(channel="responses", operation="message.create", input="hi")
|
||||
payload = HostedRunResult("original")
|
||||
channel = _DummyChannel()
|
||||
|
||||
same = await apply_channel_response_hook(channel, payload, request=request, originating=True)
|
||||
cloned = await apply_channel_response_hook(channel, payload, request=request, originating=True, clone=True)
|
||||
|
||||
assert same is payload
|
||||
assert cloned is not payload
|
||||
assert cloned.result == payload.result
|
||||
|
||||
|
||||
class TestApplyRunHook:
|
||||
"""`apply_run_hook` is the channel-side helper that invokes a
|
||||
`ChannelRunHook` with the standard kwargs (`request` positional,
|
||||
|
||||
Reference in New Issue
Block a user