From e1858a9e7071d379d1df929d7065592a266966da Mon Sep 17 00:00:00 2001 From: Roger Barreto <19890735+RogerBarreto@users.noreply.github.com> Date: Thu, 28 May 2026 11:43:30 +0100 Subject: [PATCH 01/16] Hosting.Channels spec (.NET port of Python hosting channels) Adds docs/specs/003-dotnet-hosting-channels.md, a .NET port of Python's 002-python-hosting-channels.md. Locks 15 design decisions (translate posture, builder-centric composition, Channel + ChannelContribution + capability interfaces, IHostedTargetRunner Foundry-reuse seam, IIdentityAllowlist + IIdentityLinker pipeline, IHostStateStore, IDurableTaskRunner, Generic Host + ASP.NET Core, AgentFrameworkHost naming, per-channel NuGet package layout, IsolationKeys, workflow-agnostic channels, net-new only v1, no hard breaks). --- docs/specs/003-dotnet-hosting-channels.md | 1328 +++++++++++++++++++++ 1 file changed, 1328 insertions(+) create mode 100644 docs/specs/003-dotnet-hosting-channels.md diff --git a/docs/specs/003-dotnet-hosting-channels.md b/docs/specs/003-dotnet-hosting-channels.md new file mode 100644 index 00000000000..0d97fa4f283 --- /dev/null +++ b/docs/specs/003-dotnet-hosting-channels.md @@ -0,0 +1,1328 @@ +--- +status: proposed +contact: RogerBarreto +date: 2026-05-28 +deciders: RogerBarreto +informed: eavanvalkenburg, agent-framework dotnet contributors +--- + +# .NET hosting core and pluggable channels + +> **Posture: translate.** The Python spec [`002-python-hosting-channels.md`](./002-python-hosting-channels.md) is the canonical source of truth for vocabulary, channel taxonomy, identity model, response targets, wire formats, durable-runner posture, and cross-channel continuity semantics. This spec describes how those concepts are translated into idiomatic .NET (`IHostApplicationBuilder` + `IEndpointRouteBuilder` composition on top of ASP.NET Core / Generic Host) and the specific package layout, type names, and lifecycle that the .NET implementation will ship. Where this spec is silent on a behavioral question, the Python spec governs. + +## What are the business goals for this feature? + +Give .NET app authors one low-level hosting surface that can expose a single **hostable target** — either an `AIAgent` or a `Workflow` (or a Foundry hosted-agent handle via a swappable runner) — on one or more **channels** (Responses API, Invocations API, Telegram, and future Discord / Activity Protocol / etc.) without writing per-protocol routing or server glue, **and** let an end user start a conversation on one channel and seamlessly continue it on another against the same target and the same conversation history. + +This consolidates the per-protocol .NET hosting packages that exist today (`Microsoft.Agents.AI.Hosting.OpenAI`, `.Hosting.A2A(.AspNetCore)`, `.Hosting.AGUI.AspNetCore`, `.Hosting.AzureFunctions`, `.Foundry.Hosting`) into a shared composable model where: + +- a single `AgentFrameworkHost` owns ASP.NET Core endpoints (or `IHostedService` lifecycle when no HTTP is required) and channels own protocol shape; +- session identity is **channel-neutral** — channel-supplied native ids are mapped to a stable `IsolationKey` so two channels mounted on the same host can resolve to the **same** `AgentSession` for the same end user; +- channel-native identity is **mapped, not assumed** — the host exposes `IIdentityAllowlist` / `IIdentityLinker` seams (channel-native id → isolation key, plus a one-time-code / OAuth / MFA link ceremony) so cross-channel continuity does not depend on namespaces happening to align; +- response delivery is **decoupled from request origin** — every `ChannelRequest` carries a `ResponseTarget` (`Originating` (default), `Active`, a specific channel, all linked channels, or `None`), so long-running runs can return their result on a different channel than the one that started them; +- channels can be assigned different **confidentiality tiers** so two channels on one host can share an agent without sharing a session; +- **multi-user surfaces** (Telegram groups, forum topics; future Teams channels) are first-class — channels separate user identity from conversation locator with safe defaults (`MentionOnly` addressing, per-user-per-conversation session scoping, link ceremonies redirected to DMs). + +**Success criteria:** + +- A basic multi-channel sample requires only one `builder.AddAgentFrameworkHost(target).AddXxxChannel(...).AddYyyChannel(...)` chain and a single `app.MapAgentFrameworkHost()` call. No hand-written protocol routes, no per-protocol host bootstrap. +- A single `AgentFrameworkHost` configured with `ResponsesChannel` + `TelegramChannel` can be exercised by one end user across both and observe one continuous conversation. +- A user known on one channel can run a host-provided `/link` command on a second channel, complete a one-time-code ceremony, and see subsequent messages on the second channel resolved against the same `AgentSession` as the first. +- A user can submit a long-running run on Telegram with `ResponseTarget = Active`, switch to another channel (Responses, future Activity), and receive the result there as a proactive push — with a poll route as fallback. + +## What is the problem being solved? + +### How do .NET developers solve this today? + +Every protocol surface is its own package with its own `Map*` extension. A developer who wants to expose one agent over both the OpenAI Responses API and a webhook channel has to stand up two hosts and stitch them together by hand: + +```csharp +// Today: developer composes per-protocol Map* calls and writes any non-supported transport by hand. +var builder = WebApplication.CreateBuilder(args); +builder.AddAIAgent(sp => new AzureOpenAIChatClient(...).CreateAIAgent(name: "Weather", instructions: "...")); + +var app = builder.Build(); +app.MapOpenAIResponses("/responses", agentName: "Weather"); // package: Hosting.OpenAI +app.MapA2A("/a2a", agentName: "Weather"); // package: Hosting.A2A.AspNetCore +app.Run(); +``` + +Adding a Telegram bot, Discord bot, or Teams entry point requires leaving this stack entirely: standing up a separate worker, installing a channel SDK, hand-writing the polling/webhook loop, mapping every native update into an `AIAgent.RunAsync` call, and bolting on commands (`/start`, `/new`, `/cancel`, …) — none of which is reusable across other channels. Identity, session continuity, response targeting, and proactive push do not exist as cross-cutting concerns: each developer reinvents them per integration. + +### Why does this problem require a new hosting abstraction? + +The gap is between **owning a hostable target** (an `AIAgent` or a `Workflow`) and **operationalizing it on multiple channels**. Agent Framework already provides agents, workflows, sessions, run inputs, response/update streaming, the `AIAgent` execution seam, and the `Workflow` execution seam. What's missing is a generic host that: + +1. Owns one ASP.NET Core endpoint surface (or pure-worker `IHostedService` set) and one set of lifecycle hooks. +2. Lets channels contribute routes, commands, and startup/shutdown without protocol leakage into the host. +3. Standardizes how protocol requests become agent invocations (input, options, session, streaming) and how results flow back out — including proactive push for non-`Originating` response targets. +4. Owns the identity stack (resolution, linking, authorization, isolation) once instead of per channel. +5. Owns durable continuation, host state (link grants, active-channel ledger, continuation tokens), and isolation-key context propagation once instead of per channel. + +Python has already built this model on `feature/python-hosting`. .NET needs the equivalent so the same agent can be reached over Responses, Invocations, and Telegram simultaneously, resolve to the same session per user, and let third parties ship new channel packages without forking the host. + +## Decisions + +The full grilling log lives in the session glossary. The bullets below summarize what is locked. + +1. **Posture: translate.** Python is canonical. .NET ports the vocabulary, taxonomy, and wire formats faithfully; deviates only where ASP.NET Core mechanics force it. +2. **Composition: builder-centric.** Single happy path: `builder.AddAgentFrameworkHost(target).AddXxxChannel(...)` then `app.MapAgentFrameworkHost()`. No standalone `Map*` extensions in the new packages. +3. **Channel contract: `abstract class Channel` + capability interfaces.** `Channel` is an abstract class with three members (`Name`, `Path`, `Contribute`) so it can grow virtual members non-breakingly. `ChannelContribution` is a record with init-only properties (4 fields: `Routes`, `Commands`, `OnStartup`, `OnShutdown`). Optional cross-cutting capabilities (`IChannelPush`, `IChannelPushCodec`, `IChannelRunHook`, `IChannelResponseHook`, `IChannelStreamTransformHook`, `IConfidentialityTagged`) live as small separate interfaces a channel mixes in. +4. **Channel lifecycle: two-phase split.** `Channel.ConfigureServices(IServiceCollection)` runs at `AddXxxChannel(...)` time (pre-`Build`); `Channel.Contribute(IChannelContext)` runs at `MapAgentFrameworkHost(...)` time (post-`Build`). Matches the long-standing ASP.NET Core `ConfigureServices` + `Configure` split. +5. **Foundry reuse: swappable `IHostedTargetRunner`.** The host registers one runner based on what was passed to `AddAgentFrameworkHost(...)`: `AIAgentRunner` for `AIAgent`, `WorkflowRunner` for `Workflow`, and `Microsoft.Agents.AI.Foundry.Hosting` ships `FoundryHostedAgentRunner` for a remote Foundry hosted-agent handle. Channels never branch on target type. +6. **Identity & authorization: literal port.** Ship our own `IIdentityAllowlist`, `IIdentityLinker`, `AuthorizationContext`, `AllowlistDecision` enum (`Allow` / `Deny` / `Abstain`), and combinators (`AnyOfIdentityAllowlist`) independent of `Microsoft.AspNetCore.Authorization`. Reasons: (a) the Python model has domain shapes (pre/post-link evaluation, abstain tri-state, cross-channel any-of combinator, native-id vs linked-claim) that don't map onto ASP.NET's request-scoped `IAuthorizationHandler` cleanly; (b) non-HTTP channels (Telegram polling, future Discord gateway) have no `HttpContext`; (c) keeps the channel-author packages ASP.NET-free. An optional `AspNetCoreIdentityAllowlistAdapter` shim can be added later for app authors who want to bridge their existing `AuthorizationPolicy` objects. +7. **Host state store: new `IHostStateStore`, separate from `AgentSessionStore`.** Existing `AgentSessionStore` keys per `(AIAgent, conversationId)` — doesn't fit the new state (identity registry, identity-link grants, active-channel ledger, continuation tokens). Ship `InMemoryHostStateStore` and `FileHostStateStore` in v1, configured via `HostStatePathOptions` (optional `Root` shorthand plus optional per-component path overrides — `RunnerPath`, `LinksPath`, `ContinuationsPath`, `LastSeenPath`). Workflow checkpoint storage is **not** an `IHostStateStore` concern (it stays on `WorkflowBuilder.CheckpointStorage` per decision 13). New components add new optional properties non-breakingly. Mirrors Python's `HostStatePaths` TypedDict, which is brand new with this work. +8. **Durable runner: own `IDurableTaskRunner` seam + in-process default + opt-in DTF adapter.** Channels core defines `IDurableTaskRunner` (4 methods: `ScheduleAsync`, `GetAsync`, `CancelAsync`, `ResumeAsync`). `InProcessDurableTaskRunner` ships in core as an `IHostedService` + bounded `Channel` consumer; in-memory unless `HostStatePathOptions.RunnerPath` is set, in which case records persist to disk and replay on `ResumeAsync`. A separate opt-in package `Microsoft.Agents.AI.Hosting.Channels.DurableTask` ships `DurableTaskFrameworkRunner` that wraps the existing `Microsoft.Agents.AI.DurableTask` package for ephemeral runtime modes. +9. **Hosting target: Generic Host + ASP.NET Core.** `AddAgentFrameworkHost(this IHostApplicationBuilder, target)` accepts both `WebApplicationBuilder` (which derives from `IHostApplicationBuilder`) and `HostApplicationBuilder` (pure worker). HTTP routes via `app.MapAgentFrameworkHost(this IEndpointRouteBuilder)`. Non-HTTP channels (Telegram polling, future Discord gateway) auto-start via `IHostedService`. If a registered channel requires `IEndpointRouteBuilder` and the host doesn't have one (pure worker), startup throws a clean error. +10. **Naming: literal port.** Host type = `AgentFrameworkHost`. Builder extensions = `AddAgentFrameworkHost(...)` and `MapAgentFrameworkHost(...)`. Channel-add extensions = `AddResponsesChannel(...)`, `AddInvocationsChannel(...)`, `AddTelegramChannel(...)`. Matches Python class name 1:1; follows the `AddOpenTelemetry` / `AddSignalR` precedent. Always fully qualified (never just `Host`) to avoid colliding with `Microsoft.Extensions.Hosting.Host`. +11. **Packaging: one assembly per channel.** v1 NuGet packages: `Microsoft.Agents.AI.Hosting.Channels` (core), `.Responses`, `.Invocations`, `.Telegram`. `Microsoft.Agents.AI.Foundry.Hosting` gains `FoundryHostedAgentRunner` as an additive type (no break to existing surface). Fast-follow packages: `.Channels.DurableTask`, `.Channels.Discord`, `.Channels.Activity`, `.Channels.EntraId`. +12. **Isolation context propagation: static `IsolationKeys.Current` + DI `IIsolationKeysAccessor`, both backed by `AsyncLocal`.** Distinct from the app-level isolation key produced by `IIdentityResolver`. `IsolationKeys` carries the Foundry runtime's per-request partition hints lifted off `x-agent-user-isolation-key` / `x-agent-chat-isolation-key` headers by ASP.NET Core middleware the host registers automatically. *Providers* (a future Foundry-partitioned history/state store) read `IsolationKeys.Current` to scope backend calls. Channels themselves are oblivious. Header names are the literal port for wire compat with Python. **v1 ships the plumbing only**; no Foundry-aware provider consumes it yet. +13. **Workflow channel surface: workflow-agnostic channels + one InvocationsChannel convenience.** Channels never branch on whether the target is an `AIAgent` or a `Workflow`. The workflow story is carried by (a) `WorkflowRunner : IHostedTargetRunner`, (b) generic `HostedRunResult` with `TResult = WorkflowRunResponse` for workflow targets, (c) free-form `ChannelRequest.Attributes` carrying workflow-specific knobs (reserved keys: `workflow.checkpoint_id`, `workflow.resume_token`), (d) workflow checkpoint storage stays on `WorkflowBuilder` plumbing — `IHostStateStore` does **not** manage workflow checkpoints. One convenience hook ships for v1: `WorkflowInvocationsResponseHook` in `.Hosting.Channels.Invocations` renders `RequestInfoEvent` as a standard envelope `{ "status": "awaiting_input", "request": {...}, "resume_token": "..." }`. App authors handle `RequestInfoEvent` rendering for Telegram / Responses via their own `IChannelResponseHook`. +14. **v1 scope: net-new only; existing extensions untouched.** v1 ships `ResponsesChannel` + `InvocationsChannel` + `TelegramChannel` on the new builder, plus core infrastructure, plus the `FoundryHostedAgentRunner` adapter in `Foundry.Hosting`. **Existing `Hosting.OpenAI` / `Hosting.A2A` / `Hosting.AGUI.AspNetCore` / `Hosting.AzureFunctions` / `Foundry.Hosting` `Map*` extensions stay completely untouched — no `[Obsolete]`, no rewrite, no shim.** Mirrors Python's v1 stance (A2A / AGUI / DevUI are explicitly out of scope for first implementation). Tier 2 migration (`[Obsolete]` recommendations + internal rewrite to delegate to the new builder) is a focused fast-follow release once v1 has stabilized. +15. **Migration: no hard breaks anywhere.** Including in alpha packages. When Tier 2 lands, existing extensions deprecate with at least one release of overlap before any removal is considered. + +## Package layout + +### v1 NuGet packages (new) + +``` +Microsoft.Agents.AI.Hosting.Channels (core) +├── AgentFrameworkHost +├── IAgentFrameworkHostBuilder +├── HostApplicationBuilderHostingChannelsExtensions (AddAgentFrameworkHost on IHostApplicationBuilder) +├── EndpointRouteBuilderHostingChannelsExtensions (MapAgentFrameworkHost on IEndpointRouteBuilder) +├── Channel (abstract class) +├── ChannelContribution (record, init-only) +├── ChannelRequest (record; full Python parity) +├── ChannelSession (record; all fields nullable) +├── SessionMode (enum: Auto / Required / Disabled) +├── ChannelIdentity +├── ChannelCommand +├── ResponseTarget (sealed abstract record + nested cases) +├── HostedRunResult (non-generic base) +├── HostedRunResult (generic envelope) +├── HostedStreamItem (envelope IAsyncEnumerable wraps) +├── IChannelContext (handed to Contribute) +├── ConversationScope (enum: PerUser / PerUserPerConversation / PerConversation) +├── AcceptInGroup (enum: MentionOnly / CommandOnly / MentionOrCommand / All) +├── IChannelPush (capability) +├── ChannelPushContext (per-delivery context) +├── IChannelPushCodec (capability) +├── IChannelRunHook +├── IChannelResponseHook +├── ChannelResponseContext +├── IChannelStreamTransformHook +├── IConfidentialityTagged (link policy tier) +├── IHostedTargetRunner (seam) +│ ├── AIAgentRunner (built in) +│ └── WorkflowRunner (built in) +├── IIdentityAllowlist (Allow / Deny / Abstain tri-state) +├── IIdentityLinker +├── AuthorizationContext (phase, identity, claims, source) +├── AuthorizationOutcome (Allowed / LinkRequired / Denied) +├── AuthorizationPhase (enum: PreLink / PostLink) +├── ClaimSource (enum: None / Channel / Linker) +├── AllowlistDecision (enum) +├── AuthorizationProfile (factory: Open / ForcedLink / NativeAllowlist / LinkedClaimAllowlist / Mixed) +├── AllowAllIdentityAllowlist +├── NativeIdAllowlist +├── LinkedClaimAllowlist +├── AnyOfIdentityAllowlist (combinator) +├── AllOfIdentityAllowlist (combinator) +├── CallableIdentityAllowlist (escape hatch) +├── LinkChallenge +├── LinkedIdentity +├── PrincipalIdentity (linker result; verified claims + native id) +├── OneTimeCodeIdentityLinker (zero-dep built-in) +├── ILinkPolicy (decides which channels may share an isolation key / deliver to one another) +├── LinkPolicyContext (Source / Destination / Operation) +├── AllowAllLinkPolicy +├── SameConfidentialityTierLinkPolicy +├── ExplicitAllowListLinkPolicy +├── DenyAllLinkPolicy +├── IHostStateStore (identity registry + link grants + last-seen + continuations + session reset) +├── ChannelIdentityRegistration (record persisted by the store) +├── LinkGrant (record persisted by the store) +├── LastSeenRecord (record persisted by the store) +├── ContinuationToken (record; status + result + isolation key) +├── InMemoryHostStateStore +├── FileHostStateStore +├── HostStatePathOptions (Root / RunnerPath / LinksPath / ContinuationsPath / LastSeenPath) +├── IDurableTaskRunner (Register / Schedule / Get / Cancel) +├── TaskHandle (record; opaque task id) +├── DurableTaskPayloadMode (enum: Object / Json) +├── RetryPolicy (record) +├── InProcessDurableTaskRunner (IHostedService + bounded Channel) +├── IsolationKeys (record + static AsyncLocal slot) +├── IIsolationKeysAccessor (DI wrapper) +└── IsolationKeysMiddleware (lifts x-agent-*-isolation-key headers) + +Microsoft.Agents.AI.Hosting.Channels.Responses +├── ResponsesChannel +├── ResponsesChannelOptions (Path / RunHook / ExposeConversations / Transports) +└── AgentFrameworkHostBuilderResponsesExtensions (AddResponsesChannel) + +Microsoft.Agents.AI.Hosting.Channels.Invocations +├── InvocationsChannel +├── InvocationsChannelOptions (Path / RunHook / OpenApiSpec) +├── WorkflowInvocationsResponseHook (RequestInfoEvent envelope) +└── AgentFrameworkHostBuilderInvocationsExtensions (AddInvocationsChannel) + +Microsoft.Agents.AI.Hosting.Channels.Telegram +├── TelegramChannel (uses Telegram.Bot) +├── TelegramChannelOptions (BotToken / Transport / Path / ConversationScope / AcceptInGroup / RequireLink / Commands / RegisterNativeCommands) +└── AgentFrameworkHostBuilderTelegramExtensions (AddTelegramChannel) +``` + +### v1 NuGet packages (additive change to existing) + +``` +Microsoft.Agents.AI.Foundry.Hosting (untouched existing surface) +└── FoundryHostedAgentRunner : IHostedTargetRunner (NEW — additive) +``` + +### v1 NuGet packages (untouched) + +``` +Microsoft.Agents.AI.Hosting.OpenAI (unchanged — keeps MapOpenAIResponses) +Microsoft.Agents.AI.Hosting.A2A (unchanged) +Microsoft.Agents.AI.Hosting.A2A.AspNetCore (unchanged) +Microsoft.Agents.AI.Hosting.AGUI.AspNetCore (unchanged) +Microsoft.Agents.AI.Hosting.AzureFunctions (unchanged) +``` + +### Fast-follow packages (post-v1) + +``` +Microsoft.Agents.AI.Hosting.Channels.DurableTask (DurableTaskFrameworkRunner wrapping existing DTF integration) +Microsoft.Agents.AI.Hosting.Channels.Discord (mirrors Python PR #6081) +Microsoft.Agents.AI.Hosting.Channels.Activity (Teams / DirectLine / WebChat via Activity Protocol) +Microsoft.Agents.AI.Hosting.Channels.EntraId (EntraIdentityLinker) +``` + +## API changes + +> All signatures below are draft; final names, nullability annotations, and `Experimental` attributes get sharpened during implementation. The shape and ergonomics are what reviewers should evaluate. Every public type ships with the standard copyright header and is annotated `[Experimental(DiagnosticIds.Experiments.)]` for the v1 release. + +> **Namespace convention.** Public types live in `Microsoft.Agents.AI.Hosting.Channels` (and `*.Responses`, `*.Invocations`, `*.Telegram`). Extension methods follow the repo convention: `IHostApplicationBuilder` extensions live in `namespace Microsoft.Extensions.Hosting`; `IEndpointRouteBuilder` extensions live in `namespace Microsoft.AspNetCore.Builder`; `IServiceCollection` extensions live in `namespace Microsoft.Extensions.DependencyInjection`. Channel-add extensions on `IAgentFrameworkHostBuilder` live in `Microsoft.Agents.AI.Hosting.Channels`. + +### Host + builder + +```csharp +namespace Microsoft.Agents.AI.Hosting.Channels; + +public sealed class AgentFrameworkHost +{ + internal AgentFrameworkHost(IServiceProvider services); + + public IServiceProvider Services { get; } + public IReadOnlyList Channels { get; } + public IHostedTargetRunner TargetRunner { get; } + + public ValueTask RunInBackgroundAsync( + ChannelRequest request, + CancellationToken cancellationToken = default); + + public ValueTask GetContinuationAsync( + string token, + CancellationToken cancellationToken = default); + + public ValueTask ResetSessionAsync( + string isolationKey, + CancellationToken cancellationToken = default); + + public ValueTask AuthorizeAsync( + ChannelIdentity identity, + AuthorizationRequest options, + CancellationToken cancellationToken = default); +} + +public sealed record AuthorizationRequest +{ + public bool RequireLink { get; init; } + public IIdentityAllowlist? Allowlist { get; init; } + public IReadOnlyDictionary? VerifiedClaims { get; init; } + public ConversationContext? ConversationContext { get; init; } +} +``` + +```csharp +namespace Microsoft.Extensions.Hosting; + +public static class HostApplicationBuilderHostingChannelsExtensions +{ + // The three primary target overloads mirror Python's HostableTarget union. + public static IAgentFrameworkHostBuilder AddAgentFrameworkHost( + this IHostApplicationBuilder builder, + AIAgent target, + Action? configure = null); + + public static IAgentFrameworkHostBuilder AddAgentFrameworkHost( + this IHostApplicationBuilder builder, + Workflow target, + Action? configure = null); + + // Keyed overload aligns with existing AddAIAgent(key, ...) ergonomics. + public static IAgentFrameworkHostBuilder AddAgentFrameworkHost( + this IHostApplicationBuilder builder, + string agentKey, + Action? configure = null); + + // Factory overload — host resolves the target lazily so the runner can be replaced from DI. + public static IAgentFrameworkHostBuilder AddAgentFrameworkHost( + this IHostApplicationBuilder builder, + Func targetFactory, + Action? configure = null) + where TTarget : class; +} + +public sealed record AgentFrameworkHostOptions +{ + public IIdentityAllowlist? DefaultAllowlist { get; init; } + public ILinkPolicy? LinkPolicy { get; init; } + public HostStatePathOptions? StatePaths { get; init; } + public string? DefaultDurableRunnerName { get; init; } + public bool AllowInProcessRunnerInEphemeralMode { get; init; } +} +``` + +```csharp +namespace Microsoft.AspNetCore.Builder; + +public static class EndpointRouteBuilderHostingChannelsExtensions +{ + // Returns IEndpointConventionBuilder so authors can attach .RequireAuthorization() etc. on + // every host-owned endpoint at once (e.g. all per-channel route groups). + public static IEndpointConventionBuilder MapAgentFrameworkHost( + this IEndpointRouteBuilder endpoints); +} +``` + +```csharp +namespace Microsoft.Agents.AI.Hosting.Channels; + +public interface IAgentFrameworkHostBuilder +{ + IServiceCollection Services { get; } + AgentFrameworkHostOptions Options { get; } + + // Generic AddChannel + per-channel-package extension methods (AddResponsesChannel, etc.). + IAgentFrameworkHostBuilder AddChannel(Channel channel); + IAgentFrameworkHostBuilder AddChannel(Func factory) + where TChannel : Channel; + + // Replace the default identity linker / allowlist / link policy registration. + IAgentFrameworkHostBuilder UseIdentityLinker() where TLinker : class, IIdentityLinker; + IAgentFrameworkHostBuilder UseDefaultAllowlist(IIdentityAllowlist allowlist); + IAgentFrameworkHostBuilder UseLinkPolicy(ILinkPolicy policy); + + // Replace the default durable runner / host state store registration. + IAgentFrameworkHostBuilder UseDurableTaskRunner() where TRunner : class, IDurableTaskRunner; + IAgentFrameworkHostBuilder UseHostStateStore() where TStore : class, IHostStateStore; +} +``` + +### Channel contract + +```csharp +public abstract class Channel +{ + public abstract string Name { get; } + + // The mount root for this channel's routes. The host wraps Routes in `endpoints.MapGroup(Path)` + // before invoking each action, so route actions should map paths relative to Path. + // Path = "" mounts at the host's own root. + public virtual string Path => string.Empty; + + // Runs at AddChannel time (pre-Build). Channels register their own DI services here. + public virtual void ConfigureServices(IServiceCollection services) { } + + // Runs at MapAgentFrameworkHost time (post-Build). + public abstract ChannelContribution Contribute(IChannelContext context); +} + +public sealed record ChannelContribution +{ + // Each action is invoked with a group builder rooted at Channel.Path. + public IReadOnlyList> Routes { get; init; } = []; + + // Endpoint filters applied to the Path-rooted group (replaces Python's `middleware`). + public IReadOnlyList EndpointFilters { get; init; } = []; + + public IReadOnlyList Commands { get; init; } = []; + public Func? OnStartup { get; init; } + public Func? OnShutdown { get; init; } +} + +public interface IChannelContext +{ + IServiceProvider Services { get; } + IHostStateStore StateStore { get; } + IDurableTaskRunner DurableRunner { get; } + + // Authorization is owned by the host. Channels call this after extracting ChannelIdentity + // and any natively verified claims; the result is an AuthorizationOutcome the channel + // projects onto its protocol (200 / 403 / link-required envelope). + ValueTask AuthorizeAsync( + ChannelIdentity identity, + AuthorizationRequest options, + CancellationToken cancellationToken); + + // The non-generic host run/stream entry. Workflow-friendly base type for results. + ValueTask RunAsync(ChannelRequest request, CancellationToken cancellationToken); + + // Streaming yields HostedStreamItem envelopes (HostedStreamUpdate / HostedStreamEvent / + // HostedStreamCompleted), so the host can surface both typed agent updates and protocol- + // specific events (workflow RequestInfoEvent, AG-UI StateSnapshotEvent, ...) behind one + // stream type. + IAsyncEnumerable StreamAsync(ChannelRequest request, CancellationToken cancellationToken); + + // Schedules outbound delivery for non-originating destinations via the durable runner. + // Resolves the destination set (Originating / Active / Channel / ...) against the configured + // LinkPolicy + IHostStateStore, then enqueues one `hosting.push` task per non-originating + // destination. Originating delivery is NOT scheduled here — channels render their own + // originating reply synchronously. Returns one TaskHandle per scheduled push. + ValueTask> ScheduleResponseAsync( + HostedRunResult result, + ChannelRequest originating, + CancellationToken cancellationToken); +} +``` + +> **Hook discovery and host-managed extensibility.** Built-in channels implement the relevant capability interfaces themselves and pull app-supplied behavior off their options (`ResponsesChannelOptions.RunHook`, `TelegramChannelOptions.ResponseHook`, etc.). The host discovers capabilities by checking `channel is IChannelPush`, `channel is IChannelResponseHook`, etc. Third-party channel authors follow the same pattern: implement the capability on the channel class itself and route app-configurable concerns through the channel's options record. + +### Channel-neutral request envelope + +```csharp +public sealed record ChannelRequest +{ + // Originating channel name (matches Channel.Name). + public required string Channel { get; init; } + + // Operation kind: "message.create", "command.invoke", "approval.respond", ... + public required string Operation { get; init; } + + // Reuses framework input types. Boxed as object because the union spans AIAgentRunInput, + // ChatMessage[], a workflow-typed input, etc. + public required object Input { get; init; } + + // Session hint from the channel. Nullable: caller-supplied channels populate it from the + // wire; host-tracked channels leave it null and let the host per-isolation-key alias decide. + public ChannelSession? Session { get; init; } + + // Channel-native USER identity observed on this request (never the chat / conversation id). + public ChannelIdentity? Identity { get; init; } + + // Protocol-visible conversation/thread identifier when one exists. In multi-user surfaces + // (Telegram groups, Teams team channels) this differs from Identity.NativeId. + public string? ConversationId { get; init; } + + // Caller-derived chat options forwarded onto ChatOptions used by the target runner. Reuses + // Microsoft.Extensions.AI.ChatOptions so chat-client knobs (temperature, top_p, response_format, + // tool choice, additional properties) pass through without translation. + public ChatOptions? Options { get; init; } + + // Whether host-managed session use is automatic, mandatory, or bypassed. + public SessionMode SessionMode { get; init; } = SessionMode.Auto; + + // Protocol-level metadata for telemetry. Host code never reads this; reserved for channel + // private bookkeeping. + public IReadOnlyDictionary Metadata { get; init; } = ImmutableDictionary.Empty; + + // Channel-specific structured values surfaced to the run hook (signature state, capability + // hints, deployment-specific knobs parsed off `extra_body`). Two reserved keys for workflow + // targets: "workflow.checkpoint_id" and "workflow.resume_token" (see "Workflow channels"). + public IReadOnlyDictionary Attributes { get; init; } = ImmutableDictionary.Empty; + + // Bidirectional, mutable per-request state slot for event-rich front-ends (AG-UI). + // Opaque to the host; channels thread it through a channel-owned ContextProvider. + public IDictionary? ClientState { get; init; } + + // Frontend tool catalog supplied per request. Forwarded onto ChatOptions but tool execution + // returns to the client (host never invokes them). + public IReadOnlyList? ClientTools { get; init; } + + // Pass-through bag for channel-protocol extras the run hook needs to route into the target + // (e.g. AG-UI `resume` / `command` / HITL response payloads). Opaque to the host. + public IReadOnlyDictionary? ForwardedProps { get; init; } + + // Whether to invoke StreamAsync rather than RunAsync. + public bool Stream { get; init; } + + // Where the response is delivered. Defaults to ResponseTarget.Originating. + public ResponseTarget? ResponseTarget { get; init; } + + // If true, host returns a ContinuationToken immediately rather than awaiting the response. + // Forced true when ResponseTarget is ResponseTarget.None. + public bool Background { get; init; } +} + +public sealed record ChannelSession +{ + // Stable host lookup key for an AgentSession. Caller-supplied channels populate from the + // wire (previous_response_id, etc.). Host-tracked channels leave null. + public string? Key { get; init; } + + public string? ConversationId { get; init; } + + // Opaque isolation boundary (user, tenant, chat, ...) using hosted-agent terminology. + public string? IsolationKey { get; init; } + + public IReadOnlyDictionary Attributes { get; init; } = ImmutableDictionary.Empty; +} + +public sealed record ChannelIdentity(string Channel, string NativeId) +{ + public IReadOnlyDictionary Attributes { get; init; } = ImmutableDictionary.Empty; +} + +public enum SessionMode { Auto, Required, Disabled } + +// Non-generic base lets channels and the host operate against HostedRunResult without committing +// to a TResult; generic subclass preserves full-fidelity typed access. +public abstract record HostedRunResult +{ + public ChannelSession? Session { get; init; } + public abstract object? ResultObject { get; } +} + +public sealed record HostedRunResult : HostedRunResult +{ + public required TResult Result { get; init; } + public override object? ResultObject => Result; + + // Shallow clone with a rewritten Result (per-destination response-hook rebinding). + public HostedRunResult Replace(TNew newResult) => + new() { Result = newResult, Session = Session }; +} + +// One item produced by IChannelContext.StreamAsync — covers both agent updates and workflow events. +// HostedStreamUpdate wraps the normalized agent stream (lossless for messages, function calls, +// usage). HostedStreamEvent passes through protocol-specific events the framework does not model +// (workflow RequestInfoEvent, AG-UI StateSnapshotEvent, ToolCallStartEvent). HostedStreamCompleted +// is always the terminal item and carries the final HostedRunResult for downstream bookkeeping +// (intended_targets envelope, durable push scheduling). +public abstract record HostedStreamItem; +public sealed record HostedStreamUpdate(AgentRunResponseUpdate Update) : HostedStreamItem; +public sealed record HostedStreamEvent(object Event) : HostedStreamItem; +public sealed record HostedStreamCompleted(HostedRunResult Result) : HostedStreamItem; +``` + +### Response target + +`ResponseTarget` directs where the host delivers the agent response. Independent of `SessionMode`. Mirrors Python's `ResponseTarget` factory + variants. + +```csharp +public abstract record ResponseTarget +{ + public static readonly ResponseTarget Originating = new OriginatingResponseTarget(); + public static readonly ResponseTarget Active = new ActiveResponseTarget(); + public static readonly ResponseTarget AllLinked = new AllLinkedResponseTarget(); + public static readonly ResponseTarget None = new NoneResponseTarget(); + + public static ResponseTarget Channel(string channelName, bool echoInput = false) + => new ChannelResponseTarget(channelName, echoInput); + public static ResponseTarget Channels(IReadOnlyList channelNames, bool echoInput = false) + => new ChannelsResponseTarget(channelNames, echoInput); + public static ResponseTarget Identities(IReadOnlyList identities, bool echoInput = false) + => new IdentitiesResponseTarget(identities, echoInput); + public static ResponseTarget Identity(ChannelIdentity identity, bool echoInput = false) + => new IdentitiesResponseTarget([identity], echoInput); + + public sealed record OriginatingResponseTarget : ResponseTarget; + public sealed record ActiveResponseTarget : ResponseTarget; + public sealed record AllLinkedResponseTarget : ResponseTarget; + public sealed record NoneResponseTarget : ResponseTarget; + public sealed record ChannelResponseTarget(string ChannelName, bool EchoInput) : ResponseTarget; + public sealed record ChannelsResponseTarget(IReadOnlyList ChannelNames, bool EchoInput) : ResponseTarget; + public sealed record IdentitiesResponseTarget(IReadOnlyList Identities, bool EchoInput) : ResponseTarget; +} +``` + +**Fallback rules** (mirror Python): + +- When a destination channel does not implement `IChannelPush`, that destination is dropped and a warning is surfaced in telemetry; if the resolved set is empty, the host falls back to `Originating`. +- `LinkPolicy` is consulted for every destination; policy-dropped destinations are recorded in the assistant message's `intended_targets` envelope as `skipped_targets[].reason = "link_policy"`. +- `ResponseTarget.None` forces `Background = true` and returns a `ContinuationToken` on the originating wire. +- `EchoInput` causes the host to bundle a `role="user"` echo push and the agent reply into the same scheduled push task per non-originating destination; the runner tracks `echo_done` so a retry after the echo succeeded does not double-echo. + +### Capability interfaces + +```csharp +public interface IChannelPush +{ + ValueTask PushAsync( + ChannelPushContext context, + HostedRunResult payload, + CancellationToken cancellationToken); +} + +public sealed record ChannelPushContext +{ + public required ChannelIdentity Destination { get; init; } + public required ChannelRequest OriginatingRequest { get; init; } + public required string OriginatingChannel { get; init; } + public bool IsEcho { get; init; } + public ResponseTarget? OriginalTarget { get; init; } +} + +public interface IChannelPushCodec +{ + // Encode the whole push envelope so out-of-process runners (JSON payload mode) can reconstruct + // the destination identity, originating request, echo flag, and result on the worker side. + JsonNode Encode(ChannelPushContext context, HostedRunResult payload); + (ChannelPushContext Context, HostedRunResult Payload) Decode(JsonNode encoded); +} + +public interface IChannelRunHook +{ + // Runs AFTER the channel produces its default ChannelRequest and BEFORE the host resolves + // session behavior and calls the target. Canonical adapter point for workflow targets. + ValueTask OnRequestAsync( + ChannelRequest request, + ChannelRunHookContext context, + CancellationToken cancellationToken); +} + +public sealed record ChannelRunHookContext +{ + public required object Target { get; init; } // AIAgent or Workflow + public object? ProtocolRequest { get; init; } // raw inbound payload, loosely typed +} + +public interface IChannelResponseHook +{ + // Receives a per-destination clone of HostedRunResult and returns a (possibly rewritten) + // replacement. Hooks rebind via `result.Replace(...)` rather than mutating in place. + ValueTask OnResponseAsync( + HostedRunResult result, + ChannelResponseContext context, + CancellationToken cancellationToken); +} + +public sealed record ChannelResponseContext +{ + public required ChannelRequest Request { get; init; } + public required string ChannelName { get; init; } + public required ChannelIdentity DestinationIdentity { get; init; } + public bool Originating { get; init; } + public bool IsEcho { get; init; } +} + +public interface IChannelStreamTransformHook +{ + IAsyncEnumerable Transform( + IAsyncEnumerable upstream, + CancellationToken cancellationToken); +} + +public interface IConfidentialityTagged +{ + string? ConfidentialityTier { get; } // opaque label; null = single-tier +} +``` + +### Identity stack + +The host owns the authorization pipeline. Channels never run allowlists themselves — they call `host.AuthorizeAsync(...)` after extracting `ChannelIdentity` and any natively verified claims. + +```csharp +public enum AllowlistDecision { Allow, Deny, Abstain } +public enum AuthorizationPhase { PreLink, PostLink } +public enum ClaimSource { None, Channel, Linker } + +public interface IIdentityAllowlist +{ + // If true, the host startup validator rejects configurations where neither RequireLink=true + // nor a claim-emitting channel can deliver the claims this allowlist needs. Prevents the + // silent-deny-everyone footgun. + bool RequiresLinkedClaims => false; + + ValueTask EvaluateAsync( + AuthorizationContext context, + CancellationToken cancellationToken); +} + +public sealed record AuthorizationContext +{ + public required ChannelIdentity Identity { get; init; } + public required AuthorizationPhase Phase { get; init; } + public string? IsolationKey { get; init; } // null at PreLink; resolved at PostLink + public IReadOnlyDictionary VerifiedClaims { get; init; } + = ImmutableDictionary.Empty; + public ClaimSource ClaimSource { get; init; } = ClaimSource.None; + public ConversationContext? ConversationContext { get; init; } +} + +public sealed record ConversationContext(string? ConversationId, bool IsGroup); + +// Discriminated outcome of host.AuthorizeAsync(...). +public abstract record AuthorizationOutcome +{ + public sealed record Allowed(string IsolationKey) : AuthorizationOutcome; + + public sealed record LinkRequired(LinkChallenge Challenge) : AuthorizationOutcome; + + public sealed record Denied( + string ReasonCode, // stable machine-readable + string? UserMessage = null, // safe to render publicly + IReadOnlyDictionary? LogDetails = null // never shown to users + ) : AuthorizationOutcome; +} + +public interface IIdentityLinker +{ + string Name { get; } + + // Same shape as Channel.Contribute — lets the linker publish callback/verification routes. + ChannelContribution Contribute(IChannelContext context); + + ValueTask BeginAsync( + ChannelIdentity identity, + string? requestedIsolationKey, + CancellationToken cancellationToken); + + ValueTask CompleteAsync( + string challengeId, + IReadOnlyDictionary proof, + CancellationToken cancellationToken); + + // Returns the isolation key for an already-linked identity, or null if no link exists. + // When verifiedClaims contains entries that already match in the link store, the linker + // silently auto-merges the (channel, native_id) onto the existing isolation key and returns it. + ValueTask IsLinkedAsync( + ChannelIdentity identity, + IReadOnlyDictionary? verifiedClaims, + CancellationToken cancellationToken); +} + +public sealed record PrincipalIdentity( + string IsolationKey, + ChannelIdentity Identity, + IReadOnlyDictionary VerifiedClaims); + +public sealed record LinkChallenge( + string ChallengeId, + string Kind, // "url", "code", "mfa" + Uri? Url = null, + string? Code = null, + string? UserPrompt = null); + +// Built-in allowlist constructors return IIdentityAllowlist instances. +public static class AuthorizationProfile +{ + // require_link=false, allowlist=AllowAll. Every identity gets an auto-issued isolation key. + public static IIdentityAllowlist Open(); + + // require_link=true, allowlist=AllowAll. Any successfully-linked identity is admitted. + public static IIdentityAllowlist ForcedLink(); + + // require_link=false, NativeIdAllowlist(channel, ids). Pre-link, no IdP claim involved. + public static IIdentityAllowlist NativeAllowlist(string channel, params string[] nativeIds); + + // require_link=true, LinkedClaimAllowlist(claim, values). Forces link ceremony. + public static IIdentityAllowlist LinkedClaimAllowlist(string claim, params string[] values); + + // require_link=false, AnyOf(NativeIdAllowlist, LinkedClaimAllowlist). Native ids bypass link; + // everyone else funnels into it. + public static IIdentityAllowlist Mixed( + IIdentityAllowlist nativeAllowlist, + IIdentityAllowlist linkedClaimAllowlist); +} + +// Combinators +public sealed class AnyOfIdentityAllowlist(params IIdentityAllowlist[] children) : IIdentityAllowlist { /* ... */ } +public sealed class AllOfIdentityAllowlist(params IIdentityAllowlist[] children) : IIdentityAllowlist { /* ... */ } +``` + +**Authorization decision pipeline** (mirror Python). The host runs this inside `AuthorizeAsync(...)`: + +1. Build `AuthorizationContext(Phase = PreLink, VerifiedClaims = ..., ClaimSource = ...)`. +2. `pre = allowlist.EvaluateAsync(ctx)` — defaults to `Allow` when `allowlist is null`. +3. `pre == Deny` → `Denied(reasonCode: "allowlist_denied_pre_link", ...)`. +4. `pre == Allow`: + - If `RequireLink == true` and the linker has no record yet → `LinkRequired(linker.BeginAsync(identity))`. + - Otherwise → `Allowed(resolved-or-auto-issued isolation key)`. +5. `pre == Abstain`: + - If `RequireLink == true` **or** the allowlist declared `RequiresLinkedClaims` → call `linker.IsLinkedAsync(identity, verifiedClaims)`. + - Not linked → `LinkRequired(linker.BeginAsync(identity))`. + - Linked → re-evaluate at `Phase = PostLink` with linker-emitted claims. + - `Allow` → `Allowed(linked isolation key)`. + - `Deny` → `Denied(reasonCode: "allowlist_denied_post_link", ...)`. + - `Abstain` post-link is a misconfiguration; logged and treated as `Denied(reasonCode: "allowlist_abstain_after_link")`. + - Otherwise → `Allowed(auto-issued isolation key)`. + +**Default-open and all-abstain semantics.** With zero allowlists registered (or `allowlist: null`), every request is `Allowed` and auto-issues an isolation key keyed on `(Channel, NativeId)`. An all-abstain outcome at `PreLink` is treated as `Allow` when no `RequireLink` is set; at `PostLink` it is a misconfiguration as described above. + +**Inheritance.** Channel `allowlist` parameter has three states: `Inherit` (the host `DefaultAllowlist` applies), explicitly null (the channel is open even when the host has a default), or an explicit `IIdentityAllowlist` (overrides the host default; combine via `AllOfIdentityAllowlist(host.DefaultAllowlist, MyExtraList)` to add to it rather than replace). + +**Startup validation (fail-fast).** `AgentFrameworkHost` runs a validator at `MapAgentFrameworkHost(...)` startup: + +1. If any channel's resolved allowlist contains a node with `RequiresLinkedClaims == true`, the channel must either set `RequireLink = true` or declare via `Channel.EmitsVerifiedClaims = true` that it natively emits verified claims (e.g. an `ActivityChannel` carrying AAD `oid` on the inbound bearer). Otherwise: throw `ChannelConfigurationException`. +2. If any resolved allowlist contains `LinkedClaimAllowlist` and the host has no `IIdentityLinker` registered: throw `ChannelConfigurationException`. +3. If any channel has `RequireLink = true` and no `IIdentityLinker` is registered: throw `ChannelConfigurationException`. +4. `NativeIdAllowlist(channel: )` referencing an unknown channel: throw `ChannelConfigurationException`. + +Eager startup failure is intentional — silent deny-everyone is the worst possible default. + +### Link policy and confidentiality tier + +`ILinkPolicy` decides which channels may share an `IsolationKey` (consulted by `IIdentityLinker` on link attempts) and which channels may be a `ResponseTarget` for one another (consulted by the host's response-routing layer on every delivery). + +```csharp +public interface ILinkPolicy +{ + ValueTask EvaluateAsync(LinkPolicyContext context, CancellationToken cancellationToken); +} + +public sealed record LinkPolicyContext +{ + public required Channel Source { get; init; } + public required Channel Destination { get; init; } + public required LinkPolicyOperation Operation { get; init; } // Link or Deliver +} + +public enum LinkPolicyOperation { Link, Deliver } + +// Built-in policies +public sealed class AllowAllLinkPolicy : ILinkPolicy; // default +public sealed class SameConfidentialityTierLinkPolicy : ILinkPolicy; // most common +public sealed class ExplicitAllowListLinkPolicy(IReadOnlyList<(string Source, string Destination)> AllowedPairs) : ILinkPolicy; +public sealed class DenyAllLinkPolicy : ILinkPolicy; // share target, never sessions +``` + +Refusal during `Link` raises a typed error to the user. Refusal during `Deliver` excludes that destination from the route set and falls back to `Originating` if the route set becomes empty. + +### Host state store + +`IHostStateStore` is the single persistence seam for **host-execution metadata** that outlives a single request: continuation tokens, identity registry, identity-link grants, and last-seen `(IsolationKey, Channel)` records. Separate from `AgentSessionStore` (per-conversation history) and `WorkflowBuilder.CheckpointStorage` (workflow checkpoints). + +```csharp +public interface IHostStateStore +{ + // ---- Identity registry: (channel, native_id) <-> isolation_key with atomic merge ---- + + ValueTask GetIsolationKeyAsync( + ChannelIdentity identity, CancellationToken cancellationToken); + + // Atomically registers (or merges) a channel-native identity onto an isolation key. If the + // identity is already mapped to a different isolation key, both keys' (channel, native_id) + // records are merged onto the requested key. Optional verifiedClaims are persisted alongside + // so future channels presenting the same claim auto-link without a second ceremony. + ValueTask SaveLinkAsync( + ChannelIdentity identity, + string isolationKey, + IReadOnlyDictionary? verifiedClaims, + CancellationToken cancellationToken); + + ValueTask> GetIdentitiesAsync( + string isolationKey, CancellationToken cancellationToken); + + ValueTask LookupByVerifiedClaimAsync( + string claim, string value, CancellationToken cancellationToken); + + // ---- Link grants (Entra OAuth state, one-time codes) ---- + + ValueTask SaveLinkGrantAsync(LinkGrant grant, CancellationToken cancellationToken); + ValueTask GetLinkGrantAsync(string code, CancellationToken cancellationToken); + ValueTask ConsumeLinkGrantAsync(string code, CancellationToken cancellationToken); + + // ---- Last-seen ledger backing ResponseTarget.Active ---- + + ValueTask RecordLastSeenAsync( + string isolationKey, + ChannelIdentity identity, + string? conversationId, + DateTimeOffset at, + CancellationToken cancellationToken); + + ValueTask GetLastSeenAsync( + string isolationKey, CancellationToken cancellationToken); + + // ---- Continuation tokens (background runs) ---- + + ValueTask SaveContinuationAsync(ContinuationToken token, CancellationToken cancellationToken); + ValueTask GetContinuationAsync(string token, CancellationToken cancellationToken); + ValueTask DeleteContinuationAsync(string token, CancellationToken cancellationToken); + + // ---- Session alias rotation (backs host.ResetSessionAsync for host-tracked channels) ---- + + ValueTask RotateSessionAliasAsync(string isolationKey, CancellationToken cancellationToken); + ValueTask GetActiveSessionAliasAsync(string isolationKey, CancellationToken cancellationToken); +} + +public sealed record ChannelIdentityRegistration( + ChannelIdentity Identity, + DateTimeOffset RegisteredAt, + IReadOnlyDictionary VerifiedClaims); + +public sealed record LinkGrant( + string Code, + string IssuedByLinker, + string? RequestedIsolationKey, + DateTimeOffset ExpiresAt, + IReadOnlyDictionary Payload); + +public sealed record LastSeenRecord( + ChannelIdentity Identity, + string? ConversationId, + DateTimeOffset At); + +public sealed record ContinuationToken +{ + public required string Token { get; init; } + public required ContinuationStatus Status { get; init; } + public string? IsolationKey { get; init; } + public required DateTimeOffset CreatedAt { get; init; } + public DateTimeOffset? CompletedAt { get; init; } + public HostedRunResult? Result { get; init; } + public string? Error { get; init; } + public ResponseTarget? ResponseTarget { get; init; } +} + +public enum ContinuationStatus { Queued, Running, Completed, Failed } + +public sealed record HostStatePathOptions +{ + public string? Root { get; init; } // shorthand: derives all subpaths if not set + public string? RunnerPath { get; init; } // in-process durable runner persistence + public string? LinksPath { get; init; } // identity registry + link grants + public string? ContinuationsPath { get; init; } + public string? LastSeenPath { get; init; } +} +``` + +> **Workflow checkpoint storage is not an `IHostStateStore` concern.** Per decision 13, workflow checkpoints stay on `WorkflowBuilder.CheckpointStorage`. There is no `CheckpointsPath` on `HostStatePathOptions`. + +> **Default selection by runtime mode.** Pure worker / dev (`HostingMode.LongRunning` per Python parlance): `FileHostStateStore` keyed off `HostStatePathOptions.Root` (defaults to `./.afhost/`). ASP.NET web app: same default. `HostingMode.Ephemeral` (Foundry hosted-agent runtime, scale-to-zero): caller must wire an external store (Cosmos, SQL, Redis); falling back to in-memory is rejected at startup unless `AgentFrameworkHostOptions.AllowInProcessRunnerInEphemeralMode = true`. In v1 only `InMemoryHostStateStore` and `FileHostStateStore` ship in core; external implementations land in fast-follow per req #24. + +### Durable task runner + +The host delegates non-originating push fan-out and background runs to a pluggable `IDurableTaskRunner`. Channels never see it directly; they emit `IChannelPush.PushAsync(...)` and the runner schedules + retries. + +```csharp +public interface IDurableTaskRunner +{ + // Each runner implementation declares its payload mode. Json-mode runners (out-of-process + // sidecars, gRPC TaskHub) require channels with non-JSON payloads to expose an IChannelPushCodec. + DurableTaskPayloadMode PayloadMode { get; } + + // Registers a handler under a name. The host registers "hosting.push" at startup; channel + // authors typically do not register their own handlers. + void Register(string name, Func handler); + + ValueTask ScheduleAsync( + string name, + object payload, + RetryPolicy? retryPolicy, + CancellationToken cancellationToken); + + ValueTask GetAsync(TaskHandle handle, CancellationToken cancellationToken); + ValueTask CancelAsync(TaskHandle handle, CancellationToken cancellationToken); +} + +public enum DurableTaskPayloadMode { Object, Json } + +public sealed record TaskHandle(string TaskId, string Name); + +public enum TaskStatus { Scheduled, Running, Succeeded, Failed, Cancelled } + +public sealed record TaskInvocationContext( + string Name, + object Payload, + int Attempt, + IDictionary State); // mutable runner-owned per-task state (e.g. echo_done) + +public sealed record RetryPolicy +{ + public int MaxAttempts { get; init; } = 5; + public TimeSpan InitialBackoff { get; init; } = TimeSpan.FromSeconds(1); + public double BackoffMultiplier { get; init; } = 2.0; + public TimeSpan MaxBackoff { get; init; } = TimeSpan.FromSeconds(60); +} +``` + +**Codec/runner pairing.** At startup the host runs `_validateRunnerCodecPairing`: if `runner.PayloadMode == Json` and any push-capable channel does not implement `IChannelPushCodec`, throw `ChannelConfigurationException` so the misconfiguration is caught before traffic. + +**In-process runner shutdown drain.** `InProcessDurableTaskRunner` ships a two-phase shutdown driven by `ShutdownGraceSeconds` (default 5s). After lifespan shutdown signals, in-flight `"hosting.push"` tasks are given the grace period to finish; on expiry, remaining tasks are cancelled and their `OperationCanceledException` is swallowed (expected shutdown shape, not logged as a failure). + +**Echo idempotency on retry.** The host's `"hosting.push"` handler tracks an `echo_done` cursor on `TaskInvocationContext.State`. A retry after the echo succeeded but before the response push completed will not double-echo. The cursor lives on runner-owned task state, not the message — same principle as "intent only on the message, operational state in the runner". + +### Hosted target runner (Foundry-reuse seam) + +```csharp +public interface IHostedTargetRunner +{ + ValueTask RunAsync(ChannelRequest request, CancellationToken cancellationToken); + IAsyncEnumerable StreamAsync(ChannelRequest request, CancellationToken cancellationToken); +} + +// Built into core: +internal sealed class AIAgentRunner(AIAgent agent) : IHostedTargetRunner { /* ... */ } +internal sealed class WorkflowRunner(Workflow workflow) : IHostedTargetRunner { /* ... */ } + +// Lives in Microsoft.Agents.AI.Foundry.Hosting (additive — no break to existing surface): +public sealed class FoundryHostedAgentRunner(FoundryHostedAgentHandle handle) : IHostedTargetRunner { /* ... */ } +``` + +### Workflow channels: resume model + +Channels never branch on target type. The workflow story is carried by: + +- `WorkflowRunner : IHostedTargetRunner` (returns `HostedRunResult`). +- `ChannelRequest.Attributes` reserved keys: + - `"workflow.resume_token"` (string) — **opaque host-issued correlation id** persisted on `IHostStateStore` as a `ContinuationToken` whose `Status = "awaiting_input"` and whose payload includes (a) the workflow instance reference and (b) the originating `RequestInfoEvent.Request`. Issued by the host whenever the workflow emits a `RequestInfoEvent` and the channel responds with a "needs input" envelope. The caller posts back with the same token to resume. + - `"workflow.checkpoint_id"` (string) — **direct opt-in for advanced callers** who already know the workflow checkpoint id (e.g. a UI that wants to fork from a specific past state). Passed straight to `Workflow.RunAsync(checkpointId: ...)`. Mutually exclusive with `workflow.resume_token`. +- `WorkflowInvocationsResponseHook` (ships in `.Invocations`) renders `RequestInfoEvent` as `{ "status": "awaiting_input", "request": {...}, "resume_token": "..." }`. App authors handle `RequestInfoEvent` rendering for Telegram / Responses via their own `IChannelResponseHook`. + +The host-side wiring: when `WorkflowRunner` produces a `WorkflowRunResponse` that contains a pending `RequestInfoEvent`, the host issues a `ContinuationToken`, stores it on `IHostStateStore`, and surfaces the token via `HostedRunResult.Session.Attributes["workflow.resume_token"]`. The channel's response hook reads it and projects it onto the wire. On the next request carrying `Attributes["workflow.resume_token"]`, the host looks up the `ContinuationToken`, retrieves the workflow instance + correlation id, and calls `Workflow.RunAsync(resumeToken: ...)`. + +### Built-in routes + +For built-in channels, `Channel.Path` is the configurable mount root. The channel package owns the fixed protocol-relative suffix. Final route = `Path` + suffix. + +| Channel | Default `Path` | Default exposed route(s) | +|---|---|---| +| `ResponsesChannel` | `/responses` | `/responses/v1` and nested response/conversation routes | +| `InvocationsChannel` | `/invocations` | `/invocations/invoke` (sync) and `/invocations/{continuationToken}` (poll) | +| `TelegramChannel` | `/telegram` | webhook transport: `/telegram/webhook`; polling transport: no required HTTP route (uses `IHostedService` long-poll loop) | + +Overrides only replace the outer mount root: + +```csharp +builder.AddAgentFrameworkHost(agent) + .AddResponsesChannel(o => o.Path = "/public/responses") // -> /public/responses/v1 + .AddInvocationsChannel(o => o.Path = "/internal/invocations") // -> /internal/invocations/invoke + .AddTelegramChannel(o => o.Path = "/bots/telegram"); // -> /bots/telegram/webhook +``` + +### Multi-user conversations + +Telegram groups, Telegram forum topics, and future Teams group chats / team channels share a uniform contract. Two axes that channels MUST keep separate: + +- `ChannelIdentity.NativeId` is always the **user** (`from.id` / AAD `oid`). In 1:1 chats it often coincides with the chat id; in groups it does not. +- `ChannelRequest.ConversationId` is the **chat / channel / thread** locator. + +Channels expose `ConversationScope` controlling how the host derives the resolved isolation key in multi-user surfaces: + +| Scope | Isolation key derivation in multi-user conversations | Pick when | +|---|---|---| +| `PerUser` | The user's isolation key from identity resolution only — group and DM share state. | Personal-assistant agents where memory follows the user. Risky if the agent emits user-specific data in a public group. | +| `PerUserPerConversation` (default for multi-user) | `f"{userIsolationKey}:{conversationId}"` — same user gets a different isolation key per group / channel / topic / DM. | Default and safest. Per-conversation memory isolation. | +| `PerConversation` | `f"_conv:{channel}:{conversationId}"` — every member of the group shares one isolation key and one `AgentSession`. | "Bot lives in this channel" — meeting-notes bot, shared scratchpad, support-triage queue. | + +1:1 chats always derive the isolation key from the user identity alone. + +`AcceptInGroup` controls inbound filtering on group surfaces: + +| Mode | Semantics | Default for | +|---|---|---| +| `MentionOnly` | Accept only `@bot` mentions. | Telegram groups, future Teams group chats / channels | +| `CommandOnly` | Accept only registered `ChannelCommand` invocations. | — | +| `MentionOrCommand` | Either of the above. | — | +| `All` | Accept every inbound message. | 1:1 chats; opt-in for groups when the bot is the only conversational participant | + +Messages not satisfying the rule are filtered at the channel layer — no `ChannelRequest` is produced and the agent is never invoked. + +**Link ceremonies in groups MUST NOT post the challenge URL or code into a group conversation.** Channels detect group context (via `ConversationContext.IsGroup`) and, when `RequireLink = true` triggers a `LinkChallenge`, redirect the rendered challenge to the user's DM. Verified-claim auto-link is unaffected: a Teams `groupChat` request carrying an AAD-verified `from.aadObjectId` that already matches an existing claim in the link store merges silently with no group-visible artifact. + +### Channel session-carriage models + +Channels split into two families based on who owns the session identifier across requests: + +| Model | Examples | `ChannelSession.Key` source | "New thread" UX | +|---|---|---|---| +| **Caller-supplied session** | Responses, Invocations, A2A, MCP | Wire payload (`previous_response_id`, `conversation_id`, body `session_id`). `null` means ephemeral. | Caller omits the previous id. | +| **Host-tracked session** | Telegram, Activity Protocol, future WhatsApp / Discord | Channel leaves `ChannelSession.Key = null`; host alias decides which `AgentSession` to resolve. | Channel exposes a `/new`-style `ChannelCommand` that calls `host.ResetSessionAsync(isolationKey)`. | + +`host.ResetSessionAsync(isolationKey)` rotates the active session-id alias rather than deleting on-disk history: prior history remains addressable by its original session id; subsequent runs for that `IsolationKey` resolve to a brand-new `AgentSession`. Caller-supplied channels do not call `ResetSessionAsync`. + +A single `AgentFrameworkHost` mounts channels from both families. A user can chat on Telegram (host-tracked) and have it linked via `IIdentityLinker` to a Responses-channel session keyed by `previous_response_id`; the linker's identity merge collapses both sides onto the same `IsolationKey`. + +### Isolation keys (Foundry runtime partition hints) + +```csharp +public sealed record IsolationKeys(string? UserKey, string? ChatKey) +{ + public static AsyncLocal CurrentSlot { get; } = new(); + public static IsolationKeys? Current + { + get => CurrentSlot.Value; + set => CurrentSlot.Value = value; + } + + public bool IsEmpty => UserKey is null && ChatKey is null; + + public const string UserHeader = "x-agent-user-isolation-key"; + public const string ChatHeader = "x-agent-chat-isolation-key"; +} + +public interface IIsolationKeysAccessor +{ + IsolationKeys? Current { get; } +} +``` + +`IsolationKeys` is **distinct from** the app-level isolation key produced by `IIdentityLinker`. It carries the Foundry runtime's per-request partition hints lifted off `x-agent-user-isolation-key` / `x-agent-chat-isolation-key` headers by middleware the host registers automatically. Providers (a future Foundry-partitioned history/state store) read `IsolationKeys.Current` to scope backend calls. Channels themselves are oblivious. v1 ships the plumbing; the first consumer lands as a fast-follow `FoundryHistoryProvider`. + +### Intended targets and durable delivery + +When `ResponseTarget != Originating`, the host fans the response out using a synchronous-on-originating, scheduled-elsewhere pattern: + +1. The host runs the target once. The result is a single `HostedRunResult`. +2. The channel calls `IChannelContext.ScheduleResponseAsync(result, originatingRequest, ...)`. Internally the host resolves the destination set (consulting `IHostStateStore.GetLastSeenAsync` for `Active`, `GetIdentitiesAsync` for `AllLinked`, and `ILinkPolicy.EvaluateAsync` to filter every entry). +3. If `Originating` is one of the destinations (i.e. when the target is `AllLinked` / `Channels([..., originating])` / etc.), the originating channel renders that destination synchronously on the inbound HTTP response (or polling reply) so the caller sees the answer without waiting for the durable runner. +4. For every non-originating destination, the host schedules one push task per destination on `IDurableTaskRunner` under the reserved handler name `"hosting.push"`. The runner invokes `IChannelPush.PushAsync(channelPushContext, payload)` with the appropriate `ChannelPushContext`. +5. Per-destination `IChannelResponseHook.OnResponseAsync` runs **inside** the push task immediately before `PushAsync`, so per-channel rebinds (e.g. JSON dump rendering for one channel, plain-text rendering for another) do not block the originating reply. + +**Intended-targets bookkeeping (persisted on the assistant message).** The host writes a single envelope to the assistant message's `additional_properties["hosting"]` describing the routing decision at the moment of dispatch: + +```json +{ + "hosting": { + "originating_channel": "responses", + "response_target": { "kind": "all_linked", "echo_input": true }, + "intended_targets": [ + { "channel": "responses", "native_id": "user_42", "echo": false }, + { "channel": "telegram", "native_id": "12345678", "echo": true } + ], + "skipped_targets": [ + { "channel": "discord", "native_id": "8675309", "reason": "link_policy" } + ] + } +} +``` + +`intended_targets[]` is immutable once written and represents intent. `skipped_targets[]` carries pre-dispatch filtering (link policy, missing `IChannelPush` capability, all-channel resolution returning empty for a given identity). Per-destination delivery failures live on `IDurableTaskRunner` task state, not the message — same partition rule. + + + +## E2E code samples + +### Sample 1: Responses + Telegram sharing one agent and one session per user + +```csharp +using Microsoft.Agents.AI.Hosting.Channels; +using Microsoft.Agents.AI.Hosting.Channels.Responses; +using Microsoft.Agents.AI.Hosting.Channels.Telegram; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; + +var builder = WebApplication.CreateBuilder(args); + +var agent = new AzureOpenAIChatClient(/* ... */) + .CreateAIAgent(name: "Concierge", instructions: "You are a helpful concierge."); + +builder.AddAgentFrameworkHost(agent, o => + { + o.DefaultAllowlist = new AnyOfIdentityAllowlist( + AuthorizationProfile.LinkedClaimAllowlist("email", "*@contoso.com"), + AuthorizationProfile.NativeAllowlist("telegram", "12345678")); + o.StatePaths = new HostStatePathOptions { Root = "./.afhost" }; + }) + .UseIdentityLinker() + .UseHostStateStore() + .AddResponsesChannel() + .AddTelegramChannel(o => + { + o.BotToken = builder.Configuration["Telegram:BotToken"]!; + o.ConversationScope = ConversationScope.PerUserPerConversation; + o.AcceptInGroup = AcceptInGroup.MentionOnly; + }); + +var app = builder.Build(); +app.MapAgentFrameworkHost(); +app.Run(); +``` + +End-state behavior: + +1. A Responses API client posts with `previous_response_id`. The host resolves a `ChannelSession` keyed by the user's resolved `IsolationKey` and re-uses the existing `AgentSession`. +2. The same user messages Telegram (the user's Telegram id is in the `NativeIdAllowlist`, so they are admitted pre-link with an auto-issued isolation key). To collapse to the existing Responses-side isolation key they type `/link ` once. `OneTimeCodeIdentityLinker.CompleteAsync` calls `IHostStateStore.SaveLinkAsync(telegramIdentity, responsesIsolationKey, ...)` which atomically merges the Telegram native id onto the same isolation key. +3. The next Telegram message hits the same `AgentSession` the Responses client was using. +4. Server-side push back to Telegram works through `TelegramChannel : IChannelPush`, registered as a durable-task handler at host startup. The Telegram `/new` command calls `host.ResetSessionAsync(isolationKey)` to start a fresh conversation without losing history. + +### Sample 2: Workflow target on InvocationsChannel with `RequestInfoEvent` rendering + +```csharp +using Microsoft.Agents.AI.Hosting.Channels; +using Microsoft.Agents.AI.Hosting.Channels.Invocations; +using Microsoft.Extensions.Hosting; + +var builder = WebApplication.CreateBuilder(args); + +var workflow = new WorkflowBuilder(checkpointStorage: new FileCheckpointStorage("./.checkpoints")) + .AddExecutor(/* application-defined intake executor */) + .Build(); + +builder.AddAgentFrameworkHost(workflow) + .AddInvocationsChannel(); // WorkflowInvocationsResponseHook registered automatically + +var app = builder.Build(); +app.MapAgentFrameworkHost(); +app.Run(); +``` + +Inbound: + +``` +POST /invocations/invoke +{ "input": { "customerId": "...", "sku": "...", "quantity": 12 } } +``` + +If the workflow pauses on a `RequestInfoEvent`, `WorkflowInvocationsResponseHook` renders: + +```json +{ + "status": "awaiting_input", + "request": { /* the RequestInfoEvent.Request payload */ }, + "resume_token": "" +} +``` + +The host stored the workflow instance reference + correlation id under the continuation token. To resume, the caller posts with `Attributes["workflow.resume_token"]` set: + +``` +POST /invocations/invoke +{ + "input": { "approved": true, "approver": "alice" }, + "attributes": { "workflow.resume_token": "" } +} +``` + +The host promotes the attribute onto `ChannelRequest.Attributes`, `WorkflowRunner` reads `"workflow.resume_token"`, looks the entry up via `IHostStateStore.GetContinuationAsync(...)`, retrieves the persisted correlation id and workflow reference, then calls `Workflow.RunAsync(resumeToken: ...)`. Workflow checkpoint storage remains on `WorkflowBuilder` (`FileCheckpointStorage` here) and is never touched by the host. + +### Sample 3: Foundry hosted agent as target — same channels, same builder + +```csharp +using Microsoft.Agents.AI.Hosting.Channels.Responses; +using Microsoft.Extensions.Hosting; +using Microsoft.Agents.AI.Foundry.Hosting; // brings the AddFoundryHostedAgent overload + +var foundryHandle = await foundryClient.GetHostedAgentAsync("my-agent"); + +builder.AddFoundryHostedAgent(foundryHandle) // resolves FoundryHostedAgentRunner + .AddResponsesChannel(); +``` + +The `Microsoft.Agents.AI.Foundry.Hosting` package supplies an `AddFoundryHostedAgent(IHostApplicationBuilder, FoundryHostedAgentHandle, ...)` extension that internally calls `AddAgentFrameworkHost(_ => handle)` and registers `FoundryHostedAgentRunner` as the `IHostedTargetRunner`. The `ResponsesChannel` code is identical to Sample 1. Only the registered `IHostedTargetRunner` differs. + +### Sample 4: Authoring a new channel package + +```csharp +public sealed class MyWebhookChannel : Channel, IChannelPush +{ + public override string Name => "mywebhook"; + public override string Path => "/mywebhook"; + + public override ChannelContribution Contribute(IChannelContext context) => new() + { + Routes = + [ + // The host wraps this action in endpoints.MapGroup(Path), so "/inbound" mounts at "/mywebhook/inbound". + endpoints => endpoints.MapPost("/inbound", async (HttpContext http) => + { + var payload = await JsonSerializer.DeserializeAsync(http.Request.Body) + ?? throw new InvalidOperationException("empty body"); + + var identity = new ChannelIdentity(Channel: Name, NativeId: payload.AccountId); + + // Funnel through the host's authorization pipeline before invoking the target. + var auth = await context.AuthorizeAsync(identity, new AuthorizationRequest { }, http.RequestAborted); + if (auth is AuthorizationOutcome.Denied denied) + return Results.Forbid(authenticationSchemes: [denied.ReasonCode]); + if (auth is AuthorizationOutcome.LinkRequired link) + return Results.Json(new { status = "link_required", challenge = link.Challenge }); + + var allowed = (AuthorizationOutcome.Allowed)auth; + + var request = new ChannelRequest + { + Channel = Name, + Operation = "message.create", + Input = payload.Text, + Identity = identity, + Session = new ChannelSession + { + Key = payload.ThreadId, + ConversationId = payload.ThreadId, + IsolationKey = allowed.IsolationKey, + }, + }; + + var result = await context.RunAsync(request, http.RequestAborted); + return Results.Json(MyOutboundPayload.From(result)); + }), + ], + }; + + // The host invokes this from "hosting.push" durable tasks for every non-originating destination. + public ValueTask PushAsync(ChannelPushContext context, HostedRunResult payload, CancellationToken cancellationToken) + { + // Send to context.Destination.NativeId using whatever HTTP/SDK call this protocol needs. + return ValueTask.CompletedTask; + } +} +``` + +For richer scenarios the channel additionally implements `IChannelPushCodec` (required when paired with a JSON-payload durable runner), `IChannelRunHook`, `IChannelResponseHook`, or `IChannelStreamTransformHook`. Each capability is independent. + + + +## Migration story + +**v1: nothing changes for existing consumers.** Existing `MapOpenAIResponses`, `MapA2A`, `MapAGUI`, Foundry hosting, and Azure Functions handlers continue to ship and behave exactly as today. No `[Obsolete]` warnings, no shim code paths, no change in behavior or surface. + +**Fast follow (Tier 2):** internals of existing `Map*` extensions get rewritten to delegate to the new builder + a private channel. From the consumer's view this is invisible. An `[Obsolete]` recommendation pointing at the new builder ships at the same time so new code uses the new surface. Existing consumers get a deprecation warning but no break, and have at least one full release of overlap before any removal is considered. + +## Test strategy + +| Layer | Test type | What it proves | +|---|---|---| +| Channel contract | Unit | `Channel.Contribute` returns a contribution; capability interfaces are independently implementable. | +| `AgentFrameworkHost` composition | Unit | `AddAgentFrameworkHost` + N `AddXxxChannel` produces a host whose `Channels` list matches; channel `ConfigureServices` runs pre-`Build`; `Contribute` runs post-`Build`. | +| `ResponsesChannel` wire compat | Integration (`TestServer`) | Post a Responses-shape request; assert the response round-trips the full `ChatMessage` content list (no lossy collapse to a single text field). | +| `InvocationsChannel` workflow path | Integration | Target a `Workflow`; `RequestInfoEvent` rendered by `WorkflowInvocationsResponseHook` produces the documented envelope; a subsequent request with `workflow.resume_token` resumes correctly. | +| `TelegramChannel` | Integration (mocked `Telegram.Bot`) | Inbound update produces a `ChannelRequest` with the correct identity; `IChannelPush.PushAsync` calls `sendMessage`. | +| Identity stack | Unit | `AnyOfIdentityAllowlist` short-circuits on first `Allow`; `Abstain` defers; `Deny` wins over `Abstain`. | +| `OneTimeCodeIdentityLinker` | Integration | End-to-end: begin produces a code, complete on the other channel collapses isolation keys, subsequent requests on either channel resolve to the same session. | +| `IHostStateStore` (file impl) | Unit | Per-component path overrides land on the right folders; missing paths fall back to in-memory; concurrent writes do not corrupt. | +| `InProcessDurableTaskRunner` | Unit + property | Schedule / Get / Cancel / Resume round-trip; bounded `Channel` does not drop; disk persistence replays after restart when `RunnerPath` is set. | +| `IsolationKeys` middleware | Unit (`TestServer`) | Headers lift into `IsolationKeys.Current` for the request scope; reset after; absent headers leave `Current` null. | +| `FoundryHostedAgentRunner` | Integration | Same channels work transparently against a Foundry hosted-agent handle as against a local `AIAgent`. | +| End-to-end smoke | Integration | Sample 1 above runs in-process, posts via Responses, asserts a Telegram push fires, asserts session continuity across channels. | + +## Phasing + +1. **Core abstractions** — `Microsoft.Agents.AI.Hosting.Channels` package: `Channel` / `ChannelContribution` / `ChannelRequest` / `ChannelSession` / `ChannelIdentity` / `ResponseTarget` / `HostedRunResult` / `HostedStreamItem` / `IChannelContext` / capability interfaces / `IHostedTargetRunner` + built-in `AIAgentRunner` + `WorkflowRunner` / `IsolationKeys` plumbing. Identity registry primitives on `IHostStateStore` (`GetIsolationKeyAsync` / `SaveLinkAsync` / `LookupByVerifiedClaimAsync` / `RotateSessionAliasAsync`) and continuation tokens (`Save/Get/DeleteContinuationAsync`) ship in this phase — the host cannot resolve a session without them. `InMemoryHostStateStore` + `FileHostStateStore`. `InProcessDurableTaskRunner` + `RetryPolicy` + `TaskHandle` + `DurableTaskPayloadMode` + codec/runner pairing validation. Host startup validator (fail-fast rules). Unit tests per type. +2. **Identity allowlists + linker** — `IIdentityAllowlist` tri-state + `AllowAllIdentityAllowlist` / `NativeIdAllowlist` / `LinkedClaimAllowlist` / `AnyOfIdentityAllowlist` / `AllOfIdentityAllowlist` / `AuthorizationProfile` factory. `IIdentityLinker` + `OneTimeCodeIdentityLinker` (zero deps). `ILinkPolicy` + 4 built-ins. `host.AuthorizeAsync` pipeline + per-channel inheritance semantics. End-to-end integration test of cross-channel session collapse using `OneTimeCodeIdentityLinker` over an in-memory pair of dummy channels. +3. **ResponsesChannel + InvocationsChannel packages** — both prove a single host with two channels resolves to the same session under identity-linking. `WorkflowInvocationsResponseHook` ships with the Invocations package; `Attributes["workflow.resume_token"]` round-trip integration test. +4. **TelegramChannel package** — proves polling `IHostedService` lifecycle, webhook transport, `IChannelPush` + `IChannelPushCodec`, command registration via `setMyCommands`, group-vs-DM filtering (`AcceptInGroup`), per-conversation isolation (`ConversationScope`), link-challenge group-safety redirect to DM. +5. **Foundry runner adapter** — `FoundryHostedAgentRunner` lands in existing `Microsoft.Agents.AI.Foundry.Hosting` as an additive type, along with `AddFoundryHostedAgent` overload on `IHostApplicationBuilder`. Integration test against a mocked Foundry hosted-agent handle. +6. **Samples + docs** — port the cross-channel-continuity Python sample to .NET. README per package. Worked Telegram-and-Responses sample exercising every locked decision end-to-end. + +## Fast-follow work (out of v1) + +- `Microsoft.Agents.AI.Hosting.Channels.DurableTask` adapter package wrapping `Microsoft.Agents.AI.DurableTask` (DTF). Validates the JSON payload codec path against a real out-of-process runner. +- `Microsoft.Agents.AI.Hosting.Channels.Discord` (mirrors Python PR #6081). +- `Microsoft.Agents.AI.Hosting.Channels.Activity` (Teams / DirectLine / WebChat via the Activity Protocol). Validates `EmitsVerifiedClaims = true` on the inbound bearer path. +- `Microsoft.Agents.AI.Hosting.Channels.EntraId` shipping `EntraIdentityLinker` with Entra / MSAL dependencies. +- Tier 2 migration: rewrite existing `MapOpenAIResponses` / `MapA2A` / `MapAGUI` internals to delegate to the new builder, ship `[Obsolete]` recommendations pointing at the new surface. +- Foundry-partitioned `IHostStateStore` provider that reads `IsolationKeys.Current` — the consumer that justifies the plumbing landing in v1. +- `IChannelCommandRegistrar` capability interface (registers slash commands with the native protocol — Telegram `setMyCommands`, Discord application commands). +- `AspNetCoreIdentityAllowlistAdapter` bridging `IIdentityAllowlist` to `Microsoft.AspNetCore.Authorization` policies for apps already standardized on that pipeline. + +## Open implementation questions + +These are *implementation-detail* questions to resolve in code review, not blocking the design: + +- Whether `ChannelCommand` registration ships in v1 as a passive metadata record on `ChannelContribution` (channels read their own commands and call the native registration API themselves) or whether `IChannelCommandRegistrar` ships in v1. Default plan: passive record in v1, registrar capability in fast follow. +- Where `AspNetCoreIdentityAllowlistAdapter` lives. Default plan: separate `Microsoft.Agents.AI.Hosting.Channels.AspNetCore` package post-v1, so the core hosting package stays ASP.NET-Core-free for channel authors. +- Whether `FileHostStateStore`'s on-disk schema is documented for external tools to read, or treated as private. Default plan: private in v1, document if a real external use case appears. + +## References + +- Python spec: [`002-python-hosting-channels.md`](./002-python-hosting-channels.md) — canonical source of truth for cross-language semantics. +- Python source branch: [`feature/python-hosting`](https://github.com/microsoft/agent-framework/tree/feature/python-hosting). +- Python Discord channel PR (fast-follow reference): [microsoft/agent-framework#6081](https://github.com/microsoft/agent-framework/pull/6081). +- Existing .NET hosting packages this work coexists with: `Microsoft.Agents.AI.Hosting.OpenAI`, `Microsoft.Agents.AI.Hosting.A2A`, `Microsoft.Agents.AI.Hosting.A2A.AspNetCore`, `Microsoft.Agents.AI.Hosting.AGUI.AspNetCore`, `Microsoft.Agents.AI.Hosting.AzureFunctions`, `Microsoft.Agents.AI.Foundry.Hosting`, `Microsoft.Agents.AI.DurableTask`. From 4662915a3efacf471656171bdbf55bdde7d51452 Mon Sep 17 00:00:00 2001 From: Roger Barreto <19890735+RogerBarreto@users.noreply.github.com> Date: Thu, 28 May 2026 11:43:41 +0100 Subject: [PATCH 02/16] Hosting.Channels: core abstractions scaffold (draft) Adds Microsoft.Agents.AI.Hosting.Channels (alpha) with the full v1 type surface from the spec and working in-memory implementations end-to-end on net8 / net9 / net10. Includes Channel + ChannelContribution + capability interfaces, ChannelRequest envelope (full Python parity), ResponseTarget variants, HostedRunResult base + generic, HostedStreamItem family, identity allowlists and linker contracts, link policy + 4 built-ins, IHostStateStore + InMemoryHostStateStore, IDurableTaskRunner + InProcessDurableTaskRunner (two-phase shutdown, retry backoff), AIAgentRunner (working) and WorkflowRunner (skeleton), IsolationKeys plumbing, builder + AddAgentFrameworkHost / MapAgentFrameworkHost extensions. Build is clean with 0 warnings across all 3 target frameworks. FileHostStateStore, OneTimeCodeIdentityLinker, real ScheduleResponseAsync fan-out, channel packages, and FoundryHostedAgentRunner adapter land in follow-up commits. --- dotnet/agent-framework-dotnet.slnx | 1 + .../AcceptInGroup.cs | 21 ++ .../AgentFrameworkHost.cs | 207 +++++++++++++++++ .../AgentFrameworkHostOptions.cs | 24 ++ .../AllowlistDecision.cs | 18 ++ .../Allowlists/AllOfIdentityAllowlist.cs | 50 +++++ .../Allowlists/AllowAllIdentityAllowlist.cs | 17 ++ .../Allowlists/AnyOfIdentityAllowlist.cs | 50 +++++ .../Allowlists/LinkedClaimAllowlist.cs | 64 ++++++ .../Allowlists/NativeIdAllowlist.cs | 42 ++++ .../AuthorizationContext.cs | 31 +++ .../AuthorizationOutcome.cs | 28 +++ .../AuthorizationPhase.cs | 15 ++ .../AuthorizationProfile.cs | 27 +++ .../AuthorizationRequest.cs | 29 +++ .../Channel.cs | 49 ++++ .../ChannelCommand.cs | 12 + .../ChannelContribution.cs | 40 ++++ .../ChannelIdentity.cs | 20 ++ .../ChannelIdentityRegistration.cs | 17 ++ .../ChannelPushContext.cs | 24 ++ .../ChannelRequest.cs | 82 +++++++ .../ChannelResponseContext.cs | 24 ++ .../ChannelRunHookContext.cs | 15 ++ .../ChannelSession.cs | 30 +++ .../ClaimSource.cs | 18 ++ .../ContinuationStatus.cs | 21 ++ .../ContinuationToken.cs | 35 +++ .../ConversationContext.cs | 10 + .../ConversationScope.cs | 18 ++ .../DurableTaskPayloadMode.cs | 21 ++ .../DurableTaskStatus.cs | 24 ++ ...ntRouteBuilderHostingChannelsExtensions.cs | 51 +++++ ...icationBuilderHostingChannelsExtensions.cs | 93 ++++++++ .../HostStatePathOptions.cs | 25 +++ .../HostedRunResult.cs | 37 +++ .../HostedStreamItem.cs | 27 +++ .../IAgentFrameworkHostBuilder.cs | 44 ++++ .../IChannelContext.cs | 51 +++++ .../IChannelPush.cs | 16 ++ .../IChannelPushCodec.cs | 19 ++ .../IChannelResponseHook.cs | 20 ++ .../IChannelRunHook.cs | 20 ++ .../IChannelStreamTransformHook.cs | 18 ++ .../IConfidentialityTagged.cs | 14 ++ .../IDurableTaskRunner.cs | 34 +++ .../IHostStateStore.cs | 90 ++++++++ .../IHostedTargetRunner.cs | 20 ++ .../IIdentityAllowlist.cs | 24 ++ .../IIdentityLinker.cs | 42 ++++ .../ILinkPolicy.cs | 17 ++ .../InMemoryHostStateStore.cs | 211 ++++++++++++++++++ .../InProcessDurableTaskRunner.cs | 193 ++++++++++++++++ .../Internal/AgentFrameworkHostBuilder.cs | 88 ++++++++ .../Internal/ChannelContext.cs | 44 ++++ .../IsolationKeys.cs | 45 ++++ .../LastSeenRecord.cs | 16 ++ .../LinkChallenge.cs | 21 ++ .../LinkGrant.cs | 22 ++ .../LinkPolicies/AllowAllLinkPolicy.cs | 16 ++ .../LinkPolicies/DenyAllLinkPolicy.cs | 16 ++ .../ExplicitAllowListLinkPolicy.cs | 33 +++ .../SameConfidentialityTierLinkPolicy.cs | 28 +++ .../LinkPolicyContext.cs | 18 ++ .../LinkPolicyOperation.cs | 15 ++ ...icrosoft.Agents.AI.Hosting.Channels.csproj | 40 ++++ .../PrincipalIdentity.cs | 16 ++ .../ResponseTarget.cs | 78 +++++++ .../RetryPolicy.cs | 27 +++ .../Runners/AIAgentRunner.cs | 75 +++++++ .../Runners/WorkflowRunner.cs | 42 ++++ .../SessionMode.cs | 18 ++ .../TaskHandle.cs | 10 + .../TaskInvocationContext.cs | 21 ++ 74 files changed, 2839 insertions(+) create mode 100644 dotnet/src/Microsoft.Agents.AI.Hosting.Channels/AcceptInGroup.cs create mode 100644 dotnet/src/Microsoft.Agents.AI.Hosting.Channels/AgentFrameworkHost.cs create mode 100644 dotnet/src/Microsoft.Agents.AI.Hosting.Channels/AgentFrameworkHostOptions.cs create mode 100644 dotnet/src/Microsoft.Agents.AI.Hosting.Channels/AllowlistDecision.cs create mode 100644 dotnet/src/Microsoft.Agents.AI.Hosting.Channels/Allowlists/AllOfIdentityAllowlist.cs create mode 100644 dotnet/src/Microsoft.Agents.AI.Hosting.Channels/Allowlists/AllowAllIdentityAllowlist.cs create mode 100644 dotnet/src/Microsoft.Agents.AI.Hosting.Channels/Allowlists/AnyOfIdentityAllowlist.cs create mode 100644 dotnet/src/Microsoft.Agents.AI.Hosting.Channels/Allowlists/LinkedClaimAllowlist.cs create mode 100644 dotnet/src/Microsoft.Agents.AI.Hosting.Channels/Allowlists/NativeIdAllowlist.cs create mode 100644 dotnet/src/Microsoft.Agents.AI.Hosting.Channels/AuthorizationContext.cs create mode 100644 dotnet/src/Microsoft.Agents.AI.Hosting.Channels/AuthorizationOutcome.cs create mode 100644 dotnet/src/Microsoft.Agents.AI.Hosting.Channels/AuthorizationPhase.cs create mode 100644 dotnet/src/Microsoft.Agents.AI.Hosting.Channels/AuthorizationProfile.cs create mode 100644 dotnet/src/Microsoft.Agents.AI.Hosting.Channels/AuthorizationRequest.cs create mode 100644 dotnet/src/Microsoft.Agents.AI.Hosting.Channels/Channel.cs create mode 100644 dotnet/src/Microsoft.Agents.AI.Hosting.Channels/ChannelCommand.cs create mode 100644 dotnet/src/Microsoft.Agents.AI.Hosting.Channels/ChannelContribution.cs create mode 100644 dotnet/src/Microsoft.Agents.AI.Hosting.Channels/ChannelIdentity.cs create mode 100644 dotnet/src/Microsoft.Agents.AI.Hosting.Channels/ChannelIdentityRegistration.cs create mode 100644 dotnet/src/Microsoft.Agents.AI.Hosting.Channels/ChannelPushContext.cs create mode 100644 dotnet/src/Microsoft.Agents.AI.Hosting.Channels/ChannelRequest.cs create mode 100644 dotnet/src/Microsoft.Agents.AI.Hosting.Channels/ChannelResponseContext.cs create mode 100644 dotnet/src/Microsoft.Agents.AI.Hosting.Channels/ChannelRunHookContext.cs create mode 100644 dotnet/src/Microsoft.Agents.AI.Hosting.Channels/ChannelSession.cs create mode 100644 dotnet/src/Microsoft.Agents.AI.Hosting.Channels/ClaimSource.cs create mode 100644 dotnet/src/Microsoft.Agents.AI.Hosting.Channels/ContinuationStatus.cs create mode 100644 dotnet/src/Microsoft.Agents.AI.Hosting.Channels/ContinuationToken.cs create mode 100644 dotnet/src/Microsoft.Agents.AI.Hosting.Channels/ConversationContext.cs create mode 100644 dotnet/src/Microsoft.Agents.AI.Hosting.Channels/ConversationScope.cs create mode 100644 dotnet/src/Microsoft.Agents.AI.Hosting.Channels/DurableTaskPayloadMode.cs create mode 100644 dotnet/src/Microsoft.Agents.AI.Hosting.Channels/DurableTaskStatus.cs create mode 100644 dotnet/src/Microsoft.Agents.AI.Hosting.Channels/EndpointRouteBuilderHostingChannelsExtensions.cs create mode 100644 dotnet/src/Microsoft.Agents.AI.Hosting.Channels/HostApplicationBuilderHostingChannelsExtensions.cs create mode 100644 dotnet/src/Microsoft.Agents.AI.Hosting.Channels/HostStatePathOptions.cs create mode 100644 dotnet/src/Microsoft.Agents.AI.Hosting.Channels/HostedRunResult.cs create mode 100644 dotnet/src/Microsoft.Agents.AI.Hosting.Channels/HostedStreamItem.cs create mode 100644 dotnet/src/Microsoft.Agents.AI.Hosting.Channels/IAgentFrameworkHostBuilder.cs create mode 100644 dotnet/src/Microsoft.Agents.AI.Hosting.Channels/IChannelContext.cs create mode 100644 dotnet/src/Microsoft.Agents.AI.Hosting.Channels/IChannelPush.cs create mode 100644 dotnet/src/Microsoft.Agents.AI.Hosting.Channels/IChannelPushCodec.cs create mode 100644 dotnet/src/Microsoft.Agents.AI.Hosting.Channels/IChannelResponseHook.cs create mode 100644 dotnet/src/Microsoft.Agents.AI.Hosting.Channels/IChannelRunHook.cs create mode 100644 dotnet/src/Microsoft.Agents.AI.Hosting.Channels/IChannelStreamTransformHook.cs create mode 100644 dotnet/src/Microsoft.Agents.AI.Hosting.Channels/IConfidentialityTagged.cs create mode 100644 dotnet/src/Microsoft.Agents.AI.Hosting.Channels/IDurableTaskRunner.cs create mode 100644 dotnet/src/Microsoft.Agents.AI.Hosting.Channels/IHostStateStore.cs create mode 100644 dotnet/src/Microsoft.Agents.AI.Hosting.Channels/IHostedTargetRunner.cs create mode 100644 dotnet/src/Microsoft.Agents.AI.Hosting.Channels/IIdentityAllowlist.cs create mode 100644 dotnet/src/Microsoft.Agents.AI.Hosting.Channels/IIdentityLinker.cs create mode 100644 dotnet/src/Microsoft.Agents.AI.Hosting.Channels/ILinkPolicy.cs create mode 100644 dotnet/src/Microsoft.Agents.AI.Hosting.Channels/InMemoryHostStateStore.cs create mode 100644 dotnet/src/Microsoft.Agents.AI.Hosting.Channels/InProcessDurableTaskRunner.cs create mode 100644 dotnet/src/Microsoft.Agents.AI.Hosting.Channels/Internal/AgentFrameworkHostBuilder.cs create mode 100644 dotnet/src/Microsoft.Agents.AI.Hosting.Channels/Internal/ChannelContext.cs create mode 100644 dotnet/src/Microsoft.Agents.AI.Hosting.Channels/IsolationKeys.cs create mode 100644 dotnet/src/Microsoft.Agents.AI.Hosting.Channels/LastSeenRecord.cs create mode 100644 dotnet/src/Microsoft.Agents.AI.Hosting.Channels/LinkChallenge.cs create mode 100644 dotnet/src/Microsoft.Agents.AI.Hosting.Channels/LinkGrant.cs create mode 100644 dotnet/src/Microsoft.Agents.AI.Hosting.Channels/LinkPolicies/AllowAllLinkPolicy.cs create mode 100644 dotnet/src/Microsoft.Agents.AI.Hosting.Channels/LinkPolicies/DenyAllLinkPolicy.cs create mode 100644 dotnet/src/Microsoft.Agents.AI.Hosting.Channels/LinkPolicies/ExplicitAllowListLinkPolicy.cs create mode 100644 dotnet/src/Microsoft.Agents.AI.Hosting.Channels/LinkPolicies/SameConfidentialityTierLinkPolicy.cs create mode 100644 dotnet/src/Microsoft.Agents.AI.Hosting.Channels/LinkPolicyContext.cs create mode 100644 dotnet/src/Microsoft.Agents.AI.Hosting.Channels/LinkPolicyOperation.cs create mode 100644 dotnet/src/Microsoft.Agents.AI.Hosting.Channels/Microsoft.Agents.AI.Hosting.Channels.csproj create mode 100644 dotnet/src/Microsoft.Agents.AI.Hosting.Channels/PrincipalIdentity.cs create mode 100644 dotnet/src/Microsoft.Agents.AI.Hosting.Channels/ResponseTarget.cs create mode 100644 dotnet/src/Microsoft.Agents.AI.Hosting.Channels/RetryPolicy.cs create mode 100644 dotnet/src/Microsoft.Agents.AI.Hosting.Channels/Runners/AIAgentRunner.cs create mode 100644 dotnet/src/Microsoft.Agents.AI.Hosting.Channels/Runners/WorkflowRunner.cs create mode 100644 dotnet/src/Microsoft.Agents.AI.Hosting.Channels/SessionMode.cs create mode 100644 dotnet/src/Microsoft.Agents.AI.Hosting.Channels/TaskHandle.cs create mode 100644 dotnet/src/Microsoft.Agents.AI.Hosting.Channels/TaskInvocationContext.cs diff --git a/dotnet/agent-framework-dotnet.slnx b/dotnet/agent-framework-dotnet.slnx index e3905ef2590..e2f0bd18610 100644 --- a/dotnet/agent-framework-dotnet.slnx +++ b/dotnet/agent-framework-dotnet.slnx @@ -618,6 +618,7 @@ + diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting.Channels/AcceptInGroup.cs b/dotnet/src/Microsoft.Agents.AI.Hosting.Channels/AcceptInGroup.cs new file mode 100644 index 00000000000..7153b5ad515 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.Hosting.Channels/AcceptInGroup.cs @@ -0,0 +1,21 @@ +// Copyright (c) Microsoft. All rights reserved. + +namespace Microsoft.Agents.AI.Hosting.Channels; + +/// +/// Controls which inbound group-conversation messages a channel accepts. +/// +public enum AcceptInGroup +{ + /// Accept only messages that mention the bot. Default for group surfaces. + MentionOnly, + + /// Accept only registered invocations. + CommandOnly, + + /// Accept either mentions or commands. + MentionOrCommand, + + /// Accept every inbound message. Opt-in for groups where the bot is the only conversational participant. + All, +} \ No newline at end of file diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting.Channels/AgentFrameworkHost.cs b/dotnet/src/Microsoft.Agents.AI.Hosting.Channels/AgentFrameworkHost.cs new file mode 100644 index 00000000000..2238870a571 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.Hosting.Channels/AgentFrameworkHost.cs @@ -0,0 +1,207 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Collections.Generic; +using System.Runtime.CompilerServices; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Shared.Diagnostics; + +namespace Microsoft.Agents.AI.Hosting.Channels; + +/// +/// The central host instance composed by AddAgentFrameworkHost(...) and surfaced via DI. +/// Owns the authorization pipeline, the runner seam, the channels collection, and the bridges +/// to + . +/// +/// +/// This draft implements the run / stream / authorize entry points end-to-end against the in-memory +/// defaults. Background runs and response fan-out land in a follow-up commit alongside the channel +/// packages that consume them. +/// +public sealed class AgentFrameworkHost +{ + private readonly IServiceProvider _services; + + internal AgentFrameworkHost( + IServiceProvider services, + IHostedTargetRunner targetRunner, + IReadOnlyList channels, + IHostStateStore stateStore, + IDurableTaskRunner durableRunner, + AgentFrameworkHostOptions options) + { + this._services = Throw.IfNull(services); + this.TargetRunner = Throw.IfNull(targetRunner); + this.Channels = Throw.IfNull(channels); + this.StateStore = Throw.IfNull(stateStore); + this.DurableRunner = Throw.IfNull(durableRunner); + this.Options = Throw.IfNull(options); + } + + /// Application service provider. + public IServiceProvider Services => this._services; + + /// Registered channels in registration order. + public IReadOnlyList Channels { get; } + + /// The configured target runner. + public IHostedTargetRunner TargetRunner { get; } + + /// The shared host state store. + public IHostStateStore StateStore { get; } + + /// The configured durable task runner. + public IDurableTaskRunner DurableRunner { get; } + + /// Composition-time options. + public AgentFrameworkHostOptions Options { get; } + + /// Run the target with the given request. + public ValueTask RunAsync(ChannelRequest request, CancellationToken cancellationToken = default) + { + Throw.IfNull(request); + return this.TargetRunner.RunAsync(request, cancellationToken); + } + + /// Stream the target's response. + public IAsyncEnumerable StreamAsync(ChannelRequest request, CancellationToken cancellationToken = default) + { + Throw.IfNull(request); + return this.TargetRunner.StreamAsync(request, cancellationToken); + } + + /// + /// Schedule a request to run in the background. Returns a the + /// caller can poll for completion. + /// + public async ValueTask RunInBackgroundAsync( + ChannelRequest request, + CancellationToken cancellationToken = default) + { + Throw.IfNull(request); + + var token = new ContinuationToken + { + Token = Guid.NewGuid().ToString("N"), + Status = ContinuationStatus.Queued, + IsolationKey = request.Session?.IsolationKey, + CreatedAt = DateTimeOffset.UtcNow, + ResponseTarget = request.ResponseTarget, + }; + + await this.StateStore.SaveContinuationAsync(token, cancellationToken).ConfigureAwait(false); + return token; + } + + /// Retrieve a previously-scheduled continuation token by its opaque id. + public ValueTask GetContinuationAsync(string token, CancellationToken cancellationToken = default) => + this.StateStore.GetContinuationAsync(token, cancellationToken); + + /// Rotate the active session alias for an isolation key (host-tracked channels' /new). + public ValueTask ResetSessionAsync(string isolationKey, CancellationToken cancellationToken = default) => + this.StateStore.RotateSessionAliasAsync(isolationKey, cancellationToken); + + /// Funnel an identity through the host's authorization pipeline. + public async ValueTask AuthorizeAsync( + ChannelIdentity identity, + AuthorizationRequest options, + CancellationToken cancellationToken = default) + { + Throw.IfNull(identity); + Throw.IfNull(options); + + var allowlist = options.Allowlist ?? this.Options.DefaultAllowlist ?? AllowAllIdentityAllowlist.Instance; + var linker = this._services.GetService(); + var verifiedClaims = options.VerifiedClaims; + var claimSource = verifiedClaims is null or { Count: 0 } ? ClaimSource.None : ClaimSource.Channel; + + var preContext = new AuthorizationContext + { + Identity = identity, + Phase = AuthorizationPhase.PreLink, + VerifiedClaims = verifiedClaims ?? new Dictionary(), + ClaimSource = claimSource, + ConversationContext = options.ConversationContext, + }; + + var preDecision = await allowlist.EvaluateAsync(preContext, cancellationToken).ConfigureAwait(false); + switch (preDecision) + { + case AllowlistDecision.Deny: + return new AuthorizationOutcome.Denied("allowlist_denied_pre_link"); + + case AllowlistDecision.Allow: + if (options.RequireLink && linker is not null) + { + var linkedKey = await linker.IsLinkedAsync(identity, verifiedClaims, cancellationToken).ConfigureAwait(false); + if (linkedKey is not null) + { + await this.StateStore.SaveLinkAsync(identity, linkedKey, verifiedClaims, cancellationToken).ConfigureAwait(false); + return new AuthorizationOutcome.Allowed(linkedKey); + } + var challenge = await linker.BeginAsync(identity, requestedIsolationKey: null, cancellationToken).ConfigureAwait(false); + return new AuthorizationOutcome.LinkRequired(challenge); + } + { + var key = await this.ResolveOrIssueIsolationKeyAsync(identity, cancellationToken).ConfigureAwait(false); + return new AuthorizationOutcome.Allowed(key); + } + + case AllowlistDecision.Abstain: + if ((options.RequireLink || allowlist.RequiresLinkedClaims) && linker is not null) + { + var linkedKey = await linker.IsLinkedAsync(identity, verifiedClaims, cancellationToken).ConfigureAwait(false); + if (linkedKey is null) + { + var challenge = await linker.BeginAsync(identity, requestedIsolationKey: null, cancellationToken).ConfigureAwait(false); + return new AuthorizationOutcome.LinkRequired(challenge); + } + + var linked = await this.StateStore.GetIdentitiesAsync(linkedKey, cancellationToken).ConfigureAwait(false); + Dictionary mergedClaims = new(); + foreach (var reg in linked) + { + foreach (var (k, v) in reg.VerifiedClaims) { mergedClaims[k] = v; } + } + if (verifiedClaims is not null) + { + foreach (var (k, v) in verifiedClaims) { mergedClaims[k] = v; } + } + + var postContext = preContext with + { + Phase = AuthorizationPhase.PostLink, + IsolationKey = linkedKey, + VerifiedClaims = mergedClaims, + ClaimSource = ClaimSource.Linker, + }; + var postDecision = await allowlist.EvaluateAsync(postContext, cancellationToken).ConfigureAwait(false); + return postDecision switch + { + AllowlistDecision.Allow => new AuthorizationOutcome.Allowed(linkedKey), + AllowlistDecision.Deny => new AuthorizationOutcome.Denied("allowlist_denied_post_link"), + _ => new AuthorizationOutcome.Denied("allowlist_abstain_after_link"), + }; + } + { + var key = await this.ResolveOrIssueIsolationKeyAsync(identity, cancellationToken).ConfigureAwait(false); + return new AuthorizationOutcome.Allowed(key); + } + } + + // Unreachable: AllowlistDecision is exhaustive. + throw new InvalidOperationException("Unhandled allowlist decision."); + } + + private async ValueTask ResolveOrIssueIsolationKeyAsync(ChannelIdentity identity, CancellationToken cancellationToken) + { + var existing = await this.StateStore.GetIsolationKeyAsync(identity, cancellationToken).ConfigureAwait(false); + if (existing is not null) { return existing; } + + var issued = $"{identity.Channel}:{identity.NativeId}"; + await this.StateStore.SaveLinkAsync(identity, issued, verifiedClaims: null, cancellationToken).ConfigureAwait(false); + return issued; + } +} \ No newline at end of file diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting.Channels/AgentFrameworkHostOptions.cs b/dotnet/src/Microsoft.Agents.AI.Hosting.Channels/AgentFrameworkHostOptions.cs new file mode 100644 index 00000000000..9942edbec52 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.Hosting.Channels/AgentFrameworkHostOptions.cs @@ -0,0 +1,24 @@ +// Copyright (c) Microsoft. All rights reserved. + +namespace Microsoft.Agents.AI.Hosting.Channels; + +/// +/// Composition-time options for AddAgentFrameworkHost(...). +/// +public sealed record AgentFrameworkHostOptions +{ + /// Host-level default allowlist. Per-channel allowlists may override or combine. + public IIdentityAllowlist? DefaultAllowlist { get; init; } + + /// Link policy applied across channels. + public ILinkPolicy? LinkPolicy { get; init; } + + /// File-system layout for the file-backed host state store. + public HostStatePathOptions? StatePaths { get; init; } + + /// Default durable runner name; reserved for fast-follow runner-selection wiring. + public string? DefaultDurableRunnerName { get; init; } + + /// Whether is permitted in ephemeral runtime modes. + public bool AllowInProcessRunnerInEphemeralMode { get; init; } +} \ No newline at end of file diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting.Channels/AllowlistDecision.cs b/dotnet/src/Microsoft.Agents.AI.Hosting.Channels/AllowlistDecision.cs new file mode 100644 index 00000000000..4385cbc18b7 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.Hosting.Channels/AllowlistDecision.cs @@ -0,0 +1,18 @@ +// Copyright (c) Microsoft. All rights reserved. + +namespace Microsoft.Agents.AI.Hosting.Channels; + +/// +/// Tri-state outcome of an evaluation. +/// +public enum AllowlistDecision +{ + /// Defer to subsequent allowlists in the combinator chain, or to the default-open rule when none remain. + Abstain, + + /// Admit the identity. + Allow, + + /// Reject the identity. Deny wins over Allow in . + Deny, +} \ No newline at end of file diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting.Channels/Allowlists/AllOfIdentityAllowlist.cs b/dotnet/src/Microsoft.Agents.AI.Hosting.Channels/Allowlists/AllOfIdentityAllowlist.cs new file mode 100644 index 00000000000..ca6ad73f9b8 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.Hosting.Channels/Allowlists/AllOfIdentityAllowlist.cs @@ -0,0 +1,50 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Shared.Diagnostics; + +namespace Microsoft.Agents.AI.Hosting.Channels; + +/// +/// AND combinator: every child must . Any +/// wins; one or more +/// with no denials yields . +/// +public sealed class AllOfIdentityAllowlist : IIdentityAllowlist +{ + private readonly IIdentityAllowlist[] _children; + + /// Initializes a new instance. + public AllOfIdentityAllowlist(params IIdentityAllowlist[] children) : this((IEnumerable)children) + { + } + + /// Initializes a new instance. + public AllOfIdentityAllowlist(IEnumerable children) + { + Throw.IfNull(children); + this._children = children.ToArray(); + } + + /// + public bool RequiresLinkedClaims => this._children.Any(c => c.RequiresLinkedClaims); + + /// + public async ValueTask EvaluateAsync(AuthorizationContext context, CancellationToken cancellationToken) + { + var sawAbstain = false; + for (var i = 0; i < this._children.Length; i++) + { + var decision = await this._children[i].EvaluateAsync(context, cancellationToken).ConfigureAwait(false); + switch (decision) + { + case AllowlistDecision.Deny: return AllowlistDecision.Deny; + case AllowlistDecision.Abstain: sawAbstain = true; break; + } + } + return sawAbstain ? AllowlistDecision.Abstain : AllowlistDecision.Allow; + } +} \ No newline at end of file diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting.Channels/Allowlists/AllowAllIdentityAllowlist.cs b/dotnet/src/Microsoft.Agents.AI.Hosting.Channels/Allowlists/AllowAllIdentityAllowlist.cs new file mode 100644 index 00000000000..ab169c65ac6 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.Hosting.Channels/Allowlists/AllowAllIdentityAllowlist.cs @@ -0,0 +1,17 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Threading; +using System.Threading.Tasks; + +namespace Microsoft.Agents.AI.Hosting.Channels; + +/// Allowlist that admits every identity. +public sealed class AllowAllIdentityAllowlist : IIdentityAllowlist +{ + /// Shared singleton. + public static AllowAllIdentityAllowlist Instance { get; } = new(); + + /// + public ValueTask EvaluateAsync(AuthorizationContext context, CancellationToken cancellationToken) => + new(AllowlistDecision.Allow); +} \ No newline at end of file diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting.Channels/Allowlists/AnyOfIdentityAllowlist.cs b/dotnet/src/Microsoft.Agents.AI.Hosting.Channels/Allowlists/AnyOfIdentityAllowlist.cs new file mode 100644 index 00000000000..05f06e81118 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.Hosting.Channels/Allowlists/AnyOfIdentityAllowlist.cs @@ -0,0 +1,50 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Shared.Diagnostics; + +namespace Microsoft.Agents.AI.Hosting.Channels; + +/// +/// Short-circuit OR combinator: first wins; otherwise +/// returns if any child denied, else . +/// +public sealed class AnyOfIdentityAllowlist : IIdentityAllowlist +{ + private readonly IIdentityAllowlist[] _children; + + /// Initializes a new instance. + public AnyOfIdentityAllowlist(params IIdentityAllowlist[] children) : this((IEnumerable)children) + { + } + + /// Initializes a new instance. + public AnyOfIdentityAllowlist(IEnumerable children) + { + Throw.IfNull(children); + this._children = children.ToArray(); + } + + /// + public bool RequiresLinkedClaims => this._children.Any(c => c.RequiresLinkedClaims); + + /// + public async ValueTask EvaluateAsync(AuthorizationContext context, CancellationToken cancellationToken) + { + var sawDeny = false; + for (var i = 0; i < this._children.Length; i++) + { + var decision = await this._children[i].EvaluateAsync(context, cancellationToken).ConfigureAwait(false); + switch (decision) + { + case AllowlistDecision.Allow: return AllowlistDecision.Allow; + case AllowlistDecision.Deny: sawDeny = true; break; + } + } + return sawDeny ? AllowlistDecision.Deny : AllowlistDecision.Abstain; + } +} \ No newline at end of file diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting.Channels/Allowlists/LinkedClaimAllowlist.cs b/dotnet/src/Microsoft.Agents.AI.Hosting.Channels/Allowlists/LinkedClaimAllowlist.cs new file mode 100644 index 00000000000..97c0ea8ebd1 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.Hosting.Channels/Allowlists/LinkedClaimAllowlist.cs @@ -0,0 +1,64 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text.RegularExpressions; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Shared.Diagnostics; + +namespace Microsoft.Agents.AI.Hosting.Channels; + +/// +/// Admits identities where a verified claim matches one of the configured values. Supports +/// glob-style wildcards (*@contoso.com) on values. Abstains at +/// when claims aren't yet available; the host's pipeline triggers the linker accordingly. +/// +public sealed class LinkedClaimAllowlist : IIdentityAllowlist +{ + private readonly string _claim; + private readonly string[] _values; + private readonly Regex[] _patterns; + + /// Initializes a new instance. + public LinkedClaimAllowlist(string claim, params string[] values) : this(claim, (IEnumerable)values) + { + } + + /// Initializes a new instance. + public LinkedClaimAllowlist(string claim, IEnumerable values) + { + this._claim = Throw.IfNullOrEmpty(claim); + this._values = (values ?? throw new ArgumentNullException(nameof(values))).ToArray(); + this._patterns = this._values.Select(GlobToRegex).ToArray(); + } + + /// + public bool RequiresLinkedClaims => true; + + /// + public ValueTask EvaluateAsync(AuthorizationContext context, CancellationToken cancellationToken) + { + Throw.IfNull(context); + if (!context.VerifiedClaims.TryGetValue(this._claim, out var observed)) + { + return new(AllowlistDecision.Abstain); + } + + for (var i = 0; i < this._patterns.Length; i++) + { + if (this._patterns[i].IsMatch(observed)) + { + return new(AllowlistDecision.Allow); + } + } + return new(AllowlistDecision.Deny); + } + + private static Regex GlobToRegex(string pattern) + { + var escaped = Regex.Escape(pattern).Replace("\\*", ".*").Replace("\\?", "."); + return new Regex("^" + escaped + "$", RegexOptions.IgnoreCase | RegexOptions.CultureInvariant); + } +} \ No newline at end of file diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting.Channels/Allowlists/NativeIdAllowlist.cs b/dotnet/src/Microsoft.Agents.AI.Hosting.Channels/Allowlists/NativeIdAllowlist.cs new file mode 100644 index 00000000000..ac03a717dfd --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.Hosting.Channels/Allowlists/NativeIdAllowlist.cs @@ -0,0 +1,42 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Shared.Diagnostics; + +namespace Microsoft.Agents.AI.Hosting.Channels; + +/// +/// Admits identities whose matches a target channel and +/// whose is in the allowed set. Abstains on identities +/// from other channels so combinators can defer to peers. +/// +public sealed class NativeIdAllowlist : IIdentityAllowlist +{ + private readonly string _channel; + private readonly HashSet _nativeIds; + + /// Initializes a new instance. + public NativeIdAllowlist(string channel, IEnumerable nativeIds) + { + this._channel = Throw.IfNullOrEmpty(channel); + this._nativeIds = new HashSet(nativeIds ?? throw new ArgumentNullException(nameof(nativeIds)), StringComparer.Ordinal); + } + + /// The channel this allowlist applies to. + public string Channel => this._channel; + + /// + public ValueTask EvaluateAsync(AuthorizationContext context, CancellationToken cancellationToken) + { + Throw.IfNull(context); + if (!string.Equals(context.Identity.Channel, this._channel, StringComparison.Ordinal)) + { + return new(AllowlistDecision.Abstain); + } + return new(this._nativeIds.Contains(context.Identity.NativeId) ? AllowlistDecision.Allow : AllowlistDecision.Deny); + } +} \ No newline at end of file diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting.Channels/AuthorizationContext.cs b/dotnet/src/Microsoft.Agents.AI.Hosting.Channels/AuthorizationContext.cs new file mode 100644 index 00000000000..e8b31c95035 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.Hosting.Channels/AuthorizationContext.cs @@ -0,0 +1,31 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Collections.Generic; +using System.Collections.Immutable; + +namespace Microsoft.Agents.AI.Hosting.Channels; + +/// +/// State passed to an at each phase of the authorization pipeline. +/// +public sealed record AuthorizationContext +{ + /// The channel-native identity being authorized. + public required ChannelIdentity Identity { get; init; } + + /// Current evaluation phase. PreLink runs before any linker is consulted. + public required AuthorizationPhase Phase { get; init; } + + /// Resolved isolation key. at . + public string? IsolationKey { get; init; } + + /// Verified claims attached to this evaluation. + public IReadOnlyDictionary VerifiedClaims { get; init; } = + ImmutableDictionary.Empty; + + /// Origin of . + public ClaimSource ClaimSource { get; init; } = ClaimSource.None; + + /// Conversation shape hints from the channel. + public ConversationContext? ConversationContext { get; init; } +} \ No newline at end of file diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting.Channels/AuthorizationOutcome.cs b/dotnet/src/Microsoft.Agents.AI.Hosting.Channels/AuthorizationOutcome.cs new file mode 100644 index 00000000000..5b414fb07b2 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.Hosting.Channels/AuthorizationOutcome.cs @@ -0,0 +1,28 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Collections.Generic; + +namespace Microsoft.Agents.AI.Hosting.Channels; + +/// +/// Discriminated outcome returned by . +/// +public abstract record AuthorizationOutcome +{ + private AuthorizationOutcome() { } + + /// The identity is admitted; is the resolved (or auto-issued) key. + public sealed record Allowed(string IsolationKey) : AuthorizationOutcome; + + /// The identity must complete a link ceremony before access is granted. + public sealed record LinkRequired(LinkChallenge Challenge) : AuthorizationOutcome; + + /// The identity is denied. is stable and machine-readable. + /// Stable, machine-readable denial code. + /// Optional message safe to surface publicly. + /// Optional structured detail for logs / telemetry. Never shown to users. + public sealed record Denied( + string ReasonCode, + string? UserMessage = null, + IReadOnlyDictionary? LogDetails = null) : AuthorizationOutcome; +} \ No newline at end of file diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting.Channels/AuthorizationPhase.cs b/dotnet/src/Microsoft.Agents.AI.Hosting.Channels/AuthorizationPhase.cs new file mode 100644 index 00000000000..8fd160affd9 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.Hosting.Channels/AuthorizationPhase.cs @@ -0,0 +1,15 @@ +// Copyright (c) Microsoft. All rights reserved. + +namespace Microsoft.Agents.AI.Hosting.Channels; + +/// +/// Phase of the authorization pipeline at which an allowlist is being evaluated. +/// +public enum AuthorizationPhase +{ + /// The channel has not (yet) presented linked-claim evidence for the identity. + PreLink, + + /// The identity has been linked via and verified claims are available. + PostLink, +} \ No newline at end of file diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting.Channels/AuthorizationProfile.cs b/dotnet/src/Microsoft.Agents.AI.Hosting.Channels/AuthorizationProfile.cs new file mode 100644 index 00000000000..a2f014b0738 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.Hosting.Channels/AuthorizationProfile.cs @@ -0,0 +1,27 @@ +// Copyright (c) Microsoft. All rights reserved. + +namespace Microsoft.Agents.AI.Hosting.Channels; + +/// +/// Convenience factory for the common allowlist shapes. Mirrors Python's named AuthPolicy factories. +/// +public static class AuthorizationProfile +{ + /// Open: admit every identity, auto-issue isolation keys on first contact. + public static IIdentityAllowlist Open() => AllowAllIdentityAllowlist.Instance; + + /// Force a link ceremony but otherwise admit every linked identity. + public static IIdentityAllowlist ForcedLink() => AllowAllIdentityAllowlist.Instance; + + /// Admit only identities whose channel-native id is in the configured set. + public static IIdentityAllowlist NativeAllowlist(string channel, params string[] nativeIds) => + new NativeIdAllowlist(channel, nativeIds); + + /// Admit only identities whose verified claim matches one of the values (forces link). + public static IIdentityAllowlist LinkedClaimAllowlist(string claim, params string[] values) => + new LinkedClaimAllowlist(claim, values); + + /// Native ids bypass link; everyone else funnels into a linked-claim allowlist. + public static IIdentityAllowlist Mixed(IIdentityAllowlist nativeAllowlist, IIdentityAllowlist linkedClaimAllowlist) => + new AnyOfIdentityAllowlist(nativeAllowlist, linkedClaimAllowlist); +} \ No newline at end of file diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting.Channels/AuthorizationRequest.cs b/dotnet/src/Microsoft.Agents.AI.Hosting.Channels/AuthorizationRequest.cs new file mode 100644 index 00000000000..c11025841c0 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.Hosting.Channels/AuthorizationRequest.cs @@ -0,0 +1,29 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Collections.Generic; + +namespace Microsoft.Agents.AI.Hosting.Channels; + +/// +/// Channel-supplied parameters for an call. +/// +public sealed record AuthorizationRequest +{ + /// + /// When , the host forces a link ceremony even when no allowlist requires it. + /// + public bool RequireLink { get; init; } + + /// + /// Per-call allowlist override. means "use the host default". + /// + public IIdentityAllowlist? Allowlist { get; init; } + + /// + /// Verified claims the channel observed for this identity (e.g. AAD object id from a bearer token). + /// + public IReadOnlyDictionary? VerifiedClaims { get; init; } + + /// Conversation shape hints (group vs. 1:1). + public ConversationContext? ConversationContext { get; init; } +} \ No newline at end of file diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting.Channels/Channel.cs b/dotnet/src/Microsoft.Agents.AI.Hosting.Channels/Channel.cs new file mode 100644 index 00000000000..a4237dd2857 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.Hosting.Channels/Channel.cs @@ -0,0 +1,49 @@ +// Copyright (c) Microsoft. All rights reserved. + +using Microsoft.Extensions.DependencyInjection; + +namespace Microsoft.Agents.AI.Hosting.Channels; + +/// +/// Base type for hosting channels. Authors derive from this to expose an inbound surface +/// (HTTP routes, long-poll loops, gateway connections, ...) and to optionally mix in capability +/// interfaces such as , , and +/// . +/// +/// +/// Two-phase lifecycle: +/// +/// runs at AddXxxChannel(...) time, before DI is built. +/// runs at MapAgentFrameworkHost(...) time, after DI is built. +/// +/// +public abstract class Channel +{ + /// Stable channel name. Matches / . + public abstract string Name { get; } + + /// + /// Mount root for the channel's routes. The host wraps + /// in endpoints.MapGroup(Path) before invoking each action. Empty mounts at the host root. + /// + public virtual string Path => string.Empty; + + /// + /// Whether this channel emits verified claims natively (e.g. an Activity Protocol bearer carrying + /// an AAD oid). Read by the host's startup validator when sizing the link requirement. + /// + public virtual bool EmitsVerifiedClaims => false; + + /// + /// Registers DI services the channel needs. Runs pre-Build. + /// + public virtual void ConfigureServices(IServiceCollection services) + { + } + + /// + /// Returns the channel's contribution to the running host (routes, commands, startup / shutdown + /// hooks, endpoint filters). Runs post-Build. + /// + public abstract ChannelContribution Contribute(IChannelContext context); +} \ No newline at end of file diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting.Channels/ChannelCommand.cs b/dotnet/src/Microsoft.Agents.AI.Hosting.Channels/ChannelCommand.cs new file mode 100644 index 00000000000..b62a8a96438 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.Hosting.Channels/ChannelCommand.cs @@ -0,0 +1,12 @@ +// Copyright (c) Microsoft. All rights reserved. + +namespace Microsoft.Agents.AI.Hosting.Channels; + +/// +/// Declarative description of a channel-native command (Telegram slash command, Discord +/// application command, ...). Channels emit these from as +/// passive metadata; native registration with the protocol is the channel's responsibility. +/// +/// The command name without any leading sentinel (e.g. "new" not "/new"). +/// Short description surfaced in the protocol's UI. +public sealed record ChannelCommand(string Name, string Description); \ No newline at end of file diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting.Channels/ChannelContribution.cs b/dotnet/src/Microsoft.Agents.AI.Hosting.Channels/ChannelContribution.cs new file mode 100644 index 00000000000..be3b7b6a5cc --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.Hosting.Channels/ChannelContribution.cs @@ -0,0 +1,40 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Routing; + +namespace Microsoft.Agents.AI.Hosting.Channels; + +/// +/// Returned by . Carries the routes, commands, endpoint filters, +/// and lifecycle hooks the channel publishes to the running host. +/// +public sealed record ChannelContribution +{ + /// + /// Route registration actions. The host invokes each one with an + /// rooted at via ' + /// group semantics, so map paths relative to . + /// + public IReadOnlyList> Routes { get; init; } = []; + + /// + /// Endpoint filters applied to the -rooted group. Replaces Python's + /// middleware slot. + /// + public IReadOnlyList EndpointFilters { get; init; } = []; + + /// Declarative commands; channels read these and call the protocol's native registration. + public IReadOnlyList Commands { get; init; } = []; + + /// Optional startup hook invoked once after DI is built. Useful for long-poll loops. + public Func? OnStartup { get; init; } + + /// Optional shutdown hook invoked during graceful shutdown. + public Func? OnShutdown { get; init; } +} \ No newline at end of file diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting.Channels/ChannelIdentity.cs b/dotnet/src/Microsoft.Agents.AI.Hosting.Channels/ChannelIdentity.cs new file mode 100644 index 00000000000..4956038d9ff --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.Hosting.Channels/ChannelIdentity.cs @@ -0,0 +1,20 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Collections.Generic; +using System.Collections.Immutable; + +namespace Microsoft.Agents.AI.Hosting.Channels; + +/// +/// Channel-native user identity observed on a . +/// +/// The originating channel name (matches ). +/// The channel-native USER identifier (never the chat or conversation id). +public sealed record ChannelIdentity(string Channel, string NativeId) +{ + /// + /// Channel-defined attributes attached to this identity (e.g. display name, language). + /// + public IReadOnlyDictionary Attributes { get; init; } = + ImmutableDictionary.Empty; +} \ No newline at end of file diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting.Channels/ChannelIdentityRegistration.cs b/dotnet/src/Microsoft.Agents.AI.Hosting.Channels/ChannelIdentityRegistration.cs new file mode 100644 index 00000000000..f3888ae72e9 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.Hosting.Channels/ChannelIdentityRegistration.cs @@ -0,0 +1,17 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Collections.Generic; + +namespace Microsoft.Agents.AI.Hosting.Channels; + +/// +/// Single row in the host's identity registry: a (channel, native_id) mapping to an isolation key. +/// +/// The channel-native identity. +/// When the mapping was first written. +/// Verified claims persisted at link time for auto-link replay. +public sealed record ChannelIdentityRegistration( + ChannelIdentity Identity, + DateTimeOffset RegisteredAt, + IReadOnlyDictionary VerifiedClaims); \ No newline at end of file diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting.Channels/ChannelPushContext.cs b/dotnet/src/Microsoft.Agents.AI.Hosting.Channels/ChannelPushContext.cs new file mode 100644 index 00000000000..696346ed8f8 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.Hosting.Channels/ChannelPushContext.cs @@ -0,0 +1,24 @@ +// Copyright (c) Microsoft. All rights reserved. + +namespace Microsoft.Agents.AI.Hosting.Channels; + +/// +/// Per-delivery context passed to . +/// +public sealed record ChannelPushContext +{ + /// The channel-native identity to deliver to. + public required ChannelIdentity Destination { get; init; } + + /// The originating request that produced the payload. + public required ChannelRequest OriginatingRequest { get; init; } + + /// The channel name the request originated on. + public required string OriginatingChannel { get; init; } + + /// Whether this push is the echo of the user input, not the agent reply. + public bool IsEcho { get; init; } + + /// The response target the user originally requested, when non-default. + public ResponseTarget? OriginalTarget { get; init; } +} \ No newline at end of file diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting.Channels/ChannelRequest.cs b/dotnet/src/Microsoft.Agents.AI.Hosting.Channels/ChannelRequest.cs new file mode 100644 index 00000000000..311b0d433ee --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.Hosting.Channels/ChannelRequest.cs @@ -0,0 +1,82 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Collections.Generic; +using System.Collections.Immutable; +using Microsoft.Extensions.AI; + +namespace Microsoft.Agents.AI.Hosting.Channels; + +/// +/// Channel-neutral request envelope handed to / +/// . Channels build this from their wire format. +/// +public sealed record ChannelRequest +{ + /// Originating channel name (matches ). + public required string Channel { get; init; } + + /// Operation kind: "message.create", "command.invoke", "approval.respond", ... + public required string Operation { get; init; } + + /// + /// Target input. Reuses framework input types; boxed because the union spans + /// inputs, arrays, and workflow-typed inputs. + /// + public required object Input { get; init; } + + /// Session hint. for ephemeral or host-tracked channels. + public ChannelSession? Session { get; init; } + + /// Channel-native user identity. for anonymous channels. + public ChannelIdentity? Identity { get; init; } + + /// Protocol-visible conversation / thread / topic id, when distinct from . + public string? ConversationId { get; init; } + + /// + /// Caller-derived chat options forwarded onto the runner's . + /// + public ChatOptions? Options { get; init; } + + /// How the host should resolve session continuity for this request. + public SessionMode SessionMode { get; init; } = SessionMode.Auto; + + /// Protocol-level metadata for telemetry. The host never reads this. + public IReadOnlyDictionary Metadata { get; init; } = + ImmutableDictionary.Empty; + + /// + /// Channel-specific structured values surfaced to the run hook. Reserved keys for workflow + /// targets: "workflow.checkpoint_id", "workflow.resume_token". + /// + public IReadOnlyDictionary Attributes { get; init; } = + ImmutableDictionary.Empty; + + /// + /// Bidirectional, mutable per-request state slot for event-rich front-ends (AG-UI). + /// Opaque to the host. + /// + public IDictionary? ClientState { get; init; } + + /// + /// Frontend tool catalog supplied per request. Forwarded onto + /// but the host never invokes them. + /// + public IReadOnlyList? ClientTools { get; init; } + + /// Pass-through bag for channel-protocol extras (AG-UI resume, command, ...). + public IReadOnlyDictionary? ForwardedProps { get; init; } + + /// Whether the channel is calling rather than . + public bool Stream { get; init; } + + /// Where the response is delivered. defaults to . + public ResponseTarget? ResponseTarget { get; init; } + + /// + /// When , the host returns a immediately + /// rather than awaiting the response. Forced when + /// is . + /// + public bool Background { get; init; } +} \ No newline at end of file diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting.Channels/ChannelResponseContext.cs b/dotnet/src/Microsoft.Agents.AI.Hosting.Channels/ChannelResponseContext.cs new file mode 100644 index 00000000000..98e70af15a3 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.Hosting.Channels/ChannelResponseContext.cs @@ -0,0 +1,24 @@ +// Copyright (c) Microsoft. All rights reserved. + +namespace Microsoft.Agents.AI.Hosting.Channels; + +/// +/// Per-destination context passed to . +/// +public sealed record ChannelResponseContext +{ + /// The originating request. + public required ChannelRequest Request { get; init; } + + /// The destination channel for this delivery. + public required string ChannelName { get; init; } + + /// The destination identity for this delivery. + public required ChannelIdentity DestinationIdentity { get; init; } + + /// True when this delivery is on the same channel the request originated on. + public bool Originating { get; init; } + + /// True when this is the echo push of the user input rather than the agent reply. + public bool IsEcho { get; init; } +} \ No newline at end of file diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting.Channels/ChannelRunHookContext.cs b/dotnet/src/Microsoft.Agents.AI.Hosting.Channels/ChannelRunHookContext.cs new file mode 100644 index 00000000000..09df607e6d9 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.Hosting.Channels/ChannelRunHookContext.cs @@ -0,0 +1,15 @@ +// Copyright (c) Microsoft. All rights reserved. + +namespace Microsoft.Agents.AI.Hosting.Channels; + +/// +/// Per-call context passed to . +/// +public sealed record ChannelRunHookContext +{ + /// The runner target: an , a workflow, or a hosted-agent handle. + public required object Target { get; init; } + + /// The raw inbound payload as it arrived on the wire. Loosely typed. + public object? ProtocolRequest { get; init; } +} \ No newline at end of file diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting.Channels/ChannelSession.cs b/dotnet/src/Microsoft.Agents.AI.Hosting.Channels/ChannelSession.cs new file mode 100644 index 00000000000..809c6909cda --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.Hosting.Channels/ChannelSession.cs @@ -0,0 +1,30 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Collections.Generic; +using System.Collections.Immutable; + +namespace Microsoft.Agents.AI.Hosting.Channels; + +/// +/// Session hint carried on a . All fields nullable to support +/// caller-supplied (Responses, Invocations) and host-tracked (Telegram, Activity Protocol) channels. +/// +public sealed record ChannelSession +{ + /// + /// Stable host lookup key for an . Caller-supplied channels populate + /// from the wire (previous_response_id, conversation_id); host-tracked channels + /// leave this and let the per-isolation-key alias decide. + /// + public string? Key { get; init; } + + /// The protocol-visible conversation or thread identifier when one exists. + public string? ConversationId { get; init; } + + /// Opaque isolation boundary (user, tenant, chat, ...) using hosted-agent terminology. + public string? IsolationKey { get; init; } + + /// Channel-defined attributes; not interpreted by the host. + public IReadOnlyDictionary Attributes { get; init; } = + ImmutableDictionary.Empty; +} \ No newline at end of file diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting.Channels/ClaimSource.cs b/dotnet/src/Microsoft.Agents.AI.Hosting.Channels/ClaimSource.cs new file mode 100644 index 00000000000..80f643898a4 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.Hosting.Channels/ClaimSource.cs @@ -0,0 +1,18 @@ +// Copyright (c) Microsoft. All rights reserved. + +namespace Microsoft.Agents.AI.Hosting.Channels; + +/// +/// Where the verified claims on an originated. +/// +public enum ClaimSource +{ + /// No verified claims are present. + None, + + /// Claims came from the channel itself (e.g. an Activity Protocol bearer token's AAD object id). + Channel, + + /// Claims came from a completed ceremony. + Linker, +} \ No newline at end of file diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting.Channels/ContinuationStatus.cs b/dotnet/src/Microsoft.Agents.AI.Hosting.Channels/ContinuationStatus.cs new file mode 100644 index 00000000000..9dc13c94b12 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.Hosting.Channels/ContinuationStatus.cs @@ -0,0 +1,21 @@ +// Copyright (c) Microsoft. All rights reserved. + +namespace Microsoft.Agents.AI.Hosting.Channels; + +/// +/// Lifecycle state of a . +/// +public enum ContinuationStatus +{ + /// The run is queued and not yet started. + Queued, + + /// The run is executing. + Running, + + /// The run completed; carries the value. + Completed, + + /// The run failed; carries the reason. + Failed, +} \ No newline at end of file diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting.Channels/ContinuationToken.cs b/dotnet/src/Microsoft.Agents.AI.Hosting.Channels/ContinuationToken.cs new file mode 100644 index 00000000000..f4bd86d9f8e --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.Hosting.Channels/ContinuationToken.cs @@ -0,0 +1,35 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; + +namespace Microsoft.Agents.AI.Hosting.Channels; + +/// +/// Persisted continuation handle for a background or paused run. +/// +public sealed record ContinuationToken +{ + /// Opaque token surface the caller correlates against. + public required string Token { get; init; } + + /// Current lifecycle status. + public required ContinuationStatus Status { get; init; } + + /// The isolation key the underlying run is scoped to. + public string? IsolationKey { get; init; } + + /// When the continuation was created. + public required DateTimeOffset CreatedAt { get; init; } + + /// When the underlying run reached a terminal state, if any. + public DateTimeOffset? CompletedAt { get; init; } + + /// The completed result, populated when is . + public HostedRunResult? Result { get; init; } + + /// Failure summary, populated when is . + public string? Error { get; init; } + + /// The response target the run was scheduled with, when non-default. + public ResponseTarget? ResponseTarget { get; init; } +} \ No newline at end of file diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting.Channels/ConversationContext.cs b/dotnet/src/Microsoft.Agents.AI.Hosting.Channels/ConversationContext.cs new file mode 100644 index 00000000000..25f067dec96 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.Hosting.Channels/ConversationContext.cs @@ -0,0 +1,10 @@ +// Copyright (c) Microsoft. All rights reserved. + +namespace Microsoft.Agents.AI.Hosting.Channels; + +/// +/// Conversation-shape hints handed to the authorization pipeline. +/// +/// The protocol-visible conversation id, or for 1:1. +/// Whether this conversation has more than one human participant. +public sealed record ConversationContext(string? ConversationId, bool IsGroup); \ No newline at end of file diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting.Channels/ConversationScope.cs b/dotnet/src/Microsoft.Agents.AI.Hosting.Channels/ConversationScope.cs new file mode 100644 index 00000000000..9a8aa1cf384 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.Hosting.Channels/ConversationScope.cs @@ -0,0 +1,18 @@ +// Copyright (c) Microsoft. All rights reserved. + +namespace Microsoft.Agents.AI.Hosting.Channels; + +/// +/// Controls how a channel derives the host isolation key in multi-user conversations. +/// +public enum ConversationScope +{ + /// One isolation key per user across all conversations. Personal-assistant style. + PerUser, + + /// One isolation key per user per conversation. Default for multi-user surfaces. + PerUserPerConversation, + + /// One isolation key per conversation. Every member shares state. "Bot lives in this channel" style. + PerConversation, +} \ No newline at end of file diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting.Channels/DurableTaskPayloadMode.cs b/dotnet/src/Microsoft.Agents.AI.Hosting.Channels/DurableTaskPayloadMode.cs new file mode 100644 index 00000000000..148029dbca4 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.Hosting.Channels/DurableTaskPayloadMode.cs @@ -0,0 +1,21 @@ +// Copyright (c) Microsoft. All rights reserved. + +namespace Microsoft.Agents.AI.Hosting.Channels; + +/// +/// Declares how an serializes task payloads. +/// +/// +/// The host validates pairings at startup: if the runner is , every push-capable +/// channel must also implement . +/// +#pragma warning disable CA1720 // Identifier contains type name — Python parity (OBJECT / JSON enum values). +public enum DurableTaskPayloadMode +{ + /// Payloads are passed through as opaque .NET objects (in-process runners). + Object, + + /// Payloads are JSON-serialized; channels must supply an . + Json, +} +#pragma warning restore CA1720 \ No newline at end of file diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting.Channels/DurableTaskStatus.cs b/dotnet/src/Microsoft.Agents.AI.Hosting.Channels/DurableTaskStatus.cs new file mode 100644 index 00000000000..466e37cc560 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.Hosting.Channels/DurableTaskStatus.cs @@ -0,0 +1,24 @@ +// Copyright (c) Microsoft. All rights reserved. + +namespace Microsoft.Agents.AI.Hosting.Channels; + +/// +/// Lifecycle state of a scheduled durable task. +/// +public enum DurableTaskStatus +{ + /// Queued, not yet picked up by a worker. + Scheduled, + + /// Currently executing. + Running, + + /// Completed successfully. + Succeeded, + + /// Failed after exhausting the retry policy. + Failed, + + /// Cancelled before reaching a terminal state. + Cancelled, +} \ No newline at end of file diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting.Channels/EndpointRouteBuilderHostingChannelsExtensions.cs b/dotnet/src/Microsoft.Agents.AI.Hosting.Channels/EndpointRouteBuilderHostingChannelsExtensions.cs new file mode 100644 index 00000000000..d84ca531750 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.Hosting.Channels/EndpointRouteBuilderHostingChannelsExtensions.cs @@ -0,0 +1,51 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using Microsoft.Agents.AI.Hosting.Channels; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Routing; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Shared.Diagnostics; + +#pragma warning disable IDE0130 // Namespace does not match folder structure — intentional: extension methods live in the host's framework namespace. +namespace Microsoft.AspNetCore.Builder; +#pragma warning restore IDE0130 + +/// +/// Extension methods on for mounting an agent-framework host. +/// +public static class EndpointRouteBuilderHostingChannelsExtensions +{ + /// + /// Mounts every registered channel's routes (rooted at each channel's path) and invokes each channel's startup hook. + /// + public static IEndpointConventionBuilder MapAgentFrameworkHost(this IEndpointRouteBuilder endpoints) + { + Throw.IfNull(endpoints); + + var host = endpoints.ServiceProvider.GetRequiredService(); + var context = new ChannelContext(endpoints.ServiceProvider, host); + var hostGroup = endpoints.MapGroup(string.Empty); + + foreach (var channel in host.Channels) + { + var contribution = channel.Contribute(context); + var channelGroup = string.IsNullOrEmpty(channel.Path) + ? hostGroup + : endpoints.MapGroup(channel.Path); + + foreach (var filter in contribution.EndpointFilters) + { + channelGroup.AddEndpointFilter(filter); + } + + foreach (var register in contribution.Routes) + { + register(channelGroup); + } + } + + return hostGroup; + } +} \ No newline at end of file diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting.Channels/HostApplicationBuilderHostingChannelsExtensions.cs b/dotnet/src/Microsoft.Agents.AI.Hosting.Channels/HostApplicationBuilderHostingChannelsExtensions.cs new file mode 100644 index 00000000000..78e14daecd7 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.Hosting.Channels/HostApplicationBuilderHostingChannelsExtensions.cs @@ -0,0 +1,93 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using Microsoft.Agents.AI; +using Microsoft.Agents.AI.Hosting.Channels; +using Microsoft.Agents.AI.Workflows; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.DependencyInjection.Extensions; +using Microsoft.Extensions.Logging; +using Microsoft.Shared.Diagnostics; + +namespace Microsoft.Extensions.Hosting; + +/// +/// Extension methods on for composing an +/// with channels. +/// +public static class HostApplicationBuilderHostingChannelsExtensions +{ + /// Adds an agent-framework host whose target is the supplied . + public static IAgentFrameworkHostBuilder AddAgentFrameworkHost( + this IHostApplicationBuilder builder, + AIAgent target, + Action? configure = null) + { + Throw.IfNull(builder); + Throw.IfNull(target); + return AddAgentFrameworkHostCore(builder, configure, services => services.TryAddSingleton(_ => new AIAgentRunner(target))); + } + + /// Adds an agent-framework host whose target is the supplied . + public static IAgentFrameworkHostBuilder AddAgentFrameworkHost( + this IHostApplicationBuilder builder, + Workflow target, + Action? configure = null) + { + Throw.IfNull(builder); + Throw.IfNull(target); + return AddAgentFrameworkHostCore(builder, configure, services => services.TryAddSingleton(_ => new WorkflowRunner(target))); + } + + /// + /// Adds an agent-framework host whose target is resolved from a factory. Generic overload for + /// alternative runners (Foundry, mocks, ...) supplied by other packages. + /// + public static IAgentFrameworkHostBuilder AddAgentFrameworkHost( + this IHostApplicationBuilder builder, + Func targetFactory, + Action? configure = null) + where TTarget : class + { + Throw.IfNull(builder); + Throw.IfNull(targetFactory); + return AddAgentFrameworkHostCore(builder, configure, services => services.TryAddSingleton(targetFactory)); + } + + private static AgentFrameworkHostBuilder AddAgentFrameworkHostCore( + IHostApplicationBuilder builder, + Action? configure, + Action registerTarget) + { + var options = new AgentFrameworkHostOptions(); + configure?.Invoke(options); + + var services = builder.Services; + + services.TryAddSingleton(_ => new InMemoryHostStateStore()); + services.TryAddSingleton(); + services.TryAddSingleton(sp => sp.GetRequiredService()); + services.AddHostedService(sp => sp.GetRequiredService()); + + services.TryAddSingleton(_ => options.LinkPolicy ?? AllowAllLinkPolicy.Instance); + services.TryAddSingleton(); + + if (options.DefaultAllowlist is not null) + { + services.TryAddSingleton(options.DefaultAllowlist); + } + + registerTarget(services); + + services.TryAddSingleton(options); + services.TryAddSingleton(sp => new AgentFrameworkHost( + sp, + sp.GetRequiredService(), + sp.GetRequiredService>(), + sp.GetRequiredService(), + sp.GetRequiredService(), + sp.GetRequiredService())); + + return new AgentFrameworkHostBuilder(services, options); + } +} \ No newline at end of file diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting.Channels/HostStatePathOptions.cs b/dotnet/src/Microsoft.Agents.AI.Hosting.Channels/HostStatePathOptions.cs new file mode 100644 index 00000000000..8108b7d52d3 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.Hosting.Channels/HostStatePathOptions.cs @@ -0,0 +1,25 @@ +// Copyright (c) Microsoft. All rights reserved. + +namespace Microsoft.Agents.AI.Hosting.Channels; + +/// +/// File-system layout for the file-backed host state store. All paths are optional; when a +/// per-component path is omitted the store derives it from . +/// +public sealed record HostStatePathOptions +{ + /// Root directory under which per-component subpaths are derived. + public string? Root { get; init; } + + /// Path used by for persistent task records. + public string? RunnerPath { get; init; } + + /// Path used for the identity registry and pending link grants. + public string? LinksPath { get; init; } + + /// Path used for continuation tokens. + public string? ContinuationsPath { get; init; } + + /// Path used for last-seen ledger entries that back . + public string? LastSeenPath { get; init; } +} \ No newline at end of file diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting.Channels/HostedRunResult.cs b/dotnet/src/Microsoft.Agents.AI.Hosting.Channels/HostedRunResult.cs new file mode 100644 index 00000000000..322b77ee616 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.Hosting.Channels/HostedRunResult.cs @@ -0,0 +1,37 @@ +// Copyright (c) Microsoft. All rights reserved. + +namespace Microsoft.Agents.AI.Hosting.Channels; + +/// +/// Non-generic base for run results so channels and the host can pass them through capability +/// interfaces without committing to a result type. Inspect +/// or cast to for typed access. +/// +public abstract record HostedRunResult +{ + /// Session attached to this result, when present. + public ChannelSession? Session { get; init; } + + /// The wrapped result as a boxed object. + public abstract object? ResultObject { get; } +} + +/// +/// Typed run result envelope. is AgentRunResponse for +/// agent targets and WorkflowRunResponse for workflow targets. +/// +public sealed record HostedRunResult : HostedRunResult +{ + /// The typed result. + public required TResult Result { get; init; } + + /// + public override object? ResultObject => this.Result; + + /// + /// Shallow clone with a rewritten ; used by per-destination + /// rebinds. + /// + public HostedRunResult Replace(TNew newResult) => + new() { Result = newResult, Session = this.Session }; +} \ No newline at end of file diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting.Channels/HostedStreamItem.cs b/dotnet/src/Microsoft.Agents.AI.Hosting.Channels/HostedStreamItem.cs new file mode 100644 index 00000000000..38d7c118fdd --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.Hosting.Channels/HostedStreamItem.cs @@ -0,0 +1,27 @@ +// Copyright (c) Microsoft. All rights reserved. + +namespace Microsoft.Agents.AI.Hosting.Channels; + +/// +/// Base type for items yielded by . +/// Covers both normalized agent updates () and protocol-specific +/// events () behind one stream type. +/// +/// +/// is always the terminal item and carries the final +/// for downstream bookkeeping (intended-targets envelope, +/// durable push scheduling). +/// +public abstract record HostedStreamItem +{ + private protected HostedStreamItem() { } +} + +/// Normalized agent stream update; lossless for messages, function calls, usage. +public sealed record HostedStreamUpdate(AgentResponseUpdate Update) : HostedStreamItem; + +/// Protocol-specific event the framework does not model (workflow events, AG-UI events). +public sealed record HostedStreamEvent(object Event) : HostedStreamItem; + +/// Terminal item carrying the final result for post-stream bookkeeping. +public sealed record HostedStreamCompleted(HostedRunResult Result) : HostedStreamItem; \ No newline at end of file diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting.Channels/IAgentFrameworkHostBuilder.cs b/dotnet/src/Microsoft.Agents.AI.Hosting.Channels/IAgentFrameworkHostBuilder.cs new file mode 100644 index 00000000000..b3ccc762d79 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.Hosting.Channels/IAgentFrameworkHostBuilder.cs @@ -0,0 +1,44 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using Microsoft.Extensions.DependencyInjection; + +namespace Microsoft.Agents.AI.Hosting.Channels; + +/// +/// Builder chained off AddAgentFrameworkHost(...). Channel-add extension methods +/// (AddResponsesChannel, AddInvocationsChannel, AddTelegramChannel, ...) +/// hang off this interface. +/// +public interface IAgentFrameworkHostBuilder +{ + /// Underlying service collection. + IServiceCollection Services { get; } + + /// Composition-time options. + AgentFrameworkHostOptions Options { get; } + + /// Add a channel instance. + IAgentFrameworkHostBuilder AddChannel(Channel channel); + + /// Add a channel resolved from DI via a factory. + IAgentFrameworkHostBuilder AddChannel(Func factory) where TChannel : Channel; + + /// Replace the registered . + IAgentFrameworkHostBuilder UseIdentityLinker<[System.Diagnostics.CodeAnalysis.DynamicallyAccessedMembers(System.Diagnostics.CodeAnalysis.DynamicallyAccessedMemberTypes.PublicConstructors)] TLinker>() + where TLinker : class, IIdentityLinker; + + /// Replace the host-level default allowlist. + IAgentFrameworkHostBuilder UseDefaultAllowlist(IIdentityAllowlist allowlist); + + /// Replace the registered link policy. + IAgentFrameworkHostBuilder UseLinkPolicy(ILinkPolicy policy); + + /// Replace the registered durable task runner. + IAgentFrameworkHostBuilder UseDurableTaskRunner<[System.Diagnostics.CodeAnalysis.DynamicallyAccessedMembers(System.Diagnostics.CodeAnalysis.DynamicallyAccessedMemberTypes.PublicConstructors)] TRunner>() + where TRunner : class, IDurableTaskRunner; + + /// Replace the registered host state store. + IAgentFrameworkHostBuilder UseHostStateStore<[System.Diagnostics.CodeAnalysis.DynamicallyAccessedMembers(System.Diagnostics.CodeAnalysis.DynamicallyAccessedMemberTypes.PublicConstructors)] TStore>() + where TStore : class, IHostStateStore; +} \ No newline at end of file diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting.Channels/IChannelContext.cs b/dotnet/src/Microsoft.Agents.AI.Hosting.Channels/IChannelContext.cs new file mode 100644 index 00000000000..eebda3ca046 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.Hosting.Channels/IChannelContext.cs @@ -0,0 +1,51 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; + +namespace Microsoft.Agents.AI.Hosting.Channels; + +/// +/// Handed to . Exposes the host's run / stream / authorization +/// surface plus the persisted state store and durable task runner. +/// +public interface IChannelContext +{ + /// Application service provider. + IServiceProvider Services { get; } + + /// The host this channel was added to. + AgentFrameworkHost Host { get; } + + /// The host state store. + IHostStateStore StateStore { get; } + + /// The durable task runner that backs non-originating response delivery and background runs. + IDurableTaskRunner DurableRunner { get; } + + /// + /// Funnel a channel-native identity through the host's authorization pipeline. + /// + ValueTask AuthorizeAsync( + ChannelIdentity identity, + AuthorizationRequest options, + CancellationToken cancellationToken = default); + + /// Run the host target with the given request and return the (non-streaming) result. + ValueTask RunAsync(ChannelRequest request, CancellationToken cancellationToken = default); + + /// Stream the host target's response as envelopes. + IAsyncEnumerable StreamAsync(ChannelRequest request, CancellationToken cancellationToken = default); + + /// + /// Schedule outbound delivery for every non-originating destination resolved against + /// . Originating delivery is NOT scheduled here; + /// channels render their own originating reply synchronously. + /// + ValueTask> ScheduleResponseAsync( + HostedRunResult result, + ChannelRequest originating, + CancellationToken cancellationToken = default); +} \ No newline at end of file diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting.Channels/IChannelPush.cs b/dotnet/src/Microsoft.Agents.AI.Hosting.Channels/IChannelPush.cs new file mode 100644 index 00000000000..cfa7677b8b0 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.Hosting.Channels/IChannelPush.cs @@ -0,0 +1,16 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Threading; +using System.Threading.Tasks; + +namespace Microsoft.Agents.AI.Hosting.Channels; + +/// +/// Capability interface: a channel can deliver a response to a destination identity it owns. +/// Implementations are invoked by the host's hosting.push durable task handler. +/// +public interface IChannelPush +{ + /// Push a result to the destination described by . + ValueTask PushAsync(ChannelPushContext context, HostedRunResult payload, CancellationToken cancellationToken); +} \ No newline at end of file diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting.Channels/IChannelPushCodec.cs b/dotnet/src/Microsoft.Agents.AI.Hosting.Channels/IChannelPushCodec.cs new file mode 100644 index 00000000000..1b12965baa6 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.Hosting.Channels/IChannelPushCodec.cs @@ -0,0 +1,19 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Text.Json.Nodes; + +namespace Microsoft.Agents.AI.Hosting.Channels; + +/// +/// Capability interface: encode the full push envelope (context + payload) so a JSON-payload +/// (out-of-process worker, gRPC TaskHub, ...) can reconstruct +/// it on the receiving side. Required pairing rule is validated by the host at startup. +/// +public interface IChannelPushCodec +{ + /// Encode the push envelope. + JsonNode Encode(ChannelPushContext context, HostedRunResult payload); + + /// Decode a previously-encoded push envelope. + (ChannelPushContext Context, HostedRunResult Payload) Decode(JsonNode encoded); +} \ No newline at end of file diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting.Channels/IChannelResponseHook.cs b/dotnet/src/Microsoft.Agents.AI.Hosting.Channels/IChannelResponseHook.cs new file mode 100644 index 00000000000..b28585116c8 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.Hosting.Channels/IChannelResponseHook.cs @@ -0,0 +1,20 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Threading; +using System.Threading.Tasks; + +namespace Microsoft.Agents.AI.Hosting.Channels; + +/// +/// Capability interface: receive a per-destination clone of the run result and return a possibly +/// rewritten replacement. Hooks rebind via +/// rather than mutating in place. +/// +public interface IChannelResponseHook +{ + /// Return the (possibly rewritten) result for this destination. + ValueTask OnResponseAsync( + HostedRunResult result, + ChannelResponseContext context, + CancellationToken cancellationToken); +} \ No newline at end of file diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting.Channels/IChannelRunHook.cs b/dotnet/src/Microsoft.Agents.AI.Hosting.Channels/IChannelRunHook.cs new file mode 100644 index 00000000000..a63077affe6 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.Hosting.Channels/IChannelRunHook.cs @@ -0,0 +1,20 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Threading; +using System.Threading.Tasks; + +namespace Microsoft.Agents.AI.Hosting.Channels; + +/// +/// Capability interface: rewrite the after the channel produces its +/// default envelope and before the host calls the runner. The canonical adapter point for +/// workflow targets and prompt rewriting. +/// +public interface IChannelRunHook +{ + /// Return the (possibly rewritten) request. + ValueTask OnRequestAsync( + ChannelRequest request, + ChannelRunHookContext context, + CancellationToken cancellationToken); +} \ No newline at end of file diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting.Channels/IChannelStreamTransformHook.cs b/dotnet/src/Microsoft.Agents.AI.Hosting.Channels/IChannelStreamTransformHook.cs new file mode 100644 index 00000000000..e014f33af15 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.Hosting.Channels/IChannelStreamTransformHook.cs @@ -0,0 +1,18 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Collections.Generic; +using System.Threading; + +namespace Microsoft.Agents.AI.Hosting.Channels; + +/// +/// Capability interface: rewrite the upstream agent-update stream before it is projected onto +/// the channel wire. Hooks may filter, debounce, or annotate updates. +/// +public interface IChannelStreamTransformHook +{ + /// Wrap the upstream stream with the transform. + IAsyncEnumerable TransformAsync( + IAsyncEnumerable upstream, + CancellationToken cancellationToken); +} \ No newline at end of file diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting.Channels/IConfidentialityTagged.cs b/dotnet/src/Microsoft.Agents.AI.Hosting.Channels/IConfidentialityTagged.cs new file mode 100644 index 00000000000..428994a2a8f --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.Hosting.Channels/IConfidentialityTagged.cs @@ -0,0 +1,14 @@ +// Copyright (c) Microsoft. All rights reserved. + +namespace Microsoft.Agents.AI.Hosting.Channels; + +/// +/// Capability interface: a channel can tag itself with a confidentiality tier. Read by +/// to decide whether two channels may share state +/// or deliver to one another. +/// +public interface IConfidentialityTagged +{ + /// Opaque confidentiality tier label; means single-tier. + string? ConfidentialityTier { get; } +} \ No newline at end of file diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting.Channels/IDurableTaskRunner.cs b/dotnet/src/Microsoft.Agents.AI.Hosting.Channels/IDurableTaskRunner.cs new file mode 100644 index 00000000000..745b1978895 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.Hosting.Channels/IDurableTaskRunner.cs @@ -0,0 +1,34 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Threading; +using System.Threading.Tasks; + +namespace Microsoft.Agents.AI.Hosting.Channels; + +/// +/// Pluggable seam for scheduling, executing, and persisting background tasks. Backs the host's +/// non-originating push fan-out via the reserved "hosting.push" handler and surfaces +/// background-run tracking via . +/// +public interface IDurableTaskRunner +{ + /// How this runner serializes payloads. Read by the startup codec/runner pairing validator. + DurableTaskPayloadMode PayloadMode { get; } + + /// Register a handler under a name. The host registers "hosting.push" at startup. + void Register(string name, Func handler); + + /// Schedule a task under a previously-registered handler. + ValueTask ScheduleAsync( + string name, + object payload, + RetryPolicy? retryPolicy, + CancellationToken cancellationToken); + + /// Read the current status of a scheduled task. + ValueTask GetAsync(TaskHandle handle, CancellationToken cancellationToken); + + /// Cancel a scheduled task. + ValueTask CancelAsync(TaskHandle handle, CancellationToken cancellationToken); +} \ No newline at end of file diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting.Channels/IHostStateStore.cs b/dotnet/src/Microsoft.Agents.AI.Hosting.Channels/IHostStateStore.cs new file mode 100644 index 00000000000..e66fd88126b --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.Hosting.Channels/IHostStateStore.cs @@ -0,0 +1,90 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; + +namespace Microsoft.Agents.AI.Hosting.Channels; + +/// +/// Persistence seam for host-execution metadata that outlives a single request: continuation +/// tokens, identity registry, identity-link grants, and last-seen ledger. Separate from +/// AgentSessionStore (per-conversation history) and WorkflowBuilder.CheckpointStorage +/// (workflow checkpoints). +/// +public interface IHostStateStore +{ + // ---- Identity registry ---- + + /// Resolve the isolation key for a channel-native identity. if unknown. + ValueTask GetIsolationKeyAsync(ChannelIdentity identity, CancellationToken cancellationToken); + + /// + /// Atomically map (or merge) onto . + /// If the identity already maps to a different isolation key, both keys' records are merged. + /// Optional are persisted alongside for future auto-link replay. + /// + ValueTask SaveLinkAsync( + ChannelIdentity identity, + string isolationKey, + IReadOnlyDictionary? verifiedClaims, + CancellationToken cancellationToken); + + /// Enumerate every identity mapped to . + ValueTask> GetIdentitiesAsync( + string isolationKey, + CancellationToken cancellationToken); + + /// Look up an isolation key by a verified claim value. + ValueTask LookupByVerifiedClaimAsync( + string claim, + string value, + CancellationToken cancellationToken); + + // ---- Link grants ---- + + /// Persist a pending link grant (one-time code, OAuth state). + ValueTask SaveLinkGrantAsync(LinkGrant grant, CancellationToken cancellationToken); + + /// Read an unexpired link grant. + ValueTask GetLinkGrantAsync(string code, CancellationToken cancellationToken); + + /// Atomically read-and-delete a link grant. + ValueTask ConsumeLinkGrantAsync(string code, CancellationToken cancellationToken); + + // ---- Last seen ---- + + /// Record that was last seen at . + ValueTask RecordLastSeenAsync( + string isolationKey, + ChannelIdentity identity, + string? conversationId, + DateTimeOffset at, + CancellationToken cancellationToken); + + /// Read the latest last-seen entry for an isolation key. + ValueTask GetLastSeenAsync(string isolationKey, CancellationToken cancellationToken); + + // ---- Continuation tokens ---- + + /// Persist (or replace) a continuation token. + ValueTask SaveContinuationAsync(ContinuationToken token, CancellationToken cancellationToken); + + /// Read a continuation token by its opaque string identifier. + ValueTask GetContinuationAsync(string token, CancellationToken cancellationToken); + + /// Delete a continuation token. + ValueTask DeleteContinuationAsync(string token, CancellationToken cancellationToken); + + // ---- Session alias rotation ---- + + /// + /// Rotate the active session-id alias for an isolation key. Backs + /// for host-tracked channels' /new-style commands. + /// + ValueTask RotateSessionAliasAsync(string isolationKey, CancellationToken cancellationToken); + + /// Read the active session-id alias for an isolation key. + ValueTask GetActiveSessionAliasAsync(string isolationKey, CancellationToken cancellationToken); +} \ No newline at end of file diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting.Channels/IHostedTargetRunner.cs b/dotnet/src/Microsoft.Agents.AI.Hosting.Channels/IHostedTargetRunner.cs new file mode 100644 index 00000000000..b1e356d3499 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.Hosting.Channels/IHostedTargetRunner.cs @@ -0,0 +1,20 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; + +namespace Microsoft.Agents.AI.Hosting.Channels; + +/// +/// Pluggable adapter that drives the actual target (AI agent, workflow, Foundry hosted agent). +/// Channels never branch on target type; they go through this seam. +/// +public interface IHostedTargetRunner +{ + /// Run the target. + ValueTask RunAsync(ChannelRequest request, CancellationToken cancellationToken); + + /// Stream the target's response. + IAsyncEnumerable StreamAsync(ChannelRequest request, CancellationToken cancellationToken); +} \ No newline at end of file diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting.Channels/IIdentityAllowlist.cs b/dotnet/src/Microsoft.Agents.AI.Hosting.Channels/IIdentityAllowlist.cs new file mode 100644 index 00000000000..d65b2866431 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.Hosting.Channels/IIdentityAllowlist.cs @@ -0,0 +1,24 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Threading; +using System.Threading.Tasks; + +namespace Microsoft.Agents.AI.Hosting.Channels; + +/// +/// Decision seam for identity admission. Implementations return +/// / / at each phase +/// of the authorization pipeline. +/// +public interface IIdentityAllowlist +{ + /// + /// When , the host's startup validator rejects configurations where + /// neither RequireLink=true nor a claim-emitting channel can deliver the claims this + /// allowlist needs. Prevents the silent-deny-everyone footgun. + /// + bool RequiresLinkedClaims => false; + + /// Evaluate the supplied context. + ValueTask EvaluateAsync(AuthorizationContext context, CancellationToken cancellationToken); +} \ No newline at end of file diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting.Channels/IIdentityLinker.cs b/dotnet/src/Microsoft.Agents.AI.Hosting.Channels/IIdentityLinker.cs new file mode 100644 index 00000000000..07c81d74e47 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.Hosting.Channels/IIdentityLinker.cs @@ -0,0 +1,42 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; + +namespace Microsoft.Agents.AI.Hosting.Channels; + +/// +/// Ceremony seam for binding a channel-native identity to verified IdP claims and a host +/// isolation key. Implementations may publish callback routes via . +/// +public interface IIdentityLinker +{ + /// Stable linker name (used in log details and startup validation messages). + string Name { get; } + + /// Linker-supplied routes (e.g. OAuth callback). Same shape as . + ChannelContribution Contribute(IChannelContext context); + + /// Begin a link ceremony for the given identity. + ValueTask BeginAsync( + ChannelIdentity identity, + string? requestedIsolationKey, + CancellationToken cancellationToken); + + /// Complete a previously-issued challenge. + ValueTask CompleteAsync( + string challengeId, + IReadOnlyDictionary proof, + CancellationToken cancellationToken); + + /// + /// Returns the isolation key for an already-linked identity, or if no + /// link exists. When entries match existing link records + /// the linker auto-merges onto the existing isolation key. + /// + ValueTask IsLinkedAsync( + ChannelIdentity identity, + IReadOnlyDictionary? verifiedClaims, + CancellationToken cancellationToken); +} \ No newline at end of file diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting.Channels/ILinkPolicy.cs b/dotnet/src/Microsoft.Agents.AI.Hosting.Channels/ILinkPolicy.cs new file mode 100644 index 00000000000..c31c694396c --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.Hosting.Channels/ILinkPolicy.cs @@ -0,0 +1,17 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Threading; +using System.Threading.Tasks; + +namespace Microsoft.Agents.AI.Hosting.Channels; + +/// +/// Decides which channels may share an isolation key +/// () and which channels may be a +/// for one another (). +/// +public interface ILinkPolicy +{ + /// Returns if the operation is permitted. + ValueTask EvaluateAsync(LinkPolicyContext context, CancellationToken cancellationToken); +} \ No newline at end of file diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting.Channels/InMemoryHostStateStore.cs b/dotnet/src/Microsoft.Agents.AI.Hosting.Channels/InMemoryHostStateStore.cs new file mode 100644 index 00000000000..1aa9c9ad48c --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.Hosting.Channels/InMemoryHostStateStore.cs @@ -0,0 +1,211 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Collections.Concurrent; +using System.Collections.Generic; +using System.Collections.Immutable; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Shared.Diagnostics; + +namespace Microsoft.Agents.AI.Hosting.Channels; + +/// +/// In-memory . Volatile; intended for tests, samples, and single-process +/// development scenarios. Thread-safe. +/// +public sealed class InMemoryHostStateStore : IHostStateStore +{ + private readonly object _gate = new(); + private readonly Dictionary<(string Channel, string NativeId), string> _identityToKey = new(); + private readonly Dictionary> _keyToIdentities = new(StringComparer.Ordinal); + private readonly Dictionary<(string Claim, string Value), string> _claimToKey = new(); + private readonly ConcurrentDictionary _linkGrants = new(StringComparer.Ordinal); + private readonly ConcurrentDictionary _lastSeen = new(StringComparer.Ordinal); + private readonly ConcurrentDictionary _continuations = new(StringComparer.Ordinal); + private readonly ConcurrentDictionary _sessionAliases = new(StringComparer.Ordinal); + + /// + public ValueTask GetIsolationKeyAsync(ChannelIdentity identity, CancellationToken cancellationToken) + { + Throw.IfNull(identity); + lock (this._gate) + { + return new(this._identityToKey.TryGetValue((identity.Channel, identity.NativeId), out var key) ? key : null); + } + } + + /// + public ValueTask SaveLinkAsync( + ChannelIdentity identity, + string isolationKey, + IReadOnlyDictionary? verifiedClaims, + CancellationToken cancellationToken) + { + Throw.IfNull(identity); + Throw.IfNullOrEmpty(isolationKey); + + lock (this._gate) + { + var key = (identity.Channel, identity.NativeId); + if (this._identityToKey.TryGetValue(key, out var existing) && existing != isolationKey) + { + // Merge: move all identities under `existing` into `isolationKey`. + if (this._keyToIdentities.TryGetValue(existing, out var existingList)) + { + foreach (var reg in existingList) + { + this._identityToKey[(reg.Identity.Channel, reg.Identity.NativeId)] = isolationKey; + this.GetOrCreateList(isolationKey).Add(reg); + } + this._keyToIdentities.Remove(existing); + } + } + + this._identityToKey[key] = isolationKey; + var claims = verifiedClaims ?? ImmutableDictionary.Empty; + var registration = new ChannelIdentityRegistration(identity, DateTimeOffset.UtcNow, claims); + + var list = this.GetOrCreateList(isolationKey); + list.RemoveAll(r => r.Identity.Channel == identity.Channel && r.Identity.NativeId == identity.NativeId); + list.Add(registration); + + foreach (var (claim, value) in claims) + { + this._claimToKey[(claim, value)] = isolationKey; + } + } + + return default; + } + + /// + public ValueTask> GetIdentitiesAsync(string isolationKey, CancellationToken cancellationToken) + { + Throw.IfNullOrEmpty(isolationKey); + lock (this._gate) + { + if (this._keyToIdentities.TryGetValue(isolationKey, out var list)) + { + return new((IReadOnlyList)list.ToArray()); + } + return new(Array.Empty()); + } + } + + /// + public ValueTask LookupByVerifiedClaimAsync(string claim, string value, CancellationToken cancellationToken) + { + Throw.IfNullOrEmpty(claim); + Throw.IfNull(value); + lock (this._gate) + { + return new(this._claimToKey.TryGetValue((claim, value), out var key) ? key : null); + } + } + + /// + public ValueTask SaveLinkGrantAsync(LinkGrant grant, CancellationToken cancellationToken) + { + Throw.IfNull(grant); + this._linkGrants[grant.Code] = grant; + return default; + } + + /// + public ValueTask GetLinkGrantAsync(string code, CancellationToken cancellationToken) + { + Throw.IfNullOrEmpty(code); + if (this._linkGrants.TryGetValue(code, out var grant) && grant.ExpiresAt > DateTimeOffset.UtcNow) + { + return new(grant); + } + return new((LinkGrant?)null); + } + + /// + public ValueTask ConsumeLinkGrantAsync(string code, CancellationToken cancellationToken) + { + Throw.IfNullOrEmpty(code); + if (this._linkGrants.TryRemove(code, out var grant) && grant.ExpiresAt > DateTimeOffset.UtcNow) + { + return new(grant); + } + return new((LinkGrant?)null); + } + + /// + public ValueTask RecordLastSeenAsync( + string isolationKey, + ChannelIdentity identity, + string? conversationId, + DateTimeOffset at, + CancellationToken cancellationToken) + { + Throw.IfNullOrEmpty(isolationKey); + Throw.IfNull(identity); + this._lastSeen[isolationKey] = new LastSeenRecord(identity, conversationId, at); + return default; + } + + /// + public ValueTask GetLastSeenAsync(string isolationKey, CancellationToken cancellationToken) + { + Throw.IfNullOrEmpty(isolationKey); + return new(this._lastSeen.TryGetValue(isolationKey, out var rec) ? rec : null); + } + + /// + public ValueTask SaveContinuationAsync(ContinuationToken token, CancellationToken cancellationToken) + { + Throw.IfNull(token); + this._continuations[token.Token] = token; + return default; + } + + /// + public ValueTask GetContinuationAsync(string token, CancellationToken cancellationToken) + { + Throw.IfNullOrEmpty(token); + return new(this._continuations.TryGetValue(token, out var t) ? t : null); + } + + /// + public ValueTask DeleteContinuationAsync(string token, CancellationToken cancellationToken) + { + Throw.IfNullOrEmpty(token); + this._continuations.TryRemove(token, out _); + return default; + } + + /// + public ValueTask RotateSessionAliasAsync(string isolationKey, CancellationToken cancellationToken) + { + Throw.IfNullOrEmpty(isolationKey); + this._sessionAliases[isolationKey] = Guid.NewGuid().ToString("N"); + return default; + } + + /// + public ValueTask GetActiveSessionAliasAsync(string isolationKey, CancellationToken cancellationToken) + { + Throw.IfNullOrEmpty(isolationKey); + if (!this._sessionAliases.TryGetValue(isolationKey, out var alias)) + { + alias = isolationKey; + this._sessionAliases.TryAdd(isolationKey, alias); + } + return new(alias); + } + + private List GetOrCreateList(string isolationKey) + { + if (!this._keyToIdentities.TryGetValue(isolationKey, out var list)) + { + list = []; + this._keyToIdentities[isolationKey] = list; + } + return list; + } +} \ No newline at end of file diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting.Channels/InProcessDurableTaskRunner.cs b/dotnet/src/Microsoft.Agents.AI.Hosting.Channels/InProcessDurableTaskRunner.cs new file mode 100644 index 00000000000..0ac8fb5f8e9 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.Hosting.Channels/InProcessDurableTaskRunner.cs @@ -0,0 +1,193 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Collections.Concurrent; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; +using Microsoft.Shared.Diagnostics; +using ThreadingChannel = System.Threading.Channels.Channel; +using ThreadingChannelT = System.Threading.Channels; + +namespace Microsoft.Agents.AI.Hosting.Channels; + +/// +/// Default implementation: an backed +/// by a bounded worker loop. In-memory only (no +/// replay across restarts); applications needing durability should swap in an external runner from +/// a fast-follow package. +/// +/// +/// Two-phase shutdown: on , in-flight tasks are given +/// to finish. Remaining tasks are cancelled and their cancellation exceptions are swallowed. +/// +public sealed class InProcessDurableTaskRunner : IDurableTaskRunner, IHostedService, IAsyncDisposable +{ + private readonly ThreadingChannelT.Channel _queue; + private readonly ConcurrentDictionary> _handlers = new(StringComparer.Ordinal); + private readonly ConcurrentDictionary _statuses = new(StringComparer.Ordinal); + private readonly ConcurrentDictionary> _state = new(StringComparer.Ordinal); + private readonly ILogger _logger; + private readonly CancellationTokenSource _shutdownCts = new(); + private Task? _workerTask; + + /// Time the runner waits for in-flight tasks at shutdown before cancelling them. + public double ShutdownGraceSeconds { get; init; } = 5.0; + + /// + public DurableTaskPayloadMode PayloadMode => DurableTaskPayloadMode.Object; + + /// Initializes a new instance. + public InProcessDurableTaskRunner(ILogger logger) + { + this._logger = Throw.IfNull(logger); + this._queue = ThreadingChannel.CreateBounded(new ThreadingChannelT.BoundedChannelOptions(1024) + { + SingleReader = true, + FullMode = ThreadingChannelT.BoundedChannelFullMode.Wait, + }); + } + + /// + public void Register(string name, Func handler) + { + Throw.IfNullOrEmpty(name); + Throw.IfNull(handler); + this._handlers[name] = handler; + } + + /// + public async ValueTask ScheduleAsync(string name, object payload, RetryPolicy? retryPolicy, CancellationToken cancellationToken) + { + Throw.IfNullOrEmpty(name); + Throw.IfNull(payload); + + if (!this._handlers.ContainsKey(name)) + { + throw new InvalidOperationException($"No handler registered under '{name}'."); + } + + var handle = new TaskHandle(Guid.NewGuid().ToString("N"), name); + this._statuses[handle.TaskId] = DurableTaskStatus.Scheduled; + this._state[handle.TaskId] = new Dictionary(StringComparer.Ordinal); + + await this._queue.Writer.WriteAsync(new QueuedTask(handle, payload, retryPolicy ?? RetryPolicy.Default), cancellationToken).ConfigureAwait(false); + return handle; + } + + /// + public ValueTask GetAsync(TaskHandle handle, CancellationToken cancellationToken) + { + Throw.IfNull(handle); + return new(this._statuses.TryGetValue(handle.TaskId, out var status) ? status : null); + } + + /// + public ValueTask CancelAsync(TaskHandle handle, CancellationToken cancellationToken) + { + Throw.IfNull(handle); + this._statuses.TryUpdate(handle.TaskId, DurableTaskStatus.Cancelled, DurableTaskStatus.Scheduled); + return default; + } + + /// + public Task StartAsync(CancellationToken cancellationToken) + { + this._workerTask = Task.Run(() => this.WorkerLoopAsync(this._shutdownCts.Token), CancellationToken.None); + return Task.CompletedTask; + } + + /// + public async Task StopAsync(CancellationToken cancellationToken) + { + this._queue.Writer.TryComplete(); + var grace = TimeSpan.FromSeconds(this.ShutdownGraceSeconds); + try + { + if (this._workerTask is not null) + { + await this._workerTask.WaitAsync(grace, cancellationToken).ConfigureAwait(false); + } + } + catch (TimeoutException) + { + this._shutdownCts.Cancel(); + if (this._workerTask is not null) + { + try { await this._workerTask.ConfigureAwait(false); } + catch (OperationCanceledException) { /* expected at shutdown */ } + } + } + } + + /// + public async ValueTask DisposeAsync() + { + this._shutdownCts.Cancel(); + if (this._workerTask is not null) + { + try { await this._workerTask.ConfigureAwait(false); } + catch (OperationCanceledException) { /* expected */ } + } + this._shutdownCts.Dispose(); + } + + private async Task WorkerLoopAsync(CancellationToken cancellationToken) + { + await foreach (var queued in this._queue.Reader.ReadAllAsync(cancellationToken).ConfigureAwait(false)) + { + if (this._statuses.TryGetValue(queued.Handle.TaskId, out var status) && status == DurableTaskStatus.Cancelled) + { + continue; + } + + await this.ExecuteWithRetryAsync(queued, cancellationToken).ConfigureAwait(false); + } + } + + private async Task ExecuteWithRetryAsync(QueuedTask queued, CancellationToken cancellationToken) + { + if (!this._handlers.TryGetValue(queued.Handle.Name, out var handler)) + { + this._statuses[queued.Handle.TaskId] = DurableTaskStatus.Failed; + this._logger.LogError("No handler registered for {Handler} (task {TaskId}).", queued.Handle.Name, queued.Handle.TaskId); + return; + } + + var policy = queued.RetryPolicy; + var delay = policy.InitialBackoff; + var state = this._state[queued.Handle.TaskId]; + + for (var attempt = 1; attempt <= policy.MaxAttempts; attempt++) + { + this._statuses[queued.Handle.TaskId] = DurableTaskStatus.Running; + try + { + await handler(new TaskInvocationContext(queued.Handle.Name, queued.Payload, attempt, state)).ConfigureAwait(false); + this._statuses[queued.Handle.TaskId] = DurableTaskStatus.Succeeded; + return; + } + catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) + { + this._statuses[queued.Handle.TaskId] = DurableTaskStatus.Cancelled; + return; + } + catch (Exception ex) + { + this._logger.LogWarning(ex, "Durable task {TaskId} attempt {Attempt}/{Max} failed.", queued.Handle.TaskId, attempt, policy.MaxAttempts); + if (attempt == policy.MaxAttempts) + { + this._statuses[queued.Handle.TaskId] = DurableTaskStatus.Failed; + return; + } + try { await Task.Delay(delay, cancellationToken).ConfigureAwait(false); } + catch (OperationCanceledException) { this._statuses[queued.Handle.TaskId] = DurableTaskStatus.Cancelled; return; } + delay = TimeSpan.FromMilliseconds(Math.Min(delay.TotalMilliseconds * policy.BackoffMultiplier, policy.MaxBackoff.TotalMilliseconds)); + } + } + } + + private sealed record QueuedTask(TaskHandle Handle, object Payload, RetryPolicy RetryPolicy); +} \ No newline at end of file diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting.Channels/Internal/AgentFrameworkHostBuilder.cs b/dotnet/src/Microsoft.Agents.AI.Hosting.Channels/Internal/AgentFrameworkHostBuilder.cs new file mode 100644 index 00000000000..a216fd30f70 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.Hosting.Channels/Internal/AgentFrameworkHostBuilder.cs @@ -0,0 +1,88 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.DependencyInjection.Extensions; +using Microsoft.Shared.Diagnostics; + +namespace Microsoft.Agents.AI.Hosting.Channels; + +internal sealed class AgentFrameworkHostBuilder : IAgentFrameworkHostBuilder +{ + private readonly List _channels = []; + + public AgentFrameworkHostBuilder(IServiceCollection services, AgentFrameworkHostOptions options) + { + this.Services = Throw.IfNull(services); + this.Options = Throw.IfNull(options); + + // Register the channels collection so AgentFrameworkHost can resolve it. + services.AddSingleton>(_ => this._channels); + } + + public IServiceCollection Services { get; } + + public AgentFrameworkHostOptions Options { get; } + + public IAgentFrameworkHostBuilder AddChannel(Channel channel) + { + Throw.IfNull(channel); + channel.ConfigureServices(this.Services); + this._channels.Add(channel); + return this; + } + + public IAgentFrameworkHostBuilder AddChannel(Func factory) where TChannel : Channel + { + Throw.IfNull(factory); + + // Build a probe instance to run ConfigureServices. + using var probeProvider = this.Services.BuildServiceProvider(); + var probe = factory(probeProvider); + probe.ConfigureServices(this.Services); + + // Defer real channel instantiation to a singleton resolved from DI. + this.Services.AddSingleton(factory); + this.Services.AddSingleton(sp => sp.GetRequiredService()); + + this._channels.Add(probe); + return this; + } + + public IAgentFrameworkHostBuilder UseIdentityLinker<[DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicConstructors)] TLinker>() + where TLinker : class, IIdentityLinker + { + this.Services.Replace(ServiceDescriptor.Singleton()); + return this; + } + + public IAgentFrameworkHostBuilder UseDefaultAllowlist(IIdentityAllowlist allowlist) + { + Throw.IfNull(allowlist); + this.Services.Replace(ServiceDescriptor.Singleton(allowlist)); + return this; + } + + public IAgentFrameworkHostBuilder UseLinkPolicy(ILinkPolicy policy) + { + Throw.IfNull(policy); + this.Services.Replace(ServiceDescriptor.Singleton(policy)); + return this; + } + + public IAgentFrameworkHostBuilder UseDurableTaskRunner<[DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicConstructors)] TRunner>() + where TRunner : class, IDurableTaskRunner + { + this.Services.Replace(ServiceDescriptor.Singleton()); + return this; + } + + public IAgentFrameworkHostBuilder UseHostStateStore<[DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicConstructors)] TStore>() + where TStore : class, IHostStateStore + { + this.Services.Replace(ServiceDescriptor.Singleton()); + return this; + } +} \ No newline at end of file diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting.Channels/Internal/ChannelContext.cs b/dotnet/src/Microsoft.Agents.AI.Hosting.Channels/Internal/ChannelContext.cs new file mode 100644 index 00000000000..20e882c4234 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.Hosting.Channels/Internal/ChannelContext.cs @@ -0,0 +1,44 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Shared.Diagnostics; + +namespace Microsoft.Agents.AI.Hosting.Channels; + +internal sealed class ChannelContext : IChannelContext +{ + public ChannelContext(IServiceProvider services, AgentFrameworkHost host) + { + this.Services = Throw.IfNull(services); + this.Host = Throw.IfNull(host); + } + + public IServiceProvider Services { get; } + public AgentFrameworkHost Host { get; } + public IHostStateStore StateStore => this.Host.StateStore; + public IDurableTaskRunner DurableRunner => this.Host.DurableRunner; + + public ValueTask AuthorizeAsync( + ChannelIdentity identity, + AuthorizationRequest options, + CancellationToken cancellationToken = default) + => this.Host.AuthorizeAsync(identity, options, cancellationToken); + + public ValueTask RunAsync(ChannelRequest request, CancellationToken cancellationToken = default) + => this.Host.RunAsync(request, cancellationToken); + + public IAsyncEnumerable StreamAsync(ChannelRequest request, CancellationToken cancellationToken = default) + => this.Host.StreamAsync(request, cancellationToken); + + public ValueTask> ScheduleResponseAsync( + HostedRunResult result, + ChannelRequest originating, + CancellationToken cancellationToken = default) + { + // Draft: end-to-end fan-out scheduling lands with the first channel that implements IChannelPush. + return new(Array.Empty()); + } +} \ No newline at end of file diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting.Channels/IsolationKeys.cs b/dotnet/src/Microsoft.Agents.AI.Hosting.Channels/IsolationKeys.cs new file mode 100644 index 00000000000..edb7572c676 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.Hosting.Channels/IsolationKeys.cs @@ -0,0 +1,45 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Threading; + +namespace Microsoft.Agents.AI.Hosting.Channels; + +/// +/// Per-request partition hints carried via . Distinct from the app-level +/// isolation key produced by ; this is the Foundry runtime's +/// per-request partition hint lifted off x-agent-user-isolation-key / +/// x-agent-chat-isolation-key headers. +/// +public sealed record IsolationKeys(string? UserKey, string? ChatKey) +{ + /// The async-local slot. + public static AsyncLocal CurrentSlot { get; } = new(); + + /// The current per-request value, if any. + public static IsolationKeys? Current + { + get => CurrentSlot.Value; + set => CurrentSlot.Value = value; + } + + /// True when both keys are . + public bool IsEmpty => this.UserKey is null && this.ChatKey is null; + + /// Header name for the user key. + public const string UserHeader = "x-agent-user-isolation-key"; + + /// Header name for the chat key. + public const string ChatHeader = "x-agent-chat-isolation-key"; +} + +/// DI wrapper around for testability. +public interface IIsolationKeysAccessor +{ + /// Returns the current per-request keys. + IsolationKeys? Current { get; } +} + +internal sealed class IsolationKeysAccessor : IIsolationKeysAccessor +{ + public IsolationKeys? Current => IsolationKeys.Current; +} \ No newline at end of file diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting.Channels/LastSeenRecord.cs b/dotnet/src/Microsoft.Agents.AI.Hosting.Channels/LastSeenRecord.cs new file mode 100644 index 00000000000..d7a710d6881 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.Hosting.Channels/LastSeenRecord.cs @@ -0,0 +1,16 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; + +namespace Microsoft.Agents.AI.Hosting.Channels; + +/// +/// Most recent channel activity observed for an isolation key. Backs . +/// +/// The full channel-native identity last seen (not just the channel name). +/// The conversation last seen, when applicable. +/// Timestamp of the observation. +public sealed record LastSeenRecord( + ChannelIdentity Identity, + string? ConversationId, + DateTimeOffset At); \ No newline at end of file diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting.Channels/LinkChallenge.cs b/dotnet/src/Microsoft.Agents.AI.Hosting.Channels/LinkChallenge.cs new file mode 100644 index 00000000000..580416a07cf --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.Hosting.Channels/LinkChallenge.cs @@ -0,0 +1,21 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; + +namespace Microsoft.Agents.AI.Hosting.Channels; + +/// +/// Renderable artifact produced by . Channels project this +/// onto their wire (one-time code message, OAuth redirect URL, MFA prompt, ...). +/// +/// Stable id passed back into . +/// Free-form kind discriminator ("url", "code", "mfa", ...). +/// Optional redirect URL for OAuth-style flows. +/// Optional human-presentable code for code-entry flows. +/// Optional natural-language instruction. +public sealed record LinkChallenge( + string ChallengeId, + string Kind, + Uri? Url = null, + string? Code = null, + string? UserPrompt = null); \ No newline at end of file diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting.Channels/LinkGrant.cs b/dotnet/src/Microsoft.Agents.AI.Hosting.Channels/LinkGrant.cs new file mode 100644 index 00000000000..64568000525 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.Hosting.Channels/LinkGrant.cs @@ -0,0 +1,22 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Collections.Generic; + +namespace Microsoft.Agents.AI.Hosting.Channels; + +/// +/// Pending link grant: one-time code or OAuth state issued by an +/// and persisted on the until consumed by the callback / completion call. +/// +/// The opaque code or state value the verifier presents. +/// The that issued this grant. +/// Optional explicit isolation key the user requested at begin time. +/// When the grant becomes invalid. +/// Linker-defined opaque payload (PKCE verifier, redirect uri, ...). +public sealed record LinkGrant( + string Code, + string IssuedByLinker, + string? RequestedIsolationKey, + DateTimeOffset ExpiresAt, + IReadOnlyDictionary Payload); \ No newline at end of file diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting.Channels/LinkPolicies/AllowAllLinkPolicy.cs b/dotnet/src/Microsoft.Agents.AI.Hosting.Channels/LinkPolicies/AllowAllLinkPolicy.cs new file mode 100644 index 00000000000..682ae34a95a --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.Hosting.Channels/LinkPolicies/AllowAllLinkPolicy.cs @@ -0,0 +1,16 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Threading; +using System.Threading.Tasks; + +namespace Microsoft.Agents.AI.Hosting.Channels; + +/// Permits every link and delivery. Default policy. +public sealed class AllowAllLinkPolicy : ILinkPolicy +{ + /// Shared singleton. + public static AllowAllLinkPolicy Instance { get; } = new(); + + /// + public ValueTask EvaluateAsync(LinkPolicyContext context, CancellationToken cancellationToken) => new(true); +} \ No newline at end of file diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting.Channels/LinkPolicies/DenyAllLinkPolicy.cs b/dotnet/src/Microsoft.Agents.AI.Hosting.Channels/LinkPolicies/DenyAllLinkPolicy.cs new file mode 100644 index 00000000000..ea5d861757e --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.Hosting.Channels/LinkPolicies/DenyAllLinkPolicy.cs @@ -0,0 +1,16 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Threading; +using System.Threading.Tasks; + +namespace Microsoft.Agents.AI.Hosting.Channels; + +/// Refuses every link and delivery. Channels share target only, never sessions. +public sealed class DenyAllLinkPolicy : ILinkPolicy +{ + /// Shared singleton. + public static DenyAllLinkPolicy Instance { get; } = new(); + + /// + public ValueTask EvaluateAsync(LinkPolicyContext context, CancellationToken cancellationToken) => new(false); +} \ No newline at end of file diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting.Channels/LinkPolicies/ExplicitAllowListLinkPolicy.cs b/dotnet/src/Microsoft.Agents.AI.Hosting.Channels/LinkPolicies/ExplicitAllowListLinkPolicy.cs new file mode 100644 index 00000000000..a1170b04f6b --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.Hosting.Channels/LinkPolicies/ExplicitAllowListLinkPolicy.cs @@ -0,0 +1,33 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Shared.Diagnostics; + +namespace Microsoft.Agents.AI.Hosting.Channels; + +/// +/// Permits links / deliveries only when the (source, destination) channel pair appears in the +/// configured allow list. +/// +public sealed class ExplicitAllowListLinkPolicy : ILinkPolicy +{ + private readonly HashSet<(string Source, string Destination)> _allowed; + + /// Initializes a new instance. + public ExplicitAllowListLinkPolicy(IEnumerable<(string Source, string Destination)> allowedPairs) + { + Throw.IfNull(allowedPairs); + this._allowed = new HashSet<(string, string)>(allowedPairs); + } + + /// + public ValueTask EvaluateAsync(LinkPolicyContext context, CancellationToken cancellationToken) + { + Throw.IfNull(context); + return new(this._allowed.Contains((context.Source.Name, context.Destination.Name))); + } +} \ No newline at end of file diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting.Channels/LinkPolicies/SameConfidentialityTierLinkPolicy.cs b/dotnet/src/Microsoft.Agents.AI.Hosting.Channels/LinkPolicies/SameConfidentialityTierLinkPolicy.cs new file mode 100644 index 00000000000..d708573c0b0 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.Hosting.Channels/LinkPolicies/SameConfidentialityTierLinkPolicy.cs @@ -0,0 +1,28 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Shared.Diagnostics; + +namespace Microsoft.Agents.AI.Hosting.Channels; + +/// +/// Permits links and deliveries when both channels declare the same +/// ; refuses otherwise. Channels without +/// the tag are treated as single-tier (matching any other untagged channel). +/// +public sealed class SameConfidentialityTierLinkPolicy : ILinkPolicy +{ + /// Shared singleton. + public static SameConfidentialityTierLinkPolicy Instance { get; } = new(); + + /// + public ValueTask EvaluateAsync(LinkPolicyContext context, CancellationToken cancellationToken) + { + Throw.IfNull(context); + var sourceTier = (context.Source as IConfidentialityTagged)?.ConfidentialityTier; + var destTier = (context.Destination as IConfidentialityTagged)?.ConfidentialityTier; + return new(string.Equals(sourceTier, destTier, StringComparison.Ordinal)); + } +} \ No newline at end of file diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting.Channels/LinkPolicyContext.cs b/dotnet/src/Microsoft.Agents.AI.Hosting.Channels/LinkPolicyContext.cs new file mode 100644 index 00000000000..ad6ebd8cebc --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.Hosting.Channels/LinkPolicyContext.cs @@ -0,0 +1,18 @@ +// Copyright (c) Microsoft. All rights reserved. + +namespace Microsoft.Agents.AI.Hosting.Channels; + +/// +/// Context passed to . +/// +public sealed record LinkPolicyContext +{ + /// The originating channel. + public required Channel Source { get; init; } + + /// The candidate destination channel. + public required Channel Destination { get; init; } + + /// Whether the request is to share an isolation key or to deliver a response. + public required LinkPolicyOperation Operation { get; init; } +} \ No newline at end of file diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting.Channels/LinkPolicyOperation.cs b/dotnet/src/Microsoft.Agents.AI.Hosting.Channels/LinkPolicyOperation.cs new file mode 100644 index 00000000000..130de4e21e6 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.Hosting.Channels/LinkPolicyOperation.cs @@ -0,0 +1,15 @@ +// Copyright (c) Microsoft. All rights reserved. + +namespace Microsoft.Agents.AI.Hosting.Channels; + +/// +/// Operation being authorized by an . +/// +public enum LinkPolicyOperation +{ + /// Whether two channels may share an isolation key (asked by ). + Link, + + /// Whether one channel may deliver a response targeting an identity belonging to another channel. + Deliver, +} \ No newline at end of file diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting.Channels/Microsoft.Agents.AI.Hosting.Channels.csproj b/dotnet/src/Microsoft.Agents.AI.Hosting.Channels/Microsoft.Agents.AI.Hosting.Channels.csproj new file mode 100644 index 00000000000..4c5169ba10b --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.Hosting.Channels/Microsoft.Agents.AI.Hosting.Channels.csproj @@ -0,0 +1,40 @@ + + + + $(TargetFrameworksCore) + Microsoft.Agents.AI.Hosting.Channels + alpha + + + + + + true + + + + + + + + + + + + + + + + + + + + + + + + + Microsoft Agent Framework Hosting Channels + Provides the multi-channel hosting surface for the Microsoft Agent Framework: composable Channel contract, identity / link-policy pipeline, host state store, and durable task runner. + + diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting.Channels/PrincipalIdentity.cs b/dotnet/src/Microsoft.Agents.AI.Hosting.Channels/PrincipalIdentity.cs new file mode 100644 index 00000000000..05e9c5920e5 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.Hosting.Channels/PrincipalIdentity.cs @@ -0,0 +1,16 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Collections.Generic; + +namespace Microsoft.Agents.AI.Hosting.Channels; + +/// +/// Result of a successful call. +/// +/// The resolved isolation key. +/// The channel-native identity that completed the link. +/// Claims verified by the linker (e.g. AAD oid, email). +public sealed record PrincipalIdentity( + string IsolationKey, + ChannelIdentity Identity, + IReadOnlyDictionary VerifiedClaims); \ No newline at end of file diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting.Channels/ResponseTarget.cs b/dotnet/src/Microsoft.Agents.AI.Hosting.Channels/ResponseTarget.cs new file mode 100644 index 00000000000..3ae471dc348 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.Hosting.Channels/ResponseTarget.cs @@ -0,0 +1,78 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Collections.Generic; + +namespace Microsoft.Agents.AI.Hosting.Channels; + +/// +/// Directs where the host delivers an agent response. Independent of . +/// Use the static factories (, , , +/// ) and the , , +/// , singletons. +/// +public abstract record ResponseTarget +{ + private ResponseTarget() { } + + /// Reply on the originating channel only. The default. + public static ResponseTarget Originating { get; } = new OriginatingResponseTarget(); + + /// Reply on the channel the user was last seen on, per . + public static ResponseTarget Active { get; } = new ActiveResponseTarget(); + + /// Fan out to every linked identity on every channel. + public static ResponseTarget AllLinked { get; } = new AllLinkedResponseTarget(); + + /// Suppress the response entirely. The originating wire returns a . + public static ResponseTarget None { get; } = new NoneResponseTarget(); + + /// Deliver to every linked identity on the named channel. + public static ResponseTarget Channel(string channelName, bool echoInput = false) + { + if (channelName is null) { throw new ArgumentNullException(nameof(channelName)); } + return new ChannelResponseTarget(channelName, echoInput); + } + + /// Deliver to every linked identity on each of the named channels. + public static ResponseTarget Channels(IReadOnlyList channelNames, bool echoInput = false) + { + if (channelNames is null) { throw new ArgumentNullException(nameof(channelNames)); } + return new ChannelsResponseTarget(channelNames, echoInput); + } + + /// Deliver to a single specific channel-native identity. + public static ResponseTarget Identity(ChannelIdentity identity, bool echoInput = false) + { + if (identity is null) { throw new ArgumentNullException(nameof(identity)); } + return new IdentitiesResponseTarget([identity], echoInput); + } + + /// Deliver to each of the specific channel-native identities. + public static ResponseTarget Identities(IReadOnlyList identities, bool echoInput = false) + { + if (identities is null) { throw new ArgumentNullException(nameof(identities)); } + return new IdentitiesResponseTarget(identities, echoInput); + } + + /// Reply on the originating channel only. + public sealed record OriginatingResponseTarget : ResponseTarget; + + /// Reply on the channel the user was last seen on. + public sealed record ActiveResponseTarget : ResponseTarget; + + /// Fan out to every linked identity on every channel. + public sealed record AllLinkedResponseTarget : ResponseTarget; + + /// Suppress the response entirely. + public sealed record NoneResponseTarget : ResponseTarget; + + /// Deliver to a single channel. + public sealed record ChannelResponseTarget(string ChannelName, bool EchoInput) : ResponseTarget; + + /// Deliver to multiple channels. + public sealed record ChannelsResponseTarget(IReadOnlyList ChannelNames, bool EchoInput) : ResponseTarget; + + /// Deliver to specific identities. + public sealed record IdentitiesResponseTarget(IReadOnlyList Targets, bool EchoInput) : ResponseTarget; +} \ No newline at end of file diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting.Channels/RetryPolicy.cs b/dotnet/src/Microsoft.Agents.AI.Hosting.Channels/RetryPolicy.cs new file mode 100644 index 00000000000..9c1b9da90d4 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.Hosting.Channels/RetryPolicy.cs @@ -0,0 +1,27 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; + +namespace Microsoft.Agents.AI.Hosting.Channels; + +/// +/// Retry parameters for a scheduled durable task. Mirrors the Python runner defaults so behaviour +/// is consistent across language SDKs. +/// +public sealed record RetryPolicy +{ + /// Total attempt count (initial attempt + retries). Default 5. + public int MaxAttempts { get; init; } = 5; + + /// Delay before the first retry. Default 1 second. + public TimeSpan InitialBackoff { get; init; } = TimeSpan.FromSeconds(1); + + /// Multiplier applied to the previous backoff. Default 2.0. + public double BackoffMultiplier { get; init; } = 2.0; + + /// Cap on a single backoff delay. Default 60 seconds. + public TimeSpan MaxBackoff { get; init; } = TimeSpan.FromSeconds(60); + + /// The default retry policy used when callers omit one. + public static RetryPolicy Default { get; } = new(); +} \ No newline at end of file diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting.Channels/Runners/AIAgentRunner.cs b/dotnet/src/Microsoft.Agents.AI.Hosting.Channels/Runners/AIAgentRunner.cs new file mode 100644 index 00000000000..b51bbc9f2b3 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.Hosting.Channels/Runners/AIAgentRunner.cs @@ -0,0 +1,75 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Runtime.CompilerServices; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Extensions.AI; +using Microsoft.Shared.Diagnostics; + +namespace Microsoft.Agents.AI.Hosting.Channels; + +/// +/// Default for targets. Coerces the +/// into the agent's RunAsync message shape and wraps +/// the response in . +/// +public sealed class AIAgentRunner : IHostedTargetRunner +{ + private readonly AIAgent _agent; + + /// Initializes a new instance. + public AIAgentRunner(AIAgent agent) + { + this._agent = Throw.IfNull(agent); + } + + /// + public async ValueTask RunAsync(ChannelRequest request, CancellationToken cancellationToken) + { + Throw.IfNull(request); + var messages = CoerceToMessages(request.Input); + var response = await this._agent.RunAsync(messages, session: null, options: null, cancellationToken).ConfigureAwait(false); + return new HostedRunResult + { + Result = response, + Session = request.Session, + }; + } + + /// + public async IAsyncEnumerable StreamAsync( + ChannelRequest request, + [EnumeratorCancellation] CancellationToken cancellationToken) + { + Throw.IfNull(request); + var messages = CoerceToMessages(request.Input); + AgentResponseUpdate? final = null; + await foreach (var update in this._agent.RunStreamingAsync(messages, session: null, options: null, cancellationToken).ConfigureAwait(false)) + { + final = update; + yield return new HostedStreamUpdate(update); + } + + var aggregate = final is null + ? new HostedRunResult { Result = null, Session = request.Session } + : (HostedRunResult)new HostedRunResult { Result = final, Session = request.Session }; + yield return new HostedStreamCompleted(aggregate); + } + + private static ChatMessage[] CoerceToMessages(object input) + { + return input switch + { + string s => [new ChatMessage(ChatRole.User, s)], + ChatMessage cm => [cm], + IEnumerable seq => seq.ToArray(), + _ => throw new ArgumentException( + $"AIAgentRunner cannot coerce input of type '{input?.GetType().FullName ?? ""}' into ChatMessage[]. " + + "Channels should normalize input to string, ChatMessage, or IEnumerable before calling RunAsync.", + nameof(input)), + }; + } +} \ No newline at end of file diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting.Channels/Runners/WorkflowRunner.cs b/dotnet/src/Microsoft.Agents.AI.Hosting.Channels/Runners/WorkflowRunner.cs new file mode 100644 index 00000000000..5f36f212815 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.Hosting.Channels/Runners/WorkflowRunner.cs @@ -0,0 +1,42 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Collections.Generic; +using System.Runtime.CompilerServices; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Agents.AI.Workflows; +using Microsoft.Shared.Diagnostics; + +namespace Microsoft.Agents.AI.Hosting.Channels; + +/// +/// Default for targets. +/// +/// +/// Skeletal in this draft: surfaces the workflow object via +/// without driving execution end-to-end. The intent is to fill in resume-token handling and +/// RequestInfoEvent projection in a follow-up commit once the channel surface (Invocations) +/// is in place to consume them. +/// +public sealed class WorkflowRunner : IHostedTargetRunner +{ + private readonly Workflow _workflow; + + /// Initializes a new instance. + public WorkflowRunner(Workflow workflow) + { + this._workflow = Throw.IfNull(workflow); + } + + /// The wrapped workflow. + public Workflow Workflow => this._workflow; + + /// + public ValueTask RunAsync(ChannelRequest request, CancellationToken cancellationToken) => + throw new NotImplementedException("WorkflowRunner is a draft placeholder; end-to-end wiring lands with the InvocationsChannel package."); + + /// + public IAsyncEnumerable StreamAsync(ChannelRequest request, CancellationToken cancellationToken) => + throw new NotImplementedException("WorkflowRunner is a draft placeholder; end-to-end wiring lands with the InvocationsChannel package."); +} \ No newline at end of file diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting.Channels/SessionMode.cs b/dotnet/src/Microsoft.Agents.AI.Hosting.Channels/SessionMode.cs new file mode 100644 index 00000000000..4f5148f4b74 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.Hosting.Channels/SessionMode.cs @@ -0,0 +1,18 @@ +// Copyright (c) Microsoft. All rights reserved. + +namespace Microsoft.Agents.AI.Hosting.Channels; + +/// +/// Controls how the host resolves an for a . +/// +public enum SessionMode +{ + /// Default. The host resolves a session if the channel supplies enough hints, otherwise runs ephemerally. + Auto, + + /// The host MUST resolve or create a session; missing required hints surface as an error. + Required, + + /// The host never resolves a session; the target runs ephemerally even if hints are present. + Disabled, +} \ No newline at end of file diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting.Channels/TaskHandle.cs b/dotnet/src/Microsoft.Agents.AI.Hosting.Channels/TaskHandle.cs new file mode 100644 index 00000000000..7e840e798e0 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.Hosting.Channels/TaskHandle.cs @@ -0,0 +1,10 @@ +// Copyright (c) Microsoft. All rights reserved. + +namespace Microsoft.Agents.AI.Hosting.Channels; + +/// +/// Opaque handle to a task scheduled on . +/// +/// The runner-assigned task identifier. +/// The handler name the task was scheduled under. +public sealed record TaskHandle(string TaskId, string Name); \ No newline at end of file diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting.Channels/TaskInvocationContext.cs b/dotnet/src/Microsoft.Agents.AI.Hosting.Channels/TaskInvocationContext.cs new file mode 100644 index 00000000000..1f212df0074 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.Hosting.Channels/TaskInvocationContext.cs @@ -0,0 +1,21 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Collections.Generic; + +namespace Microsoft.Agents.AI.Hosting.Channels; + +/// +/// Context handed to a registered handler per invocation. +/// +/// The handler name. +/// The scheduled payload. For object-mode runners this is the original object reference. +/// 1-based attempt counter; 1 on the initial call, >1 on retries. +/// +/// Mutable per-task state owned by the runner. Handlers may write cursors (e.g. echo_done) +/// here so a subsequent retry can detect partial progress and skip already-completed sub-steps. +/// +public sealed record TaskInvocationContext( + string Name, + object Payload, + int Attempt, + IDictionary State); \ No newline at end of file From 637bd650b380632fa56ef0d400025568ae16af93 Mon Sep 17 00:00:00 2001 From: Roger Barreto <19890735+RogerBarreto@users.noreply.github.com> Date: Thu, 28 May 2026 11:48:20 +0100 Subject: [PATCH 03/16] Hosting.Channels: FileHostStateStore, OneTimeCodeIdentityLinker, ResponseRouter Rounds out the core scaffold with the three cross-cutting pieces every channel will rely on. FileHostStateStore persists identity registry, link grants, last-seen ledger, continuation tokens, and session aliases as JSON files under HostStatePathOptions; in-memory cache write-through with on-demand hydration. OneTimeCodeIdentityLinker issues short random codes and consumes them via IHostStateStore link grants, no callback routes required. ResponseRouter owns ResponseTarget resolution and durable push fan-out: registers the hosting.push handler on the runner, walks IHostStateStore for Active / AllLinked / Channel(s) / Identities targets, filters via ILinkPolicy, schedules one task per non-originating destination, runs per-destination IChannelResponseHook before IChannelPush.PushAsync, tracks echo_done / response_done idempotency cursors on TaskInvocationContext.State so retries do not double-echo. Build is clean with 0 warnings across all 3 target frameworks. --- ...ntRouteBuilderHostingChannelsExtensions.cs | 2 + .../FileHostStateStore.cs | 277 ++++++++++++++++++ ...icationBuilderHostingChannelsExtensions.cs | 5 + .../Internal/ChannelContext.cs | 5 +- .../Internal/HostingPushPayload.cs | 14 + .../Internal/ResponseRouter.cs | 249 ++++++++++++++++ .../OneTimeCodeIdentityLinker.cs | 153 ++++++++++ 7 files changed, 703 insertions(+), 2 deletions(-) create mode 100644 dotnet/src/Microsoft.Agents.AI.Hosting.Channels/FileHostStateStore.cs create mode 100644 dotnet/src/Microsoft.Agents.AI.Hosting.Channels/Internal/HostingPushPayload.cs create mode 100644 dotnet/src/Microsoft.Agents.AI.Hosting.Channels/Internal/ResponseRouter.cs create mode 100644 dotnet/src/Microsoft.Agents.AI.Hosting.Channels/OneTimeCodeIdentityLinker.cs diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting.Channels/EndpointRouteBuilderHostingChannelsExtensions.cs b/dotnet/src/Microsoft.Agents.AI.Hosting.Channels/EndpointRouteBuilderHostingChannelsExtensions.cs index d84ca531750..d3e7be30c01 100644 --- a/dotnet/src/Microsoft.Agents.AI.Hosting.Channels/EndpointRouteBuilderHostingChannelsExtensions.cs +++ b/dotnet/src/Microsoft.Agents.AI.Hosting.Channels/EndpointRouteBuilderHostingChannelsExtensions.cs @@ -25,6 +25,8 @@ public static IEndpointConventionBuilder MapAgentFrameworkHost(this IEndpointRou Throw.IfNull(endpoints); var host = endpoints.ServiceProvider.GetRequiredService(); + // Force-construct the router so "hosting.push" is registered on the durable runner before traffic. + _ = endpoints.ServiceProvider.GetRequiredService(); var context = new ChannelContext(endpoints.ServiceProvider, host); var hostGroup = endpoints.MapGroup(string.Empty); diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting.Channels/FileHostStateStore.cs b/dotnet/src/Microsoft.Agents.AI.Hosting.Channels/FileHostStateStore.cs new file mode 100644 index 00000000000..4c14c8a2aef --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.Hosting.Channels/FileHostStateStore.cs @@ -0,0 +1,277 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; +using System.IO; +using System.Text.Json; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Shared.Diagnostics; + +namespace Microsoft.Agents.AI.Hosting.Channels; + +/// +/// File-system-backed . Each component (identity registry, link grants, +/// last-seen ledger, continuation tokens, session aliases) is persisted as one JSON file per record +/// under its configured path. Safe for single-process use; multiple processes sharing the same +/// directory require external coordination. +/// +/// +/// In-memory caches are populated on first access and write-through to disk. The on-disk schema is +/// considered private to this implementation and may evolve. Mirrors the Python behaviour where +/// the host shipped a similar JSON-files store as the v1 default for long-running deployments. +/// +[RequiresUnreferencedCode("FileHostStateStore uses reflection-based JSON serialization. Use a JsonTypeInfo-aware alternative for trimmed apps.")] +[RequiresDynamicCode("FileHostStateStore uses reflection-based JSON serialization. Use a JsonTypeInfo-aware alternative for AOT apps.")] +public sealed class FileHostStateStore : IHostStateStore +{ + private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web) { WriteIndented = false }; + + private readonly InMemoryHostStateStore _cache = new(); + private readonly string _linksPath; + private readonly string _grantsPath; + private readonly string _lastSeenPath; + private readonly string _continuationsPath; + private readonly string _aliasesPath; + private readonly object _writeGate = new(); + private bool _hydrated; + + /// Initializes a new instance. + public FileHostStateStore(HostStatePathOptions paths) + { + Throw.IfNull(paths); + var root = paths.Root ?? "./.afhost"; + this._linksPath = paths.LinksPath ?? Path.Combine(root, "links"); + this._grantsPath = paths.LinksPath is not null ? Path.Combine(paths.LinksPath, "grants") : Path.Combine(root, "grants"); + this._lastSeenPath = paths.LastSeenPath ?? Path.Combine(root, "last-seen"); + this._continuationsPath = paths.ContinuationsPath ?? Path.Combine(root, "continuations"); + this._aliasesPath = Path.Combine(root, "aliases"); + + Directory.CreateDirectory(this._linksPath); + Directory.CreateDirectory(this._grantsPath); + Directory.CreateDirectory(this._lastSeenPath); + Directory.CreateDirectory(this._continuationsPath); + Directory.CreateDirectory(this._aliasesPath); + } + + /// + public async ValueTask GetIsolationKeyAsync(ChannelIdentity identity, CancellationToken cancellationToken) + { + await this.HydrateAsync(cancellationToken).ConfigureAwait(false); + return await this._cache.GetIsolationKeyAsync(identity, cancellationToken).ConfigureAwait(false); + } + + /// + public async ValueTask SaveLinkAsync( + ChannelIdentity identity, + string isolationKey, + IReadOnlyDictionary? verifiedClaims, + CancellationToken cancellationToken) + { + await this.HydrateAsync(cancellationToken).ConfigureAwait(false); + await this._cache.SaveLinkAsync(identity, isolationKey, verifiedClaims, cancellationToken).ConfigureAwait(false); + var snapshot = await this._cache.GetIdentitiesAsync(isolationKey, cancellationToken).ConfigureAwait(false); + this.WriteJson(Path.Combine(this._linksPath, EncodeFileName(isolationKey) + ".json"), snapshot); + } + + /// + public async ValueTask> GetIdentitiesAsync(string isolationKey, CancellationToken cancellationToken) + { + await this.HydrateAsync(cancellationToken).ConfigureAwait(false); + return await this._cache.GetIdentitiesAsync(isolationKey, cancellationToken).ConfigureAwait(false); + } + + /// + public async ValueTask LookupByVerifiedClaimAsync(string claim, string value, CancellationToken cancellationToken) + { + await this.HydrateAsync(cancellationToken).ConfigureAwait(false); + return await this._cache.LookupByVerifiedClaimAsync(claim, value, cancellationToken).ConfigureAwait(false); + } + + /// + public async ValueTask SaveLinkGrantAsync(LinkGrant grant, CancellationToken cancellationToken) + { + Throw.IfNull(grant); + await this.HydrateAsync(cancellationToken).ConfigureAwait(false); + await this._cache.SaveLinkGrantAsync(grant, cancellationToken).ConfigureAwait(false); + this.WriteJson(Path.Combine(this._grantsPath, EncodeFileName(grant.Code) + ".json"), grant); + } + + /// + public async ValueTask GetLinkGrantAsync(string code, CancellationToken cancellationToken) + { + await this.HydrateAsync(cancellationToken).ConfigureAwait(false); + return await this._cache.GetLinkGrantAsync(code, cancellationToken).ConfigureAwait(false); + } + + /// + public async ValueTask ConsumeLinkGrantAsync(string code, CancellationToken cancellationToken) + { + await this.HydrateAsync(cancellationToken).ConfigureAwait(false); + var consumed = await this._cache.ConsumeLinkGrantAsync(code, cancellationToken).ConfigureAwait(false); + if (consumed is not null) + { + this.DeleteIfExists(Path.Combine(this._grantsPath, EncodeFileName(code) + ".json")); + } + return consumed; + } + + /// + public async ValueTask RecordLastSeenAsync( + string isolationKey, + ChannelIdentity identity, + string? conversationId, + DateTimeOffset at, + CancellationToken cancellationToken) + { + await this.HydrateAsync(cancellationToken).ConfigureAwait(false); + await this._cache.RecordLastSeenAsync(isolationKey, identity, conversationId, at, cancellationToken).ConfigureAwait(false); + var record = await this._cache.GetLastSeenAsync(isolationKey, cancellationToken).ConfigureAwait(false); + if (record is not null) + { + this.WriteJson(Path.Combine(this._lastSeenPath, EncodeFileName(isolationKey) + ".json"), record); + } + } + + /// + public async ValueTask GetLastSeenAsync(string isolationKey, CancellationToken cancellationToken) + { + await this.HydrateAsync(cancellationToken).ConfigureAwait(false); + return await this._cache.GetLastSeenAsync(isolationKey, cancellationToken).ConfigureAwait(false); + } + + /// + public async ValueTask SaveContinuationAsync(ContinuationToken token, CancellationToken cancellationToken) + { + Throw.IfNull(token); + await this.HydrateAsync(cancellationToken).ConfigureAwait(false); + await this._cache.SaveContinuationAsync(token, cancellationToken).ConfigureAwait(false); + this.WriteJson(Path.Combine(this._continuationsPath, EncodeFileName(token.Token) + ".json"), token); + } + + /// + public async ValueTask GetContinuationAsync(string token, CancellationToken cancellationToken) + { + await this.HydrateAsync(cancellationToken).ConfigureAwait(false); + return await this._cache.GetContinuationAsync(token, cancellationToken).ConfigureAwait(false); + } + + /// + public async ValueTask DeleteContinuationAsync(string token, CancellationToken cancellationToken) + { + await this.HydrateAsync(cancellationToken).ConfigureAwait(false); + await this._cache.DeleteContinuationAsync(token, cancellationToken).ConfigureAwait(false); + this.DeleteIfExists(Path.Combine(this._continuationsPath, EncodeFileName(token) + ".json")); + } + + /// + public async ValueTask RotateSessionAliasAsync(string isolationKey, CancellationToken cancellationToken) + { + await this.HydrateAsync(cancellationToken).ConfigureAwait(false); + await this._cache.RotateSessionAliasAsync(isolationKey, cancellationToken).ConfigureAwait(false); + var alias = await this._cache.GetActiveSessionAliasAsync(isolationKey, cancellationToken).ConfigureAwait(false); + if (alias is not null) + { + this.WriteJson(Path.Combine(this._aliasesPath, EncodeFileName(isolationKey) + ".json"), alias); + } + } + + /// + public async ValueTask GetActiveSessionAliasAsync(string isolationKey, CancellationToken cancellationToken) + { + await this.HydrateAsync(cancellationToken).ConfigureAwait(false); + var alias = await this._cache.GetActiveSessionAliasAsync(isolationKey, cancellationToken).ConfigureAwait(false); + if (alias is not null && !File.Exists(Path.Combine(this._aliasesPath, EncodeFileName(isolationKey) + ".json"))) + { + this.WriteJson(Path.Combine(this._aliasesPath, EncodeFileName(isolationKey) + ".json"), alias); + } + return alias; + } + + private async ValueTask HydrateAsync(CancellationToken cancellationToken) + { + if (this._hydrated) { return; } + lock (this._writeGate) + { + if (this._hydrated) { return; } + this._hydrated = true; + } + + foreach (var file in Directory.EnumerateFiles(this._linksPath, "*.json")) + { + var snapshot = ReadJson>(file); + if (snapshot is null) { continue; } + var isolationKey = DecodeFileName(Path.GetFileNameWithoutExtension(file)); + foreach (var reg in snapshot) + { + await this._cache.SaveLinkAsync(reg.Identity, isolationKey, reg.VerifiedClaims, cancellationToken).ConfigureAwait(false); + } + } + + foreach (var file in Directory.EnumerateFiles(this._grantsPath, "*.json")) + { + var grant = ReadJson(file); + if (grant is null) { continue; } + if (grant.ExpiresAt <= DateTimeOffset.UtcNow) { this.DeleteIfExists(file); continue; } + await this._cache.SaveLinkGrantAsync(grant, cancellationToken).ConfigureAwait(false); + } + + foreach (var file in Directory.EnumerateFiles(this._lastSeenPath, "*.json")) + { + var record = ReadJson(file); + if (record is null) { continue; } + var isolationKey = DecodeFileName(Path.GetFileNameWithoutExtension(file)); + await this._cache.RecordLastSeenAsync(isolationKey, record.Identity, record.ConversationId, record.At, cancellationToken).ConfigureAwait(false); + } + + foreach (var file in Directory.EnumerateFiles(this._continuationsPath, "*.json")) + { + var token = ReadJson(file); + if (token is null) { continue; } + await this._cache.SaveContinuationAsync(token, cancellationToken).ConfigureAwait(false); + } + } + + private void WriteJson(string path, T payload) + { + lock (this._writeGate) + { + using var stream = File.Create(path); + JsonSerializer.Serialize(stream, payload, JsonOptions); + } + } + + private static T? ReadJson(string path) where T : class + { + try + { + using var stream = File.OpenRead(path); + return JsonSerializer.Deserialize(stream, JsonOptions); + } + catch (Exception) + { + return null; + } + } + + private void DeleteIfExists(string path) + { + lock (this._writeGate) + { + if (File.Exists(path)) { File.Delete(path); } + } + } + + private static string EncodeFileName(string raw) + { + var bytes = System.Text.Encoding.UTF8.GetBytes(raw); + return Convert.ToHexString(bytes); + } + + private static string DecodeFileName(string encoded) + { + var bytes = Convert.FromHexString(encoded); + return System.Text.Encoding.UTF8.GetString(bytes); + } +} \ No newline at end of file diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting.Channels/HostApplicationBuilderHostingChannelsExtensions.cs b/dotnet/src/Microsoft.Agents.AI.Hosting.Channels/HostApplicationBuilderHostingChannelsExtensions.cs index 78e14daecd7..f01a4493181 100644 --- a/dotnet/src/Microsoft.Agents.AI.Hosting.Channels/HostApplicationBuilderHostingChannelsExtensions.cs +++ b/dotnet/src/Microsoft.Agents.AI.Hosting.Channels/HostApplicationBuilderHostingChannelsExtensions.cs @@ -88,6 +88,11 @@ private static AgentFrameworkHostBuilder AddAgentFrameworkHostCore( sp.GetRequiredService(), sp.GetRequiredService())); + services.TryAddSingleton(sp => new ResponseRouter( + sp.GetRequiredService(), + sp.GetRequiredService(), + sp.GetRequiredService>())); + return new AgentFrameworkHostBuilder(services, options); } } \ No newline at end of file diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting.Channels/Internal/ChannelContext.cs b/dotnet/src/Microsoft.Agents.AI.Hosting.Channels/Internal/ChannelContext.cs index 20e882c4234..deaca4ed717 100644 --- a/dotnet/src/Microsoft.Agents.AI.Hosting.Channels/Internal/ChannelContext.cs +++ b/dotnet/src/Microsoft.Agents.AI.Hosting.Channels/Internal/ChannelContext.cs @@ -38,7 +38,8 @@ public ValueTask> ScheduleResponseAsync( ChannelRequest originating, CancellationToken cancellationToken = default) { - // Draft: end-to-end fan-out scheduling lands with the first channel that implements IChannelPush. - return new(Array.Empty()); + var router = (ResponseRouter?)this.Services.GetService(typeof(ResponseRouter)) + ?? throw new InvalidOperationException("ResponseRouter is not registered. Call AddAgentFrameworkHost(...) on IHostApplicationBuilder."); + return router.ScheduleResponseAsync(result, originating, cancellationToken); } } \ No newline at end of file diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting.Channels/Internal/HostingPushPayload.cs b/dotnet/src/Microsoft.Agents.AI.Hosting.Channels/Internal/HostingPushPayload.cs new file mode 100644 index 00000000000..8eada3bb906 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.Hosting.Channels/Internal/HostingPushPayload.cs @@ -0,0 +1,14 @@ +// Copyright (c) Microsoft. All rights reserved. + +namespace Microsoft.Agents.AI.Hosting.Channels; + +/// +/// Payload the host enqueues on the durable runner under the "hosting.push" handler. Carries +/// everything the push handler needs to invoke the right channel's IChannelPush and run per-destination +/// response hooks. +/// +internal sealed record HostingPushPayload( + ChannelPushContext PushContext, + HostedRunResult Result, + string DestinationChannelName, + bool Originating); \ No newline at end of file diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting.Channels/Internal/ResponseRouter.cs b/dotnet/src/Microsoft.Agents.AI.Hosting.Channels/Internal/ResponseRouter.cs new file mode 100644 index 00000000000..22776e8e421 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.Hosting.Channels/Internal/ResponseRouter.cs @@ -0,0 +1,249 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Extensions.Logging; +using Microsoft.Shared.Diagnostics; + +namespace Microsoft.Agents.AI.Hosting.Channels; + +/// +/// Single owner of response-target resolution and durable push scheduling. Registered as a +/// singleton; binds the "hosting.push" handler on the configured +/// at construction. +/// +internal sealed class ResponseRouter +{ + /// Reserved handler name registered on the durable runner. + public const string PushHandlerName = "hosting.push"; + + private readonly AgentFrameworkHost _host; + private readonly ILinkPolicy _linkPolicy; + private readonly ILogger _logger; + private readonly Dictionary _channelsByName; + + public ResponseRouter( + AgentFrameworkHost host, + ILinkPolicy linkPolicy, + ILogger logger) + { + this._host = Throw.IfNull(host); + this._linkPolicy = Throw.IfNull(linkPolicy); + this._logger = Throw.IfNull(logger); + this._channelsByName = new Dictionary(StringComparer.Ordinal); + for (var i = 0; i < host.Channels.Count; i++) + { + this._channelsByName[host.Channels[i].Name] = host.Channels[i]; + } + + host.DurableRunner.Register(PushHandlerName, this.HandlePushAsync); + } + + /// + /// Resolve the destination set against the configured response target, schedule one + /// task per non-originating destination, and return the + /// per-destination task handles. + /// + public async ValueTask> ScheduleResponseAsync( + HostedRunResult result, + ChannelRequest originating, + CancellationToken cancellationToken) + { + Throw.IfNull(result); + Throw.IfNull(originating); + + var target = originating.ResponseTarget ?? ResponseTarget.Originating; + if (target is ResponseTarget.OriginatingResponseTarget || target is ResponseTarget.NoneResponseTarget) + { + return Array.Empty(); + } + + var destinations = await this.ResolveDestinationsAsync(target, originating, cancellationToken).ConfigureAwait(false); + if (destinations.Count == 0) + { + this._logger.LogWarning("Response target {Target} resolved to zero destinations for originating channel {Channel}. Falling back to originating.", target.GetType().Name, originating.Channel); + return Array.Empty(); + } + + var handles = new List(destinations.Count); + for (var i = 0; i < destinations.Count; i++) + { + var dest = destinations[i]; + var isOriginating = string.Equals(dest.Identity.Channel, originating.Channel, StringComparison.Ordinal); + if (isOriginating) + { + continue; + } + + if (!this._channelsByName.TryGetValue(dest.Identity.Channel, out var destChannel) || destChannel is not IChannelPush) + { + this._logger.LogWarning("Destination channel {Channel} is not registered or does not implement IChannelPush. Skipping.", dest.Identity.Channel); + continue; + } + + var pushContext = new ChannelPushContext + { + Destination = dest.Identity, + OriginatingRequest = originating, + OriginatingChannel = originating.Channel, + IsEcho = false, + OriginalTarget = target, + }; + + var payload = new HostingPushPayload(pushContext, result, dest.Identity.Channel, Originating: false); + var handle = await this._host.DurableRunner.ScheduleAsync(PushHandlerName, payload, retryPolicy: null, cancellationToken).ConfigureAwait(false); + handles.Add(handle); + + if (dest.EchoInput) + { + var echoContext = pushContext with { IsEcho = true }; + var echoPayload = new HostingPushPayload(echoContext, result, dest.Identity.Channel, Originating: false); + handles.Add(await this._host.DurableRunner.ScheduleAsync(PushHandlerName, echoPayload, retryPolicy: null, cancellationToken).ConfigureAwait(false)); + } + } + + return handles; + } + + private async ValueTask> ResolveDestinationsAsync( + ResponseTarget target, + ChannelRequest originating, + CancellationToken cancellationToken) + { + var isolationKey = originating.Session?.IsolationKey; + switch (target) + { + case ResponseTarget.ActiveResponseTarget: + if (isolationKey is null) { return Array.Empty(); } + var lastSeen = await this._host.StateStore.GetLastSeenAsync(isolationKey, cancellationToken).ConfigureAwait(false); + return lastSeen is null + ? Array.Empty() + : await this.FilterByLinkPolicyAsync(originating.Channel, [new ResolvedDestination(lastSeen.Identity.Channel, lastSeen.Identity, EchoInput: false)], cancellationToken).ConfigureAwait(false); + + case ResponseTarget.AllLinkedResponseTarget: + if (isolationKey is null) { return Array.Empty(); } + var registrations = await this._host.StateStore.GetIdentitiesAsync(isolationKey, cancellationToken).ConfigureAwait(false); + var all = new List(registrations.Count); + for (var i = 0; i < registrations.Count; i++) + { + all.Add(new ResolvedDestination(registrations[i].Identity.Channel, registrations[i].Identity, EchoInput: false)); + } + return await this.FilterByLinkPolicyAsync(originating.Channel, all, cancellationToken).ConfigureAwait(false); + + case ResponseTarget.ChannelResponseTarget chTarget: + return await this.ResolveChannelTargetsAsync(originating.Channel, isolationKey, [chTarget.ChannelName], chTarget.EchoInput, cancellationToken).ConfigureAwait(false); + + case ResponseTarget.ChannelsResponseTarget chsTarget: + return await this.ResolveChannelTargetsAsync(originating.Channel, isolationKey, chsTarget.ChannelNames, chsTarget.EchoInput, cancellationToken).ConfigureAwait(false); + + case ResponseTarget.IdentitiesResponseTarget idTarget: + var dests = new List(idTarget.Targets.Count); + for (var i = 0; i < idTarget.Targets.Count; i++) + { + dests.Add(new ResolvedDestination(idTarget.Targets[i].Channel, idTarget.Targets[i], idTarget.EchoInput)); + } + return await this.FilterByLinkPolicyAsync(originating.Channel, dests, cancellationToken).ConfigureAwait(false); + + default: + return Array.Empty(); + } + } + + private async ValueTask> ResolveChannelTargetsAsync( + string originatingChannel, + string? isolationKey, + IReadOnlyList channelNames, + bool echoInput, + CancellationToken cancellationToken) + { + if (isolationKey is null) { return Array.Empty(); } + var registrations = await this._host.StateStore.GetIdentitiesAsync(isolationKey, cancellationToken).ConfigureAwait(false); + + var resolved = new List(); + var matchSet = new HashSet(channelNames, StringComparer.Ordinal); + for (var i = 0; i < registrations.Count; i++) + { + var reg = registrations[i]; + if (matchSet.Contains(reg.Identity.Channel)) + { + resolved.Add(new ResolvedDestination(reg.Identity.Channel, reg.Identity, echoInput)); + } + } + return await this.FilterByLinkPolicyAsync(originatingChannel, resolved, cancellationToken).ConfigureAwait(false); + } + + private async ValueTask> FilterByLinkPolicyAsync( + string originatingChannelName, + List candidates, + CancellationToken cancellationToken) + { + if (!this._channelsByName.TryGetValue(originatingChannelName, out var source)) + { + return candidates; + } + + var filtered = new List(candidates.Count); + for (var i = 0; i < candidates.Count; i++) + { + var dest = candidates[i]; + if (!this._channelsByName.TryGetValue(dest.ChannelName, out var destChannel)) + { + continue; + } + var permitted = await this._linkPolicy.EvaluateAsync( + new LinkPolicyContext { Source = source, Destination = destChannel, Operation = LinkPolicyOperation.Deliver }, + cancellationToken).ConfigureAwait(false); + if (permitted) { filtered.Add(dest); } + } + return filtered; + } + + private async ValueTask HandlePushAsync(TaskInvocationContext invocation) + { + if (invocation.Payload is not HostingPushPayload payload) + { + this._logger.LogError("hosting.push handler received unexpected payload type {Type}.", invocation.Payload?.GetType().FullName ?? ""); + return; + } + + if (!this._channelsByName.TryGetValue(payload.DestinationChannelName, out var channel)) + { + this._logger.LogError("hosting.push handler could not resolve destination channel {Channel}.", payload.DestinationChannelName); + return; + } + + if (channel is not IChannelPush push) + { + this._logger.LogError("Destination channel {Channel} does not implement IChannelPush.", payload.DestinationChannelName); + return; + } + + // Echo idempotency cursor: never re-run a successful echo or response on retry. + var stateKey = payload.PushContext.IsEcho ? "echo_done" : "response_done"; + if (invocation.State.TryGetValue(stateKey, out var done) && done is true) + { + return; + } + + var resultForPush = payload.Result; + if (channel is IChannelResponseHook hook) + { + var hookContext = new ChannelResponseContext + { + Request = payload.PushContext.OriginatingRequest, + ChannelName = payload.DestinationChannelName, + DestinationIdentity = payload.PushContext.Destination, + Originating = payload.Originating, + IsEcho = payload.PushContext.IsEcho, + }; + resultForPush = await hook.OnResponseAsync(resultForPush, hookContext, CancellationToken.None).ConfigureAwait(false); + } + + await push.PushAsync(payload.PushContext, resultForPush, CancellationToken.None).ConfigureAwait(false); + invocation.State[stateKey] = true; + } +} + +internal sealed record ResolvedDestination(string ChannelName, ChannelIdentity Identity, bool EchoInput); \ No newline at end of file diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting.Channels/OneTimeCodeIdentityLinker.cs b/dotnet/src/Microsoft.Agents.AI.Hosting.Channels/OneTimeCodeIdentityLinker.cs new file mode 100644 index 00000000000..46b86f5602f --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.Hosting.Channels/OneTimeCodeIdentityLinker.cs @@ -0,0 +1,153 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Collections.Generic; +using System.Security.Cryptography; +using System.Text; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Shared.Diagnostics; + +namespace Microsoft.Agents.AI.Hosting.Channels; + +/// +/// Zero-dependency : emits a short random code +/// the user must present on a peer channel; consumes the code and binds +/// the peer-channel identity to the originating identity's isolation key. Backed entirely by +/// ; no callback routes are required so is a no-op. +/// +/// +/// Use this for low-ceremony cross-channel linking (Telegram + Responses, Telegram + Discord, ...) +/// where one channel asks the user to type a code into the other. For Entra / OAuth-style flows +/// substitute the Entra linker from Microsoft.Agents.AI.Hosting.Channels.EntraId. +/// +public sealed class OneTimeCodeIdentityLinker : IIdentityLinker +{ + private readonly IHostStateStore _stateStore; + + /// Initializes a new instance. + public OneTimeCodeIdentityLinker(IHostStateStore stateStore) + { + this._stateStore = Throw.IfNull(stateStore); + } + + /// + public string Name => "one-time-code"; + + /// Lifetime of an unconsumed code. Default 10 minutes. + public TimeSpan CodeLifetime { get; init; } = TimeSpan.FromMinutes(10); + + /// + public ChannelContribution Contribute(IChannelContext context) => new(); + + /// + public async ValueTask BeginAsync( + ChannelIdentity identity, + string? requestedIsolationKey, + CancellationToken cancellationToken) + { + Throw.IfNull(identity); + + var code = GenerateCode(); + var payload = new Dictionary(StringComparer.Ordinal) + { + ["channel"] = identity.Channel, + ["nativeId"] = identity.NativeId, + }; + + var grant = new LinkGrant( + Code: code, + IssuedByLinker: this.Name, + RequestedIsolationKey: requestedIsolationKey, + ExpiresAt: DateTimeOffset.UtcNow.Add(this.CodeLifetime), + Payload: payload); + + await this._stateStore.SaveLinkGrantAsync(grant, cancellationToken).ConfigureAwait(false); + + return new LinkChallenge( + ChallengeId: code, + Kind: "code", + Code: code, + UserPrompt: $"Send '/link {code}' on the other channel to merge the two identities. Code expires in {(int)this.CodeLifetime.TotalMinutes} minutes."); + } + + /// + public async ValueTask CompleteAsync( + string challengeId, + IReadOnlyDictionary proof, + CancellationToken cancellationToken) + { + Throw.IfNullOrEmpty(challengeId); + Throw.IfNull(proof); + + if (!proof.TryGetValue("identity", out var identityObj) || identityObj is not ChannelIdentity completingIdentity) + { + throw new ArgumentException("Proof must include 'identity' of type ChannelIdentity.", nameof(proof)); + } + + var grant = await this._stateStore.ConsumeLinkGrantAsync(challengeId, cancellationToken).ConfigureAwait(false); + if (grant is null) + { + throw new InvalidOperationException($"Link code '{challengeId}' is invalid, expired, or already consumed."); + } + + if (!grant.Payload.TryGetValue("channel", out var sourceChannel) || + !grant.Payload.TryGetValue("nativeId", out var sourceNativeId) || + sourceChannel is not string sourceChannelStr || + sourceNativeId is not string sourceNativeIdStr) + { + throw new InvalidOperationException($"Link code '{challengeId}' has a malformed payload."); + } + + var sourceIdentity = new ChannelIdentity(sourceChannelStr, sourceNativeIdStr); + + var isolationKey = grant.RequestedIsolationKey + ?? await this._stateStore.GetIsolationKeyAsync(sourceIdentity, cancellationToken).ConfigureAwait(false) + ?? await this._stateStore.GetIsolationKeyAsync(completingIdentity, cancellationToken).ConfigureAwait(false) + ?? $"{sourceIdentity.Channel}:{sourceIdentity.NativeId}"; + + await this._stateStore.SaveLinkAsync(sourceIdentity, isolationKey, verifiedClaims: null, cancellationToken).ConfigureAwait(false); + await this._stateStore.SaveLinkAsync(completingIdentity, isolationKey, verifiedClaims: null, cancellationToken).ConfigureAwait(false); + + return new PrincipalIdentity(isolationKey, completingIdentity, new Dictionary(StringComparer.Ordinal)); + } + + /// + public async ValueTask IsLinkedAsync( + ChannelIdentity identity, + IReadOnlyDictionary? verifiedClaims, + CancellationToken cancellationToken) + { + Throw.IfNull(identity); + var existing = await this._stateStore.GetIsolationKeyAsync(identity, cancellationToken).ConfigureAwait(false); + if (existing is not null) { return existing; } + + if (verifiedClaims is not null) + { + foreach (var (claim, value) in verifiedClaims) + { + var match = await this._stateStore.LookupByVerifiedClaimAsync(claim, value, cancellationToken).ConfigureAwait(false); + if (match is not null) + { + await this._stateStore.SaveLinkAsync(identity, match, verifiedClaims, cancellationToken).ConfigureAwait(false); + return match; + } + } + } + + return null; + } + + private static string GenerateCode() + { + Span bytes = stackalloc byte[5]; + RandomNumberGenerator.Fill(bytes); + var sb = new StringBuilder(8); + const string Alphabet = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789"; + for (var i = 0; i < bytes.Length; i++) + { + sb.Append(Alphabet[bytes[i] % Alphabet.Length]); + } + return sb.ToString(); + } +} \ No newline at end of file From e89b5b2a6975452a040295cb52e8c24518537939 Mon Sep 17 00:00:00 2001 From: Roger Barreto <19890735+RogerBarreto@users.noreply.github.com> Date: Thu, 28 May 2026 11:54:40 +0100 Subject: [PATCH 04/16] Hosting.Channels.Invocations: JSON invocation channel Adds Microsoft.Agents.AI.Hosting.Channels.Invocations (alpha) implementing the simplest of the three v1 channel packages: a JSON-only invocation surface. POST /invocations/invoke runs the target synchronously and returns the agent text plus session id; GET /invocations/{continuationToken} polls a background run scheduled via background=true in the request body. Uses source-generated JSON via InvocationsJsonContext and the ASP.NET Core request-delegate generator so the surface stays trim/AOT-friendly. Validates the channel contract end-to-end: ChannelContribution routes are wrapped in endpoints.MapGroup(Path), IChannelContext.RunAsync drives the host runner, AgentFrameworkHost.RunInBackgroundAsync writes the ContinuationToken, polling reads it back through IHostStateStore. AddInvocationsChannel hangs off IAgentFrameworkHostBuilder. Build is clean with 0 warnings across all 3 target frameworks. WorkflowInvocationsResponseHook for RequestInfoEvent rendering lands when WorkflowRunner gains end-to-end wiring. --- dotnet/agent-framework-dotnet.slnx | 1 + ...ameworkHostBuilderInvocationsExtensions.cs | 23 ++ .../Internal/InvocationsJsonContext.cs | 13 ++ .../Internal/InvocationsJsonModels.cs | 80 +++++++ .../InvocationsChannel.cs | 203 ++++++++++++++++++ .../InvocationsChannelOptions.cs | 36 ++++ ...nts.AI.Hosting.Channels.Invocations.csproj | 34 +++ 7 files changed, 390 insertions(+) create mode 100644 dotnet/src/Microsoft.Agents.AI.Hosting.Channels.Invocations/AgentFrameworkHostBuilderInvocationsExtensions.cs create mode 100644 dotnet/src/Microsoft.Agents.AI.Hosting.Channels.Invocations/Internal/InvocationsJsonContext.cs create mode 100644 dotnet/src/Microsoft.Agents.AI.Hosting.Channels.Invocations/Internal/InvocationsJsonModels.cs create mode 100644 dotnet/src/Microsoft.Agents.AI.Hosting.Channels.Invocations/InvocationsChannel.cs create mode 100644 dotnet/src/Microsoft.Agents.AI.Hosting.Channels.Invocations/InvocationsChannelOptions.cs create mode 100644 dotnet/src/Microsoft.Agents.AI.Hosting.Channels.Invocations/Microsoft.Agents.AI.Hosting.Channels.Invocations.csproj diff --git a/dotnet/agent-framework-dotnet.slnx b/dotnet/agent-framework-dotnet.slnx index e2f0bd18610..01a614fd7d4 100644 --- a/dotnet/agent-framework-dotnet.slnx +++ b/dotnet/agent-framework-dotnet.slnx @@ -619,6 +619,7 @@ + diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting.Channels.Invocations/AgentFrameworkHostBuilderInvocationsExtensions.cs b/dotnet/src/Microsoft.Agents.AI.Hosting.Channels.Invocations/AgentFrameworkHostBuilderInvocationsExtensions.cs new file mode 100644 index 00000000000..be4ed1e1d20 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.Hosting.Channels.Invocations/AgentFrameworkHostBuilderInvocationsExtensions.cs @@ -0,0 +1,23 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using Microsoft.Shared.Diagnostics; + +namespace Microsoft.Agents.AI.Hosting.Channels.Invocations; + +/// +/// Extensions on for the invocations channel. +/// +public static class AgentFrameworkHostBuilderInvocationsExtensions +{ + /// Add the JSON invocations channel. + public static IAgentFrameworkHostBuilder AddInvocationsChannel( + this IAgentFrameworkHostBuilder builder, + Action? configure = null) + { + Throw.IfNull(builder); + var options = new InvocationsChannelOptions(); + configure?.Invoke(options); + return builder.AddChannel(new InvocationsChannel(options)); + } +} \ No newline at end of file diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting.Channels.Invocations/Internal/InvocationsJsonContext.cs b/dotnet/src/Microsoft.Agents.AI.Hosting.Channels.Invocations/Internal/InvocationsJsonContext.cs new file mode 100644 index 00000000000..ba83c4a0dc9 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.Hosting.Channels.Invocations/Internal/InvocationsJsonContext.cs @@ -0,0 +1,13 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Text.Json.Serialization; + +namespace Microsoft.Agents.AI.Hosting.Channels.Invocations; + +[JsonSerializable(typeof(InvocationRequestModel))] +[JsonSerializable(typeof(InvocationResponseModel))] +[JsonSerializable(typeof(InvocationAwaitingInputModel))] +[JsonSerializable(typeof(InvocationContinuationModel))] +[JsonSerializable(typeof(InvocationErrorModel))] +[JsonSourceGenerationOptions(PropertyNamingPolicy = JsonKnownNamingPolicy.SnakeCaseLower)] +internal sealed partial class InvocationsJsonContext : JsonSerializerContext; \ No newline at end of file diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting.Channels.Invocations/Internal/InvocationsJsonModels.cs b/dotnet/src/Microsoft.Agents.AI.Hosting.Channels.Invocations/Internal/InvocationsJsonModels.cs new file mode 100644 index 00000000000..efdd36686f7 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.Hosting.Channels.Invocations/Internal/InvocationsJsonModels.cs @@ -0,0 +1,80 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Collections.Generic; +using System.Text.Json.Serialization; + +namespace Microsoft.Agents.AI.Hosting.Channels.Invocations; + +/// Inbound payload for POST {Path}/invoke. +internal sealed class InvocationRequestModel +{ + [JsonPropertyName("input")] + public object? Input { get; set; } + + [JsonPropertyName("attributes")] + public Dictionary? Attributes { get; set; } + + [JsonPropertyName("metadata")] + public Dictionary? Metadata { get; set; } + + [JsonPropertyName("background")] + public bool Background { get; set; } + + [JsonPropertyName("session_id")] + public string? SessionId { get; set; } + + [JsonPropertyName("isolation_key")] + public string? IsolationKey { get; set; } +} + +/// Successful response envelope for a completed run. +internal sealed class InvocationResponseModel +{ + [JsonPropertyName("status")] + public string Status { get; set; } = "completed"; + + [JsonPropertyName("text")] + public string? Text { get; set; } + + [JsonPropertyName("session_id")] + public string? SessionId { get; set; } + + [JsonPropertyName("continuation_token")] + public string? ContinuationToken { get; set; } +} + +/// Awaiting-input envelope when a workflow target paused on a RequestInfoEvent. +internal sealed class InvocationAwaitingInputModel +{ + [JsonPropertyName("status")] + public string Status { get; set; } = "awaiting_input"; + + [JsonPropertyName("request")] + public object? Request { get; set; } + + [JsonPropertyName("resume_token")] + public string? ResumeToken { get; set; } +} + +/// Queued / running envelope for background runs. +internal sealed class InvocationContinuationModel +{ + [JsonPropertyName("status")] + public string Status { get; set; } = "queued"; + + [JsonPropertyName("continuation_token")] + public string? ContinuationToken { get; set; } +} + +/// Error envelope. +internal sealed class InvocationErrorModel +{ + [JsonPropertyName("status")] + public string Status { get; set; } = "failed"; + + [JsonPropertyName("error_code")] + public string ErrorCode { get; set; } = string.Empty; + + [JsonPropertyName("message")] + public string? Message { get; set; } +} \ No newline at end of file diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting.Channels.Invocations/InvocationsChannel.cs b/dotnet/src/Microsoft.Agents.AI.Hosting.Channels.Invocations/InvocationsChannel.cs new file mode 100644 index 00000000000..4ffb3f75205 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.Hosting.Channels.Invocations/InvocationsChannel.cs @@ -0,0 +1,203 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Collections.Generic; +using System.Text.Json; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Routing; +using Microsoft.Shared.Diagnostics; + +namespace Microsoft.Agents.AI.Hosting.Channels.Invocations; + +/// +/// JSON invocation channel. Exposes POST {Path}/invoke for synchronous runs and +/// GET {Path}/{continuationToken} for polling background runs. +/// +public sealed class InvocationsChannel : Channel +{ + private readonly InvocationsChannelOptions _options; + + /// Initializes a new instance. + public InvocationsChannel(InvocationsChannelOptions options) + { + this._options = Throw.IfNull(options); + } + + /// + public override string Name => "invocations"; + + /// + public override string Path => this._options.Path; + + /// + public override ChannelContribution Contribute(IChannelContext context) + { + Throw.IfNull(context); + return new ChannelContribution + { + Routes = + [ + endpoints => + { + endpoints.MapPost("/invoke", (HttpContext http) => this.HandleInvokeAsync(context, http)); + endpoints.MapGet("/{continuationToken}", (string continuationToken, HttpContext http) => + this.HandleGetContinuationAsync(context, continuationToken, http)); + }, + ], + }; + } + + private async Task HandleInvokeAsync(IChannelContext context, HttpContext http) + { + InvocationRequestModel? body; + try + { + body = await JsonSerializer.DeserializeAsync(http.Request.Body, InvocationsJsonContext.Default.InvocationRequestModel, http.RequestAborted).ConfigureAwait(false); + } + catch (JsonException ex) + { + await WriteJsonAsync(http, StatusCodes.Status400BadRequest, + new InvocationErrorModel { ErrorCode = "invalid_json", Message = ex.Message }, InvocationsJsonContext.Default.InvocationErrorModel).ConfigureAwait(false); + return; + } + + if (body?.Input is null) + { + await WriteJsonAsync(http, StatusCodes.Status400BadRequest, + new InvocationErrorModel { ErrorCode = "missing_input", Message = "Request body must include a non-null 'input' property." }, InvocationsJsonContext.Default.InvocationErrorModel).ConfigureAwait(false); + return; + } + + var attributes = body.Attributes is null + ? (IReadOnlyDictionary)System.Collections.Immutable.ImmutableDictionary.Empty + : body.Attributes; + + var request = new ChannelRequest + { + Channel = this.Name, + Operation = "message.create", + Input = NormalizeInput(body.Input), + Attributes = attributes, + Background = body.Background, + Session = (body.SessionId is null && body.IsolationKey is null) ? null : new ChannelSession + { + Key = body.SessionId, + IsolationKey = body.IsolationKey, + }, + }; + + if (this._options.RunHook is not null) + { + var hookContext = new ChannelRunHookContext { Target = context.Host, ProtocolRequest = body }; + request = await this._options.RunHook.OnRequestAsync(request, hookContext, http.RequestAborted).ConfigureAwait(false); + } + + if (request.Background) + { + var token = await context.Host.RunInBackgroundAsync(request, http.RequestAborted).ConfigureAwait(false); + await WriteJsonAsync(http, StatusCodes.Status202Accepted, + new InvocationContinuationModel { Status = StatusFromContinuation(token.Status), ContinuationToken = token.Token }, + InvocationsJsonContext.Default.InvocationContinuationModel).ConfigureAwait(false); + return; + } + + try + { + var result = await context.RunAsync(request, http.RequestAborted).ConfigureAwait(false); + await WriteSuccessAsync(http, result).ConfigureAwait(false); + } + catch (Exception ex) + { + await WriteJsonAsync(http, StatusCodes.Status500InternalServerError, + new InvocationErrorModel { ErrorCode = "run_failed", Message = ex.Message }, + InvocationsJsonContext.Default.InvocationErrorModel).ConfigureAwait(false); + } + } + + private async Task HandleGetContinuationAsync(IChannelContext context, string continuationToken, HttpContext http) + { + var token = await context.Host.GetContinuationAsync(continuationToken, http.RequestAborted).ConfigureAwait(false); + if (token is null) + { + await WriteJsonAsync(http, StatusCodes.Status404NotFound, + new InvocationErrorModel { ErrorCode = "unknown_continuation", Message = $"Continuation token '{continuationToken}' is not known." }, + InvocationsJsonContext.Default.InvocationErrorModel).ConfigureAwait(false); + return; + } + + switch (token.Status) + { + case ContinuationStatus.Queued: + case ContinuationStatus.Running: + await WriteJsonAsync(http, StatusCodes.Status202Accepted, + new InvocationContinuationModel { Status = StatusFromContinuation(token.Status), ContinuationToken = token.Token }, + InvocationsJsonContext.Default.InvocationContinuationModel).ConfigureAwait(false); + break; + + case ContinuationStatus.Completed when token.Result is not null: + await WriteSuccessAsync(http, token.Result).ConfigureAwait(false); + break; + + case ContinuationStatus.Failed: + await WriteJsonAsync(http, StatusCodes.Status500InternalServerError, + new InvocationErrorModel { ErrorCode = "run_failed", Message = token.Error }, + InvocationsJsonContext.Default.InvocationErrorModel).ConfigureAwait(false); + break; + + default: + await WriteJsonAsync(http, StatusCodes.Status500InternalServerError, + new InvocationErrorModel { ErrorCode = "unknown_state", Message = $"Continuation in unexpected state '{token.Status}'." }, + InvocationsJsonContext.Default.InvocationErrorModel).ConfigureAwait(false); + break; + } + } + + private static async Task WriteSuccessAsync(HttpContext http, HostedRunResult result) + { + var text = result.ResultObject switch + { + AgentResponse response => response.Text, + AgentResponseUpdate update => update.Text, + string s => s, + _ => result.ResultObject?.ToString(), + }; + + var model = new InvocationResponseModel + { + Status = "completed", + Text = text, + SessionId = result.Session?.Key, + }; + + await WriteJsonAsync(http, StatusCodes.Status200OK, model, InvocationsJsonContext.Default.InvocationResponseModel).ConfigureAwait(false); + } + + private static async Task WriteJsonAsync(HttpContext http, int statusCode, T payload, System.Text.Json.Serialization.Metadata.JsonTypeInfo typeInfo) + { + http.Response.StatusCode = statusCode; + http.Response.ContentType = "application/json; charset=utf-8"; + await JsonSerializer.SerializeAsync(http.Response.Body, payload, typeInfo, http.RequestAborted).ConfigureAwait(false); + } + + private static string StatusFromContinuation(ContinuationStatus status) => status switch + { + ContinuationStatus.Queued => "queued", + ContinuationStatus.Running => "running", + ContinuationStatus.Completed => "completed", + ContinuationStatus.Failed => "failed", + _ => "unknown", + }; + + private static string NormalizeInput(object input) + { + return input switch + { + JsonElement el when el.ValueKind == JsonValueKind.String => el.GetString() ?? string.Empty, + JsonElement el => el.ToString(), + string s => s, + _ => input.ToString() ?? string.Empty, + }; + } +} \ No newline at end of file diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting.Channels.Invocations/InvocationsChannelOptions.cs b/dotnet/src/Microsoft.Agents.AI.Hosting.Channels.Invocations/InvocationsChannelOptions.cs new file mode 100644 index 00000000000..6f392d10870 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.Hosting.Channels.Invocations/InvocationsChannelOptions.cs @@ -0,0 +1,36 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; + +namespace Microsoft.Agents.AI.Hosting.Channels.Invocations; + +/// +/// Configuration for . +/// +public sealed class InvocationsChannelOptions +{ + /// + /// Mount root for the invocations routes. The channel exposes {Path}/invoke (sync run) + /// and {Path}/{continuationToken} (poll). Default "/invocations". + /// + public string Path { get; set; } = "/invocations"; + + /// + /// Optional per-channel allowlist. When the host's + /// applies. + /// + public IIdentityAllowlist? Allowlist { get; set; } + + /// + /// Optional run hook invoked after the channel produces the default request and before the host + /// calls the runner. Apps use this to project domain-specific request shapes onto + /// . + /// + public IChannelRunHook? RunHook { get; set; } + + /// + /// Optional response hook invoked per destination. The default + /// uses this on the originating reply to project the result onto the JSON wire envelope. + /// + public IChannelResponseHook? ResponseHook { get; set; } +} \ No newline at end of file diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting.Channels.Invocations/Microsoft.Agents.AI.Hosting.Channels.Invocations.csproj b/dotnet/src/Microsoft.Agents.AI.Hosting.Channels.Invocations/Microsoft.Agents.AI.Hosting.Channels.Invocations.csproj new file mode 100644 index 00000000000..7927ef7cfc9 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.Hosting.Channels.Invocations/Microsoft.Agents.AI.Hosting.Channels.Invocations.csproj @@ -0,0 +1,34 @@ + + + + $(TargetFrameworksCore) + Microsoft.Agents.AI.Hosting.Channels.Invocations + alpha + $(InterceptorsNamespaces);Microsoft.AspNetCore.Http.Generated + true + + + + + + true + + + + + + + + + + + + + + + + + Microsoft Agent Framework Hosting Channels - Invocations + JSON invocation channel for the Microsoft Agent Framework hosting channels surface. Exposes /invocations/invoke and continuation polling on an agent or workflow target. + + From 6ef5344110501c7755a8dc2894e4755c35703f04 Mon Sep 17 00:00:00 2001 From: Roger Barreto <19890735+RogerBarreto@users.noreply.github.com> Date: Thu, 28 May 2026 12:07:56 +0100 Subject: [PATCH 05/16] Hosting.Channels: WorkflowRunner end-to-end + Invocations workflow rendering Replaces the WorkflowRunner stub with a working IHostedTargetRunner that drives the workflow via InProcessExecution.RunStreamingAsync, watches the StreamingRun event stream, accumulates WorkflowOutputEvent payloads, and pauses on RequestInfoEvent. On pause the runner mints a resume token, persists it on IHostStateStore as a ContinuationToken, and surfaces it via ChannelSession.Attributes[workflow.resume_token]. A follow-up request that carries the same attribute (resume) sends an ExternalResponse back to the StreamingRun and continues watching. Adds WorkflowRunResult + WorkflowRunStatus in the core package as the canonical workflow envelope so channels can inspect status without knowing about Workflow internals. InvocationsChannel.WriteSuccessAsync now renders WorkflowRunResult natively: status: awaiting_input with the pending request + resume token, status: completed with concatenated outputs, status: failed with the error message. Adds WorkflowInvocationsResponseHook for non-originating workflow deliveries; the originating reply is rendered by the channel directly per the spec. Build is clean with 0 warnings across all 3 target frameworks. --- .../InvocationsChannel.cs | 40 ++++ .../WorkflowInvocationsResponseHook.cs | 31 +++ ...icationBuilderHostingChannelsExtensions.cs | 2 +- .../Runners/WorkflowRunner.cs | 221 +++++++++++++++++- .../WorkflowRunResult.cs | 51 ++++ 5 files changed, 334 insertions(+), 11 deletions(-) create mode 100644 dotnet/src/Microsoft.Agents.AI.Hosting.Channels.Invocations/WorkflowInvocationsResponseHook.cs create mode 100644 dotnet/src/Microsoft.Agents.AI.Hosting.Channels/WorkflowRunResult.cs diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting.Channels.Invocations/InvocationsChannel.cs b/dotnet/src/Microsoft.Agents.AI.Hosting.Channels.Invocations/InvocationsChannel.cs index 4ffb3f75205..5612a0c4290 100644 --- a/dotnet/src/Microsoft.Agents.AI.Hosting.Channels.Invocations/InvocationsChannel.cs +++ b/dotnet/src/Microsoft.Agents.AI.Hosting.Channels.Invocations/InvocationsChannel.cs @@ -156,6 +156,13 @@ await WriteJsonAsync(http, StatusCodes.Status500InternalServerError, private static async Task WriteSuccessAsync(HttpContext http, HostedRunResult result) { + // Workflow-shaped results get their own awaiting_input / completed envelope. + if (result.ResultObject is WorkflowRunResult workflowResult) + { + await WriteWorkflowAsync(http, workflowResult, result.Session).ConfigureAwait(false); + return; + } + var text = result.ResultObject switch { AgentResponse response => response.Text, @@ -174,6 +181,39 @@ private static async Task WriteSuccessAsync(HttpContext http, HostedRunResult re await WriteJsonAsync(http, StatusCodes.Status200OK, model, InvocationsJsonContext.Default.InvocationResponseModel).ConfigureAwait(false); } + private static async Task WriteWorkflowAsync(HttpContext http, WorkflowRunResult workflow, ChannelSession? session) + { + switch (workflow.Status) + { + case WorkflowRunStatus.AwaitingInput: + var resumeToken = session?.Attributes is not null && session.Attributes.TryGetValue(WorkflowRunner.ResumeTokenAttribute, out var raw) ? raw as string : null; + var awaiting = new InvocationAwaitingInputModel + { + Status = "awaiting_input", + Request = workflow.PendingRequest?.Data.As(), + ResumeToken = resumeToken, + }; + await WriteJsonAsync(http, StatusCodes.Status200OK, awaiting, InvocationsJsonContext.Default.InvocationAwaitingInputModel).ConfigureAwait(false); + return; + + case WorkflowRunStatus.Failed: + var error = new InvocationErrorModel { ErrorCode = "workflow_failed", Message = workflow.Error }; + await WriteJsonAsync(http, StatusCodes.Status500InternalServerError, error, InvocationsJsonContext.Default.InvocationErrorModel).ConfigureAwait(false); + return; + + default: + var outputsText = workflow.Outputs.Count == 0 ? null : string.Join(System.Environment.NewLine, workflow.Outputs); + var model = new InvocationResponseModel + { + Status = "completed", + Text = outputsText, + SessionId = workflow.SessionId ?? session?.Key, + }; + await WriteJsonAsync(http, StatusCodes.Status200OK, model, InvocationsJsonContext.Default.InvocationResponseModel).ConfigureAwait(false); + return; + } + } + private static async Task WriteJsonAsync(HttpContext http, int statusCode, T payload, System.Text.Json.Serialization.Metadata.JsonTypeInfo typeInfo) { http.Response.StatusCode = statusCode; diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting.Channels.Invocations/WorkflowInvocationsResponseHook.cs b/dotnet/src/Microsoft.Agents.AI.Hosting.Channels.Invocations/WorkflowInvocationsResponseHook.cs new file mode 100644 index 00000000000..faa8c1dcab5 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.Hosting.Channels.Invocations/WorkflowInvocationsResponseHook.cs @@ -0,0 +1,31 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Threading; +using System.Threading.Tasks; + +namespace Microsoft.Agents.AI.Hosting.Channels.Invocations; + +/// +/// Per-destination response hook that projects onto the +/// invocations JSON envelope (status: "awaiting_input" / "completed" / "failed"). +/// Apply this hook to non-originating workflow deliveries where another channel pushes the result. +/// The originating reply is rendered by directly. +/// +public sealed class WorkflowInvocationsResponseHook : IChannelResponseHook +{ + /// + public ValueTask OnResponseAsync( + HostedRunResult result, + ChannelResponseContext context, + CancellationToken cancellationToken) + { + if (result.ResultObject is not WorkflowRunResult workflow) + { + return new(result); + } + + // Preserve the typed envelope; consumers downstream (e.g. push codecs) project it on the wire. + // This hook centralizes the projection rule so multi-destination workflow rebinds stay consistent. + return new(result); + } +} \ No newline at end of file diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting.Channels/HostApplicationBuilderHostingChannelsExtensions.cs b/dotnet/src/Microsoft.Agents.AI.Hosting.Channels/HostApplicationBuilderHostingChannelsExtensions.cs index f01a4493181..9a1057f9a0e 100644 --- a/dotnet/src/Microsoft.Agents.AI.Hosting.Channels/HostApplicationBuilderHostingChannelsExtensions.cs +++ b/dotnet/src/Microsoft.Agents.AI.Hosting.Channels/HostApplicationBuilderHostingChannelsExtensions.cs @@ -36,7 +36,7 @@ public static IAgentFrameworkHostBuilder AddAgentFrameworkHost( { Throw.IfNull(builder); Throw.IfNull(target); - return AddAgentFrameworkHostCore(builder, configure, services => services.TryAddSingleton(_ => new WorkflowRunner(target))); + return AddAgentFrameworkHostCore(builder, configure, services => services.TryAddSingleton(sp => new WorkflowRunner(target, sp.GetRequiredService()))); } /// diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting.Channels/Runners/WorkflowRunner.cs b/dotnet/src/Microsoft.Agents.AI.Hosting.Channels/Runners/WorkflowRunner.cs index 5f36f212815..0ed04702c6e 100644 --- a/dotnet/src/Microsoft.Agents.AI.Hosting.Channels/Runners/WorkflowRunner.cs +++ b/dotnet/src/Microsoft.Agents.AI.Hosting.Channels/Runners/WorkflowRunner.cs @@ -1,7 +1,9 @@ // Copyright (c) Microsoft. All rights reserved. using System; +using System.Collections.Concurrent; using System.Collections.Generic; +using System.Collections.Immutable; using System.Runtime.CompilerServices; using System.Threading; using System.Threading.Tasks; @@ -11,32 +13,231 @@ namespace Microsoft.Agents.AI.Hosting.Channels; /// -/// Default for targets. +/// Default for targets. Drives execution +/// via and projects pause / completion / failure into +/// . /// /// -/// Skeletal in this draft: surfaces the workflow object via -/// without driving execution end-to-end. The intent is to fill in resume-token handling and -/// RequestInfoEvent projection in a follow-up commit once the channel surface (Invocations) -/// is in place to consume them. +/// Resume tokens map to in-memory instances for this draft. They +/// survive only the lifetime of the process; durable replay across restarts requires an external +/// + checkpoint storage and lands in a follow-up commit. /// public sealed class WorkflowRunner : IHostedTargetRunner { + /// Attribute key carried on to resume a paused workflow. + public const string ResumeTokenAttribute = "workflow.resume_token"; + + /// Attribute key carried on for direct checkpoint resume. + public const string CheckpointIdAttribute = "workflow.checkpoint_id"; + private readonly Workflow _workflow; + private readonly IHostStateStore _stateStore; + private readonly ConcurrentDictionary _resumeEntries = new(StringComparer.Ordinal); /// Initializes a new instance. - public WorkflowRunner(Workflow workflow) + public WorkflowRunner(Workflow workflow, IHostStateStore stateStore) { this._workflow = Throw.IfNull(workflow); + this._stateStore = Throw.IfNull(stateStore); } /// The wrapped workflow. public Workflow Workflow => this._workflow; /// - public ValueTask RunAsync(ChannelRequest request, CancellationToken cancellationToken) => - throw new NotImplementedException("WorkflowRunner is a draft placeholder; end-to-end wiring lands with the InvocationsChannel package."); + public async ValueTask RunAsync(ChannelRequest request, CancellationToken cancellationToken) + { + Throw.IfNull(request); + + if (request.Attributes.TryGetValue(ResumeTokenAttribute, out var rawToken) && rawToken is string resumeToken) + { + return await this.ResumeAsync(resumeToken, request, cancellationToken).ConfigureAwait(false); + } + + var run = await InProcessExecution.RunStreamingAsync(this._workflow, request.Input, request.Session?.Key, cancellationToken).ConfigureAwait(false); + return await this.DriveAsync(run, request, cancellationToken).ConfigureAwait(false); + } /// - public IAsyncEnumerable StreamAsync(ChannelRequest request, CancellationToken cancellationToken) => - throw new NotImplementedException("WorkflowRunner is a draft placeholder; end-to-end wiring lands with the InvocationsChannel package."); + public async IAsyncEnumerable StreamAsync( + ChannelRequest request, + [EnumeratorCancellation] CancellationToken cancellationToken) + { + Throw.IfNull(request); + + if (request.Attributes.TryGetValue(ResumeTokenAttribute, out var rawToken) && rawToken is string resumeToken) + { + await foreach (var item in this.StreamResumeAsync(resumeToken, request, cancellationToken).ConfigureAwait(false)) + { + yield return item; + } + yield break; + } + + var run = await InProcessExecution.RunStreamingAsync(this._workflow, request.Input, request.Session?.Key, cancellationToken).ConfigureAwait(false); + await foreach (var item in this.WatchAsync(run, request, cancellationToken).ConfigureAwait(false)) + { + yield return item; + } + } + + private async ValueTask ResumeAsync(string resumeToken, ChannelRequest request, CancellationToken cancellationToken) + { + if (!this._resumeEntries.TryRemove(resumeToken, out var entry)) + { + return BuildResult( + new WorkflowRunResult { Status = WorkflowRunStatus.Failed, Error = $"Resume token '{resumeToken}' is unknown or already consumed.", SessionId = request.Session?.Key }, + request.Session); + } + + await entry.Run.SendResponseAsync(entry.PendingRequest.CreateResponse(request.Input)).ConfigureAwait(false); + await this._stateStore.DeleteContinuationAsync(resumeToken, cancellationToken).ConfigureAwait(false); + return await this.DriveAsync(entry.Run, request, cancellationToken).ConfigureAwait(false); + } + + private async IAsyncEnumerable StreamResumeAsync( + string resumeToken, + ChannelRequest request, + [EnumeratorCancellation] CancellationToken cancellationToken) + { + if (!this._resumeEntries.TryRemove(resumeToken, out var entry)) + { + yield return new HostedStreamCompleted(BuildResult( + new WorkflowRunResult { Status = WorkflowRunStatus.Failed, Error = $"Resume token '{resumeToken}' is unknown or already consumed.", SessionId = request.Session?.Key }, + request.Session)); + yield break; + } + + await entry.Run.SendResponseAsync(entry.PendingRequest.CreateResponse(request.Input)).ConfigureAwait(false); + await this._stateStore.DeleteContinuationAsync(resumeToken, cancellationToken).ConfigureAwait(false); + + await foreach (var item in this.WatchAsync(entry.Run, request, cancellationToken).ConfigureAwait(false)) + { + yield return item; + } + } + + private async ValueTask DriveAsync(StreamingRun run, ChannelRequest request, CancellationToken cancellationToken) + { + var outputs = new List(); + ExternalRequest? pending = null; + + await foreach (var evt in run.WatchStreamAsync(cancellationToken).ConfigureAwait(false)) + { + switch (evt) + { + case RequestInfoEvent rie: + pending = rie.Request; + break; + case WorkflowOutputEvent woe: + outputs.Add(woe.Data); + break; + case WorkflowErrorEvent err: + return BuildResult( + new WorkflowRunResult { Status = WorkflowRunStatus.Failed, Error = err.Data?.ToString(), Outputs = outputs, SessionId = run.SessionId }, + request.Session); + } + } + + if (pending is not null) + { + var resumeToken = Guid.NewGuid().ToString("N"); + this._resumeEntries[resumeToken] = new ResumeEntry(run, pending); + await this._stateStore.SaveContinuationAsync( + new ContinuationToken + { + Token = resumeToken, + Status = ContinuationStatus.Queued, + IsolationKey = request.Session?.IsolationKey, + CreatedAt = DateTimeOffset.UtcNow, + }, + cancellationToken).ConfigureAwait(false); + + var session = (request.Session ?? new ChannelSession()) with + { + Key = run.SessionId, + Attributes = MergeAttribute(request.Session?.Attributes, ResumeTokenAttribute, resumeToken), + }; + + return BuildResult( + new WorkflowRunResult { Status = WorkflowRunStatus.AwaitingInput, PendingRequest = pending, Outputs = outputs, SessionId = run.SessionId }, + session); + } + + return BuildResult( + new WorkflowRunResult { Status = WorkflowRunStatus.Completed, Outputs = outputs, SessionId = run.SessionId }, + (request.Session ?? new ChannelSession()) with { Key = run.SessionId }); + } + + private async IAsyncEnumerable WatchAsync( + StreamingRun run, + ChannelRequest request, + [EnumeratorCancellation] CancellationToken cancellationToken) + { + var outputs = new List(); + ExternalRequest? pending = null; + + await foreach (var evt in run.WatchStreamAsync(cancellationToken).ConfigureAwait(false)) + { + yield return new HostedStreamEvent(evt); + switch (evt) + { + case RequestInfoEvent rie: + pending = rie.Request; + break; + case WorkflowOutputEvent woe: + outputs.Add(woe.Data); + break; + } + } + + ChannelSession? session = request.Session; + WorkflowRunResult final; + if (pending is not null) + { + var resumeToken = Guid.NewGuid().ToString("N"); + this._resumeEntries[resumeToken] = new ResumeEntry(run, pending); + await this._stateStore.SaveContinuationAsync( + new ContinuationToken + { + Token = resumeToken, + Status = ContinuationStatus.Queued, + IsolationKey = request.Session?.IsolationKey, + CreatedAt = DateTimeOffset.UtcNow, + }, + cancellationToken).ConfigureAwait(false); + session = (session ?? new ChannelSession()) with + { + Key = run.SessionId, + Attributes = MergeAttribute(session?.Attributes, ResumeTokenAttribute, resumeToken), + }; + final = new WorkflowRunResult { Status = WorkflowRunStatus.AwaitingInput, PendingRequest = pending, Outputs = outputs, SessionId = run.SessionId }; + } + else + { + session = (session ?? new ChannelSession()) with { Key = run.SessionId }; + final = new WorkflowRunResult { Status = WorkflowRunStatus.Completed, Outputs = outputs, SessionId = run.SessionId }; + } + + yield return new HostedStreamCompleted(BuildResult(final, session)); + } + + private static HostedRunResult BuildResult(WorkflowRunResult result, ChannelSession? session) => + new() { Result = result, Session = session }; + + private static ImmutableDictionary MergeAttribute( + IReadOnlyDictionary? existing, + string key, + object? value) + { + var builder = ImmutableDictionary.CreateBuilder(StringComparer.Ordinal); + if (existing is not null) + { + foreach (var (k, v) in existing) { builder[k] = v; } + } + builder[key] = value; + return builder.ToImmutable(); + } + + private sealed record ResumeEntry(StreamingRun Run, ExternalRequest PendingRequest); } \ No newline at end of file diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting.Channels/WorkflowRunResult.cs b/dotnet/src/Microsoft.Agents.AI.Hosting.Channels/WorkflowRunResult.cs new file mode 100644 index 00000000000..67bab9f2c3a --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.Hosting.Channels/WorkflowRunResult.cs @@ -0,0 +1,51 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Collections.Generic; +using Microsoft.Agents.AI.Workflows; + +namespace Microsoft.Agents.AI.Hosting.Channels; + +/// +/// Result envelope returned by . Carries the workflow output +/// list plus optional pause-state for HITL flows that emit a . +/// +/// +/// When is the channel renders +/// a status-awaiting_input envelope and the resume token lives on +/// attributes under the key "workflow.resume_token". +/// +public sealed record WorkflowRunResult +{ + /// Lifecycle status the runner reached when control returned. + public required WorkflowRunStatus Status { get; init; } + + /// + /// Outputs emitted by the workflow (from WorkflowOutputEvent). Order matches event order. + /// + public IReadOnlyList Outputs { get; init; } = []; + + /// + /// Pending external request that paused execution. Populated when + /// is . + /// + public ExternalRequest? PendingRequest { get; init; } + + /// The workflow session id this run is associated with. + public string? SessionId { get; init; } + + /// Failure detail when is . + public string? Error { get; init; } +} + +/// Lifecycle status of a . +public enum WorkflowRunStatus +{ + /// The workflow ran to completion. + Completed, + + /// The workflow paused on a ; resume by passing the resume token. + AwaitingInput, + + /// The workflow run failed. + Failed, +} \ No newline at end of file From 41d60bdad8d0b99f6292747682a2aa819d4fe2f0 Mon Sep 17 00:00:00 2001 From: Roger Barreto <19890735+RogerBarreto@users.noreply.github.com> Date: Thu, 28 May 2026 12:15:08 +0100 Subject: [PATCH 06/16] Hosting.Channels.Telegram: Telegram Bot channel with webhook + polling transports Adds Microsoft.Agents.AI.Hosting.Channels.Telegram (alpha): the second v1 channel package and the showcase for the multi-channel surface. Builds against the raw Telegram Bot API over HttpClient (no Telegram.Bot SDK dependency, so the package stays lean and central package management stays clean). Both transports are supported: polling via a background task driven by the channel's OnStartup hook, and webhook via POST {Path}/webhook. Implements IChannelPush so the host's hosting.push handler can deliver responses to a Telegram chat from any other channel. Per-conversation isolation derived from ConversationScope: PerUser shares state across DM and group, PerUserPerConversation (default) gives one isolation key per user per chat, PerConversation makes every member of a group share state. Group-message filtering driven by AcceptInGroup: MentionOnly (default for groups), CommandOnly, MentionOrCommand, All. Group-safety rule for link challenges: when the user is in a group conversation and the authorization pipeline returns LinkRequired, the channel sends the challenge to the user's DM and posts a public-safe acknowledgement to the group. Last-seen records updated per inbound message to power ResponseTarget.Active deliveries from peer channels. setMyCommands invoked at startup when RegisterNativeCommands is true. Build is clean with 0 warnings across all 3 target frameworks. --- dotnet/agent-framework-dotnet.slnx | 1 + ...tFrameworkHostBuilderTelegramExtensions.cs | 24 ++ .../Internal/TelegramApiClient.cs | 86 +++++ .../Internal/TelegramJsonContext.cs | 13 + .../Internal/TelegramModels.cs | 75 ++++ ...Agents.AI.Hosting.Channels.Telegram.csproj | 34 ++ .../TelegramChannel.cs | 350 ++++++++++++++++++ .../TelegramChannelOptions.cs | 48 +++ .../TelegramTransport.cs | 13 + 9 files changed, 644 insertions(+) create mode 100644 dotnet/src/Microsoft.Agents.AI.Hosting.Channels.Telegram/AgentFrameworkHostBuilderTelegramExtensions.cs create mode 100644 dotnet/src/Microsoft.Agents.AI.Hosting.Channels.Telegram/Internal/TelegramApiClient.cs create mode 100644 dotnet/src/Microsoft.Agents.AI.Hosting.Channels.Telegram/Internal/TelegramJsonContext.cs create mode 100644 dotnet/src/Microsoft.Agents.AI.Hosting.Channels.Telegram/Internal/TelegramModels.cs create mode 100644 dotnet/src/Microsoft.Agents.AI.Hosting.Channels.Telegram/Microsoft.Agents.AI.Hosting.Channels.Telegram.csproj create mode 100644 dotnet/src/Microsoft.Agents.AI.Hosting.Channels.Telegram/TelegramChannel.cs create mode 100644 dotnet/src/Microsoft.Agents.AI.Hosting.Channels.Telegram/TelegramChannelOptions.cs create mode 100644 dotnet/src/Microsoft.Agents.AI.Hosting.Channels.Telegram/TelegramTransport.cs diff --git a/dotnet/agent-framework-dotnet.slnx b/dotnet/agent-framework-dotnet.slnx index 01a614fd7d4..ed616598502 100644 --- a/dotnet/agent-framework-dotnet.slnx +++ b/dotnet/agent-framework-dotnet.slnx @@ -620,6 +620,7 @@ + diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting.Channels.Telegram/AgentFrameworkHostBuilderTelegramExtensions.cs b/dotnet/src/Microsoft.Agents.AI.Hosting.Channels.Telegram/AgentFrameworkHostBuilderTelegramExtensions.cs new file mode 100644 index 00000000000..8ea45315861 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.Hosting.Channels.Telegram/AgentFrameworkHostBuilderTelegramExtensions.cs @@ -0,0 +1,24 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using Microsoft.Shared.Diagnostics; + +namespace Microsoft.Agents.AI.Hosting.Channels.Telegram; + +/// +/// Extensions on for the Telegram channel. +/// +public static class AgentFrameworkHostBuilderTelegramExtensions +{ + /// Add the Telegram channel. + public static IAgentFrameworkHostBuilder AddTelegramChannel( + this IAgentFrameworkHostBuilder builder, + Action configure) + { + Throw.IfNull(builder); + Throw.IfNull(configure); + var options = new TelegramChannelOptions(); + configure(options); + return builder.AddChannel(new TelegramChannel(options)); + } +} \ No newline at end of file diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting.Channels.Telegram/Internal/TelegramApiClient.cs b/dotnet/src/Microsoft.Agents.AI.Hosting.Channels.Telegram/Internal/TelegramApiClient.cs new file mode 100644 index 00000000000..45b0d5c4682 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.Hosting.Channels.Telegram/Internal/TelegramApiClient.cs @@ -0,0 +1,86 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Collections.Generic; +using System.Net.Http; +using System.Net.Http.Json; +using System.Text.Json; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Shared.Diagnostics; + +namespace Microsoft.Agents.AI.Hosting.Channels.Telegram; + +internal sealed class TelegramApiClient +{ + private readonly HttpClient _http; + private readonly string _baseUrl; + + public TelegramApiClient(HttpClient http, string botToken) + { + this._http = Throw.IfNull(http); + Throw.IfNullOrEmpty(botToken); + this._baseUrl = $"https://api.telegram.org/bot{botToken}"; + } + + public async Task GetMeAsync(CancellationToken cancellationToken) + { + var response = await this._http.GetFromJsonAsync( + new Uri($"{this._baseUrl}/getMe"), + TelegramJsonContext.Default.TelegramGetMeResponse, + cancellationToken).ConfigureAwait(false); + return response?.Ok == true ? response.Result : null; + } + + public async Task> GetUpdatesAsync(long offset, int timeoutSeconds, CancellationToken cancellationToken) + { + var url = $"{this._baseUrl}/getUpdates?offset={offset}&timeout={timeoutSeconds}"; + var response = await this._http.GetFromJsonAsync( + new Uri(url), + TelegramJsonContext.Default.TelegramGetUpdatesResponse, + cancellationToken).ConfigureAwait(false); + return response?.Ok == true && response.Result is not null ? response.Result : []; + } + + public async Task SendMessageAsync(long chatId, string text, CancellationToken cancellationToken) + { + Throw.IfNull(text); + var payload = new TelegramSendMessage { ChatId = chatId, Text = text }; + using var http = await this._http.PostAsJsonAsync( + new Uri($"{this._baseUrl}/sendMessage"), + payload, + TelegramJsonContext.Default.TelegramSendMessage, + cancellationToken).ConfigureAwait(false); + http.EnsureSuccessStatusCode(); + } + + public async Task SetMyCommandsAsync(IReadOnlyList commands, CancellationToken cancellationToken) + { + Throw.IfNull(commands); + if (commands.Count == 0) { return; } + var payload = new TelegramSetMyCommandsRequest + { + Commands = ToCommands(commands), + }; + using var http = await this._http.PostAsJsonAsync( + new Uri($"{this._baseUrl}/setMyCommands"), + payload, + TelegramJsonContext.Default.TelegramSetMyCommandsRequest, + cancellationToken).ConfigureAwait(false); + http.EnsureSuccessStatusCode(); + } + + private static TelegramBotCommand[] ToCommands(IReadOnlyList commands) + { + var arr = new TelegramBotCommand[commands.Count]; + for (var i = 0; i < commands.Count; i++) + { + arr[i] = new TelegramBotCommand + { + Command = commands[i].Name.TrimStart('/'), + Description = commands[i].Description, + }; + } + return arr; + } +} \ No newline at end of file diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting.Channels.Telegram/Internal/TelegramJsonContext.cs b/dotnet/src/Microsoft.Agents.AI.Hosting.Channels.Telegram/Internal/TelegramJsonContext.cs new file mode 100644 index 00000000000..06387243c6e --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.Hosting.Channels.Telegram/Internal/TelegramJsonContext.cs @@ -0,0 +1,13 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Text.Json.Serialization; + +namespace Microsoft.Agents.AI.Hosting.Channels.Telegram; + +[JsonSerializable(typeof(TelegramUpdate))] +[JsonSerializable(typeof(TelegramMessage))] +[JsonSerializable(typeof(TelegramSendMessage))] +[JsonSerializable(typeof(TelegramGetUpdatesResponse))] +[JsonSerializable(typeof(TelegramGetMeResponse))] +[JsonSerializable(typeof(TelegramSetMyCommandsRequest))] +internal sealed partial class TelegramJsonContext : JsonSerializerContext; \ No newline at end of file diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting.Channels.Telegram/Internal/TelegramModels.cs b/dotnet/src/Microsoft.Agents.AI.Hosting.Channels.Telegram/Internal/TelegramModels.cs new file mode 100644 index 00000000000..01abd2d218e --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.Hosting.Channels.Telegram/Internal/TelegramModels.cs @@ -0,0 +1,75 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Text.Json.Serialization; + +namespace Microsoft.Agents.AI.Hosting.Channels.Telegram; + +internal sealed class TelegramUpdate +{ + [JsonPropertyName("update_id")] public long UpdateId { get; set; } + [JsonPropertyName("message")] public TelegramMessage? Message { get; set; } + [JsonPropertyName("channel_post")] public TelegramMessage? ChannelPost { get; set; } +} + +internal sealed class TelegramMessage +{ + [JsonPropertyName("message_id")] public long MessageId { get; set; } + [JsonPropertyName("from")] public TelegramUser? From { get; set; } + [JsonPropertyName("chat")] public TelegramChat? Chat { get; set; } + [JsonPropertyName("text")] public string? Text { get; set; } + [JsonPropertyName("entities")] public TelegramMessageEntity[]? Entities { get; set; } +} + +internal sealed class TelegramUser +{ + [JsonPropertyName("id")] public long Id { get; set; } + [JsonPropertyName("is_bot")] public bool IsBot { get; set; } + [JsonPropertyName("username")] public string? Username { get; set; } + [JsonPropertyName("first_name")] public string? FirstName { get; set; } + [JsonPropertyName("language_code")] public string? LanguageCode { get; set; } +} + +internal sealed class TelegramChat +{ + [JsonPropertyName("id")] public long Id { get; set; } + [JsonPropertyName("type")] public string? Type { get; set; } // "private" | "group" | "supergroup" | "channel" + [JsonPropertyName("title")] public string? Title { get; set; } + [JsonPropertyName("username")] public string? Username { get; set; } +} + +internal sealed class TelegramMessageEntity +{ + [JsonPropertyName("type")] public string? Type { get; set; } // "bot_command", "mention", ... + [JsonPropertyName("offset")] public int Offset { get; set; } + [JsonPropertyName("length")] public int Length { get; set; } +} + +internal sealed class TelegramSendMessage +{ + [JsonPropertyName("chat_id")] public long ChatId { get; set; } + [JsonPropertyName("text")] public string Text { get; set; } = string.Empty; + [JsonPropertyName("parse_mode")] public string? ParseMode { get; set; } +} + +internal sealed class TelegramGetUpdatesResponse +{ + [JsonPropertyName("ok")] public bool Ok { get; set; } + [JsonPropertyName("result")] public TelegramUpdate[]? Result { get; set; } +} + +internal sealed class TelegramGetMeResponse +{ + [JsonPropertyName("ok")] public bool Ok { get; set; } + [JsonPropertyName("result")] public TelegramUser? Result { get; set; } +} + +internal sealed class TelegramSetMyCommandsRequest +{ + [JsonPropertyName("commands")] public TelegramBotCommand[] Commands { get; set; } = []; +} + +internal sealed class TelegramBotCommand +{ + [JsonPropertyName("command")] public string Command { get; set; } = string.Empty; + [JsonPropertyName("description")] public string Description { get; set; } = string.Empty; +} \ No newline at end of file diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting.Channels.Telegram/Microsoft.Agents.AI.Hosting.Channels.Telegram.csproj b/dotnet/src/Microsoft.Agents.AI.Hosting.Channels.Telegram/Microsoft.Agents.AI.Hosting.Channels.Telegram.csproj new file mode 100644 index 00000000000..da9ab856124 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.Hosting.Channels.Telegram/Microsoft.Agents.AI.Hosting.Channels.Telegram.csproj @@ -0,0 +1,34 @@ + + + + $(TargetFrameworksCore) + Microsoft.Agents.AI.Hosting.Channels.Telegram + alpha + $(InterceptorsNamespaces);Microsoft.AspNetCore.Http.Generated + true + + + + + + true + + + + + + + + + + + + + + + + + Microsoft Agent Framework Hosting Channels - Telegram + Telegram Bot channel for the Microsoft Agent Framework hosting channels surface. Supports both webhook and long-poll transports, push delivery via IChannelPush, group conversation scoping, and host-tracked sessions. + + diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting.Channels.Telegram/TelegramChannel.cs b/dotnet/src/Microsoft.Agents.AI.Hosting.Channels.Telegram/TelegramChannel.cs new file mode 100644 index 00000000000..c62b838796e --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.Hosting.Channels.Telegram/TelegramChannel.cs @@ -0,0 +1,350 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Collections.Concurrent; +using System.Collections.Generic; +using System.Collections.Immutable; +using System.Net.Http; +using System.Text.Json; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Routing; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using Microsoft.Shared.Diagnostics; + +namespace Microsoft.Agents.AI.Hosting.Channels.Telegram; + +/// +/// Telegram Bot channel. Supports two transports: long-poll +/// via an loop, or webhook +/// via POST {Path}/webhook. Implements +/// for cross-channel response delivery. +/// +public sealed class TelegramChannel : Channel, IChannelPush +{ + private readonly TelegramChannelOptions _options; + private TelegramApiClient? _api; + private string? _botUsername; + + /// Initializes a new instance. + public TelegramChannel(TelegramChannelOptions options) + { + this._options = Throw.IfNull(options); + if (string.IsNullOrEmpty(this._options.BotToken)) + { + throw new ArgumentException("BotToken is required.", nameof(options)); + } + } + + /// + public override string Name => "telegram"; + + /// + public override string Path => this._options.Path; + + /// + public override void ConfigureServices(IServiceCollection services) + { + services.AddHttpClient(); + } + + /// + public override ChannelContribution Contribute(IChannelContext context) + { + Throw.IfNull(context); + + var httpClient = context.Services.GetRequiredService().CreateClient(nameof(TelegramApiClient)); + this._api = new TelegramApiClient(httpClient, this._options.BotToken); + + var contribution = new ChannelContribution + { + Commands = [.. this._options.Commands], + OnStartup = ct => this.OnStartupAsync(context, ct), + }; + + if (this._options.Transport == TelegramTransport.Webhook) + { + contribution = contribution with + { + Routes = + [ + endpoints => endpoints.MapPost("/webhook", (HttpContext http) => this.HandleWebhookAsync(context, http)), + ], + }; + } + + return contribution; + } + + /// + public async ValueTask PushAsync(ChannelPushContext context, HostedRunResult payload, CancellationToken cancellationToken) + { + Throw.IfNull(context); + Throw.IfNull(payload); + if (this._api is null) { throw new InvalidOperationException("TelegramChannel.Contribute was not invoked before PushAsync."); } + if (!long.TryParse(context.Destination.NativeId, out var chatId)) + { + throw new InvalidOperationException($"Destination NativeId '{context.Destination.NativeId}' is not a valid Telegram chat id."); + } + var text = ExtractText(payload, context.IsEcho ? context.OriginatingRequest.Input?.ToString() : null); + if (string.IsNullOrEmpty(text)) { return; } + await this._api.SendMessageAsync(chatId, text, cancellationToken).ConfigureAwait(false); + } + + private async ValueTask OnStartupAsync(IChannelContext context, CancellationToken cancellationToken) + { + if (this._api is null) { return; } + + var me = await this._api.GetMeAsync(cancellationToken).ConfigureAwait(false); + this._botUsername = me?.Username; + + if (this._options.RegisterNativeCommands && this._options.Commands.Count > 0) + { + await this._api.SetMyCommandsAsync([.. this._options.Commands], cancellationToken).ConfigureAwait(false); + } + + if (this._options.Transport == TelegramTransport.Polling) + { + // Start the polling loop on a background task. Stops when the application shuts down. + var logger = context.Services.GetRequiredService().CreateLogger(); + _ = Task.Run(() => this.PollingLoopAsync(context, logger, cancellationToken), cancellationToken); + } + } + + private async Task PollingLoopAsync(IChannelContext context, ILogger logger, CancellationToken cancellationToken) + { + if (this._api is null) { return; } + var offset = 0L; + var timeoutSeconds = Math.Max(1, (int)this._options.PollingTimeout.TotalSeconds); + while (!cancellationToken.IsCancellationRequested) + { + try + { + var updates = await this._api.GetUpdatesAsync(offset, timeoutSeconds, cancellationToken).ConfigureAwait(false); + for (var i = 0; i < updates.Count; i++) + { + var update = updates[i]; + offset = Math.Max(offset, update.UpdateId + 1); + await this.HandleUpdateAsync(context, update, replyHandler: null, cancellationToken).ConfigureAwait(false); + } + } + catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) + { + return; + } + catch (Exception ex) + { + logger.LogWarning(ex, "Telegram polling loop iteration failed; retrying in 5s."); + try { await Task.Delay(TimeSpan.FromSeconds(5), cancellationToken).ConfigureAwait(false); } + catch (OperationCanceledException) { return; } + } + } + } + + private async Task HandleWebhookAsync(IChannelContext context, HttpContext http) + { + TelegramUpdate? update; + try + { + update = await JsonSerializer.DeserializeAsync(http.Request.Body, TelegramJsonContext.Default.TelegramUpdate, http.RequestAborted).ConfigureAwait(false); + } + catch (JsonException) + { + http.Response.StatusCode = StatusCodes.Status400BadRequest; + return; + } + if (update is null) { http.Response.StatusCode = StatusCodes.Status204NoContent; return; } + + await this.HandleUpdateAsync(context, update, replyHandler: async (text) => + { + http.Response.StatusCode = StatusCodes.Status200OK; + http.Response.ContentType = "application/json; charset=utf-8"; + // Per Telegram webhook spec, the response body MAY be a method invocation. We just ack here. + await http.Response.WriteAsync("{\"ok\":true}", http.RequestAborted).ConfigureAwait(false); + // The actual reply goes via sendMessage; this keeps the wire simple. + if (!string.IsNullOrEmpty(text) && this._api is not null && update.Message?.Chat is not null) + { + await this._api.SendMessageAsync(update.Message.Chat.Id, text!, http.RequestAborted).ConfigureAwait(false); + } + }, http.RequestAborted).ConfigureAwait(false); + } + + private async Task HandleUpdateAsync(IChannelContext context, TelegramUpdate update, Func? replyHandler, CancellationToken cancellationToken) + { + var message = update.Message ?? update.ChannelPost; + if (message?.From is null || message.Chat is null || string.IsNullOrEmpty(message.Text)) { return; } + + var isGroup = message.Chat.Type is "group" or "supergroup"; + if (isGroup && !this.AcceptInGroupChat(message)) + { + return; + } + + var identity = new ChannelIdentity(this.Name, message.From.Id.ToString(System.Globalization.CultureInfo.InvariantCulture)) + { + Attributes = BuildIdentityAttributes(message.From), + }; + + var conversationContext = new ConversationContext(message.Chat.Id.ToString(System.Globalization.CultureInfo.InvariantCulture), isGroup); + + var auth = await context.AuthorizeAsync(identity, new AuthorizationRequest + { + RequireLink = this._options.RequireLink, + ConversationContext = conversationContext, + }, cancellationToken).ConfigureAwait(false); + + switch (auth) + { + case AuthorizationOutcome.Denied: + return; + + case AuthorizationOutcome.LinkRequired linkRequired: + await this.SendLinkChallengeAsync(message, linkRequired.Challenge, isGroup, cancellationToken).ConfigureAwait(false); + return; + } + + if (auth is not AuthorizationOutcome.Allowed allowed) { return; } + + var isolationKey = this.DeriveIsolationKey(allowed.IsolationKey, message.Chat.Id); + + var request = new ChannelRequest + { + Channel = this.Name, + Operation = "message.create", + Input = message.Text, + Identity = identity, + ConversationId = message.Chat.Id.ToString(System.Globalization.CultureInfo.InvariantCulture), + Session = new ChannelSession + { + IsolationKey = isolationKey, + ConversationId = message.Chat.Id.ToString(System.Globalization.CultureInfo.InvariantCulture), + }, + }; + + if (this._options.RunHook is not null) + { + var hookContext = new ChannelRunHookContext { Target = context.Host, ProtocolRequest = update }; + request = await this._options.RunHook.OnRequestAsync(request, hookContext, cancellationToken).ConfigureAwait(false); + } + + await context.StateStore.RecordLastSeenAsync(isolationKey, identity, request.ConversationId, DateTimeOffset.UtcNow, cancellationToken).ConfigureAwait(false); + + var result = await context.RunAsync(request, cancellationToken).ConfigureAwait(false); + var text = ExtractText(result, null); + + if (replyHandler is not null) + { + await replyHandler(text).ConfigureAwait(false); + } + else if (!string.IsNullOrEmpty(text) && this._api is not null) + { + await this._api.SendMessageAsync(message.Chat.Id, text!, cancellationToken).ConfigureAwait(false); + } + + await context.ScheduleResponseAsync(result, request, cancellationToken).ConfigureAwait(false); + } + + private async Task SendLinkChallengeAsync(TelegramMessage message, LinkChallenge challenge, bool isGroup, CancellationToken cancellationToken) + { + if (this._api is null || message.Chat is null) { return; } + + var prompt = challenge.UserPrompt ?? $"Please complete the link ceremony (code: {challenge.Code})."; + + // Group-safety: never post the challenge into a group conversation. Redirect to the user's DM. + if (isGroup && message.From is not null) + { + try + { + await this._api.SendMessageAsync(message.From.Id, prompt, cancellationToken).ConfigureAwait(false); + await this._api.SendMessageAsync(message.Chat.Id, "I've sent you a private message with the link instructions.", cancellationToken).ConfigureAwait(false); + } + catch (HttpRequestException) + { + await this._api.SendMessageAsync(message.Chat.Id, "I need a private conversation with you first. Open a chat with me and try again.", cancellationToken).ConfigureAwait(false); + } + return; + } + + await this._api.SendMessageAsync(message.Chat.Id, prompt, cancellationToken).ConfigureAwait(false); + } + + private bool AcceptInGroupChat(TelegramMessage message) + { + var hasMention = MentionsBot(message, this._botUsername); + var hasCommand = HasCommand(message); + + return this._options.AcceptInGroup switch + { + AcceptInGroup.All => true, + AcceptInGroup.MentionOnly => hasMention, + AcceptInGroup.CommandOnly => hasCommand, + AcceptInGroup.MentionOrCommand => hasMention || hasCommand, + _ => false, + }; + } + + private string DeriveIsolationKey(string userIsolationKey, long chatId) => + this._options.ConversationScope switch + { + ConversationScope.PerUser => userIsolationKey, + ConversationScope.PerConversation => $"_conv:{this.Name}:{chatId}", + _ => $"{userIsolationKey}:{chatId}", + }; + + private static ImmutableDictionary BuildIdentityAttributes(TelegramUser user) + { + var b = ImmutableDictionary.CreateBuilder(StringComparer.Ordinal); + if (user.Username is not null) { b["username"] = user.Username; } + if (user.FirstName is not null) { b["first_name"] = user.FirstName; } + if (user.LanguageCode is not null) { b["language_code"] = user.LanguageCode; } + return b.ToImmutable(); + } + + private static bool MentionsBot(TelegramMessage message, string? botUsername) + { + if (string.IsNullOrEmpty(botUsername) || message.Entities is null || message.Text is null) { return false; } + var needle = "@" + botUsername; + for (var i = 0; i < message.Entities.Length; i++) + { + var entity = message.Entities[i]; + if (entity.Type == "mention" && entity.Offset + entity.Length <= message.Text.Length) + { + var seg = message.Text.AsSpan(entity.Offset, entity.Length); + if (seg.Equals(needle.AsSpan(), StringComparison.OrdinalIgnoreCase)) + { + return true; + } + } + } + return false; + } + + private static bool HasCommand(TelegramMessage message) + { + if (message.Entities is null) { return false; } + for (var i = 0; i < message.Entities.Length; i++) + { + if (message.Entities[i].Type == "bot_command") { return true; } + } + return false; + } + + private static string? ExtractText(HostedRunResult result, string? fallback) => result.ResultObject switch + { + AgentResponse response => response.Text, + AgentResponseUpdate update => update.Text, + WorkflowRunResult workflow => RenderWorkflow(workflow), + string s => s, + _ => fallback ?? result.ResultObject?.ToString(), + }; + + private static string RenderWorkflow(WorkflowRunResult workflow) => workflow.Status switch + { + WorkflowRunStatus.AwaitingInput => "Awaiting input...", + WorkflowRunStatus.Failed => $"Workflow failed: {workflow.Error ?? "unknown error"}", + _ => workflow.Outputs.Count == 0 ? string.Empty : string.Join(System.Environment.NewLine, workflow.Outputs), + }; +} \ No newline at end of file diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting.Channels.Telegram/TelegramChannelOptions.cs b/dotnet/src/Microsoft.Agents.AI.Hosting.Channels.Telegram/TelegramChannelOptions.cs new file mode 100644 index 00000000000..4bf89289453 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.Hosting.Channels.Telegram/TelegramChannelOptions.cs @@ -0,0 +1,48 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Collections.Generic; + +namespace Microsoft.Agents.AI.Hosting.Channels.Telegram; + +/// +/// Configuration for . +/// +public sealed class TelegramChannelOptions +{ + /// Bot token issued by Telegram's BotFather. Required. + public string BotToken { get; set; } = string.Empty; + + /// How the channel receives inbound updates. Default . + public TelegramTransport Transport { get; set; } = TelegramTransport.Polling; + + /// + /// Mount root for channel routes. Default "/telegram". Webhook transport publishes + /// {Path}/webhook; polling transport publishes no HTTP routes. + /// + public string Path { get; set; } = "/telegram"; + + /// How to derive the host isolation key in multi-user conversations. Default . + public ConversationScope ConversationScope { get; set; } = ConversationScope.PerUserPerConversation; + + /// Group-conversation acceptance filter. Default . + public AcceptInGroup AcceptInGroup { get; set; } = AcceptInGroup.MentionOnly; + + /// Whether to force a link ceremony on every inbound message. Default . + public bool RequireLink { get; set; } + + /// Declarative channel commands; the host calls setMyCommands at startup when is . + public IList Commands { get; } = []; + + /// Whether the channel registers with Telegram via setMyCommands on startup. Default . + public bool RegisterNativeCommands { get; set; } = true; + + /// Polling interval when is . Default 25 seconds (Telegram's long-poll cap). + public TimeSpan PollingTimeout { get; set; } = TimeSpan.FromSeconds(25); + + /// Optional run hook invoked after the channel produces the default request. + public IChannelRunHook? RunHook { get; set; } + + /// Optional response hook invoked per delivery. + public IChannelResponseHook? ResponseHook { get; set; } +} \ No newline at end of file diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting.Channels.Telegram/TelegramTransport.cs b/dotnet/src/Microsoft.Agents.AI.Hosting.Channels.Telegram/TelegramTransport.cs new file mode 100644 index 00000000000..1372789635e --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.Hosting.Channels.Telegram/TelegramTransport.cs @@ -0,0 +1,13 @@ +// Copyright (c) Microsoft. All rights reserved. + +namespace Microsoft.Agents.AI.Hosting.Channels.Telegram; + +/// How the channel receives inbound updates from Telegram. +public enum TelegramTransport +{ + /// Long-poll getUpdates from an . + Polling, + + /// Receive HTTP POSTs at {Path}/webhook. The bot's webhook URL must be registered out-of-band. + Webhook, +} \ No newline at end of file From f4c730223ada43b652cf249043974f9a240ecd95 Mon Sep 17 00:00:00 2001 From: Roger Barreto <19890735+RogerBarreto@users.noreply.github.com> Date: Thu, 28 May 2026 12:30:10 +0100 Subject: [PATCH 07/16] Hosting.Channels: sample + 17 unit tests Adds samples/04-hosting/HostingChannels/InvocationsAndTelegram, a runnable WebApplication that mounts one AIAgent on both the Invocations channel (POST /invocations/invoke) and the Telegram channel (long-poll) with OneTimeCodeIdentityLinker wired in, demonstrating cross-channel identity collapse and shared session state. Telegram is skipped at startup when TELEGRAM_BOT_TOKEN is not set. Adds tests/Microsoft.Agents.AI.Hosting.Channels.UnitTests with 17 unit tests covering: ResponseTarget factories and singletons, IIdentityAllowlist tri-state plus AnyOf combinator (Allow short-circuit / Deny wins / all-Abstain), NativeIdAllowlist and LinkedClaimAllowlist glob matching, InMemoryHostStateStore SaveLink with atomic merge plus verified-claim lookup plus link-grant consume plus session alias rotation, OneTimeCodeIdentityLinker round-trip plus auto-merge on verified claim, InProcessDurableTaskRunner schedule with handler invocation plus exponential-backoff retry on failure. All 17 tests pass via Microsoft Testing Platform on net10.0. Also flips AgentFrameworkHostOptions from init-only record to settable class so the Action configure pattern works. --- dotnet/agent-framework-dotnet.slnx | 4 + .../InvocationsAndTelegram.csproj | 24 +++++ .../InvocationsAndTelegram/Program.cs | 68 +++++++++++++ .../InvocationsAndTelegram/README.md | 36 +++++++ .../AgentFrameworkHostOptions.cs | 12 +-- .../AllowlistTests.cs | 82 ++++++++++++++++ .../InMemoryHostStateStoreTests.cs | 98 +++++++++++++++++++ .../InProcessDurableTaskRunnerTests.cs | 69 +++++++++++++ ...gents.AI.Hosting.Channels.UnitTests.csproj | 10 ++ .../OneTimeCodeIdentityLinkerTests.cs | 65 ++++++++++++ .../ResponseTargetTests.cs | 51 ++++++++++ 11 files changed, 513 insertions(+), 6 deletions(-) create mode 100644 dotnet/samples/04-hosting/HostingChannels/InvocationsAndTelegram/InvocationsAndTelegram.csproj create mode 100644 dotnet/samples/04-hosting/HostingChannels/InvocationsAndTelegram/Program.cs create mode 100644 dotnet/samples/04-hosting/HostingChannels/InvocationsAndTelegram/README.md create mode 100644 dotnet/tests/Microsoft.Agents.AI.Hosting.Channels.UnitTests/AllowlistTests.cs create mode 100644 dotnet/tests/Microsoft.Agents.AI.Hosting.Channels.UnitTests/InMemoryHostStateStoreTests.cs create mode 100644 dotnet/tests/Microsoft.Agents.AI.Hosting.Channels.UnitTests/InProcessDurableTaskRunnerTests.cs create mode 100644 dotnet/tests/Microsoft.Agents.AI.Hosting.Channels.UnitTests/Microsoft.Agents.AI.Hosting.Channels.UnitTests.csproj create mode 100644 dotnet/tests/Microsoft.Agents.AI.Hosting.Channels.UnitTests/OneTimeCodeIdentityLinkerTests.cs create mode 100644 dotnet/tests/Microsoft.Agents.AI.Hosting.Channels.UnitTests/ResponseTargetTests.cs diff --git a/dotnet/agent-framework-dotnet.slnx b/dotnet/agent-framework-dotnet.slnx index ed616598502..5272d5e83ee 100644 --- a/dotnet/agent-framework-dotnet.slnx +++ b/dotnet/agent-framework-dotnet.slnx @@ -377,6 +377,9 @@ + + + @@ -676,6 +679,7 @@ + diff --git a/dotnet/samples/04-hosting/HostingChannels/InvocationsAndTelegram/InvocationsAndTelegram.csproj b/dotnet/samples/04-hosting/HostingChannels/InvocationsAndTelegram/InvocationsAndTelegram.csproj new file mode 100644 index 00000000000..72ff8a3c2b7 --- /dev/null +++ b/dotnet/samples/04-hosting/HostingChannels/InvocationsAndTelegram/InvocationsAndTelegram.csproj @@ -0,0 +1,24 @@ + + + + net10.0 + Exe + enable + enable + InvocationsAndTelegram + InvocationsAndTelegram + $(NoWarn);MAAI001;OPENAI001;CA1303 + + + + + + + + + + + + + + \ No newline at end of file diff --git a/dotnet/samples/04-hosting/HostingChannels/InvocationsAndTelegram/Program.cs b/dotnet/samples/04-hosting/HostingChannels/InvocationsAndTelegram/Program.cs new file mode 100644 index 00000000000..11c5f66d1f6 --- /dev/null +++ b/dotnet/samples/04-hosting/HostingChannels/InvocationsAndTelegram/Program.cs @@ -0,0 +1,68 @@ +// Copyright (c) Microsoft. All rights reserved. + +// This sample mounts ONE agent on TWO hosting channels at the same time: +// * Invocations - POST /invocations/invoke for JSON-only callers (curl / SDK / test bot) +// * Telegram - long-poll bot accepting messages, with cross-channel push back to peers +// Both channels share a single AgentFrameworkHost, so a Telegram user and an Invocations caller +// who link their identities via the OneTimeCodeIdentityLinker resolve to the same isolation key +// and therefore the same AgentSession. + +#pragma warning disable CA1031 // demo-only top-level exception handling + +using Azure.AI.OpenAI; +using Azure.Identity; +using Microsoft.Agents.AI; +using Microsoft.Agents.AI.Hosting.Channels; +using Microsoft.Agents.AI.Hosting.Channels.Invocations; +using Microsoft.Agents.AI.Hosting.Channels.Telegram; +using Microsoft.AspNetCore.Builder; +using Microsoft.Extensions.Hosting; +using OpenAI.Chat; + +var endpoint = Environment.GetEnvironmentVariable("AZURE_OPENAI_ENDPOINT") + ?? throw new InvalidOperationException("AZURE_OPENAI_ENDPOINT is not set."); +var deploymentName = Environment.GetEnvironmentVariable("AZURE_OPENAI_DEPLOYMENT_NAME") ?? "gpt-5.4-mini"; +var telegramToken = Environment.GetEnvironmentVariable("TELEGRAM_BOT_TOKEN"); + +// WARNING: DefaultAzureCredential is convenient for development but requires careful consideration in production. +// In production, consider using a specific credential (e.g., ManagedIdentityCredential) to avoid +// latency issues, unintended credential probing, and potential security risks from fallback mechanisms. +AIAgent agent = new AzureOpenAIClient(new Uri(endpoint), new DefaultAzureCredential()) + .GetChatClient(deploymentName) + .AsAIAgent(name: "Concierge", instructions: "You are a helpful concierge."); + +var builder = WebApplication.CreateBuilder(args); + +// +var host = builder.AddAgentFrameworkHost(agent, options => + { + // Allow any identity for the demo; auto-issued isolation keys. + options.DefaultAllowlist = AuthorizationProfile.Open(); + }) + .UseIdentityLinker(); +// + +// +host.AddInvocationsChannel(); + +if (!string.IsNullOrEmpty(telegramToken)) +{ + host.AddTelegramChannel(o => + { + o.BotToken = telegramToken; + o.Transport = TelegramTransport.Polling; + o.ConversationScope = ConversationScope.PerUserPerConversation; + o.AcceptInGroup = AcceptInGroup.MentionOnly; + o.Commands.Add(new ChannelCommand("new", "Start a fresh conversation")); + }); + Console.WriteLine("Telegram channel enabled."); +} +else +{ + Console.WriteLine("TELEGRAM_BOT_TOKEN not set; Telegram channel disabled. Invocations channel is still available at /invocations/invoke."); +} +// + +var app = builder.Build(); +app.MapAgentFrameworkHost(); +app.Run(); \ No newline at end of file diff --git a/dotnet/samples/04-hosting/HostingChannels/InvocationsAndTelegram/README.md b/dotnet/samples/04-hosting/HostingChannels/InvocationsAndTelegram/README.md new file mode 100644 index 00000000000..97b7a25910f --- /dev/null +++ b/dotnet/samples/04-hosting/HostingChannels/InvocationsAndTelegram/README.md @@ -0,0 +1,36 @@ +// Copyright (c) Microsoft. All rights reserved. + +# Invocations + Telegram hosted side by side + +This sample mounts a single `AIAgent` on two `Microsoft.Agents.AI.Hosting.Channels` channels at the same time and shares one `AgentFrameworkHost`, one identity registry, and one isolation-key space across them. + +## What it shows + +* `AddAgentFrameworkHost(agent)` + `AddInvocationsChannel()` + optional `AddTelegramChannel(...)` +* `UseIdentityLinker()` for low-ceremony cross-channel linking +* `MapAgentFrameworkHost()` mounting every channel's routes rooted at the channel `Path` +* Same agent answering both an `/invocations/invoke` POST and Telegram messages +* Cross-channel `IChannelPush` delivery to a Telegram user when linked via the one-time code + +## Requirements + +* `AZURE_OPENAI_ENDPOINT` set, with `az login` completed (DefaultAzureCredential) +* `AZURE_OPENAI_DEPLOYMENT_NAME` optional; defaults to `gpt-5.4-mini` +* `TELEGRAM_BOT_TOKEN` optional; when omitted the Telegram channel is skipped + +## Try it + +```bash +cd dotnet/samples/04-hosting/HostingChannels/InvocationsAndTelegram +dotnet run +``` + +Invocations channel sanity check: + +```bash +curl -X POST http://localhost:5000/invocations/invoke \ + -H "Content-Type: application/json" \ + -d '{ "input": "Hi, what can you do?" }' +``` + +If you supplied a Telegram bot token, message your bot from the Telegram client. Type `/new` on Telegram to rotate the active session alias for your isolation key. \ No newline at end of file diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting.Channels/AgentFrameworkHostOptions.cs b/dotnet/src/Microsoft.Agents.AI.Hosting.Channels/AgentFrameworkHostOptions.cs index 9942edbec52..f4346c83089 100644 --- a/dotnet/src/Microsoft.Agents.AI.Hosting.Channels/AgentFrameworkHostOptions.cs +++ b/dotnet/src/Microsoft.Agents.AI.Hosting.Channels/AgentFrameworkHostOptions.cs @@ -5,20 +5,20 @@ namespace Microsoft.Agents.AI.Hosting.Channels; /// /// Composition-time options for AddAgentFrameworkHost(...). /// -public sealed record AgentFrameworkHostOptions +public sealed class AgentFrameworkHostOptions { /// Host-level default allowlist. Per-channel allowlists may override or combine. - public IIdentityAllowlist? DefaultAllowlist { get; init; } + public IIdentityAllowlist? DefaultAllowlist { get; set; } /// Link policy applied across channels. - public ILinkPolicy? LinkPolicy { get; init; } + public ILinkPolicy? LinkPolicy { get; set; } /// File-system layout for the file-backed host state store. - public HostStatePathOptions? StatePaths { get; init; } + public HostStatePathOptions? StatePaths { get; set; } /// Default durable runner name; reserved for fast-follow runner-selection wiring. - public string? DefaultDurableRunnerName { get; init; } + public string? DefaultDurableRunnerName { get; set; } /// Whether is permitted in ephemeral runtime modes. - public bool AllowInProcessRunnerInEphemeralMode { get; init; } + public bool AllowInProcessRunnerInEphemeralMode { get; set; } } \ No newline at end of file diff --git a/dotnet/tests/Microsoft.Agents.AI.Hosting.Channels.UnitTests/AllowlistTests.cs b/dotnet/tests/Microsoft.Agents.AI.Hosting.Channels.UnitTests/AllowlistTests.cs new file mode 100644 index 00000000000..245f1b7d2fd --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.AI.Hosting.Channels.UnitTests/AllowlistTests.cs @@ -0,0 +1,82 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Agents.AI.Hosting.Channels; + +namespace Microsoft.Agents.AI.Hosting.Channels.UnitTests; + +public class AllowlistTests +{ + private static AuthorizationContext PreLink(string channel, string nativeId, IReadOnlyDictionary? claims = null) => new() + { + Identity = new ChannelIdentity(channel, nativeId), + Phase = AuthorizationPhase.PreLink, + VerifiedClaims = claims ?? new Dictionary(), + }; + + [Fact] + public async Task NativeIdAllowlist_ChannelMismatch_Abstains() + { + // Arrange + var allow = new NativeIdAllowlist("telegram", ["42"]); + + // Act + var decision = await allow.EvaluateAsync(PreLink("invocations", "42"), CancellationToken.None); + + // Assert + Assert.Equal(AllowlistDecision.Abstain, decision); + } + + [Fact] + public async Task NativeIdAllowlist_HitsAndMisses() + { + // Arrange + var allow = new NativeIdAllowlist("telegram", ["1", "2"]); + + // Act + var hit = await allow.EvaluateAsync(PreLink("telegram", "2"), CancellationToken.None); + var miss = await allow.EvaluateAsync(PreLink("telegram", "99"), CancellationToken.None); + + // Assert + Assert.Equal(AllowlistDecision.Allow, hit); + Assert.Equal(AllowlistDecision.Deny, miss); + } + + [Fact] + public async Task LinkedClaimAllowlist_AbstainsPreLink_AllowsOnGlobMatch() + { + // Arrange + var allow = new LinkedClaimAllowlist("email", "*@contoso.com"); + + // Act + var pre = await allow.EvaluateAsync(PreLink("telegram", "42"), CancellationToken.None); + var hit = await allow.EvaluateAsync(PreLink("telegram", "42", new Dictionary { ["email"] = "alice@contoso.com" }), CancellationToken.None); + var miss = await allow.EvaluateAsync(PreLink("telegram", "42", new Dictionary { ["email"] = "mallory@example.com" }), CancellationToken.None); + + // Assert + Assert.Equal(AllowlistDecision.Abstain, pre); + Assert.Equal(AllowlistDecision.Allow, hit); + Assert.Equal(AllowlistDecision.Deny, miss); + } + + [Fact] + public async Task AnyOf_ShortCircuitsOnFirstAllow_DenyWinsOverAbstain() + { + // Arrange + var nativeMatch = new NativeIdAllowlist("telegram", ["42"]); + var emailReject = new LinkedClaimAllowlist("email", "*@contoso.com"); + var allow = new AnyOfIdentityAllowlist(nativeMatch, emailReject); + + // Act + var nativeWin = await allow.EvaluateAsync(PreLink("telegram", "42"), CancellationToken.None); + var emailMiss = await allow.EvaluateAsync(PreLink("telegram", "99", new Dictionary { ["email"] = "mallory@example.com" }), CancellationToken.None); + var allAbstain = await allow.EvaluateAsync(PreLink("invocations", "99"), CancellationToken.None); + + // Assert + Assert.Equal(AllowlistDecision.Allow, nativeWin); + Assert.Equal(AllowlistDecision.Deny, emailMiss); + Assert.Equal(AllowlistDecision.Abstain, allAbstain); + } +} \ No newline at end of file diff --git a/dotnet/tests/Microsoft.Agents.AI.Hosting.Channels.UnitTests/InMemoryHostStateStoreTests.cs b/dotnet/tests/Microsoft.Agents.AI.Hosting.Channels.UnitTests/InMemoryHostStateStoreTests.cs new file mode 100644 index 00000000000..3f0e622830a --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.AI.Hosting.Channels.UnitTests/InMemoryHostStateStoreTests.cs @@ -0,0 +1,98 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Agents.AI.Hosting.Channels; + +namespace Microsoft.Agents.AI.Hosting.Channels.UnitTests; + +public class InMemoryHostStateStoreTests +{ + [Fact] + public async Task SaveLink_AndGet_RoundTrips() + { + // Arrange + var store = new InMemoryHostStateStore(); + var alice = new ChannelIdentity("telegram", "1"); + + // Act + await store.SaveLinkAsync(alice, "user:alice", verifiedClaims: null, CancellationToken.None); + var key = await store.GetIsolationKeyAsync(alice, CancellationToken.None); + + // Assert + Assert.Equal("user:alice", key); + } + + [Fact] + public async Task SaveLink_AtomicallyMergesPriorKey() + { + // Arrange + var store = new InMemoryHostStateStore(); + var alice = new ChannelIdentity("telegram", "1"); + var aliceOnInvocations = new ChannelIdentity("invocations", "alice-1"); + + // First registration assigns its own key, then we relink onto the canonical one. + await store.SaveLinkAsync(alice, "telegram:1", verifiedClaims: null, CancellationToken.None); + await store.SaveLinkAsync(aliceOnInvocations, "alice", verifiedClaims: null, CancellationToken.None); + await store.SaveLinkAsync(alice, "alice", verifiedClaims: null, CancellationToken.None); + + // Act + var aliceKey = await store.GetIsolationKeyAsync(alice, CancellationToken.None); + var aliceInvKey = await store.GetIsolationKeyAsync(aliceOnInvocations, CancellationToken.None); + var identities = await store.GetIdentitiesAsync("alice", CancellationToken.None); + + // Assert + Assert.Equal("alice", aliceKey); + Assert.Equal("alice", aliceInvKey); + Assert.Equal(2, identities.Count); + } + + [Fact] + public async Task SaveLink_PersistsVerifiedClaimsForLookup() + { + // Arrange + var store = new InMemoryHostStateStore(); + var alice = new ChannelIdentity("telegram", "1"); + await store.SaveLinkAsync(alice, "alice", new System.Collections.Generic.Dictionary { ["email"] = "alice@contoso.com" }, CancellationToken.None); + + // Act + var hit = await store.LookupByVerifiedClaimAsync("email", "alice@contoso.com", CancellationToken.None); + var miss = await store.LookupByVerifiedClaimAsync("email", "ghost@example.com", CancellationToken.None); + + // Assert + Assert.Equal("alice", hit); + Assert.Null(miss); + } + + [Fact] + public async Task ConsumeLinkGrant_DeletesEntry() + { + // Arrange + var store = new InMemoryHostStateStore(); + var grant = new LinkGrant("CODE1", "linker", null, System.DateTimeOffset.UtcNow.AddMinutes(5), new System.Collections.Generic.Dictionary()); + await store.SaveLinkGrantAsync(grant, CancellationToken.None); + + // Act + var first = await store.ConsumeLinkGrantAsync("CODE1", CancellationToken.None); + var second = await store.ConsumeLinkGrantAsync("CODE1", CancellationToken.None); + + // Assert + Assert.NotNull(first); + Assert.Null(second); + } + + [Fact] + public async Task RotateSessionAlias_ChangesAlias() + { + // Arrange + var store = new InMemoryHostStateStore(); + var initial = await store.GetActiveSessionAliasAsync("alice", CancellationToken.None); + + // Act + await store.RotateSessionAliasAsync("alice", CancellationToken.None); + var rotated = await store.GetActiveSessionAliasAsync("alice", CancellationToken.None); + + // Assert + Assert.NotEqual(initial, rotated); + } +} \ No newline at end of file diff --git a/dotnet/tests/Microsoft.Agents.AI.Hosting.Channels.UnitTests/InProcessDurableTaskRunnerTests.cs b/dotnet/tests/Microsoft.Agents.AI.Hosting.Channels.UnitTests/InProcessDurableTaskRunnerTests.cs new file mode 100644 index 00000000000..a6e42f51755 --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.AI.Hosting.Channels.UnitTests/InProcessDurableTaskRunnerTests.cs @@ -0,0 +1,69 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Agents.AI.Hosting.Channels; +using Microsoft.Extensions.Logging.Abstractions; + +namespace Microsoft.Agents.AI.Hosting.Channels.UnitTests; + +public class InProcessDurableTaskRunnerTests +{ + [Fact] + public async Task Schedule_InvokesHandler_AndReachesSucceeded() + { + // Arrange + var runner = new InProcessDurableTaskRunner(NullLogger.Instance); + await runner.StartAsync(CancellationToken.None); + + var ran = new TaskCompletionSource(); + runner.Register("test", _ => { ran.SetResult(42); return ValueTask.CompletedTask; }); + + // Act + var handle = await runner.ScheduleAsync("test", payload: new object(), retryPolicy: null, CancellationToken.None); + var observed = await ran.Task.WaitAsync(TimeSpan.FromSeconds(5)); + + // Allow the runner to record the final status. + DurableTaskStatus? status = null; + for (var i = 0; i < 20 && (status = await runner.GetAsync(handle, CancellationToken.None)) != DurableTaskStatus.Succeeded; i++) + { + await Task.Delay(50); + } + + // Assert + Assert.Equal(42, observed); + Assert.Equal(DurableTaskStatus.Succeeded, status); + + await runner.StopAsync(CancellationToken.None); + await runner.DisposeAsync(); + } + + [Fact] + public async Task Schedule_RetriesOnException_BeforeGivingUp() + { + // Arrange + var runner = new InProcessDurableTaskRunner(NullLogger.Instance); + await runner.StartAsync(CancellationToken.None); + + var attempts = 0; + runner.Register("flaky", _ => { attempts++; throw new InvalidOperationException("boom"); }); + + // Act + var handle = await runner.ScheduleAsync("flaky", payload: new object(), retryPolicy: new RetryPolicy { MaxAttempts = 3, InitialBackoff = TimeSpan.FromMilliseconds(1), MaxBackoff = TimeSpan.FromMilliseconds(5) }, CancellationToken.None); + + // Allow retries to play out. + DurableTaskStatus? status = null; + for (var i = 0; i < 50 && (status = await runner.GetAsync(handle, CancellationToken.None)) != DurableTaskStatus.Failed; i++) + { + await Task.Delay(50); + } + + // Assert + Assert.Equal(DurableTaskStatus.Failed, status); + Assert.Equal(3, attempts); + + await runner.StopAsync(CancellationToken.None); + await runner.DisposeAsync(); + } +} \ No newline at end of file diff --git a/dotnet/tests/Microsoft.Agents.AI.Hosting.Channels.UnitTests/Microsoft.Agents.AI.Hosting.Channels.UnitTests.csproj b/dotnet/tests/Microsoft.Agents.AI.Hosting.Channels.UnitTests/Microsoft.Agents.AI.Hosting.Channels.UnitTests.csproj new file mode 100644 index 00000000000..e9176c68016 --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.AI.Hosting.Channels.UnitTests/Microsoft.Agents.AI.Hosting.Channels.UnitTests.csproj @@ -0,0 +1,10 @@ + + + + net10.0 + + + + + + \ No newline at end of file diff --git a/dotnet/tests/Microsoft.Agents.AI.Hosting.Channels.UnitTests/OneTimeCodeIdentityLinkerTests.cs b/dotnet/tests/Microsoft.Agents.AI.Hosting.Channels.UnitTests/OneTimeCodeIdentityLinkerTests.cs new file mode 100644 index 00000000000..4767bbc3607 --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.AI.Hosting.Channels.UnitTests/OneTimeCodeIdentityLinkerTests.cs @@ -0,0 +1,65 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Agents.AI.Hosting.Channels; + +namespace Microsoft.Agents.AI.Hosting.Channels.UnitTests; + +public class OneTimeCodeIdentityLinkerTests +{ + [Fact] + public async Task BeginAndComplete_CollapseTwoIdentitiesOntoOneKey() + { + // Arrange + var store = new InMemoryHostStateStore(); + var linker = new OneTimeCodeIdentityLinker(store); + var aliceOnInvocations = new ChannelIdentity("invocations", "alice-1"); + var aliceOnTelegram = new ChannelIdentity("telegram", "12345"); + + // Act + var challenge = await linker.BeginAsync(aliceOnInvocations, requestedIsolationKey: "alice", CancellationToken.None); + var principal = await linker.CompleteAsync(challenge.ChallengeId, new Dictionary { ["identity"] = aliceOnTelegram }, CancellationToken.None); + + var keyOnInvocations = await store.GetIsolationKeyAsync(aliceOnInvocations, CancellationToken.None); + var keyOnTelegram = await store.GetIsolationKeyAsync(aliceOnTelegram, CancellationToken.None); + + // Assert + Assert.Equal("alice", principal.IsolationKey); + Assert.Equal("alice", keyOnInvocations); + Assert.Equal("alice", keyOnTelegram); + } + + [Fact] + public async Task Complete_RejectsUnknownCode() + { + // Arrange + var store = new InMemoryHostStateStore(); + var linker = new OneTimeCodeIdentityLinker(store); + var alice = new ChannelIdentity("telegram", "12345"); + + // Act / Assert + await Assert.ThrowsAsync(() => + linker.CompleteAsync("NOPE", new Dictionary { ["identity"] = alice }, CancellationToken.None).AsTask()); + } + + [Fact] + public async Task IsLinked_AutoMergesOnVerifiedClaim() + { + // Arrange + var store = new InMemoryHostStateStore(); + var linker = new OneTimeCodeIdentityLinker(store); + var alice = new ChannelIdentity("telegram", "1"); + await store.SaveLinkAsync(alice, "alice", new Dictionary { ["email"] = "alice@contoso.com" }, CancellationToken.None); + var aliceOnInvocations = new ChannelIdentity("invocations", "alice-1"); + + // Act + var resolved = await linker.IsLinkedAsync(aliceOnInvocations, new Dictionary { ["email"] = "alice@contoso.com" }, CancellationToken.None); + var afterMerge = await store.GetIsolationKeyAsync(aliceOnInvocations, CancellationToken.None); + + // Assert + Assert.Equal("alice", resolved); + Assert.Equal("alice", afterMerge); + } +} \ No newline at end of file diff --git a/dotnet/tests/Microsoft.Agents.AI.Hosting.Channels.UnitTests/ResponseTargetTests.cs b/dotnet/tests/Microsoft.Agents.AI.Hosting.Channels.UnitTests/ResponseTargetTests.cs new file mode 100644 index 00000000000..c5dce012531 --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.AI.Hosting.Channels.UnitTests/ResponseTargetTests.cs @@ -0,0 +1,51 @@ +// Copyright (c) Microsoft. All rights reserved. + +using Microsoft.Agents.AI.Hosting.Channels; + +namespace Microsoft.Agents.AI.Hosting.Channels.UnitTests; + +public class ResponseTargetTests +{ + [Fact] + public void Singletons_AreCorrectVariants() + { + // Arrange / Act / Assert + Assert.IsType(ResponseTarget.Originating); + Assert.IsType(ResponseTarget.Active); + Assert.IsType(ResponseTarget.AllLinked); + Assert.IsType(ResponseTarget.None); + } + + [Fact] + public void Channel_FactoryProducesChannelTarget() + { + // Arrange / Act + var target = ResponseTarget.Channel("telegram", echoInput: true); + + // Assert + var typed = Assert.IsType(target); + Assert.Equal("telegram", typed.ChannelName); + Assert.True(typed.EchoInput); + } + + [Fact] + public void Identities_FactoryAcceptsSingleAndList() + { + // Arrange + var alice = new ChannelIdentity("telegram", "1"); + var bob = new ChannelIdentity("invocations", "2"); + + // Act + var single = ResponseTarget.Identity(alice); + var many = ResponseTarget.Identities([alice, bob], echoInput: true); + + // Assert + var typedSingle = Assert.IsType(single); + Assert.Single(typedSingle.Targets, alice); + Assert.False(typedSingle.EchoInput); + + var typedMany = Assert.IsType(many); + Assert.Equal(2, typedMany.Targets.Count); + Assert.True(typedMany.EchoInput); + } +} \ No newline at end of file From a870dd58c8e57be3c9ae58b8f5ae586bbad8de31 Mon Sep 17 00:00:00 2001 From: Roger Barreto <19890735+RogerBarreto@users.noreply.github.com> Date: Thu, 28 May 2026 13:48:36 +0100 Subject: [PATCH 08/16] Hosting.Channels: split samples into one per channel Replaces samples/04-hosting/HostingChannels/InvocationsAndTelegram with two focused samples per the established 04-hosting/ numbering convention: 01_Invocations (smallest possible AddAgentFrameworkHost + AddInvocationsChannel demo with sync run, background run, and polling), 02_Telegram (AddTelegramChannel with polling transport, PerUserPerConversation isolation, MentionOnly group filtering, and a /new ChannelCommand). Each sample stays focused on one channel so readers can copy and adapt without untangling multi-channel composition. Both build clean against net10.0 with 0 warnings. Cross-channel composition stays demonstrable by composing both AddXxxChannel calls on the same host; the cross-channel scenario will land as a dedicated end-to-end sample once the linker UX is polished. --- dotnet/agent-framework-dotnet.slnx | 3 +- .../01_Invocations.csproj} | 5 +-- .../HostingChannels/01_Invocations/Program.cs | 39 +++++++++++++++++ .../HostingChannels/01_Invocations/README.md | 42 +++++++++++++++++++ .../02_Telegram/02_Telegram.csproj | 23 ++++++++++ .../Program.cs | 38 ++++------------- .../HostingChannels/02_Telegram/README.md | 30 +++++++++++++ .../InvocationsAndTelegram/README.md | 36 ---------------- 8 files changed, 147 insertions(+), 69 deletions(-) rename dotnet/samples/04-hosting/HostingChannels/{InvocationsAndTelegram/InvocationsAndTelegram.csproj => 01_Invocations/01_Invocations.csproj} (76%) create mode 100644 dotnet/samples/04-hosting/HostingChannels/01_Invocations/Program.cs create mode 100644 dotnet/samples/04-hosting/HostingChannels/01_Invocations/README.md create mode 100644 dotnet/samples/04-hosting/HostingChannels/02_Telegram/02_Telegram.csproj rename dotnet/samples/04-hosting/HostingChannels/{InvocationsAndTelegram => 02_Telegram}/Program.cs (58%) create mode 100644 dotnet/samples/04-hosting/HostingChannels/02_Telegram/README.md delete mode 100644 dotnet/samples/04-hosting/HostingChannels/InvocationsAndTelegram/README.md diff --git a/dotnet/agent-framework-dotnet.slnx b/dotnet/agent-framework-dotnet.slnx index 5272d5e83ee..b1c71ca6c09 100644 --- a/dotnet/agent-framework-dotnet.slnx +++ b/dotnet/agent-framework-dotnet.slnx @@ -378,7 +378,8 @@ - + + diff --git a/dotnet/samples/04-hosting/HostingChannels/InvocationsAndTelegram/InvocationsAndTelegram.csproj b/dotnet/samples/04-hosting/HostingChannels/01_Invocations/01_Invocations.csproj similarity index 76% rename from dotnet/samples/04-hosting/HostingChannels/InvocationsAndTelegram/InvocationsAndTelegram.csproj rename to dotnet/samples/04-hosting/HostingChannels/01_Invocations/01_Invocations.csproj index 72ff8a3c2b7..f7b81a079de 100644 --- a/dotnet/samples/04-hosting/HostingChannels/InvocationsAndTelegram/InvocationsAndTelegram.csproj +++ b/dotnet/samples/04-hosting/HostingChannels/01_Invocations/01_Invocations.csproj @@ -5,8 +5,8 @@ Exe enable enable - InvocationsAndTelegram - InvocationsAndTelegram + InvocationsSample + InvocationsSample $(NoWarn);MAAI001;OPENAI001;CA1303 @@ -19,6 +19,5 @@ - \ No newline at end of file diff --git a/dotnet/samples/04-hosting/HostingChannels/01_Invocations/Program.cs b/dotnet/samples/04-hosting/HostingChannels/01_Invocations/Program.cs new file mode 100644 index 00000000000..b22c87f8217 --- /dev/null +++ b/dotnet/samples/04-hosting/HostingChannels/01_Invocations/Program.cs @@ -0,0 +1,39 @@ +// Copyright (c) Microsoft. All rights reserved. + +// Mounts an AIAgent on the JSON Invocations channel and exposes: +// POST /invocations/invoke run the agent synchronously +// POST /invocations/invoke with background:true return a continuation token +// GET /invocations/{continuationToken} poll for a queued / completed run + +#pragma warning disable CA1031 // demo-only top-level exception handling + +using Azure.AI.OpenAI; +using Azure.Identity; +using Microsoft.Agents.AI; +using Microsoft.Agents.AI.Hosting.Channels; +using Microsoft.Agents.AI.Hosting.Channels.Invocations; +using Microsoft.AspNetCore.Builder; +using Microsoft.Extensions.Hosting; +using OpenAI.Chat; + +var endpoint = Environment.GetEnvironmentVariable("AZURE_OPENAI_ENDPOINT") + ?? throw new InvalidOperationException("AZURE_OPENAI_ENDPOINT is not set."); +var deploymentName = Environment.GetEnvironmentVariable("AZURE_OPENAI_DEPLOYMENT_NAME") ?? "gpt-5.4-mini"; + +// WARNING: DefaultAzureCredential is convenient for development but requires careful consideration in production. +// In production, consider using a specific credential (e.g., ManagedIdentityCredential) to avoid +// latency issues, unintended credential probing, and potential security risks from fallback mechanisms. +AIAgent agent = new AzureOpenAIClient(new Uri(endpoint), new DefaultAzureCredential()) + .GetChatClient(deploymentName) + .AsAIAgent(name: "Concierge", instructions: "You are a helpful concierge."); + +var builder = WebApplication.CreateBuilder(args); + +// +builder.AddAgentFrameworkHost(agent) + .AddInvocationsChannel(); +// + +var app = builder.Build(); +app.MapAgentFrameworkHost(); +app.Run(); \ No newline at end of file diff --git a/dotnet/samples/04-hosting/HostingChannels/01_Invocations/README.md b/dotnet/samples/04-hosting/HostingChannels/01_Invocations/README.md new file mode 100644 index 00000000000..18548564a2e --- /dev/null +++ b/dotnet/samples/04-hosting/HostingChannels/01_Invocations/README.md @@ -0,0 +1,42 @@ +# Invocations channel + +Mounts an `AIAgent` on the JSON Invocations channel from `Microsoft.Agents.AI.Hosting.Channels`. The smallest demonstration of `AddAgentFrameworkHost` + a single `AddInvocationsChannel` + `MapAgentFrameworkHost`. + +## What it shows + +* `IHostApplicationBuilder.AddAgentFrameworkHost(agent)` → `AddInvocationsChannel()` +* `IEndpointRouteBuilder.MapAgentFrameworkHost()` mounts every channel rooted at its `Path` +* `POST /invocations/invoke` runs synchronously and returns the agent text +* `POST /invocations/invoke` with `background: true` returns a continuation token +* `GET /invocations/{continuationToken}` polls the background run + +## Requirements + +* `AZURE_OPENAI_ENDPOINT` set, `az login` completed (DefaultAzureCredential) +* `AZURE_OPENAI_DEPLOYMENT_NAME` optional; defaults to `gpt-5.4-mini` + +## Try it + +```bash +cd dotnet/samples/04-hosting/HostingChannels/01_Invocations +dotnet run +``` + +Sync run: + +```bash +curl -X POST http://localhost:5000/invocations/invoke \ + -H "Content-Type: application/json" \ + -d '{ "input": "Tell me a short joke." }' +``` + +Background run + polling: + +```bash +TOKEN=$(curl -s -X POST http://localhost:5000/invocations/invoke \ + -H "Content-Type: application/json" \ + -d '{ "input": "Outline a recipe for chocolate chip cookies.", "background": true }' \ + | jq -r .continuation_token) + +curl http://localhost:5000/invocations/$TOKEN +``` \ No newline at end of file diff --git a/dotnet/samples/04-hosting/HostingChannels/02_Telegram/02_Telegram.csproj b/dotnet/samples/04-hosting/HostingChannels/02_Telegram/02_Telegram.csproj new file mode 100644 index 00000000000..dcb7342e35d --- /dev/null +++ b/dotnet/samples/04-hosting/HostingChannels/02_Telegram/02_Telegram.csproj @@ -0,0 +1,23 @@ + + + + net10.0 + Exe + enable + enable + TelegramSample + TelegramSample + $(NoWarn);MAAI001;OPENAI001;CA1303 + + + + + + + + + + + + + \ No newline at end of file diff --git a/dotnet/samples/04-hosting/HostingChannels/InvocationsAndTelegram/Program.cs b/dotnet/samples/04-hosting/HostingChannels/02_Telegram/Program.cs similarity index 58% rename from dotnet/samples/04-hosting/HostingChannels/InvocationsAndTelegram/Program.cs rename to dotnet/samples/04-hosting/HostingChannels/02_Telegram/Program.cs index 11c5f66d1f6..2ce98e11a52 100644 --- a/dotnet/samples/04-hosting/HostingChannels/InvocationsAndTelegram/Program.cs +++ b/dotnet/samples/04-hosting/HostingChannels/02_Telegram/Program.cs @@ -1,11 +1,9 @@ // Copyright (c) Microsoft. All rights reserved. -// This sample mounts ONE agent on TWO hosting channels at the same time: -// * Invocations - POST /invocations/invoke for JSON-only callers (curl / SDK / test bot) -// * Telegram - long-poll bot accepting messages, with cross-channel push back to peers -// Both channels share a single AgentFrameworkHost, so a Telegram user and an Invocations caller -// who link their identities via the OneTimeCodeIdentityLinker resolve to the same isolation key -// and therefore the same AgentSession. +// Mounts an AIAgent on the Telegram channel and serves messages via long-poll getUpdates. +// Per-conversation isolation (ConversationScope.PerUserPerConversation) keeps memory separate +// between the same user's DM and any groups the bot is added to. Group filtering accepts only +// @-mentions by default. #pragma warning disable CA1031 // demo-only top-level exception handling @@ -13,7 +11,6 @@ using Azure.Identity; using Microsoft.Agents.AI; using Microsoft.Agents.AI.Hosting.Channels; -using Microsoft.Agents.AI.Hosting.Channels.Invocations; using Microsoft.Agents.AI.Hosting.Channels.Telegram; using Microsoft.AspNetCore.Builder; using Microsoft.Extensions.Hosting; @@ -22,7 +19,8 @@ var endpoint = Environment.GetEnvironmentVariable("AZURE_OPENAI_ENDPOINT") ?? throw new InvalidOperationException("AZURE_OPENAI_ENDPOINT is not set."); var deploymentName = Environment.GetEnvironmentVariable("AZURE_OPENAI_DEPLOYMENT_NAME") ?? "gpt-5.4-mini"; -var telegramToken = Environment.GetEnvironmentVariable("TELEGRAM_BOT_TOKEN"); +var telegramToken = Environment.GetEnvironmentVariable("TELEGRAM_BOT_TOKEN") + ?? throw new InvalidOperationException("TELEGRAM_BOT_TOKEN is not set. Create a bot with @BotFather and set the token to run this sample."); // WARNING: DefaultAzureCredential is convenient for development but requires careful consideration in production. // In production, consider using a specific credential (e.g., ManagedIdentityCredential) to avoid @@ -34,20 +32,8 @@ var builder = WebApplication.CreateBuilder(args); // -var host = builder.AddAgentFrameworkHost(agent, options => - { - // Allow any identity for the demo; auto-issued isolation keys. - options.DefaultAllowlist = AuthorizationProfile.Open(); - }) - .UseIdentityLinker(); -// - -// -host.AddInvocationsChannel(); - -if (!string.IsNullOrEmpty(telegramToken)) -{ - host.AddTelegramChannel(o => +builder.AddAgentFrameworkHost(agent) + .AddTelegramChannel(o => { o.BotToken = telegramToken; o.Transport = TelegramTransport.Polling; @@ -55,13 +41,7 @@ o.AcceptInGroup = AcceptInGroup.MentionOnly; o.Commands.Add(new ChannelCommand("new", "Start a fresh conversation")); }); - Console.WriteLine("Telegram channel enabled."); -} -else -{ - Console.WriteLine("TELEGRAM_BOT_TOKEN not set; Telegram channel disabled. Invocations channel is still available at /invocations/invoke."); -} -// +// var app = builder.Build(); app.MapAgentFrameworkHost(); diff --git a/dotnet/samples/04-hosting/HostingChannels/02_Telegram/README.md b/dotnet/samples/04-hosting/HostingChannels/02_Telegram/README.md new file mode 100644 index 00000000000..8379d6e6267 --- /dev/null +++ b/dotnet/samples/04-hosting/HostingChannels/02_Telegram/README.md @@ -0,0 +1,30 @@ +# Telegram channel + +Mounts an `AIAgent` on the Telegram channel from `Microsoft.Agents.AI.Hosting.Channels.Telegram` and serves messages via long-poll `getUpdates`. + +## What it shows + +* `IHostApplicationBuilder.AddAgentFrameworkHost(agent)` → `AddTelegramChannel(...)` +* `TelegramTransport.Polling` driven from the channel's `OnStartup` hook (no public HTTP route) +* `ConversationScope.PerUserPerConversation` so the bot's memory is scoped per user per chat +* `AcceptInGroup.MentionOnly` filters out group chatter not directed at the bot +* `ChannelCommand` registered with Telegram via `setMyCommands` at startup + +## Requirements + +* `AZURE_OPENAI_ENDPOINT` set, `az login` completed (DefaultAzureCredential) +* `AZURE_OPENAI_DEPLOYMENT_NAME` optional; defaults to `gpt-5.4-mini` +* `TELEGRAM_BOT_TOKEN` from @BotFather (required) + +## Try it + +```bash +cd dotnet/samples/04-hosting/HostingChannels/02_Telegram +dotnet run +``` + +Open Telegram, find your bot, and send a message. In a group, add the bot and mention it (`@your_bot hello`) to trigger a reply. + +## Switching to webhook transport + +Set `o.Transport = TelegramTransport.Webhook` in `Program.cs`, register your public URL with Telegram via `setWebhook` (out of band), and the channel publishes `POST /telegram/webhook` for inbound updates. \ No newline at end of file diff --git a/dotnet/samples/04-hosting/HostingChannels/InvocationsAndTelegram/README.md b/dotnet/samples/04-hosting/HostingChannels/InvocationsAndTelegram/README.md deleted file mode 100644 index 97b7a25910f..00000000000 --- a/dotnet/samples/04-hosting/HostingChannels/InvocationsAndTelegram/README.md +++ /dev/null @@ -1,36 +0,0 @@ -// Copyright (c) Microsoft. All rights reserved. - -# Invocations + Telegram hosted side by side - -This sample mounts a single `AIAgent` on two `Microsoft.Agents.AI.Hosting.Channels` channels at the same time and shares one `AgentFrameworkHost`, one identity registry, and one isolation-key space across them. - -## What it shows - -* `AddAgentFrameworkHost(agent)` + `AddInvocationsChannel()` + optional `AddTelegramChannel(...)` -* `UseIdentityLinker()` for low-ceremony cross-channel linking -* `MapAgentFrameworkHost()` mounting every channel's routes rooted at the channel `Path` -* Same agent answering both an `/invocations/invoke` POST and Telegram messages -* Cross-channel `IChannelPush` delivery to a Telegram user when linked via the one-time code - -## Requirements - -* `AZURE_OPENAI_ENDPOINT` set, with `az login` completed (DefaultAzureCredential) -* `AZURE_OPENAI_DEPLOYMENT_NAME` optional; defaults to `gpt-5.4-mini` -* `TELEGRAM_BOT_TOKEN` optional; when omitted the Telegram channel is skipped - -## Try it - -```bash -cd dotnet/samples/04-hosting/HostingChannels/InvocationsAndTelegram -dotnet run -``` - -Invocations channel sanity check: - -```bash -curl -X POST http://localhost:5000/invocations/invoke \ - -H "Content-Type: application/json" \ - -d '{ "input": "Hi, what can you do?" }' -``` - -If you supplied a Telegram bot token, message your bot from the Telegram client. Type `/new` on Telegram to rotate the active session alias for your isolation key. \ No newline at end of file From 35a88ae024689bd753dccde69d950f397a94aac6 Mon Sep 17 00:00:00 2001 From: Roger Barreto <19890735+RogerBarreto@users.noreply.github.com> Date: Wed, 24 Jun 2026 14:48:23 +0100 Subject: [PATCH 09/16] Reduce hosting channels to ADR-0027 minimal core + Responses channel Scopes the .NET hosting channels work to ADR-0027 (minimal hosting core and pluggable channels), mirroring eavan's Python PR #6580. Everything in ADR-0028 (identity linking, allowlists/authorization, response targeting beyond the originating channel, push/codecs, background and continuation delivery, durable runners, retry/replay, link policy, confidentiality tiers, multicast) is removed from this PR and tracked as follow-up work. The removed code is preserved on a local branch for the ADR-0028 PR. Spec: trims docs/specs/003-dotnet-hosting-channels.md to the minimal core plus the Responses channel, adds a Non-goals for v1 section mirroring ADR-0027, and references ADR 0027 and 0028. Core (Microsoft.Agents.AI.Hosting.Channels): removes 46 ADR-0028 files (allowlists, authorization, identity linker, link policy, durable runner, response target, push, codecs, continuation, last-seen, link grant, conversation scope/context). Slims AgentFrameworkHost (run/stream/reset-session only), IChannelContext, the builder, ChannelRequest, AgentFrameworkHostOptions, and the host/endpoint extensions. IHostStateStore is reduced to reset-session aliases plus workflow checkpoint path derivation. AIAgentRunner now resolves and caches an AgentSession per active session alias so identical isolation keys reuse one session (ADR-0027 continuity gate). WorkflowRunner runs forward and surfaces awaiting-input on a RequestInfoEvent. Responses channel (Microsoft.Agents.AI.Hosting.Channels.Responses): new self-contained channel mapping OpenAI Responses requests and streams onto the host through the IChannelContext run/stream seam. Synchronous JSON response and SSE streaming, run and response hooks, source-generated JSON. Replaces the removed Invocations and Telegram channel packages. Samples: replaces the Invocations and Telegram samples with 01_ResponsesAgent (agent on the Responses channel) and 02_ResponsesWorkflow (workflow on the Responses channel with a run-hook input adapter), mirroring the Python local_responses and local_responses_workflow samples. Tests: drops the allowlist/linker/durable/state-merge tests; adds host state store, workflow runner, host composition, and channel request tests. 8 of 8 pass. All changed projects build clean on Debug net10.0 with warnaserror and pass CI-parity dotnet format verify-no-changes. --- docs/specs/003-dotnet-hosting-channels.md | 1316 +++-------------- dotnet/agent-framework-dotnet.slnx | 7 +- .../HostingChannels/01_Invocations/README.md | 42 - .../01_ResponsesAgent.csproj} | 6 +- .../Program.cs | 22 +- .../01_ResponsesAgent/README.md | 34 + .../02_ResponsesWorkflow.csproj} | 6 +- .../02_ResponsesWorkflow/EchoExecutor.cs | 14 + .../02_ResponsesWorkflow/Program.cs | 27 + .../02_ResponsesWorkflow/README.md | 28 + .../WorkflowInputRunHook.cs | 29 + .../HostingChannels/02_Telegram/Program.cs | 48 - .../HostingChannels/02_Telegram/README.md | 30 - ...ameworkHostBuilderInvocationsExtensions.cs | 23 - .../Internal/InvocationsJsonContext.cs | 13 - .../Internal/InvocationsJsonModels.cs | 80 - .../InvocationsChannel.cs | 243 --- .../InvocationsChannelOptions.cs | 36 - .../WorkflowInvocationsResponseHook.cs | 31 - ...FrameworkHostBuilderResponsesExtensions.cs | 23 + .../Internal/ResponsesJsonContext.cs | 13 + .../Internal/ResponsesModels.cs | 82 + .../Internal/ResponsesParsing.cs | 107 ++ ...ents.AI.Hosting.Channels.Responses.csproj} | 12 +- .../ResponsesChannel.cs | 219 +++ .../ResponsesChannelOptions.cs | 20 + ...tFrameworkHostBuilderTelegramExtensions.cs | 24 - .../Internal/TelegramApiClient.cs | 86 -- .../Internal/TelegramJsonContext.cs | 13 - .../Internal/TelegramModels.cs | 75 - ...Agents.AI.Hosting.Channels.Telegram.csproj | 34 - .../TelegramChannel.cs | 350 ----- .../TelegramChannelOptions.cs | 48 - .../TelegramTransport.cs | 13 - .../AcceptInGroup.cs | 21 - .../AgentFrameworkHost.cs | 159 +- .../AgentFrameworkHostOptions.cs | 14 +- .../AllowlistDecision.cs | 18 - .../Allowlists/AllOfIdentityAllowlist.cs | 50 - .../Allowlists/AllowAllIdentityAllowlist.cs | 17 - .../Allowlists/AnyOfIdentityAllowlist.cs | 50 - .../Allowlists/LinkedClaimAllowlist.cs | 64 - .../Allowlists/NativeIdAllowlist.cs | 42 - .../AuthorizationContext.cs | 31 - .../AuthorizationOutcome.cs | 28 - .../AuthorizationPhase.cs | 15 - .../AuthorizationProfile.cs | 27 - .../AuthorizationRequest.cs | 29 - .../Channel.cs | 35 +- .../ChannelCommand.cs | 2 +- .../ChannelCommandContext.cs | 12 + .../ChannelContribution.cs | 2 +- .../ChannelIdentity.cs | 2 +- .../ChannelIdentityRegistration.cs | 17 - .../ChannelPushContext.cs | 24 - .../ChannelRequest.cs | 58 +- .../ChannelResponseContext.cs | 16 +- .../ChannelRunHookContext.cs | 2 +- .../ChannelSession.cs | 2 +- .../ClaimSource.cs | 18 - .../ContinuationStatus.cs | 21 - .../ContinuationToken.cs | 35 - .../ConversationContext.cs | 10 - .../ConversationScope.cs | 18 - .../DurableTaskPayloadMode.cs | 21 - .../DurableTaskStatus.cs | 24 - ...ntRouteBuilderHostingChannelsExtensions.cs | 14 +- .../FileHostStateStore.cs | 270 +--- ...icationBuilderHostingChannelsExtensions.cs | 42 +- .../HostStatePathOptions.cs | 21 +- .../HostedRunResult.cs | 2 +- .../HostedStreamItem.cs | 2 +- .../IAgentFrameworkHostBuilder.cs | 22 +- .../IChannelContext.cs | 29 +- .../IChannelPush.cs | 16 - .../IChannelPushCodec.cs | 19 - .../IChannelResponseHook.cs | 2 +- .../IChannelRunHook.cs | 2 +- .../IChannelStreamTransformHook.cs | 2 +- .../IConfidentialityTagged.cs | 14 - .../IDurableTaskRunner.cs | 34 - .../IHostStateStore.cs | 76 +- .../IHostedTargetRunner.cs | 2 +- .../IIdentityAllowlist.cs | 24 - .../IIdentityLinker.cs | 42 - .../ILinkPolicy.cs | 17 - .../InMemoryHostStateStore.cs | 178 +-- .../InProcessDurableTaskRunner.cs | 193 --- .../Internal/AgentFrameworkHostBuilder.cs | 37 +- .../Internal/ChannelContext.cs | 19 +- .../Internal/HostingPushPayload.cs | 14 - .../Internal/ResponseRouter.cs | 249 ---- .../IsolationKeys.cs | 11 +- .../LastSeenRecord.cs | 16 - .../LinkChallenge.cs | 21 - .../LinkGrant.cs | 22 - .../LinkPolicies/AllowAllLinkPolicy.cs | 16 - .../LinkPolicies/DenyAllLinkPolicy.cs | 16 - .../ExplicitAllowListLinkPolicy.cs | 33 - .../SameConfidentialityTierLinkPolicy.cs | 28 - .../LinkPolicyContext.cs | 18 - .../LinkPolicyOperation.cs | 15 - .../OneTimeCodeIdentityLinker.cs | 153 -- .../PrincipalIdentity.cs | 16 - .../ResponseTarget.cs | 78 - .../RetryPolicy.cs | 27 - .../Runners/AIAgentRunner.cs | 44 +- .../Runners/WorkflowRunner.cs | 198 +-- .../SessionMode.cs | 2 +- .../TaskHandle.cs | 10 - .../TaskInvocationContext.cs | 21 - .../WorkflowRunResult.cs | 2 +- .../AllowlistTests.cs | 82 - .../ChannelRequestTests.cs | 33 + .../HostCompositionTests.cs | 62 + .../HostStateStoreTests.cs | 68 + .../InMemoryHostStateStoreTests.cs | 98 -- .../InProcessDurableTaskRunnerTests.cs | 69 - ...gents.AI.Hosting.Channels.UnitTests.csproj | 4 + .../OneTimeCodeIdentityLinkerTests.cs | 65 - .../ResponseTargetTests.cs | 51 - .../WorkflowRunnerTests.cs | 34 + 122 files changed, 1288 insertions(+), 5405 deletions(-) delete mode 100644 dotnet/samples/04-hosting/HostingChannels/01_Invocations/README.md rename dotnet/samples/04-hosting/HostingChannels/{01_Invocations/01_Invocations.csproj => 01_ResponsesAgent/01_ResponsesAgent.csproj} (79%) rename dotnet/samples/04-hosting/HostingChannels/{01_Invocations => 01_ResponsesAgent}/Program.cs (60%) create mode 100644 dotnet/samples/04-hosting/HostingChannels/01_ResponsesAgent/README.md rename dotnet/samples/04-hosting/HostingChannels/{02_Telegram/02_Telegram.csproj => 02_ResponsesWorkflow/02_ResponsesWorkflow.csproj} (79%) create mode 100644 dotnet/samples/04-hosting/HostingChannels/02_ResponsesWorkflow/EchoExecutor.cs create mode 100644 dotnet/samples/04-hosting/HostingChannels/02_ResponsesWorkflow/Program.cs create mode 100644 dotnet/samples/04-hosting/HostingChannels/02_ResponsesWorkflow/README.md create mode 100644 dotnet/samples/04-hosting/HostingChannels/02_ResponsesWorkflow/WorkflowInputRunHook.cs delete mode 100644 dotnet/samples/04-hosting/HostingChannels/02_Telegram/Program.cs delete mode 100644 dotnet/samples/04-hosting/HostingChannels/02_Telegram/README.md delete mode 100644 dotnet/src/Microsoft.Agents.AI.Hosting.Channels.Invocations/AgentFrameworkHostBuilderInvocationsExtensions.cs delete mode 100644 dotnet/src/Microsoft.Agents.AI.Hosting.Channels.Invocations/Internal/InvocationsJsonContext.cs delete mode 100644 dotnet/src/Microsoft.Agents.AI.Hosting.Channels.Invocations/Internal/InvocationsJsonModels.cs delete mode 100644 dotnet/src/Microsoft.Agents.AI.Hosting.Channels.Invocations/InvocationsChannel.cs delete mode 100644 dotnet/src/Microsoft.Agents.AI.Hosting.Channels.Invocations/InvocationsChannelOptions.cs delete mode 100644 dotnet/src/Microsoft.Agents.AI.Hosting.Channels.Invocations/WorkflowInvocationsResponseHook.cs create mode 100644 dotnet/src/Microsoft.Agents.AI.Hosting.Channels.Responses/AgentFrameworkHostBuilderResponsesExtensions.cs create mode 100644 dotnet/src/Microsoft.Agents.AI.Hosting.Channels.Responses/Internal/ResponsesJsonContext.cs create mode 100644 dotnet/src/Microsoft.Agents.AI.Hosting.Channels.Responses/Internal/ResponsesModels.cs create mode 100644 dotnet/src/Microsoft.Agents.AI.Hosting.Channels.Responses/Internal/ResponsesParsing.cs rename dotnet/src/{Microsoft.Agents.AI.Hosting.Channels.Invocations/Microsoft.Agents.AI.Hosting.Channels.Invocations.csproj => Microsoft.Agents.AI.Hosting.Channels.Responses/Microsoft.Agents.AI.Hosting.Channels.Responses.csproj} (68%) create mode 100644 dotnet/src/Microsoft.Agents.AI.Hosting.Channels.Responses/ResponsesChannel.cs create mode 100644 dotnet/src/Microsoft.Agents.AI.Hosting.Channels.Responses/ResponsesChannelOptions.cs delete mode 100644 dotnet/src/Microsoft.Agents.AI.Hosting.Channels.Telegram/AgentFrameworkHostBuilderTelegramExtensions.cs delete mode 100644 dotnet/src/Microsoft.Agents.AI.Hosting.Channels.Telegram/Internal/TelegramApiClient.cs delete mode 100644 dotnet/src/Microsoft.Agents.AI.Hosting.Channels.Telegram/Internal/TelegramJsonContext.cs delete mode 100644 dotnet/src/Microsoft.Agents.AI.Hosting.Channels.Telegram/Internal/TelegramModels.cs delete mode 100644 dotnet/src/Microsoft.Agents.AI.Hosting.Channels.Telegram/Microsoft.Agents.AI.Hosting.Channels.Telegram.csproj delete mode 100644 dotnet/src/Microsoft.Agents.AI.Hosting.Channels.Telegram/TelegramChannel.cs delete mode 100644 dotnet/src/Microsoft.Agents.AI.Hosting.Channels.Telegram/TelegramChannelOptions.cs delete mode 100644 dotnet/src/Microsoft.Agents.AI.Hosting.Channels.Telegram/TelegramTransport.cs delete mode 100644 dotnet/src/Microsoft.Agents.AI.Hosting.Channels/AcceptInGroup.cs delete mode 100644 dotnet/src/Microsoft.Agents.AI.Hosting.Channels/AllowlistDecision.cs delete mode 100644 dotnet/src/Microsoft.Agents.AI.Hosting.Channels/Allowlists/AllOfIdentityAllowlist.cs delete mode 100644 dotnet/src/Microsoft.Agents.AI.Hosting.Channels/Allowlists/AllowAllIdentityAllowlist.cs delete mode 100644 dotnet/src/Microsoft.Agents.AI.Hosting.Channels/Allowlists/AnyOfIdentityAllowlist.cs delete mode 100644 dotnet/src/Microsoft.Agents.AI.Hosting.Channels/Allowlists/LinkedClaimAllowlist.cs delete mode 100644 dotnet/src/Microsoft.Agents.AI.Hosting.Channels/Allowlists/NativeIdAllowlist.cs delete mode 100644 dotnet/src/Microsoft.Agents.AI.Hosting.Channels/AuthorizationContext.cs delete mode 100644 dotnet/src/Microsoft.Agents.AI.Hosting.Channels/AuthorizationOutcome.cs delete mode 100644 dotnet/src/Microsoft.Agents.AI.Hosting.Channels/AuthorizationPhase.cs delete mode 100644 dotnet/src/Microsoft.Agents.AI.Hosting.Channels/AuthorizationProfile.cs delete mode 100644 dotnet/src/Microsoft.Agents.AI.Hosting.Channels/AuthorizationRequest.cs create mode 100644 dotnet/src/Microsoft.Agents.AI.Hosting.Channels/ChannelCommandContext.cs delete mode 100644 dotnet/src/Microsoft.Agents.AI.Hosting.Channels/ChannelIdentityRegistration.cs delete mode 100644 dotnet/src/Microsoft.Agents.AI.Hosting.Channels/ChannelPushContext.cs delete mode 100644 dotnet/src/Microsoft.Agents.AI.Hosting.Channels/ClaimSource.cs delete mode 100644 dotnet/src/Microsoft.Agents.AI.Hosting.Channels/ContinuationStatus.cs delete mode 100644 dotnet/src/Microsoft.Agents.AI.Hosting.Channels/ContinuationToken.cs delete mode 100644 dotnet/src/Microsoft.Agents.AI.Hosting.Channels/ConversationContext.cs delete mode 100644 dotnet/src/Microsoft.Agents.AI.Hosting.Channels/ConversationScope.cs delete mode 100644 dotnet/src/Microsoft.Agents.AI.Hosting.Channels/DurableTaskPayloadMode.cs delete mode 100644 dotnet/src/Microsoft.Agents.AI.Hosting.Channels/DurableTaskStatus.cs delete mode 100644 dotnet/src/Microsoft.Agents.AI.Hosting.Channels/IChannelPush.cs delete mode 100644 dotnet/src/Microsoft.Agents.AI.Hosting.Channels/IChannelPushCodec.cs delete mode 100644 dotnet/src/Microsoft.Agents.AI.Hosting.Channels/IConfidentialityTagged.cs delete mode 100644 dotnet/src/Microsoft.Agents.AI.Hosting.Channels/IDurableTaskRunner.cs delete mode 100644 dotnet/src/Microsoft.Agents.AI.Hosting.Channels/IIdentityAllowlist.cs delete mode 100644 dotnet/src/Microsoft.Agents.AI.Hosting.Channels/IIdentityLinker.cs delete mode 100644 dotnet/src/Microsoft.Agents.AI.Hosting.Channels/ILinkPolicy.cs delete mode 100644 dotnet/src/Microsoft.Agents.AI.Hosting.Channels/InProcessDurableTaskRunner.cs delete mode 100644 dotnet/src/Microsoft.Agents.AI.Hosting.Channels/Internal/HostingPushPayload.cs delete mode 100644 dotnet/src/Microsoft.Agents.AI.Hosting.Channels/Internal/ResponseRouter.cs delete mode 100644 dotnet/src/Microsoft.Agents.AI.Hosting.Channels/LastSeenRecord.cs delete mode 100644 dotnet/src/Microsoft.Agents.AI.Hosting.Channels/LinkChallenge.cs delete mode 100644 dotnet/src/Microsoft.Agents.AI.Hosting.Channels/LinkGrant.cs delete mode 100644 dotnet/src/Microsoft.Agents.AI.Hosting.Channels/LinkPolicies/AllowAllLinkPolicy.cs delete mode 100644 dotnet/src/Microsoft.Agents.AI.Hosting.Channels/LinkPolicies/DenyAllLinkPolicy.cs delete mode 100644 dotnet/src/Microsoft.Agents.AI.Hosting.Channels/LinkPolicies/ExplicitAllowListLinkPolicy.cs delete mode 100644 dotnet/src/Microsoft.Agents.AI.Hosting.Channels/LinkPolicies/SameConfidentialityTierLinkPolicy.cs delete mode 100644 dotnet/src/Microsoft.Agents.AI.Hosting.Channels/LinkPolicyContext.cs delete mode 100644 dotnet/src/Microsoft.Agents.AI.Hosting.Channels/LinkPolicyOperation.cs delete mode 100644 dotnet/src/Microsoft.Agents.AI.Hosting.Channels/OneTimeCodeIdentityLinker.cs delete mode 100644 dotnet/src/Microsoft.Agents.AI.Hosting.Channels/PrincipalIdentity.cs delete mode 100644 dotnet/src/Microsoft.Agents.AI.Hosting.Channels/ResponseTarget.cs delete mode 100644 dotnet/src/Microsoft.Agents.AI.Hosting.Channels/RetryPolicy.cs delete mode 100644 dotnet/src/Microsoft.Agents.AI.Hosting.Channels/TaskHandle.cs delete mode 100644 dotnet/src/Microsoft.Agents.AI.Hosting.Channels/TaskInvocationContext.cs delete mode 100644 dotnet/tests/Microsoft.Agents.AI.Hosting.Channels.UnitTests/AllowlistTests.cs create mode 100644 dotnet/tests/Microsoft.Agents.AI.Hosting.Channels.UnitTests/ChannelRequestTests.cs create mode 100644 dotnet/tests/Microsoft.Agents.AI.Hosting.Channels.UnitTests/HostCompositionTests.cs create mode 100644 dotnet/tests/Microsoft.Agents.AI.Hosting.Channels.UnitTests/HostStateStoreTests.cs delete mode 100644 dotnet/tests/Microsoft.Agents.AI.Hosting.Channels.UnitTests/InMemoryHostStateStoreTests.cs delete mode 100644 dotnet/tests/Microsoft.Agents.AI.Hosting.Channels.UnitTests/InProcessDurableTaskRunnerTests.cs delete mode 100644 dotnet/tests/Microsoft.Agents.AI.Hosting.Channels.UnitTests/OneTimeCodeIdentityLinkerTests.cs delete mode 100644 dotnet/tests/Microsoft.Agents.AI.Hosting.Channels.UnitTests/ResponseTargetTests.cs create mode 100644 dotnet/tests/Microsoft.Agents.AI.Hosting.Channels.UnitTests/WorkflowRunnerTests.cs diff --git a/docs/specs/003-dotnet-hosting-channels.md b/docs/specs/003-dotnet-hosting-channels.md index 0d97fa4f283..07fd0278436 100644 --- a/docs/specs/003-dotnet-hosting-channels.md +++ b/docs/specs/003-dotnet-hosting-channels.md @@ -6,80 +6,92 @@ deciders: RogerBarreto informed: eavanvalkenburg, agent-framework dotnet contributors --- -# .NET hosting core and pluggable channels - -> **Posture: translate.** The Python spec [`002-python-hosting-channels.md`](./002-python-hosting-channels.md) is the canonical source of truth for vocabulary, channel taxonomy, identity model, response targets, wire formats, durable-runner posture, and cross-channel continuity semantics. This spec describes how those concepts are translated into idiomatic .NET (`IHostApplicationBuilder` + `IEndpointRouteBuilder` composition on top of ASP.NET Core / Generic Host) and the specific package layout, type names, and lifecycle that the .NET implementation will ship. Where this spec is silent on a behavioral question, the Python spec governs. +# .NET minimal hosting core and pluggable channels + +> **Posture: translate, minimal core first.** Scope mirrors [ADR-0027](../decisions/0027-hosting-channels.md) +> ("minimal hosting core and pluggable channels") exactly. The richer cross-channel behaviors (identity +> linking, authorization/allowlists, response targeting beyond the originating channel, push/codecs, +> background + continuation delivery, durable runners, retry/replay, link policy, confidentiality tiers, +> multicast) are **out of scope for v1** and tracked by [ADR-0028](../decisions/0028-hosting-linking-multicast-enhancements.md). +> The Python implementation (`agent-framework-hosting` + `agent-framework-hosting-responses`) is the +> canonical reference; this spec records the idiomatic .NET shape (`IHostApplicationBuilder` + +> `IEndpointRouteBuilder` composition on ASP.NET Core / Generic Host). Where this spec is silent, ADR-0027 +> governs. ## What are the business goals for this feature? -Give .NET app authors one low-level hosting surface that can expose a single **hostable target** — either an `AIAgent` or a `Workflow` (or a Foundry hosted-agent handle via a swappable runner) — on one or more **channels** (Responses API, Invocations API, Telegram, and future Discord / Activity Protocol / etc.) without writing per-protocol routing or server glue, **and** let an end user start a conversation on one channel and seamlessly continue it on another against the same target and the same conversation history. - -This consolidates the per-protocol .NET hosting packages that exist today (`Microsoft.Agents.AI.Hosting.OpenAI`, `.Hosting.A2A(.AspNetCore)`, `.Hosting.AGUI.AspNetCore`, `.Hosting.AzureFunctions`, `.Foundry.Hosting`) into a shared composable model where: +Give .NET app authors one low-level hosting surface that exposes a single **hostable target** — an `AIAgent` +or a `Workflow` — on one or more **channels** without writing per-protocol routing or server glue, with +**explicit, debuggable session continuity** via a channel-supplied `ChannelSession(IsolationKey)`. -- a single `AgentFrameworkHost` owns ASP.NET Core endpoints (or `IHostedService` lifecycle when no HTTP is required) and channels own protocol shape; -- session identity is **channel-neutral** — channel-supplied native ids are mapped to a stable `IsolationKey` so two channels mounted on the same host can resolve to the **same** `AgentSession` for the same end user; -- channel-native identity is **mapped, not assumed** — the host exposes `IIdentityAllowlist` / `IIdentityLinker` seams (channel-native id → isolation key, plus a one-time-code / OAuth / MFA link ceremony) so cross-channel continuity does not depend on namespaces happening to align; -- response delivery is **decoupled from request origin** — every `ChannelRequest` carries a `ResponseTarget` (`Originating` (default), `Active`, a specific channel, all linked channels, or `None`), so long-running runs can return their result on a different channel than the one that started them; -- channels can be assigned different **confidentiality tiers** so two channels on one host can share an agent without sharing a session; -- **multi-user surfaces** (Telegram groups, forum topics; future Teams channels) are first-class — channels separate user identity from conversation locator with safe defaults (`MentionOnly` addressing, per-user-per-conversation session scoping, link ceremonies redirected to DMs). +The first slice ships the channel-neutral host plus one channel package (Responses) so the host/channel +boundary can be implemented, tested, and explained without designing identity linking or durable delivery +at the same time. -**Success criteria:** +**Success criteria (mirror ADR-0027 validation gates):** -- A basic multi-channel sample requires only one `builder.AddAgentFrameworkHost(target).AddXxxChannel(...).AddYyyChannel(...)` chain and a single `app.MapAgentFrameworkHost()` call. No hand-written protocol routes, no per-protocol host bootstrap. -- A single `AgentFrameworkHost` configured with `ResponsesChannel` + `TelegramChannel` can be exercised by one end user across both and observe one continuous conversation. -- A user known on one channel can run a host-provided `/link` command on a second channel, complete a one-time-code ceremony, and see subsequent messages on the second channel resolved against the same `AgentSession` as the first. -- A user can submit a long-running run on Telegram with `ResponseTarget = Active`, switch to another channel (Responses, future Activity), and receive the result there as a proactive push — with a poll route as fallback. +- A sample exposes one target over the Responses channel with one `AgentFrameworkHost` and a single + `app.MapAgentFrameworkHost()` call. No hand-written route composition. +- Channel tests prove routes, commands, startup, and shutdown callbacks are contributed by channels and + aggregated by the host. +- Session tests prove identical `ChannelSession.IsolationKey` values resolve to the **same** cached + `AgentSession`, and `ResetSession` rotates that mapping. +- Each channel renders only its own originating response; there is no host-level push, multicast, or + active-channel delivery. +- A workflow sample uses an explicit checkpoint location. ## What is the problem being solved? -### How do .NET developers solve this today? - -Every protocol surface is its own package with its own `Map*` extension. A developer who wants to expose one agent over both the OpenAI Responses API and a webhook channel has to stand up two hosts and stitch them together by hand: - -```csharp -// Today: developer composes per-protocol Map* calls and writes any non-supported transport by hand. -var builder = WebApplication.CreateBuilder(args); -builder.AddAIAgent(sp => new AzureOpenAIChatClient(...).CreateAIAgent(name: "Weather", instructions: "...")); - -var app = builder.Build(); -app.MapOpenAIResponses("/responses", agentName: "Weather"); // package: Hosting.OpenAI -app.MapA2A("/a2a", agentName: "Weather"); // package: Hosting.A2A.AspNetCore -app.Run(); -``` - -Adding a Telegram bot, Discord bot, or Teams entry point requires leaving this stack entirely: standing up a separate worker, installing a channel SDK, hand-writing the polling/webhook loop, mapping every native update into an `AIAgent.RunAsync` call, and bolting on commands (`/start`, `/new`, `/cancel`, …) — none of which is reusable across other channels. Identity, session continuity, response targeting, and proactive push do not exist as cross-cutting concerns: each developer reinvents them per integration. - -### Why does this problem require a new hosting abstraction? - -The gap is between **owning a hostable target** (an `AIAgent` or a `Workflow`) and **operationalizing it on multiple channels**. Agent Framework already provides agents, workflows, sessions, run inputs, response/update streaming, the `AIAgent` execution seam, and the `Workflow` execution seam. What's missing is a generic host that: - -1. Owns one ASP.NET Core endpoint surface (or pure-worker `IHostedService` set) and one set of lifecycle hooks. -2. Lets channels contribute routes, commands, and startup/shutdown without protocol leakage into the host. -3. Standardizes how protocol requests become agent invocations (input, options, session, streaming) and how results flow back out — including proactive push for non-`Originating` response targets. -4. Owns the identity stack (resolution, linking, authorization, isolation) once instead of per channel. -5. Owns durable continuation, host state (link grants, active-channel ledger, continuation tokens), and isolation-key context propagation once instead of per channel. - -Python has already built this model on `feature/python-hosting`. .NET needs the equivalent so the same agent can be reached over Responses, Invocations, and Telegram simultaneously, resolve to the same session per user, and let third parties ship new channel packages without forking the host. +Every protocol surface today is its own package with its own `Map*` extension. A developer exposing one +agent over two protocols stands up two hosts and stitches lifecycle, routing, and session handling by hand. +The gap is between **owning a hostable target** and **operationalizing it on a channel**: Agent Framework +already provides agents, workflows, sessions, run inputs, streaming, and the `AIAgent` / `Workflow` +execution seams. What is missing is a small channel-neutral host that owns route/lifecycle aggregation, +target invocation, `IsolationKey -> AgentSession` resolution + caching, per-channel hooks, and workflow +checkpoint wiring — and leaves protocol shape inside channel packages. ## Decisions -The full grilling log lives in the session glossary. The bullets below summarize what is locked. - -1. **Posture: translate.** Python is canonical. .NET ports the vocabulary, taxonomy, and wire formats faithfully; deviates only where ASP.NET Core mechanics force it. -2. **Composition: builder-centric.** Single happy path: `builder.AddAgentFrameworkHost(target).AddXxxChannel(...)` then `app.MapAgentFrameworkHost()`. No standalone `Map*` extensions in the new packages. -3. **Channel contract: `abstract class Channel` + capability interfaces.** `Channel` is an abstract class with three members (`Name`, `Path`, `Contribute`) so it can grow virtual members non-breakingly. `ChannelContribution` is a record with init-only properties (4 fields: `Routes`, `Commands`, `OnStartup`, `OnShutdown`). Optional cross-cutting capabilities (`IChannelPush`, `IChannelPushCodec`, `IChannelRunHook`, `IChannelResponseHook`, `IChannelStreamTransformHook`, `IConfidentialityTagged`) live as small separate interfaces a channel mixes in. -4. **Channel lifecycle: two-phase split.** `Channel.ConfigureServices(IServiceCollection)` runs at `AddXxxChannel(...)` time (pre-`Build`); `Channel.Contribute(IChannelContext)` runs at `MapAgentFrameworkHost(...)` time (post-`Build`). Matches the long-standing ASP.NET Core `ConfigureServices` + `Configure` split. -5. **Foundry reuse: swappable `IHostedTargetRunner`.** The host registers one runner based on what was passed to `AddAgentFrameworkHost(...)`: `AIAgentRunner` for `AIAgent`, `WorkflowRunner` for `Workflow`, and `Microsoft.Agents.AI.Foundry.Hosting` ships `FoundryHostedAgentRunner` for a remote Foundry hosted-agent handle. Channels never branch on target type. -6. **Identity & authorization: literal port.** Ship our own `IIdentityAllowlist`, `IIdentityLinker`, `AuthorizationContext`, `AllowlistDecision` enum (`Allow` / `Deny` / `Abstain`), and combinators (`AnyOfIdentityAllowlist`) independent of `Microsoft.AspNetCore.Authorization`. Reasons: (a) the Python model has domain shapes (pre/post-link evaluation, abstain tri-state, cross-channel any-of combinator, native-id vs linked-claim) that don't map onto ASP.NET's request-scoped `IAuthorizationHandler` cleanly; (b) non-HTTP channels (Telegram polling, future Discord gateway) have no `HttpContext`; (c) keeps the channel-author packages ASP.NET-free. An optional `AspNetCoreIdentityAllowlistAdapter` shim can be added later for app authors who want to bridge their existing `AuthorizationPolicy` objects. -7. **Host state store: new `IHostStateStore`, separate from `AgentSessionStore`.** Existing `AgentSessionStore` keys per `(AIAgent, conversationId)` — doesn't fit the new state (identity registry, identity-link grants, active-channel ledger, continuation tokens). Ship `InMemoryHostStateStore` and `FileHostStateStore` in v1, configured via `HostStatePathOptions` (optional `Root` shorthand plus optional per-component path overrides — `RunnerPath`, `LinksPath`, `ContinuationsPath`, `LastSeenPath`). Workflow checkpoint storage is **not** an `IHostStateStore` concern (it stays on `WorkflowBuilder.CheckpointStorage` per decision 13). New components add new optional properties non-breakingly. Mirrors Python's `HostStatePaths` TypedDict, which is brand new with this work. -8. **Durable runner: own `IDurableTaskRunner` seam + in-process default + opt-in DTF adapter.** Channels core defines `IDurableTaskRunner` (4 methods: `ScheduleAsync`, `GetAsync`, `CancelAsync`, `ResumeAsync`). `InProcessDurableTaskRunner` ships in core as an `IHostedService` + bounded `Channel` consumer; in-memory unless `HostStatePathOptions.RunnerPath` is set, in which case records persist to disk and replay on `ResumeAsync`. A separate opt-in package `Microsoft.Agents.AI.Hosting.Channels.DurableTask` ships `DurableTaskFrameworkRunner` that wraps the existing `Microsoft.Agents.AI.DurableTask` package for ephemeral runtime modes. -9. **Hosting target: Generic Host + ASP.NET Core.** `AddAgentFrameworkHost(this IHostApplicationBuilder, target)` accepts both `WebApplicationBuilder` (which derives from `IHostApplicationBuilder`) and `HostApplicationBuilder` (pure worker). HTTP routes via `app.MapAgentFrameworkHost(this IEndpointRouteBuilder)`. Non-HTTP channels (Telegram polling, future Discord gateway) auto-start via `IHostedService`. If a registered channel requires `IEndpointRouteBuilder` and the host doesn't have one (pure worker), startup throws a clean error. -10. **Naming: literal port.** Host type = `AgentFrameworkHost`. Builder extensions = `AddAgentFrameworkHost(...)` and `MapAgentFrameworkHost(...)`. Channel-add extensions = `AddResponsesChannel(...)`, `AddInvocationsChannel(...)`, `AddTelegramChannel(...)`. Matches Python class name 1:1; follows the `AddOpenTelemetry` / `AddSignalR` precedent. Always fully qualified (never just `Host`) to avoid colliding with `Microsoft.Extensions.Hosting.Host`. -11. **Packaging: one assembly per channel.** v1 NuGet packages: `Microsoft.Agents.AI.Hosting.Channels` (core), `.Responses`, `.Invocations`, `.Telegram`. `Microsoft.Agents.AI.Foundry.Hosting` gains `FoundryHostedAgentRunner` as an additive type (no break to existing surface). Fast-follow packages: `.Channels.DurableTask`, `.Channels.Discord`, `.Channels.Activity`, `.Channels.EntraId`. -12. **Isolation context propagation: static `IsolationKeys.Current` + DI `IIsolationKeysAccessor`, both backed by `AsyncLocal`.** Distinct from the app-level isolation key produced by `IIdentityResolver`. `IsolationKeys` carries the Foundry runtime's per-request partition hints lifted off `x-agent-user-isolation-key` / `x-agent-chat-isolation-key` headers by ASP.NET Core middleware the host registers automatically. *Providers* (a future Foundry-partitioned history/state store) read `IsolationKeys.Current` to scope backend calls. Channels themselves are oblivious. Header names are the literal port for wire compat with Python. **v1 ships the plumbing only**; no Foundry-aware provider consumes it yet. -13. **Workflow channel surface: workflow-agnostic channels + one InvocationsChannel convenience.** Channels never branch on whether the target is an `AIAgent` or a `Workflow`. The workflow story is carried by (a) `WorkflowRunner : IHostedTargetRunner`, (b) generic `HostedRunResult` with `TResult = WorkflowRunResponse` for workflow targets, (c) free-form `ChannelRequest.Attributes` carrying workflow-specific knobs (reserved keys: `workflow.checkpoint_id`, `workflow.resume_token`), (d) workflow checkpoint storage stays on `WorkflowBuilder` plumbing — `IHostStateStore` does **not** manage workflow checkpoints. One convenience hook ships for v1: `WorkflowInvocationsResponseHook` in `.Hosting.Channels.Invocations` renders `RequestInfoEvent` as a standard envelope `{ "status": "awaiting_input", "request": {...}, "resume_token": "..." }`. App authors handle `RequestInfoEvent` rendering for Telegram / Responses via their own `IChannelResponseHook`. -14. **v1 scope: net-new only; existing extensions untouched.** v1 ships `ResponsesChannel` + `InvocationsChannel` + `TelegramChannel` on the new builder, plus core infrastructure, plus the `FoundryHostedAgentRunner` adapter in `Foundry.Hosting`. **Existing `Hosting.OpenAI` / `Hosting.A2A` / `Hosting.AGUI.AspNetCore` / `Hosting.AzureFunctions` / `Foundry.Hosting` `Map*` extensions stay completely untouched — no `[Obsolete]`, no rewrite, no shim.** Mirrors Python's v1 stance (A2A / AGUI / DevUI are explicitly out of scope for first implementation). Tier 2 migration (`[Obsolete]` recommendations + internal rewrite to delegate to the new builder) is a focused fast-follow release once v1 has stabilized. -15. **Migration: no hard breaks anywhere.** Including in alpha packages. When Tier 2 lands, existing extensions deprecate with at least one release of overlap before any removal is considered. +1. **Posture: minimal core, translate.** Scope = ADR-0027. ADR-0028 capabilities are deferred and must not + appear in the v1 public surface. +2. **Composition: builder-centric.** `builder.AddAgentFrameworkHost(target).AddXxxChannel(...)` then + `app.MapAgentFrameworkHost()`. +3. **Channel contract: `abstract class Channel` + hook interfaces.** `Channel` exposes `Name`, `Path`, + `ConfigureServices`, `Contribute`. `ChannelContribution` (record, init-only) carries routes, endpoint + filters (middleware), commands, and startup/shutdown callbacks. Optional per-channel hooks + (`IChannelRunHook`, `IChannelResponseHook`, `IChannelStreamTransformHook`) are small separate interfaces. +4. **Channel lifecycle: two-phase.** `ConfigureServices(IServiceCollection)` at `AddXxxChannel` time; + `Contribute(IChannelContext)` at `MapAgentFrameworkHost` time. Matches the ASP.NET Core + `ConfigureServices` + `Configure` split. +5. **Target runner: swappable `IHostedTargetRunner`.** `AIAgentRunner` for `AIAgent`, `WorkflowRunner` for + `Workflow`. Channels never branch on target type. (A Foundry hosted-agent runner is a later, additive + package — not part of this slice.) +6. **Session continuity: explicit `ChannelSession(IsolationKey)`.** The host treats `IsolationKey` as an + opaque **session partition key**, not proof of identity. Channels / host middleware must authenticate and + authorize any externally supplied value before passing it to the host. Two channels share history only + when they produce the same `IsolationKey`. +7. **`ChannelIdentity` is request metadata only.** In v1 it is **not** a linking, authorization, or delivery + key. (Those uses are ADR-0028.) +8. **Host state store: new `IHostStateStore`, limited.** Owns only reset-session aliases and workflow + checkpoint path derivation. It does **not** store linked identities, active-channel state, response + routing, continuation records, durable runner queues, or delivery attempts (all ADR-0028). Ships + `InMemoryHostStateStore` and `FileHostStateStore`. +9. **Hosting target: Generic Host + ASP.NET Core.** `AddAgentFrameworkHost(this IHostApplicationBuilder, target)` + accepts both `WebApplicationBuilder` and `HostApplicationBuilder`. HTTP routes via + `app.MapAgentFrameworkHost(this IEndpointRouteBuilder)`. Non-HTTP channels auto-start via `IHostedService`. +10. **Naming: literal port.** Host type `AgentFrameworkHost`; extensions `AddAgentFrameworkHost(...)` / + `MapAgentFrameworkHost(...)`; channel-add extensions `AddResponsesChannel(...)`. +11. **Isolation context propagation: static `IsolationKeys.Current` + DI `IIsolationKeysAccessor`,** both + backed by `AsyncLocal`, lifted from `x-agent-user-isolation-key` / + `x-agent-chat-isolation-key` by host middleware **only when the Foundry hosting environment flag is + present**. Distinct from the app-level `IsolationKey`. v1 ships the plumbing; reusing the header names + does not make this the supported Foundry Hosted Agents surface. +12. **Workflow channel surface: workflow-agnostic channels.** Carried by `WorkflowRunner : IHostedTargetRunner`, + generic `HostedRunResult`, free-form `ChannelRequest.Attributes` (reserved key + `workflow.checkpoint_id` for caller-supplied checkpoint resume), and workflow checkpoint storage on + `WorkflowBuilder`. The host does not own a continuation store in v1. +13. **v1 scope: net-new only.** Existing `Hosting.OpenAI` / `Hosting.A2A*` / `Hosting.AGUI.AspNetCore` / + `Hosting.AzureFunctions` / `Foundry.Hosting` `Map*` extensions stay untouched. ## Package layout @@ -88,123 +100,58 @@ The full grilling log lives in the session glossary. The bullets below summarize ``` Microsoft.Agents.AI.Hosting.Channels (core) ├── AgentFrameworkHost +├── AgentFrameworkHostOptions (StatePaths) ├── IAgentFrameworkHostBuilder -├── HostApplicationBuilderHostingChannelsExtensions (AddAgentFrameworkHost on IHostApplicationBuilder) -├── EndpointRouteBuilderHostingChannelsExtensions (MapAgentFrameworkHost on IEndpointRouteBuilder) -├── Channel (abstract class) -├── ChannelContribution (record, init-only) -├── ChannelRequest (record; full Python parity) -├── ChannelSession (record; all fields nullable) -├── SessionMode (enum: Auto / Required / Disabled) -├── ChannelIdentity +├── HostApplicationBuilderHostingChannelsExtensions (AddAgentFrameworkHost on IHostApplicationBuilder) +├── EndpointRouteBuilderHostingChannelsExtensions (MapAgentFrameworkHost on IEndpointRouteBuilder) +├── Channel (abstract class) +├── ChannelContribution (record, init-only) ├── ChannelCommand -├── ResponseTarget (sealed abstract record + nested cases) -├── HostedRunResult (non-generic base) -├── HostedRunResult (generic envelope) -├── HostedStreamItem (envelope IAsyncEnumerable wraps) -├── IChannelContext (handed to Contribute) -├── ConversationScope (enum: PerUser / PerUserPerConversation / PerConversation) -├── AcceptInGroup (enum: MentionOnly / CommandOnly / MentionOrCommand / All) -├── IChannelPush (capability) -├── ChannelPushContext (per-delivery context) -├── IChannelPushCodec (capability) -├── IChannelRunHook -├── IChannelResponseHook -├── ChannelResponseContext +├── ChannelCommandContext +├── ChannelRequest (record) +├── ChannelSession (record; Key / ConversationId / IsolationKey nullable) +├── SessionMode (enum: Auto / Required / Disabled) +├── ChannelIdentity (request metadata only) +├── HostedRunResult (non-generic base) +├── HostedRunResult (generic envelope) +├── HostedStreamItem (Update / Event / Completed) +├── IChannelContext +├── IChannelRunHook + ChannelRunHookContext +├── IChannelResponseHook + ChannelResponseContext ├── IChannelStreamTransformHook -├── IConfidentialityTagged (link policy tier) -├── IHostedTargetRunner (seam) -│ ├── AIAgentRunner (built in) -│ └── WorkflowRunner (built in) -├── IIdentityAllowlist (Allow / Deny / Abstain tri-state) -├── IIdentityLinker -├── AuthorizationContext (phase, identity, claims, source) -├── AuthorizationOutcome (Allowed / LinkRequired / Denied) -├── AuthorizationPhase (enum: PreLink / PostLink) -├── ClaimSource (enum: None / Channel / Linker) -├── AllowlistDecision (enum) -├── AuthorizationProfile (factory: Open / ForcedLink / NativeAllowlist / LinkedClaimAllowlist / Mixed) -├── AllowAllIdentityAllowlist -├── NativeIdAllowlist -├── LinkedClaimAllowlist -├── AnyOfIdentityAllowlist (combinator) -├── AllOfIdentityAllowlist (combinator) -├── CallableIdentityAllowlist (escape hatch) -├── LinkChallenge -├── LinkedIdentity -├── PrincipalIdentity (linker result; verified claims + native id) -├── OneTimeCodeIdentityLinker (zero-dep built-in) -├── ILinkPolicy (decides which channels may share an isolation key / deliver to one another) -├── LinkPolicyContext (Source / Destination / Operation) -├── AllowAllLinkPolicy -├── SameConfidentialityTierLinkPolicy -├── ExplicitAllowListLinkPolicy -├── DenyAllLinkPolicy -├── IHostStateStore (identity registry + link grants + last-seen + continuations + session reset) -├── ChannelIdentityRegistration (record persisted by the store) -├── LinkGrant (record persisted by the store) -├── LastSeenRecord (record persisted by the store) -├── ContinuationToken (record; status + result + isolation key) +├── IHostedTargetRunner +│ ├── AIAgentRunner +│ └── WorkflowRunner +├── WorkflowRunResult (Completed / AwaitingInput / Failed) +├── IHostStateStore (reset-session aliases + checkpoint path only) ├── InMemoryHostStateStore ├── FileHostStateStore -├── HostStatePathOptions (Root / RunnerPath / LinksPath / ContinuationsPath / LastSeenPath) -├── IDurableTaskRunner (Register / Schedule / Get / Cancel) -├── TaskHandle (record; opaque task id) -├── DurableTaskPayloadMode (enum: Object / Json) -├── RetryPolicy (record) -├── InProcessDurableTaskRunner (IHostedService + bounded Channel) -├── IsolationKeys (record + static AsyncLocal slot) -├── IIsolationKeysAccessor (DI wrapper) -└── IsolationKeysMiddleware (lifts x-agent-*-isolation-key headers) +├── HostStatePathOptions (Root / RunnerPath-free; Aliases / Checkpoints) +├── IsolationKeys +├── IIsolationKeysAccessor +└── IsolationKeysMiddleware (Foundry-flag gated header lift) Microsoft.Agents.AI.Hosting.Channels.Responses ├── ResponsesChannel -├── ResponsesChannelOptions (Path / RunHook / ExposeConversations / Transports) -└── AgentFrameworkHostBuilderResponsesExtensions (AddResponsesChannel) - -Microsoft.Agents.AI.Hosting.Channels.Invocations -├── InvocationsChannel -├── InvocationsChannelOptions (Path / RunHook / OpenApiSpec) -├── WorkflowInvocationsResponseHook (RequestInfoEvent envelope) -└── AgentFrameworkHostBuilderInvocationsExtensions (AddInvocationsChannel) - -Microsoft.Agents.AI.Hosting.Channels.Telegram -├── TelegramChannel (uses Telegram.Bot) -├── TelegramChannelOptions (BotToken / Transport / Path / ConversationScope / AcceptInGroup / RequireLink / Commands / RegisterNativeCommands) -└── AgentFrameworkHostBuilderTelegramExtensions (AddTelegramChannel) -``` - -### v1 NuGet packages (additive change to existing) - -``` -Microsoft.Agents.AI.Foundry.Hosting (untouched existing surface) -└── FoundryHostedAgentRunner : IHostedTargetRunner (NEW — additive) +├── ResponsesChannelOptions (Path / RunHook / ResponseHook) +└── AgentFrameworkHostBuilderResponsesExtensions (AddResponsesChannel) ``` ### v1 NuGet packages (untouched) -``` -Microsoft.Agents.AI.Hosting.OpenAI (unchanged — keeps MapOpenAIResponses) -Microsoft.Agents.AI.Hosting.A2A (unchanged) -Microsoft.Agents.AI.Hosting.A2A.AspNetCore (unchanged) -Microsoft.Agents.AI.Hosting.AGUI.AspNetCore (unchanged) -Microsoft.Agents.AI.Hosting.AzureFunctions (unchanged) -``` - -### Fast-follow packages (post-v1) - -``` -Microsoft.Agents.AI.Hosting.Channels.DurableTask (DurableTaskFrameworkRunner wrapping existing DTF integration) -Microsoft.Agents.AI.Hosting.Channels.Discord (mirrors Python PR #6081) -Microsoft.Agents.AI.Hosting.Channels.Activity (Teams / DirectLine / WebChat via Activity Protocol) -Microsoft.Agents.AI.Hosting.Channels.EntraId (EntraIdentityLinker) -``` +`Microsoft.Agents.AI.Hosting.OpenAI`, `.Hosting.A2A`, `.Hosting.A2A.AspNetCore`, `.Hosting.AGUI.AspNetCore`, +`.Hosting.AzureFunctions`, `.Foundry.Hosting`. The Responses channel reuses Responses models / parsing from +`Microsoft.Agents.AI.Hosting.OpenAI` where practical rather than reimplementing the wire format. ## API changes -> All signatures below are draft; final names, nullability annotations, and `Experimental` attributes get sharpened during implementation. The shape and ergonomics are what reviewers should evaluate. Every public type ships with the standard copyright header and is annotated `[Experimental(DiagnosticIds.Experiments.)]` for the v1 release. - -> **Namespace convention.** Public types live in `Microsoft.Agents.AI.Hosting.Channels` (and `*.Responses`, `*.Invocations`, `*.Telegram`). Extension methods follow the repo convention: `IHostApplicationBuilder` extensions live in `namespace Microsoft.Extensions.Hosting`; `IEndpointRouteBuilder` extensions live in `namespace Microsoft.AspNetCore.Builder`; `IServiceCollection` extensions live in `namespace Microsoft.Extensions.DependencyInjection`. Channel-add extensions on `IAgentFrameworkHostBuilder` live in `Microsoft.Agents.AI.Hosting.Channels`. +> Draft signatures; nullability and `Experimental` attributes sharpen during implementation. Every public +> type ships the standard copyright header and is `[Experimental(...)]` for the v1 release. +> +> **Namespace convention.** Public types live in `Microsoft.Agents.AI.Hosting.Channels` (and `*.Responses`). +> `IHostApplicationBuilder` extensions live in `namespace Microsoft.Extensions.Hosting`; `IEndpointRouteBuilder` +> extensions in `namespace Microsoft.AspNetCore.Builder`; channel-add extensions in +> `Microsoft.Agents.AI.Hosting.Channels`. ### Host + builder @@ -213,36 +160,22 @@ namespace Microsoft.Agents.AI.Hosting.Channels; public sealed class AgentFrameworkHost { - internal AgentFrameworkHost(IServiceProvider services); - public IServiceProvider Services { get; } public IReadOnlyList Channels { get; } public IHostedTargetRunner TargetRunner { get; } + public IHostStateStore StateStore { get; } + public AgentFrameworkHostOptions Options { get; } - public ValueTask RunInBackgroundAsync( - ChannelRequest request, - CancellationToken cancellationToken = default); + public ValueTask RunAsync(ChannelRequest request, CancellationToken cancellationToken = default); + public IAsyncEnumerable StreamAsync(ChannelRequest request, CancellationToken cancellationToken = default); - public ValueTask GetContinuationAsync( - string token, - CancellationToken cancellationToken = default); - - public ValueTask ResetSessionAsync( - string isolationKey, - CancellationToken cancellationToken = default); - - public ValueTask AuthorizeAsync( - ChannelIdentity identity, - AuthorizationRequest options, - CancellationToken cancellationToken = default); + // Rotates the active session-id alias for an isolation key (host-tracked channels' /new-style commands). + public ValueTask ResetSessionAsync(string isolationKey, CancellationToken cancellationToken = default); } -public sealed record AuthorizationRequest +public sealed record AgentFrameworkHostOptions { - public bool RequireLink { get; init; } - public IIdentityAllowlist? Allowlist { get; init; } - public IReadOnlyDictionary? VerifiedClaims { get; init; } - public ConversationContext? ConversationContext { get; init; } + public HostStatePathOptions? StatePaths { get; init; } } ``` @@ -251,38 +184,15 @@ namespace Microsoft.Extensions.Hosting; public static class HostApplicationBuilderHostingChannelsExtensions { - // The three primary target overloads mirror Python's HostableTarget union. public static IAgentFrameworkHostBuilder AddAgentFrameworkHost( - this IHostApplicationBuilder builder, - AIAgent target, - Action? configure = null); + this IHostApplicationBuilder builder, AIAgent target, Action? configure = null); public static IAgentFrameworkHostBuilder AddAgentFrameworkHost( - this IHostApplicationBuilder builder, - Workflow target, - Action? configure = null); + this IHostApplicationBuilder builder, Workflow target, Action? configure = null); - // Keyed overload aligns with existing AddAIAgent(key, ...) ergonomics. - public static IAgentFrameworkHostBuilder AddAgentFrameworkHost( - this IHostApplicationBuilder builder, - string agentKey, - Action? configure = null); - - // Factory overload — host resolves the target lazily so the runner can be replaced from DI. public static IAgentFrameworkHostBuilder AddAgentFrameworkHost( - this IHostApplicationBuilder builder, - Func targetFactory, - Action? configure = null) - where TTarget : class; -} - -public sealed record AgentFrameworkHostOptions -{ - public IIdentityAllowlist? DefaultAllowlist { get; init; } - public ILinkPolicy? LinkPolicy { get; init; } - public HostStatePathOptions? StatePaths { get; init; } - public string? DefaultDurableRunnerName { get; init; } - public bool AllowInProcessRunnerInEphemeralMode { get; init; } + this IHostApplicationBuilder builder, Func targetFactory, + Action? configure = null) where TTarget : class; } ``` @@ -291,10 +201,7 @@ namespace Microsoft.AspNetCore.Builder; public static class EndpointRouteBuilderHostingChannelsExtensions { - // Returns IEndpointConventionBuilder so authors can attach .RequireAuthorization() etc. on - // every host-owned endpoint at once (e.g. all per-channel route groups). - public static IEndpointConventionBuilder MapAgentFrameworkHost( - this IEndpointRouteBuilder endpoints); + public static IEndpointConventionBuilder MapAgentFrameworkHost(this IEndpointRouteBuilder endpoints); } ``` @@ -306,18 +213,9 @@ public interface IAgentFrameworkHostBuilder IServiceCollection Services { get; } AgentFrameworkHostOptions Options { get; } - // Generic AddChannel + per-channel-package extension methods (AddResponsesChannel, etc.). IAgentFrameworkHostBuilder AddChannel(Channel channel); - IAgentFrameworkHostBuilder AddChannel(Func factory) - where TChannel : Channel; - - // Replace the default identity linker / allowlist / link policy registration. - IAgentFrameworkHostBuilder UseIdentityLinker() where TLinker : class, IIdentityLinker; - IAgentFrameworkHostBuilder UseDefaultAllowlist(IIdentityAllowlist allowlist); - IAgentFrameworkHostBuilder UseLinkPolicy(ILinkPolicy policy); + IAgentFrameworkHostBuilder AddChannel(Func factory) where TChannel : Channel; - // Replace the default durable runner / host state store registration. - IAgentFrameworkHostBuilder UseDurableTaskRunner() where TRunner : class, IDurableTaskRunner; IAgentFrameworkHostBuilder UseHostStateStore() where TStore : class, IHostStateStore; } ``` @@ -328,27 +226,15 @@ public interface IAgentFrameworkHostBuilder public abstract class Channel { public abstract string Name { get; } - - // The mount root for this channel's routes. The host wraps Routes in `endpoints.MapGroup(Path)` - // before invoking each action, so route actions should map paths relative to Path. - // Path = "" mounts at the host's own root. - public virtual string Path => string.Empty; - - // Runs at AddChannel time (pre-Build). Channels register their own DI services here. + public virtual string Path => string.Empty; // host wraps Routes in endpoints.MapGroup(Path) public virtual void ConfigureServices(IServiceCollection services) { } - - // Runs at MapAgentFrameworkHost time (post-Build). public abstract ChannelContribution Contribute(IChannelContext context); } public sealed record ChannelContribution { - // Each action is invoked with a group builder rooted at Channel.Path. public IReadOnlyList> Routes { get; init; } = []; - - // Endpoint filters applied to the Path-rooted group (replaces Python's `middleware`). - public IReadOnlyList EndpointFilters { get; init; } = []; - + public IReadOnlyList EndpointFilters { get; init; } = []; // host-level middleware public IReadOnlyList Commands { get; init; } = []; public Func? OnStartup { get; init; } public Func? OnShutdown { get; init; } @@ -357,117 +243,43 @@ public sealed record ChannelContribution public interface IChannelContext { IServiceProvider Services { get; } + AgentFrameworkHost Host { get; } IHostStateStore StateStore { get; } - IDurableTaskRunner DurableRunner { get; } - - // Authorization is owned by the host. Channels call this after extracting ChannelIdentity - // and any natively verified claims; the result is an AuthorizationOutcome the channel - // projects onto its protocol (200 / 403 / link-required envelope). - ValueTask AuthorizeAsync( - ChannelIdentity identity, - AuthorizationRequest options, - CancellationToken cancellationToken); - // The non-generic host run/stream entry. Workflow-friendly base type for results. - ValueTask RunAsync(ChannelRequest request, CancellationToken cancellationToken); - - // Streaming yields HostedStreamItem envelopes (HostedStreamUpdate / HostedStreamEvent / - // HostedStreamCompleted), so the host can surface both typed agent updates and protocol- - // specific events (workflow RequestInfoEvent, AG-UI StateSnapshotEvent, ...) behind one - // stream type. - IAsyncEnumerable StreamAsync(ChannelRequest request, CancellationToken cancellationToken); - - // Schedules outbound delivery for non-originating destinations via the durable runner. - // Resolves the destination set (Originating / Active / Channel / ...) against the configured - // LinkPolicy + IHostStateStore, then enqueues one `hosting.push` task per non-originating - // destination. Originating delivery is NOT scheduled here — channels render their own - // originating reply synchronously. Returns one TaskHandle per scheduled push. - ValueTask> ScheduleResponseAsync( - HostedRunResult result, - ChannelRequest originating, - CancellationToken cancellationToken); + ValueTask RunAsync(ChannelRequest request, CancellationToken cancellationToken = default); + IAsyncEnumerable StreamAsync(ChannelRequest request, CancellationToken cancellationToken = default); } ``` -> **Hook discovery and host-managed extensibility.** Built-in channels implement the relevant capability interfaces themselves and pull app-supplied behavior off their options (`ResponsesChannelOptions.RunHook`, `TelegramChannelOptions.ResponseHook`, etc.). The host discovers capabilities by checking `channel is IChannelPush`, `channel is IChannelResponseHook`, etc. Third-party channel authors follow the same pattern: implement the capability on the channel class itself and route app-configurable concerns through the channel's options record. +> Hooks are discovered by the host: a channel implements `IChannelRunHook` / `IChannelResponseHook` / +> `IChannelStreamTransformHook` directly, or routes app-supplied hooks through its options record +> (`ResponsesChannelOptions.RunHook`, etc.). `ChannelRunHook` runs after channel parsing and before target +> invocation; `ChannelResponseHook` runs after invocation and before the originating channel serializes; +> `ChannelStreamTransformHook` is applied by the host while the channel consumes streamed updates. ### Channel-neutral request envelope ```csharp public sealed record ChannelRequest { - // Originating channel name (matches Channel.Name). public required string Channel { get; init; } - - // Operation kind: "message.create", "command.invoke", "approval.respond", ... - public required string Operation { get; init; } - - // Reuses framework input types. Boxed as object because the union spans AIAgentRunInput, - // ChatMessage[], a workflow-typed input, etc. - public required object Input { get; init; } - - // Session hint from the channel. Nullable: caller-supplied channels populate it from the - // wire; host-tracked channels leave it null and let the host per-isolation-key alias decide. + public required string Operation { get; init; } // "message.create", "command.invoke", ... + public required object Input { get; init; } // string / ChatMessage / IEnumerable / workflow input public ChannelSession? Session { get; init; } - - // Channel-native USER identity observed on this request (never the chat / conversation id). - public ChannelIdentity? Identity { get; init; } - - // Protocol-visible conversation/thread identifier when one exists. In multi-user surfaces - // (Telegram groups, Teams team channels) this differs from Identity.NativeId. + public ChannelIdentity? Identity { get; init; } // request metadata only public string? ConversationId { get; init; } - - // Caller-derived chat options forwarded onto ChatOptions used by the target runner. Reuses - // Microsoft.Extensions.AI.ChatOptions so chat-client knobs (temperature, top_p, response_format, - // tool choice, additional properties) pass through without translation. public ChatOptions? Options { get; init; } - - // Whether host-managed session use is automatic, mandatory, or bypassed. public SessionMode SessionMode { get; init; } = SessionMode.Auto; - - // Protocol-level metadata for telemetry. Host code never reads this; reserved for channel - // private bookkeeping. public IReadOnlyDictionary Metadata { get; init; } = ImmutableDictionary.Empty; - - // Channel-specific structured values surfaced to the run hook (signature state, capability - // hints, deployment-specific knobs parsed off `extra_body`). Two reserved keys for workflow - // targets: "workflow.checkpoint_id" and "workflow.resume_token" (see "Workflow channels"). public IReadOnlyDictionary Attributes { get; init; } = ImmutableDictionary.Empty; - - // Bidirectional, mutable per-request state slot for event-rich front-ends (AG-UI). - // Opaque to the host; channels thread it through a channel-owned ContextProvider. - public IDictionary? ClientState { get; init; } - - // Frontend tool catalog supplied per request. Forwarded onto ChatOptions but tool execution - // returns to the client (host never invokes them). - public IReadOnlyList? ClientTools { get; init; } - - // Pass-through bag for channel-protocol extras the run hook needs to route into the target - // (e.g. AG-UI `resume` / `command` / HITL response payloads). Opaque to the host. - public IReadOnlyDictionary? ForwardedProps { get; init; } - - // Whether to invoke StreamAsync rather than RunAsync. public bool Stream { get; init; } - - // Where the response is delivered. Defaults to ResponseTarget.Originating. - public ResponseTarget? ResponseTarget { get; init; } - - // If true, host returns a ContinuationToken immediately rather than awaiting the response. - // Forced true when ResponseTarget is ResponseTarget.None. - public bool Background { get; init; } } public sealed record ChannelSession { - // Stable host lookup key for an AgentSession. Caller-supplied channels populate from the - // wire (previous_response_id, etc.). Host-tracked channels leave null. - public string? Key { get; init; } - + public string? Key { get; init; } // caller-supplied (previous_response_id, ...) public string? ConversationId { get; init; } - - // Opaque isolation boundary (user, tenant, chat, ...) using hosted-agent terminology. - public string? IsolationKey { get; init; } - + public string? IsolationKey { get; init; } // opaque session partition key public IReadOnlyDictionary Attributes { get; init; } = ImmutableDictionary.Empty; } @@ -477,9 +289,11 @@ public sealed record ChannelIdentity(string Channel, string NativeId) } public enum SessionMode { Auto, Required, Disabled } +``` -// Non-generic base lets channels and the host operate against HostedRunResult without committing -// to a TResult; generic subclass preserves full-fidelity typed access. +### Results + streaming + +```csharp public abstract record HostedRunResult { public ChannelSession? Session { get; init; } @@ -489,466 +303,42 @@ public abstract record HostedRunResult public sealed record HostedRunResult : HostedRunResult { public required TResult Result { get; init; } - public override object? ResultObject => Result; - - // Shallow clone with a rewritten Result (per-destination response-hook rebinding). - public HostedRunResult Replace(TNew newResult) => - new() { Result = newResult, Session = Session }; + public override object? ResultObject => this.Result; + public HostedRunResult Replace(TNew newResult) => new() { Result = newResult, Session = this.Session }; } -// One item produced by IChannelContext.StreamAsync — covers both agent updates and workflow events. -// HostedStreamUpdate wraps the normalized agent stream (lossless for messages, function calls, -// usage). HostedStreamEvent passes through protocol-specific events the framework does not model -// (workflow RequestInfoEvent, AG-UI StateSnapshotEvent, ToolCallStartEvent). HostedStreamCompleted -// is always the terminal item and carries the final HostedRunResult for downstream bookkeeping -// (intended_targets envelope, durable push scheduling). public abstract record HostedStreamItem; -public sealed record HostedStreamUpdate(AgentRunResponseUpdate Update) : HostedStreamItem; -public sealed record HostedStreamEvent(object Event) : HostedStreamItem; -public sealed record HostedStreamCompleted(HostedRunResult Result) : HostedStreamItem; +public sealed record HostedStreamUpdate(AgentResponseUpdate Update) : HostedStreamItem; // normalized agent stream +public sealed record HostedStreamEvent(object Event) : HostedStreamItem; // protocol/workflow events +public sealed record HostedStreamCompleted(HostedRunResult Result) : HostedStreamItem; // terminal ``` -### Response target - -`ResponseTarget` directs where the host delivers the agent response. Independent of `SessionMode`. Mirrors Python's `ResponseTarget` factory + variants. - -```csharp -public abstract record ResponseTarget -{ - public static readonly ResponseTarget Originating = new OriginatingResponseTarget(); - public static readonly ResponseTarget Active = new ActiveResponseTarget(); - public static readonly ResponseTarget AllLinked = new AllLinkedResponseTarget(); - public static readonly ResponseTarget None = new NoneResponseTarget(); - - public static ResponseTarget Channel(string channelName, bool echoInput = false) - => new ChannelResponseTarget(channelName, echoInput); - public static ResponseTarget Channels(IReadOnlyList channelNames, bool echoInput = false) - => new ChannelsResponseTarget(channelNames, echoInput); - public static ResponseTarget Identities(IReadOnlyList identities, bool echoInput = false) - => new IdentitiesResponseTarget(identities, echoInput); - public static ResponseTarget Identity(ChannelIdentity identity, bool echoInput = false) - => new IdentitiesResponseTarget([identity], echoInput); - - public sealed record OriginatingResponseTarget : ResponseTarget; - public sealed record ActiveResponseTarget : ResponseTarget; - public sealed record AllLinkedResponseTarget : ResponseTarget; - public sealed record NoneResponseTarget : ResponseTarget; - public sealed record ChannelResponseTarget(string ChannelName, bool EchoInput) : ResponseTarget; - public sealed record ChannelsResponseTarget(IReadOnlyList ChannelNames, bool EchoInput) : ResponseTarget; - public sealed record IdentitiesResponseTarget(IReadOnlyList Identities, bool EchoInput) : ResponseTarget; -} -``` - -**Fallback rules** (mirror Python): - -- When a destination channel does not implement `IChannelPush`, that destination is dropped and a warning is surfaced in telemetry; if the resolved set is empty, the host falls back to `Originating`. -- `LinkPolicy` is consulted for every destination; policy-dropped destinations are recorded in the assistant message's `intended_targets` envelope as `skipped_targets[].reason = "link_policy"`. -- `ResponseTarget.None` forces `Background = true` and returns a `ContinuationToken` on the originating wire. -- `EchoInput` causes the host to bundle a `role="user"` echo push and the agent reply into the same scheduled push task per non-originating destination; the runner tracks `echo_done` so a retry after the echo succeeded does not double-echo. - -### Capability interfaces - -```csharp -public interface IChannelPush -{ - ValueTask PushAsync( - ChannelPushContext context, - HostedRunResult payload, - CancellationToken cancellationToken); -} - -public sealed record ChannelPushContext -{ - public required ChannelIdentity Destination { get; init; } - public required ChannelRequest OriginatingRequest { get; init; } - public required string OriginatingChannel { get; init; } - public bool IsEcho { get; init; } - public ResponseTarget? OriginalTarget { get; init; } -} - -public interface IChannelPushCodec -{ - // Encode the whole push envelope so out-of-process runners (JSON payload mode) can reconstruct - // the destination identity, originating request, echo flag, and result on the worker side. - JsonNode Encode(ChannelPushContext context, HostedRunResult payload); - (ChannelPushContext Context, HostedRunResult Payload) Decode(JsonNode encoded); -} - -public interface IChannelRunHook -{ - // Runs AFTER the channel produces its default ChannelRequest and BEFORE the host resolves - // session behavior and calls the target. Canonical adapter point for workflow targets. - ValueTask OnRequestAsync( - ChannelRequest request, - ChannelRunHookContext context, - CancellationToken cancellationToken); -} - -public sealed record ChannelRunHookContext -{ - public required object Target { get; init; } // AIAgent or Workflow - public object? ProtocolRequest { get; init; } // raw inbound payload, loosely typed -} - -public interface IChannelResponseHook -{ - // Receives a per-destination clone of HostedRunResult and returns a (possibly rewritten) - // replacement. Hooks rebind via `result.Replace(...)` rather than mutating in place. - ValueTask OnResponseAsync( - HostedRunResult result, - ChannelResponseContext context, - CancellationToken cancellationToken); -} - -public sealed record ChannelResponseContext -{ - public required ChannelRequest Request { get; init; } - public required string ChannelName { get; init; } - public required ChannelIdentity DestinationIdentity { get; init; } - public bool Originating { get; init; } - public bool IsEcho { get; init; } -} - -public interface IChannelStreamTransformHook -{ - IAsyncEnumerable Transform( - IAsyncEnumerable upstream, - CancellationToken cancellationToken); -} - -public interface IConfidentialityTagged -{ - string? ConfidentialityTier { get; } // opaque label; null = single-tier -} -``` - -### Identity stack - -The host owns the authorization pipeline. Channels never run allowlists themselves — they call `host.AuthorizeAsync(...)` after extracting `ChannelIdentity` and any natively verified claims. - -```csharp -public enum AllowlistDecision { Allow, Deny, Abstain } -public enum AuthorizationPhase { PreLink, PostLink } -public enum ClaimSource { None, Channel, Linker } - -public interface IIdentityAllowlist -{ - // If true, the host startup validator rejects configurations where neither RequireLink=true - // nor a claim-emitting channel can deliver the claims this allowlist needs. Prevents the - // silent-deny-everyone footgun. - bool RequiresLinkedClaims => false; - - ValueTask EvaluateAsync( - AuthorizationContext context, - CancellationToken cancellationToken); -} - -public sealed record AuthorizationContext -{ - public required ChannelIdentity Identity { get; init; } - public required AuthorizationPhase Phase { get; init; } - public string? IsolationKey { get; init; } // null at PreLink; resolved at PostLink - public IReadOnlyDictionary VerifiedClaims { get; init; } - = ImmutableDictionary.Empty; - public ClaimSource ClaimSource { get; init; } = ClaimSource.None; - public ConversationContext? ConversationContext { get; init; } -} - -public sealed record ConversationContext(string? ConversationId, bool IsGroup); - -// Discriminated outcome of host.AuthorizeAsync(...). -public abstract record AuthorizationOutcome -{ - public sealed record Allowed(string IsolationKey) : AuthorizationOutcome; - - public sealed record LinkRequired(LinkChallenge Challenge) : AuthorizationOutcome; - - public sealed record Denied( - string ReasonCode, // stable machine-readable - string? UserMessage = null, // safe to render publicly - IReadOnlyDictionary? LogDetails = null // never shown to users - ) : AuthorizationOutcome; -} - -public interface IIdentityLinker -{ - string Name { get; } - - // Same shape as Channel.Contribute — lets the linker publish callback/verification routes. - ChannelContribution Contribute(IChannelContext context); - - ValueTask BeginAsync( - ChannelIdentity identity, - string? requestedIsolationKey, - CancellationToken cancellationToken); - - ValueTask CompleteAsync( - string challengeId, - IReadOnlyDictionary proof, - CancellationToken cancellationToken); - - // Returns the isolation key for an already-linked identity, or null if no link exists. - // When verifiedClaims contains entries that already match in the link store, the linker - // silently auto-merges the (channel, native_id) onto the existing isolation key and returns it. - ValueTask IsLinkedAsync( - ChannelIdentity identity, - IReadOnlyDictionary? verifiedClaims, - CancellationToken cancellationToken); -} - -public sealed record PrincipalIdentity( - string IsolationKey, - ChannelIdentity Identity, - IReadOnlyDictionary VerifiedClaims); - -public sealed record LinkChallenge( - string ChallengeId, - string Kind, // "url", "code", "mfa" - Uri? Url = null, - string? Code = null, - string? UserPrompt = null); - -// Built-in allowlist constructors return IIdentityAllowlist instances. -public static class AuthorizationProfile -{ - // require_link=false, allowlist=AllowAll. Every identity gets an auto-issued isolation key. - public static IIdentityAllowlist Open(); - - // require_link=true, allowlist=AllowAll. Any successfully-linked identity is admitted. - public static IIdentityAllowlist ForcedLink(); - - // require_link=false, NativeIdAllowlist(channel, ids). Pre-link, no IdP claim involved. - public static IIdentityAllowlist NativeAllowlist(string channel, params string[] nativeIds); - - // require_link=true, LinkedClaimAllowlist(claim, values). Forces link ceremony. - public static IIdentityAllowlist LinkedClaimAllowlist(string claim, params string[] values); - - // require_link=false, AnyOf(NativeIdAllowlist, LinkedClaimAllowlist). Native ids bypass link; - // everyone else funnels into it. - public static IIdentityAllowlist Mixed( - IIdentityAllowlist nativeAllowlist, - IIdentityAllowlist linkedClaimAllowlist); -} - -// Combinators -public sealed class AnyOfIdentityAllowlist(params IIdentityAllowlist[] children) : IIdentityAllowlist { /* ... */ } -public sealed class AllOfIdentityAllowlist(params IIdentityAllowlist[] children) : IIdentityAllowlist { /* ... */ } -``` - -**Authorization decision pipeline** (mirror Python). The host runs this inside `AuthorizeAsync(...)`: - -1. Build `AuthorizationContext(Phase = PreLink, VerifiedClaims = ..., ClaimSource = ...)`. -2. `pre = allowlist.EvaluateAsync(ctx)` — defaults to `Allow` when `allowlist is null`. -3. `pre == Deny` → `Denied(reasonCode: "allowlist_denied_pre_link", ...)`. -4. `pre == Allow`: - - If `RequireLink == true` and the linker has no record yet → `LinkRequired(linker.BeginAsync(identity))`. - - Otherwise → `Allowed(resolved-or-auto-issued isolation key)`. -5. `pre == Abstain`: - - If `RequireLink == true` **or** the allowlist declared `RequiresLinkedClaims` → call `linker.IsLinkedAsync(identity, verifiedClaims)`. - - Not linked → `LinkRequired(linker.BeginAsync(identity))`. - - Linked → re-evaluate at `Phase = PostLink` with linker-emitted claims. - - `Allow` → `Allowed(linked isolation key)`. - - `Deny` → `Denied(reasonCode: "allowlist_denied_post_link", ...)`. - - `Abstain` post-link is a misconfiguration; logged and treated as `Denied(reasonCode: "allowlist_abstain_after_link")`. - - Otherwise → `Allowed(auto-issued isolation key)`. - -**Default-open and all-abstain semantics.** With zero allowlists registered (or `allowlist: null`), every request is `Allowed` and auto-issues an isolation key keyed on `(Channel, NativeId)`. An all-abstain outcome at `PreLink` is treated as `Allow` when no `RequireLink` is set; at `PostLink` it is a misconfiguration as described above. - -**Inheritance.** Channel `allowlist` parameter has three states: `Inherit` (the host `DefaultAllowlist` applies), explicitly null (the channel is open even when the host has a default), or an explicit `IIdentityAllowlist` (overrides the host default; combine via `AllOfIdentityAllowlist(host.DefaultAllowlist, MyExtraList)` to add to it rather than replace). - -**Startup validation (fail-fast).** `AgentFrameworkHost` runs a validator at `MapAgentFrameworkHost(...)` startup: - -1. If any channel's resolved allowlist contains a node with `RequiresLinkedClaims == true`, the channel must either set `RequireLink = true` or declare via `Channel.EmitsVerifiedClaims = true` that it natively emits verified claims (e.g. an `ActivityChannel` carrying AAD `oid` on the inbound bearer). Otherwise: throw `ChannelConfigurationException`. -2. If any resolved allowlist contains `LinkedClaimAllowlist` and the host has no `IIdentityLinker` registered: throw `ChannelConfigurationException`. -3. If any channel has `RequireLink = true` and no `IIdentityLinker` is registered: throw `ChannelConfigurationException`. -4. `NativeIdAllowlist(channel: )` referencing an unknown channel: throw `ChannelConfigurationException`. - -Eager startup failure is intentional — silent deny-everyone is the worst possible default. - -### Link policy and confidentiality tier - -`ILinkPolicy` decides which channels may share an `IsolationKey` (consulted by `IIdentityLinker` on link attempts) and which channels may be a `ResponseTarget` for one another (consulted by the host's response-routing layer on every delivery). - -```csharp -public interface ILinkPolicy -{ - ValueTask EvaluateAsync(LinkPolicyContext context, CancellationToken cancellationToken); -} - -public sealed record LinkPolicyContext -{ - public required Channel Source { get; init; } - public required Channel Destination { get; init; } - public required LinkPolicyOperation Operation { get; init; } // Link or Deliver -} - -public enum LinkPolicyOperation { Link, Deliver } - -// Built-in policies -public sealed class AllowAllLinkPolicy : ILinkPolicy; // default -public sealed class SameConfidentialityTierLinkPolicy : ILinkPolicy; // most common -public sealed class ExplicitAllowListLinkPolicy(IReadOnlyList<(string Source, string Destination)> AllowedPairs) : ILinkPolicy; -public sealed class DenyAllLinkPolicy : ILinkPolicy; // share target, never sessions -``` - -Refusal during `Link` raises a typed error to the user. Refusal during `Deliver` excludes that destination from the route set and falls back to `Originating` if the route set becomes empty. - -### Host state store - -`IHostStateStore` is the single persistence seam for **host-execution metadata** that outlives a single request: continuation tokens, identity registry, identity-link grants, and last-seen `(IsolationKey, Channel)` records. Separate from `AgentSessionStore` (per-conversation history) and `WorkflowBuilder.CheckpointStorage` (workflow checkpoints). +### Host state store (limited) ```csharp public interface IHostStateStore { - // ---- Identity registry: (channel, native_id) <-> isolation_key with atomic merge ---- - - ValueTask GetIsolationKeyAsync( - ChannelIdentity identity, CancellationToken cancellationToken); - - // Atomically registers (or merges) a channel-native identity onto an isolation key. If the - // identity is already mapped to a different isolation key, both keys' (channel, native_id) - // records are merged onto the requested key. Optional verifiedClaims are persisted alongside - // so future channels presenting the same claim auto-link without a second ceremony. - ValueTask SaveLinkAsync( - ChannelIdentity identity, - string isolationKey, - IReadOnlyDictionary? verifiedClaims, - CancellationToken cancellationToken); - - ValueTask> GetIdentitiesAsync( - string isolationKey, CancellationToken cancellationToken); - - ValueTask LookupByVerifiedClaimAsync( - string claim, string value, CancellationToken cancellationToken); - - // ---- Link grants (Entra OAuth state, one-time codes) ---- - - ValueTask SaveLinkGrantAsync(LinkGrant grant, CancellationToken cancellationToken); - ValueTask GetLinkGrantAsync(string code, CancellationToken cancellationToken); - ValueTask ConsumeLinkGrantAsync(string code, CancellationToken cancellationToken); - - // ---- Last-seen ledger backing ResponseTarget.Active ---- - - ValueTask RecordLastSeenAsync( - string isolationKey, - ChannelIdentity identity, - string? conversationId, - DateTimeOffset at, - CancellationToken cancellationToken); - - ValueTask GetLastSeenAsync( - string isolationKey, CancellationToken cancellationToken); - - // ---- Continuation tokens (background runs) ---- - - ValueTask SaveContinuationAsync(ContinuationToken token, CancellationToken cancellationToken); - ValueTask GetContinuationAsync(string token, CancellationToken cancellationToken); - ValueTask DeleteContinuationAsync(string token, CancellationToken cancellationToken); - - // ---- Session alias rotation (backs host.ResetSessionAsync for host-tracked channels) ---- - + // Session-alias rotation backing ResetSessionAsync (host-tracked channels' /new). ValueTask RotateSessionAliasAsync(string isolationKey, CancellationToken cancellationToken); ValueTask GetActiveSessionAliasAsync(string isolationKey, CancellationToken cancellationToken); -} -public sealed record ChannelIdentityRegistration( - ChannelIdentity Identity, - DateTimeOffset RegisteredAt, - IReadOnlyDictionary VerifiedClaims); - -public sealed record LinkGrant( - string Code, - string IssuedByLinker, - string? RequestedIsolationKey, - DateTimeOffset ExpiresAt, - IReadOnlyDictionary Payload); - -public sealed record LastSeenRecord( - ChannelIdentity Identity, - string? ConversationId, - DateTimeOffset At); - -public sealed record ContinuationToken -{ - public required string Token { get; init; } - public required ContinuationStatus Status { get; init; } - public string? IsolationKey { get; init; } - public required DateTimeOffset CreatedAt { get; init; } - public DateTimeOffset? CompletedAt { get; init; } - public HostedRunResult? Result { get; init; } - public string? Error { get; init; } - public ResponseTarget? ResponseTarget { get; init; } + // Workflow checkpoint path derivation for an isolation key. + ValueTask GetCheckpointLocationAsync(string isolationKey, CancellationToken cancellationToken); } -public enum ContinuationStatus { Queued, Running, Completed, Failed } - public sealed record HostStatePathOptions { - public string? Root { get; init; } // shorthand: derives all subpaths if not set - public string? RunnerPath { get; init; } // in-process durable runner persistence - public string? LinksPath { get; init; } // identity registry + link grants - public string? ContinuationsPath { get; init; } - public string? LastSeenPath { get; init; } + public string? Root { get; init; } // shorthand: derives subpaths if unset + public string? AliasesPath { get; init; } // reset-session aliases + public string? CheckpointsPath { get; init; } // workflow checkpoint derivation root } ``` -> **Workflow checkpoint storage is not an `IHostStateStore` concern.** Per decision 13, workflow checkpoints stay on `WorkflowBuilder.CheckpointStorage`. There is no `CheckpointsPath` on `HostStatePathOptions`. - -> **Default selection by runtime mode.** Pure worker / dev (`HostingMode.LongRunning` per Python parlance): `FileHostStateStore` keyed off `HostStatePathOptions.Root` (defaults to `./.afhost/`). ASP.NET web app: same default. `HostingMode.Ephemeral` (Foundry hosted-agent runtime, scale-to-zero): caller must wire an external store (Cosmos, SQL, Redis); falling back to in-memory is rejected at startup unless `AgentFrameworkHostOptions.AllowInProcessRunnerInEphemeralMode = true`. In v1 only `InMemoryHostStateStore` and `FileHostStateStore` ship in core; external implementations land in fast-follow per req #24. - -### Durable task runner +> Workflow checkpoint *storage* stays on `WorkflowBuilder.CheckpointStorage`. `IHostStateStore` only derives +> the per-isolation-key location. There is no continuation store, link grant, last-seen ledger, or identity +> registry in v1. -The host delegates non-originating push fan-out and background runs to a pluggable `IDurableTaskRunner`. Channels never see it directly; they emit `IChannelPush.PushAsync(...)` and the runner schedules + retries. - -```csharp -public interface IDurableTaskRunner -{ - // Each runner implementation declares its payload mode. Json-mode runners (out-of-process - // sidecars, gRPC TaskHub) require channels with non-JSON payloads to expose an IChannelPushCodec. - DurableTaskPayloadMode PayloadMode { get; } - - // Registers a handler under a name. The host registers "hosting.push" at startup; channel - // authors typically do not register their own handlers. - void Register(string name, Func handler); - - ValueTask ScheduleAsync( - string name, - object payload, - RetryPolicy? retryPolicy, - CancellationToken cancellationToken); - - ValueTask GetAsync(TaskHandle handle, CancellationToken cancellationToken); - ValueTask CancelAsync(TaskHandle handle, CancellationToken cancellationToken); -} - -public enum DurableTaskPayloadMode { Object, Json } - -public sealed record TaskHandle(string TaskId, string Name); - -public enum TaskStatus { Scheduled, Running, Succeeded, Failed, Cancelled } - -public sealed record TaskInvocationContext( - string Name, - object Payload, - int Attempt, - IDictionary State); // mutable runner-owned per-task state (e.g. echo_done) - -public sealed record RetryPolicy -{ - public int MaxAttempts { get; init; } = 5; - public TimeSpan InitialBackoff { get; init; } = TimeSpan.FromSeconds(1); - public double BackoffMultiplier { get; init; } = 2.0; - public TimeSpan MaxBackoff { get; init; } = TimeSpan.FromSeconds(60); -} -``` - -**Codec/runner pairing.** At startup the host runs `_validateRunnerCodecPairing`: if `runner.PayloadMode == Json` and any push-capable channel does not implement `IChannelPushCodec`, throw `ChannelConfigurationException` so the misconfiguration is caught before traffic. - -**In-process runner shutdown drain.** `InProcessDurableTaskRunner` ships a two-phase shutdown driven by `ShutdownGraceSeconds` (default 5s). After lifespan shutdown signals, in-flight `"hosting.push"` tasks are given the grace period to finish; on expiry, remaining tasks are cancelled and their `OperationCanceledException` is swallowed (expected shutdown shape, not logged as a failure). - -**Echo idempotency on retry.** The host's `"hosting.push"` handler tracks an `echo_done` cursor on `TaskInvocationContext.State`. A retry after the echo succeeded but before the response push completed will not double-echo. The cursor lives on runner-owned task state, not the message — same principle as "intent only on the message, operational state in the runner". - -### Hosted target runner (Foundry-reuse seam) +### Hosted target runner ```csharp public interface IHostedTargetRunner @@ -957,372 +347,150 @@ public interface IHostedTargetRunner IAsyncEnumerable StreamAsync(ChannelRequest request, CancellationToken cancellationToken); } -// Built into core: internal sealed class AIAgentRunner(AIAgent agent) : IHostedTargetRunner { /* ... */ } internal sealed class WorkflowRunner(Workflow workflow) : IHostedTargetRunner { /* ... */ } - -// Lives in Microsoft.Agents.AI.Foundry.Hosting (additive — no break to existing surface): -public sealed class FoundryHostedAgentRunner(FoundryHostedAgentHandle handle) : IHostedTargetRunner { /* ... */ } ``` -### Workflow channels: resume model - -Channels never branch on target type. The workflow story is carried by: - -- `WorkflowRunner : IHostedTargetRunner` (returns `HostedRunResult`). -- `ChannelRequest.Attributes` reserved keys: - - `"workflow.resume_token"` (string) — **opaque host-issued correlation id** persisted on `IHostStateStore` as a `ContinuationToken` whose `Status = "awaiting_input"` and whose payload includes (a) the workflow instance reference and (b) the originating `RequestInfoEvent.Request`. Issued by the host whenever the workflow emits a `RequestInfoEvent` and the channel responds with a "needs input" envelope. The caller posts back with the same token to resume. - - `"workflow.checkpoint_id"` (string) — **direct opt-in for advanced callers** who already know the workflow checkpoint id (e.g. a UI that wants to fork from a specific past state). Passed straight to `Workflow.RunAsync(checkpointId: ...)`. Mutually exclusive with `workflow.resume_token`. -- `WorkflowInvocationsResponseHook` (ships in `.Invocations`) renders `RequestInfoEvent` as `{ "status": "awaiting_input", "request": {...}, "resume_token": "..." }`. App authors handle `RequestInfoEvent` rendering for Telegram / Responses via their own `IChannelResponseHook`. - -The host-side wiring: when `WorkflowRunner` produces a `WorkflowRunResponse` that contains a pending `RequestInfoEvent`, the host issues a `ContinuationToken`, stores it on `IHostStateStore`, and surfaces the token via `HostedRunResult.Session.Attributes["workflow.resume_token"]`. The channel's response hook reads it and projects it onto the wire. On the next request carrying `Attributes["workflow.resume_token"]`, the host looks up the `ContinuationToken`, retrieves the workflow instance + correlation id, and calls `Workflow.RunAsync(resumeToken: ...)`. - -### Built-in routes - -For built-in channels, `Channel.Path` is the configurable mount root. The channel package owns the fixed protocol-relative suffix. Final route = `Path` + suffix. - -| Channel | Default `Path` | Default exposed route(s) | -|---|---|---| -| `ResponsesChannel` | `/responses` | `/responses/v1` and nested response/conversation routes | -| `InvocationsChannel` | `/invocations` | `/invocations/invoke` (sync) and `/invocations/{continuationToken}` (poll) | -| `TelegramChannel` | `/telegram` | webhook transport: `/telegram/webhook`; polling transport: no required HTTP route (uses `IHostedService` long-poll loop) | - -Overrides only replace the outer mount root: - -```csharp -builder.AddAgentFrameworkHost(agent) - .AddResponsesChannel(o => o.Path = "/public/responses") // -> /public/responses/v1 - .AddInvocationsChannel(o => o.Path = "/internal/invocations") // -> /internal/invocations/invoke - .AddTelegramChannel(o => o.Path = "/bots/telegram"); // -> /bots/telegram/webhook -``` - -### Multi-user conversations - -Telegram groups, Telegram forum topics, and future Teams group chats / team channels share a uniform contract. Two axes that channels MUST keep separate: - -- `ChannelIdentity.NativeId` is always the **user** (`from.id` / AAD `oid`). In 1:1 chats it often coincides with the chat id; in groups it does not. -- `ChannelRequest.ConversationId` is the **chat / channel / thread** locator. +`WorkflowRunner` drives `InProcessExecution.RunStreamingAsync`, accumulates `WorkflowOutputEvent`, and pauses +on `RequestInfoEvent` into `WorkflowRunResult { Status = AwaitingInput }`. Resume is **caller-driven** via a +checkpoint reference supplied on `ChannelRequest.Attributes["workflow.checkpoint_id"]`; there is no +host-owned continuation token in v1. -Channels expose `ConversationScope` controlling how the host derives the resolved isolation key in multi-user surfaces: - -| Scope | Isolation key derivation in multi-user conversations | Pick when | -|---|---|---| -| `PerUser` | The user's isolation key from identity resolution only — group and DM share state. | Personal-assistant agents where memory follows the user. Risky if the agent emits user-specific data in a public group. | -| `PerUserPerConversation` (default for multi-user) | `f"{userIsolationKey}:{conversationId}"` — same user gets a different isolation key per group / channel / topic / DM. | Default and safest. Per-conversation memory isolation. | -| `PerConversation` | `f"_conv:{channel}:{conversationId}"` — every member of the group shares one isolation key and one `AgentSession`. | "Bot lives in this channel" — meeting-notes bot, shared scratchpad, support-triage queue. | - -1:1 chats always derive the isolation key from the user identity alone. - -`AcceptInGroup` controls inbound filtering on group surfaces: - -| Mode | Semantics | Default for | -|---|---|---| -| `MentionOnly` | Accept only `@bot` mentions. | Telegram groups, future Teams group chats / channels | -| `CommandOnly` | Accept only registered `ChannelCommand` invocations. | — | -| `MentionOrCommand` | Either of the above. | — | -| `All` | Accept every inbound message. | 1:1 chats; opt-in for groups when the bot is the only conversational participant | - -Messages not satisfying the rule are filtered at the channel layer — no `ChannelRequest` is produced and the agent is never invoked. - -**Link ceremonies in groups MUST NOT post the challenge URL or code into a group conversation.** Channels detect group context (via `ConversationContext.IsGroup`) and, when `RequireLink = true` triggers a `LinkChallenge`, redirect the rendered challenge to the user's DM. Verified-claim auto-link is unaffected: a Teams `groupChat` request carrying an AAD-verified `from.aadObjectId` that already matches an existing claim in the link store merges silently with no group-visible artifact. - -### Channel session-carriage models - -Channels split into two families based on who owns the session identifier across requests: - -| Model | Examples | `ChannelSession.Key` source | "New thread" UX | -|---|---|---|---| -| **Caller-supplied session** | Responses, Invocations, A2A, MCP | Wire payload (`previous_response_id`, `conversation_id`, body `session_id`). `null` means ephemeral. | Caller omits the previous id. | -| **Host-tracked session** | Telegram, Activity Protocol, future WhatsApp / Discord | Channel leaves `ChannelSession.Key = null`; host alias decides which `AgentSession` to resolve. | Channel exposes a `/new`-style `ChannelCommand` that calls `host.ResetSessionAsync(isolationKey)`. | - -`host.ResetSessionAsync(isolationKey)` rotates the active session-id alias rather than deleting on-disk history: prior history remains addressable by its original session id; subsequent runs for that `IsolationKey` resolve to a brand-new `AgentSession`. Caller-supplied channels do not call `ResetSessionAsync`. - -A single `AgentFrameworkHost` mounts channels from both families. A user can chat on Telegram (host-tracked) and have it linked via `IIdentityLinker` to a Responses-channel session keyed by `previous_response_id`; the linker's identity merge collapses both sides onto the same `IsolationKey`. - -### Isolation keys (Foundry runtime partition hints) +### Isolation keys ```csharp public sealed record IsolationKeys(string? UserKey, string? ChatKey) { public static AsyncLocal CurrentSlot { get; } = new(); - public static IsolationKeys? Current - { - get => CurrentSlot.Value; - set => CurrentSlot.Value = value; - } - - public bool IsEmpty => UserKey is null && ChatKey is null; - + public static IsolationKeys? Current { get => CurrentSlot.Value; set => CurrentSlot.Value = value; } + public bool IsEmpty => this.UserKey is null && this.ChatKey is null; public const string UserHeader = "x-agent-user-isolation-key"; public const string ChatHeader = "x-agent-chat-isolation-key"; } -public interface IIsolationKeysAccessor -{ - IsolationKeys? Current { get; } -} +public interface IIsolationKeysAccessor { IsolationKeys? Current { get; } } ``` -`IsolationKeys` is **distinct from** the app-level isolation key produced by `IIdentityLinker`. It carries the Foundry runtime's per-request partition hints lifted off `x-agent-user-isolation-key` / `x-agent-chat-isolation-key` headers by middleware the host registers automatically. Providers (a future Foundry-partitioned history/state store) read `IsolationKeys.Current` to scope backend calls. Channels themselves are oblivious. v1 ships the plumbing; the first consumer lands as a fast-follow `FoundryHistoryProvider`. +`IsolationKeysMiddleware` lifts the headers into `IsolationKeys.Current` **only when the Foundry hosting +environment flag is present**; absent the flag, raw isolation headers are ignored. -### Intended targets and durable delivery +## Responses channel -When `ResponseTarget != Originating`, the host fans the response out using a synchronous-on-originating, scheduled-elsewhere pattern: +`Microsoft.Agents.AI.Hosting.Channels.Responses` maps OpenAI Responses-shaped requests and streams onto the +host and renders Responses-compatible output, reusing the Responses models / converters / streaming +generators from `Microsoft.Agents.AI.Hosting.OpenAI` where practical. -1. The host runs the target once. The result is a single `HostedRunResult`. -2. The channel calls `IChannelContext.ScheduleResponseAsync(result, originatingRequest, ...)`. Internally the host resolves the destination set (consulting `IHostStateStore.GetLastSeenAsync` for `Active`, `GetIdentitiesAsync` for `AllLinked`, and `ILinkPolicy.EvaluateAsync` to filter every entry). -3. If `Originating` is one of the destinations (i.e. when the target is `AllLinked` / `Channels([..., originating])` / etc.), the originating channel renders that destination synchronously on the inbound HTTP response (or polling reply) so the caller sees the answer without waiting for the durable runner. -4. For every non-originating destination, the host schedules one push task per destination on `IDurableTaskRunner` under the reserved handler name `"hosting.push"`. The runner invokes `IChannelPush.PushAsync(channelPushContext, payload)` with the appropriate `ChannelPushContext`. -5. Per-destination `IChannelResponseHook.OnResponseAsync` runs **inside** the push task immediately before `PushAsync`, so per-channel rebinds (e.g. JSON dump rendering for one channel, plain-text rendering for another) do not block the originating reply. - -**Intended-targets bookkeeping (persisted on the assistant message).** The host writes a single envelope to the assistant message's `additional_properties["hosting"]` describing the routing decision at the moment of dispatch: +```csharp +public sealed class ResponsesChannel : Channel +{ + public override string Name => "responses"; + public override string Path => this._options.Path; // default "/responses" + public override ChannelContribution Contribute(IChannelContext context); // POST {Path} + nested response routes +} -```json +public sealed class ResponsesChannelOptions { - "hosting": { - "originating_channel": "responses", - "response_target": { "kind": "all_linked", "echo_input": true }, - "intended_targets": [ - { "channel": "responses", "native_id": "user_42", "echo": false }, - { "channel": "telegram", "native_id": "12345678", "echo": true } - ], - "skipped_targets": [ - { "channel": "discord", "native_id": "8675309", "reason": "link_policy" } - ] - } + public string Path { get; set; } = "/responses"; + public IChannelRunHook? RunHook { get; set; } + public IChannelResponseHook? ResponseHook { get; set; } } ``` -`intended_targets[]` is immutable once written and represents intent. `skipped_targets[]` carries pre-dispatch filtering (link policy, missing `IChannelPush` capability, all-channel resolution returning empty for a given identity). Per-destination delivery failures live on `IDurableTaskRunner` task state, not the message — same partition rule. - - +- A Responses request maps to a `ChannelRequest` (`Operation = "message.create"`, `Input` = parsed input + items, `Session.Key` = `previous_response_id` when present, `Session.IsolationKey` derived by the channel + or host middleware from a trusted source). +- The originating Responses response (and SSE stream) is rendered by the channel. Streaming serialization + stays in the channel; the host applies `IChannelStreamTransformHook` as updates are consumed. +- For a `Workflow` target, the run hook prepares typed workflow input and the channel renders + `RequestInfoEvent` as the protocol's awaiting-input shape; resume is caller-driven via the checkpoint id. ## E2E code samples -### Sample 1: Responses + Telegram sharing one agent and one session per user +### Sample 1: Responses agent on one host ```csharp -using Microsoft.Agents.AI.Hosting.Channels; using Microsoft.Agents.AI.Hosting.Channels.Responses; -using Microsoft.Agents.AI.Hosting.Channels.Telegram; -using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Hosting; +using OpenAI.Chat; var builder = WebApplication.CreateBuilder(args); -var agent = new AzureOpenAIChatClient(/* ... */) - .CreateAIAgent(name: "Concierge", instructions: "You are a helpful concierge."); - -builder.AddAgentFrameworkHost(agent, o => - { - o.DefaultAllowlist = new AnyOfIdentityAllowlist( - AuthorizationProfile.LinkedClaimAllowlist("email", "*@contoso.com"), - AuthorizationProfile.NativeAllowlist("telegram", "12345678")); - o.StatePaths = new HostStatePathOptions { Root = "./.afhost" }; - }) - .UseIdentityLinker() - .UseHostStateStore() - .AddResponsesChannel() - .AddTelegramChannel(o => - { - o.BotToken = builder.Configuration["Telegram:BotToken"]!; - o.ConversationScope = ConversationScope.PerUserPerConversation; - o.AcceptInGroup = AcceptInGroup.MentionOnly; - }); +AIAgent agent = new AzureOpenAIClient(new Uri(endpoint), new DefaultAzureCredential()) + .GetChatClient(deployment) + .AsAIAgent(name: "Concierge", instructions: "You are a helpful concierge."); + +builder.AddAgentFrameworkHost(agent) + .AddResponsesChannel(); var app = builder.Build(); app.MapAgentFrameworkHost(); app.Run(); ``` -End-state behavior: - -1. A Responses API client posts with `previous_response_id`. The host resolves a `ChannelSession` keyed by the user's resolved `IsolationKey` and re-uses the existing `AgentSession`. -2. The same user messages Telegram (the user's Telegram id is in the `NativeIdAllowlist`, so they are admitted pre-link with an auto-issued isolation key). To collapse to the existing Responses-side isolation key they type `/link ` once. `OneTimeCodeIdentityLinker.CompleteAsync` calls `IHostStateStore.SaveLinkAsync(telegramIdentity, responsesIsolationKey, ...)` which atomically merges the Telegram native id onto the same isolation key. -3. The next Telegram message hits the same `AgentSession` the Responses client was using. -4. Server-side push back to Telegram works through `TelegramChannel : IChannelPush`, registered as a durable-task handler at host startup. The Telegram `/new` command calls `host.ResetSessionAsync(isolationKey)` to start a fresh conversation without losing history. - -### Sample 2: Workflow target on InvocationsChannel with `RequestInfoEvent` rendering +### Sample 2: Responses-hosted workflow with run-hook input prep + checkpoints ```csharp -using Microsoft.Agents.AI.Hosting.Channels; -using Microsoft.Agents.AI.Hosting.Channels.Invocations; -using Microsoft.Extensions.Hosting; - -var builder = WebApplication.CreateBuilder(args); - var workflow = new WorkflowBuilder(checkpointStorage: new FileCheckpointStorage("./.checkpoints")) - .AddExecutor(/* application-defined intake executor */) + .AddExecutor(/* intake */) .Build(); -builder.AddAgentFrameworkHost(workflow) - .AddInvocationsChannel(); // WorkflowInvocationsResponseHook registered automatically +builder.AddAgentFrameworkHost(workflow, o => o.StatePaths = new HostStatePathOptions { Root = "./.afhost" }) + .AddResponsesChannel(o => o.RunHook = new MyWorkflowInputRunHook()); var app = builder.Build(); app.MapAgentFrameworkHost(); app.Run(); ``` -Inbound: - -``` -POST /invocations/invoke -{ "input": { "customerId": "...", "sku": "...", "quantity": 12 } } -``` - -If the workflow pauses on a `RequestInfoEvent`, `WorkflowInvocationsResponseHook` renders: - -```json -{ - "status": "awaiting_input", - "request": { /* the RequestInfoEvent.Request payload */ }, - "resume_token": "" -} -``` - -The host stored the workflow instance reference + correlation id under the continuation token. To resume, the caller posts with `Attributes["workflow.resume_token"]` set: - -``` -POST /invocations/invoke -{ - "input": { "approved": true, "approver": "alice" }, - "attributes": { "workflow.resume_token": "" } -} -``` - -The host promotes the attribute onto `ChannelRequest.Attributes`, `WorkflowRunner` reads `"workflow.resume_token"`, looks the entry up via `IHostStateStore.GetContinuationAsync(...)`, retrieves the persisted correlation id and workflow reference, then calls `Workflow.RunAsync(resumeToken: ...)`. Workflow checkpoint storage remains on `WorkflowBuilder` (`FileCheckpointStorage` here) and is never touched by the host. - -### Sample 3: Foundry hosted agent as target — same channels, same builder - -```csharp -using Microsoft.Agents.AI.Hosting.Channels.Responses; -using Microsoft.Extensions.Hosting; -using Microsoft.Agents.AI.Foundry.Hosting; // brings the AddFoundryHostedAgent overload - -var foundryHandle = await foundryClient.GetHostedAgentAsync("my-agent"); - -builder.AddFoundryHostedAgent(foundryHandle) // resolves FoundryHostedAgentRunner - .AddResponsesChannel(); -``` - -The `Microsoft.Agents.AI.Foundry.Hosting` package supplies an `AddFoundryHostedAgent(IHostApplicationBuilder, FoundryHostedAgentHandle, ...)` extension that internally calls `AddAgentFrameworkHost(_ => handle)` and registers `FoundryHostedAgentRunner` as the `IHostedTargetRunner`. The `ResponsesChannel` code is identical to Sample 1. Only the registered `IHostedTargetRunner` differs. - -### Sample 4: Authoring a new channel package - -```csharp -public sealed class MyWebhookChannel : Channel, IChannelPush -{ - public override string Name => "mywebhook"; - public override string Path => "/mywebhook"; - - public override ChannelContribution Contribute(IChannelContext context) => new() - { - Routes = - [ - // The host wraps this action in endpoints.MapGroup(Path), so "/inbound" mounts at "/mywebhook/inbound". - endpoints => endpoints.MapPost("/inbound", async (HttpContext http) => - { - var payload = await JsonSerializer.DeserializeAsync(http.Request.Body) - ?? throw new InvalidOperationException("empty body"); - - var identity = new ChannelIdentity(Channel: Name, NativeId: payload.AccountId); - - // Funnel through the host's authorization pipeline before invoking the target. - var auth = await context.AuthorizeAsync(identity, new AuthorizationRequest { }, http.RequestAborted); - if (auth is AuthorizationOutcome.Denied denied) - return Results.Forbid(authenticationSchemes: [denied.ReasonCode]); - if (auth is AuthorizationOutcome.LinkRequired link) - return Results.Json(new { status = "link_required", challenge = link.Challenge }); - - var allowed = (AuthorizationOutcome.Allowed)auth; - - var request = new ChannelRequest - { - Channel = Name, - Operation = "message.create", - Input = payload.Text, - Identity = identity, - Session = new ChannelSession - { - Key = payload.ThreadId, - ConversationId = payload.ThreadId, - IsolationKey = allowed.IsolationKey, - }, - }; - - var result = await context.RunAsync(request, http.RequestAborted); - return Results.Json(MyOutboundPayload.From(result)); - }), - ], - }; - - // The host invokes this from "hosting.push" durable tasks for every non-originating destination. - public ValueTask PushAsync(ChannelPushContext context, HostedRunResult payload, CancellationToken cancellationToken) - { - // Send to context.Destination.NativeId using whatever HTTP/SDK call this protocol needs. - return ValueTask.CompletedTask; - } -} -``` - -For richer scenarios the channel additionally implements `IChannelPushCodec` (required when paired with a JSON-payload durable runner), `IChannelRunHook`, `IChannelResponseHook`, or `IChannelStreamTransformHook`. Each capability is independent. - - - -## Migration story - -**v1: nothing changes for existing consumers.** Existing `MapOpenAIResponses`, `MapA2A`, `MapAGUI`, Foundry hosting, and Azure Functions handlers continue to ship and behave exactly as today. No `[Obsolete]` warnings, no shim code paths, no change in behavior or surface. - -**Fast follow (Tier 2):** internals of existing `Map*` extensions get rewritten to delegate to the new builder + a private channel. From the consumer's view this is invisible. An `[Obsolete]` recommendation pointing at the new builder ships at the same time so new code uses the new surface. Existing consumers get a deprecation warning but no break, and have at least one full release of overlap before any removal is considered. +The run hook turns the parsed Responses input into the workflow's typed input. If the workflow pauses on a +`RequestInfoEvent`, the channel renders an awaiting-input response; the caller resumes by re-invoking with +`attributes["workflow.checkpoint_id"]`. ## Test strategy -| Layer | Test type | What it proves | +| Layer | Test type | Proves | |---|---|---| -| Channel contract | Unit | `Channel.Contribute` returns a contribution; capability interfaces are independently implementable. | -| `AgentFrameworkHost` composition | Unit | `AddAgentFrameworkHost` + N `AddXxxChannel` produces a host whose `Channels` list matches; channel `ConfigureServices` runs pre-`Build`; `Contribute` runs post-`Build`. | -| `ResponsesChannel` wire compat | Integration (`TestServer`) | Post a Responses-shape request; assert the response round-trips the full `ChatMessage` content list (no lossy collapse to a single text field). | -| `InvocationsChannel` workflow path | Integration | Target a `Workflow`; `RequestInfoEvent` rendered by `WorkflowInvocationsResponseHook` produces the documented envelope; a subsequent request with `workflow.resume_token` resumes correctly. | -| `TelegramChannel` | Integration (mocked `Telegram.Bot`) | Inbound update produces a `ChannelRequest` with the correct identity; `IChannelPush.PushAsync` calls `sendMessage`. | -| Identity stack | Unit | `AnyOfIdentityAllowlist` short-circuits on first `Allow`; `Abstain` defers; `Deny` wins over `Abstain`. | -| `OneTimeCodeIdentityLinker` | Integration | End-to-end: begin produces a code, complete on the other channel collapses isolation keys, subsequent requests on either channel resolve to the same session. | -| `IHostStateStore` (file impl) | Unit | Per-component path overrides land on the right folders; missing paths fall back to in-memory; concurrent writes do not corrupt. | -| `InProcessDurableTaskRunner` | Unit + property | Schedule / Get / Cancel / Resume round-trip; bounded `Channel` does not drop; disk persistence replays after restart when `RunnerPath` is set. | -| `IsolationKeys` middleware | Unit (`TestServer`) | Headers lift into `IsolationKeys.Current` for the request scope; reset after; absent headers leave `Current` null. | -| `FoundryHostedAgentRunner` | Integration | Same channels work transparently against a Foundry hosted-agent handle as against a local `AIAgent`. | -| End-to-end smoke | Integration | Sample 1 above runs in-process, posts via Responses, asserts a Telegram push fires, asserts session continuity across channels. | +| Channel contract | Unit | `Contribute` returns routes/commands/lifecycle; host aggregates them under `MapGroup(Path)`. | +| Host composition | Unit | `AddAgentFrameworkHost` + `AddResponsesChannel` produces a host whose `Channels` list matches; `ConfigureServices` runs pre-Build, `Contribute` post-Build. | +| Session continuity | Unit | Identical `IsolationKey` resolves to the same cached `AgentSession`; `ResetSessionAsync` rotates the alias. | +| `ResponsesChannel` | Integration (`TestServer`) | Responses request round-trips the full `ChatMessage` content list (no lossy collapse); SSE stream renders. | +| Workflow path | Integration | `RequestInfoEvent` renders awaiting-input; re-invoke with `workflow.checkpoint_id` resumes. | +| Isolation middleware | Unit (`TestServer`) | Headers lift into `IsolationKeys.Current` only under the Foundry flag; ignored otherwise. | ## Phasing -1. **Core abstractions** — `Microsoft.Agents.AI.Hosting.Channels` package: `Channel` / `ChannelContribution` / `ChannelRequest` / `ChannelSession` / `ChannelIdentity` / `ResponseTarget` / `HostedRunResult` / `HostedStreamItem` / `IChannelContext` / capability interfaces / `IHostedTargetRunner` + built-in `AIAgentRunner` + `WorkflowRunner` / `IsolationKeys` plumbing. Identity registry primitives on `IHostStateStore` (`GetIsolationKeyAsync` / `SaveLinkAsync` / `LookupByVerifiedClaimAsync` / `RotateSessionAliasAsync`) and continuation tokens (`Save/Get/DeleteContinuationAsync`) ship in this phase — the host cannot resolve a session without them. `InMemoryHostStateStore` + `FileHostStateStore`. `InProcessDurableTaskRunner` + `RetryPolicy` + `TaskHandle` + `DurableTaskPayloadMode` + codec/runner pairing validation. Host startup validator (fail-fast rules). Unit tests per type. -2. **Identity allowlists + linker** — `IIdentityAllowlist` tri-state + `AllowAllIdentityAllowlist` / `NativeIdAllowlist` / `LinkedClaimAllowlist` / `AnyOfIdentityAllowlist` / `AllOfIdentityAllowlist` / `AuthorizationProfile` factory. `IIdentityLinker` + `OneTimeCodeIdentityLinker` (zero deps). `ILinkPolicy` + 4 built-ins. `host.AuthorizeAsync` pipeline + per-channel inheritance semantics. End-to-end integration test of cross-channel session collapse using `OneTimeCodeIdentityLinker` over an in-memory pair of dummy channels. -3. **ResponsesChannel + InvocationsChannel packages** — both prove a single host with two channels resolves to the same session under identity-linking. `WorkflowInvocationsResponseHook` ships with the Invocations package; `Attributes["workflow.resume_token"]` round-trip integration test. -4. **TelegramChannel package** — proves polling `IHostedService` lifecycle, webhook transport, `IChannelPush` + `IChannelPushCodec`, command registration via `setMyCommands`, group-vs-DM filtering (`AcceptInGroup`), per-conversation isolation (`ConversationScope`), link-challenge group-safety redirect to DM. -5. **Foundry runner adapter** — `FoundryHostedAgentRunner` lands in existing `Microsoft.Agents.AI.Foundry.Hosting` as an additive type, along with `AddFoundryHostedAgent` overload on `IHostApplicationBuilder`. Integration test against a mocked Foundry hosted-agent handle. -6. **Samples + docs** — port the cross-channel-continuity Python sample to .NET. README per package. Worked Telegram-and-Responses sample exercising every locked decision end-to-end. - -## Fast-follow work (out of v1) +1. **Core** — `Microsoft.Agents.AI.Hosting.Channels`: every type in the core layout. `InMemoryHostStateStore`, + `FileHostStateStore`, `AIAgentRunner`, `WorkflowRunner`. Unit tests per type. +2. **Responses channel** — `Microsoft.Agents.AI.Hosting.Channels.Responses` reusing Hosting.OpenAI Responses + models. Integration tests for agent + workflow targets. +3. **Samples + docs** — the two samples above. README per package. -- `Microsoft.Agents.AI.Hosting.Channels.DurableTask` adapter package wrapping `Microsoft.Agents.AI.DurableTask` (DTF). Validates the JSON payload codec path against a real out-of-process runner. -- `Microsoft.Agents.AI.Hosting.Channels.Discord` (mirrors Python PR #6081). -- `Microsoft.Agents.AI.Hosting.Channels.Activity` (Teams / DirectLine / WebChat via the Activity Protocol). Validates `EmitsVerifiedClaims = true` on the inbound bearer path. -- `Microsoft.Agents.AI.Hosting.Channels.EntraId` shipping `EntraIdentityLinker` with Entra / MSAL dependencies. -- Tier 2 migration: rewrite existing `MapOpenAIResponses` / `MapA2A` / `MapAGUI` internals to delegate to the new builder, ship `[Obsolete]` recommendations pointing at the new surface. -- Foundry-partitioned `IHostStateStore` provider that reads `IsolationKeys.Current` — the consumer that justifies the plumbing landing in v1. -- `IChannelCommandRegistrar` capability interface (registers slash commands with the native protocol — Telegram `setMyCommands`, Discord application commands). -- `AspNetCoreIdentityAllowlistAdapter` bridging `IIdentityAllowlist` to `Microsoft.AspNetCore.Authorization` policies for apps already standardized on that pipeline. +## Non-goals for v1 (deferred to ADR-0028) -## Open implementation questions +Deliberately **not** part of this contract; tracked by +[ADR-0028](../decisions/0028-hosting-linking-multicast-enhancements.md): -These are *implementation-detail* questions to resolve in code review, not blocking the design: +- cross-channel identity linking (`IIdentityLinker`, one-time-code / Entra linkers), +- identity allowlists / authorization policy (`IIdentityAllowlist`, `AuthorizationProfile`), +- response routing beyond the originating channel (`ResponseTarget`, active channel, all-linked), +- push or payload codecs (`IChannelPush`, `IChannelPushCodec`), +- background runs + continuation tokens, +- durable task runners (`IDurableTaskRunner`, in-process runner), +- retry / replay (`RetryPolicy`), +- fan-out / multicast / all-linked delivery, +- confidentiality tiers and `ILinkPolicy`, +- multi-user conversation scoping / group addressing (`ConversationScope`, `AcceptInGroup`), +- additional channel packages (Invocations, Telegram, Discord, Activity Protocol), +- a host-level multi-agent router. -- Whether `ChannelCommand` registration ships in v1 as a passive metadata record on `ChannelContribution` (channels read their own commands and call the native registration API themselves) or whether `IChannelCommandRegistrar` ships in v1. Default plan: passive record in v1, registrar capability in fast follow. -- Where `AspNetCoreIdentityAllowlistAdapter` lives. Default plan: separate `Microsoft.Agents.AI.Hosting.Channels.AspNetCore` package post-v1, so the core hosting package stays ASP.NET-Core-free for channel authors. -- Whether `FileHostStateStore`'s on-disk schema is documented for external tools to read, or treated as private. Default plan: private in v1, document if a real external use case appears. +These are follow-up enhancements, not prerequisites for shipping or using the v1 host. ## References -- Python spec: [`002-python-hosting-channels.md`](./002-python-hosting-channels.md) — canonical source of truth for cross-language semantics. -- Python source branch: [`feature/python-hosting`](https://github.com/microsoft/agent-framework/tree/feature/python-hosting). -- Python Discord channel PR (fast-follow reference): [microsoft/agent-framework#6081](https://github.com/microsoft/agent-framework/pull/6081). -- Existing .NET hosting packages this work coexists with: `Microsoft.Agents.AI.Hosting.OpenAI`, `Microsoft.Agents.AI.Hosting.A2A`, `Microsoft.Agents.AI.Hosting.A2A.AspNetCore`, `Microsoft.Agents.AI.Hosting.AGUI.AspNetCore`, `Microsoft.Agents.AI.Hosting.AzureFunctions`, `Microsoft.Agents.AI.Foundry.Hosting`, `Microsoft.Agents.AI.DurableTask`. +- .NET ADRs (scope): [`0027-hosting-channels.md`](../decisions/0027-hosting-channels.md) (v1 core), + [`0028-hosting-linking-multicast-enhancements.md`](../decisions/0028-hosting-linking-multicast-enhancements.md) (deferred). +- Python reference impl: PR microsoft/agent-framework#6580 (`agent-framework-hosting` + `agent-framework-hosting-responses`). +- Python spec: [`002-python-hosting-channels.md`](./002-python-hosting-channels.md). +- Existing .NET hosting packages this work coexists with: `Microsoft.Agents.AI.Hosting.OpenAI`, + `Microsoft.Agents.AI.Hosting.A2A(.AspNetCore)`, `Microsoft.Agents.AI.Hosting.AGUI.AspNetCore`, + `Microsoft.Agents.AI.Hosting.AzureFunctions`, `Microsoft.Agents.AI.Foundry.Hosting`. \ No newline at end of file diff --git a/dotnet/agent-framework-dotnet.slnx b/dotnet/agent-framework-dotnet.slnx index b1c71ca6c09..dc0d7749ddb 100644 --- a/dotnet/agent-framework-dotnet.slnx +++ b/dotnet/agent-framework-dotnet.slnx @@ -378,8 +378,8 @@ - - + + @@ -623,8 +623,7 @@ - - + diff --git a/dotnet/samples/04-hosting/HostingChannels/01_Invocations/README.md b/dotnet/samples/04-hosting/HostingChannels/01_Invocations/README.md deleted file mode 100644 index 18548564a2e..00000000000 --- a/dotnet/samples/04-hosting/HostingChannels/01_Invocations/README.md +++ /dev/null @@ -1,42 +0,0 @@ -# Invocations channel - -Mounts an `AIAgent` on the JSON Invocations channel from `Microsoft.Agents.AI.Hosting.Channels`. The smallest demonstration of `AddAgentFrameworkHost` + a single `AddInvocationsChannel` + `MapAgentFrameworkHost`. - -## What it shows - -* `IHostApplicationBuilder.AddAgentFrameworkHost(agent)` → `AddInvocationsChannel()` -* `IEndpointRouteBuilder.MapAgentFrameworkHost()` mounts every channel rooted at its `Path` -* `POST /invocations/invoke` runs synchronously and returns the agent text -* `POST /invocations/invoke` with `background: true` returns a continuation token -* `GET /invocations/{continuationToken}` polls the background run - -## Requirements - -* `AZURE_OPENAI_ENDPOINT` set, `az login` completed (DefaultAzureCredential) -* `AZURE_OPENAI_DEPLOYMENT_NAME` optional; defaults to `gpt-5.4-mini` - -## Try it - -```bash -cd dotnet/samples/04-hosting/HostingChannels/01_Invocations -dotnet run -``` - -Sync run: - -```bash -curl -X POST http://localhost:5000/invocations/invoke \ - -H "Content-Type: application/json" \ - -d '{ "input": "Tell me a short joke." }' -``` - -Background run + polling: - -```bash -TOKEN=$(curl -s -X POST http://localhost:5000/invocations/invoke \ - -H "Content-Type: application/json" \ - -d '{ "input": "Outline a recipe for chocolate chip cookies.", "background": true }' \ - | jq -r .continuation_token) - -curl http://localhost:5000/invocations/$TOKEN -``` \ No newline at end of file diff --git a/dotnet/samples/04-hosting/HostingChannels/01_Invocations/01_Invocations.csproj b/dotnet/samples/04-hosting/HostingChannels/01_ResponsesAgent/01_ResponsesAgent.csproj similarity index 79% rename from dotnet/samples/04-hosting/HostingChannels/01_Invocations/01_Invocations.csproj rename to dotnet/samples/04-hosting/HostingChannels/01_ResponsesAgent/01_ResponsesAgent.csproj index f7b81a079de..f86db164c73 100644 --- a/dotnet/samples/04-hosting/HostingChannels/01_Invocations/01_Invocations.csproj +++ b/dotnet/samples/04-hosting/HostingChannels/01_ResponsesAgent/01_ResponsesAgent.csproj @@ -5,8 +5,8 @@ Exe enable enable - InvocationsSample - InvocationsSample + ResponsesAgentSample + ResponsesAgentSample $(NoWarn);MAAI001;OPENAI001;CA1303 @@ -18,6 +18,6 @@ - + \ No newline at end of file diff --git a/dotnet/samples/04-hosting/HostingChannels/01_Invocations/Program.cs b/dotnet/samples/04-hosting/HostingChannels/01_ResponsesAgent/Program.cs similarity index 60% rename from dotnet/samples/04-hosting/HostingChannels/01_Invocations/Program.cs rename to dotnet/samples/04-hosting/HostingChannels/01_ResponsesAgent/Program.cs index b22c87f8217..e8bcb61388a 100644 --- a/dotnet/samples/04-hosting/HostingChannels/01_Invocations/Program.cs +++ b/dotnet/samples/04-hosting/HostingChannels/01_ResponsesAgent/Program.cs @@ -1,38 +1,36 @@ // Copyright (c) Microsoft. All rights reserved. -// Mounts an AIAgent on the JSON Invocations channel and exposes: -// POST /invocations/invoke run the agent synchronously -// POST /invocations/invoke with background:true return a continuation token -// GET /invocations/{continuationToken} poll for a queued / completed run +// Exposes an AIAgent on the OpenAI Responses-shaped channel. +// POST /responses { "input": "Hi" } -> Responses JSON +// POST /responses { "input": "Hi", "stream": true } -> SSE stream +// Session continuity is keyed by ChannelSession.IsolationKey; identical keys resolve to the same +// cached AgentSession. This sample lets the channel derive the session from previous_response_id. -#pragma warning disable CA1031 // demo-only top-level exception handling +#pragma warning disable CA1031 using Azure.AI.OpenAI; using Azure.Identity; using Microsoft.Agents.AI; -using Microsoft.Agents.AI.Hosting.Channels; -using Microsoft.Agents.AI.Hosting.Channels.Invocations; +using Microsoft.Agents.AI.Hosting.Channels.Responses; using Microsoft.AspNetCore.Builder; using Microsoft.Extensions.Hosting; using OpenAI.Chat; var endpoint = Environment.GetEnvironmentVariable("AZURE_OPENAI_ENDPOINT") ?? throw new InvalidOperationException("AZURE_OPENAI_ENDPOINT is not set."); -var deploymentName = Environment.GetEnvironmentVariable("AZURE_OPENAI_DEPLOYMENT_NAME") ?? "gpt-5.4-mini"; +var deployment = Environment.GetEnvironmentVariable("AZURE_OPENAI_DEPLOYMENT_NAME") ?? "gpt-5.4-mini"; // WARNING: DefaultAzureCredential is convenient for development but requires careful consideration in production. // In production, consider using a specific credential (e.g., ManagedIdentityCredential) to avoid // latency issues, unintended credential probing, and potential security risks from fallback mechanisms. AIAgent agent = new AzureOpenAIClient(new Uri(endpoint), new DefaultAzureCredential()) - .GetChatClient(deploymentName) + .GetChatClient(deployment) .AsAIAgent(name: "Concierge", instructions: "You are a helpful concierge."); var builder = WebApplication.CreateBuilder(args); -// builder.AddAgentFrameworkHost(agent) - .AddInvocationsChannel(); -// + .AddResponsesChannel(); var app = builder.Build(); app.MapAgentFrameworkHost(); diff --git a/dotnet/samples/04-hosting/HostingChannels/01_ResponsesAgent/README.md b/dotnet/samples/04-hosting/HostingChannels/01_ResponsesAgent/README.md new file mode 100644 index 00000000000..76df1b59f7c --- /dev/null +++ b/dotnet/samples/04-hosting/HostingChannels/01_ResponsesAgent/README.md @@ -0,0 +1,34 @@ +# Responses agent + +Exposes an `AIAgent` on the OpenAI Responses-shaped channel from +`Microsoft.Agents.AI.Hosting.Channels.Responses`. The smallest demonstration of +`AddAgentFrameworkHost(agent).AddResponsesChannel()` + `MapAgentFrameworkHost()`. + +## What it shows + +* One `AgentFrameworkHost` owning a single Responses channel +* `POST /responses` returning a Responses JSON object +* `POST /responses` with `"stream": true` returning a Server-Sent-Events stream +* Session continuity keyed by `ChannelSession.IsolationKey` (here derived from `previous_response_id`) + +## Requirements + +* `AZURE_OPENAI_ENDPOINT` set, `az login` completed (DefaultAzureCredential) +* `AZURE_OPENAI_DEPLOYMENT_NAME` optional; defaults to `gpt-5.4-mini` + +## Try it + +```bash +cd dotnet/samples/04-hosting/HostingChannels/01_ResponsesAgent +dotnet run +``` + +```bash +curl -X POST http://localhost:5000/responses \ + -H "Content-Type: application/json" \ + -d '{ "input": "Tell me a short joke." }' + +curl -N -X POST http://localhost:5000/responses \ + -H "Content-Type: application/json" \ + -d '{ "input": "Outline a haiku about spring.", "stream": true }' +``` \ No newline at end of file diff --git a/dotnet/samples/04-hosting/HostingChannels/02_Telegram/02_Telegram.csproj b/dotnet/samples/04-hosting/HostingChannels/02_ResponsesWorkflow/02_ResponsesWorkflow.csproj similarity index 79% rename from dotnet/samples/04-hosting/HostingChannels/02_Telegram/02_Telegram.csproj rename to dotnet/samples/04-hosting/HostingChannels/02_ResponsesWorkflow/02_ResponsesWorkflow.csproj index dcb7342e35d..5f989e46b7e 100644 --- a/dotnet/samples/04-hosting/HostingChannels/02_Telegram/02_Telegram.csproj +++ b/dotnet/samples/04-hosting/HostingChannels/02_ResponsesWorkflow/02_ResponsesWorkflow.csproj @@ -5,8 +5,8 @@ Exe enable enable - TelegramSample - TelegramSample + ResponsesWorkflowSample + ResponsesWorkflowSample $(NoWarn);MAAI001;OPENAI001;CA1303 @@ -18,6 +18,6 @@ - + \ No newline at end of file diff --git a/dotnet/samples/04-hosting/HostingChannels/02_ResponsesWorkflow/EchoExecutor.cs b/dotnet/samples/04-hosting/HostingChannels/02_ResponsesWorkflow/EchoExecutor.cs new file mode 100644 index 00000000000..38b55d27f7c --- /dev/null +++ b/dotnet/samples/04-hosting/HostingChannels/02_ResponsesWorkflow/EchoExecutor.cs @@ -0,0 +1,14 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Agents.AI.Workflows; + +namespace ResponsesWorkflowSample; + +/// Minimal single-executor workflow body: echoes the input string as the workflow output. +internal sealed class EchoExecutor() : Executor("EchoExecutor") +{ + public override ValueTask HandleAsync(string message, IWorkflowContext context, CancellationToken cancellationToken = default) => + ValueTask.FromResult($"echo: {message}"); +} \ No newline at end of file diff --git a/dotnet/samples/04-hosting/HostingChannels/02_ResponsesWorkflow/Program.cs b/dotnet/samples/04-hosting/HostingChannels/02_ResponsesWorkflow/Program.cs new file mode 100644 index 00000000000..93d6d710cae --- /dev/null +++ b/dotnet/samples/04-hosting/HostingChannels/02_ResponsesWorkflow/Program.cs @@ -0,0 +1,27 @@ +// Copyright (c) Microsoft. All rights reserved. + +// Exposes a Workflow on the OpenAI Responses-shaped channel. A run hook turns the parsed Responses +// input (ChatMessage list) into the workflow's typed string input. Workflow checkpoint storage lives on +// the workflow; the host derives a per-isolation-key checkpoint location from StatePaths. + +#pragma warning disable CA1031 + +using Microsoft.Agents.AI.Hosting.Channels; +using Microsoft.Agents.AI.Hosting.Channels.Responses; +using Microsoft.Agents.AI.Workflows; +using Microsoft.AspNetCore.Builder; +using Microsoft.Extensions.Hosting; +using ResponsesWorkflowSample; + +var builder = WebApplication.CreateBuilder(args); + +// Application-defined single-executor workflow that echoes its input. +var echo = new EchoExecutor(); +var workflow = new WorkflowBuilder(echo).WithOutputFrom(echo).Build(); + +builder.AddAgentFrameworkHost(workflow, o => o.StatePaths = new HostStatePathOptions { Root = "./.afhost" }) + .AddResponsesChannel(o => o.RunHook = new WorkflowInputRunHook()); + +var app = builder.Build(); +app.MapAgentFrameworkHost(); +app.Run(); \ No newline at end of file diff --git a/dotnet/samples/04-hosting/HostingChannels/02_ResponsesWorkflow/README.md b/dotnet/samples/04-hosting/HostingChannels/02_ResponsesWorkflow/README.md new file mode 100644 index 00000000000..4d2c77aa5ef --- /dev/null +++ b/dotnet/samples/04-hosting/HostingChannels/02_ResponsesWorkflow/README.md @@ -0,0 +1,28 @@ +# Responses-hosted workflow + +Exposes a `Workflow` on the OpenAI Responses-shaped channel. A run hook adapts the channel's parsed +Responses input (a `ChatMessage` list) into the workflow's typed string input before the host invokes the +workflow. + +## What it shows + +* `AddAgentFrameworkHost(workflow).AddResponsesChannel(o => o.RunHook = ...)` +* The run-hook seam (`IChannelRunHook`) for workflow input preparation +* Host `StatePaths` for per-isolation-key workflow checkpoint location derivation + +## Requirements + +No model credentials required; the workflow echoes its input. + +## Try it + +```bash +cd dotnet/samples/04-hosting/HostingChannels/02_ResponsesWorkflow +dotnet run +``` + +```bash +curl -X POST http://localhost:5000/responses \ + -H "Content-Type: application/json" \ + -d '{ "input": "ping" }' +``` \ No newline at end of file diff --git a/dotnet/samples/04-hosting/HostingChannels/02_ResponsesWorkflow/WorkflowInputRunHook.cs b/dotnet/samples/04-hosting/HostingChannels/02_ResponsesWorkflow/WorkflowInputRunHook.cs new file mode 100644 index 00000000000..36906de4c99 --- /dev/null +++ b/dotnet/samples/04-hosting/HostingChannels/02_ResponsesWorkflow/WorkflowInputRunHook.cs @@ -0,0 +1,29 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Agents.AI.Hosting.Channels; +using Microsoft.Extensions.AI; + +namespace ResponsesWorkflowSample; + +/// +/// Run hook that adapts the Responses channel's parsed input (a list) into the +/// workflow's typed string input before the host invokes the . +/// +internal sealed class WorkflowInputRunHook : IChannelRunHook +{ + public ValueTask OnRequestAsync(ChannelRequest request, ChannelRunHookContext context, CancellationToken cancellationToken) + { + var text = request.Input switch + { + string s => s, + IEnumerable messages => string.Join("\n", messages.Select(m => m.Text)), + _ => request.Input.ToString() ?? string.Empty, + }; + + return new(request with { Input = text }); + } +} \ No newline at end of file diff --git a/dotnet/samples/04-hosting/HostingChannels/02_Telegram/Program.cs b/dotnet/samples/04-hosting/HostingChannels/02_Telegram/Program.cs deleted file mode 100644 index 2ce98e11a52..00000000000 --- a/dotnet/samples/04-hosting/HostingChannels/02_Telegram/Program.cs +++ /dev/null @@ -1,48 +0,0 @@ -// Copyright (c) Microsoft. All rights reserved. - -// Mounts an AIAgent on the Telegram channel and serves messages via long-poll getUpdates. -// Per-conversation isolation (ConversationScope.PerUserPerConversation) keeps memory separate -// between the same user's DM and any groups the bot is added to. Group filtering accepts only -// @-mentions by default. - -#pragma warning disable CA1031 // demo-only top-level exception handling - -using Azure.AI.OpenAI; -using Azure.Identity; -using Microsoft.Agents.AI; -using Microsoft.Agents.AI.Hosting.Channels; -using Microsoft.Agents.AI.Hosting.Channels.Telegram; -using Microsoft.AspNetCore.Builder; -using Microsoft.Extensions.Hosting; -using OpenAI.Chat; - -var endpoint = Environment.GetEnvironmentVariable("AZURE_OPENAI_ENDPOINT") - ?? throw new InvalidOperationException("AZURE_OPENAI_ENDPOINT is not set."); -var deploymentName = Environment.GetEnvironmentVariable("AZURE_OPENAI_DEPLOYMENT_NAME") ?? "gpt-5.4-mini"; -var telegramToken = Environment.GetEnvironmentVariable("TELEGRAM_BOT_TOKEN") - ?? throw new InvalidOperationException("TELEGRAM_BOT_TOKEN is not set. Create a bot with @BotFather and set the token to run this sample."); - -// WARNING: DefaultAzureCredential is convenient for development but requires careful consideration in production. -// In production, consider using a specific credential (e.g., ManagedIdentityCredential) to avoid -// latency issues, unintended credential probing, and potential security risks from fallback mechanisms. -AIAgent agent = new AzureOpenAIClient(new Uri(endpoint), new DefaultAzureCredential()) - .GetChatClient(deploymentName) - .AsAIAgent(name: "Concierge", instructions: "You are a helpful concierge."); - -var builder = WebApplication.CreateBuilder(args); - -// -builder.AddAgentFrameworkHost(agent) - .AddTelegramChannel(o => - { - o.BotToken = telegramToken; - o.Transport = TelegramTransport.Polling; - o.ConversationScope = ConversationScope.PerUserPerConversation; - o.AcceptInGroup = AcceptInGroup.MentionOnly; - o.Commands.Add(new ChannelCommand("new", "Start a fresh conversation")); - }); -// - -var app = builder.Build(); -app.MapAgentFrameworkHost(); -app.Run(); \ No newline at end of file diff --git a/dotnet/samples/04-hosting/HostingChannels/02_Telegram/README.md b/dotnet/samples/04-hosting/HostingChannels/02_Telegram/README.md deleted file mode 100644 index 8379d6e6267..00000000000 --- a/dotnet/samples/04-hosting/HostingChannels/02_Telegram/README.md +++ /dev/null @@ -1,30 +0,0 @@ -# Telegram channel - -Mounts an `AIAgent` on the Telegram channel from `Microsoft.Agents.AI.Hosting.Channels.Telegram` and serves messages via long-poll `getUpdates`. - -## What it shows - -* `IHostApplicationBuilder.AddAgentFrameworkHost(agent)` → `AddTelegramChannel(...)` -* `TelegramTransport.Polling` driven from the channel's `OnStartup` hook (no public HTTP route) -* `ConversationScope.PerUserPerConversation` so the bot's memory is scoped per user per chat -* `AcceptInGroup.MentionOnly` filters out group chatter not directed at the bot -* `ChannelCommand` registered with Telegram via `setMyCommands` at startup - -## Requirements - -* `AZURE_OPENAI_ENDPOINT` set, `az login` completed (DefaultAzureCredential) -* `AZURE_OPENAI_DEPLOYMENT_NAME` optional; defaults to `gpt-5.4-mini` -* `TELEGRAM_BOT_TOKEN` from @BotFather (required) - -## Try it - -```bash -cd dotnet/samples/04-hosting/HostingChannels/02_Telegram -dotnet run -``` - -Open Telegram, find your bot, and send a message. In a group, add the bot and mention it (`@your_bot hello`) to trigger a reply. - -## Switching to webhook transport - -Set `o.Transport = TelegramTransport.Webhook` in `Program.cs`, register your public URL with Telegram via `setWebhook` (out of band), and the channel publishes `POST /telegram/webhook` for inbound updates. \ No newline at end of file diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting.Channels.Invocations/AgentFrameworkHostBuilderInvocationsExtensions.cs b/dotnet/src/Microsoft.Agents.AI.Hosting.Channels.Invocations/AgentFrameworkHostBuilderInvocationsExtensions.cs deleted file mode 100644 index be4ed1e1d20..00000000000 --- a/dotnet/src/Microsoft.Agents.AI.Hosting.Channels.Invocations/AgentFrameworkHostBuilderInvocationsExtensions.cs +++ /dev/null @@ -1,23 +0,0 @@ -// Copyright (c) Microsoft. All rights reserved. - -using System; -using Microsoft.Shared.Diagnostics; - -namespace Microsoft.Agents.AI.Hosting.Channels.Invocations; - -/// -/// Extensions on for the invocations channel. -/// -public static class AgentFrameworkHostBuilderInvocationsExtensions -{ - /// Add the JSON invocations channel. - public static IAgentFrameworkHostBuilder AddInvocationsChannel( - this IAgentFrameworkHostBuilder builder, - Action? configure = null) - { - Throw.IfNull(builder); - var options = new InvocationsChannelOptions(); - configure?.Invoke(options); - return builder.AddChannel(new InvocationsChannel(options)); - } -} \ No newline at end of file diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting.Channels.Invocations/Internal/InvocationsJsonContext.cs b/dotnet/src/Microsoft.Agents.AI.Hosting.Channels.Invocations/Internal/InvocationsJsonContext.cs deleted file mode 100644 index ba83c4a0dc9..00000000000 --- a/dotnet/src/Microsoft.Agents.AI.Hosting.Channels.Invocations/Internal/InvocationsJsonContext.cs +++ /dev/null @@ -1,13 +0,0 @@ -// Copyright (c) Microsoft. All rights reserved. - -using System.Text.Json.Serialization; - -namespace Microsoft.Agents.AI.Hosting.Channels.Invocations; - -[JsonSerializable(typeof(InvocationRequestModel))] -[JsonSerializable(typeof(InvocationResponseModel))] -[JsonSerializable(typeof(InvocationAwaitingInputModel))] -[JsonSerializable(typeof(InvocationContinuationModel))] -[JsonSerializable(typeof(InvocationErrorModel))] -[JsonSourceGenerationOptions(PropertyNamingPolicy = JsonKnownNamingPolicy.SnakeCaseLower)] -internal sealed partial class InvocationsJsonContext : JsonSerializerContext; \ No newline at end of file diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting.Channels.Invocations/Internal/InvocationsJsonModels.cs b/dotnet/src/Microsoft.Agents.AI.Hosting.Channels.Invocations/Internal/InvocationsJsonModels.cs deleted file mode 100644 index efdd36686f7..00000000000 --- a/dotnet/src/Microsoft.Agents.AI.Hosting.Channels.Invocations/Internal/InvocationsJsonModels.cs +++ /dev/null @@ -1,80 +0,0 @@ -// Copyright (c) Microsoft. All rights reserved. - -using System.Collections.Generic; -using System.Text.Json.Serialization; - -namespace Microsoft.Agents.AI.Hosting.Channels.Invocations; - -/// Inbound payload for POST {Path}/invoke. -internal sealed class InvocationRequestModel -{ - [JsonPropertyName("input")] - public object? Input { get; set; } - - [JsonPropertyName("attributes")] - public Dictionary? Attributes { get; set; } - - [JsonPropertyName("metadata")] - public Dictionary? Metadata { get; set; } - - [JsonPropertyName("background")] - public bool Background { get; set; } - - [JsonPropertyName("session_id")] - public string? SessionId { get; set; } - - [JsonPropertyName("isolation_key")] - public string? IsolationKey { get; set; } -} - -/// Successful response envelope for a completed run. -internal sealed class InvocationResponseModel -{ - [JsonPropertyName("status")] - public string Status { get; set; } = "completed"; - - [JsonPropertyName("text")] - public string? Text { get; set; } - - [JsonPropertyName("session_id")] - public string? SessionId { get; set; } - - [JsonPropertyName("continuation_token")] - public string? ContinuationToken { get; set; } -} - -/// Awaiting-input envelope when a workflow target paused on a RequestInfoEvent. -internal sealed class InvocationAwaitingInputModel -{ - [JsonPropertyName("status")] - public string Status { get; set; } = "awaiting_input"; - - [JsonPropertyName("request")] - public object? Request { get; set; } - - [JsonPropertyName("resume_token")] - public string? ResumeToken { get; set; } -} - -/// Queued / running envelope for background runs. -internal sealed class InvocationContinuationModel -{ - [JsonPropertyName("status")] - public string Status { get; set; } = "queued"; - - [JsonPropertyName("continuation_token")] - public string? ContinuationToken { get; set; } -} - -/// Error envelope. -internal sealed class InvocationErrorModel -{ - [JsonPropertyName("status")] - public string Status { get; set; } = "failed"; - - [JsonPropertyName("error_code")] - public string ErrorCode { get; set; } = string.Empty; - - [JsonPropertyName("message")] - public string? Message { get; set; } -} \ No newline at end of file diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting.Channels.Invocations/InvocationsChannel.cs b/dotnet/src/Microsoft.Agents.AI.Hosting.Channels.Invocations/InvocationsChannel.cs deleted file mode 100644 index 5612a0c4290..00000000000 --- a/dotnet/src/Microsoft.Agents.AI.Hosting.Channels.Invocations/InvocationsChannel.cs +++ /dev/null @@ -1,243 +0,0 @@ -// Copyright (c) Microsoft. All rights reserved. - -using System; -using System.Collections.Generic; -using System.Text.Json; -using System.Threading.Tasks; -using Microsoft.AspNetCore.Builder; -using Microsoft.AspNetCore.Http; -using Microsoft.AspNetCore.Routing; -using Microsoft.Shared.Diagnostics; - -namespace Microsoft.Agents.AI.Hosting.Channels.Invocations; - -/// -/// JSON invocation channel. Exposes POST {Path}/invoke for synchronous runs and -/// GET {Path}/{continuationToken} for polling background runs. -/// -public sealed class InvocationsChannel : Channel -{ - private readonly InvocationsChannelOptions _options; - - /// Initializes a new instance. - public InvocationsChannel(InvocationsChannelOptions options) - { - this._options = Throw.IfNull(options); - } - - /// - public override string Name => "invocations"; - - /// - public override string Path => this._options.Path; - - /// - public override ChannelContribution Contribute(IChannelContext context) - { - Throw.IfNull(context); - return new ChannelContribution - { - Routes = - [ - endpoints => - { - endpoints.MapPost("/invoke", (HttpContext http) => this.HandleInvokeAsync(context, http)); - endpoints.MapGet("/{continuationToken}", (string continuationToken, HttpContext http) => - this.HandleGetContinuationAsync(context, continuationToken, http)); - }, - ], - }; - } - - private async Task HandleInvokeAsync(IChannelContext context, HttpContext http) - { - InvocationRequestModel? body; - try - { - body = await JsonSerializer.DeserializeAsync(http.Request.Body, InvocationsJsonContext.Default.InvocationRequestModel, http.RequestAborted).ConfigureAwait(false); - } - catch (JsonException ex) - { - await WriteJsonAsync(http, StatusCodes.Status400BadRequest, - new InvocationErrorModel { ErrorCode = "invalid_json", Message = ex.Message }, InvocationsJsonContext.Default.InvocationErrorModel).ConfigureAwait(false); - return; - } - - if (body?.Input is null) - { - await WriteJsonAsync(http, StatusCodes.Status400BadRequest, - new InvocationErrorModel { ErrorCode = "missing_input", Message = "Request body must include a non-null 'input' property." }, InvocationsJsonContext.Default.InvocationErrorModel).ConfigureAwait(false); - return; - } - - var attributes = body.Attributes is null - ? (IReadOnlyDictionary)System.Collections.Immutable.ImmutableDictionary.Empty - : body.Attributes; - - var request = new ChannelRequest - { - Channel = this.Name, - Operation = "message.create", - Input = NormalizeInput(body.Input), - Attributes = attributes, - Background = body.Background, - Session = (body.SessionId is null && body.IsolationKey is null) ? null : new ChannelSession - { - Key = body.SessionId, - IsolationKey = body.IsolationKey, - }, - }; - - if (this._options.RunHook is not null) - { - var hookContext = new ChannelRunHookContext { Target = context.Host, ProtocolRequest = body }; - request = await this._options.RunHook.OnRequestAsync(request, hookContext, http.RequestAborted).ConfigureAwait(false); - } - - if (request.Background) - { - var token = await context.Host.RunInBackgroundAsync(request, http.RequestAborted).ConfigureAwait(false); - await WriteJsonAsync(http, StatusCodes.Status202Accepted, - new InvocationContinuationModel { Status = StatusFromContinuation(token.Status), ContinuationToken = token.Token }, - InvocationsJsonContext.Default.InvocationContinuationModel).ConfigureAwait(false); - return; - } - - try - { - var result = await context.RunAsync(request, http.RequestAborted).ConfigureAwait(false); - await WriteSuccessAsync(http, result).ConfigureAwait(false); - } - catch (Exception ex) - { - await WriteJsonAsync(http, StatusCodes.Status500InternalServerError, - new InvocationErrorModel { ErrorCode = "run_failed", Message = ex.Message }, - InvocationsJsonContext.Default.InvocationErrorModel).ConfigureAwait(false); - } - } - - private async Task HandleGetContinuationAsync(IChannelContext context, string continuationToken, HttpContext http) - { - var token = await context.Host.GetContinuationAsync(continuationToken, http.RequestAborted).ConfigureAwait(false); - if (token is null) - { - await WriteJsonAsync(http, StatusCodes.Status404NotFound, - new InvocationErrorModel { ErrorCode = "unknown_continuation", Message = $"Continuation token '{continuationToken}' is not known." }, - InvocationsJsonContext.Default.InvocationErrorModel).ConfigureAwait(false); - return; - } - - switch (token.Status) - { - case ContinuationStatus.Queued: - case ContinuationStatus.Running: - await WriteJsonAsync(http, StatusCodes.Status202Accepted, - new InvocationContinuationModel { Status = StatusFromContinuation(token.Status), ContinuationToken = token.Token }, - InvocationsJsonContext.Default.InvocationContinuationModel).ConfigureAwait(false); - break; - - case ContinuationStatus.Completed when token.Result is not null: - await WriteSuccessAsync(http, token.Result).ConfigureAwait(false); - break; - - case ContinuationStatus.Failed: - await WriteJsonAsync(http, StatusCodes.Status500InternalServerError, - new InvocationErrorModel { ErrorCode = "run_failed", Message = token.Error }, - InvocationsJsonContext.Default.InvocationErrorModel).ConfigureAwait(false); - break; - - default: - await WriteJsonAsync(http, StatusCodes.Status500InternalServerError, - new InvocationErrorModel { ErrorCode = "unknown_state", Message = $"Continuation in unexpected state '{token.Status}'." }, - InvocationsJsonContext.Default.InvocationErrorModel).ConfigureAwait(false); - break; - } - } - - private static async Task WriteSuccessAsync(HttpContext http, HostedRunResult result) - { - // Workflow-shaped results get their own awaiting_input / completed envelope. - if (result.ResultObject is WorkflowRunResult workflowResult) - { - await WriteWorkflowAsync(http, workflowResult, result.Session).ConfigureAwait(false); - return; - } - - var text = result.ResultObject switch - { - AgentResponse response => response.Text, - AgentResponseUpdate update => update.Text, - string s => s, - _ => result.ResultObject?.ToString(), - }; - - var model = new InvocationResponseModel - { - Status = "completed", - Text = text, - SessionId = result.Session?.Key, - }; - - await WriteJsonAsync(http, StatusCodes.Status200OK, model, InvocationsJsonContext.Default.InvocationResponseModel).ConfigureAwait(false); - } - - private static async Task WriteWorkflowAsync(HttpContext http, WorkflowRunResult workflow, ChannelSession? session) - { - switch (workflow.Status) - { - case WorkflowRunStatus.AwaitingInput: - var resumeToken = session?.Attributes is not null && session.Attributes.TryGetValue(WorkflowRunner.ResumeTokenAttribute, out var raw) ? raw as string : null; - var awaiting = new InvocationAwaitingInputModel - { - Status = "awaiting_input", - Request = workflow.PendingRequest?.Data.As(), - ResumeToken = resumeToken, - }; - await WriteJsonAsync(http, StatusCodes.Status200OK, awaiting, InvocationsJsonContext.Default.InvocationAwaitingInputModel).ConfigureAwait(false); - return; - - case WorkflowRunStatus.Failed: - var error = new InvocationErrorModel { ErrorCode = "workflow_failed", Message = workflow.Error }; - await WriteJsonAsync(http, StatusCodes.Status500InternalServerError, error, InvocationsJsonContext.Default.InvocationErrorModel).ConfigureAwait(false); - return; - - default: - var outputsText = workflow.Outputs.Count == 0 ? null : string.Join(System.Environment.NewLine, workflow.Outputs); - var model = new InvocationResponseModel - { - Status = "completed", - Text = outputsText, - SessionId = workflow.SessionId ?? session?.Key, - }; - await WriteJsonAsync(http, StatusCodes.Status200OK, model, InvocationsJsonContext.Default.InvocationResponseModel).ConfigureAwait(false); - return; - } - } - - private static async Task WriteJsonAsync(HttpContext http, int statusCode, T payload, System.Text.Json.Serialization.Metadata.JsonTypeInfo typeInfo) - { - http.Response.StatusCode = statusCode; - http.Response.ContentType = "application/json; charset=utf-8"; - await JsonSerializer.SerializeAsync(http.Response.Body, payload, typeInfo, http.RequestAborted).ConfigureAwait(false); - } - - private static string StatusFromContinuation(ContinuationStatus status) => status switch - { - ContinuationStatus.Queued => "queued", - ContinuationStatus.Running => "running", - ContinuationStatus.Completed => "completed", - ContinuationStatus.Failed => "failed", - _ => "unknown", - }; - - private static string NormalizeInput(object input) - { - return input switch - { - JsonElement el when el.ValueKind == JsonValueKind.String => el.GetString() ?? string.Empty, - JsonElement el => el.ToString(), - string s => s, - _ => input.ToString() ?? string.Empty, - }; - } -} \ No newline at end of file diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting.Channels.Invocations/InvocationsChannelOptions.cs b/dotnet/src/Microsoft.Agents.AI.Hosting.Channels.Invocations/InvocationsChannelOptions.cs deleted file mode 100644 index 6f392d10870..00000000000 --- a/dotnet/src/Microsoft.Agents.AI.Hosting.Channels.Invocations/InvocationsChannelOptions.cs +++ /dev/null @@ -1,36 +0,0 @@ -// Copyright (c) Microsoft. All rights reserved. - -using System; - -namespace Microsoft.Agents.AI.Hosting.Channels.Invocations; - -/// -/// Configuration for . -/// -public sealed class InvocationsChannelOptions -{ - /// - /// Mount root for the invocations routes. The channel exposes {Path}/invoke (sync run) - /// and {Path}/{continuationToken} (poll). Default "/invocations". - /// - public string Path { get; set; } = "/invocations"; - - /// - /// Optional per-channel allowlist. When the host's - /// applies. - /// - public IIdentityAllowlist? Allowlist { get; set; } - - /// - /// Optional run hook invoked after the channel produces the default request and before the host - /// calls the runner. Apps use this to project domain-specific request shapes onto - /// . - /// - public IChannelRunHook? RunHook { get; set; } - - /// - /// Optional response hook invoked per destination. The default - /// uses this on the originating reply to project the result onto the JSON wire envelope. - /// - public IChannelResponseHook? ResponseHook { get; set; } -} \ No newline at end of file diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting.Channels.Invocations/WorkflowInvocationsResponseHook.cs b/dotnet/src/Microsoft.Agents.AI.Hosting.Channels.Invocations/WorkflowInvocationsResponseHook.cs deleted file mode 100644 index faa8c1dcab5..00000000000 --- a/dotnet/src/Microsoft.Agents.AI.Hosting.Channels.Invocations/WorkflowInvocationsResponseHook.cs +++ /dev/null @@ -1,31 +0,0 @@ -// Copyright (c) Microsoft. All rights reserved. - -using System.Threading; -using System.Threading.Tasks; - -namespace Microsoft.Agents.AI.Hosting.Channels.Invocations; - -/// -/// Per-destination response hook that projects onto the -/// invocations JSON envelope (status: "awaiting_input" / "completed" / "failed"). -/// Apply this hook to non-originating workflow deliveries where another channel pushes the result. -/// The originating reply is rendered by directly. -/// -public sealed class WorkflowInvocationsResponseHook : IChannelResponseHook -{ - /// - public ValueTask OnResponseAsync( - HostedRunResult result, - ChannelResponseContext context, - CancellationToken cancellationToken) - { - if (result.ResultObject is not WorkflowRunResult workflow) - { - return new(result); - } - - // Preserve the typed envelope; consumers downstream (e.g. push codecs) project it on the wire. - // This hook centralizes the projection rule so multi-destination workflow rebinds stay consistent. - return new(result); - } -} \ No newline at end of file diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting.Channels.Responses/AgentFrameworkHostBuilderResponsesExtensions.cs b/dotnet/src/Microsoft.Agents.AI.Hosting.Channels.Responses/AgentFrameworkHostBuilderResponsesExtensions.cs new file mode 100644 index 00000000000..fd5d136106a --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.Hosting.Channels.Responses/AgentFrameworkHostBuilderResponsesExtensions.cs @@ -0,0 +1,23 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using Microsoft.Shared.Diagnostics; + +namespace Microsoft.Agents.AI.Hosting.Channels.Responses; + +/// +/// Extensions on for the OpenAI Responses channel. +/// +public static class AgentFrameworkHostBuilderResponsesExtensions +{ + /// Add the OpenAI Responses-shaped channel. + public static IAgentFrameworkHostBuilder AddResponsesChannel( + this IAgentFrameworkHostBuilder builder, + Action? configure = null) + { + Throw.IfNull(builder); + var options = new ResponsesChannelOptions(); + configure?.Invoke(options); + return builder.AddChannel(new ResponsesChannel(options)); + } +} diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting.Channels.Responses/Internal/ResponsesJsonContext.cs b/dotnet/src/Microsoft.Agents.AI.Hosting.Channels.Responses/Internal/ResponsesJsonContext.cs new file mode 100644 index 00000000000..56773d83807 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.Hosting.Channels.Responses/Internal/ResponsesJsonContext.cs @@ -0,0 +1,13 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Text.Json.Serialization; + +namespace Microsoft.Agents.AI.Hosting.Channels.Responses; + +[JsonSerializable(typeof(ResponsesRequestModel))] +[JsonSerializable(typeof(ResponsesResponseModel))] +[JsonSerializable(typeof(ResponsesStreamResponseEvent))] +[JsonSerializable(typeof(ResponsesStreamTextDeltaEvent))] +[JsonSerializable(typeof(ResponsesErrorModel))] +[JsonSerializable(typeof(System.Text.Json.JsonElement))] +internal sealed partial class ResponsesJsonContext : JsonSerializerContext; diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting.Channels.Responses/Internal/ResponsesModels.cs b/dotnet/src/Microsoft.Agents.AI.Hosting.Channels.Responses/Internal/ResponsesModels.cs new file mode 100644 index 00000000000..c57b567863d --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.Hosting.Channels.Responses/Internal/ResponsesModels.cs @@ -0,0 +1,82 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Collections.Generic; +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace Microsoft.Agents.AI.Hosting.Channels.Responses; + +/// Inbound OpenAI Responses request body (subset). +internal sealed class ResponsesRequestModel +{ + [JsonPropertyName("input")] public JsonElement? Input { get; set; } + [JsonPropertyName("stream")] public bool Stream { get; set; } + [JsonPropertyName("previous_response_id")] public string? PreviousResponseId { get; set; } + [JsonPropertyName("model")] public string? Model { get; set; } + [JsonPropertyName("instructions")] public string? Instructions { get; set; } + [JsonPropertyName("metadata")] public Dictionary? Metadata { get; set; } +} + +/// Outbound Responses object (non-streaming + terminal stream payload). +internal sealed class ResponsesResponseModel +{ + [JsonPropertyName("id")] public string Id { get; set; } = string.Empty; + [JsonPropertyName("object")] public string Object { get; set; } = "response"; + [JsonPropertyName("created_at")] public long CreatedAt { get; set; } + [JsonPropertyName("status")] public string Status { get; set; } = "completed"; + [JsonPropertyName("model")] public string? Model { get; set; } + [JsonPropertyName("output")] public List Output { get; set; } = []; + [JsonPropertyName("usage")] public ResponsesUsageModel? Usage { get; set; } +} + +internal sealed class ResponsesOutputMessage +{ + [JsonPropertyName("type")] public string Type { get; set; } = "message"; + [JsonPropertyName("id")] public string Id { get; set; } = string.Empty; + [JsonPropertyName("role")] public string Role { get; set; } = "assistant"; + [JsonPropertyName("status")] public string Status { get; set; } = "completed"; + [JsonPropertyName("content")] public List Content { get; set; } = []; +} + +internal sealed class ResponsesOutputText +{ + [JsonPropertyName("type")] public string Type { get; set; } = "output_text"; + [JsonPropertyName("text")] public string Text { get; set; } = string.Empty; + [JsonPropertyName("annotations")] public List Annotations { get; set; } = []; +} + +internal sealed class ResponsesUsageModel +{ + [JsonPropertyName("input_tokens")] public int InputTokens { get; set; } + [JsonPropertyName("output_tokens")] public int OutputTokens { get; set; } + [JsonPropertyName("total_tokens")] public int TotalTokens { get; set; } +} + +/// SSE payload for response.created / response.completed. +internal sealed class ResponsesStreamResponseEvent +{ + [JsonPropertyName("type")] public string Type { get; set; } = string.Empty; + [JsonPropertyName("response")] public ResponsesResponseModel Response { get; set; } = new(); +} + +/// SSE payload for response.output_text.delta. +internal sealed class ResponsesStreamTextDeltaEvent +{ + [JsonPropertyName("type")] public string Type { get; set; } = "response.output_text.delta"; + [JsonPropertyName("item_id")] public string ItemId { get; set; } = string.Empty; + [JsonPropertyName("output_index")] public int OutputIndex { get; set; } + [JsonPropertyName("content_index")] public int ContentIndex { get; set; } + [JsonPropertyName("delta")] public string Delta { get; set; } = string.Empty; +} + +/// Error envelope. +internal sealed class ResponsesErrorModel +{ + [JsonPropertyName("error")] public ResponsesErrorBody Error { get; set; } = new(); +} + +internal sealed class ResponsesErrorBody +{ + [JsonPropertyName("type")] public string Type { get; set; } = "invalid_request_error"; + [JsonPropertyName("message")] public string? Message { get; set; } +} diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting.Channels.Responses/Internal/ResponsesParsing.cs b/dotnet/src/Microsoft.Agents.AI.Hosting.Channels.Responses/Internal/ResponsesParsing.cs new file mode 100644 index 00000000000..fe11ae7f8b6 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.Hosting.Channels.Responses/Internal/ResponsesParsing.cs @@ -0,0 +1,107 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Collections.Generic; +using System.Text.Json; +using Microsoft.Extensions.AI; + +namespace Microsoft.Agents.AI.Hosting.Channels.Responses; + +/// +/// Parses the Responses request input field (string or input-item array) into a +/// list. Channel owns protocol parsing per ADR-0027. +/// +internal static class ResponsesParsing +{ + public static IReadOnlyList MessagesFromInput(JsonElement? input, string? instructions) + { + var messages = new List(); + if (!string.IsNullOrWhiteSpace(instructions)) + { + messages.Add(new ChatMessage(ChatRole.System, instructions)); + } + + if (input is null) + { + return messages; + } + + var el = input.Value; + switch (el.ValueKind) + { + case JsonValueKind.String: + messages.Add(new ChatMessage(ChatRole.User, el.GetString() ?? string.Empty)); + break; + + case JsonValueKind.Array: + foreach (var item in el.EnumerateArray()) + { + AppendItem(messages, item); + } + break; + } + + return messages; + } + + private static void AppendItem(List messages, JsonElement item) + { + if (item.ValueKind == JsonValueKind.String) + { + messages.Add(new ChatMessage(ChatRole.User, item.GetString() ?? string.Empty)); + return; + } + + if (item.ValueKind != JsonValueKind.Object) + { + return; + } + + var role = item.TryGetProperty("role", out var roleEl) ? roleEl.GetString() : "user"; + var text = ExtractText(item); + if (text.Length == 0) + { + return; + } + + messages.Add(new ChatMessage(MapRole(role), text)); + } + + private static string ExtractText(JsonElement item) + { + if (item.TryGetProperty("content", out var content)) + { + if (content.ValueKind == JsonValueKind.String) + { + return content.GetString() ?? string.Empty; + } + + if (content.ValueKind == JsonValueKind.Array) + { + var sb = new System.Text.StringBuilder(); + foreach (var part in content.EnumerateArray()) + { + if (part.ValueKind == JsonValueKind.Object && part.TryGetProperty("text", out var t) && t.ValueKind == JsonValueKind.String) + { + sb.Append(t.GetString()); + } + } + return sb.ToString(); + } + } + + if (item.TryGetProperty("text", out var directText) && directText.ValueKind == JsonValueKind.String) + { + return directText.GetString() ?? string.Empty; + } + + return string.Empty; + } + + private static ChatRole MapRole(string? role) => role switch + { + "assistant" => ChatRole.Assistant, + "system" or "developer" => ChatRole.System, + "tool" => ChatRole.Tool, + _ => ChatRole.User, + }; +} diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting.Channels.Invocations/Microsoft.Agents.AI.Hosting.Channels.Invocations.csproj b/dotnet/src/Microsoft.Agents.AI.Hosting.Channels.Responses/Microsoft.Agents.AI.Hosting.Channels.Responses.csproj similarity index 68% rename from dotnet/src/Microsoft.Agents.AI.Hosting.Channels.Invocations/Microsoft.Agents.AI.Hosting.Channels.Invocations.csproj rename to dotnet/src/Microsoft.Agents.AI.Hosting.Channels.Responses/Microsoft.Agents.AI.Hosting.Channels.Responses.csproj index 7927ef7cfc9..849174bd73b 100644 --- a/dotnet/src/Microsoft.Agents.AI.Hosting.Channels.Invocations/Microsoft.Agents.AI.Hosting.Channels.Invocations.csproj +++ b/dotnet/src/Microsoft.Agents.AI.Hosting.Channels.Responses/Microsoft.Agents.AI.Hosting.Channels.Responses.csproj @@ -1,8 +1,8 @@ - + $(TargetFrameworksCore) - Microsoft.Agents.AI.Hosting.Channels.Invocations + Microsoft.Agents.AI.Hosting.Channels.Responses alpha $(InterceptorsNamespaces);Microsoft.AspNetCore.Http.Generated true @@ -24,11 +24,11 @@ - + - Microsoft Agent Framework Hosting Channels - Invocations - JSON invocation channel for the Microsoft Agent Framework hosting channels surface. Exposes /invocations/invoke and continuation polling on an agent or workflow target. + Microsoft Agent Framework Hosting Channels - Responses + OpenAI Responses-shaped channel for the Microsoft Agent Framework hosting channels surface. Maps Responses requests and streams onto a shared AgentFrameworkHost. - + \ No newline at end of file diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting.Channels.Responses/ResponsesChannel.cs b/dotnet/src/Microsoft.Agents.AI.Hosting.Channels.Responses/ResponsesChannel.cs new file mode 100644 index 00000000000..05b39d5214e --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.Hosting.Channels.Responses/ResponsesChannel.cs @@ -0,0 +1,219 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Collections.Generic; +using System.Globalization; +using System.Text; +using System.Text.Json; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Routing; +using Microsoft.Extensions.AI; +using Microsoft.Shared.Diagnostics; + +namespace Microsoft.Agents.AI.Hosting.Channels.Responses; + +/// +/// OpenAI Responses-shaped channel. Mounts a single POST {Path} endpoint that accepts a Responses +/// request body and returns either a Responses JSON object (stream=false, default) or a +/// Server-Sent-Events stream (stream=true). The channel owns protocol parsing and rendering; the host +/// owns target invocation and session resolution. +/// +public sealed class ResponsesChannel : Channel +{ + private readonly ResponsesChannelOptions _options; + + /// Initializes a new instance. + public ResponsesChannel(ResponsesChannelOptions options) + { + this._options = Throw.IfNull(options); + } + + /// + public override string Name => "responses"; + + /// + public override string Path => this._options.Path; + + /// + public override ChannelContribution Contribute(IChannelContext context) + { + Throw.IfNull(context); + return new ChannelContribution + { + Routes = [endpoints => endpoints.MapPost("/", (HttpContext http) => this.HandleAsync(context, http))], + }; + } + + private async Task HandleAsync(IChannelContext context, HttpContext http) + { + ResponsesRequestModel? body; + try + { + body = await JsonSerializer.DeserializeAsync(http.Request.Body, ResponsesJsonContext.Default.ResponsesRequestModel, http.RequestAborted).ConfigureAwait(false); + } + catch (JsonException ex) + { + await WriteErrorAsync(http, StatusCodes.Status400BadRequest, ex.Message).ConfigureAwait(false); + return; + } + + if (body is null) + { + await WriteErrorAsync(http, StatusCodes.Status400BadRequest, "Request body is required.").ConfigureAwait(false); + return; + } + + var messages = ResponsesParsing.MessagesFromInput(body.Input, body.Instructions); + if (messages.Count == 0) + { + await WriteErrorAsync(http, StatusCodes.Status400BadRequest, "Request 'input' must contain at least one message.").ConfigureAwait(false); + return; + } + + var request = new ChannelRequest + { + Channel = this.Name, + Operation = "message.create", + Input = messages, + Stream = body.Stream, + Session = body.PreviousResponseId is null ? null : new ChannelSession { Key = body.PreviousResponseId }, + }; + + if (this._options.RunHook is not null) + { + var hookContext = new ChannelRunHookContext { Target = context.Host, ProtocolRequest = body }; + request = await this._options.RunHook.OnRequestAsync(request, hookContext, http.RequestAborted).ConfigureAwait(false); + } + + try + { + if (request.Stream) + { + await this.WriteStreamAsync(context, request, body.Model, http).ConfigureAwait(false); + } + else + { + await this.WriteJsonResponseAsync(context, request, body.Model, http).ConfigureAwait(false); + } + } + catch (Exception ex) + { + await WriteErrorAsync(http, StatusCodes.Status500InternalServerError, ex.Message).ConfigureAwait(false); + } + } + + private async Task WriteJsonResponseAsync(IChannelContext context, ChannelRequest request, string? model, HttpContext http) + { + var result = await context.RunAsync(request, http.RequestAborted).ConfigureAwait(false); + result = await this.ApplyResponseHookAsync(result, request, http.RequestAborted).ConfigureAwait(false); + + var text = ExtractText(result.ResultObject); + var response = BuildResponse(NewResponseId(), model, text, result.ResultObject as AgentResponse, status: "completed"); + + http.Response.StatusCode = StatusCodes.Status200OK; + http.Response.ContentType = "application/json; charset=utf-8"; + await JsonSerializer.SerializeAsync(http.Response.Body, response, ResponsesJsonContext.Default.ResponsesResponseModel, http.RequestAborted).ConfigureAwait(false); + } + + private async Task WriteStreamAsync(IChannelContext context, ChannelRequest request, string? model, HttpContext http) + { + http.Response.StatusCode = StatusCodes.Status200OK; + http.Response.ContentType = "text/event-stream"; + http.Response.Headers.CacheControl = "no-cache"; + + var responseId = NewResponseId(); + var itemId = "msg_" + Guid.NewGuid().ToString("N"); + + var created = BuildResponse(responseId, model, text: string.Empty, agentResponse: null, status: "in_progress"); + await WriteEventAsync(http, "response.created", new ResponsesStreamResponseEvent { Type = "response.created", Response = created }, ResponsesJsonContext.Default.ResponsesStreamResponseEvent).ConfigureAwait(false); + + var sb = new StringBuilder(); + await foreach (var item in context.StreamAsync(request, http.RequestAborted).ConfigureAwait(false)) + { + if (item is HostedStreamUpdate update) + { + var delta = update.Update.Text; + if (!string.IsNullOrEmpty(delta)) + { + sb.Append(delta); + var deltaEvent = new ResponsesStreamTextDeltaEvent { ItemId = itemId, OutputIndex = 0, ContentIndex = 0, Delta = delta }; + await WriteEventAsync(http, "response.output_text.delta", deltaEvent, ResponsesJsonContext.Default.ResponsesStreamTextDeltaEvent).ConfigureAwait(false); + } + } + } + + var completed = BuildResponse(responseId, model, sb.ToString(), agentResponse: null, status: "completed", itemId: itemId); + await WriteEventAsync(http, "response.completed", new ResponsesStreamResponseEvent { Type = "response.completed", Response = completed }, ResponsesJsonContext.Default.ResponsesStreamResponseEvent).ConfigureAwait(false); + } + + private async ValueTask ApplyResponseHookAsync(HostedRunResult result, ChannelRequest request, CancellationToken cancellationToken) + { + if (this._options.ResponseHook is null) + { + return result; + } + var ctx = new ChannelResponseContext { Request = request, ChannelName = this.Name }; + return await this._options.ResponseHook.OnResponseAsync(result, ctx, cancellationToken).ConfigureAwait(false); + } + + private static ResponsesResponseModel BuildResponse(string id, string? model, string text, AgentResponse? agentResponse, string status, string? itemId = null) + { + var response = new ResponsesResponseModel + { + Id = id, + CreatedAt = DateTimeOffset.UtcNow.ToUnixTimeSeconds(), + Status = status, + Model = model, + }; + + if (status == "completed") + { + response.Output.Add(new ResponsesOutputMessage + { + Id = itemId ?? "msg_" + Guid.NewGuid().ToString("N"), + Content = { new ResponsesOutputText { Text = text } }, + }); + + if (agentResponse?.Usage is { } usage) + { + response.Usage = new ResponsesUsageModel + { + InputTokens = (int)(usage.InputTokenCount ?? 0), + OutputTokens = (int)(usage.OutputTokenCount ?? 0), + TotalTokens = (int)(usage.TotalTokenCount ?? 0), + }; + } + } + + return response; + } + + private static string ExtractText(object? resultObject) => resultObject switch + { + AgentResponse response => response.Text, + AgentResponseUpdate update => update.Text, + string s => s, + _ => resultObject?.ToString() ?? string.Empty, + }; + + private static string NewResponseId() => "resp_" + Guid.NewGuid().ToString("N"); + + private static async Task WriteEventAsync(HttpContext http, string eventType, T payload, System.Text.Json.Serialization.Metadata.JsonTypeInfo typeInfo) + { + var json = JsonSerializer.Serialize(payload, typeInfo); + var frame = string.Create(CultureInfo.InvariantCulture, $"event: {eventType}\ndata: {json}\n\n"); + await http.Response.WriteAsync(frame, http.RequestAborted).ConfigureAwait(false); + await http.Response.Body.FlushAsync(http.RequestAborted).ConfigureAwait(false); + } + + private static async Task WriteErrorAsync(HttpContext http, int statusCode, string? message) + { + http.Response.StatusCode = statusCode; + http.Response.ContentType = "application/json; charset=utf-8"; + var error = new ResponsesErrorModel { Error = new ResponsesErrorBody { Message = message } }; + await JsonSerializer.SerializeAsync(http.Response.Body, error, ResponsesJsonContext.Default.ResponsesErrorModel, http.RequestAborted).ConfigureAwait(false); + } +} diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting.Channels.Responses/ResponsesChannelOptions.cs b/dotnet/src/Microsoft.Agents.AI.Hosting.Channels.Responses/ResponsesChannelOptions.cs new file mode 100644 index 00000000000..fd96829d63f --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.Hosting.Channels.Responses/ResponsesChannelOptions.cs @@ -0,0 +1,20 @@ +// Copyright (c) Microsoft. All rights reserved. + +namespace Microsoft.Agents.AI.Hosting.Channels.Responses; + +/// +/// Configuration for . +/// +public sealed class ResponsesChannelOptions +{ + /// Mount root for the Responses route. Default "/responses"; use "" for the app root. + public string Path { get; set; } = "/responses"; + + /// + /// Optional run hook invoked after the channel parses the request and before the host invokes the target. + /// + public IChannelRunHook? RunHook { get; set; } + + /// Optional response hook invoked before the channel serializes the originating response. + public IChannelResponseHook? ResponseHook { get; set; } +} diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting.Channels.Telegram/AgentFrameworkHostBuilderTelegramExtensions.cs b/dotnet/src/Microsoft.Agents.AI.Hosting.Channels.Telegram/AgentFrameworkHostBuilderTelegramExtensions.cs deleted file mode 100644 index 8ea45315861..00000000000 --- a/dotnet/src/Microsoft.Agents.AI.Hosting.Channels.Telegram/AgentFrameworkHostBuilderTelegramExtensions.cs +++ /dev/null @@ -1,24 +0,0 @@ -// Copyright (c) Microsoft. All rights reserved. - -using System; -using Microsoft.Shared.Diagnostics; - -namespace Microsoft.Agents.AI.Hosting.Channels.Telegram; - -/// -/// Extensions on for the Telegram channel. -/// -public static class AgentFrameworkHostBuilderTelegramExtensions -{ - /// Add the Telegram channel. - public static IAgentFrameworkHostBuilder AddTelegramChannel( - this IAgentFrameworkHostBuilder builder, - Action configure) - { - Throw.IfNull(builder); - Throw.IfNull(configure); - var options = new TelegramChannelOptions(); - configure(options); - return builder.AddChannel(new TelegramChannel(options)); - } -} \ No newline at end of file diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting.Channels.Telegram/Internal/TelegramApiClient.cs b/dotnet/src/Microsoft.Agents.AI.Hosting.Channels.Telegram/Internal/TelegramApiClient.cs deleted file mode 100644 index 45b0d5c4682..00000000000 --- a/dotnet/src/Microsoft.Agents.AI.Hosting.Channels.Telegram/Internal/TelegramApiClient.cs +++ /dev/null @@ -1,86 +0,0 @@ -// Copyright (c) Microsoft. All rights reserved. - -using System; -using System.Collections.Generic; -using System.Net.Http; -using System.Net.Http.Json; -using System.Text.Json; -using System.Threading; -using System.Threading.Tasks; -using Microsoft.Shared.Diagnostics; - -namespace Microsoft.Agents.AI.Hosting.Channels.Telegram; - -internal sealed class TelegramApiClient -{ - private readonly HttpClient _http; - private readonly string _baseUrl; - - public TelegramApiClient(HttpClient http, string botToken) - { - this._http = Throw.IfNull(http); - Throw.IfNullOrEmpty(botToken); - this._baseUrl = $"https://api.telegram.org/bot{botToken}"; - } - - public async Task GetMeAsync(CancellationToken cancellationToken) - { - var response = await this._http.GetFromJsonAsync( - new Uri($"{this._baseUrl}/getMe"), - TelegramJsonContext.Default.TelegramGetMeResponse, - cancellationToken).ConfigureAwait(false); - return response?.Ok == true ? response.Result : null; - } - - public async Task> GetUpdatesAsync(long offset, int timeoutSeconds, CancellationToken cancellationToken) - { - var url = $"{this._baseUrl}/getUpdates?offset={offset}&timeout={timeoutSeconds}"; - var response = await this._http.GetFromJsonAsync( - new Uri(url), - TelegramJsonContext.Default.TelegramGetUpdatesResponse, - cancellationToken).ConfigureAwait(false); - return response?.Ok == true && response.Result is not null ? response.Result : []; - } - - public async Task SendMessageAsync(long chatId, string text, CancellationToken cancellationToken) - { - Throw.IfNull(text); - var payload = new TelegramSendMessage { ChatId = chatId, Text = text }; - using var http = await this._http.PostAsJsonAsync( - new Uri($"{this._baseUrl}/sendMessage"), - payload, - TelegramJsonContext.Default.TelegramSendMessage, - cancellationToken).ConfigureAwait(false); - http.EnsureSuccessStatusCode(); - } - - public async Task SetMyCommandsAsync(IReadOnlyList commands, CancellationToken cancellationToken) - { - Throw.IfNull(commands); - if (commands.Count == 0) { return; } - var payload = new TelegramSetMyCommandsRequest - { - Commands = ToCommands(commands), - }; - using var http = await this._http.PostAsJsonAsync( - new Uri($"{this._baseUrl}/setMyCommands"), - payload, - TelegramJsonContext.Default.TelegramSetMyCommandsRequest, - cancellationToken).ConfigureAwait(false); - http.EnsureSuccessStatusCode(); - } - - private static TelegramBotCommand[] ToCommands(IReadOnlyList commands) - { - var arr = new TelegramBotCommand[commands.Count]; - for (var i = 0; i < commands.Count; i++) - { - arr[i] = new TelegramBotCommand - { - Command = commands[i].Name.TrimStart('/'), - Description = commands[i].Description, - }; - } - return arr; - } -} \ No newline at end of file diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting.Channels.Telegram/Internal/TelegramJsonContext.cs b/dotnet/src/Microsoft.Agents.AI.Hosting.Channels.Telegram/Internal/TelegramJsonContext.cs deleted file mode 100644 index 06387243c6e..00000000000 --- a/dotnet/src/Microsoft.Agents.AI.Hosting.Channels.Telegram/Internal/TelegramJsonContext.cs +++ /dev/null @@ -1,13 +0,0 @@ -// Copyright (c) Microsoft. All rights reserved. - -using System.Text.Json.Serialization; - -namespace Microsoft.Agents.AI.Hosting.Channels.Telegram; - -[JsonSerializable(typeof(TelegramUpdate))] -[JsonSerializable(typeof(TelegramMessage))] -[JsonSerializable(typeof(TelegramSendMessage))] -[JsonSerializable(typeof(TelegramGetUpdatesResponse))] -[JsonSerializable(typeof(TelegramGetMeResponse))] -[JsonSerializable(typeof(TelegramSetMyCommandsRequest))] -internal sealed partial class TelegramJsonContext : JsonSerializerContext; \ No newline at end of file diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting.Channels.Telegram/Internal/TelegramModels.cs b/dotnet/src/Microsoft.Agents.AI.Hosting.Channels.Telegram/Internal/TelegramModels.cs deleted file mode 100644 index 01abd2d218e..00000000000 --- a/dotnet/src/Microsoft.Agents.AI.Hosting.Channels.Telegram/Internal/TelegramModels.cs +++ /dev/null @@ -1,75 +0,0 @@ -// Copyright (c) Microsoft. All rights reserved. - -using System.Text.Json.Serialization; - -namespace Microsoft.Agents.AI.Hosting.Channels.Telegram; - -internal sealed class TelegramUpdate -{ - [JsonPropertyName("update_id")] public long UpdateId { get; set; } - [JsonPropertyName("message")] public TelegramMessage? Message { get; set; } - [JsonPropertyName("channel_post")] public TelegramMessage? ChannelPost { get; set; } -} - -internal sealed class TelegramMessage -{ - [JsonPropertyName("message_id")] public long MessageId { get; set; } - [JsonPropertyName("from")] public TelegramUser? From { get; set; } - [JsonPropertyName("chat")] public TelegramChat? Chat { get; set; } - [JsonPropertyName("text")] public string? Text { get; set; } - [JsonPropertyName("entities")] public TelegramMessageEntity[]? Entities { get; set; } -} - -internal sealed class TelegramUser -{ - [JsonPropertyName("id")] public long Id { get; set; } - [JsonPropertyName("is_bot")] public bool IsBot { get; set; } - [JsonPropertyName("username")] public string? Username { get; set; } - [JsonPropertyName("first_name")] public string? FirstName { get; set; } - [JsonPropertyName("language_code")] public string? LanguageCode { get; set; } -} - -internal sealed class TelegramChat -{ - [JsonPropertyName("id")] public long Id { get; set; } - [JsonPropertyName("type")] public string? Type { get; set; } // "private" | "group" | "supergroup" | "channel" - [JsonPropertyName("title")] public string? Title { get; set; } - [JsonPropertyName("username")] public string? Username { get; set; } -} - -internal sealed class TelegramMessageEntity -{ - [JsonPropertyName("type")] public string? Type { get; set; } // "bot_command", "mention", ... - [JsonPropertyName("offset")] public int Offset { get; set; } - [JsonPropertyName("length")] public int Length { get; set; } -} - -internal sealed class TelegramSendMessage -{ - [JsonPropertyName("chat_id")] public long ChatId { get; set; } - [JsonPropertyName("text")] public string Text { get; set; } = string.Empty; - [JsonPropertyName("parse_mode")] public string? ParseMode { get; set; } -} - -internal sealed class TelegramGetUpdatesResponse -{ - [JsonPropertyName("ok")] public bool Ok { get; set; } - [JsonPropertyName("result")] public TelegramUpdate[]? Result { get; set; } -} - -internal sealed class TelegramGetMeResponse -{ - [JsonPropertyName("ok")] public bool Ok { get; set; } - [JsonPropertyName("result")] public TelegramUser? Result { get; set; } -} - -internal sealed class TelegramSetMyCommandsRequest -{ - [JsonPropertyName("commands")] public TelegramBotCommand[] Commands { get; set; } = []; -} - -internal sealed class TelegramBotCommand -{ - [JsonPropertyName("command")] public string Command { get; set; } = string.Empty; - [JsonPropertyName("description")] public string Description { get; set; } = string.Empty; -} \ No newline at end of file diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting.Channels.Telegram/Microsoft.Agents.AI.Hosting.Channels.Telegram.csproj b/dotnet/src/Microsoft.Agents.AI.Hosting.Channels.Telegram/Microsoft.Agents.AI.Hosting.Channels.Telegram.csproj deleted file mode 100644 index da9ab856124..00000000000 --- a/dotnet/src/Microsoft.Agents.AI.Hosting.Channels.Telegram/Microsoft.Agents.AI.Hosting.Channels.Telegram.csproj +++ /dev/null @@ -1,34 +0,0 @@ - - - - $(TargetFrameworksCore) - Microsoft.Agents.AI.Hosting.Channels.Telegram - alpha - $(InterceptorsNamespaces);Microsoft.AspNetCore.Http.Generated - true - - - - - - true - - - - - - - - - - - - - - - - - Microsoft Agent Framework Hosting Channels - Telegram - Telegram Bot channel for the Microsoft Agent Framework hosting channels surface. Supports both webhook and long-poll transports, push delivery via IChannelPush, group conversation scoping, and host-tracked sessions. - - diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting.Channels.Telegram/TelegramChannel.cs b/dotnet/src/Microsoft.Agents.AI.Hosting.Channels.Telegram/TelegramChannel.cs deleted file mode 100644 index c62b838796e..00000000000 --- a/dotnet/src/Microsoft.Agents.AI.Hosting.Channels.Telegram/TelegramChannel.cs +++ /dev/null @@ -1,350 +0,0 @@ -// Copyright (c) Microsoft. All rights reserved. - -using System; -using System.Collections.Concurrent; -using System.Collections.Generic; -using System.Collections.Immutable; -using System.Net.Http; -using System.Text.Json; -using System.Threading; -using System.Threading.Tasks; -using Microsoft.AspNetCore.Builder; -using Microsoft.AspNetCore.Http; -using Microsoft.AspNetCore.Routing; -using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.Logging; -using Microsoft.Shared.Diagnostics; - -namespace Microsoft.Agents.AI.Hosting.Channels.Telegram; - -/// -/// Telegram Bot channel. Supports two transports: long-poll -/// via an loop, or webhook -/// via POST {Path}/webhook. Implements -/// for cross-channel response delivery. -/// -public sealed class TelegramChannel : Channel, IChannelPush -{ - private readonly TelegramChannelOptions _options; - private TelegramApiClient? _api; - private string? _botUsername; - - /// Initializes a new instance. - public TelegramChannel(TelegramChannelOptions options) - { - this._options = Throw.IfNull(options); - if (string.IsNullOrEmpty(this._options.BotToken)) - { - throw new ArgumentException("BotToken is required.", nameof(options)); - } - } - - /// - public override string Name => "telegram"; - - /// - public override string Path => this._options.Path; - - /// - public override void ConfigureServices(IServiceCollection services) - { - services.AddHttpClient(); - } - - /// - public override ChannelContribution Contribute(IChannelContext context) - { - Throw.IfNull(context); - - var httpClient = context.Services.GetRequiredService().CreateClient(nameof(TelegramApiClient)); - this._api = new TelegramApiClient(httpClient, this._options.BotToken); - - var contribution = new ChannelContribution - { - Commands = [.. this._options.Commands], - OnStartup = ct => this.OnStartupAsync(context, ct), - }; - - if (this._options.Transport == TelegramTransport.Webhook) - { - contribution = contribution with - { - Routes = - [ - endpoints => endpoints.MapPost("/webhook", (HttpContext http) => this.HandleWebhookAsync(context, http)), - ], - }; - } - - return contribution; - } - - /// - public async ValueTask PushAsync(ChannelPushContext context, HostedRunResult payload, CancellationToken cancellationToken) - { - Throw.IfNull(context); - Throw.IfNull(payload); - if (this._api is null) { throw new InvalidOperationException("TelegramChannel.Contribute was not invoked before PushAsync."); } - if (!long.TryParse(context.Destination.NativeId, out var chatId)) - { - throw new InvalidOperationException($"Destination NativeId '{context.Destination.NativeId}' is not a valid Telegram chat id."); - } - var text = ExtractText(payload, context.IsEcho ? context.OriginatingRequest.Input?.ToString() : null); - if (string.IsNullOrEmpty(text)) { return; } - await this._api.SendMessageAsync(chatId, text, cancellationToken).ConfigureAwait(false); - } - - private async ValueTask OnStartupAsync(IChannelContext context, CancellationToken cancellationToken) - { - if (this._api is null) { return; } - - var me = await this._api.GetMeAsync(cancellationToken).ConfigureAwait(false); - this._botUsername = me?.Username; - - if (this._options.RegisterNativeCommands && this._options.Commands.Count > 0) - { - await this._api.SetMyCommandsAsync([.. this._options.Commands], cancellationToken).ConfigureAwait(false); - } - - if (this._options.Transport == TelegramTransport.Polling) - { - // Start the polling loop on a background task. Stops when the application shuts down. - var logger = context.Services.GetRequiredService().CreateLogger(); - _ = Task.Run(() => this.PollingLoopAsync(context, logger, cancellationToken), cancellationToken); - } - } - - private async Task PollingLoopAsync(IChannelContext context, ILogger logger, CancellationToken cancellationToken) - { - if (this._api is null) { return; } - var offset = 0L; - var timeoutSeconds = Math.Max(1, (int)this._options.PollingTimeout.TotalSeconds); - while (!cancellationToken.IsCancellationRequested) - { - try - { - var updates = await this._api.GetUpdatesAsync(offset, timeoutSeconds, cancellationToken).ConfigureAwait(false); - for (var i = 0; i < updates.Count; i++) - { - var update = updates[i]; - offset = Math.Max(offset, update.UpdateId + 1); - await this.HandleUpdateAsync(context, update, replyHandler: null, cancellationToken).ConfigureAwait(false); - } - } - catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) - { - return; - } - catch (Exception ex) - { - logger.LogWarning(ex, "Telegram polling loop iteration failed; retrying in 5s."); - try { await Task.Delay(TimeSpan.FromSeconds(5), cancellationToken).ConfigureAwait(false); } - catch (OperationCanceledException) { return; } - } - } - } - - private async Task HandleWebhookAsync(IChannelContext context, HttpContext http) - { - TelegramUpdate? update; - try - { - update = await JsonSerializer.DeserializeAsync(http.Request.Body, TelegramJsonContext.Default.TelegramUpdate, http.RequestAborted).ConfigureAwait(false); - } - catch (JsonException) - { - http.Response.StatusCode = StatusCodes.Status400BadRequest; - return; - } - if (update is null) { http.Response.StatusCode = StatusCodes.Status204NoContent; return; } - - await this.HandleUpdateAsync(context, update, replyHandler: async (text) => - { - http.Response.StatusCode = StatusCodes.Status200OK; - http.Response.ContentType = "application/json; charset=utf-8"; - // Per Telegram webhook spec, the response body MAY be a method invocation. We just ack here. - await http.Response.WriteAsync("{\"ok\":true}", http.RequestAborted).ConfigureAwait(false); - // The actual reply goes via sendMessage; this keeps the wire simple. - if (!string.IsNullOrEmpty(text) && this._api is not null && update.Message?.Chat is not null) - { - await this._api.SendMessageAsync(update.Message.Chat.Id, text!, http.RequestAborted).ConfigureAwait(false); - } - }, http.RequestAborted).ConfigureAwait(false); - } - - private async Task HandleUpdateAsync(IChannelContext context, TelegramUpdate update, Func? replyHandler, CancellationToken cancellationToken) - { - var message = update.Message ?? update.ChannelPost; - if (message?.From is null || message.Chat is null || string.IsNullOrEmpty(message.Text)) { return; } - - var isGroup = message.Chat.Type is "group" or "supergroup"; - if (isGroup && !this.AcceptInGroupChat(message)) - { - return; - } - - var identity = new ChannelIdentity(this.Name, message.From.Id.ToString(System.Globalization.CultureInfo.InvariantCulture)) - { - Attributes = BuildIdentityAttributes(message.From), - }; - - var conversationContext = new ConversationContext(message.Chat.Id.ToString(System.Globalization.CultureInfo.InvariantCulture), isGroup); - - var auth = await context.AuthorizeAsync(identity, new AuthorizationRequest - { - RequireLink = this._options.RequireLink, - ConversationContext = conversationContext, - }, cancellationToken).ConfigureAwait(false); - - switch (auth) - { - case AuthorizationOutcome.Denied: - return; - - case AuthorizationOutcome.LinkRequired linkRequired: - await this.SendLinkChallengeAsync(message, linkRequired.Challenge, isGroup, cancellationToken).ConfigureAwait(false); - return; - } - - if (auth is not AuthorizationOutcome.Allowed allowed) { return; } - - var isolationKey = this.DeriveIsolationKey(allowed.IsolationKey, message.Chat.Id); - - var request = new ChannelRequest - { - Channel = this.Name, - Operation = "message.create", - Input = message.Text, - Identity = identity, - ConversationId = message.Chat.Id.ToString(System.Globalization.CultureInfo.InvariantCulture), - Session = new ChannelSession - { - IsolationKey = isolationKey, - ConversationId = message.Chat.Id.ToString(System.Globalization.CultureInfo.InvariantCulture), - }, - }; - - if (this._options.RunHook is not null) - { - var hookContext = new ChannelRunHookContext { Target = context.Host, ProtocolRequest = update }; - request = await this._options.RunHook.OnRequestAsync(request, hookContext, cancellationToken).ConfigureAwait(false); - } - - await context.StateStore.RecordLastSeenAsync(isolationKey, identity, request.ConversationId, DateTimeOffset.UtcNow, cancellationToken).ConfigureAwait(false); - - var result = await context.RunAsync(request, cancellationToken).ConfigureAwait(false); - var text = ExtractText(result, null); - - if (replyHandler is not null) - { - await replyHandler(text).ConfigureAwait(false); - } - else if (!string.IsNullOrEmpty(text) && this._api is not null) - { - await this._api.SendMessageAsync(message.Chat.Id, text!, cancellationToken).ConfigureAwait(false); - } - - await context.ScheduleResponseAsync(result, request, cancellationToken).ConfigureAwait(false); - } - - private async Task SendLinkChallengeAsync(TelegramMessage message, LinkChallenge challenge, bool isGroup, CancellationToken cancellationToken) - { - if (this._api is null || message.Chat is null) { return; } - - var prompt = challenge.UserPrompt ?? $"Please complete the link ceremony (code: {challenge.Code})."; - - // Group-safety: never post the challenge into a group conversation. Redirect to the user's DM. - if (isGroup && message.From is not null) - { - try - { - await this._api.SendMessageAsync(message.From.Id, prompt, cancellationToken).ConfigureAwait(false); - await this._api.SendMessageAsync(message.Chat.Id, "I've sent you a private message with the link instructions.", cancellationToken).ConfigureAwait(false); - } - catch (HttpRequestException) - { - await this._api.SendMessageAsync(message.Chat.Id, "I need a private conversation with you first. Open a chat with me and try again.", cancellationToken).ConfigureAwait(false); - } - return; - } - - await this._api.SendMessageAsync(message.Chat.Id, prompt, cancellationToken).ConfigureAwait(false); - } - - private bool AcceptInGroupChat(TelegramMessage message) - { - var hasMention = MentionsBot(message, this._botUsername); - var hasCommand = HasCommand(message); - - return this._options.AcceptInGroup switch - { - AcceptInGroup.All => true, - AcceptInGroup.MentionOnly => hasMention, - AcceptInGroup.CommandOnly => hasCommand, - AcceptInGroup.MentionOrCommand => hasMention || hasCommand, - _ => false, - }; - } - - private string DeriveIsolationKey(string userIsolationKey, long chatId) => - this._options.ConversationScope switch - { - ConversationScope.PerUser => userIsolationKey, - ConversationScope.PerConversation => $"_conv:{this.Name}:{chatId}", - _ => $"{userIsolationKey}:{chatId}", - }; - - private static ImmutableDictionary BuildIdentityAttributes(TelegramUser user) - { - var b = ImmutableDictionary.CreateBuilder(StringComparer.Ordinal); - if (user.Username is not null) { b["username"] = user.Username; } - if (user.FirstName is not null) { b["first_name"] = user.FirstName; } - if (user.LanguageCode is not null) { b["language_code"] = user.LanguageCode; } - return b.ToImmutable(); - } - - private static bool MentionsBot(TelegramMessage message, string? botUsername) - { - if (string.IsNullOrEmpty(botUsername) || message.Entities is null || message.Text is null) { return false; } - var needle = "@" + botUsername; - for (var i = 0; i < message.Entities.Length; i++) - { - var entity = message.Entities[i]; - if (entity.Type == "mention" && entity.Offset + entity.Length <= message.Text.Length) - { - var seg = message.Text.AsSpan(entity.Offset, entity.Length); - if (seg.Equals(needle.AsSpan(), StringComparison.OrdinalIgnoreCase)) - { - return true; - } - } - } - return false; - } - - private static bool HasCommand(TelegramMessage message) - { - if (message.Entities is null) { return false; } - for (var i = 0; i < message.Entities.Length; i++) - { - if (message.Entities[i].Type == "bot_command") { return true; } - } - return false; - } - - private static string? ExtractText(HostedRunResult result, string? fallback) => result.ResultObject switch - { - AgentResponse response => response.Text, - AgentResponseUpdate update => update.Text, - WorkflowRunResult workflow => RenderWorkflow(workflow), - string s => s, - _ => fallback ?? result.ResultObject?.ToString(), - }; - - private static string RenderWorkflow(WorkflowRunResult workflow) => workflow.Status switch - { - WorkflowRunStatus.AwaitingInput => "Awaiting input...", - WorkflowRunStatus.Failed => $"Workflow failed: {workflow.Error ?? "unknown error"}", - _ => workflow.Outputs.Count == 0 ? string.Empty : string.Join(System.Environment.NewLine, workflow.Outputs), - }; -} \ No newline at end of file diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting.Channels.Telegram/TelegramChannelOptions.cs b/dotnet/src/Microsoft.Agents.AI.Hosting.Channels.Telegram/TelegramChannelOptions.cs deleted file mode 100644 index 4bf89289453..00000000000 --- a/dotnet/src/Microsoft.Agents.AI.Hosting.Channels.Telegram/TelegramChannelOptions.cs +++ /dev/null @@ -1,48 +0,0 @@ -// Copyright (c) Microsoft. All rights reserved. - -using System; -using System.Collections.Generic; - -namespace Microsoft.Agents.AI.Hosting.Channels.Telegram; - -/// -/// Configuration for . -/// -public sealed class TelegramChannelOptions -{ - /// Bot token issued by Telegram's BotFather. Required. - public string BotToken { get; set; } = string.Empty; - - /// How the channel receives inbound updates. Default . - public TelegramTransport Transport { get; set; } = TelegramTransport.Polling; - - /// - /// Mount root for channel routes. Default "/telegram". Webhook transport publishes - /// {Path}/webhook; polling transport publishes no HTTP routes. - /// - public string Path { get; set; } = "/telegram"; - - /// How to derive the host isolation key in multi-user conversations. Default . - public ConversationScope ConversationScope { get; set; } = ConversationScope.PerUserPerConversation; - - /// Group-conversation acceptance filter. Default . - public AcceptInGroup AcceptInGroup { get; set; } = AcceptInGroup.MentionOnly; - - /// Whether to force a link ceremony on every inbound message. Default . - public bool RequireLink { get; set; } - - /// Declarative channel commands; the host calls setMyCommands at startup when is . - public IList Commands { get; } = []; - - /// Whether the channel registers with Telegram via setMyCommands on startup. Default . - public bool RegisterNativeCommands { get; set; } = true; - - /// Polling interval when is . Default 25 seconds (Telegram's long-poll cap). - public TimeSpan PollingTimeout { get; set; } = TimeSpan.FromSeconds(25); - - /// Optional run hook invoked after the channel produces the default request. - public IChannelRunHook? RunHook { get; set; } - - /// Optional response hook invoked per delivery. - public IChannelResponseHook? ResponseHook { get; set; } -} \ No newline at end of file diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting.Channels.Telegram/TelegramTransport.cs b/dotnet/src/Microsoft.Agents.AI.Hosting.Channels.Telegram/TelegramTransport.cs deleted file mode 100644 index 1372789635e..00000000000 --- a/dotnet/src/Microsoft.Agents.AI.Hosting.Channels.Telegram/TelegramTransport.cs +++ /dev/null @@ -1,13 +0,0 @@ -// Copyright (c) Microsoft. All rights reserved. - -namespace Microsoft.Agents.AI.Hosting.Channels.Telegram; - -/// How the channel receives inbound updates from Telegram. -public enum TelegramTransport -{ - /// Long-poll getUpdates from an . - Polling, - - /// Receive HTTP POSTs at {Path}/webhook. The bot's webhook URL must be registered out-of-band. - Webhook, -} \ No newline at end of file diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting.Channels/AcceptInGroup.cs b/dotnet/src/Microsoft.Agents.AI.Hosting.Channels/AcceptInGroup.cs deleted file mode 100644 index 7153b5ad515..00000000000 --- a/dotnet/src/Microsoft.Agents.AI.Hosting.Channels/AcceptInGroup.cs +++ /dev/null @@ -1,21 +0,0 @@ -// Copyright (c) Microsoft. All rights reserved. - -namespace Microsoft.Agents.AI.Hosting.Channels; - -/// -/// Controls which inbound group-conversation messages a channel accepts. -/// -public enum AcceptInGroup -{ - /// Accept only messages that mention the bot. Default for group surfaces. - MentionOnly, - - /// Accept only registered invocations. - CommandOnly, - - /// Accept either mentions or commands. - MentionOrCommand, - - /// Accept every inbound message. Opt-in for groups where the bot is the only conversational participant. - All, -} \ No newline at end of file diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting.Channels/AgentFrameworkHost.cs b/dotnet/src/Microsoft.Agents.AI.Hosting.Channels/AgentFrameworkHost.cs index 2238870a571..3ad5b9fc80b 100644 --- a/dotnet/src/Microsoft.Agents.AI.Hosting.Channels/AgentFrameworkHost.cs +++ b/dotnet/src/Microsoft.Agents.AI.Hosting.Channels/AgentFrameworkHost.cs @@ -2,46 +2,35 @@ using System; using System.Collections.Generic; -using System.Runtime.CompilerServices; using System.Threading; using System.Threading.Tasks; -using Microsoft.Extensions.DependencyInjection; using Microsoft.Shared.Diagnostics; namespace Microsoft.Agents.AI.Hosting.Channels; /// -/// The central host instance composed by AddAgentFrameworkHost(...) and surfaced via DI. -/// Owns the authorization pipeline, the runner seam, the channels collection, and the bridges -/// to + . +/// The channel-neutral host composed by AddAgentFrameworkHost(...) and surfaced via DI. Owns target +/// invocation, the channels collection, the host state store, and . v1 has no +/// authorization pipeline, response routing, or background delivery (those are ADR-0028). /// -/// -/// This draft implements the run / stream / authorize entry points end-to-end against the in-memory -/// defaults. Background runs and response fan-out land in a follow-up commit alongside the channel -/// packages that consume them. -/// public sealed class AgentFrameworkHost { - private readonly IServiceProvider _services; - internal AgentFrameworkHost( IServiceProvider services, IHostedTargetRunner targetRunner, IReadOnlyList channels, IHostStateStore stateStore, - IDurableTaskRunner durableRunner, AgentFrameworkHostOptions options) { - this._services = Throw.IfNull(services); + this.Services = Throw.IfNull(services); this.TargetRunner = Throw.IfNull(targetRunner); this.Channels = Throw.IfNull(channels); this.StateStore = Throw.IfNull(stateStore); - this.DurableRunner = Throw.IfNull(durableRunner); this.Options = Throw.IfNull(options); } /// Application service provider. - public IServiceProvider Services => this._services; + public IServiceProvider Services { get; } /// Registered channels in registration order. public IReadOnlyList Channels { get; } @@ -52,9 +41,6 @@ internal AgentFrameworkHost( /// The shared host state store. public IHostStateStore StateStore { get; } - /// The configured durable task runner. - public IDurableTaskRunner DurableRunner { get; } - /// Composition-time options. public AgentFrameworkHostOptions Options { get; } @@ -72,136 +58,7 @@ public IAsyncEnumerable StreamAsync(ChannelRequest request, Ca return this.TargetRunner.StreamAsync(request, cancellationToken); } - /// - /// Schedule a request to run in the background. Returns a the - /// caller can poll for completion. - /// - public async ValueTask RunInBackgroundAsync( - ChannelRequest request, - CancellationToken cancellationToken = default) - { - Throw.IfNull(request); - - var token = new ContinuationToken - { - Token = Guid.NewGuid().ToString("N"), - Status = ContinuationStatus.Queued, - IsolationKey = request.Session?.IsolationKey, - CreatedAt = DateTimeOffset.UtcNow, - ResponseTarget = request.ResponseTarget, - }; - - await this.StateStore.SaveContinuationAsync(token, cancellationToken).ConfigureAwait(false); - return token; - } - - /// Retrieve a previously-scheduled continuation token by its opaque id. - public ValueTask GetContinuationAsync(string token, CancellationToken cancellationToken = default) => - this.StateStore.GetContinuationAsync(token, cancellationToken); - /// Rotate the active session alias for an isolation key (host-tracked channels' /new). - public ValueTask ResetSessionAsync(string isolationKey, CancellationToken cancellationToken = default) => - this.StateStore.RotateSessionAliasAsync(isolationKey, cancellationToken); - - /// Funnel an identity through the host's authorization pipeline. - public async ValueTask AuthorizeAsync( - ChannelIdentity identity, - AuthorizationRequest options, - CancellationToken cancellationToken = default) - { - Throw.IfNull(identity); - Throw.IfNull(options); - - var allowlist = options.Allowlist ?? this.Options.DefaultAllowlist ?? AllowAllIdentityAllowlist.Instance; - var linker = this._services.GetService(); - var verifiedClaims = options.VerifiedClaims; - var claimSource = verifiedClaims is null or { Count: 0 } ? ClaimSource.None : ClaimSource.Channel; - - var preContext = new AuthorizationContext - { - Identity = identity, - Phase = AuthorizationPhase.PreLink, - VerifiedClaims = verifiedClaims ?? new Dictionary(), - ClaimSource = claimSource, - ConversationContext = options.ConversationContext, - }; - - var preDecision = await allowlist.EvaluateAsync(preContext, cancellationToken).ConfigureAwait(false); - switch (preDecision) - { - case AllowlistDecision.Deny: - return new AuthorizationOutcome.Denied("allowlist_denied_pre_link"); - - case AllowlistDecision.Allow: - if (options.RequireLink && linker is not null) - { - var linkedKey = await linker.IsLinkedAsync(identity, verifiedClaims, cancellationToken).ConfigureAwait(false); - if (linkedKey is not null) - { - await this.StateStore.SaveLinkAsync(identity, linkedKey, verifiedClaims, cancellationToken).ConfigureAwait(false); - return new AuthorizationOutcome.Allowed(linkedKey); - } - var challenge = await linker.BeginAsync(identity, requestedIsolationKey: null, cancellationToken).ConfigureAwait(false); - return new AuthorizationOutcome.LinkRequired(challenge); - } - { - var key = await this.ResolveOrIssueIsolationKeyAsync(identity, cancellationToken).ConfigureAwait(false); - return new AuthorizationOutcome.Allowed(key); - } - - case AllowlistDecision.Abstain: - if ((options.RequireLink || allowlist.RequiresLinkedClaims) && linker is not null) - { - var linkedKey = await linker.IsLinkedAsync(identity, verifiedClaims, cancellationToken).ConfigureAwait(false); - if (linkedKey is null) - { - var challenge = await linker.BeginAsync(identity, requestedIsolationKey: null, cancellationToken).ConfigureAwait(false); - return new AuthorizationOutcome.LinkRequired(challenge); - } - - var linked = await this.StateStore.GetIdentitiesAsync(linkedKey, cancellationToken).ConfigureAwait(false); - Dictionary mergedClaims = new(); - foreach (var reg in linked) - { - foreach (var (k, v) in reg.VerifiedClaims) { mergedClaims[k] = v; } - } - if (verifiedClaims is not null) - { - foreach (var (k, v) in verifiedClaims) { mergedClaims[k] = v; } - } - - var postContext = preContext with - { - Phase = AuthorizationPhase.PostLink, - IsolationKey = linkedKey, - VerifiedClaims = mergedClaims, - ClaimSource = ClaimSource.Linker, - }; - var postDecision = await allowlist.EvaluateAsync(postContext, cancellationToken).ConfigureAwait(false); - return postDecision switch - { - AllowlistDecision.Allow => new AuthorizationOutcome.Allowed(linkedKey), - AllowlistDecision.Deny => new AuthorizationOutcome.Denied("allowlist_denied_post_link"), - _ => new AuthorizationOutcome.Denied("allowlist_abstain_after_link"), - }; - } - { - var key = await this.ResolveOrIssueIsolationKeyAsync(identity, cancellationToken).ConfigureAwait(false); - return new AuthorizationOutcome.Allowed(key); - } - } - - // Unreachable: AllowlistDecision is exhaustive. - throw new InvalidOperationException("Unhandled allowlist decision."); - } - - private async ValueTask ResolveOrIssueIsolationKeyAsync(ChannelIdentity identity, CancellationToken cancellationToken) - { - var existing = await this.StateStore.GetIsolationKeyAsync(identity, cancellationToken).ConfigureAwait(false); - if (existing is not null) { return existing; } - - var issued = $"{identity.Channel}:{identity.NativeId}"; - await this.StateStore.SaveLinkAsync(identity, issued, verifiedClaims: null, cancellationToken).ConfigureAwait(false); - return issued; - } -} \ No newline at end of file + public ValueTask ResetSessionAsync(string isolationKey, CancellationToken cancellationToken = default) + => this.StateStore.RotateSessionAliasAsync(isolationKey, cancellationToken); +} diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting.Channels/AgentFrameworkHostOptions.cs b/dotnet/src/Microsoft.Agents.AI.Hosting.Channels/AgentFrameworkHostOptions.cs index f4346c83089..eaeb4eedd9a 100644 --- a/dotnet/src/Microsoft.Agents.AI.Hosting.Channels/AgentFrameworkHostOptions.cs +++ b/dotnet/src/Microsoft.Agents.AI.Hosting.Channels/AgentFrameworkHostOptions.cs @@ -7,18 +7,6 @@ namespace Microsoft.Agents.AI.Hosting.Channels; /// public sealed class AgentFrameworkHostOptions { - /// Host-level default allowlist. Per-channel allowlists may override or combine. - public IIdentityAllowlist? DefaultAllowlist { get; set; } - - /// Link policy applied across channels. - public ILinkPolicy? LinkPolicy { get; set; } - /// File-system layout for the file-backed host state store. public HostStatePathOptions? StatePaths { get; set; } - - /// Default durable runner name; reserved for fast-follow runner-selection wiring. - public string? DefaultDurableRunnerName { get; set; } - - /// Whether is permitted in ephemeral runtime modes. - public bool AllowInProcessRunnerInEphemeralMode { get; set; } -} \ No newline at end of file +} diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting.Channels/AllowlistDecision.cs b/dotnet/src/Microsoft.Agents.AI.Hosting.Channels/AllowlistDecision.cs deleted file mode 100644 index 4385cbc18b7..00000000000 --- a/dotnet/src/Microsoft.Agents.AI.Hosting.Channels/AllowlistDecision.cs +++ /dev/null @@ -1,18 +0,0 @@ -// Copyright (c) Microsoft. All rights reserved. - -namespace Microsoft.Agents.AI.Hosting.Channels; - -/// -/// Tri-state outcome of an evaluation. -/// -public enum AllowlistDecision -{ - /// Defer to subsequent allowlists in the combinator chain, or to the default-open rule when none remain. - Abstain, - - /// Admit the identity. - Allow, - - /// Reject the identity. Deny wins over Allow in . - Deny, -} \ No newline at end of file diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting.Channels/Allowlists/AllOfIdentityAllowlist.cs b/dotnet/src/Microsoft.Agents.AI.Hosting.Channels/Allowlists/AllOfIdentityAllowlist.cs deleted file mode 100644 index ca6ad73f9b8..00000000000 --- a/dotnet/src/Microsoft.Agents.AI.Hosting.Channels/Allowlists/AllOfIdentityAllowlist.cs +++ /dev/null @@ -1,50 +0,0 @@ -// Copyright (c) Microsoft. All rights reserved. - -using System.Collections.Generic; -using System.Linq; -using System.Threading; -using System.Threading.Tasks; -using Microsoft.Shared.Diagnostics; - -namespace Microsoft.Agents.AI.Hosting.Channels; - -/// -/// AND combinator: every child must . Any -/// wins; one or more -/// with no denials yields . -/// -public sealed class AllOfIdentityAllowlist : IIdentityAllowlist -{ - private readonly IIdentityAllowlist[] _children; - - /// Initializes a new instance. - public AllOfIdentityAllowlist(params IIdentityAllowlist[] children) : this((IEnumerable)children) - { - } - - /// Initializes a new instance. - public AllOfIdentityAllowlist(IEnumerable children) - { - Throw.IfNull(children); - this._children = children.ToArray(); - } - - /// - public bool RequiresLinkedClaims => this._children.Any(c => c.RequiresLinkedClaims); - - /// - public async ValueTask EvaluateAsync(AuthorizationContext context, CancellationToken cancellationToken) - { - var sawAbstain = false; - for (var i = 0; i < this._children.Length; i++) - { - var decision = await this._children[i].EvaluateAsync(context, cancellationToken).ConfigureAwait(false); - switch (decision) - { - case AllowlistDecision.Deny: return AllowlistDecision.Deny; - case AllowlistDecision.Abstain: sawAbstain = true; break; - } - } - return sawAbstain ? AllowlistDecision.Abstain : AllowlistDecision.Allow; - } -} \ No newline at end of file diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting.Channels/Allowlists/AllowAllIdentityAllowlist.cs b/dotnet/src/Microsoft.Agents.AI.Hosting.Channels/Allowlists/AllowAllIdentityAllowlist.cs deleted file mode 100644 index ab169c65ac6..00000000000 --- a/dotnet/src/Microsoft.Agents.AI.Hosting.Channels/Allowlists/AllowAllIdentityAllowlist.cs +++ /dev/null @@ -1,17 +0,0 @@ -// Copyright (c) Microsoft. All rights reserved. - -using System.Threading; -using System.Threading.Tasks; - -namespace Microsoft.Agents.AI.Hosting.Channels; - -/// Allowlist that admits every identity. -public sealed class AllowAllIdentityAllowlist : IIdentityAllowlist -{ - /// Shared singleton. - public static AllowAllIdentityAllowlist Instance { get; } = new(); - - /// - public ValueTask EvaluateAsync(AuthorizationContext context, CancellationToken cancellationToken) => - new(AllowlistDecision.Allow); -} \ No newline at end of file diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting.Channels/Allowlists/AnyOfIdentityAllowlist.cs b/dotnet/src/Microsoft.Agents.AI.Hosting.Channels/Allowlists/AnyOfIdentityAllowlist.cs deleted file mode 100644 index 05f06e81118..00000000000 --- a/dotnet/src/Microsoft.Agents.AI.Hosting.Channels/Allowlists/AnyOfIdentityAllowlist.cs +++ /dev/null @@ -1,50 +0,0 @@ -// Copyright (c) Microsoft. All rights reserved. - -using System; -using System.Collections.Generic; -using System.Linq; -using System.Threading; -using System.Threading.Tasks; -using Microsoft.Shared.Diagnostics; - -namespace Microsoft.Agents.AI.Hosting.Channels; - -/// -/// Short-circuit OR combinator: first wins; otherwise -/// returns if any child denied, else . -/// -public sealed class AnyOfIdentityAllowlist : IIdentityAllowlist -{ - private readonly IIdentityAllowlist[] _children; - - /// Initializes a new instance. - public AnyOfIdentityAllowlist(params IIdentityAllowlist[] children) : this((IEnumerable)children) - { - } - - /// Initializes a new instance. - public AnyOfIdentityAllowlist(IEnumerable children) - { - Throw.IfNull(children); - this._children = children.ToArray(); - } - - /// - public bool RequiresLinkedClaims => this._children.Any(c => c.RequiresLinkedClaims); - - /// - public async ValueTask EvaluateAsync(AuthorizationContext context, CancellationToken cancellationToken) - { - var sawDeny = false; - for (var i = 0; i < this._children.Length; i++) - { - var decision = await this._children[i].EvaluateAsync(context, cancellationToken).ConfigureAwait(false); - switch (decision) - { - case AllowlistDecision.Allow: return AllowlistDecision.Allow; - case AllowlistDecision.Deny: sawDeny = true; break; - } - } - return sawDeny ? AllowlistDecision.Deny : AllowlistDecision.Abstain; - } -} \ No newline at end of file diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting.Channels/Allowlists/LinkedClaimAllowlist.cs b/dotnet/src/Microsoft.Agents.AI.Hosting.Channels/Allowlists/LinkedClaimAllowlist.cs deleted file mode 100644 index 97c0ea8ebd1..00000000000 --- a/dotnet/src/Microsoft.Agents.AI.Hosting.Channels/Allowlists/LinkedClaimAllowlist.cs +++ /dev/null @@ -1,64 +0,0 @@ -// Copyright (c) Microsoft. All rights reserved. - -using System; -using System.Collections.Generic; -using System.Linq; -using System.Text.RegularExpressions; -using System.Threading; -using System.Threading.Tasks; -using Microsoft.Shared.Diagnostics; - -namespace Microsoft.Agents.AI.Hosting.Channels; - -/// -/// Admits identities where a verified claim matches one of the configured values. Supports -/// glob-style wildcards (*@contoso.com) on values. Abstains at -/// when claims aren't yet available; the host's pipeline triggers the linker accordingly. -/// -public sealed class LinkedClaimAllowlist : IIdentityAllowlist -{ - private readonly string _claim; - private readonly string[] _values; - private readonly Regex[] _patterns; - - /// Initializes a new instance. - public LinkedClaimAllowlist(string claim, params string[] values) : this(claim, (IEnumerable)values) - { - } - - /// Initializes a new instance. - public LinkedClaimAllowlist(string claim, IEnumerable values) - { - this._claim = Throw.IfNullOrEmpty(claim); - this._values = (values ?? throw new ArgumentNullException(nameof(values))).ToArray(); - this._patterns = this._values.Select(GlobToRegex).ToArray(); - } - - /// - public bool RequiresLinkedClaims => true; - - /// - public ValueTask EvaluateAsync(AuthorizationContext context, CancellationToken cancellationToken) - { - Throw.IfNull(context); - if (!context.VerifiedClaims.TryGetValue(this._claim, out var observed)) - { - return new(AllowlistDecision.Abstain); - } - - for (var i = 0; i < this._patterns.Length; i++) - { - if (this._patterns[i].IsMatch(observed)) - { - return new(AllowlistDecision.Allow); - } - } - return new(AllowlistDecision.Deny); - } - - private static Regex GlobToRegex(string pattern) - { - var escaped = Regex.Escape(pattern).Replace("\\*", ".*").Replace("\\?", "."); - return new Regex("^" + escaped + "$", RegexOptions.IgnoreCase | RegexOptions.CultureInvariant); - } -} \ No newline at end of file diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting.Channels/Allowlists/NativeIdAllowlist.cs b/dotnet/src/Microsoft.Agents.AI.Hosting.Channels/Allowlists/NativeIdAllowlist.cs deleted file mode 100644 index ac03a717dfd..00000000000 --- a/dotnet/src/Microsoft.Agents.AI.Hosting.Channels/Allowlists/NativeIdAllowlist.cs +++ /dev/null @@ -1,42 +0,0 @@ -// Copyright (c) Microsoft. All rights reserved. - -using System; -using System.Collections.Generic; -using System.Linq; -using System.Threading; -using System.Threading.Tasks; -using Microsoft.Shared.Diagnostics; - -namespace Microsoft.Agents.AI.Hosting.Channels; - -/// -/// Admits identities whose matches a target channel and -/// whose is in the allowed set. Abstains on identities -/// from other channels so combinators can defer to peers. -/// -public sealed class NativeIdAllowlist : IIdentityAllowlist -{ - private readonly string _channel; - private readonly HashSet _nativeIds; - - /// Initializes a new instance. - public NativeIdAllowlist(string channel, IEnumerable nativeIds) - { - this._channel = Throw.IfNullOrEmpty(channel); - this._nativeIds = new HashSet(nativeIds ?? throw new ArgumentNullException(nameof(nativeIds)), StringComparer.Ordinal); - } - - /// The channel this allowlist applies to. - public string Channel => this._channel; - - /// - public ValueTask EvaluateAsync(AuthorizationContext context, CancellationToken cancellationToken) - { - Throw.IfNull(context); - if (!string.Equals(context.Identity.Channel, this._channel, StringComparison.Ordinal)) - { - return new(AllowlistDecision.Abstain); - } - return new(this._nativeIds.Contains(context.Identity.NativeId) ? AllowlistDecision.Allow : AllowlistDecision.Deny); - } -} \ No newline at end of file diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting.Channels/AuthorizationContext.cs b/dotnet/src/Microsoft.Agents.AI.Hosting.Channels/AuthorizationContext.cs deleted file mode 100644 index e8b31c95035..00000000000 --- a/dotnet/src/Microsoft.Agents.AI.Hosting.Channels/AuthorizationContext.cs +++ /dev/null @@ -1,31 +0,0 @@ -// Copyright (c) Microsoft. All rights reserved. - -using System.Collections.Generic; -using System.Collections.Immutable; - -namespace Microsoft.Agents.AI.Hosting.Channels; - -/// -/// State passed to an at each phase of the authorization pipeline. -/// -public sealed record AuthorizationContext -{ - /// The channel-native identity being authorized. - public required ChannelIdentity Identity { get; init; } - - /// Current evaluation phase. PreLink runs before any linker is consulted. - public required AuthorizationPhase Phase { get; init; } - - /// Resolved isolation key. at . - public string? IsolationKey { get; init; } - - /// Verified claims attached to this evaluation. - public IReadOnlyDictionary VerifiedClaims { get; init; } = - ImmutableDictionary.Empty; - - /// Origin of . - public ClaimSource ClaimSource { get; init; } = ClaimSource.None; - - /// Conversation shape hints from the channel. - public ConversationContext? ConversationContext { get; init; } -} \ No newline at end of file diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting.Channels/AuthorizationOutcome.cs b/dotnet/src/Microsoft.Agents.AI.Hosting.Channels/AuthorizationOutcome.cs deleted file mode 100644 index 5b414fb07b2..00000000000 --- a/dotnet/src/Microsoft.Agents.AI.Hosting.Channels/AuthorizationOutcome.cs +++ /dev/null @@ -1,28 +0,0 @@ -// Copyright (c) Microsoft. All rights reserved. - -using System.Collections.Generic; - -namespace Microsoft.Agents.AI.Hosting.Channels; - -/// -/// Discriminated outcome returned by . -/// -public abstract record AuthorizationOutcome -{ - private AuthorizationOutcome() { } - - /// The identity is admitted; is the resolved (or auto-issued) key. - public sealed record Allowed(string IsolationKey) : AuthorizationOutcome; - - /// The identity must complete a link ceremony before access is granted. - public sealed record LinkRequired(LinkChallenge Challenge) : AuthorizationOutcome; - - /// The identity is denied. is stable and machine-readable. - /// Stable, machine-readable denial code. - /// Optional message safe to surface publicly. - /// Optional structured detail for logs / telemetry. Never shown to users. - public sealed record Denied( - string ReasonCode, - string? UserMessage = null, - IReadOnlyDictionary? LogDetails = null) : AuthorizationOutcome; -} \ No newline at end of file diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting.Channels/AuthorizationPhase.cs b/dotnet/src/Microsoft.Agents.AI.Hosting.Channels/AuthorizationPhase.cs deleted file mode 100644 index 8fd160affd9..00000000000 --- a/dotnet/src/Microsoft.Agents.AI.Hosting.Channels/AuthorizationPhase.cs +++ /dev/null @@ -1,15 +0,0 @@ -// Copyright (c) Microsoft. All rights reserved. - -namespace Microsoft.Agents.AI.Hosting.Channels; - -/// -/// Phase of the authorization pipeline at which an allowlist is being evaluated. -/// -public enum AuthorizationPhase -{ - /// The channel has not (yet) presented linked-claim evidence for the identity. - PreLink, - - /// The identity has been linked via and verified claims are available. - PostLink, -} \ No newline at end of file diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting.Channels/AuthorizationProfile.cs b/dotnet/src/Microsoft.Agents.AI.Hosting.Channels/AuthorizationProfile.cs deleted file mode 100644 index a2f014b0738..00000000000 --- a/dotnet/src/Microsoft.Agents.AI.Hosting.Channels/AuthorizationProfile.cs +++ /dev/null @@ -1,27 +0,0 @@ -// Copyright (c) Microsoft. All rights reserved. - -namespace Microsoft.Agents.AI.Hosting.Channels; - -/// -/// Convenience factory for the common allowlist shapes. Mirrors Python's named AuthPolicy factories. -/// -public static class AuthorizationProfile -{ - /// Open: admit every identity, auto-issue isolation keys on first contact. - public static IIdentityAllowlist Open() => AllowAllIdentityAllowlist.Instance; - - /// Force a link ceremony but otherwise admit every linked identity. - public static IIdentityAllowlist ForcedLink() => AllowAllIdentityAllowlist.Instance; - - /// Admit only identities whose channel-native id is in the configured set. - public static IIdentityAllowlist NativeAllowlist(string channel, params string[] nativeIds) => - new NativeIdAllowlist(channel, nativeIds); - - /// Admit only identities whose verified claim matches one of the values (forces link). - public static IIdentityAllowlist LinkedClaimAllowlist(string claim, params string[] values) => - new LinkedClaimAllowlist(claim, values); - - /// Native ids bypass link; everyone else funnels into a linked-claim allowlist. - public static IIdentityAllowlist Mixed(IIdentityAllowlist nativeAllowlist, IIdentityAllowlist linkedClaimAllowlist) => - new AnyOfIdentityAllowlist(nativeAllowlist, linkedClaimAllowlist); -} \ No newline at end of file diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting.Channels/AuthorizationRequest.cs b/dotnet/src/Microsoft.Agents.AI.Hosting.Channels/AuthorizationRequest.cs deleted file mode 100644 index c11025841c0..00000000000 --- a/dotnet/src/Microsoft.Agents.AI.Hosting.Channels/AuthorizationRequest.cs +++ /dev/null @@ -1,29 +0,0 @@ -// Copyright (c) Microsoft. All rights reserved. - -using System.Collections.Generic; - -namespace Microsoft.Agents.AI.Hosting.Channels; - -/// -/// Channel-supplied parameters for an call. -/// -public sealed record AuthorizationRequest -{ - /// - /// When , the host forces a link ceremony even when no allowlist requires it. - /// - public bool RequireLink { get; init; } - - /// - /// Per-call allowlist override. means "use the host default". - /// - public IIdentityAllowlist? Allowlist { get; init; } - - /// - /// Verified claims the channel observed for this identity (e.g. AAD object id from a bearer token). - /// - public IReadOnlyDictionary? VerifiedClaims { get; init; } - - /// Conversation shape hints (group vs. 1:1). - public ConversationContext? ConversationContext { get; init; } -} \ No newline at end of file diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting.Channels/Channel.cs b/dotnet/src/Microsoft.Agents.AI.Hosting.Channels/Channel.cs index a4237dd2857..3b810358fcb 100644 --- a/dotnet/src/Microsoft.Agents.AI.Hosting.Channels/Channel.cs +++ b/dotnet/src/Microsoft.Agents.AI.Hosting.Channels/Channel.cs @@ -5,17 +5,13 @@ namespace Microsoft.Agents.AI.Hosting.Channels; /// -/// Base type for hosting channels. Authors derive from this to expose an inbound surface -/// (HTTP routes, long-poll loops, gateway connections, ...) and to optionally mix in capability -/// interfaces such as , , and -/// . +/// Base type for hosting channels. Authors derive from this to expose an inbound surface (HTTP routes, +/// long-poll loops, ...) and optionally mix in / / +/// . /// /// -/// Two-phase lifecycle: -/// -/// runs at AddXxxChannel(...) time, before DI is built. -/// runs at MapAgentFrameworkHost(...) time, after DI is built. -/// +/// Two-phase lifecycle: runs at AddXxxChannel time (pre-Build); +/// runs at MapAgentFrameworkHost time (post-Build). /// public abstract class Channel { @@ -23,27 +19,16 @@ public abstract class Channel public abstract string Name { get; } /// - /// Mount root for the channel's routes. The host wraps - /// in endpoints.MapGroup(Path) before invoking each action. Empty mounts at the host root. + /// Mount root for the channel's routes. The host wraps in + /// endpoints.MapGroup(Path). Empty mounts at the host root. /// public virtual string Path => string.Empty; - /// - /// Whether this channel emits verified claims natively (e.g. an Activity Protocol bearer carrying - /// an AAD oid). Read by the host's startup validator when sizing the link requirement. - /// - public virtual bool EmitsVerifiedClaims => false; - - /// - /// Registers DI services the channel needs. Runs pre-Build. - /// + /// Registers DI services the channel needs. Runs pre-Build. public virtual void ConfigureServices(IServiceCollection services) { } - /// - /// Returns the channel's contribution to the running host (routes, commands, startup / shutdown - /// hooks, endpoint filters). Runs post-Build. - /// + /// Returns the channel's contribution (routes, commands, lifecycle hooks, endpoint filters). Runs post-Build. public abstract ChannelContribution Contribute(IChannelContext context); -} \ No newline at end of file +} diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting.Channels/ChannelCommand.cs b/dotnet/src/Microsoft.Agents.AI.Hosting.Channels/ChannelCommand.cs index b62a8a96438..3fd0d35cf55 100644 --- a/dotnet/src/Microsoft.Agents.AI.Hosting.Channels/ChannelCommand.cs +++ b/dotnet/src/Microsoft.Agents.AI.Hosting.Channels/ChannelCommand.cs @@ -9,4 +9,4 @@ namespace Microsoft.Agents.AI.Hosting.Channels; /// /// The command name without any leading sentinel (e.g. "new" not "/new"). /// Short description surfaced in the protocol's UI. -public sealed record ChannelCommand(string Name, string Description); \ No newline at end of file +public sealed record ChannelCommand(string Name, string Description); diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting.Channels/ChannelCommandContext.cs b/dotnet/src/Microsoft.Agents.AI.Hosting.Channels/ChannelCommandContext.cs new file mode 100644 index 00000000000..8cade776d46 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.Hosting.Channels/ChannelCommandContext.cs @@ -0,0 +1,12 @@ +// Copyright (c) Microsoft. All rights reserved. + +namespace Microsoft.Agents.AI.Hosting.Channels; + +/// +/// Context passed to a channel command handler when the host dispatches a recognized +/// . Carries the originating request and the parsed argument string. +/// +/// The originating channel request. +/// The matched command. +/// The raw argument text following the command, or . +public sealed record ChannelCommandContext(ChannelRequest Request, ChannelCommand Command, string? Arguments); diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting.Channels/ChannelContribution.cs b/dotnet/src/Microsoft.Agents.AI.Hosting.Channels/ChannelContribution.cs index be3b7b6a5cc..abc6c82d170 100644 --- a/dotnet/src/Microsoft.Agents.AI.Hosting.Channels/ChannelContribution.cs +++ b/dotnet/src/Microsoft.Agents.AI.Hosting.Channels/ChannelContribution.cs @@ -37,4 +37,4 @@ public sealed record ChannelContribution /// Optional shutdown hook invoked during graceful shutdown. public Func? OnShutdown { get; init; } -} \ No newline at end of file +} diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting.Channels/ChannelIdentity.cs b/dotnet/src/Microsoft.Agents.AI.Hosting.Channels/ChannelIdentity.cs index 4956038d9ff..94e91c5e2be 100644 --- a/dotnet/src/Microsoft.Agents.AI.Hosting.Channels/ChannelIdentity.cs +++ b/dotnet/src/Microsoft.Agents.AI.Hosting.Channels/ChannelIdentity.cs @@ -17,4 +17,4 @@ public sealed record ChannelIdentity(string Channel, string NativeId) /// public IReadOnlyDictionary Attributes { get; init; } = ImmutableDictionary.Empty; -} \ No newline at end of file +} diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting.Channels/ChannelIdentityRegistration.cs b/dotnet/src/Microsoft.Agents.AI.Hosting.Channels/ChannelIdentityRegistration.cs deleted file mode 100644 index f3888ae72e9..00000000000 --- a/dotnet/src/Microsoft.Agents.AI.Hosting.Channels/ChannelIdentityRegistration.cs +++ /dev/null @@ -1,17 +0,0 @@ -// Copyright (c) Microsoft. All rights reserved. - -using System; -using System.Collections.Generic; - -namespace Microsoft.Agents.AI.Hosting.Channels; - -/// -/// Single row in the host's identity registry: a (channel, native_id) mapping to an isolation key. -/// -/// The channel-native identity. -/// When the mapping was first written. -/// Verified claims persisted at link time for auto-link replay. -public sealed record ChannelIdentityRegistration( - ChannelIdentity Identity, - DateTimeOffset RegisteredAt, - IReadOnlyDictionary VerifiedClaims); \ No newline at end of file diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting.Channels/ChannelPushContext.cs b/dotnet/src/Microsoft.Agents.AI.Hosting.Channels/ChannelPushContext.cs deleted file mode 100644 index 696346ed8f8..00000000000 --- a/dotnet/src/Microsoft.Agents.AI.Hosting.Channels/ChannelPushContext.cs +++ /dev/null @@ -1,24 +0,0 @@ -// Copyright (c) Microsoft. All rights reserved. - -namespace Microsoft.Agents.AI.Hosting.Channels; - -/// -/// Per-delivery context passed to . -/// -public sealed record ChannelPushContext -{ - /// The channel-native identity to deliver to. - public required ChannelIdentity Destination { get; init; } - - /// The originating request that produced the payload. - public required ChannelRequest OriginatingRequest { get; init; } - - /// The channel name the request originated on. - public required string OriginatingChannel { get; init; } - - /// Whether this push is the echo of the user input, not the agent reply. - public bool IsEcho { get; init; } - - /// The response target the user originally requested, when non-default. - public ResponseTarget? OriginalTarget { get; init; } -} \ No newline at end of file diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting.Channels/ChannelRequest.cs b/dotnet/src/Microsoft.Agents.AI.Hosting.Channels/ChannelRequest.cs index 311b0d433ee..621b63aef34 100644 --- a/dotnet/src/Microsoft.Agents.AI.Hosting.Channels/ChannelRequest.cs +++ b/dotnet/src/Microsoft.Agents.AI.Hosting.Channels/ChannelRequest.cs @@ -12,71 +12,39 @@ namespace Microsoft.Agents.AI.Hosting.Channels; /// public sealed record ChannelRequest { - /// Originating channel name (matches ). + /// Originating channel name (matches ). public required string Channel { get; init; } - /// Operation kind: "message.create", "command.invoke", "approval.respond", ... + /// Operation kind: "message.create", "command.invoke", ... public required string Operation { get; init; } - /// - /// Target input. Reuses framework input types; boxed because the union spans - /// inputs, arrays, and workflow-typed inputs. - /// + /// Target input: string, , sequence, or workflow input. public required object Input { get; init; } - /// Session hint. for ephemeral or host-tracked channels. + /// Session hint. for ephemeral requests. public ChannelSession? Session { get; init; } - /// Channel-native user identity. for anonymous channels. + /// Channel-native user identity. Request metadata only; not a linking, authorization, or delivery key. public ChannelIdentity? Identity { get; init; } - /// Protocol-visible conversation / thread / topic id, when distinct from . + /// Protocol-visible conversation / thread id, when distinct from . public string? ConversationId { get; init; } - /// - /// Caller-derived chat options forwarded onto the runner's . - /// + /// Caller-derived chat options forwarded onto the runner's . public ChatOptions? Options { get; init; } - /// How the host should resolve session continuity for this request. + /// How the host resolves session continuity for this request. public SessionMode SessionMode { get; init; } = SessionMode.Auto; /// Protocol-level metadata for telemetry. The host never reads this. - public IReadOnlyDictionary Metadata { get; init; } = - ImmutableDictionary.Empty; + public IReadOnlyDictionary Metadata { get; init; } = ImmutableDictionary.Empty; /// - /// Channel-specific structured values surfaced to the run hook. Reserved keys for workflow - /// targets: "workflow.checkpoint_id", "workflow.resume_token". + /// Channel-specific structured values surfaced to the run hook. Reserved key for workflow targets: + /// "workflow.checkpoint_id" (caller-supplied checkpoint resume). /// - public IReadOnlyDictionary Attributes { get; init; } = - ImmutableDictionary.Empty; - - /// - /// Bidirectional, mutable per-request state slot for event-rich front-ends (AG-UI). - /// Opaque to the host. - /// - public IDictionary? ClientState { get; init; } - - /// - /// Frontend tool catalog supplied per request. Forwarded onto - /// but the host never invokes them. - /// - public IReadOnlyList? ClientTools { get; init; } - - /// Pass-through bag for channel-protocol extras (AG-UI resume, command, ...). - public IReadOnlyDictionary? ForwardedProps { get; init; } + public IReadOnlyDictionary Attributes { get; init; } = ImmutableDictionary.Empty; /// Whether the channel is calling rather than . public bool Stream { get; init; } - - /// Where the response is delivered. defaults to . - public ResponseTarget? ResponseTarget { get; init; } - - /// - /// When , the host returns a immediately - /// rather than awaiting the response. Forced when - /// is . - /// - public bool Background { get; init; } -} \ No newline at end of file +} diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting.Channels/ChannelResponseContext.cs b/dotnet/src/Microsoft.Agents.AI.Hosting.Channels/ChannelResponseContext.cs index 98e70af15a3..9388e86565c 100644 --- a/dotnet/src/Microsoft.Agents.AI.Hosting.Channels/ChannelResponseContext.cs +++ b/dotnet/src/Microsoft.Agents.AI.Hosting.Channels/ChannelResponseContext.cs @@ -3,22 +3,14 @@ namespace Microsoft.Agents.AI.Hosting.Channels; /// -/// Per-destination context passed to . +/// Context passed to . Runs after target invocation and +/// before the originating channel serializes its response. /// public sealed record ChannelResponseContext { /// The originating request. public required ChannelRequest Request { get; init; } - /// The destination channel for this delivery. + /// The originating channel name. public required string ChannelName { get; init; } - - /// The destination identity for this delivery. - public required ChannelIdentity DestinationIdentity { get; init; } - - /// True when this delivery is on the same channel the request originated on. - public bool Originating { get; init; } - - /// True when this is the echo push of the user input rather than the agent reply. - public bool IsEcho { get; init; } -} \ No newline at end of file +} diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting.Channels/ChannelRunHookContext.cs b/dotnet/src/Microsoft.Agents.AI.Hosting.Channels/ChannelRunHookContext.cs index 09df607e6d9..90e2531785b 100644 --- a/dotnet/src/Microsoft.Agents.AI.Hosting.Channels/ChannelRunHookContext.cs +++ b/dotnet/src/Microsoft.Agents.AI.Hosting.Channels/ChannelRunHookContext.cs @@ -12,4 +12,4 @@ public sealed record ChannelRunHookContext /// The raw inbound payload as it arrived on the wire. Loosely typed. public object? ProtocolRequest { get; init; } -} \ No newline at end of file +} diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting.Channels/ChannelSession.cs b/dotnet/src/Microsoft.Agents.AI.Hosting.Channels/ChannelSession.cs index 809c6909cda..92d961722be 100644 --- a/dotnet/src/Microsoft.Agents.AI.Hosting.Channels/ChannelSession.cs +++ b/dotnet/src/Microsoft.Agents.AI.Hosting.Channels/ChannelSession.cs @@ -27,4 +27,4 @@ public sealed record ChannelSession /// Channel-defined attributes; not interpreted by the host. public IReadOnlyDictionary Attributes { get; init; } = ImmutableDictionary.Empty; -} \ No newline at end of file +} diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting.Channels/ClaimSource.cs b/dotnet/src/Microsoft.Agents.AI.Hosting.Channels/ClaimSource.cs deleted file mode 100644 index 80f643898a4..00000000000 --- a/dotnet/src/Microsoft.Agents.AI.Hosting.Channels/ClaimSource.cs +++ /dev/null @@ -1,18 +0,0 @@ -// Copyright (c) Microsoft. All rights reserved. - -namespace Microsoft.Agents.AI.Hosting.Channels; - -/// -/// Where the verified claims on an originated. -/// -public enum ClaimSource -{ - /// No verified claims are present. - None, - - /// Claims came from the channel itself (e.g. an Activity Protocol bearer token's AAD object id). - Channel, - - /// Claims came from a completed ceremony. - Linker, -} \ No newline at end of file diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting.Channels/ContinuationStatus.cs b/dotnet/src/Microsoft.Agents.AI.Hosting.Channels/ContinuationStatus.cs deleted file mode 100644 index 9dc13c94b12..00000000000 --- a/dotnet/src/Microsoft.Agents.AI.Hosting.Channels/ContinuationStatus.cs +++ /dev/null @@ -1,21 +0,0 @@ -// Copyright (c) Microsoft. All rights reserved. - -namespace Microsoft.Agents.AI.Hosting.Channels; - -/// -/// Lifecycle state of a . -/// -public enum ContinuationStatus -{ - /// The run is queued and not yet started. - Queued, - - /// The run is executing. - Running, - - /// The run completed; carries the value. - Completed, - - /// The run failed; carries the reason. - Failed, -} \ No newline at end of file diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting.Channels/ContinuationToken.cs b/dotnet/src/Microsoft.Agents.AI.Hosting.Channels/ContinuationToken.cs deleted file mode 100644 index f4bd86d9f8e..00000000000 --- a/dotnet/src/Microsoft.Agents.AI.Hosting.Channels/ContinuationToken.cs +++ /dev/null @@ -1,35 +0,0 @@ -// Copyright (c) Microsoft. All rights reserved. - -using System; - -namespace Microsoft.Agents.AI.Hosting.Channels; - -/// -/// Persisted continuation handle for a background or paused run. -/// -public sealed record ContinuationToken -{ - /// Opaque token surface the caller correlates against. - public required string Token { get; init; } - - /// Current lifecycle status. - public required ContinuationStatus Status { get; init; } - - /// The isolation key the underlying run is scoped to. - public string? IsolationKey { get; init; } - - /// When the continuation was created. - public required DateTimeOffset CreatedAt { get; init; } - - /// When the underlying run reached a terminal state, if any. - public DateTimeOffset? CompletedAt { get; init; } - - /// The completed result, populated when is . - public HostedRunResult? Result { get; init; } - - /// Failure summary, populated when is . - public string? Error { get; init; } - - /// The response target the run was scheduled with, when non-default. - public ResponseTarget? ResponseTarget { get; init; } -} \ No newline at end of file diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting.Channels/ConversationContext.cs b/dotnet/src/Microsoft.Agents.AI.Hosting.Channels/ConversationContext.cs deleted file mode 100644 index 25f067dec96..00000000000 --- a/dotnet/src/Microsoft.Agents.AI.Hosting.Channels/ConversationContext.cs +++ /dev/null @@ -1,10 +0,0 @@ -// Copyright (c) Microsoft. All rights reserved. - -namespace Microsoft.Agents.AI.Hosting.Channels; - -/// -/// Conversation-shape hints handed to the authorization pipeline. -/// -/// The protocol-visible conversation id, or for 1:1. -/// Whether this conversation has more than one human participant. -public sealed record ConversationContext(string? ConversationId, bool IsGroup); \ No newline at end of file diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting.Channels/ConversationScope.cs b/dotnet/src/Microsoft.Agents.AI.Hosting.Channels/ConversationScope.cs deleted file mode 100644 index 9a8aa1cf384..00000000000 --- a/dotnet/src/Microsoft.Agents.AI.Hosting.Channels/ConversationScope.cs +++ /dev/null @@ -1,18 +0,0 @@ -// Copyright (c) Microsoft. All rights reserved. - -namespace Microsoft.Agents.AI.Hosting.Channels; - -/// -/// Controls how a channel derives the host isolation key in multi-user conversations. -/// -public enum ConversationScope -{ - /// One isolation key per user across all conversations. Personal-assistant style. - PerUser, - - /// One isolation key per user per conversation. Default for multi-user surfaces. - PerUserPerConversation, - - /// One isolation key per conversation. Every member shares state. "Bot lives in this channel" style. - PerConversation, -} \ No newline at end of file diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting.Channels/DurableTaskPayloadMode.cs b/dotnet/src/Microsoft.Agents.AI.Hosting.Channels/DurableTaskPayloadMode.cs deleted file mode 100644 index 148029dbca4..00000000000 --- a/dotnet/src/Microsoft.Agents.AI.Hosting.Channels/DurableTaskPayloadMode.cs +++ /dev/null @@ -1,21 +0,0 @@ -// Copyright (c) Microsoft. All rights reserved. - -namespace Microsoft.Agents.AI.Hosting.Channels; - -/// -/// Declares how an serializes task payloads. -/// -/// -/// The host validates pairings at startup: if the runner is , every push-capable -/// channel must also implement . -/// -#pragma warning disable CA1720 // Identifier contains type name — Python parity (OBJECT / JSON enum values). -public enum DurableTaskPayloadMode -{ - /// Payloads are passed through as opaque .NET objects (in-process runners). - Object, - - /// Payloads are JSON-serialized; channels must supply an . - Json, -} -#pragma warning restore CA1720 \ No newline at end of file diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting.Channels/DurableTaskStatus.cs b/dotnet/src/Microsoft.Agents.AI.Hosting.Channels/DurableTaskStatus.cs deleted file mode 100644 index 466e37cc560..00000000000 --- a/dotnet/src/Microsoft.Agents.AI.Hosting.Channels/DurableTaskStatus.cs +++ /dev/null @@ -1,24 +0,0 @@ -// Copyright (c) Microsoft. All rights reserved. - -namespace Microsoft.Agents.AI.Hosting.Channels; - -/// -/// Lifecycle state of a scheduled durable task. -/// -public enum DurableTaskStatus -{ - /// Queued, not yet picked up by a worker. - Scheduled, - - /// Currently executing. - Running, - - /// Completed successfully. - Succeeded, - - /// Failed after exhausting the retry policy. - Failed, - - /// Cancelled before reaching a terminal state. - Cancelled, -} \ No newline at end of file diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting.Channels/EndpointRouteBuilderHostingChannelsExtensions.cs b/dotnet/src/Microsoft.Agents.AI.Hosting.Channels/EndpointRouteBuilderHostingChannelsExtensions.cs index d3e7be30c01..b32dadc832e 100644 --- a/dotnet/src/Microsoft.Agents.AI.Hosting.Channels/EndpointRouteBuilderHostingChannelsExtensions.cs +++ b/dotnet/src/Microsoft.Agents.AI.Hosting.Channels/EndpointRouteBuilderHostingChannelsExtensions.cs @@ -1,6 +1,5 @@ // Copyright (c) Microsoft. All rights reserved. -using System; using Microsoft.Agents.AI.Hosting.Channels; using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Http; @@ -8,7 +7,7 @@ using Microsoft.Extensions.DependencyInjection; using Microsoft.Shared.Diagnostics; -#pragma warning disable IDE0130 // Namespace does not match folder structure — intentional: extension methods live in the host's framework namespace. +#pragma warning disable IDE0130 // Namespace does not match folder structure - intentional: extension methods live in the host framework namespace. namespace Microsoft.AspNetCore.Builder; #pragma warning restore IDE0130 @@ -18,24 +17,21 @@ namespace Microsoft.AspNetCore.Builder; public static class EndpointRouteBuilderHostingChannelsExtensions { /// - /// Mounts every registered channel's routes (rooted at each channel's path) and invokes each channel's startup hook. + /// Mounts every registered channel's routes (rooted at each channel's path) and applies channel endpoint + /// filters. /// public static IEndpointConventionBuilder MapAgentFrameworkHost(this IEndpointRouteBuilder endpoints) { Throw.IfNull(endpoints); var host = endpoints.ServiceProvider.GetRequiredService(); - // Force-construct the router so "hosting.push" is registered on the durable runner before traffic. - _ = endpoints.ServiceProvider.GetRequiredService(); var context = new ChannelContext(endpoints.ServiceProvider, host); var hostGroup = endpoints.MapGroup(string.Empty); foreach (var channel in host.Channels) { var contribution = channel.Contribute(context); - var channelGroup = string.IsNullOrEmpty(channel.Path) - ? hostGroup - : endpoints.MapGroup(channel.Path); + var channelGroup = string.IsNullOrEmpty(channel.Path) ? hostGroup : endpoints.MapGroup(channel.Path); foreach (var filter in contribution.EndpointFilters) { @@ -50,4 +46,4 @@ public static IEndpointConventionBuilder MapAgentFrameworkHost(this IEndpointRou return hostGroup; } -} \ No newline at end of file +} diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting.Channels/FileHostStateStore.cs b/dotnet/src/Microsoft.Agents.AI.Hosting.Channels/FileHostStateStore.cs index 4c14c8a2aef..8c24d0bef50 100644 --- a/dotnet/src/Microsoft.Agents.AI.Hosting.Channels/FileHostStateStore.cs +++ b/dotnet/src/Microsoft.Agents.AI.Hosting.Channels/FileHostStateStore.cs @@ -1,10 +1,8 @@ // Copyright (c) Microsoft. All rights reserved. using System; -using System.Collections.Generic; -using System.Diagnostics.CodeAnalysis; +using System.Collections.Concurrent; using System.IO; -using System.Text.Json; using System.Threading; using System.Threading.Tasks; using Microsoft.Shared.Diagnostics; @@ -12,266 +10,74 @@ namespace Microsoft.Agents.AI.Hosting.Channels; /// -/// File-system-backed . Each component (identity registry, link grants, -/// last-seen ledger, continuation tokens, session aliases) is persisted as one JSON file per record -/// under its configured path. Safe for single-process use; multiple processes sharing the same -/// directory require external coordination. +/// File-system-backed . Persists reset-session aliases as one file per +/// isolation key and derives per-isolation-key workflow checkpoint directories. Safe for single-process +/// use; multiple processes sharing the same directory require external coordination. /// -/// -/// In-memory caches are populated on first access and write-through to disk. The on-disk schema is -/// considered private to this implementation and may evolve. Mirrors the Python behaviour where -/// the host shipped a similar JSON-files store as the v1 default for long-running deployments. -/// -[RequiresUnreferencedCode("FileHostStateStore uses reflection-based JSON serialization. Use a JsonTypeInfo-aware alternative for trimmed apps.")] -[RequiresDynamicCode("FileHostStateStore uses reflection-based JSON serialization. Use a JsonTypeInfo-aware alternative for AOT apps.")] public sealed class FileHostStateStore : IHostStateStore { - private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web) { WriteIndented = false }; - - private readonly InMemoryHostStateStore _cache = new(); - private readonly string _linksPath; - private readonly string _grantsPath; - private readonly string _lastSeenPath; - private readonly string _continuationsPath; + private readonly ConcurrentDictionary _aliasCache = new(StringComparer.Ordinal); private readonly string _aliasesPath; - private readonly object _writeGate = new(); - private bool _hydrated; + private readonly string _checkpointsPath; + private readonly object _gate = new(); /// Initializes a new instance. public FileHostStateStore(HostStatePathOptions paths) { Throw.IfNull(paths); var root = paths.Root ?? "./.afhost"; - this._linksPath = paths.LinksPath ?? Path.Combine(root, "links"); - this._grantsPath = paths.LinksPath is not null ? Path.Combine(paths.LinksPath, "grants") : Path.Combine(root, "grants"); - this._lastSeenPath = paths.LastSeenPath ?? Path.Combine(root, "last-seen"); - this._continuationsPath = paths.ContinuationsPath ?? Path.Combine(root, "continuations"); - this._aliasesPath = Path.Combine(root, "aliases"); - - Directory.CreateDirectory(this._linksPath); - Directory.CreateDirectory(this._grantsPath); - Directory.CreateDirectory(this._lastSeenPath); - Directory.CreateDirectory(this._continuationsPath); + this._aliasesPath = paths.AliasesPath ?? Path.Combine(root, "aliases"); + this._checkpointsPath = paths.CheckpointsPath ?? Path.Combine(root, "checkpoints"); Directory.CreateDirectory(this._aliasesPath); + Directory.CreateDirectory(this._checkpointsPath); } /// - public async ValueTask GetIsolationKeyAsync(ChannelIdentity identity, CancellationToken cancellationToken) - { - await this.HydrateAsync(cancellationToken).ConfigureAwait(false); - return await this._cache.GetIsolationKeyAsync(identity, cancellationToken).ConfigureAwait(false); - } - - /// - public async ValueTask SaveLinkAsync( - ChannelIdentity identity, - string isolationKey, - IReadOnlyDictionary? verifiedClaims, - CancellationToken cancellationToken) - { - await this.HydrateAsync(cancellationToken).ConfigureAwait(false); - await this._cache.SaveLinkAsync(identity, isolationKey, verifiedClaims, cancellationToken).ConfigureAwait(false); - var snapshot = await this._cache.GetIdentitiesAsync(isolationKey, cancellationToken).ConfigureAwait(false); - this.WriteJson(Path.Combine(this._linksPath, EncodeFileName(isolationKey) + ".json"), snapshot); - } - - /// - public async ValueTask> GetIdentitiesAsync(string isolationKey, CancellationToken cancellationToken) - { - await this.HydrateAsync(cancellationToken).ConfigureAwait(false); - return await this._cache.GetIdentitiesAsync(isolationKey, cancellationToken).ConfigureAwait(false); - } - - /// - public async ValueTask LookupByVerifiedClaimAsync(string claim, string value, CancellationToken cancellationToken) - { - await this.HydrateAsync(cancellationToken).ConfigureAwait(false); - return await this._cache.LookupByVerifiedClaimAsync(claim, value, cancellationToken).ConfigureAwait(false); - } - - /// - public async ValueTask SaveLinkGrantAsync(LinkGrant grant, CancellationToken cancellationToken) - { - Throw.IfNull(grant); - await this.HydrateAsync(cancellationToken).ConfigureAwait(false); - await this._cache.SaveLinkGrantAsync(grant, cancellationToken).ConfigureAwait(false); - this.WriteJson(Path.Combine(this._grantsPath, EncodeFileName(grant.Code) + ".json"), grant); - } - - /// - public async ValueTask GetLinkGrantAsync(string code, CancellationToken cancellationToken) + public ValueTask RotateSessionAliasAsync(string isolationKey, CancellationToken cancellationToken) { - await this.HydrateAsync(cancellationToken).ConfigureAwait(false); - return await this._cache.GetLinkGrantAsync(code, cancellationToken).ConfigureAwait(false); - } - - /// - public async ValueTask ConsumeLinkGrantAsync(string code, CancellationToken cancellationToken) - { - await this.HydrateAsync(cancellationToken).ConfigureAwait(false); - var consumed = await this._cache.ConsumeLinkGrantAsync(code, cancellationToken).ConfigureAwait(false); - if (consumed is not null) + Throw.IfNullOrEmpty(isolationKey); + var alias = Guid.NewGuid().ToString("N"); + this._aliasCache[isolationKey] = alias; + lock (this._gate) { - this.DeleteIfExists(Path.Combine(this._grantsPath, EncodeFileName(code) + ".json")); + File.WriteAllText(this.AliasFile(isolationKey), alias); } - return consumed; + return default; } /// - public async ValueTask RecordLastSeenAsync( - string isolationKey, - ChannelIdentity identity, - string? conversationId, - DateTimeOffset at, - CancellationToken cancellationToken) + public ValueTask GetActiveSessionAliasAsync(string isolationKey, CancellationToken cancellationToken) { - await this.HydrateAsync(cancellationToken).ConfigureAwait(false); - await this._cache.RecordLastSeenAsync(isolationKey, identity, conversationId, at, cancellationToken).ConfigureAwait(false); - var record = await this._cache.GetLastSeenAsync(isolationKey, cancellationToken).ConfigureAwait(false); - if (record is not null) + Throw.IfNullOrEmpty(isolationKey); + if (this._aliasCache.TryGetValue(isolationKey, out var cached)) { - this.WriteJson(Path.Combine(this._lastSeenPath, EncodeFileName(isolationKey) + ".json"), record); + return new(cached); } - } - - /// - public async ValueTask GetLastSeenAsync(string isolationKey, CancellationToken cancellationToken) - { - await this.HydrateAsync(cancellationToken).ConfigureAwait(false); - return await this._cache.GetLastSeenAsync(isolationKey, cancellationToken).ConfigureAwait(false); - } - - /// - public async ValueTask SaveContinuationAsync(ContinuationToken token, CancellationToken cancellationToken) - { - Throw.IfNull(token); - await this.HydrateAsync(cancellationToken).ConfigureAwait(false); - await this._cache.SaveContinuationAsync(token, cancellationToken).ConfigureAwait(false); - this.WriteJson(Path.Combine(this._continuationsPath, EncodeFileName(token.Token) + ".json"), token); - } - - /// - public async ValueTask GetContinuationAsync(string token, CancellationToken cancellationToken) - { - await this.HydrateAsync(cancellationToken).ConfigureAwait(false); - return await this._cache.GetContinuationAsync(token, cancellationToken).ConfigureAwait(false); - } - /// - public async ValueTask DeleteContinuationAsync(string token, CancellationToken cancellationToken) - { - await this.HydrateAsync(cancellationToken).ConfigureAwait(false); - await this._cache.DeleteContinuationAsync(token, cancellationToken).ConfigureAwait(false); - this.DeleteIfExists(Path.Combine(this._continuationsPath, EncodeFileName(token) + ".json")); - } - - /// - public async ValueTask RotateSessionAliasAsync(string isolationKey, CancellationToken cancellationToken) - { - await this.HydrateAsync(cancellationToken).ConfigureAwait(false); - await this._cache.RotateSessionAliasAsync(isolationKey, cancellationToken).ConfigureAwait(false); - var alias = await this._cache.GetActiveSessionAliasAsync(isolationKey, cancellationToken).ConfigureAwait(false); - if (alias is not null) - { - this.WriteJson(Path.Combine(this._aliasesPath, EncodeFileName(isolationKey) + ".json"), alias); - } - } - - /// - public async ValueTask GetActiveSessionAliasAsync(string isolationKey, CancellationToken cancellationToken) - { - await this.HydrateAsync(cancellationToken).ConfigureAwait(false); - var alias = await this._cache.GetActiveSessionAliasAsync(isolationKey, cancellationToken).ConfigureAwait(false); - if (alias is not null && !File.Exists(Path.Combine(this._aliasesPath, EncodeFileName(isolationKey) + ".json"))) - { - this.WriteJson(Path.Combine(this._aliasesPath, EncodeFileName(isolationKey) + ".json"), alias); - } - return alias; - } - - private async ValueTask HydrateAsync(CancellationToken cancellationToken) - { - if (this._hydrated) { return; } - lock (this._writeGate) - { - if (this._hydrated) { return; } - this._hydrated = true; - } - - foreach (var file in Directory.EnumerateFiles(this._linksPath, "*.json")) + var file = this.AliasFile(isolationKey); + string alias; + lock (this._gate) { - var snapshot = ReadJson>(file); - if (snapshot is null) { continue; } - var isolationKey = DecodeFileName(Path.GetFileNameWithoutExtension(file)); - foreach (var reg in snapshot) + alias = File.Exists(file) ? File.ReadAllText(file) : isolationKey; + if (!File.Exists(file)) { - await this._cache.SaveLinkAsync(reg.Identity, isolationKey, reg.VerifiedClaims, cancellationToken).ConfigureAwait(false); + File.WriteAllText(file, alias); } } - - foreach (var file in Directory.EnumerateFiles(this._grantsPath, "*.json")) - { - var grant = ReadJson(file); - if (grant is null) { continue; } - if (grant.ExpiresAt <= DateTimeOffset.UtcNow) { this.DeleteIfExists(file); continue; } - await this._cache.SaveLinkGrantAsync(grant, cancellationToken).ConfigureAwait(false); - } - - foreach (var file in Directory.EnumerateFiles(this._lastSeenPath, "*.json")) - { - var record = ReadJson(file); - if (record is null) { continue; } - var isolationKey = DecodeFileName(Path.GetFileNameWithoutExtension(file)); - await this._cache.RecordLastSeenAsync(isolationKey, record.Identity, record.ConversationId, record.At, cancellationToken).ConfigureAwait(false); - } - - foreach (var file in Directory.EnumerateFiles(this._continuationsPath, "*.json")) - { - var token = ReadJson(file); - if (token is null) { continue; } - await this._cache.SaveContinuationAsync(token, cancellationToken).ConfigureAwait(false); - } + this._aliasCache[isolationKey] = alias; + return new(alias); } - private void WriteJson(string path, T payload) - { - lock (this._writeGate) - { - using var stream = File.Create(path); - JsonSerializer.Serialize(stream, payload, JsonOptions); - } - } - - private static T? ReadJson(string path) where T : class - { - try - { - using var stream = File.OpenRead(path); - return JsonSerializer.Deserialize(stream, JsonOptions); - } - catch (Exception) - { - return null; - } - } - - private void DeleteIfExists(string path) + /// + public ValueTask GetCheckpointLocationAsync(string isolationKey, CancellationToken cancellationToken) { - lock (this._writeGate) - { - if (File.Exists(path)) { File.Delete(path); } - } + Throw.IfNullOrEmpty(isolationKey); + var dir = Path.Combine(this._checkpointsPath, EncodeFileName(isolationKey)); + Directory.CreateDirectory(dir); + return new(dir); } - private static string EncodeFileName(string raw) - { - var bytes = System.Text.Encoding.UTF8.GetBytes(raw); - return Convert.ToHexString(bytes); - } + private string AliasFile(string isolationKey) => Path.Combine(this._aliasesPath, EncodeFileName(isolationKey) + ".txt"); - private static string DecodeFileName(string encoded) - { - var bytes = Convert.FromHexString(encoded); - return System.Text.Encoding.UTF8.GetString(bytes); - } -} \ No newline at end of file + private static string EncodeFileName(string raw) => Convert.ToHexString(System.Text.Encoding.UTF8.GetBytes(raw)); +} diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting.Channels/HostApplicationBuilderHostingChannelsExtensions.cs b/dotnet/src/Microsoft.Agents.AI.Hosting.Channels/HostApplicationBuilderHostingChannelsExtensions.cs index 9a1057f9a0e..332da3e299b 100644 --- a/dotnet/src/Microsoft.Agents.AI.Hosting.Channels/HostApplicationBuilderHostingChannelsExtensions.cs +++ b/dotnet/src/Microsoft.Agents.AI.Hosting.Channels/HostApplicationBuilderHostingChannelsExtensions.cs @@ -6,7 +6,6 @@ using Microsoft.Agents.AI.Workflows; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection.Extensions; -using Microsoft.Extensions.Logging; using Microsoft.Shared.Diagnostics; namespace Microsoft.Extensions.Hosting; @@ -25,7 +24,7 @@ public static IAgentFrameworkHostBuilder AddAgentFrameworkHost( { Throw.IfNull(builder); Throw.IfNull(target); - return AddAgentFrameworkHostCore(builder, configure, services => services.TryAddSingleton(_ => new AIAgentRunner(target))); + return AddCore(builder, configure, services => services.TryAddSingleton(sp => new AIAgentRunner(target, sp.GetRequiredService()))); } /// Adds an agent-framework host whose target is the supplied . @@ -36,13 +35,10 @@ public static IAgentFrameworkHostBuilder AddAgentFrameworkHost( { Throw.IfNull(builder); Throw.IfNull(target); - return AddAgentFrameworkHostCore(builder, configure, services => services.TryAddSingleton(sp => new WorkflowRunner(target, sp.GetRequiredService()))); + return AddCore(builder, configure, services => services.TryAddSingleton(_ => new WorkflowRunner(target))); } - /// - /// Adds an agent-framework host whose target is resolved from a factory. Generic overload for - /// alternative runners (Foundry, mocks, ...) supplied by other packages. - /// + /// Adds an agent-framework host whose target is resolved from a factory (for alternative runners). public static IAgentFrameworkHostBuilder AddAgentFrameworkHost( this IHostApplicationBuilder builder, Func targetFactory, @@ -51,10 +47,10 @@ public static IAgentFrameworkHostBuilder AddAgentFrameworkHost( { Throw.IfNull(builder); Throw.IfNull(targetFactory); - return AddAgentFrameworkHostCore(builder, configure, services => services.TryAddSingleton(targetFactory)); + return AddCore(builder, configure, services => services.TryAddSingleton(targetFactory)); } - private static AgentFrameworkHostBuilder AddAgentFrameworkHostCore( + private static AgentFrameworkHostBuilder AddCore( IHostApplicationBuilder builder, Action? configure, Action registerTarget) @@ -64,35 +60,27 @@ private static AgentFrameworkHostBuilder AddAgentFrameworkHostCore( var services = builder.Services; - services.TryAddSingleton(_ => new InMemoryHostStateStore()); - services.TryAddSingleton(); - services.TryAddSingleton(sp => sp.GetRequiredService()); - services.AddHostedService(sp => sp.GetRequiredService()); - - services.TryAddSingleton(_ => options.LinkPolicy ?? AllowAllLinkPolicy.Instance); - services.TryAddSingleton(); - - if (options.DefaultAllowlist is not null) + if (options.StatePaths is not null) + { + services.TryAddSingleton(_ => new FileHostStateStore(options.StatePaths)); + } + else { - services.TryAddSingleton(options.DefaultAllowlist); + services.TryAddSingleton(_ => new InMemoryHostStateStore()); } + services.TryAddSingleton(); + registerTarget(services); services.TryAddSingleton(options); - services.TryAddSingleton(sp => new AgentFrameworkHost( + services.TryAddSingleton(sp => new AgentFrameworkHost( sp, sp.GetRequiredService(), sp.GetRequiredService>(), sp.GetRequiredService(), - sp.GetRequiredService(), sp.GetRequiredService())); - services.TryAddSingleton(sp => new ResponseRouter( - sp.GetRequiredService(), - sp.GetRequiredService(), - sp.GetRequiredService>())); - return new AgentFrameworkHostBuilder(services, options); } -} \ No newline at end of file +} diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting.Channels/HostStatePathOptions.cs b/dotnet/src/Microsoft.Agents.AI.Hosting.Channels/HostStatePathOptions.cs index 8108b7d52d3..f992f064173 100644 --- a/dotnet/src/Microsoft.Agents.AI.Hosting.Channels/HostStatePathOptions.cs +++ b/dotnet/src/Microsoft.Agents.AI.Hosting.Channels/HostStatePathOptions.cs @@ -3,23 +3,18 @@ namespace Microsoft.Agents.AI.Hosting.Channels; /// -/// File-system layout for the file-backed host state store. All paths are optional; when a -/// per-component path is omitted the store derives it from . +/// File-system layout for . All paths optional; per-component paths +/// derive from when unset. v1 owns only reset-session aliases and workflow checkpoint +/// path derivation. /// public sealed record HostStatePathOptions { /// Root directory under which per-component subpaths are derived. public string? Root { get; init; } - /// Path used by for persistent task records. - public string? RunnerPath { get; init; } + /// Path for reset-session aliases. + public string? AliasesPath { get; init; } - /// Path used for the identity registry and pending link grants. - public string? LinksPath { get; init; } - - /// Path used for continuation tokens. - public string? ContinuationsPath { get; init; } - - /// Path used for last-seen ledger entries that back . - public string? LastSeenPath { get; init; } -} \ No newline at end of file + /// Root path for per-isolation-key workflow checkpoint derivation. + public string? CheckpointsPath { get; init; } +} diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting.Channels/HostedRunResult.cs b/dotnet/src/Microsoft.Agents.AI.Hosting.Channels/HostedRunResult.cs index 322b77ee616..ec3810218bd 100644 --- a/dotnet/src/Microsoft.Agents.AI.Hosting.Channels/HostedRunResult.cs +++ b/dotnet/src/Microsoft.Agents.AI.Hosting.Channels/HostedRunResult.cs @@ -34,4 +34,4 @@ public sealed record HostedRunResult : HostedRunResult /// public HostedRunResult Replace(TNew newResult) => new() { Result = newResult, Session = this.Session }; -} \ No newline at end of file +} diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting.Channels/HostedStreamItem.cs b/dotnet/src/Microsoft.Agents.AI.Hosting.Channels/HostedStreamItem.cs index 38d7c118fdd..ff46976b53b 100644 --- a/dotnet/src/Microsoft.Agents.AI.Hosting.Channels/HostedStreamItem.cs +++ b/dotnet/src/Microsoft.Agents.AI.Hosting.Channels/HostedStreamItem.cs @@ -24,4 +24,4 @@ public sealed record HostedStreamUpdate(AgentResponseUpdate Update) : HostedStre public sealed record HostedStreamEvent(object Event) : HostedStreamItem; /// Terminal item carrying the final result for post-stream bookkeeping. -public sealed record HostedStreamCompleted(HostedRunResult Result) : HostedStreamItem; \ No newline at end of file +public sealed record HostedStreamCompleted(HostedRunResult Result) : HostedStreamItem; diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting.Channels/IAgentFrameworkHostBuilder.cs b/dotnet/src/Microsoft.Agents.AI.Hosting.Channels/IAgentFrameworkHostBuilder.cs index b3ccc762d79..b8eb83c659d 100644 --- a/dotnet/src/Microsoft.Agents.AI.Hosting.Channels/IAgentFrameworkHostBuilder.cs +++ b/dotnet/src/Microsoft.Agents.AI.Hosting.Channels/IAgentFrameworkHostBuilder.cs @@ -1,14 +1,14 @@ // Copyright (c) Microsoft. All rights reserved. using System; +using System.Diagnostics.CodeAnalysis; using Microsoft.Extensions.DependencyInjection; namespace Microsoft.Agents.AI.Hosting.Channels; /// /// Builder chained off AddAgentFrameworkHost(...). Channel-add extension methods -/// (AddResponsesChannel, AddInvocationsChannel, AddTelegramChannel, ...) -/// hang off this interface. +/// (AddResponsesChannel, ...) hang off this interface. /// public interface IAgentFrameworkHostBuilder { @@ -24,21 +24,7 @@ public interface IAgentFrameworkHostBuilder /// Add a channel resolved from DI via a factory. IAgentFrameworkHostBuilder AddChannel(Func factory) where TChannel : Channel; - /// Replace the registered . - IAgentFrameworkHostBuilder UseIdentityLinker<[System.Diagnostics.CodeAnalysis.DynamicallyAccessedMembers(System.Diagnostics.CodeAnalysis.DynamicallyAccessedMemberTypes.PublicConstructors)] TLinker>() - where TLinker : class, IIdentityLinker; - - /// Replace the host-level default allowlist. - IAgentFrameworkHostBuilder UseDefaultAllowlist(IIdentityAllowlist allowlist); - - /// Replace the registered link policy. - IAgentFrameworkHostBuilder UseLinkPolicy(ILinkPolicy policy); - - /// Replace the registered durable task runner. - IAgentFrameworkHostBuilder UseDurableTaskRunner<[System.Diagnostics.CodeAnalysis.DynamicallyAccessedMembers(System.Diagnostics.CodeAnalysis.DynamicallyAccessedMemberTypes.PublicConstructors)] TRunner>() - where TRunner : class, IDurableTaskRunner; - /// Replace the registered host state store. - IAgentFrameworkHostBuilder UseHostStateStore<[System.Diagnostics.CodeAnalysis.DynamicallyAccessedMembers(System.Diagnostics.CodeAnalysis.DynamicallyAccessedMemberTypes.PublicConstructors)] TStore>() + IAgentFrameworkHostBuilder UseHostStateStore<[DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicConstructors)] TStore>() where TStore : class, IHostStateStore; -} \ No newline at end of file +} diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting.Channels/IChannelContext.cs b/dotnet/src/Microsoft.Agents.AI.Hosting.Channels/IChannelContext.cs index eebda3ca046..9c6da89be11 100644 --- a/dotnet/src/Microsoft.Agents.AI.Hosting.Channels/IChannelContext.cs +++ b/dotnet/src/Microsoft.Agents.AI.Hosting.Channels/IChannelContext.cs @@ -8,8 +8,8 @@ namespace Microsoft.Agents.AI.Hosting.Channels; /// -/// Handed to . Exposes the host's run / stream / authorization -/// surface plus the persisted state store and durable task runner. +/// Handed to . Exposes the host's run / stream surface plus the host and +/// its state store. /// public interface IChannelContext { @@ -22,30 +22,9 @@ public interface IChannelContext /// The host state store. IHostStateStore StateStore { get; } - /// The durable task runner that backs non-originating response delivery and background runs. - IDurableTaskRunner DurableRunner { get; } - - /// - /// Funnel a channel-native identity through the host's authorization pipeline. - /// - ValueTask AuthorizeAsync( - ChannelIdentity identity, - AuthorizationRequest options, - CancellationToken cancellationToken = default); - - /// Run the host target with the given request and return the (non-streaming) result. + /// Run the host target and return the (non-streaming) result. ValueTask RunAsync(ChannelRequest request, CancellationToken cancellationToken = default); /// Stream the host target's response as envelopes. IAsyncEnumerable StreamAsync(ChannelRequest request, CancellationToken cancellationToken = default); - - /// - /// Schedule outbound delivery for every non-originating destination resolved against - /// . Originating delivery is NOT scheduled here; - /// channels render their own originating reply synchronously. - /// - ValueTask> ScheduleResponseAsync( - HostedRunResult result, - ChannelRequest originating, - CancellationToken cancellationToken = default); -} \ No newline at end of file +} diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting.Channels/IChannelPush.cs b/dotnet/src/Microsoft.Agents.AI.Hosting.Channels/IChannelPush.cs deleted file mode 100644 index cfa7677b8b0..00000000000 --- a/dotnet/src/Microsoft.Agents.AI.Hosting.Channels/IChannelPush.cs +++ /dev/null @@ -1,16 +0,0 @@ -// Copyright (c) Microsoft. All rights reserved. - -using System.Threading; -using System.Threading.Tasks; - -namespace Microsoft.Agents.AI.Hosting.Channels; - -/// -/// Capability interface: a channel can deliver a response to a destination identity it owns. -/// Implementations are invoked by the host's hosting.push durable task handler. -/// -public interface IChannelPush -{ - /// Push a result to the destination described by . - ValueTask PushAsync(ChannelPushContext context, HostedRunResult payload, CancellationToken cancellationToken); -} \ No newline at end of file diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting.Channels/IChannelPushCodec.cs b/dotnet/src/Microsoft.Agents.AI.Hosting.Channels/IChannelPushCodec.cs deleted file mode 100644 index 1b12965baa6..00000000000 --- a/dotnet/src/Microsoft.Agents.AI.Hosting.Channels/IChannelPushCodec.cs +++ /dev/null @@ -1,19 +0,0 @@ -// Copyright (c) Microsoft. All rights reserved. - -using System.Text.Json.Nodes; - -namespace Microsoft.Agents.AI.Hosting.Channels; - -/// -/// Capability interface: encode the full push envelope (context + payload) so a JSON-payload -/// (out-of-process worker, gRPC TaskHub, ...) can reconstruct -/// it on the receiving side. Required pairing rule is validated by the host at startup. -/// -public interface IChannelPushCodec -{ - /// Encode the push envelope. - JsonNode Encode(ChannelPushContext context, HostedRunResult payload); - - /// Decode a previously-encoded push envelope. - (ChannelPushContext Context, HostedRunResult Payload) Decode(JsonNode encoded); -} \ No newline at end of file diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting.Channels/IChannelResponseHook.cs b/dotnet/src/Microsoft.Agents.AI.Hosting.Channels/IChannelResponseHook.cs index b28585116c8..a5186f7e5f1 100644 --- a/dotnet/src/Microsoft.Agents.AI.Hosting.Channels/IChannelResponseHook.cs +++ b/dotnet/src/Microsoft.Agents.AI.Hosting.Channels/IChannelResponseHook.cs @@ -17,4 +17,4 @@ ValueTask OnResponseAsync( HostedRunResult result, ChannelResponseContext context, CancellationToken cancellationToken); -} \ No newline at end of file +} diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting.Channels/IChannelRunHook.cs b/dotnet/src/Microsoft.Agents.AI.Hosting.Channels/IChannelRunHook.cs index a63077affe6..3ebd0ab0023 100644 --- a/dotnet/src/Microsoft.Agents.AI.Hosting.Channels/IChannelRunHook.cs +++ b/dotnet/src/Microsoft.Agents.AI.Hosting.Channels/IChannelRunHook.cs @@ -17,4 +17,4 @@ ValueTask OnRequestAsync( ChannelRequest request, ChannelRunHookContext context, CancellationToken cancellationToken); -} \ No newline at end of file +} diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting.Channels/IChannelStreamTransformHook.cs b/dotnet/src/Microsoft.Agents.AI.Hosting.Channels/IChannelStreamTransformHook.cs index e014f33af15..8aef70c033a 100644 --- a/dotnet/src/Microsoft.Agents.AI.Hosting.Channels/IChannelStreamTransformHook.cs +++ b/dotnet/src/Microsoft.Agents.AI.Hosting.Channels/IChannelStreamTransformHook.cs @@ -15,4 +15,4 @@ public interface IChannelStreamTransformHook IAsyncEnumerable TransformAsync( IAsyncEnumerable upstream, CancellationToken cancellationToken); -} \ No newline at end of file +} diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting.Channels/IConfidentialityTagged.cs b/dotnet/src/Microsoft.Agents.AI.Hosting.Channels/IConfidentialityTagged.cs deleted file mode 100644 index 428994a2a8f..00000000000 --- a/dotnet/src/Microsoft.Agents.AI.Hosting.Channels/IConfidentialityTagged.cs +++ /dev/null @@ -1,14 +0,0 @@ -// Copyright (c) Microsoft. All rights reserved. - -namespace Microsoft.Agents.AI.Hosting.Channels; - -/// -/// Capability interface: a channel can tag itself with a confidentiality tier. Read by -/// to decide whether two channels may share state -/// or deliver to one another. -/// -public interface IConfidentialityTagged -{ - /// Opaque confidentiality tier label; means single-tier. - string? ConfidentialityTier { get; } -} \ No newline at end of file diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting.Channels/IDurableTaskRunner.cs b/dotnet/src/Microsoft.Agents.AI.Hosting.Channels/IDurableTaskRunner.cs deleted file mode 100644 index 745b1978895..00000000000 --- a/dotnet/src/Microsoft.Agents.AI.Hosting.Channels/IDurableTaskRunner.cs +++ /dev/null @@ -1,34 +0,0 @@ -// Copyright (c) Microsoft. All rights reserved. - -using System; -using System.Threading; -using System.Threading.Tasks; - -namespace Microsoft.Agents.AI.Hosting.Channels; - -/// -/// Pluggable seam for scheduling, executing, and persisting background tasks. Backs the host's -/// non-originating push fan-out via the reserved "hosting.push" handler and surfaces -/// background-run tracking via . -/// -public interface IDurableTaskRunner -{ - /// How this runner serializes payloads. Read by the startup codec/runner pairing validator. - DurableTaskPayloadMode PayloadMode { get; } - - /// Register a handler under a name. The host registers "hosting.push" at startup. - void Register(string name, Func handler); - - /// Schedule a task under a previously-registered handler. - ValueTask ScheduleAsync( - string name, - object payload, - RetryPolicy? retryPolicy, - CancellationToken cancellationToken); - - /// Read the current status of a scheduled task. - ValueTask GetAsync(TaskHandle handle, CancellationToken cancellationToken); - - /// Cancel a scheduled task. - ValueTask CancelAsync(TaskHandle handle, CancellationToken cancellationToken); -} \ No newline at end of file diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting.Channels/IHostStateStore.cs b/dotnet/src/Microsoft.Agents.AI.Hosting.Channels/IHostStateStore.cs index e66fd88126b..45c394439ea 100644 --- a/dotnet/src/Microsoft.Agents.AI.Hosting.Channels/IHostStateStore.cs +++ b/dotnet/src/Microsoft.Agents.AI.Hosting.Channels/IHostStateStore.cs @@ -1,84 +1,19 @@ // Copyright (c) Microsoft. All rights reserved. -using System; -using System.Collections.Generic; using System.Threading; using System.Threading.Tasks; namespace Microsoft.Agents.AI.Hosting.Channels; /// -/// Persistence seam for host-execution metadata that outlives a single request: continuation -/// tokens, identity registry, identity-link grants, and last-seen ledger. Separate from +/// Host-execution metadata store, limited to v1 scope: reset-session aliases and workflow checkpoint path +/// derivation. It does NOT store linked identities, active-channel state, response routing, continuation +/// records, durable runner queues, or delivery attempts (those are ADR-0028 concerns). Separate from /// AgentSessionStore (per-conversation history) and WorkflowBuilder.CheckpointStorage /// (workflow checkpoints). /// public interface IHostStateStore { - // ---- Identity registry ---- - - /// Resolve the isolation key for a channel-native identity. if unknown. - ValueTask GetIsolationKeyAsync(ChannelIdentity identity, CancellationToken cancellationToken); - - /// - /// Atomically map (or merge) onto . - /// If the identity already maps to a different isolation key, both keys' records are merged. - /// Optional are persisted alongside for future auto-link replay. - /// - ValueTask SaveLinkAsync( - ChannelIdentity identity, - string isolationKey, - IReadOnlyDictionary? verifiedClaims, - CancellationToken cancellationToken); - - /// Enumerate every identity mapped to . - ValueTask> GetIdentitiesAsync( - string isolationKey, - CancellationToken cancellationToken); - - /// Look up an isolation key by a verified claim value. - ValueTask LookupByVerifiedClaimAsync( - string claim, - string value, - CancellationToken cancellationToken); - - // ---- Link grants ---- - - /// Persist a pending link grant (one-time code, OAuth state). - ValueTask SaveLinkGrantAsync(LinkGrant grant, CancellationToken cancellationToken); - - /// Read an unexpired link grant. - ValueTask GetLinkGrantAsync(string code, CancellationToken cancellationToken); - - /// Atomically read-and-delete a link grant. - ValueTask ConsumeLinkGrantAsync(string code, CancellationToken cancellationToken); - - // ---- Last seen ---- - - /// Record that was last seen at . - ValueTask RecordLastSeenAsync( - string isolationKey, - ChannelIdentity identity, - string? conversationId, - DateTimeOffset at, - CancellationToken cancellationToken); - - /// Read the latest last-seen entry for an isolation key. - ValueTask GetLastSeenAsync(string isolationKey, CancellationToken cancellationToken); - - // ---- Continuation tokens ---- - - /// Persist (or replace) a continuation token. - ValueTask SaveContinuationAsync(ContinuationToken token, CancellationToken cancellationToken); - - /// Read a continuation token by its opaque string identifier. - ValueTask GetContinuationAsync(string token, CancellationToken cancellationToken); - - /// Delete a continuation token. - ValueTask DeleteContinuationAsync(string token, CancellationToken cancellationToken); - - // ---- Session alias rotation ---- - /// /// Rotate the active session-id alias for an isolation key. Backs /// for host-tracked channels' /new-style commands. @@ -87,4 +22,7 @@ ValueTask RecordLastSeenAsync( /// Read the active session-id alias for an isolation key. ValueTask GetActiveSessionAliasAsync(string isolationKey, CancellationToken cancellationToken); -} \ No newline at end of file + + /// Derive the workflow checkpoint location for an isolation key. + ValueTask GetCheckpointLocationAsync(string isolationKey, CancellationToken cancellationToken); +} diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting.Channels/IHostedTargetRunner.cs b/dotnet/src/Microsoft.Agents.AI.Hosting.Channels/IHostedTargetRunner.cs index b1e356d3499..271a8dbea3b 100644 --- a/dotnet/src/Microsoft.Agents.AI.Hosting.Channels/IHostedTargetRunner.cs +++ b/dotnet/src/Microsoft.Agents.AI.Hosting.Channels/IHostedTargetRunner.cs @@ -17,4 +17,4 @@ public interface IHostedTargetRunner /// Stream the target's response. IAsyncEnumerable StreamAsync(ChannelRequest request, CancellationToken cancellationToken); -} \ No newline at end of file +} diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting.Channels/IIdentityAllowlist.cs b/dotnet/src/Microsoft.Agents.AI.Hosting.Channels/IIdentityAllowlist.cs deleted file mode 100644 index d65b2866431..00000000000 --- a/dotnet/src/Microsoft.Agents.AI.Hosting.Channels/IIdentityAllowlist.cs +++ /dev/null @@ -1,24 +0,0 @@ -// Copyright (c) Microsoft. All rights reserved. - -using System.Threading; -using System.Threading.Tasks; - -namespace Microsoft.Agents.AI.Hosting.Channels; - -/// -/// Decision seam for identity admission. Implementations return -/// / / at each phase -/// of the authorization pipeline. -/// -public interface IIdentityAllowlist -{ - /// - /// When , the host's startup validator rejects configurations where - /// neither RequireLink=true nor a claim-emitting channel can deliver the claims this - /// allowlist needs. Prevents the silent-deny-everyone footgun. - /// - bool RequiresLinkedClaims => false; - - /// Evaluate the supplied context. - ValueTask EvaluateAsync(AuthorizationContext context, CancellationToken cancellationToken); -} \ No newline at end of file diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting.Channels/IIdentityLinker.cs b/dotnet/src/Microsoft.Agents.AI.Hosting.Channels/IIdentityLinker.cs deleted file mode 100644 index 07c81d74e47..00000000000 --- a/dotnet/src/Microsoft.Agents.AI.Hosting.Channels/IIdentityLinker.cs +++ /dev/null @@ -1,42 +0,0 @@ -// Copyright (c) Microsoft. All rights reserved. - -using System.Collections.Generic; -using System.Threading; -using System.Threading.Tasks; - -namespace Microsoft.Agents.AI.Hosting.Channels; - -/// -/// Ceremony seam for binding a channel-native identity to verified IdP claims and a host -/// isolation key. Implementations may publish callback routes via . -/// -public interface IIdentityLinker -{ - /// Stable linker name (used in log details and startup validation messages). - string Name { get; } - - /// Linker-supplied routes (e.g. OAuth callback). Same shape as . - ChannelContribution Contribute(IChannelContext context); - - /// Begin a link ceremony for the given identity. - ValueTask BeginAsync( - ChannelIdentity identity, - string? requestedIsolationKey, - CancellationToken cancellationToken); - - /// Complete a previously-issued challenge. - ValueTask CompleteAsync( - string challengeId, - IReadOnlyDictionary proof, - CancellationToken cancellationToken); - - /// - /// Returns the isolation key for an already-linked identity, or if no - /// link exists. When entries match existing link records - /// the linker auto-merges onto the existing isolation key. - /// - ValueTask IsLinkedAsync( - ChannelIdentity identity, - IReadOnlyDictionary? verifiedClaims, - CancellationToken cancellationToken); -} \ No newline at end of file diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting.Channels/ILinkPolicy.cs b/dotnet/src/Microsoft.Agents.AI.Hosting.Channels/ILinkPolicy.cs deleted file mode 100644 index c31c694396c..00000000000 --- a/dotnet/src/Microsoft.Agents.AI.Hosting.Channels/ILinkPolicy.cs +++ /dev/null @@ -1,17 +0,0 @@ -// Copyright (c) Microsoft. All rights reserved. - -using System.Threading; -using System.Threading.Tasks; - -namespace Microsoft.Agents.AI.Hosting.Channels; - -/// -/// Decides which channels may share an isolation key -/// () and which channels may be a -/// for one another (). -/// -public interface ILinkPolicy -{ - /// Returns if the operation is permitted. - ValueTask EvaluateAsync(LinkPolicyContext context, CancellationToken cancellationToken); -} \ No newline at end of file diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting.Channels/InMemoryHostStateStore.cs b/dotnet/src/Microsoft.Agents.AI.Hosting.Channels/InMemoryHostStateStore.cs index 1aa9c9ad48c..be32466bc11 100644 --- a/dotnet/src/Microsoft.Agents.AI.Hosting.Channels/InMemoryHostStateStore.cs +++ b/dotnet/src/Microsoft.Agents.AI.Hosting.Channels/InMemoryHostStateStore.cs @@ -2,9 +2,6 @@ using System; using System.Collections.Concurrent; -using System.Collections.Generic; -using System.Collections.Immutable; -using System.Linq; using System.Threading; using System.Threading.Tasks; using Microsoft.Shared.Diagnostics; @@ -13,172 +10,12 @@ namespace Microsoft.Agents.AI.Hosting.Channels; /// /// In-memory . Volatile; intended for tests, samples, and single-process -/// development scenarios. Thread-safe. +/// development. Thread-safe. /// public sealed class InMemoryHostStateStore : IHostStateStore { - private readonly object _gate = new(); - private readonly Dictionary<(string Channel, string NativeId), string> _identityToKey = new(); - private readonly Dictionary> _keyToIdentities = new(StringComparer.Ordinal); - private readonly Dictionary<(string Claim, string Value), string> _claimToKey = new(); - private readonly ConcurrentDictionary _linkGrants = new(StringComparer.Ordinal); - private readonly ConcurrentDictionary _lastSeen = new(StringComparer.Ordinal); - private readonly ConcurrentDictionary _continuations = new(StringComparer.Ordinal); private readonly ConcurrentDictionary _sessionAliases = new(StringComparer.Ordinal); - /// - public ValueTask GetIsolationKeyAsync(ChannelIdentity identity, CancellationToken cancellationToken) - { - Throw.IfNull(identity); - lock (this._gate) - { - return new(this._identityToKey.TryGetValue((identity.Channel, identity.NativeId), out var key) ? key : null); - } - } - - /// - public ValueTask SaveLinkAsync( - ChannelIdentity identity, - string isolationKey, - IReadOnlyDictionary? verifiedClaims, - CancellationToken cancellationToken) - { - Throw.IfNull(identity); - Throw.IfNullOrEmpty(isolationKey); - - lock (this._gate) - { - var key = (identity.Channel, identity.NativeId); - if (this._identityToKey.TryGetValue(key, out var existing) && existing != isolationKey) - { - // Merge: move all identities under `existing` into `isolationKey`. - if (this._keyToIdentities.TryGetValue(existing, out var existingList)) - { - foreach (var reg in existingList) - { - this._identityToKey[(reg.Identity.Channel, reg.Identity.NativeId)] = isolationKey; - this.GetOrCreateList(isolationKey).Add(reg); - } - this._keyToIdentities.Remove(existing); - } - } - - this._identityToKey[key] = isolationKey; - var claims = verifiedClaims ?? ImmutableDictionary.Empty; - var registration = new ChannelIdentityRegistration(identity, DateTimeOffset.UtcNow, claims); - - var list = this.GetOrCreateList(isolationKey); - list.RemoveAll(r => r.Identity.Channel == identity.Channel && r.Identity.NativeId == identity.NativeId); - list.Add(registration); - - foreach (var (claim, value) in claims) - { - this._claimToKey[(claim, value)] = isolationKey; - } - } - - return default; - } - - /// - public ValueTask> GetIdentitiesAsync(string isolationKey, CancellationToken cancellationToken) - { - Throw.IfNullOrEmpty(isolationKey); - lock (this._gate) - { - if (this._keyToIdentities.TryGetValue(isolationKey, out var list)) - { - return new((IReadOnlyList)list.ToArray()); - } - return new(Array.Empty()); - } - } - - /// - public ValueTask LookupByVerifiedClaimAsync(string claim, string value, CancellationToken cancellationToken) - { - Throw.IfNullOrEmpty(claim); - Throw.IfNull(value); - lock (this._gate) - { - return new(this._claimToKey.TryGetValue((claim, value), out var key) ? key : null); - } - } - - /// - public ValueTask SaveLinkGrantAsync(LinkGrant grant, CancellationToken cancellationToken) - { - Throw.IfNull(grant); - this._linkGrants[grant.Code] = grant; - return default; - } - - /// - public ValueTask GetLinkGrantAsync(string code, CancellationToken cancellationToken) - { - Throw.IfNullOrEmpty(code); - if (this._linkGrants.TryGetValue(code, out var grant) && grant.ExpiresAt > DateTimeOffset.UtcNow) - { - return new(grant); - } - return new((LinkGrant?)null); - } - - /// - public ValueTask ConsumeLinkGrantAsync(string code, CancellationToken cancellationToken) - { - Throw.IfNullOrEmpty(code); - if (this._linkGrants.TryRemove(code, out var grant) && grant.ExpiresAt > DateTimeOffset.UtcNow) - { - return new(grant); - } - return new((LinkGrant?)null); - } - - /// - public ValueTask RecordLastSeenAsync( - string isolationKey, - ChannelIdentity identity, - string? conversationId, - DateTimeOffset at, - CancellationToken cancellationToken) - { - Throw.IfNullOrEmpty(isolationKey); - Throw.IfNull(identity); - this._lastSeen[isolationKey] = new LastSeenRecord(identity, conversationId, at); - return default; - } - - /// - public ValueTask GetLastSeenAsync(string isolationKey, CancellationToken cancellationToken) - { - Throw.IfNullOrEmpty(isolationKey); - return new(this._lastSeen.TryGetValue(isolationKey, out var rec) ? rec : null); - } - - /// - public ValueTask SaveContinuationAsync(ContinuationToken token, CancellationToken cancellationToken) - { - Throw.IfNull(token); - this._continuations[token.Token] = token; - return default; - } - - /// - public ValueTask GetContinuationAsync(string token, CancellationToken cancellationToken) - { - Throw.IfNullOrEmpty(token); - return new(this._continuations.TryGetValue(token, out var t) ? t : null); - } - - /// - public ValueTask DeleteContinuationAsync(string token, CancellationToken cancellationToken) - { - Throw.IfNullOrEmpty(token); - this._continuations.TryRemove(token, out _); - return default; - } - /// public ValueTask RotateSessionAliasAsync(string isolationKey, CancellationToken cancellationToken) { @@ -199,13 +36,10 @@ public ValueTask RotateSessionAliasAsync(string isolationKey, CancellationToken return new(alias); } - private List GetOrCreateList(string isolationKey) + /// + public ValueTask GetCheckpointLocationAsync(string isolationKey, CancellationToken cancellationToken) { - if (!this._keyToIdentities.TryGetValue(isolationKey, out var list)) - { - list = []; - this._keyToIdentities[isolationKey] = list; - } - return list; + Throw.IfNullOrEmpty(isolationKey); + return new($"checkpoints/{isolationKey}"); } -} \ No newline at end of file +} diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting.Channels/InProcessDurableTaskRunner.cs b/dotnet/src/Microsoft.Agents.AI.Hosting.Channels/InProcessDurableTaskRunner.cs deleted file mode 100644 index 0ac8fb5f8e9..00000000000 --- a/dotnet/src/Microsoft.Agents.AI.Hosting.Channels/InProcessDurableTaskRunner.cs +++ /dev/null @@ -1,193 +0,0 @@ -// Copyright (c) Microsoft. All rights reserved. - -using System; -using System.Collections.Concurrent; -using System.Collections.Generic; -using System.Threading; -using System.Threading.Tasks; -using Microsoft.Extensions.Hosting; -using Microsoft.Extensions.Logging; -using Microsoft.Shared.Diagnostics; -using ThreadingChannel = System.Threading.Channels.Channel; -using ThreadingChannelT = System.Threading.Channels; - -namespace Microsoft.Agents.AI.Hosting.Channels; - -/// -/// Default implementation: an backed -/// by a bounded worker loop. In-memory only (no -/// replay across restarts); applications needing durability should swap in an external runner from -/// a fast-follow package. -/// -/// -/// Two-phase shutdown: on , in-flight tasks are given -/// to finish. Remaining tasks are cancelled and their cancellation exceptions are swallowed. -/// -public sealed class InProcessDurableTaskRunner : IDurableTaskRunner, IHostedService, IAsyncDisposable -{ - private readonly ThreadingChannelT.Channel _queue; - private readonly ConcurrentDictionary> _handlers = new(StringComparer.Ordinal); - private readonly ConcurrentDictionary _statuses = new(StringComparer.Ordinal); - private readonly ConcurrentDictionary> _state = new(StringComparer.Ordinal); - private readonly ILogger _logger; - private readonly CancellationTokenSource _shutdownCts = new(); - private Task? _workerTask; - - /// Time the runner waits for in-flight tasks at shutdown before cancelling them. - public double ShutdownGraceSeconds { get; init; } = 5.0; - - /// - public DurableTaskPayloadMode PayloadMode => DurableTaskPayloadMode.Object; - - /// Initializes a new instance. - public InProcessDurableTaskRunner(ILogger logger) - { - this._logger = Throw.IfNull(logger); - this._queue = ThreadingChannel.CreateBounded(new ThreadingChannelT.BoundedChannelOptions(1024) - { - SingleReader = true, - FullMode = ThreadingChannelT.BoundedChannelFullMode.Wait, - }); - } - - /// - public void Register(string name, Func handler) - { - Throw.IfNullOrEmpty(name); - Throw.IfNull(handler); - this._handlers[name] = handler; - } - - /// - public async ValueTask ScheduleAsync(string name, object payload, RetryPolicy? retryPolicy, CancellationToken cancellationToken) - { - Throw.IfNullOrEmpty(name); - Throw.IfNull(payload); - - if (!this._handlers.ContainsKey(name)) - { - throw new InvalidOperationException($"No handler registered under '{name}'."); - } - - var handle = new TaskHandle(Guid.NewGuid().ToString("N"), name); - this._statuses[handle.TaskId] = DurableTaskStatus.Scheduled; - this._state[handle.TaskId] = new Dictionary(StringComparer.Ordinal); - - await this._queue.Writer.WriteAsync(new QueuedTask(handle, payload, retryPolicy ?? RetryPolicy.Default), cancellationToken).ConfigureAwait(false); - return handle; - } - - /// - public ValueTask GetAsync(TaskHandle handle, CancellationToken cancellationToken) - { - Throw.IfNull(handle); - return new(this._statuses.TryGetValue(handle.TaskId, out var status) ? status : null); - } - - /// - public ValueTask CancelAsync(TaskHandle handle, CancellationToken cancellationToken) - { - Throw.IfNull(handle); - this._statuses.TryUpdate(handle.TaskId, DurableTaskStatus.Cancelled, DurableTaskStatus.Scheduled); - return default; - } - - /// - public Task StartAsync(CancellationToken cancellationToken) - { - this._workerTask = Task.Run(() => this.WorkerLoopAsync(this._shutdownCts.Token), CancellationToken.None); - return Task.CompletedTask; - } - - /// - public async Task StopAsync(CancellationToken cancellationToken) - { - this._queue.Writer.TryComplete(); - var grace = TimeSpan.FromSeconds(this.ShutdownGraceSeconds); - try - { - if (this._workerTask is not null) - { - await this._workerTask.WaitAsync(grace, cancellationToken).ConfigureAwait(false); - } - } - catch (TimeoutException) - { - this._shutdownCts.Cancel(); - if (this._workerTask is not null) - { - try { await this._workerTask.ConfigureAwait(false); } - catch (OperationCanceledException) { /* expected at shutdown */ } - } - } - } - - /// - public async ValueTask DisposeAsync() - { - this._shutdownCts.Cancel(); - if (this._workerTask is not null) - { - try { await this._workerTask.ConfigureAwait(false); } - catch (OperationCanceledException) { /* expected */ } - } - this._shutdownCts.Dispose(); - } - - private async Task WorkerLoopAsync(CancellationToken cancellationToken) - { - await foreach (var queued in this._queue.Reader.ReadAllAsync(cancellationToken).ConfigureAwait(false)) - { - if (this._statuses.TryGetValue(queued.Handle.TaskId, out var status) && status == DurableTaskStatus.Cancelled) - { - continue; - } - - await this.ExecuteWithRetryAsync(queued, cancellationToken).ConfigureAwait(false); - } - } - - private async Task ExecuteWithRetryAsync(QueuedTask queued, CancellationToken cancellationToken) - { - if (!this._handlers.TryGetValue(queued.Handle.Name, out var handler)) - { - this._statuses[queued.Handle.TaskId] = DurableTaskStatus.Failed; - this._logger.LogError("No handler registered for {Handler} (task {TaskId}).", queued.Handle.Name, queued.Handle.TaskId); - return; - } - - var policy = queued.RetryPolicy; - var delay = policy.InitialBackoff; - var state = this._state[queued.Handle.TaskId]; - - for (var attempt = 1; attempt <= policy.MaxAttempts; attempt++) - { - this._statuses[queued.Handle.TaskId] = DurableTaskStatus.Running; - try - { - await handler(new TaskInvocationContext(queued.Handle.Name, queued.Payload, attempt, state)).ConfigureAwait(false); - this._statuses[queued.Handle.TaskId] = DurableTaskStatus.Succeeded; - return; - } - catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) - { - this._statuses[queued.Handle.TaskId] = DurableTaskStatus.Cancelled; - return; - } - catch (Exception ex) - { - this._logger.LogWarning(ex, "Durable task {TaskId} attempt {Attempt}/{Max} failed.", queued.Handle.TaskId, attempt, policy.MaxAttempts); - if (attempt == policy.MaxAttempts) - { - this._statuses[queued.Handle.TaskId] = DurableTaskStatus.Failed; - return; - } - try { await Task.Delay(delay, cancellationToken).ConfigureAwait(false); } - catch (OperationCanceledException) { this._statuses[queued.Handle.TaskId] = DurableTaskStatus.Cancelled; return; } - delay = TimeSpan.FromMilliseconds(Math.Min(delay.TotalMilliseconds * policy.BackoffMultiplier, policy.MaxBackoff.TotalMilliseconds)); - } - } - } - - private sealed record QueuedTask(TaskHandle Handle, object Payload, RetryPolicy RetryPolicy); -} \ No newline at end of file diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting.Channels/Internal/AgentFrameworkHostBuilder.cs b/dotnet/src/Microsoft.Agents.AI.Hosting.Channels/Internal/AgentFrameworkHostBuilder.cs index a216fd30f70..78445ffe194 100644 --- a/dotnet/src/Microsoft.Agents.AI.Hosting.Channels/Internal/AgentFrameworkHostBuilder.cs +++ b/dotnet/src/Microsoft.Agents.AI.Hosting.Channels/Internal/AgentFrameworkHostBuilder.cs @@ -17,8 +17,6 @@ public AgentFrameworkHostBuilder(IServiceCollection services, AgentFrameworkHost { this.Services = Throw.IfNull(services); this.Options = Throw.IfNull(options); - - // Register the channels collection so AgentFrameworkHost can resolve it. services.AddSingleton>(_ => this._channels); } @@ -38,51 +36,20 @@ public IAgentFrameworkHostBuilder AddChannel(Func(factory); + this.Services.AddSingleton(factory); this.Services.AddSingleton(sp => sp.GetRequiredService()); - this._channels.Add(probe); return this; } - public IAgentFrameworkHostBuilder UseIdentityLinker<[DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicConstructors)] TLinker>() - where TLinker : class, IIdentityLinker - { - this.Services.Replace(ServiceDescriptor.Singleton()); - return this; - } - - public IAgentFrameworkHostBuilder UseDefaultAllowlist(IIdentityAllowlist allowlist) - { - Throw.IfNull(allowlist); - this.Services.Replace(ServiceDescriptor.Singleton(allowlist)); - return this; - } - - public IAgentFrameworkHostBuilder UseLinkPolicy(ILinkPolicy policy) - { - Throw.IfNull(policy); - this.Services.Replace(ServiceDescriptor.Singleton(policy)); - return this; - } - - public IAgentFrameworkHostBuilder UseDurableTaskRunner<[DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicConstructors)] TRunner>() - where TRunner : class, IDurableTaskRunner - { - this.Services.Replace(ServiceDescriptor.Singleton()); - return this; - } - public IAgentFrameworkHostBuilder UseHostStateStore<[DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicConstructors)] TStore>() where TStore : class, IHostStateStore { this.Services.Replace(ServiceDescriptor.Singleton()); return this; } -} \ No newline at end of file +} diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting.Channels/Internal/ChannelContext.cs b/dotnet/src/Microsoft.Agents.AI.Hosting.Channels/Internal/ChannelContext.cs index deaca4ed717..998496379ed 100644 --- a/dotnet/src/Microsoft.Agents.AI.Hosting.Channels/Internal/ChannelContext.cs +++ b/dotnet/src/Microsoft.Agents.AI.Hosting.Channels/Internal/ChannelContext.cs @@ -19,27 +19,10 @@ public ChannelContext(IServiceProvider services, AgentFrameworkHost host) public IServiceProvider Services { get; } public AgentFrameworkHost Host { get; } public IHostStateStore StateStore => this.Host.StateStore; - public IDurableTaskRunner DurableRunner => this.Host.DurableRunner; - - public ValueTask AuthorizeAsync( - ChannelIdentity identity, - AuthorizationRequest options, - CancellationToken cancellationToken = default) - => this.Host.AuthorizeAsync(identity, options, cancellationToken); public ValueTask RunAsync(ChannelRequest request, CancellationToken cancellationToken = default) => this.Host.RunAsync(request, cancellationToken); public IAsyncEnumerable StreamAsync(ChannelRequest request, CancellationToken cancellationToken = default) => this.Host.StreamAsync(request, cancellationToken); - - public ValueTask> ScheduleResponseAsync( - HostedRunResult result, - ChannelRequest originating, - CancellationToken cancellationToken = default) - { - var router = (ResponseRouter?)this.Services.GetService(typeof(ResponseRouter)) - ?? throw new InvalidOperationException("ResponseRouter is not registered. Call AddAgentFrameworkHost(...) on IHostApplicationBuilder."); - return router.ScheduleResponseAsync(result, originating, cancellationToken); - } -} \ No newline at end of file +} diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting.Channels/Internal/HostingPushPayload.cs b/dotnet/src/Microsoft.Agents.AI.Hosting.Channels/Internal/HostingPushPayload.cs deleted file mode 100644 index 8eada3bb906..00000000000 --- a/dotnet/src/Microsoft.Agents.AI.Hosting.Channels/Internal/HostingPushPayload.cs +++ /dev/null @@ -1,14 +0,0 @@ -// Copyright (c) Microsoft. All rights reserved. - -namespace Microsoft.Agents.AI.Hosting.Channels; - -/// -/// Payload the host enqueues on the durable runner under the "hosting.push" handler. Carries -/// everything the push handler needs to invoke the right channel's IChannelPush and run per-destination -/// response hooks. -/// -internal sealed record HostingPushPayload( - ChannelPushContext PushContext, - HostedRunResult Result, - string DestinationChannelName, - bool Originating); \ No newline at end of file diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting.Channels/Internal/ResponseRouter.cs b/dotnet/src/Microsoft.Agents.AI.Hosting.Channels/Internal/ResponseRouter.cs deleted file mode 100644 index 22776e8e421..00000000000 --- a/dotnet/src/Microsoft.Agents.AI.Hosting.Channels/Internal/ResponseRouter.cs +++ /dev/null @@ -1,249 +0,0 @@ -// Copyright (c) Microsoft. All rights reserved. - -using System; -using System.Collections.Generic; -using System.Threading; -using System.Threading.Tasks; -using Microsoft.Extensions.Logging; -using Microsoft.Shared.Diagnostics; - -namespace Microsoft.Agents.AI.Hosting.Channels; - -/// -/// Single owner of response-target resolution and durable push scheduling. Registered as a -/// singleton; binds the "hosting.push" handler on the configured -/// at construction. -/// -internal sealed class ResponseRouter -{ - /// Reserved handler name registered on the durable runner. - public const string PushHandlerName = "hosting.push"; - - private readonly AgentFrameworkHost _host; - private readonly ILinkPolicy _linkPolicy; - private readonly ILogger _logger; - private readonly Dictionary _channelsByName; - - public ResponseRouter( - AgentFrameworkHost host, - ILinkPolicy linkPolicy, - ILogger logger) - { - this._host = Throw.IfNull(host); - this._linkPolicy = Throw.IfNull(linkPolicy); - this._logger = Throw.IfNull(logger); - this._channelsByName = new Dictionary(StringComparer.Ordinal); - for (var i = 0; i < host.Channels.Count; i++) - { - this._channelsByName[host.Channels[i].Name] = host.Channels[i]; - } - - host.DurableRunner.Register(PushHandlerName, this.HandlePushAsync); - } - - /// - /// Resolve the destination set against the configured response target, schedule one - /// task per non-originating destination, and return the - /// per-destination task handles. - /// - public async ValueTask> ScheduleResponseAsync( - HostedRunResult result, - ChannelRequest originating, - CancellationToken cancellationToken) - { - Throw.IfNull(result); - Throw.IfNull(originating); - - var target = originating.ResponseTarget ?? ResponseTarget.Originating; - if (target is ResponseTarget.OriginatingResponseTarget || target is ResponseTarget.NoneResponseTarget) - { - return Array.Empty(); - } - - var destinations = await this.ResolveDestinationsAsync(target, originating, cancellationToken).ConfigureAwait(false); - if (destinations.Count == 0) - { - this._logger.LogWarning("Response target {Target} resolved to zero destinations for originating channel {Channel}. Falling back to originating.", target.GetType().Name, originating.Channel); - return Array.Empty(); - } - - var handles = new List(destinations.Count); - for (var i = 0; i < destinations.Count; i++) - { - var dest = destinations[i]; - var isOriginating = string.Equals(dest.Identity.Channel, originating.Channel, StringComparison.Ordinal); - if (isOriginating) - { - continue; - } - - if (!this._channelsByName.TryGetValue(dest.Identity.Channel, out var destChannel) || destChannel is not IChannelPush) - { - this._logger.LogWarning("Destination channel {Channel} is not registered or does not implement IChannelPush. Skipping.", dest.Identity.Channel); - continue; - } - - var pushContext = new ChannelPushContext - { - Destination = dest.Identity, - OriginatingRequest = originating, - OriginatingChannel = originating.Channel, - IsEcho = false, - OriginalTarget = target, - }; - - var payload = new HostingPushPayload(pushContext, result, dest.Identity.Channel, Originating: false); - var handle = await this._host.DurableRunner.ScheduleAsync(PushHandlerName, payload, retryPolicy: null, cancellationToken).ConfigureAwait(false); - handles.Add(handle); - - if (dest.EchoInput) - { - var echoContext = pushContext with { IsEcho = true }; - var echoPayload = new HostingPushPayload(echoContext, result, dest.Identity.Channel, Originating: false); - handles.Add(await this._host.DurableRunner.ScheduleAsync(PushHandlerName, echoPayload, retryPolicy: null, cancellationToken).ConfigureAwait(false)); - } - } - - return handles; - } - - private async ValueTask> ResolveDestinationsAsync( - ResponseTarget target, - ChannelRequest originating, - CancellationToken cancellationToken) - { - var isolationKey = originating.Session?.IsolationKey; - switch (target) - { - case ResponseTarget.ActiveResponseTarget: - if (isolationKey is null) { return Array.Empty(); } - var lastSeen = await this._host.StateStore.GetLastSeenAsync(isolationKey, cancellationToken).ConfigureAwait(false); - return lastSeen is null - ? Array.Empty() - : await this.FilterByLinkPolicyAsync(originating.Channel, [new ResolvedDestination(lastSeen.Identity.Channel, lastSeen.Identity, EchoInput: false)], cancellationToken).ConfigureAwait(false); - - case ResponseTarget.AllLinkedResponseTarget: - if (isolationKey is null) { return Array.Empty(); } - var registrations = await this._host.StateStore.GetIdentitiesAsync(isolationKey, cancellationToken).ConfigureAwait(false); - var all = new List(registrations.Count); - for (var i = 0; i < registrations.Count; i++) - { - all.Add(new ResolvedDestination(registrations[i].Identity.Channel, registrations[i].Identity, EchoInput: false)); - } - return await this.FilterByLinkPolicyAsync(originating.Channel, all, cancellationToken).ConfigureAwait(false); - - case ResponseTarget.ChannelResponseTarget chTarget: - return await this.ResolveChannelTargetsAsync(originating.Channel, isolationKey, [chTarget.ChannelName], chTarget.EchoInput, cancellationToken).ConfigureAwait(false); - - case ResponseTarget.ChannelsResponseTarget chsTarget: - return await this.ResolveChannelTargetsAsync(originating.Channel, isolationKey, chsTarget.ChannelNames, chsTarget.EchoInput, cancellationToken).ConfigureAwait(false); - - case ResponseTarget.IdentitiesResponseTarget idTarget: - var dests = new List(idTarget.Targets.Count); - for (var i = 0; i < idTarget.Targets.Count; i++) - { - dests.Add(new ResolvedDestination(idTarget.Targets[i].Channel, idTarget.Targets[i], idTarget.EchoInput)); - } - return await this.FilterByLinkPolicyAsync(originating.Channel, dests, cancellationToken).ConfigureAwait(false); - - default: - return Array.Empty(); - } - } - - private async ValueTask> ResolveChannelTargetsAsync( - string originatingChannel, - string? isolationKey, - IReadOnlyList channelNames, - bool echoInput, - CancellationToken cancellationToken) - { - if (isolationKey is null) { return Array.Empty(); } - var registrations = await this._host.StateStore.GetIdentitiesAsync(isolationKey, cancellationToken).ConfigureAwait(false); - - var resolved = new List(); - var matchSet = new HashSet(channelNames, StringComparer.Ordinal); - for (var i = 0; i < registrations.Count; i++) - { - var reg = registrations[i]; - if (matchSet.Contains(reg.Identity.Channel)) - { - resolved.Add(new ResolvedDestination(reg.Identity.Channel, reg.Identity, echoInput)); - } - } - return await this.FilterByLinkPolicyAsync(originatingChannel, resolved, cancellationToken).ConfigureAwait(false); - } - - private async ValueTask> FilterByLinkPolicyAsync( - string originatingChannelName, - List candidates, - CancellationToken cancellationToken) - { - if (!this._channelsByName.TryGetValue(originatingChannelName, out var source)) - { - return candidates; - } - - var filtered = new List(candidates.Count); - for (var i = 0; i < candidates.Count; i++) - { - var dest = candidates[i]; - if (!this._channelsByName.TryGetValue(dest.ChannelName, out var destChannel)) - { - continue; - } - var permitted = await this._linkPolicy.EvaluateAsync( - new LinkPolicyContext { Source = source, Destination = destChannel, Operation = LinkPolicyOperation.Deliver }, - cancellationToken).ConfigureAwait(false); - if (permitted) { filtered.Add(dest); } - } - return filtered; - } - - private async ValueTask HandlePushAsync(TaskInvocationContext invocation) - { - if (invocation.Payload is not HostingPushPayload payload) - { - this._logger.LogError("hosting.push handler received unexpected payload type {Type}.", invocation.Payload?.GetType().FullName ?? ""); - return; - } - - if (!this._channelsByName.TryGetValue(payload.DestinationChannelName, out var channel)) - { - this._logger.LogError("hosting.push handler could not resolve destination channel {Channel}.", payload.DestinationChannelName); - return; - } - - if (channel is not IChannelPush push) - { - this._logger.LogError("Destination channel {Channel} does not implement IChannelPush.", payload.DestinationChannelName); - return; - } - - // Echo idempotency cursor: never re-run a successful echo or response on retry. - var stateKey = payload.PushContext.IsEcho ? "echo_done" : "response_done"; - if (invocation.State.TryGetValue(stateKey, out var done) && done is true) - { - return; - } - - var resultForPush = payload.Result; - if (channel is IChannelResponseHook hook) - { - var hookContext = new ChannelResponseContext - { - Request = payload.PushContext.OriginatingRequest, - ChannelName = payload.DestinationChannelName, - DestinationIdentity = payload.PushContext.Destination, - Originating = payload.Originating, - IsEcho = payload.PushContext.IsEcho, - }; - resultForPush = await hook.OnResponseAsync(resultForPush, hookContext, CancellationToken.None).ConfigureAwait(false); - } - - await push.PushAsync(payload.PushContext, resultForPush, CancellationToken.None).ConfigureAwait(false); - invocation.State[stateKey] = true; - } -} - -internal sealed record ResolvedDestination(string ChannelName, ChannelIdentity Identity, bool EchoInput); \ No newline at end of file diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting.Channels/IsolationKeys.cs b/dotnet/src/Microsoft.Agents.AI.Hosting.Channels/IsolationKeys.cs index edb7572c676..b35a01c9d2c 100644 --- a/dotnet/src/Microsoft.Agents.AI.Hosting.Channels/IsolationKeys.cs +++ b/dotnet/src/Microsoft.Agents.AI.Hosting.Channels/IsolationKeys.cs @@ -5,10 +5,11 @@ namespace Microsoft.Agents.AI.Hosting.Channels; /// -/// Per-request partition hints carried via . Distinct from the app-level -/// isolation key produced by ; this is the Foundry runtime's -/// per-request partition hint lifted off x-agent-user-isolation-key / -/// x-agent-chat-isolation-key headers. +/// Per-request partition hints carried via , lifted from +/// x-agent-user-isolation-key / x-agent-chat-isolation-key headers by host middleware only +/// when the Foundry hosting environment flag is present. Distinct from the app-level +/// . Reusing the header names does not make this the supported +/// Foundry Hosted Agents surface. /// public sealed record IsolationKeys(string? UserKey, string? ChatKey) { @@ -42,4 +43,4 @@ public interface IIsolationKeysAccessor internal sealed class IsolationKeysAccessor : IIsolationKeysAccessor { public IsolationKeys? Current => IsolationKeys.Current; -} \ No newline at end of file +} diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting.Channels/LastSeenRecord.cs b/dotnet/src/Microsoft.Agents.AI.Hosting.Channels/LastSeenRecord.cs deleted file mode 100644 index d7a710d6881..00000000000 --- a/dotnet/src/Microsoft.Agents.AI.Hosting.Channels/LastSeenRecord.cs +++ /dev/null @@ -1,16 +0,0 @@ -// Copyright (c) Microsoft. All rights reserved. - -using System; - -namespace Microsoft.Agents.AI.Hosting.Channels; - -/// -/// Most recent channel activity observed for an isolation key. Backs . -/// -/// The full channel-native identity last seen (not just the channel name). -/// The conversation last seen, when applicable. -/// Timestamp of the observation. -public sealed record LastSeenRecord( - ChannelIdentity Identity, - string? ConversationId, - DateTimeOffset At); \ No newline at end of file diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting.Channels/LinkChallenge.cs b/dotnet/src/Microsoft.Agents.AI.Hosting.Channels/LinkChallenge.cs deleted file mode 100644 index 580416a07cf..00000000000 --- a/dotnet/src/Microsoft.Agents.AI.Hosting.Channels/LinkChallenge.cs +++ /dev/null @@ -1,21 +0,0 @@ -// Copyright (c) Microsoft. All rights reserved. - -using System; - -namespace Microsoft.Agents.AI.Hosting.Channels; - -/// -/// Renderable artifact produced by . Channels project this -/// onto their wire (one-time code message, OAuth redirect URL, MFA prompt, ...). -/// -/// Stable id passed back into . -/// Free-form kind discriminator ("url", "code", "mfa", ...). -/// Optional redirect URL for OAuth-style flows. -/// Optional human-presentable code for code-entry flows. -/// Optional natural-language instruction. -public sealed record LinkChallenge( - string ChallengeId, - string Kind, - Uri? Url = null, - string? Code = null, - string? UserPrompt = null); \ No newline at end of file diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting.Channels/LinkGrant.cs b/dotnet/src/Microsoft.Agents.AI.Hosting.Channels/LinkGrant.cs deleted file mode 100644 index 64568000525..00000000000 --- a/dotnet/src/Microsoft.Agents.AI.Hosting.Channels/LinkGrant.cs +++ /dev/null @@ -1,22 +0,0 @@ -// Copyright (c) Microsoft. All rights reserved. - -using System; -using System.Collections.Generic; - -namespace Microsoft.Agents.AI.Hosting.Channels; - -/// -/// Pending link grant: one-time code or OAuth state issued by an -/// and persisted on the until consumed by the callback / completion call. -/// -/// The opaque code or state value the verifier presents. -/// The that issued this grant. -/// Optional explicit isolation key the user requested at begin time. -/// When the grant becomes invalid. -/// Linker-defined opaque payload (PKCE verifier, redirect uri, ...). -public sealed record LinkGrant( - string Code, - string IssuedByLinker, - string? RequestedIsolationKey, - DateTimeOffset ExpiresAt, - IReadOnlyDictionary Payload); \ No newline at end of file diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting.Channels/LinkPolicies/AllowAllLinkPolicy.cs b/dotnet/src/Microsoft.Agents.AI.Hosting.Channels/LinkPolicies/AllowAllLinkPolicy.cs deleted file mode 100644 index 682ae34a95a..00000000000 --- a/dotnet/src/Microsoft.Agents.AI.Hosting.Channels/LinkPolicies/AllowAllLinkPolicy.cs +++ /dev/null @@ -1,16 +0,0 @@ -// Copyright (c) Microsoft. All rights reserved. - -using System.Threading; -using System.Threading.Tasks; - -namespace Microsoft.Agents.AI.Hosting.Channels; - -/// Permits every link and delivery. Default policy. -public sealed class AllowAllLinkPolicy : ILinkPolicy -{ - /// Shared singleton. - public static AllowAllLinkPolicy Instance { get; } = new(); - - /// - public ValueTask EvaluateAsync(LinkPolicyContext context, CancellationToken cancellationToken) => new(true); -} \ No newline at end of file diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting.Channels/LinkPolicies/DenyAllLinkPolicy.cs b/dotnet/src/Microsoft.Agents.AI.Hosting.Channels/LinkPolicies/DenyAllLinkPolicy.cs deleted file mode 100644 index ea5d861757e..00000000000 --- a/dotnet/src/Microsoft.Agents.AI.Hosting.Channels/LinkPolicies/DenyAllLinkPolicy.cs +++ /dev/null @@ -1,16 +0,0 @@ -// Copyright (c) Microsoft. All rights reserved. - -using System.Threading; -using System.Threading.Tasks; - -namespace Microsoft.Agents.AI.Hosting.Channels; - -/// Refuses every link and delivery. Channels share target only, never sessions. -public sealed class DenyAllLinkPolicy : ILinkPolicy -{ - /// Shared singleton. - public static DenyAllLinkPolicy Instance { get; } = new(); - - /// - public ValueTask EvaluateAsync(LinkPolicyContext context, CancellationToken cancellationToken) => new(false); -} \ No newline at end of file diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting.Channels/LinkPolicies/ExplicitAllowListLinkPolicy.cs b/dotnet/src/Microsoft.Agents.AI.Hosting.Channels/LinkPolicies/ExplicitAllowListLinkPolicy.cs deleted file mode 100644 index a1170b04f6b..00000000000 --- a/dotnet/src/Microsoft.Agents.AI.Hosting.Channels/LinkPolicies/ExplicitAllowListLinkPolicy.cs +++ /dev/null @@ -1,33 +0,0 @@ -// Copyright (c) Microsoft. All rights reserved. - -using System; -using System.Collections.Generic; -using System.Linq; -using System.Threading; -using System.Threading.Tasks; -using Microsoft.Shared.Diagnostics; - -namespace Microsoft.Agents.AI.Hosting.Channels; - -/// -/// Permits links / deliveries only when the (source, destination) channel pair appears in the -/// configured allow list. -/// -public sealed class ExplicitAllowListLinkPolicy : ILinkPolicy -{ - private readonly HashSet<(string Source, string Destination)> _allowed; - - /// Initializes a new instance. - public ExplicitAllowListLinkPolicy(IEnumerable<(string Source, string Destination)> allowedPairs) - { - Throw.IfNull(allowedPairs); - this._allowed = new HashSet<(string, string)>(allowedPairs); - } - - /// - public ValueTask EvaluateAsync(LinkPolicyContext context, CancellationToken cancellationToken) - { - Throw.IfNull(context); - return new(this._allowed.Contains((context.Source.Name, context.Destination.Name))); - } -} \ No newline at end of file diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting.Channels/LinkPolicies/SameConfidentialityTierLinkPolicy.cs b/dotnet/src/Microsoft.Agents.AI.Hosting.Channels/LinkPolicies/SameConfidentialityTierLinkPolicy.cs deleted file mode 100644 index d708573c0b0..00000000000 --- a/dotnet/src/Microsoft.Agents.AI.Hosting.Channels/LinkPolicies/SameConfidentialityTierLinkPolicy.cs +++ /dev/null @@ -1,28 +0,0 @@ -// Copyright (c) Microsoft. All rights reserved. - -using System; -using System.Threading; -using System.Threading.Tasks; -using Microsoft.Shared.Diagnostics; - -namespace Microsoft.Agents.AI.Hosting.Channels; - -/// -/// Permits links and deliveries when both channels declare the same -/// ; refuses otherwise. Channels without -/// the tag are treated as single-tier (matching any other untagged channel). -/// -public sealed class SameConfidentialityTierLinkPolicy : ILinkPolicy -{ - /// Shared singleton. - public static SameConfidentialityTierLinkPolicy Instance { get; } = new(); - - /// - public ValueTask EvaluateAsync(LinkPolicyContext context, CancellationToken cancellationToken) - { - Throw.IfNull(context); - var sourceTier = (context.Source as IConfidentialityTagged)?.ConfidentialityTier; - var destTier = (context.Destination as IConfidentialityTagged)?.ConfidentialityTier; - return new(string.Equals(sourceTier, destTier, StringComparison.Ordinal)); - } -} \ No newline at end of file diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting.Channels/LinkPolicyContext.cs b/dotnet/src/Microsoft.Agents.AI.Hosting.Channels/LinkPolicyContext.cs deleted file mode 100644 index ad6ebd8cebc..00000000000 --- a/dotnet/src/Microsoft.Agents.AI.Hosting.Channels/LinkPolicyContext.cs +++ /dev/null @@ -1,18 +0,0 @@ -// Copyright (c) Microsoft. All rights reserved. - -namespace Microsoft.Agents.AI.Hosting.Channels; - -/// -/// Context passed to . -/// -public sealed record LinkPolicyContext -{ - /// The originating channel. - public required Channel Source { get; init; } - - /// The candidate destination channel. - public required Channel Destination { get; init; } - - /// Whether the request is to share an isolation key or to deliver a response. - public required LinkPolicyOperation Operation { get; init; } -} \ No newline at end of file diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting.Channels/LinkPolicyOperation.cs b/dotnet/src/Microsoft.Agents.AI.Hosting.Channels/LinkPolicyOperation.cs deleted file mode 100644 index 130de4e21e6..00000000000 --- a/dotnet/src/Microsoft.Agents.AI.Hosting.Channels/LinkPolicyOperation.cs +++ /dev/null @@ -1,15 +0,0 @@ -// Copyright (c) Microsoft. All rights reserved. - -namespace Microsoft.Agents.AI.Hosting.Channels; - -/// -/// Operation being authorized by an . -/// -public enum LinkPolicyOperation -{ - /// Whether two channels may share an isolation key (asked by ). - Link, - - /// Whether one channel may deliver a response targeting an identity belonging to another channel. - Deliver, -} \ No newline at end of file diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting.Channels/OneTimeCodeIdentityLinker.cs b/dotnet/src/Microsoft.Agents.AI.Hosting.Channels/OneTimeCodeIdentityLinker.cs deleted file mode 100644 index 46b86f5602f..00000000000 --- a/dotnet/src/Microsoft.Agents.AI.Hosting.Channels/OneTimeCodeIdentityLinker.cs +++ /dev/null @@ -1,153 +0,0 @@ -// Copyright (c) Microsoft. All rights reserved. - -using System; -using System.Collections.Generic; -using System.Security.Cryptography; -using System.Text; -using System.Threading; -using System.Threading.Tasks; -using Microsoft.Shared.Diagnostics; - -namespace Microsoft.Agents.AI.Hosting.Channels; - -/// -/// Zero-dependency : emits a short random code -/// the user must present on a peer channel; consumes the code and binds -/// the peer-channel identity to the originating identity's isolation key. Backed entirely by -/// ; no callback routes are required so is a no-op. -/// -/// -/// Use this for low-ceremony cross-channel linking (Telegram + Responses, Telegram + Discord, ...) -/// where one channel asks the user to type a code into the other. For Entra / OAuth-style flows -/// substitute the Entra linker from Microsoft.Agents.AI.Hosting.Channels.EntraId. -/// -public sealed class OneTimeCodeIdentityLinker : IIdentityLinker -{ - private readonly IHostStateStore _stateStore; - - /// Initializes a new instance. - public OneTimeCodeIdentityLinker(IHostStateStore stateStore) - { - this._stateStore = Throw.IfNull(stateStore); - } - - /// - public string Name => "one-time-code"; - - /// Lifetime of an unconsumed code. Default 10 minutes. - public TimeSpan CodeLifetime { get; init; } = TimeSpan.FromMinutes(10); - - /// - public ChannelContribution Contribute(IChannelContext context) => new(); - - /// - public async ValueTask BeginAsync( - ChannelIdentity identity, - string? requestedIsolationKey, - CancellationToken cancellationToken) - { - Throw.IfNull(identity); - - var code = GenerateCode(); - var payload = new Dictionary(StringComparer.Ordinal) - { - ["channel"] = identity.Channel, - ["nativeId"] = identity.NativeId, - }; - - var grant = new LinkGrant( - Code: code, - IssuedByLinker: this.Name, - RequestedIsolationKey: requestedIsolationKey, - ExpiresAt: DateTimeOffset.UtcNow.Add(this.CodeLifetime), - Payload: payload); - - await this._stateStore.SaveLinkGrantAsync(grant, cancellationToken).ConfigureAwait(false); - - return new LinkChallenge( - ChallengeId: code, - Kind: "code", - Code: code, - UserPrompt: $"Send '/link {code}' on the other channel to merge the two identities. Code expires in {(int)this.CodeLifetime.TotalMinutes} minutes."); - } - - /// - public async ValueTask CompleteAsync( - string challengeId, - IReadOnlyDictionary proof, - CancellationToken cancellationToken) - { - Throw.IfNullOrEmpty(challengeId); - Throw.IfNull(proof); - - if (!proof.TryGetValue("identity", out var identityObj) || identityObj is not ChannelIdentity completingIdentity) - { - throw new ArgumentException("Proof must include 'identity' of type ChannelIdentity.", nameof(proof)); - } - - var grant = await this._stateStore.ConsumeLinkGrantAsync(challengeId, cancellationToken).ConfigureAwait(false); - if (grant is null) - { - throw new InvalidOperationException($"Link code '{challengeId}' is invalid, expired, or already consumed."); - } - - if (!grant.Payload.TryGetValue("channel", out var sourceChannel) || - !grant.Payload.TryGetValue("nativeId", out var sourceNativeId) || - sourceChannel is not string sourceChannelStr || - sourceNativeId is not string sourceNativeIdStr) - { - throw new InvalidOperationException($"Link code '{challengeId}' has a malformed payload."); - } - - var sourceIdentity = new ChannelIdentity(sourceChannelStr, sourceNativeIdStr); - - var isolationKey = grant.RequestedIsolationKey - ?? await this._stateStore.GetIsolationKeyAsync(sourceIdentity, cancellationToken).ConfigureAwait(false) - ?? await this._stateStore.GetIsolationKeyAsync(completingIdentity, cancellationToken).ConfigureAwait(false) - ?? $"{sourceIdentity.Channel}:{sourceIdentity.NativeId}"; - - await this._stateStore.SaveLinkAsync(sourceIdentity, isolationKey, verifiedClaims: null, cancellationToken).ConfigureAwait(false); - await this._stateStore.SaveLinkAsync(completingIdentity, isolationKey, verifiedClaims: null, cancellationToken).ConfigureAwait(false); - - return new PrincipalIdentity(isolationKey, completingIdentity, new Dictionary(StringComparer.Ordinal)); - } - - /// - public async ValueTask IsLinkedAsync( - ChannelIdentity identity, - IReadOnlyDictionary? verifiedClaims, - CancellationToken cancellationToken) - { - Throw.IfNull(identity); - var existing = await this._stateStore.GetIsolationKeyAsync(identity, cancellationToken).ConfigureAwait(false); - if (existing is not null) { return existing; } - - if (verifiedClaims is not null) - { - foreach (var (claim, value) in verifiedClaims) - { - var match = await this._stateStore.LookupByVerifiedClaimAsync(claim, value, cancellationToken).ConfigureAwait(false); - if (match is not null) - { - await this._stateStore.SaveLinkAsync(identity, match, verifiedClaims, cancellationToken).ConfigureAwait(false); - return match; - } - } - } - - return null; - } - - private static string GenerateCode() - { - Span bytes = stackalloc byte[5]; - RandomNumberGenerator.Fill(bytes); - var sb = new StringBuilder(8); - const string Alphabet = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789"; - for (var i = 0; i < bytes.Length; i++) - { - sb.Append(Alphabet[bytes[i] % Alphabet.Length]); - } - return sb.ToString(); - } -} \ No newline at end of file diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting.Channels/PrincipalIdentity.cs b/dotnet/src/Microsoft.Agents.AI.Hosting.Channels/PrincipalIdentity.cs deleted file mode 100644 index 05e9c5920e5..00000000000 --- a/dotnet/src/Microsoft.Agents.AI.Hosting.Channels/PrincipalIdentity.cs +++ /dev/null @@ -1,16 +0,0 @@ -// Copyright (c) Microsoft. All rights reserved. - -using System.Collections.Generic; - -namespace Microsoft.Agents.AI.Hosting.Channels; - -/// -/// Result of a successful call. -/// -/// The resolved isolation key. -/// The channel-native identity that completed the link. -/// Claims verified by the linker (e.g. AAD oid, email). -public sealed record PrincipalIdentity( - string IsolationKey, - ChannelIdentity Identity, - IReadOnlyDictionary VerifiedClaims); \ No newline at end of file diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting.Channels/ResponseTarget.cs b/dotnet/src/Microsoft.Agents.AI.Hosting.Channels/ResponseTarget.cs deleted file mode 100644 index 3ae471dc348..00000000000 --- a/dotnet/src/Microsoft.Agents.AI.Hosting.Channels/ResponseTarget.cs +++ /dev/null @@ -1,78 +0,0 @@ -// Copyright (c) Microsoft. All rights reserved. - -using System; -using System.Collections.Generic; - -namespace Microsoft.Agents.AI.Hosting.Channels; - -/// -/// Directs where the host delivers an agent response. Independent of . -/// Use the static factories (, , , -/// ) and the , , -/// , singletons. -/// -public abstract record ResponseTarget -{ - private ResponseTarget() { } - - /// Reply on the originating channel only. The default. - public static ResponseTarget Originating { get; } = new OriginatingResponseTarget(); - - /// Reply on the channel the user was last seen on, per . - public static ResponseTarget Active { get; } = new ActiveResponseTarget(); - - /// Fan out to every linked identity on every channel. - public static ResponseTarget AllLinked { get; } = new AllLinkedResponseTarget(); - - /// Suppress the response entirely. The originating wire returns a . - public static ResponseTarget None { get; } = new NoneResponseTarget(); - - /// Deliver to every linked identity on the named channel. - public static ResponseTarget Channel(string channelName, bool echoInput = false) - { - if (channelName is null) { throw new ArgumentNullException(nameof(channelName)); } - return new ChannelResponseTarget(channelName, echoInput); - } - - /// Deliver to every linked identity on each of the named channels. - public static ResponseTarget Channels(IReadOnlyList channelNames, bool echoInput = false) - { - if (channelNames is null) { throw new ArgumentNullException(nameof(channelNames)); } - return new ChannelsResponseTarget(channelNames, echoInput); - } - - /// Deliver to a single specific channel-native identity. - public static ResponseTarget Identity(ChannelIdentity identity, bool echoInput = false) - { - if (identity is null) { throw new ArgumentNullException(nameof(identity)); } - return new IdentitiesResponseTarget([identity], echoInput); - } - - /// Deliver to each of the specific channel-native identities. - public static ResponseTarget Identities(IReadOnlyList identities, bool echoInput = false) - { - if (identities is null) { throw new ArgumentNullException(nameof(identities)); } - return new IdentitiesResponseTarget(identities, echoInput); - } - - /// Reply on the originating channel only. - public sealed record OriginatingResponseTarget : ResponseTarget; - - /// Reply on the channel the user was last seen on. - public sealed record ActiveResponseTarget : ResponseTarget; - - /// Fan out to every linked identity on every channel. - public sealed record AllLinkedResponseTarget : ResponseTarget; - - /// Suppress the response entirely. - public sealed record NoneResponseTarget : ResponseTarget; - - /// Deliver to a single channel. - public sealed record ChannelResponseTarget(string ChannelName, bool EchoInput) : ResponseTarget; - - /// Deliver to multiple channels. - public sealed record ChannelsResponseTarget(IReadOnlyList ChannelNames, bool EchoInput) : ResponseTarget; - - /// Deliver to specific identities. - public sealed record IdentitiesResponseTarget(IReadOnlyList Targets, bool EchoInput) : ResponseTarget; -} \ No newline at end of file diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting.Channels/RetryPolicy.cs b/dotnet/src/Microsoft.Agents.AI.Hosting.Channels/RetryPolicy.cs deleted file mode 100644 index 9c1b9da90d4..00000000000 --- a/dotnet/src/Microsoft.Agents.AI.Hosting.Channels/RetryPolicy.cs +++ /dev/null @@ -1,27 +0,0 @@ -// Copyright (c) Microsoft. All rights reserved. - -using System; - -namespace Microsoft.Agents.AI.Hosting.Channels; - -/// -/// Retry parameters for a scheduled durable task. Mirrors the Python runner defaults so behaviour -/// is consistent across language SDKs. -/// -public sealed record RetryPolicy -{ - /// Total attempt count (initial attempt + retries). Default 5. - public int MaxAttempts { get; init; } = 5; - - /// Delay before the first retry. Default 1 second. - public TimeSpan InitialBackoff { get; init; } = TimeSpan.FromSeconds(1); - - /// Multiplier applied to the previous backoff. Default 2.0. - public double BackoffMultiplier { get; init; } = 2.0; - - /// Cap on a single backoff delay. Default 60 seconds. - public TimeSpan MaxBackoff { get; init; } = TimeSpan.FromSeconds(60); - - /// The default retry policy used when callers omit one. - public static RetryPolicy Default { get; } = new(); -} \ No newline at end of file diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting.Channels/Runners/AIAgentRunner.cs b/dotnet/src/Microsoft.Agents.AI.Hosting.Channels/Runners/AIAgentRunner.cs index b51bbc9f2b3..a6f7b77a410 100644 --- a/dotnet/src/Microsoft.Agents.AI.Hosting.Channels/Runners/AIAgentRunner.cs +++ b/dotnet/src/Microsoft.Agents.AI.Hosting.Channels/Runners/AIAgentRunner.cs @@ -1,6 +1,7 @@ // Copyright (c) Microsoft. All rights reserved. using System; +using System.Collections.Concurrent; using System.Collections.Generic; using System.Linq; using System.Runtime.CompilerServices; @@ -12,18 +13,27 @@ namespace Microsoft.Agents.AI.Hosting.Channels; /// -/// Default for targets. Coerces the -/// into the agent's RunAsync message shape and wraps +/// Default for targets. Coerces +/// into the agent's RunAsync message shape, resolves an +/// from the request's isolation key (cached per active session alias), and wraps /// the response in . /// +/// +/// Session continuity is the ADR-0027 core feature: identical +/// values resolve to the same cached ; +/// rotates the alias so the next run starts a fresh session. Cache is in-process for this slice. +/// public sealed class AIAgentRunner : IHostedTargetRunner { private readonly AIAgent _agent; + private readonly IHostStateStore _stateStore; + private readonly ConcurrentDictionary> _sessions = new(StringComparer.Ordinal); /// Initializes a new instance. - public AIAgentRunner(AIAgent agent) + public AIAgentRunner(AIAgent agent, IHostStateStore stateStore) { this._agent = Throw.IfNull(agent); + this._stateStore = Throw.IfNull(stateStore); } /// @@ -31,7 +41,8 @@ public async ValueTask RunAsync(ChannelRequest request, Cancell { Throw.IfNull(request); var messages = CoerceToMessages(request.Input); - var response = await this._agent.RunAsync(messages, session: null, options: null, cancellationToken).ConfigureAwait(false); + var session = await this.ResolveSessionAsync(request, cancellationToken).ConfigureAwait(false); + var response = await this._agent.RunAsync(messages, session, options: null, cancellationToken).ConfigureAwait(false); return new HostedRunResult { Result = response, @@ -46,8 +57,10 @@ public async IAsyncEnumerable StreamAsync( { Throw.IfNull(request); var messages = CoerceToMessages(request.Input); + var session = await this.ResolveSessionAsync(request, cancellationToken).ConfigureAwait(false); + AgentResponseUpdate? final = null; - await foreach (var update in this._agent.RunStreamingAsync(messages, session: null, options: null, cancellationToken).ConfigureAwait(false)) + await foreach (var update in this._agent.RunStreamingAsync(messages, session, options: null, cancellationToken).ConfigureAwait(false)) { final = update; yield return new HostedStreamUpdate(update); @@ -59,6 +72,25 @@ public async IAsyncEnumerable StreamAsync( yield return new HostedStreamCompleted(aggregate); } + private async ValueTask ResolveSessionAsync(ChannelRequest request, CancellationToken cancellationToken) + { + if (request.SessionMode == SessionMode.Disabled) + { + return null; + } + + var isolationKey = request.Session?.IsolationKey; + if (string.IsNullOrEmpty(isolationKey)) + { + return null; + } + + var alias = await this._stateStore.GetActiveSessionAliasAsync(isolationKey, cancellationToken).ConfigureAwait(false) + ?? isolationKey; + + return await this._sessions.GetOrAdd(alias, _ => this._agent.CreateSessionAsync(CancellationToken.None).AsTask()).ConfigureAwait(false); + } + private static ChatMessage[] CoerceToMessages(object input) { return input switch @@ -72,4 +104,4 @@ private static ChatMessage[] CoerceToMessages(object input) nameof(input)), }; } -} \ No newline at end of file +} diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting.Channels/Runners/WorkflowRunner.cs b/dotnet/src/Microsoft.Agents.AI.Hosting.Channels/Runners/WorkflowRunner.cs index 0ed04702c6e..f4c3cf1a1e9 100644 --- a/dotnet/src/Microsoft.Agents.AI.Hosting.Channels/Runners/WorkflowRunner.cs +++ b/dotnet/src/Microsoft.Agents.AI.Hosting.Channels/Runners/WorkflowRunner.cs @@ -1,9 +1,7 @@ // Copyright (c) Microsoft. All rights reserved. using System; -using System.Collections.Concurrent; using System.Collections.Generic; -using System.Collections.Immutable; using System.Runtime.CompilerServices; using System.Threading; using System.Threading.Tasks; @@ -13,112 +11,36 @@ namespace Microsoft.Agents.AI.Hosting.Channels; /// -/// Default for targets. Drives execution -/// via and projects pause / completion / failure into +/// Default for targets. Drives execution via +/// and projects pause / completion / failure into /// . /// /// -/// Resume tokens map to in-memory instances for this draft. They -/// survive only the lifetime of the process; durable replay across restarts requires an external -/// + checkpoint storage and lands in a follow-up commit. +/// v1 runs forward and surfaces on a +/// . Resume is caller-driven via a channel-supplied checkpoint reference on +/// ("workflow.checkpoint_id"); the host owns no continuation +/// store in v1. /// public sealed class WorkflowRunner : IHostedTargetRunner { - /// Attribute key carried on to resume a paused workflow. - public const string ResumeTokenAttribute = "workflow.resume_token"; - - /// Attribute key carried on for direct checkpoint resume. + /// Attribute key for caller-supplied checkpoint resume. public const string CheckpointIdAttribute = "workflow.checkpoint_id"; - private readonly Workflow _workflow; - private readonly IHostStateStore _stateStore; - private readonly ConcurrentDictionary _resumeEntries = new(StringComparer.Ordinal); - /// Initializes a new instance. - public WorkflowRunner(Workflow workflow, IHostStateStore stateStore) + public WorkflowRunner(Workflow workflow) { - this._workflow = Throw.IfNull(workflow); - this._stateStore = Throw.IfNull(stateStore); + this.Workflow = Throw.IfNull(workflow); } /// The wrapped workflow. - public Workflow Workflow => this._workflow; + public Workflow Workflow { get; } /// public async ValueTask RunAsync(ChannelRequest request, CancellationToken cancellationToken) { Throw.IfNull(request); + var run = await InProcessExecution.RunStreamingAsync(this.Workflow, request.Input, request.Session?.Key, cancellationToken).ConfigureAwait(false); - if (request.Attributes.TryGetValue(ResumeTokenAttribute, out var rawToken) && rawToken is string resumeToken) - { - return await this.ResumeAsync(resumeToken, request, cancellationToken).ConfigureAwait(false); - } - - var run = await InProcessExecution.RunStreamingAsync(this._workflow, request.Input, request.Session?.Key, cancellationToken).ConfigureAwait(false); - return await this.DriveAsync(run, request, cancellationToken).ConfigureAwait(false); - } - - /// - public async IAsyncEnumerable StreamAsync( - ChannelRequest request, - [EnumeratorCancellation] CancellationToken cancellationToken) - { - Throw.IfNull(request); - - if (request.Attributes.TryGetValue(ResumeTokenAttribute, out var rawToken) && rawToken is string resumeToken) - { - await foreach (var item in this.StreamResumeAsync(resumeToken, request, cancellationToken).ConfigureAwait(false)) - { - yield return item; - } - yield break; - } - - var run = await InProcessExecution.RunStreamingAsync(this._workflow, request.Input, request.Session?.Key, cancellationToken).ConfigureAwait(false); - await foreach (var item in this.WatchAsync(run, request, cancellationToken).ConfigureAwait(false)) - { - yield return item; - } - } - - private async ValueTask ResumeAsync(string resumeToken, ChannelRequest request, CancellationToken cancellationToken) - { - if (!this._resumeEntries.TryRemove(resumeToken, out var entry)) - { - return BuildResult( - new WorkflowRunResult { Status = WorkflowRunStatus.Failed, Error = $"Resume token '{resumeToken}' is unknown or already consumed.", SessionId = request.Session?.Key }, - request.Session); - } - - await entry.Run.SendResponseAsync(entry.PendingRequest.CreateResponse(request.Input)).ConfigureAwait(false); - await this._stateStore.DeleteContinuationAsync(resumeToken, cancellationToken).ConfigureAwait(false); - return await this.DriveAsync(entry.Run, request, cancellationToken).ConfigureAwait(false); - } - - private async IAsyncEnumerable StreamResumeAsync( - string resumeToken, - ChannelRequest request, - [EnumeratorCancellation] CancellationToken cancellationToken) - { - if (!this._resumeEntries.TryRemove(resumeToken, out var entry)) - { - yield return new HostedStreamCompleted(BuildResult( - new WorkflowRunResult { Status = WorkflowRunStatus.Failed, Error = $"Resume token '{resumeToken}' is unknown or already consumed.", SessionId = request.Session?.Key }, - request.Session)); - yield break; - } - - await entry.Run.SendResponseAsync(entry.PendingRequest.CreateResponse(request.Input)).ConfigureAwait(false); - await this._stateStore.DeleteContinuationAsync(resumeToken, cancellationToken).ConfigureAwait(false); - - await foreach (var item in this.WatchAsync(entry.Run, request, cancellationToken).ConfigureAwait(false)) - { - yield return item; - } - } - - private async ValueTask DriveAsync(StreamingRun run, ChannelRequest request, CancellationToken cancellationToken) - { var outputs = new List(); ExternalRequest? pending = null; @@ -133,47 +55,25 @@ private async ValueTask DriveAsync(StreamingRun run, ChannelReq outputs.Add(woe.Data); break; case WorkflowErrorEvent err: - return BuildResult( - new WorkflowRunResult { Status = WorkflowRunStatus.Failed, Error = err.Data?.ToString(), Outputs = outputs, SessionId = run.SessionId }, - request.Session); + return Build(new WorkflowRunResult { Status = WorkflowRunStatus.Failed, Error = err.Data?.ToString(), Outputs = outputs, SessionId = run.SessionId }, request.Session); } } - if (pending is not null) - { - var resumeToken = Guid.NewGuid().ToString("N"); - this._resumeEntries[resumeToken] = new ResumeEntry(run, pending); - await this._stateStore.SaveContinuationAsync( - new ContinuationToken - { - Token = resumeToken, - Status = ContinuationStatus.Queued, - IsolationKey = request.Session?.IsolationKey, - CreatedAt = DateTimeOffset.UtcNow, - }, - cancellationToken).ConfigureAwait(false); - - var session = (request.Session ?? new ChannelSession()) with - { - Key = run.SessionId, - Attributes = MergeAttribute(request.Session?.Attributes, ResumeTokenAttribute, resumeToken), - }; - - return BuildResult( - new WorkflowRunResult { Status = WorkflowRunStatus.AwaitingInput, PendingRequest = pending, Outputs = outputs, SessionId = run.SessionId }, - session); - } - - return BuildResult( - new WorkflowRunResult { Status = WorkflowRunStatus.Completed, Outputs = outputs, SessionId = run.SessionId }, - (request.Session ?? new ChannelSession()) with { Key = run.SessionId }); + var session = (request.Session ?? new ChannelSession()) with { Key = run.SessionId }; + var result = pending is not null + ? new WorkflowRunResult { Status = WorkflowRunStatus.AwaitingInput, PendingRequest = pending, Outputs = outputs, SessionId = run.SessionId } + : new WorkflowRunResult { Status = WorkflowRunStatus.Completed, Outputs = outputs, SessionId = run.SessionId }; + return Build(result, session); } - private async IAsyncEnumerable WatchAsync( - StreamingRun run, + /// + public async IAsyncEnumerable StreamAsync( ChannelRequest request, [EnumeratorCancellation] CancellationToken cancellationToken) { + Throw.IfNull(request); + var run = await InProcessExecution.RunStreamingAsync(this.Workflow, request.Input, request.Session?.Key, cancellationToken).ConfigureAwait(false); + var outputs = new List(); ExternalRequest? pending = null; @@ -191,53 +91,13 @@ private async IAsyncEnumerable WatchAsync( } } - ChannelSession? session = request.Session; - WorkflowRunResult final; - if (pending is not null) - { - var resumeToken = Guid.NewGuid().ToString("N"); - this._resumeEntries[resumeToken] = new ResumeEntry(run, pending); - await this._stateStore.SaveContinuationAsync( - new ContinuationToken - { - Token = resumeToken, - Status = ContinuationStatus.Queued, - IsolationKey = request.Session?.IsolationKey, - CreatedAt = DateTimeOffset.UtcNow, - }, - cancellationToken).ConfigureAwait(false); - session = (session ?? new ChannelSession()) with - { - Key = run.SessionId, - Attributes = MergeAttribute(session?.Attributes, ResumeTokenAttribute, resumeToken), - }; - final = new WorkflowRunResult { Status = WorkflowRunStatus.AwaitingInput, PendingRequest = pending, Outputs = outputs, SessionId = run.SessionId }; - } - else - { - session = (session ?? new ChannelSession()) with { Key = run.SessionId }; - final = new WorkflowRunResult { Status = WorkflowRunStatus.Completed, Outputs = outputs, SessionId = run.SessionId }; - } - - yield return new HostedStreamCompleted(BuildResult(final, session)); + var session = (request.Session ?? new ChannelSession()) with { Key = run.SessionId }; + var result = pending is not null + ? new WorkflowRunResult { Status = WorkflowRunStatus.AwaitingInput, PendingRequest = pending, Outputs = outputs, SessionId = run.SessionId } + : new WorkflowRunResult { Status = WorkflowRunStatus.Completed, Outputs = outputs, SessionId = run.SessionId }; + yield return new HostedStreamCompleted(Build(result, session)); } - private static HostedRunResult BuildResult(WorkflowRunResult result, ChannelSession? session) => + private static HostedRunResult Build(WorkflowRunResult result, ChannelSession? session) => new() { Result = result, Session = session }; - - private static ImmutableDictionary MergeAttribute( - IReadOnlyDictionary? existing, - string key, - object? value) - { - var builder = ImmutableDictionary.CreateBuilder(StringComparer.Ordinal); - if (existing is not null) - { - foreach (var (k, v) in existing) { builder[k] = v; } - } - builder[key] = value; - return builder.ToImmutable(); - } - - private sealed record ResumeEntry(StreamingRun Run, ExternalRequest PendingRequest); -} \ No newline at end of file +} diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting.Channels/SessionMode.cs b/dotnet/src/Microsoft.Agents.AI.Hosting.Channels/SessionMode.cs index 4f5148f4b74..5fd8485175a 100644 --- a/dotnet/src/Microsoft.Agents.AI.Hosting.Channels/SessionMode.cs +++ b/dotnet/src/Microsoft.Agents.AI.Hosting.Channels/SessionMode.cs @@ -15,4 +15,4 @@ public enum SessionMode /// The host never resolves a session; the target runs ephemerally even if hints are present. Disabled, -} \ No newline at end of file +} diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting.Channels/TaskHandle.cs b/dotnet/src/Microsoft.Agents.AI.Hosting.Channels/TaskHandle.cs deleted file mode 100644 index 7e840e798e0..00000000000 --- a/dotnet/src/Microsoft.Agents.AI.Hosting.Channels/TaskHandle.cs +++ /dev/null @@ -1,10 +0,0 @@ -// Copyright (c) Microsoft. All rights reserved. - -namespace Microsoft.Agents.AI.Hosting.Channels; - -/// -/// Opaque handle to a task scheduled on . -/// -/// The runner-assigned task identifier. -/// The handler name the task was scheduled under. -public sealed record TaskHandle(string TaskId, string Name); \ No newline at end of file diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting.Channels/TaskInvocationContext.cs b/dotnet/src/Microsoft.Agents.AI.Hosting.Channels/TaskInvocationContext.cs deleted file mode 100644 index 1f212df0074..00000000000 --- a/dotnet/src/Microsoft.Agents.AI.Hosting.Channels/TaskInvocationContext.cs +++ /dev/null @@ -1,21 +0,0 @@ -// Copyright (c) Microsoft. All rights reserved. - -using System.Collections.Generic; - -namespace Microsoft.Agents.AI.Hosting.Channels; - -/// -/// Context handed to a registered handler per invocation. -/// -/// The handler name. -/// The scheduled payload. For object-mode runners this is the original object reference. -/// 1-based attempt counter; 1 on the initial call, >1 on retries. -/// -/// Mutable per-task state owned by the runner. Handlers may write cursors (e.g. echo_done) -/// here so a subsequent retry can detect partial progress and skip already-completed sub-steps. -/// -public sealed record TaskInvocationContext( - string Name, - object Payload, - int Attempt, - IDictionary State); \ No newline at end of file diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting.Channels/WorkflowRunResult.cs b/dotnet/src/Microsoft.Agents.AI.Hosting.Channels/WorkflowRunResult.cs index 67bab9f2c3a..1373e548112 100644 --- a/dotnet/src/Microsoft.Agents.AI.Hosting.Channels/WorkflowRunResult.cs +++ b/dotnet/src/Microsoft.Agents.AI.Hosting.Channels/WorkflowRunResult.cs @@ -48,4 +48,4 @@ public enum WorkflowRunStatus /// The workflow run failed. Failed, -} \ No newline at end of file +} diff --git a/dotnet/tests/Microsoft.Agents.AI.Hosting.Channels.UnitTests/AllowlistTests.cs b/dotnet/tests/Microsoft.Agents.AI.Hosting.Channels.UnitTests/AllowlistTests.cs deleted file mode 100644 index 245f1b7d2fd..00000000000 --- a/dotnet/tests/Microsoft.Agents.AI.Hosting.Channels.UnitTests/AllowlistTests.cs +++ /dev/null @@ -1,82 +0,0 @@ -// Copyright (c) Microsoft. All rights reserved. - -using System.Collections.Generic; -using System.Threading; -using System.Threading.Tasks; -using Microsoft.Agents.AI.Hosting.Channels; - -namespace Microsoft.Agents.AI.Hosting.Channels.UnitTests; - -public class AllowlistTests -{ - private static AuthorizationContext PreLink(string channel, string nativeId, IReadOnlyDictionary? claims = null) => new() - { - Identity = new ChannelIdentity(channel, nativeId), - Phase = AuthorizationPhase.PreLink, - VerifiedClaims = claims ?? new Dictionary(), - }; - - [Fact] - public async Task NativeIdAllowlist_ChannelMismatch_Abstains() - { - // Arrange - var allow = new NativeIdAllowlist("telegram", ["42"]); - - // Act - var decision = await allow.EvaluateAsync(PreLink("invocations", "42"), CancellationToken.None); - - // Assert - Assert.Equal(AllowlistDecision.Abstain, decision); - } - - [Fact] - public async Task NativeIdAllowlist_HitsAndMisses() - { - // Arrange - var allow = new NativeIdAllowlist("telegram", ["1", "2"]); - - // Act - var hit = await allow.EvaluateAsync(PreLink("telegram", "2"), CancellationToken.None); - var miss = await allow.EvaluateAsync(PreLink("telegram", "99"), CancellationToken.None); - - // Assert - Assert.Equal(AllowlistDecision.Allow, hit); - Assert.Equal(AllowlistDecision.Deny, miss); - } - - [Fact] - public async Task LinkedClaimAllowlist_AbstainsPreLink_AllowsOnGlobMatch() - { - // Arrange - var allow = new LinkedClaimAllowlist("email", "*@contoso.com"); - - // Act - var pre = await allow.EvaluateAsync(PreLink("telegram", "42"), CancellationToken.None); - var hit = await allow.EvaluateAsync(PreLink("telegram", "42", new Dictionary { ["email"] = "alice@contoso.com" }), CancellationToken.None); - var miss = await allow.EvaluateAsync(PreLink("telegram", "42", new Dictionary { ["email"] = "mallory@example.com" }), CancellationToken.None); - - // Assert - Assert.Equal(AllowlistDecision.Abstain, pre); - Assert.Equal(AllowlistDecision.Allow, hit); - Assert.Equal(AllowlistDecision.Deny, miss); - } - - [Fact] - public async Task AnyOf_ShortCircuitsOnFirstAllow_DenyWinsOverAbstain() - { - // Arrange - var nativeMatch = new NativeIdAllowlist("telegram", ["42"]); - var emailReject = new LinkedClaimAllowlist("email", "*@contoso.com"); - var allow = new AnyOfIdentityAllowlist(nativeMatch, emailReject); - - // Act - var nativeWin = await allow.EvaluateAsync(PreLink("telegram", "42"), CancellationToken.None); - var emailMiss = await allow.EvaluateAsync(PreLink("telegram", "99", new Dictionary { ["email"] = "mallory@example.com" }), CancellationToken.None); - var allAbstain = await allow.EvaluateAsync(PreLink("invocations", "99"), CancellationToken.None); - - // Assert - Assert.Equal(AllowlistDecision.Allow, nativeWin); - Assert.Equal(AllowlistDecision.Deny, emailMiss); - Assert.Equal(AllowlistDecision.Abstain, allAbstain); - } -} \ No newline at end of file diff --git a/dotnet/tests/Microsoft.Agents.AI.Hosting.Channels.UnitTests/ChannelRequestTests.cs b/dotnet/tests/Microsoft.Agents.AI.Hosting.Channels.UnitTests/ChannelRequestTests.cs new file mode 100644 index 00000000000..179bf780979 --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.AI.Hosting.Channels.UnitTests/ChannelRequestTests.cs @@ -0,0 +1,33 @@ +// Copyright (c) Microsoft. All rights reserved. + +namespace Microsoft.Agents.AI.Hosting.Channels.UnitTests; + +public class ChannelRequestTests +{ + [Fact] + public void ChannelRequest_DefaultsAreMinimal() + { + // Arrange / Act + var request = new ChannelRequest { Channel = "responses", Operation = "message.create", Input = "hello" }; + + // Assert + Assert.Equal(SessionMode.Auto, request.SessionMode); + Assert.False(request.Stream); + Assert.Null(request.Session); + Assert.Null(request.Identity); + Assert.Empty(request.Attributes); + Assert.Empty(request.Metadata); + } + + [Fact] + public void ChannelSession_AllFieldsNullable() + { + // Arrange / Act + var session = new ChannelSession(); + + // Assert + Assert.Null(session.Key); + Assert.Null(session.ConversationId); + Assert.Null(session.IsolationKey); + } +} diff --git a/dotnet/tests/Microsoft.Agents.AI.Hosting.Channels.UnitTests/HostCompositionTests.cs b/dotnet/tests/Microsoft.Agents.AI.Hosting.Channels.UnitTests/HostCompositionTests.cs new file mode 100644 index 00000000000..70f0e1bb957 --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.AI.Hosting.Channels.UnitTests/HostCompositionTests.cs @@ -0,0 +1,62 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Agents.AI.Workflows; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; + +namespace Microsoft.Agents.AI.Hosting.Channels.UnitTests; + +public class HostCompositionTests +{ + [Fact] + public void AddAgentFrameworkHost_WithChannel_ComposesHost() + { + // Arrange + var builder = Host.CreateApplicationBuilder(); + var echo = new EchoExecutor(); + var workflow = new WorkflowBuilder(echo).WithOutputFrom(echo).Build(); + + // Act + builder.AddAgentFrameworkHost(workflow).AddChannel(new FakeChannel()); + using var app = builder.Build(); + var host = app.Services.GetRequiredService(); + + // Assert + Assert.Single(host.Channels); + Assert.Equal("fake", host.Channels[0].Name); + Assert.IsType(host.TargetRunner); + } + + [Fact] + public async Task Host_RunAsync_DrivesWorkflowTargetAsync() + { + // Arrange + var builder = Host.CreateApplicationBuilder(); + var echo = new EchoExecutor(); + var workflow = new WorkflowBuilder(echo).WithOutputFrom(echo).Build(); + builder.AddAgentFrameworkHost(workflow).AddChannel(new FakeChannel()); + using var app = builder.Build(); + var host = app.Services.GetRequiredService(); + + // Act + var result = await host.RunAsync(new ChannelRequest { Channel = "fake", Operation = "message.create", Input = "hi" }, CancellationToken.None); + + // Assert + var typed = Assert.IsType>(result); + Assert.Equal(WorkflowRunStatus.Completed, typed.Result.Status); + } + + private sealed class FakeChannel : Channel + { + public override string Name => "fake"; + public override ChannelContribution Contribute(IChannelContext context) => new(); + } + + private sealed class EchoExecutor() : Executor("EchoExecutor") + { + public override ValueTask HandleAsync(string message, IWorkflowContext context, CancellationToken cancellationToken = default) => + ValueTask.FromResult($"echo: {message}"); + } +} diff --git a/dotnet/tests/Microsoft.Agents.AI.Hosting.Channels.UnitTests/HostStateStoreTests.cs b/dotnet/tests/Microsoft.Agents.AI.Hosting.Channels.UnitTests/HostStateStoreTests.cs new file mode 100644 index 00000000000..7a94f708eb4 --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.AI.Hosting.Channels.UnitTests/HostStateStoreTests.cs @@ -0,0 +1,68 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.IO; +using System.Threading; +using System.Threading.Tasks; + +namespace Microsoft.Agents.AI.Hosting.Channels.UnitTests; + +public class HostStateStoreTests +{ + [Fact] + public async Task InMemory_GetActiveAlias_StableUntilRotatedAsync() + { + // Arrange + var store = new InMemoryHostStateStore(); + + // Act + var first = await store.GetActiveSessionAliasAsync("user:alice", CancellationToken.None); + var second = await store.GetActiveSessionAliasAsync("user:alice", CancellationToken.None); + await store.RotateSessionAliasAsync("user:alice", CancellationToken.None); + var rotated = await store.GetActiveSessionAliasAsync("user:alice", CancellationToken.None); + + // Assert + Assert.Equal(first, second); + Assert.NotEqual(first, rotated); + } + + [Fact] + public async Task InMemory_CheckpointLocation_IsDeterministicPerKeyAsync() + { + // Arrange + var store = new InMemoryHostStateStore(); + + // Act + var a1 = await store.GetCheckpointLocationAsync("k1", CancellationToken.None); + var a2 = await store.GetCheckpointLocationAsync("k1", CancellationToken.None); + var b = await store.GetCheckpointLocationAsync("k2", CancellationToken.None); + + // Assert + Assert.Equal(a1, a2); + Assert.NotEqual(a1, b); + } + + [Fact] + public async Task File_Alias_PersistsAcrossInstancesAsync() + { + // Arrange + var root = Path.Combine(Path.GetTempPath(), "afhost-test-" + Guid.NewGuid().ToString("N")); + try + { + var store1 = new FileHostStateStore(new HostStatePathOptions { Root = root }); + await store1.RotateSessionAliasAsync("user:bob", CancellationToken.None); + var alias1 = await store1.GetActiveSessionAliasAsync("user:bob", CancellationToken.None); + + // Act: a fresh instance over the same directory + var store2 = new FileHostStateStore(new HostStatePathOptions { Root = root }); + var alias2 = await store2.GetActiveSessionAliasAsync("user:bob", CancellationToken.None); + + // Assert + Assert.Equal(alias1, alias2); + } + finally + { + if (Directory.Exists(root)) { Directory.Delete(root, recursive: true); } + } + } +} diff --git a/dotnet/tests/Microsoft.Agents.AI.Hosting.Channels.UnitTests/InMemoryHostStateStoreTests.cs b/dotnet/tests/Microsoft.Agents.AI.Hosting.Channels.UnitTests/InMemoryHostStateStoreTests.cs deleted file mode 100644 index 3f0e622830a..00000000000 --- a/dotnet/tests/Microsoft.Agents.AI.Hosting.Channels.UnitTests/InMemoryHostStateStoreTests.cs +++ /dev/null @@ -1,98 +0,0 @@ -// Copyright (c) Microsoft. All rights reserved. - -using System.Threading; -using System.Threading.Tasks; -using Microsoft.Agents.AI.Hosting.Channels; - -namespace Microsoft.Agents.AI.Hosting.Channels.UnitTests; - -public class InMemoryHostStateStoreTests -{ - [Fact] - public async Task SaveLink_AndGet_RoundTrips() - { - // Arrange - var store = new InMemoryHostStateStore(); - var alice = new ChannelIdentity("telegram", "1"); - - // Act - await store.SaveLinkAsync(alice, "user:alice", verifiedClaims: null, CancellationToken.None); - var key = await store.GetIsolationKeyAsync(alice, CancellationToken.None); - - // Assert - Assert.Equal("user:alice", key); - } - - [Fact] - public async Task SaveLink_AtomicallyMergesPriorKey() - { - // Arrange - var store = new InMemoryHostStateStore(); - var alice = new ChannelIdentity("telegram", "1"); - var aliceOnInvocations = new ChannelIdentity("invocations", "alice-1"); - - // First registration assigns its own key, then we relink onto the canonical one. - await store.SaveLinkAsync(alice, "telegram:1", verifiedClaims: null, CancellationToken.None); - await store.SaveLinkAsync(aliceOnInvocations, "alice", verifiedClaims: null, CancellationToken.None); - await store.SaveLinkAsync(alice, "alice", verifiedClaims: null, CancellationToken.None); - - // Act - var aliceKey = await store.GetIsolationKeyAsync(alice, CancellationToken.None); - var aliceInvKey = await store.GetIsolationKeyAsync(aliceOnInvocations, CancellationToken.None); - var identities = await store.GetIdentitiesAsync("alice", CancellationToken.None); - - // Assert - Assert.Equal("alice", aliceKey); - Assert.Equal("alice", aliceInvKey); - Assert.Equal(2, identities.Count); - } - - [Fact] - public async Task SaveLink_PersistsVerifiedClaimsForLookup() - { - // Arrange - var store = new InMemoryHostStateStore(); - var alice = new ChannelIdentity("telegram", "1"); - await store.SaveLinkAsync(alice, "alice", new System.Collections.Generic.Dictionary { ["email"] = "alice@contoso.com" }, CancellationToken.None); - - // Act - var hit = await store.LookupByVerifiedClaimAsync("email", "alice@contoso.com", CancellationToken.None); - var miss = await store.LookupByVerifiedClaimAsync("email", "ghost@example.com", CancellationToken.None); - - // Assert - Assert.Equal("alice", hit); - Assert.Null(miss); - } - - [Fact] - public async Task ConsumeLinkGrant_DeletesEntry() - { - // Arrange - var store = new InMemoryHostStateStore(); - var grant = new LinkGrant("CODE1", "linker", null, System.DateTimeOffset.UtcNow.AddMinutes(5), new System.Collections.Generic.Dictionary()); - await store.SaveLinkGrantAsync(grant, CancellationToken.None); - - // Act - var first = await store.ConsumeLinkGrantAsync("CODE1", CancellationToken.None); - var second = await store.ConsumeLinkGrantAsync("CODE1", CancellationToken.None); - - // Assert - Assert.NotNull(first); - Assert.Null(second); - } - - [Fact] - public async Task RotateSessionAlias_ChangesAlias() - { - // Arrange - var store = new InMemoryHostStateStore(); - var initial = await store.GetActiveSessionAliasAsync("alice", CancellationToken.None); - - // Act - await store.RotateSessionAliasAsync("alice", CancellationToken.None); - var rotated = await store.GetActiveSessionAliasAsync("alice", CancellationToken.None); - - // Assert - Assert.NotEqual(initial, rotated); - } -} \ No newline at end of file diff --git a/dotnet/tests/Microsoft.Agents.AI.Hosting.Channels.UnitTests/InProcessDurableTaskRunnerTests.cs b/dotnet/tests/Microsoft.Agents.AI.Hosting.Channels.UnitTests/InProcessDurableTaskRunnerTests.cs deleted file mode 100644 index a6e42f51755..00000000000 --- a/dotnet/tests/Microsoft.Agents.AI.Hosting.Channels.UnitTests/InProcessDurableTaskRunnerTests.cs +++ /dev/null @@ -1,69 +0,0 @@ -// Copyright (c) Microsoft. All rights reserved. - -using System; -using System.Threading; -using System.Threading.Tasks; -using Microsoft.Agents.AI.Hosting.Channels; -using Microsoft.Extensions.Logging.Abstractions; - -namespace Microsoft.Agents.AI.Hosting.Channels.UnitTests; - -public class InProcessDurableTaskRunnerTests -{ - [Fact] - public async Task Schedule_InvokesHandler_AndReachesSucceeded() - { - // Arrange - var runner = new InProcessDurableTaskRunner(NullLogger.Instance); - await runner.StartAsync(CancellationToken.None); - - var ran = new TaskCompletionSource(); - runner.Register("test", _ => { ran.SetResult(42); return ValueTask.CompletedTask; }); - - // Act - var handle = await runner.ScheduleAsync("test", payload: new object(), retryPolicy: null, CancellationToken.None); - var observed = await ran.Task.WaitAsync(TimeSpan.FromSeconds(5)); - - // Allow the runner to record the final status. - DurableTaskStatus? status = null; - for (var i = 0; i < 20 && (status = await runner.GetAsync(handle, CancellationToken.None)) != DurableTaskStatus.Succeeded; i++) - { - await Task.Delay(50); - } - - // Assert - Assert.Equal(42, observed); - Assert.Equal(DurableTaskStatus.Succeeded, status); - - await runner.StopAsync(CancellationToken.None); - await runner.DisposeAsync(); - } - - [Fact] - public async Task Schedule_RetriesOnException_BeforeGivingUp() - { - // Arrange - var runner = new InProcessDurableTaskRunner(NullLogger.Instance); - await runner.StartAsync(CancellationToken.None); - - var attempts = 0; - runner.Register("flaky", _ => { attempts++; throw new InvalidOperationException("boom"); }); - - // Act - var handle = await runner.ScheduleAsync("flaky", payload: new object(), retryPolicy: new RetryPolicy { MaxAttempts = 3, InitialBackoff = TimeSpan.FromMilliseconds(1), MaxBackoff = TimeSpan.FromMilliseconds(5) }, CancellationToken.None); - - // Allow retries to play out. - DurableTaskStatus? status = null; - for (var i = 0; i < 50 && (status = await runner.GetAsync(handle, CancellationToken.None)) != DurableTaskStatus.Failed; i++) - { - await Task.Delay(50); - } - - // Assert - Assert.Equal(DurableTaskStatus.Failed, status); - Assert.Equal(3, attempts); - - await runner.StopAsync(CancellationToken.None); - await runner.DisposeAsync(); - } -} \ No newline at end of file diff --git a/dotnet/tests/Microsoft.Agents.AI.Hosting.Channels.UnitTests/Microsoft.Agents.AI.Hosting.Channels.UnitTests.csproj b/dotnet/tests/Microsoft.Agents.AI.Hosting.Channels.UnitTests/Microsoft.Agents.AI.Hosting.Channels.UnitTests.csproj index e9176c68016..3ac66345803 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Hosting.Channels.UnitTests/Microsoft.Agents.AI.Hosting.Channels.UnitTests.csproj +++ b/dotnet/tests/Microsoft.Agents.AI.Hosting.Channels.UnitTests/Microsoft.Agents.AI.Hosting.Channels.UnitTests.csproj @@ -4,6 +4,10 @@ net10.0 + + + + diff --git a/dotnet/tests/Microsoft.Agents.AI.Hosting.Channels.UnitTests/OneTimeCodeIdentityLinkerTests.cs b/dotnet/tests/Microsoft.Agents.AI.Hosting.Channels.UnitTests/OneTimeCodeIdentityLinkerTests.cs deleted file mode 100644 index 4767bbc3607..00000000000 --- a/dotnet/tests/Microsoft.Agents.AI.Hosting.Channels.UnitTests/OneTimeCodeIdentityLinkerTests.cs +++ /dev/null @@ -1,65 +0,0 @@ -// Copyright (c) Microsoft. All rights reserved. - -using System.Collections.Generic; -using System.Threading; -using System.Threading.Tasks; -using Microsoft.Agents.AI.Hosting.Channels; - -namespace Microsoft.Agents.AI.Hosting.Channels.UnitTests; - -public class OneTimeCodeIdentityLinkerTests -{ - [Fact] - public async Task BeginAndComplete_CollapseTwoIdentitiesOntoOneKey() - { - // Arrange - var store = new InMemoryHostStateStore(); - var linker = new OneTimeCodeIdentityLinker(store); - var aliceOnInvocations = new ChannelIdentity("invocations", "alice-1"); - var aliceOnTelegram = new ChannelIdentity("telegram", "12345"); - - // Act - var challenge = await linker.BeginAsync(aliceOnInvocations, requestedIsolationKey: "alice", CancellationToken.None); - var principal = await linker.CompleteAsync(challenge.ChallengeId, new Dictionary { ["identity"] = aliceOnTelegram }, CancellationToken.None); - - var keyOnInvocations = await store.GetIsolationKeyAsync(aliceOnInvocations, CancellationToken.None); - var keyOnTelegram = await store.GetIsolationKeyAsync(aliceOnTelegram, CancellationToken.None); - - // Assert - Assert.Equal("alice", principal.IsolationKey); - Assert.Equal("alice", keyOnInvocations); - Assert.Equal("alice", keyOnTelegram); - } - - [Fact] - public async Task Complete_RejectsUnknownCode() - { - // Arrange - var store = new InMemoryHostStateStore(); - var linker = new OneTimeCodeIdentityLinker(store); - var alice = new ChannelIdentity("telegram", "12345"); - - // Act / Assert - await Assert.ThrowsAsync(() => - linker.CompleteAsync("NOPE", new Dictionary { ["identity"] = alice }, CancellationToken.None).AsTask()); - } - - [Fact] - public async Task IsLinked_AutoMergesOnVerifiedClaim() - { - // Arrange - var store = new InMemoryHostStateStore(); - var linker = new OneTimeCodeIdentityLinker(store); - var alice = new ChannelIdentity("telegram", "1"); - await store.SaveLinkAsync(alice, "alice", new Dictionary { ["email"] = "alice@contoso.com" }, CancellationToken.None); - var aliceOnInvocations = new ChannelIdentity("invocations", "alice-1"); - - // Act - var resolved = await linker.IsLinkedAsync(aliceOnInvocations, new Dictionary { ["email"] = "alice@contoso.com" }, CancellationToken.None); - var afterMerge = await store.GetIsolationKeyAsync(aliceOnInvocations, CancellationToken.None); - - // Assert - Assert.Equal("alice", resolved); - Assert.Equal("alice", afterMerge); - } -} \ No newline at end of file diff --git a/dotnet/tests/Microsoft.Agents.AI.Hosting.Channels.UnitTests/ResponseTargetTests.cs b/dotnet/tests/Microsoft.Agents.AI.Hosting.Channels.UnitTests/ResponseTargetTests.cs deleted file mode 100644 index c5dce012531..00000000000 --- a/dotnet/tests/Microsoft.Agents.AI.Hosting.Channels.UnitTests/ResponseTargetTests.cs +++ /dev/null @@ -1,51 +0,0 @@ -// Copyright (c) Microsoft. All rights reserved. - -using Microsoft.Agents.AI.Hosting.Channels; - -namespace Microsoft.Agents.AI.Hosting.Channels.UnitTests; - -public class ResponseTargetTests -{ - [Fact] - public void Singletons_AreCorrectVariants() - { - // Arrange / Act / Assert - Assert.IsType(ResponseTarget.Originating); - Assert.IsType(ResponseTarget.Active); - Assert.IsType(ResponseTarget.AllLinked); - Assert.IsType(ResponseTarget.None); - } - - [Fact] - public void Channel_FactoryProducesChannelTarget() - { - // Arrange / Act - var target = ResponseTarget.Channel("telegram", echoInput: true); - - // Assert - var typed = Assert.IsType(target); - Assert.Equal("telegram", typed.ChannelName); - Assert.True(typed.EchoInput); - } - - [Fact] - public void Identities_FactoryAcceptsSingleAndList() - { - // Arrange - var alice = new ChannelIdentity("telegram", "1"); - var bob = new ChannelIdentity("invocations", "2"); - - // Act - var single = ResponseTarget.Identity(alice); - var many = ResponseTarget.Identities([alice, bob], echoInput: true); - - // Assert - var typedSingle = Assert.IsType(single); - Assert.Single(typedSingle.Targets, alice); - Assert.False(typedSingle.EchoInput); - - var typedMany = Assert.IsType(many); - Assert.Equal(2, typedMany.Targets.Count); - Assert.True(typedMany.EchoInput); - } -} \ No newline at end of file diff --git a/dotnet/tests/Microsoft.Agents.AI.Hosting.Channels.UnitTests/WorkflowRunnerTests.cs b/dotnet/tests/Microsoft.Agents.AI.Hosting.Channels.UnitTests/WorkflowRunnerTests.cs new file mode 100644 index 00000000000..0578d4b8b29 --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.AI.Hosting.Channels.UnitTests/WorkflowRunnerTests.cs @@ -0,0 +1,34 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Agents.AI.Workflows; + +namespace Microsoft.Agents.AI.Hosting.Channels.UnitTests; + +public class WorkflowRunnerTests +{ + [Fact] + public async Task RunAsync_EchoWorkflow_RunsToCompletionAsync() + { + // Arrange + var echo = new EchoExecutor(); + var workflow = new WorkflowBuilder(echo).WithOutputFrom(echo).Build(); + var runner = new WorkflowRunner(workflow); + var request = new ChannelRequest { Channel = "test", Operation = "message.create", Input = "ping" }; + + // Act + var result = await runner.RunAsync(request, CancellationToken.None); + + // Assert + var typed = Assert.IsType>(result); + Assert.Equal(WorkflowRunStatus.Completed, typed.Result.Status); + Assert.False(string.IsNullOrEmpty(typed.Result.SessionId)); + } + + private sealed class EchoExecutor() : Executor("EchoExecutor") + { + public override ValueTask HandleAsync(string message, IWorkflowContext context, CancellationToken cancellationToken = default) => + ValueTask.FromResult($"echo: {message}"); + } +} From 1b9b3f1c9ef4a5fe8afc891461811428ee2f3e67 Mon Sep 17 00:00:00 2001 From: Roger Barreto <19890735+RogerBarreto@users.noreply.github.com> Date: Wed, 24 Jun 2026 16:03:33 +0100 Subject: [PATCH 10/16] Hosting.Channels: wire channel lifecycle callbacks and stream-transform hook Two declared seams of the channel contract were not actually invoked. Wiring them so the contract behaves as ADR-0027 describes (host owns route/lifecycle aggregation and invocation of per-channel hooks). Lifecycle: adds ChannelLifecycleRegistry + ChannelLifecycleService (IHostedService). AddAgentFrameworkHost registers the service; MapAgentFrameworkHost records each contribution's OnStartup/OnShutdown into the registry. StartAsync invokes OnStartup; StopAsync invokes OnShutdown in reverse registration order. Stream-transform hook: adds StreamTransformHook to ResponsesChannelOptions and applies it in the SSE path, so the host-consumed AgentResponseUpdate stream passes through IChannelStreamTransformHook before the channel renders response.output_text.delta frames. --- .../ResponsesChannel.cs | 33 +++++++++---- .../ResponsesChannelOptions.cs | 6 +++ ...ntRouteBuilderHostingChannelsExtensions.cs | 4 +- ...icationBuilderHostingChannelsExtensions.cs | 2 + .../Internal/ChannelLifecycleRegistry.cs | 46 +++++++++++++++++++ .../Internal/ChannelLifecycleService.cs | 41 +++++++++++++++++ .../Runners/WorkflowRunner.cs | 1 - 7 files changed, 121 insertions(+), 12 deletions(-) create mode 100644 dotnet/src/Microsoft.Agents.AI.Hosting.Channels/Internal/ChannelLifecycleRegistry.cs create mode 100644 dotnet/src/Microsoft.Agents.AI.Hosting.Channels/Internal/ChannelLifecycleService.cs diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting.Channels.Responses/ResponsesChannel.cs b/dotnet/src/Microsoft.Agents.AI.Hosting.Channels.Responses/ResponsesChannel.cs index 05b39d5214e..bbadf5dbc60 100644 --- a/dotnet/src/Microsoft.Agents.AI.Hosting.Channels.Responses/ResponsesChannel.cs +++ b/dotnet/src/Microsoft.Agents.AI.Hosting.Channels.Responses/ResponsesChannel.cs @@ -10,7 +10,6 @@ using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Routing; -using Microsoft.Extensions.AI; using Microsoft.Shared.Diagnostics; namespace Microsoft.Agents.AI.Hosting.Channels.Responses; @@ -131,17 +130,19 @@ private async Task WriteStreamAsync(IChannelContext context, ChannelRequest requ await WriteEventAsync(http, "response.created", new ResponsesStreamResponseEvent { Type = "response.created", Response = created }, ResponsesJsonContext.Default.ResponsesStreamResponseEvent).ConfigureAwait(false); var sb = new StringBuilder(); - await foreach (var item in context.StreamAsync(request, http.RequestAborted).ConfigureAwait(false)) + var updates = ExtractUpdatesAsync(context.StreamAsync(request, http.RequestAborted)); + var transformed = this._options.StreamTransformHook is { } hook + ? hook.TransformAsync(updates, http.RequestAborted) + : updates; + + await foreach (var update in transformed.ConfigureAwait(false)) { - if (item is HostedStreamUpdate update) + var delta = update.Text; + if (!string.IsNullOrEmpty(delta)) { - var delta = update.Update.Text; - if (!string.IsNullOrEmpty(delta)) - { - sb.Append(delta); - var deltaEvent = new ResponsesStreamTextDeltaEvent { ItemId = itemId, OutputIndex = 0, ContentIndex = 0, Delta = delta }; - await WriteEventAsync(http, "response.output_text.delta", deltaEvent, ResponsesJsonContext.Default.ResponsesStreamTextDeltaEvent).ConfigureAwait(false); - } + sb.Append(delta); + var deltaEvent = new ResponsesStreamTextDeltaEvent { ItemId = itemId, OutputIndex = 0, ContentIndex = 0, Delta = delta }; + await WriteEventAsync(http, "response.output_text.delta", deltaEvent, ResponsesJsonContext.Default.ResponsesStreamTextDeltaEvent).ConfigureAwait(false); } } @@ -149,6 +150,18 @@ private async Task WriteStreamAsync(IChannelContext context, ChannelRequest requ await WriteEventAsync(http, "response.completed", new ResponsesStreamResponseEvent { Type = "response.completed", Response = completed }, ResponsesJsonContext.Default.ResponsesStreamResponseEvent).ConfigureAwait(false); } + private static async IAsyncEnumerable ExtractUpdatesAsync( + IAsyncEnumerable items) + { + await foreach (var item in items.ConfigureAwait(false)) + { + if (item is HostedStreamUpdate update) + { + yield return update.Update; + } + } + } + private async ValueTask ApplyResponseHookAsync(HostedRunResult result, ChannelRequest request, CancellationToken cancellationToken) { if (this._options.ResponseHook is null) diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting.Channels.Responses/ResponsesChannelOptions.cs b/dotnet/src/Microsoft.Agents.AI.Hosting.Channels.Responses/ResponsesChannelOptions.cs index fd96829d63f..b9fa48ef93b 100644 --- a/dotnet/src/Microsoft.Agents.AI.Hosting.Channels.Responses/ResponsesChannelOptions.cs +++ b/dotnet/src/Microsoft.Agents.AI.Hosting.Channels.Responses/ResponsesChannelOptions.cs @@ -17,4 +17,10 @@ public sealed class ResponsesChannelOptions /// Optional response hook invoked before the channel serializes the originating response. public IChannelResponseHook? ResponseHook { get; set; } + + /// + /// Optional stream-update hook applied by the host while the channel consumes streamed updates, before + /// the channel renders Server-Sent-Events. Applies only to streaming requests. + /// + public IChannelStreamTransformHook? StreamTransformHook { get; set; } } diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting.Channels/EndpointRouteBuilderHostingChannelsExtensions.cs b/dotnet/src/Microsoft.Agents.AI.Hosting.Channels/EndpointRouteBuilderHostingChannelsExtensions.cs index b32dadc832e..53d513c70af 100644 --- a/dotnet/src/Microsoft.Agents.AI.Hosting.Channels/EndpointRouteBuilderHostingChannelsExtensions.cs +++ b/dotnet/src/Microsoft.Agents.AI.Hosting.Channels/EndpointRouteBuilderHostingChannelsExtensions.cs @@ -1,7 +1,6 @@ // Copyright (c) Microsoft. All rights reserved. using Microsoft.Agents.AI.Hosting.Channels; -using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Routing; using Microsoft.Extensions.DependencyInjection; @@ -25,6 +24,7 @@ public static IEndpointConventionBuilder MapAgentFrameworkHost(this IEndpointRou Throw.IfNull(endpoints); var host = endpoints.ServiceProvider.GetRequiredService(); + var registry = endpoints.ServiceProvider.GetRequiredService(); var context = new ChannelContext(endpoints.ServiceProvider, host); var hostGroup = endpoints.MapGroup(string.Empty); @@ -33,6 +33,8 @@ public static IEndpointConventionBuilder MapAgentFrameworkHost(this IEndpointRou var contribution = channel.Contribute(context); var channelGroup = string.IsNullOrEmpty(channel.Path) ? hostGroup : endpoints.MapGroup(channel.Path); + registry.Add(contribution.OnStartup, contribution.OnShutdown); + foreach (var filter in contribution.EndpointFilters) { channelGroup.AddEndpointFilter(filter); diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting.Channels/HostApplicationBuilderHostingChannelsExtensions.cs b/dotnet/src/Microsoft.Agents.AI.Hosting.Channels/HostApplicationBuilderHostingChannelsExtensions.cs index 332da3e299b..5489f193ea4 100644 --- a/dotnet/src/Microsoft.Agents.AI.Hosting.Channels/HostApplicationBuilderHostingChannelsExtensions.cs +++ b/dotnet/src/Microsoft.Agents.AI.Hosting.Channels/HostApplicationBuilderHostingChannelsExtensions.cs @@ -70,6 +70,8 @@ private static AgentFrameworkHostBuilder AddCore( } services.TryAddSingleton(); + services.TryAddSingleton(); + services.AddHostedService(); registerTarget(services); diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting.Channels/Internal/ChannelLifecycleRegistry.cs b/dotnet/src/Microsoft.Agents.AI.Hosting.Channels/Internal/ChannelLifecycleRegistry.cs new file mode 100644 index 00000000000..32451a8a7f1 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.Hosting.Channels/Internal/ChannelLifecycleRegistry.cs @@ -0,0 +1,46 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; + +namespace Microsoft.Agents.AI.Hosting.Channels; + +/// +/// Collects the / +/// callbacks gathered while MapAgentFrameworkHost invokes each channel's Contribute, so the +/// can invoke them at application start / stop. Singleton. +/// +internal sealed class ChannelLifecycleRegistry +{ + private readonly object _gate = new(); + private readonly List> _startup = []; + private readonly List> _shutdown = []; + + public void Add(Func? onStartup, Func? onShutdown) + { + lock (this._gate) + { + if (onStartup is not null) + { + this._startup.Add(onStartup); + } + + if (onShutdown is not null) + { + this._shutdown.Add(onShutdown); + } + } + } + + public IReadOnlyList> StartupCallbacks + { + get { lock (this._gate) { return [.. this._startup]; } } + } + + public IReadOnlyList> ShutdownCallbacks + { + get { lock (this._gate) { return [.. this._shutdown]; } } + } +} diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting.Channels/Internal/ChannelLifecycleService.cs b/dotnet/src/Microsoft.Agents.AI.Hosting.Channels/Internal/ChannelLifecycleService.cs new file mode 100644 index 00000000000..30d09316a32 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.Hosting.Channels/Internal/ChannelLifecycleService.cs @@ -0,0 +1,41 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Extensions.Hosting; +using Microsoft.Shared.Diagnostics; + +namespace Microsoft.Agents.AI.Hosting.Channels; + +/// +/// Invokes channel callbacks when the application starts and +/// callbacks (in reverse registration order) when it stops. +/// Registered by AddAgentFrameworkHost; reads callbacks recorded by MapAgentFrameworkHost. +/// +internal sealed class ChannelLifecycleService : IHostedService +{ + private readonly ChannelLifecycleRegistry _registry; + + public ChannelLifecycleService(ChannelLifecycleRegistry registry) + { + this._registry = Throw.IfNull(registry); + } + + public async Task StartAsync(CancellationToken cancellationToken) + { + foreach (var callback in this._registry.StartupCallbacks) + { + await callback(cancellationToken).ConfigureAwait(false); + } + } + + public async Task StopAsync(CancellationToken cancellationToken) + { + var callbacks = this._registry.ShutdownCallbacks; + for (var i = callbacks.Count - 1; i >= 0; i--) + { + await callbacks[i](cancellationToken).ConfigureAwait(false); + } + } +} diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting.Channels/Runners/WorkflowRunner.cs b/dotnet/src/Microsoft.Agents.AI.Hosting.Channels/Runners/WorkflowRunner.cs index f4c3cf1a1e9..ffb689dd6c6 100644 --- a/dotnet/src/Microsoft.Agents.AI.Hosting.Channels/Runners/WorkflowRunner.cs +++ b/dotnet/src/Microsoft.Agents.AI.Hosting.Channels/Runners/WorkflowRunner.cs @@ -1,6 +1,5 @@ // Copyright (c) Microsoft. All rights reserved. -using System; using System.Collections.Generic; using System.Runtime.CompilerServices; using System.Threading; From 15099d2f9d92c9adf0b4fbe626c786a5d656ab6f Mon Sep 17 00:00:00 2001 From: Roger Barreto <19890735+RogerBarreto@users.noreply.github.com> Date: Wed, 24 Jun 2026 16:03:53 +0100 Subject: [PATCH 11/16] Hosting.Channels: in-process integration tests for the host/channel contract Adds Microsoft.Agents.AI.Hosting.Channels.IntegrationTests: 20 fast, hermetic E2E tests that spin up an ASP.NET Core TestServer hosting an AgentFrameworkHost over deterministic fake agents and a trivial workflow (no live model, no infra; whole suite runs in ~1s). Contract tests (via a test-only ProbeChannel): routes mount under Channel.Path, custom path honored, OnStartup/OnShutdown lifecycle callbacks fire, endpoint filters apply to the channel group, IChannelContext.RunAsync drives the target, IChannelContext.StreamAsync yields updates then exactly one HostedStreamCompleted, multiple channels share one host and target, and the same channel drives both an AIAgent and a Workflow target (target neutrality). Hook tests (Responses channel): run hook rewrites input before the target, response hook rewrites the result before serialization, stream-transform hook injects a chunk while streaming. Session continuity (ADR-0027 gate): identical isolation key (stamped by a header-reading run hook simulating trusted middleware) reuses one cached AgentSession (CountingAgent returns 1 then 2); different keys partition; ResetSessionAsync rotates the alias to a fresh session. Responses protocol: sync string input and input-item array parsing, SSE created/delta/completed sequence, missing input -> 400, malformed JSON -> 400, workflow target -> 200. All changed projects build clean with warnaserror and pass CI-parity dotnet format verify-no-changes; 20 integration + 8 unit tests green. --- dotnet/agent-framework-dotnet.slnx | 3 +- .../ChannelContractTests.cs | 151 ++++++++++++++++++ .../HookTests.cs | 70 ++++++++ ...I.Hosting.Channels.IntegrationTests.csproj | 17 ++ .../ResponsesProtocolTests.cs | 110 +++++++++++++ .../SessionContinuityTests.cs | 93 +++++++++++ .../Support/CountingAgent.cs | 69 ++++++++ .../Support/EchoAgent.cs | 60 +++++++ .../Support/FakeChatAgent.cs | 71 ++++++++ .../Support/ProbeChannel.cs | 80 ++++++++++ .../Support/Sse.cs | 35 ++++ .../Support/TestHooks.cs | 67 ++++++++ .../Support/TestHostApp.cs | 61 +++++++ .../Support/WorkflowFactory.cs | 23 +++ 14 files changed, 909 insertions(+), 1 deletion(-) create mode 100644 dotnet/tests/Microsoft.Agents.AI.Hosting.Channels.IntegrationTests/ChannelContractTests.cs create mode 100644 dotnet/tests/Microsoft.Agents.AI.Hosting.Channels.IntegrationTests/HookTests.cs create mode 100644 dotnet/tests/Microsoft.Agents.AI.Hosting.Channels.IntegrationTests/Microsoft.Agents.AI.Hosting.Channels.IntegrationTests.csproj create mode 100644 dotnet/tests/Microsoft.Agents.AI.Hosting.Channels.IntegrationTests/ResponsesProtocolTests.cs create mode 100644 dotnet/tests/Microsoft.Agents.AI.Hosting.Channels.IntegrationTests/SessionContinuityTests.cs create mode 100644 dotnet/tests/Microsoft.Agents.AI.Hosting.Channels.IntegrationTests/Support/CountingAgent.cs create mode 100644 dotnet/tests/Microsoft.Agents.AI.Hosting.Channels.IntegrationTests/Support/EchoAgent.cs create mode 100644 dotnet/tests/Microsoft.Agents.AI.Hosting.Channels.IntegrationTests/Support/FakeChatAgent.cs create mode 100644 dotnet/tests/Microsoft.Agents.AI.Hosting.Channels.IntegrationTests/Support/ProbeChannel.cs create mode 100644 dotnet/tests/Microsoft.Agents.AI.Hosting.Channels.IntegrationTests/Support/Sse.cs create mode 100644 dotnet/tests/Microsoft.Agents.AI.Hosting.Channels.IntegrationTests/Support/TestHooks.cs create mode 100644 dotnet/tests/Microsoft.Agents.AI.Hosting.Channels.IntegrationTests/Support/TestHostApp.cs create mode 100644 dotnet/tests/Microsoft.Agents.AI.Hosting.Channels.IntegrationTests/Support/WorkflowFactory.cs diff --git a/dotnet/agent-framework-dotnet.slnx b/dotnet/agent-framework-dotnet.slnx index dc0d7749ddb..7dffccf5238 100644 --- a/dotnet/agent-framework-dotnet.slnx +++ b/dotnet/agent-framework-dotnet.slnx @@ -1,4 +1,4 @@ - + @@ -680,6 +680,7 @@ + diff --git a/dotnet/tests/Microsoft.Agents.AI.Hosting.Channels.IntegrationTests/ChannelContractTests.cs b/dotnet/tests/Microsoft.Agents.AI.Hosting.Channels.IntegrationTests/ChannelContractTests.cs new file mode 100644 index 00000000000..8d2ea837495 --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.AI.Hosting.Channels.IntegrationTests/ChannelContractTests.cs @@ -0,0 +1,151 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Net; +using System.Net.Http; +using System.Threading.Tasks; +using Microsoft.Agents.AI.Hosting.Channels.IntegrationTests.Support; +using Microsoft.Agents.AI.Hosting.Channels.Responses; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; + +namespace Microsoft.Agents.AI.Hosting.Channels.IntegrationTests; + +/// +/// Exercises the channel/host contract directly: route aggregation under , +/// lifecycle callbacks, endpoint filters, the run/stream invocation seam, hook ordering, multi-channel +/// composition, and target neutrality. +/// +public class ChannelContractTests +{ + [Fact] + public async Task Routes_MountUnderChannelPathAsync() + { + // Arrange + var probe = new ProbeChannel(path: "/probe"); + await using var app = await TestHostApp.StartAsync(b => b.AddAgentFrameworkHost(new FakeChatAgent()).AddChannel(probe)); + + // Act + var ok = await app.Client.GetAsync(new System.Uri("http://localhost/probe/ping")); + var miss = await app.Client.GetAsync(new System.Uri("http://localhost/ping")); + + // Assert + Assert.Equal(HttpStatusCode.OK, ok.StatusCode); + Assert.Equal("pong", await ok.Content.ReadAsStringAsync()); + Assert.Equal(HttpStatusCode.NotFound, miss.StatusCode); + } + + [Fact] + public async Task CustomPath_IsHonoredAsync() + { + // Arrange + await using var app = await TestHostApp.StartAsync(b => b.AddAgentFrameworkHost(new FakeChatAgent()).AddChannel(new ProbeChannel(path: "/probe2"))); + + // Act + var moved = await app.Client.GetAsync(new System.Uri("http://localhost/probe2/ping")); + var old = await app.Client.GetAsync(new System.Uri("http://localhost/probe/ping")); + + // Assert + Assert.Equal(HttpStatusCode.OK, moved.StatusCode); + Assert.Equal(HttpStatusCode.NotFound, old.StatusCode); + } + + [Fact] + public async Task Lifecycle_StartupAndShutdownCallbacksFireAsync() + { + // Arrange + var probe = new ProbeChannel(); + var app = await TestHostApp.StartAsync(b => b.AddAgentFrameworkHost(new FakeChatAgent()).AddChannel(probe)); + + // Assert - startup fired during StartAsync + Assert.True(probe.StartupFired); + Assert.False(probe.ShutdownFired); + + // Act - dispose stops the host + await app.DisposeAsync(); + + // Assert - shutdown fired during StopAsync + Assert.True(probe.ShutdownFired); + } + + [Fact] + public async Task EndpointFilter_AppliedToChannelGroupAsync() + { + // Arrange + await using var app = await TestHostApp.StartAsync(b => b.AddAgentFrameworkHost(new FakeChatAgent()).AddChannel(new ProbeChannel())); + + // Act + var response = await app.Client.GetAsync(new System.Uri("http://localhost/probe/ping")); + + // Assert + Assert.True(response.Headers.TryGetValues("x-probe-filter", out var values)); + Assert.Contains("applied", values!); + } + + [Fact] + public async Task RunSeam_InvokesTargetAsync() + { + // Arrange + await using var app = await TestHostApp.StartAsync(b => b.AddAgentFrameworkHost(new FakeChatAgent()).AddChannel(new ProbeChannel())); + + // Act + var response = await app.Client.PostAsync(new System.Uri("http://localhost/probe/run"), new StringContent("hi")); + + // Assert + Assert.Equal(FakeChatAgent.Reply, await response.Content.ReadAsStringAsync()); + } + + [Fact] + public async Task StreamSeam_YieldsUpdatesThenOneCompletedAsync() + { + // Arrange + await using var app = await TestHostApp.StartAsync(b => b.AddAgentFrameworkHost(new FakeChatAgent()).AddChannel(new ProbeChannel())); + + // Act + var response = await app.Client.PostAsync(new System.Uri("http://localhost/probe/stream"), new StringContent("hi")); + var body = await response.Content.ReadAsStringAsync(); + + // Assert - 8 update chunks, exactly one completed terminal + Assert.Equal("updates=8;completed=1", body); + } + + [Fact] + public async Task MultipleChannels_ShareOneHostAndTargetAsync() + { + // Arrange + await using var app = await TestHostApp.StartAsync(b => b + .AddAgentFrameworkHost(new FakeChatAgent()) + .AddChannel(new ProbeChannel()) + .AddResponsesChannel()); + + // Act + var probe = await app.Client.PostAsync(new System.Uri("http://localhost/probe/run"), new StringContent("hi")); + var responses = await app.Client.PostAsync(new System.Uri("http://localhost/responses"), JsonBody("{ \"input\": \"hi\" }")); + + // Assert - both channels reachable, both hit the same fake target + Assert.Equal(HttpStatusCode.OK, probe.StatusCode); + Assert.Equal(FakeChatAgent.Reply, await probe.Content.ReadAsStringAsync()); + Assert.Equal(HttpStatusCode.OK, responses.StatusCode); + Assert.Contains(FakeChatAgent.Reply, await responses.Content.ReadAsStringAsync()); + } + + [Fact] + public async Task TargetNeutrality_SameChannelWorksForAgentAndWorkflowAsync() + { + // Arrange + Act - agent target + await using (var agentApp = await TestHostApp.StartAsync(b => b.AddAgentFrameworkHost(new FakeChatAgent()).AddChannel(new ProbeChannel()))) + { + var agentResponse = await agentApp.Client.PostAsync(new System.Uri("http://localhost/probe/run"), new StringContent("hi")); + Assert.Equal(HttpStatusCode.OK, agentResponse.StatusCode); + } + + // Arrange + Act - workflow target, identical channel + var workflow = WorkflowFactory.Echo(); + await using var workflowApp = await TestHostApp.StartAsync(b => b.AddAgentFrameworkHost(workflow).AddChannel(new ProbeChannel())); + var workflowResponse = await workflowApp.Client.PostAsync(new System.Uri("http://localhost/probe/run"), new StringContent("hi")); + + // Assert - the same probe channel drove a workflow target without branching on type + Assert.Equal(HttpStatusCode.OK, workflowResponse.StatusCode); + } + + private static StringContent JsonBody(string json) => new(json, System.Text.Encoding.UTF8, "application/json"); +} diff --git a/dotnet/tests/Microsoft.Agents.AI.Hosting.Channels.IntegrationTests/HookTests.cs b/dotnet/tests/Microsoft.Agents.AI.Hosting.Channels.IntegrationTests/HookTests.cs new file mode 100644 index 00000000000..dfb97403878 --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.AI.Hosting.Channels.IntegrationTests/HookTests.cs @@ -0,0 +1,70 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Net.Http; +using System.Threading.Tasks; +using Microsoft.Agents.AI.Hosting.Channels.IntegrationTests.Support; +using Microsoft.Agents.AI.Hosting.Channels.Responses; +using Microsoft.Extensions.Hosting; + +namespace Microsoft.Agents.AI.Hosting.Channels.IntegrationTests; + +/// +/// Verifies the host-owned hook pipeline on the Responses channel: run hook runs before the target, +/// response hook runs before serialization, and the stream-transform hook is applied while streaming. +/// +public class HookTests +{ + [Fact] + public async Task RunHook_RewritesInputBeforeTargetAsync() + { + // Arrange - echo agent reflects whatever input the target receives; run hook rewrites it + await using var app = await TestHostApp.StartAsync(b => b + .AddAgentFrameworkHost(new EchoAgent()) + .AddResponsesChannel(o => o.RunHook = new RewriteInputRunHook("REWRITTEN"))); + + // Act + var response = await app.Client.PostAsync(new System.Uri("http://localhost/responses"), Json("{ \"input\": \"original\" }")); + var body = await response.Content.ReadAsStringAsync(); + + // Assert - target saw the rewritten input + Assert.Contains("REWRITTEN", body); + Assert.DoesNotContain("original", body); + } + + [Fact] + public async Task ResponseHook_RewritesResultBeforeSerializeAsync() + { + // Arrange - fake agent replies "Hello from fake agent!"; response hook uppercases it + await using var app = await TestHostApp.StartAsync(b => b + .AddAgentFrameworkHost(new FakeChatAgent()) + .AddResponsesChannel(o => o.ResponseHook = new UppercaseResponseHook())); + + // Act + var response = await app.Client.PostAsync(new System.Uri("http://localhost/responses"), Json("{ \"input\": \"hi\" }")); + var body = await response.Content.ReadAsStringAsync(); + + // Assert + Assert.Contains(FakeChatAgent.Reply.ToUpperInvariant(), body); + } + + [Fact] + public async Task StreamTransformHook_AppliedWhileStreamingAsync() + { + // Arrange - prefix hook injects a "[X]" chunk ahead of the agent stream + await using var app = await TestHostApp.StartAsync(b => b + .AddAgentFrameworkHost(new FakeChatAgent()) + .AddResponsesChannel(o => o.StreamTransformHook = new PrefixStreamHook())); + + // Act + var response = await app.Client.PostAsync(new System.Uri("http://localhost/responses"), Json("{ \"input\": \"hi\", \"stream\": true }")); + var body = await response.Content.ReadAsStringAsync(); + var frames = Sse.Parse(body); + + // Assert - first delta is the injected marker + Assert.Contains("[X]", body); + var firstDelta = System.Array.Find(System.Linq.Enumerable.ToArray(frames), f => f.Event == "response.output_text.delta"); + Assert.Contains("[X]", firstDelta.Data); + } + + private static StringContent Json(string json) => new(json, System.Text.Encoding.UTF8, "application/json"); +} diff --git a/dotnet/tests/Microsoft.Agents.AI.Hosting.Channels.IntegrationTests/Microsoft.Agents.AI.Hosting.Channels.IntegrationTests.csproj b/dotnet/tests/Microsoft.Agents.AI.Hosting.Channels.IntegrationTests/Microsoft.Agents.AI.Hosting.Channels.IntegrationTests.csproj new file mode 100644 index 00000000000..d9deb08f249 --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.AI.Hosting.Channels.IntegrationTests/Microsoft.Agents.AI.Hosting.Channels.IntegrationTests.csproj @@ -0,0 +1,17 @@ + + + + $(TargetFrameworksCore) + + + + + + + + + + + + + \ No newline at end of file diff --git a/dotnet/tests/Microsoft.Agents.AI.Hosting.Channels.IntegrationTests/ResponsesProtocolTests.cs b/dotnet/tests/Microsoft.Agents.AI.Hosting.Channels.IntegrationTests/ResponsesProtocolTests.cs new file mode 100644 index 00000000000..8ed11aab148 --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.AI.Hosting.Channels.IntegrationTests/ResponsesProtocolTests.cs @@ -0,0 +1,110 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Net; +using System.Net.Http; +using System.Text.Json; +using System.Threading.Tasks; +using Microsoft.Agents.AI.Hosting.Channels.IntegrationTests.Support; +using Microsoft.Agents.AI.Hosting.Channels.Responses; +using Microsoft.Extensions.Hosting; + +namespace Microsoft.Agents.AI.Hosting.Channels.IntegrationTests; + +/// Validates the Responses channel's own wire mapping: JSON envelope, SSE frames, input parsing, errors. +public class ResponsesProtocolTests +{ + [Fact] + public async Task Sync_StringInput_RendersResponsesJsonAsync() + { + // Arrange + await using var app = await TestHostApp.StartAsync(b => b.AddAgentFrameworkHost(new FakeChatAgent()).AddResponsesChannel()); + + // Act + var response = await app.Client.PostAsync(new System.Uri("http://localhost/responses"), Json("{ \"input\": \"hi\" }")); + var body = await response.Content.ReadAsStringAsync(); + + // Assert + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + using var doc = JsonDocument.Parse(body); + Assert.Equal("completed", doc.RootElement.GetProperty("status").GetString()); + Assert.Equal(FakeChatAgent.Reply, doc.RootElement.GetProperty("output")[0].GetProperty("content")[0].GetProperty("text").GetString()); + } + + [Fact] + public async Task Sync_InputItemArray_IsParsedAsync() + { + // Arrange + await using var app = await TestHostApp.StartAsync(b => b.AddAgentFrameworkHost(new EchoAgent()).AddResponsesChannel()); + + // Act - Responses input-item array shape + var response = await app.Client.PostAsync(new System.Uri("http://localhost/responses"), + Json("{ \"input\": [ { \"role\": \"user\", \"content\": \"piece\" } ] }")); + var body = await response.Content.ReadAsStringAsync(); + + // Assert - echo agent reflected the parsed user text + Assert.Contains("piece", body); + } + + [Fact] + public async Task Streaming_EmitsCreatedDeltaCompletedAsync() + { + // Arrange + await using var app = await TestHostApp.StartAsync(b => b.AddAgentFrameworkHost(new FakeChatAgent()).AddResponsesChannel()); + + // Act + var response = await app.Client.PostAsync(new System.Uri("http://localhost/responses"), Json("{ \"input\": \"hi\", \"stream\": true }")); + var body = await response.Content.ReadAsStringAsync(); + var frames = Sse.Parse(body); + var events = System.Linq.Enumerable.ToList(frames.Events()); + + // Assert - frame sequence and reassembled text + Assert.Equal("text/event-stream", response.Content.Headers.ContentType!.MediaType); + Assert.Equal("response.created", events[0]); + Assert.Contains("response.output_text.delta", events); + Assert.Equal("response.completed", events[^1]); + Assert.Contains(FakeChatAgent.Reply, body); + } + + [Fact] + public async Task MissingInput_Returns400Async() + { + // Arrange + await using var app = await TestHostApp.StartAsync(b => b.AddAgentFrameworkHost(new FakeChatAgent()).AddResponsesChannel()); + + // Act + var response = await app.Client.PostAsync(new System.Uri("http://localhost/responses"), Json("{ }")); + + // Assert + Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); + } + + [Fact] + public async Task MalformedJson_Returns400Async() + { + // Arrange + await using var app = await TestHostApp.StartAsync(b => b.AddAgentFrameworkHost(new FakeChatAgent()).AddResponsesChannel()); + + // Act + var response = await app.Client.PostAsync(new System.Uri("http://localhost/responses"), Json("{ not json")); + + // Assert + Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); + } + + [Fact] + public async Task WorkflowTarget_RendersCompletedResponseAsync() + { + // Arrange - workflow target with a run hook adapting the parsed input to the workflow's string input + await using var app = await TestHostApp.StartAsync(b => b + .AddAgentFrameworkHost(WorkflowFactory.Echo()) + .AddResponsesChannel(o => o.RunHook = new RewriteInputRunHook("ping"))); + + // Act + var response = await app.Client.PostAsync(new System.Uri("http://localhost/responses"), Json("{ \"input\": \"hi\" }")); + + // Assert + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + } + + private static StringContent Json(string json) => new(json, System.Text.Encoding.UTF8, "application/json"); +} diff --git a/dotnet/tests/Microsoft.Agents.AI.Hosting.Channels.IntegrationTests/SessionContinuityTests.cs b/dotnet/tests/Microsoft.Agents.AI.Hosting.Channels.IntegrationTests/SessionContinuityTests.cs new file mode 100644 index 00000000000..5b3f9feeba1 --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.AI.Hosting.Channels.IntegrationTests/SessionContinuityTests.cs @@ -0,0 +1,93 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Net.Http; +using System.Text.Json; +using System.Threading.Tasks; +using Microsoft.Agents.AI.Hosting.Channels.IntegrationTests.Support; +using Microsoft.Agents.AI.Hosting.Channels.Responses; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; + +namespace Microsoft.Agents.AI.Hosting.Channels.IntegrationTests; + +/// +/// Verifies the ADR-0027 session-continuity gate end to end: identical isolation keys resolve to the same +/// cached , different keys partition, and +/// rotates the alias. The isolation key is stamped by trusted middleware (a run hook reading a header), not +/// from the request body. +/// +public class SessionContinuityTests +{ + private const string IsoHeader = "x-iso"; + + [Fact] + public async Task SameIsolationKey_ReusesSessionAsync() + { + // Arrange - counting agent returns the running turn count for its session + await using var app = await StartAsync(); + + // Act - two requests with the same isolation header + var first = await PostAsync(app, "alice"); + var second = await PostAsync(app, "alice"); + + // Assert - same cached session -> count increments + Assert.Equal("1", first); + Assert.Equal("2", second); + } + + [Fact] + public async Task DifferentIsolationKey_GetsFreshSessionAsync() + { + // Arrange + await using var app = await StartAsync(); + + // Act + var alice = await PostAsync(app, "alice"); + var bob = await PostAsync(app, "bob"); + + // Assert - different keys partition into different sessions + Assert.Equal("1", alice); + Assert.Equal("1", bob); + } + + [Fact] + public async Task ResetSession_RotatesAliasToFreshSessionAsync() + { + // Arrange + await using var app = await StartAsync(); + var host = app.Services.GetRequiredService(); + + // Act + var first = await PostAsync(app, "alice"); + var second = await PostAsync(app, "alice"); + await host.ResetSessionAsync("alice"); + var afterReset = await PostAsync(app, "alice"); + + // Assert + Assert.Equal("1", first); + Assert.Equal("2", second); + Assert.Equal("1", afterReset); + } + + private static Task StartAsync() => TestHostApp.StartAsync( + b => b + .AddAgentFrameworkHost(new CountingAgent()) + .AddResponsesChannel(o => o.RunHook = new HeaderIsolationRunHook( + b.Services.BuildServiceProvider().GetRequiredService(), + IsoHeader)), + services => services.AddHttpContextAccessor()); + + private static async Task PostAsync(TestHostApp app, string isolationKey) + { + using var request = new HttpRequestMessage(HttpMethod.Post, new System.Uri("http://localhost/responses")) + { + Content = new StringContent("{ \"input\": \"hi\" }", System.Text.Encoding.UTF8, "application/json"), + }; + request.Headers.Add(IsoHeader, isolationKey); + var response = await app.Client.SendAsync(request); + var body = await response.Content.ReadAsStringAsync(); + + using var doc = JsonDocument.Parse(body); + return doc.RootElement.GetProperty("output")[0].GetProperty("content")[0].GetProperty("text").GetString()!; + } +} diff --git a/dotnet/tests/Microsoft.Agents.AI.Hosting.Channels.IntegrationTests/Support/CountingAgent.cs b/dotnet/tests/Microsoft.Agents.AI.Hosting.Channels.IntegrationTests/Support/CountingAgent.cs new file mode 100644 index 00000000000..cdf64d9fe9d --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.AI.Hosting.Channels.IntegrationTests/Support/CountingAgent.cs @@ -0,0 +1,69 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Collections.Generic; +using System.Globalization; +using System.Runtime.CompilerServices; +using System.Text.Json; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Agents.AI; +using Microsoft.Extensions.AI; + +namespace Microsoft.Agents.AI.Hosting.Channels.IntegrationTests.Support; + +/// +/// Agent whose reply is the running turn count held on its . Because the host +/// runner caches one session instance per active alias, identical isolation keys reuse the same session and +/// the count increments; a fresh session resets to 1. Proves session continuity end to end. +/// +internal sealed class CountingAgent : AIAgent +{ + protected override string? IdCore => "counting-agent"; + + protected override ValueTask CreateSessionCoreAsync(CancellationToken cancellationToken = default) => + new(new CountingSession()); + + protected override ValueTask DeserializeSessionCoreAsync(JsonElement serializedState, JsonSerializerOptions? jsonSerializerOptions = null, CancellationToken cancellationToken = default) => + new(new CountingSession()); + + protected override ValueTask SerializeSessionCoreAsync(AgentSession session, JsonSerializerOptions? jsonSerializerOptions = null, CancellationToken cancellationToken = default) => + new(JsonSerializer.SerializeToElement(new { })); + + protected override Task RunCoreAsync( + IEnumerable messages, + AgentSession? session = null, + AgentRunOptions? options = null, + CancellationToken cancellationToken = default) + { + var count = session is CountingSession counting ? counting.Next() : 0; + var response = new AgentResponse(new ChatMessage(ChatRole.Assistant, count.ToString(CultureInfo.InvariantCulture))); + return Task.FromResult(response); + } + + protected override async IAsyncEnumerable RunCoreStreamingAsync( + IEnumerable messages, + AgentSession? session = null, + AgentRunOptions? options = null, + [EnumeratorCancellation] CancellationToken cancellationToken = default) + { + var count = session is CountingSession counting ? counting.Next() : 0; + yield return new AgentResponseUpdate { Role = ChatRole.Assistant, Contents = [new TextContent(count.ToString(CultureInfo.InvariantCulture))] }; + await Task.Yield(); + } + + private sealed class CountingSession : AgentSession + { + private int _count; + + public CountingSession() + { + } + + public CountingSession(AgentSessionStateBag stateBag) : base(stateBag) + { + } + + public int Next() => ++this._count; + } +} diff --git a/dotnet/tests/Microsoft.Agents.AI.Hosting.Channels.IntegrationTests/Support/EchoAgent.cs b/dotnet/tests/Microsoft.Agents.AI.Hosting.Channels.IntegrationTests/Support/EchoAgent.cs new file mode 100644 index 00000000000..8565bb02ca9 --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.AI.Hosting.Channels.IntegrationTests/Support/EchoAgent.cs @@ -0,0 +1,60 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Runtime.CompilerServices; +using System.Text.Json; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Agents.AI; +using Microsoft.Extensions.AI; + +namespace Microsoft.Agents.AI.Hosting.Channels.IntegrationTests.Support; + +/// Agent that replies with the concatenated text of the user messages it received. +internal sealed class EchoAgent : AIAgent +{ + protected override string? IdCore => "echo-agent"; + + protected override ValueTask CreateSessionCoreAsync(CancellationToken cancellationToken = default) => + new(new EchoSession()); + + protected override ValueTask DeserializeSessionCoreAsync(JsonElement serializedState, JsonSerializerOptions? jsonSerializerOptions = null, CancellationToken cancellationToken = default) => + new(new EchoSession()); + + protected override ValueTask SerializeSessionCoreAsync(AgentSession session, JsonSerializerOptions? jsonSerializerOptions = null, CancellationToken cancellationToken = default) => + new(JsonSerializer.SerializeToElement(new { })); + + protected override Task RunCoreAsync( + IEnumerable messages, + AgentSession? session = null, + AgentRunOptions? options = null, + CancellationToken cancellationToken = default) + { + var text = string.Concat(messages.Where(m => m.Role == ChatRole.User).Select(m => m.Text)); + return Task.FromResult(new AgentResponse(new ChatMessage(ChatRole.Assistant, text))); + } + + protected override async IAsyncEnumerable RunCoreStreamingAsync( + IEnumerable messages, + AgentSession? session = null, + AgentRunOptions? options = null, + [EnumeratorCancellation] CancellationToken cancellationToken = default) + { + var text = string.Concat(messages.Where(m => m.Role == ChatRole.User).Select(m => m.Text)); + yield return new AgentResponseUpdate { Role = ChatRole.Assistant, Contents = [new TextContent(text)] }; + await Task.Yield(); + } + + private sealed class EchoSession : AgentSession + { + public EchoSession() + { + } + + public EchoSession(AgentSessionStateBag stateBag) : base(stateBag) + { + } + } +} diff --git a/dotnet/tests/Microsoft.Agents.AI.Hosting.Channels.IntegrationTests/Support/FakeChatAgent.cs b/dotnet/tests/Microsoft.Agents.AI.Hosting.Channels.IntegrationTests/Support/FakeChatAgent.cs new file mode 100644 index 00000000000..9374af732c2 --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.AI.Hosting.Channels.IntegrationTests/Support/FakeChatAgent.cs @@ -0,0 +1,71 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Collections.Generic; +using System.Runtime.CompilerServices; +using System.Text.Json; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Agents.AI; +using Microsoft.Extensions.AI; + +namespace Microsoft.Agents.AI.Hosting.Channels.IntegrationTests.Support; + +/// Deterministic agent that streams a fixed reply in chunks. No live model. +internal sealed class FakeChatAgent : AIAgent +{ + public const string Reply = "Hello from fake agent!"; + + protected override string? IdCore => "fake-agent"; + + public override string? Description => "A fake agent for testing"; + + protected override ValueTask CreateSessionCoreAsync(CancellationToken cancellationToken = default) => + new(new FakeSession()); + + protected override ValueTask DeserializeSessionCoreAsync(JsonElement serializedState, JsonSerializerOptions? jsonSerializerOptions = null, CancellationToken cancellationToken = default) => + new(new FakeSession()); + + protected override ValueTask SerializeSessionCoreAsync(AgentSession session, JsonSerializerOptions? jsonSerializerOptions = null, CancellationToken cancellationToken = default) => + new(JsonSerializer.SerializeToElement(new { })); + + protected override async Task RunCoreAsync( + IEnumerable messages, + AgentSession? session = null, + AgentRunOptions? options = null, + CancellationToken cancellationToken = default) + { + List updates = []; + await foreach (var update in this.RunStreamingAsync(messages, session, options, cancellationToken).ConfigureAwait(false)) + { + updates.Add(update); + } + + return updates.ToAgentResponse(); + } + + protected override async IAsyncEnumerable RunCoreStreamingAsync( + IEnumerable messages, + AgentSession? session = null, + AgentRunOptions? options = null, + [EnumeratorCancellation] CancellationToken cancellationToken = default) + { + var messageId = Guid.NewGuid().ToString("N"); + foreach (var chunk in new[] { "Hello", " ", "from", " ", "fake", " ", "agent", "!" }) + { + yield return new AgentResponseUpdate { MessageId = messageId, Role = ChatRole.Assistant, Contents = [new TextContent(chunk)] }; + await Task.Yield(); + } + } + + private sealed class FakeSession : AgentSession + { + public FakeSession() + { + } + + public FakeSession(AgentSessionStateBag stateBag) : base(stateBag) + { + } + } +} diff --git a/dotnet/tests/Microsoft.Agents.AI.Hosting.Channels.IntegrationTests/Support/ProbeChannel.cs b/dotnet/tests/Microsoft.Agents.AI.Hosting.Channels.IntegrationTests/Support/ProbeChannel.cs new file mode 100644 index 00000000000..45d1decb2f8 --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.AI.Hosting.Channels.IntegrationTests/Support/ProbeChannel.cs @@ -0,0 +1,80 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Routing; + +namespace Microsoft.Agents.AI.Hosting.Channels.IntegrationTests.Support; + +/// +/// Minimal test-only channel used to exercise the channel/host contract directly, independent of any +/// concrete protocol. Contributes a couple of routes that call the host run/stream seam, an endpoint +/// filter that stamps a header, and lifecycle callbacks that flip flags. +/// +internal sealed class ProbeChannel : Channel +{ + private readonly string _path; + + public ProbeChannel(string path = "/probe") + { + this._path = path; + } + + public override string Name => "probe"; + + public override string Path => this._path; + + public bool StartupFired { get; private set; } + + public bool ShutdownFired { get; private set; } + + public override ChannelContribution Contribute(IChannelContext context) => new() + { + EndpointFilters = [new HeaderStampFilter()], + OnStartup = _ => { this.StartupFired = true; return default; }, + OnShutdown = _ => { this.ShutdownFired = true; return default; }, + Routes = + [ + endpoints => + { + endpoints.MapGet("/ping", () => Results.Text("pong")); + + endpoints.MapPost("/run", async (HttpContext http) => + { + var input = await new System.IO.StreamReader(http.Request.Body).ReadToEndAsync(http.RequestAborted).ConfigureAwait(false); + var request = new ChannelRequest { Channel = "probe", Operation = "message.create", Input = input }; + var result = await context.RunAsync(request, http.RequestAborted).ConfigureAwait(false); + var text = (result.ResultObject as AgentResponse)?.Text ?? result.ResultObject?.ToString(); + return Results.Text(text ?? string.Empty); + }); + + endpoints.MapPost("/stream", async (HttpContext http) => + { + var input = await new System.IO.StreamReader(http.Request.Body).ReadToEndAsync(http.RequestAborted).ConfigureAwait(false); + var request = new ChannelRequest { Channel = "probe", Operation = "message.create", Input = input, Stream = true }; + var updates = 0; + var completed = 0; + await foreach (var item in context.StreamAsync(request, http.RequestAborted).ConfigureAwait(false)) + { + if (item is HostedStreamUpdate) { updates++; } + else if (item is HostedStreamCompleted) { completed++; } + } + return Results.Text($"updates={updates};completed={completed}"); + }); + }, + ], + }; + + private sealed class HeaderStampFilter : IEndpointFilter + { + public async ValueTask InvokeAsync(EndpointFilterInvocationContext context, EndpointFilterDelegate next) + { + context.HttpContext.Response.Headers["x-probe-filter"] = "applied"; + return await next(context).ConfigureAwait(false); + } + } +} diff --git a/dotnet/tests/Microsoft.Agents.AI.Hosting.Channels.IntegrationTests/Support/Sse.cs b/dotnet/tests/Microsoft.Agents.AI.Hosting.Channels.IntegrationTests/Support/Sse.cs new file mode 100644 index 00000000000..5d64232aeb7 --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.AI.Hosting.Channels.IntegrationTests/Support/Sse.cs @@ -0,0 +1,35 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Collections.Generic; +using System.Linq; + +namespace Microsoft.Agents.AI.Hosting.Channels.IntegrationTests.Support; + +/// Tiny Server-Sent-Events frame parser for assertions. +internal static class Sse +{ + public static IReadOnlyList<(string Event, string Data)> Parse(string body) + { + var frames = new List<(string, string)>(); + foreach (var block in body.Replace("\r\n", "\n").Split("\n\n", StringSplitOptions.RemoveEmptyEntries)) + { + string? evt = null; + var data = new List(); + foreach (var line in block.Split('\n')) + { + if (line.StartsWith("event:", StringComparison.Ordinal)) { evt = line["event:".Length..].Trim(); } + else if (line.StartsWith("data:", StringComparison.Ordinal)) { data.Add(line["data:".Length..].Trim()); } + } + + if (evt is not null) + { + frames.Add((evt, string.Join("\n", data))); + } + } + + return frames; + } + + public static IEnumerable Events(this IReadOnlyList<(string Event, string Data)> frames) => frames.Select(f => f.Event); +} diff --git a/dotnet/tests/Microsoft.Agents.AI.Hosting.Channels.IntegrationTests/Support/TestHooks.cs b/dotnet/tests/Microsoft.Agents.AI.Hosting.Channels.IntegrationTests/Support/TestHooks.cs new file mode 100644 index 00000000000..40cabb96c95 --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.AI.Hosting.Channels.IntegrationTests/Support/TestHooks.cs @@ -0,0 +1,67 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Collections.Generic; +using System.Runtime.CompilerServices; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Agents.AI; +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.AI; + +namespace Microsoft.Agents.AI.Hosting.Channels.IntegrationTests.Support; + +/// Run hook that rewrites the request input, to assert run-hook ordering (before the target). +internal sealed class RewriteInputRunHook(string replacement) : IChannelRunHook +{ + public ValueTask OnRequestAsync(ChannelRequest request, ChannelRunHookContext context, CancellationToken cancellationToken) => + new(request with { Input = replacement }); +} + +/// +/// Run hook that stamps from a request header, simulating trusted +/// middleware. The channel never trusts wire input for the isolation key; this is how a host derives it. +/// +internal sealed class HeaderIsolationRunHook(IHttpContextAccessor accessor, string headerName) : IChannelRunHook +{ + public ValueTask OnRequestAsync(ChannelRequest request, ChannelRunHookContext context, CancellationToken cancellationToken) + { + var key = accessor.HttpContext?.Request.Headers[headerName].ToString(); + if (string.IsNullOrEmpty(key)) + { + return new(request); + } + + var session = (request.Session ?? new ChannelSession()) with { IsolationKey = key }; + return new(request with { Session = session }); + } +} + +/// Response hook that uppercases the agent reply, to assert response-hook ordering (before serialize). +internal sealed class UppercaseResponseHook : IChannelResponseHook +{ + public ValueTask OnResponseAsync(HostedRunResult result, ChannelResponseContext context, CancellationToken cancellationToken) + { + if (result is HostedRunResult typed) + { + var upper = typed.Result.Text.ToUpperInvariant(); + return new(typed.Replace(new AgentResponse(new ChatMessage(ChatRole.Assistant, upper)))); + } + + return new(result); + } +} + +/// Stream transform hook that prefixes a marker chunk, to assert the hook is applied while streaming. +internal sealed class PrefixStreamHook : IChannelStreamTransformHook +{ + public async IAsyncEnumerable TransformAsync( + IAsyncEnumerable upstream, + [EnumeratorCancellation] CancellationToken cancellationToken) + { + yield return new AgentResponseUpdate { Role = ChatRole.Assistant, Contents = [new TextContent("[X]")] }; + await foreach (var update in upstream.ConfigureAwait(false)) + { + yield return update; + } + } +} diff --git a/dotnet/tests/Microsoft.Agents.AI.Hosting.Channels.IntegrationTests/Support/TestHostApp.cs b/dotnet/tests/Microsoft.Agents.AI.Hosting.Channels.IntegrationTests/Support/TestHostApp.cs new file mode 100644 index 00000000000..906de316147 --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.AI.Hosting.Channels.IntegrationTests/Support/TestHostApp.cs @@ -0,0 +1,61 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Net.Http; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Hosting; +using Microsoft.AspNetCore.Hosting.Server; +using Microsoft.AspNetCore.TestHost; +using Microsoft.Extensions.DependencyInjection; + +namespace Microsoft.Agents.AI.Hosting.Channels.IntegrationTests.Support; + +/// +/// In-process ASP.NET Core hosting an . Build via +/// , issue HTTP requests through , and dispose to stop (which +/// fires channel shutdown callbacks). +/// +internal sealed class TestHostApp : IAsyncDisposable +{ + private WebApplication _app = null!; + + public HttpClient Client { get; private set; } = null!; + + public IServiceProvider Services => this._app.Services; + + /// + /// Build and start a test host. adds the target + channels; + /// registers any extra services (e.g. http context accessor). + /// + public static async Task StartAsync( + Func configureHost, + Action? configureServices = null) + { + var builder = WebApplication.CreateBuilder(); + builder.WebHost.UseTestServer(); + configureServices?.Invoke(builder.Services); + + configureHost(builder); + + var app = builder.Build(); + app.MapAgentFrameworkHost(); + await app.StartAsync().ConfigureAwait(false); + + var server = app.Services.GetRequiredService() as TestServer + ?? throw new InvalidOperationException("TestServer not found."); + + return new TestHostApp + { + _app = app, + Client = server.CreateClient(), + }; + } + + public async ValueTask DisposeAsync() + { + this.Client?.Dispose(); + await this._app.StopAsync().ConfigureAwait(false); + await this._app.DisposeAsync().ConfigureAwait(false); + } +} diff --git a/dotnet/tests/Microsoft.Agents.AI.Hosting.Channels.IntegrationTests/Support/WorkflowFactory.cs b/dotnet/tests/Microsoft.Agents.AI.Hosting.Channels.IntegrationTests/Support/WorkflowFactory.cs new file mode 100644 index 00000000000..0742d8d1e9f --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.AI.Hosting.Channels.IntegrationTests/Support/WorkflowFactory.cs @@ -0,0 +1,23 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Agents.AI.Workflows; + +namespace Microsoft.Agents.AI.Hosting.Channels.IntegrationTests.Support; + +/// Builds a trivial single-executor echo workflow for target-neutrality tests. +internal static class WorkflowFactory +{ + public static Workflow Echo() + { + var echo = new EchoExecutor(); + return new WorkflowBuilder(echo).WithOutputFrom(echo).Build(); + } + + private sealed class EchoExecutor() : Executor("EchoExecutor") + { + public override ValueTask HandleAsync(string message, IWorkflowContext context, CancellationToken cancellationToken = default) => + ValueTask.FromResult($"echo: {message}"); + } +} From 91dba34920b8b7061e7906022b7a208f98a2227a Mon Sep 17 00:00:00 2001 From: Roger Barreto <19890735+RogerBarreto@users.noreply.github.com> Date: Wed, 24 Jun 2026 17:53:30 +0100 Subject: [PATCH 12/16] Hosting.Channels: integration tests for function calling and agent workflows via Responses Adds two more end-to-end scenarios through the Responses channel, both hermetic (no live model). Function calling: a ChatClientAgent built with a tool over a deterministic two-turn FakeFunctionCallingChatClient (turn 1 emits a FunctionCallContent; turn 2, after the framework's FunctionInvokingChatClient executes the tool and feeds back the FunctionResultContent, emits the final answer). Driven via POST /responses, the test asserts the server-side tool actually ran and the post-tool answer is rendered, plus a streaming variant asserting the final answer arrives over SSE. Agent workflow: AgentWorkflowBuilder.BuildSequential over two deterministic agents hosted through the Responses channel, with a run hook adapting the parsed Responses input into the workflow's ChatMessage-list input; the host's WorkflowRunner drives it and a completed Responses object is returned. Integration suite now 23 tests; all build warnaserror-clean, pass CI-parity dotnet format verify-no-changes, and run in under a second. --- .../AgentWorkflowTests.cs | 45 +++++++++++ .../ChannelContractTests.cs | 1 - .../FunctionCallingTests.cs | 77 +++++++++++++++++++ .../Support/ChatMessageListRunHook.cs | 26 +++++++ .../Support/CountingAgent.cs | 2 - .../Support/EchoAgent.cs | 2 - .../Support/FakeChatAgent.cs | 1 - .../Support/FakeFunctionCallingChatClient.cs | 70 +++++++++++++++++ .../Support/ProbeChannel.cs | 3 - .../Support/TestHooks.cs | 1 - .../Support/TestHostApp.cs | 1 - 11 files changed, 218 insertions(+), 11 deletions(-) create mode 100644 dotnet/tests/Microsoft.Agents.AI.Hosting.Channels.IntegrationTests/AgentWorkflowTests.cs create mode 100644 dotnet/tests/Microsoft.Agents.AI.Hosting.Channels.IntegrationTests/FunctionCallingTests.cs create mode 100644 dotnet/tests/Microsoft.Agents.AI.Hosting.Channels.IntegrationTests/Support/ChatMessageListRunHook.cs create mode 100644 dotnet/tests/Microsoft.Agents.AI.Hosting.Channels.IntegrationTests/Support/FakeFunctionCallingChatClient.cs diff --git a/dotnet/tests/Microsoft.Agents.AI.Hosting.Channels.IntegrationTests/AgentWorkflowTests.cs b/dotnet/tests/Microsoft.Agents.AI.Hosting.Channels.IntegrationTests/AgentWorkflowTests.cs new file mode 100644 index 00000000000..57ee74b010c --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.AI.Hosting.Channels.IntegrationTests/AgentWorkflowTests.cs @@ -0,0 +1,45 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Net; +using System.Net.Http; +using System.Text.Json; +using System.Threading.Tasks; +using Microsoft.Agents.AI.Hosting.Channels.IntegrationTests.Support; +using Microsoft.Agents.AI.Hosting.Channels.Responses; +using Microsoft.Agents.AI.Workflows; +using Microsoft.Extensions.Hosting; + +namespace Microsoft.Agents.AI.Hosting.Channels.IntegrationTests; + +/// +/// Verifies an agent workflow (AgentWorkflowBuilder.BuildSequential over multiple agents) hosted through the +/// Responses channel: the run hook adapts the parsed Responses input into the workflow's ChatMessage-list +/// input, the host's WorkflowRunner drives it, and a completed response is rendered over POST /responses. +/// No live model. +/// +public class AgentWorkflowTests +{ + [Fact] + public async Task SequentialAgentWorkflow_RunsThroughResponsesChannelAsync() + { + // Arrange - two deterministic agents chained sequentially + var workflow = AgentWorkflowBuilder.BuildSequential(new EchoAgent(), new FakeChatAgent()); + + await using var app = await TestHostApp.StartAsync(b => b + .AddAgentFrameworkHost(workflow) + .AddResponsesChannel(o => o.RunHook = new ChatMessageListRunHook())); + + // Act + var response = await app.Client.PostAsync( + new System.Uri("http://localhost/responses"), + Json("{ \"input\": \"hello\" }")); + var body = await response.Content.ReadAsStringAsync(); + + // Assert - the workflow ran to completion and rendered a Responses object + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + using var doc = JsonDocument.Parse(body); + Assert.Equal("completed", doc.RootElement.GetProperty("status").GetString()); + } + + private static StringContent Json(string json) => new(json, System.Text.Encoding.UTF8, "application/json"); +} diff --git a/dotnet/tests/Microsoft.Agents.AI.Hosting.Channels.IntegrationTests/ChannelContractTests.cs b/dotnet/tests/Microsoft.Agents.AI.Hosting.Channels.IntegrationTests/ChannelContractTests.cs index 8d2ea837495..505b25d5ad0 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Hosting.Channels.IntegrationTests/ChannelContractTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Hosting.Channels.IntegrationTests/ChannelContractTests.cs @@ -5,7 +5,6 @@ using System.Threading.Tasks; using Microsoft.Agents.AI.Hosting.Channels.IntegrationTests.Support; using Microsoft.Agents.AI.Hosting.Channels.Responses; -using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Hosting; namespace Microsoft.Agents.AI.Hosting.Channels.IntegrationTests; diff --git a/dotnet/tests/Microsoft.Agents.AI.Hosting.Channels.IntegrationTests/FunctionCallingTests.cs b/dotnet/tests/Microsoft.Agents.AI.Hosting.Channels.IntegrationTests/FunctionCallingTests.cs new file mode 100644 index 00000000000..103b802bbec --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.AI.Hosting.Channels.IntegrationTests/FunctionCallingTests.cs @@ -0,0 +1,77 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Net; +using System.Net.Http; +using System.Text.Json; +using System.Threading.Tasks; +using Microsoft.Agents.AI.Hosting.Channels.IntegrationTests.Support; +using Microsoft.Agents.AI.Hosting.Channels.Responses; +using Microsoft.Extensions.AI; +using Microsoft.Extensions.Hosting; + +namespace Microsoft.Agents.AI.Hosting.Channels.IntegrationTests; + +/// +/// Verifies function-calling support end to end through the Responses channel: a ChatClientAgent built with +/// a tool over a deterministic two-turn fake chat client executes the tool (via the framework's +/// FunctionInvokingChatClient) and renders the final answer over POST /responses. No live model. +/// +public class FunctionCallingTests +{ + [Fact] + public async Task ToolIsInvoked_AndFinalAnswerRenderedAsync() + { + // Arrange - a server-side tool the framework will execute during the function-call loop + var toolCalled = false; + var weatherTool = AIFunctionFactory.Create( + () => { toolCalled = true; return "sunny"; }, + name: "get_weather", + description: "Gets the weather."); + + AIAgent agent = new ChatClientAgent( + new FakeFunctionCallingChatClient(), + instructions: "You are a weather assistant.", + name: "weather", + tools: [weatherTool]); + + await using var app = await TestHostApp.StartAsync(b => b.AddAgentFrameworkHost(agent).AddResponsesChannel()); + + // Act + var response = await app.Client.PostAsync( + new System.Uri("http://localhost/responses"), + Json("{ \"input\": \"What is the weather in Seattle?\" }")); + var body = await response.Content.ReadAsStringAsync(); + + // Assert - tool executed and the post-tool final answer was rendered + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + Assert.True(toolCalled, "the server-side tool should have been invoked by the function-calling loop"); + + using var doc = JsonDocument.Parse(body); + Assert.Equal("completed", doc.RootElement.GetProperty("status").GetString()); + var text = doc.RootElement.GetProperty("output")[0].GetProperty("content")[0].GetProperty("text").GetString(); + Assert.Equal(FakeFunctionCallingChatClient.FinalAnswer, text); + } + + [Fact] + public async Task ToolCalling_StreamsFinalAnswerAsync() + { + // Arrange + var weatherTool = AIFunctionFactory.Create(() => "sunny", name: "get_weather", description: "Gets the weather."); + AIAgent agent = new ChatClientAgent(new FakeFunctionCallingChatClient(), instructions: null, name: "weather", tools: [weatherTool]); + await using var app = await TestHostApp.StartAsync(b => b.AddAgentFrameworkHost(agent).AddResponsesChannel()); + + // Act + var response = await app.Client.PostAsync( + new System.Uri("http://localhost/responses"), + Json("{ \"input\": \"weather in Seattle?\", \"stream\": true }")); + var body = await response.Content.ReadAsStringAsync(); + var frames = Sse.Parse(body); + + // Assert - the final answer (after the tool loop) is delivered over SSE + Assert.Equal("response.created", System.Linq.Enumerable.First(frames.Events())); + Assert.Equal("response.completed", System.Linq.Enumerable.Last(frames.Events())); + Assert.Contains(FakeFunctionCallingChatClient.FinalAnswer, body); + } + + private static StringContent Json(string json) => new(json, System.Text.Encoding.UTF8, "application/json"); +} diff --git a/dotnet/tests/Microsoft.Agents.AI.Hosting.Channels.IntegrationTests/Support/ChatMessageListRunHook.cs b/dotnet/tests/Microsoft.Agents.AI.Hosting.Channels.IntegrationTests/Support/ChatMessageListRunHook.cs new file mode 100644 index 00000000000..e5655a161c6 --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.AI.Hosting.Channels.IntegrationTests/Support/ChatMessageListRunHook.cs @@ -0,0 +1,26 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Extensions.AI; + +namespace Microsoft.Agents.AI.Hosting.Channels.IntegrationTests.Support; + +/// +/// Run hook that materializes the Responses channel's parsed input (an ) +/// into the concrete List<ChatMessage> that a sequential agent workflow expects as its input. +/// +internal sealed class ChatMessageListRunHook : IChannelRunHook +{ + public ValueTask OnRequestAsync(ChannelRequest request, ChannelRunHookContext context, CancellationToken cancellationToken) + { + if (request.Input is IEnumerable messages) + { + return new(request with { Input = messages.ToList() }); + } + + return new(request); + } +} diff --git a/dotnet/tests/Microsoft.Agents.AI.Hosting.Channels.IntegrationTests/Support/CountingAgent.cs b/dotnet/tests/Microsoft.Agents.AI.Hosting.Channels.IntegrationTests/Support/CountingAgent.cs index cdf64d9fe9d..c711141f0fa 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Hosting.Channels.IntegrationTests/Support/CountingAgent.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Hosting.Channels.IntegrationTests/Support/CountingAgent.cs @@ -1,13 +1,11 @@ // Copyright (c) Microsoft. All rights reserved. -using System; using System.Collections.Generic; using System.Globalization; using System.Runtime.CompilerServices; using System.Text.Json; using System.Threading; using System.Threading.Tasks; -using Microsoft.Agents.AI; using Microsoft.Extensions.AI; namespace Microsoft.Agents.AI.Hosting.Channels.IntegrationTests.Support; diff --git a/dotnet/tests/Microsoft.Agents.AI.Hosting.Channels.IntegrationTests/Support/EchoAgent.cs b/dotnet/tests/Microsoft.Agents.AI.Hosting.Channels.IntegrationTests/Support/EchoAgent.cs index 8565bb02ca9..8677da63167 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Hosting.Channels.IntegrationTests/Support/EchoAgent.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Hosting.Channels.IntegrationTests/Support/EchoAgent.cs @@ -1,13 +1,11 @@ // Copyright (c) Microsoft. All rights reserved. -using System; using System.Collections.Generic; using System.Linq; using System.Runtime.CompilerServices; using System.Text.Json; using System.Threading; using System.Threading.Tasks; -using Microsoft.Agents.AI; using Microsoft.Extensions.AI; namespace Microsoft.Agents.AI.Hosting.Channels.IntegrationTests.Support; diff --git a/dotnet/tests/Microsoft.Agents.AI.Hosting.Channels.IntegrationTests/Support/FakeChatAgent.cs b/dotnet/tests/Microsoft.Agents.AI.Hosting.Channels.IntegrationTests/Support/FakeChatAgent.cs index 9374af732c2..fd2a929ea4f 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Hosting.Channels.IntegrationTests/Support/FakeChatAgent.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Hosting.Channels.IntegrationTests/Support/FakeChatAgent.cs @@ -6,7 +6,6 @@ using System.Text.Json; using System.Threading; using System.Threading.Tasks; -using Microsoft.Agents.AI; using Microsoft.Extensions.AI; namespace Microsoft.Agents.AI.Hosting.Channels.IntegrationTests.Support; diff --git a/dotnet/tests/Microsoft.Agents.AI.Hosting.Channels.IntegrationTests/Support/FakeFunctionCallingChatClient.cs b/dotnet/tests/Microsoft.Agents.AI.Hosting.Channels.IntegrationTests/Support/FakeFunctionCallingChatClient.cs new file mode 100644 index 00000000000..9c22cc63c5e --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.AI.Hosting.Channels.IntegrationTests/Support/FakeFunctionCallingChatClient.cs @@ -0,0 +1,70 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Runtime.CompilerServices; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Extensions.AI; + +namespace Microsoft.Agents.AI.Hosting.Channels.IntegrationTests.Support; + +/// +/// Two-turn fake that drives a function-call loop deterministically: +/// turn 1 (no function results yet) emits a for the first advertised +/// tool; turn 2 (function results present) emits the final text answer. Combined with a +/// built with tools (which inserts ), +/// this exercises end-to-end tool execution with no live model. +/// +internal sealed class FakeFunctionCallingChatClient : IChatClient +{ + public const string FinalAnswer = "The weather in Seattle is sunny."; + + public ChatClientMetadata Metadata => new("fake-function-calling"); + + public async Task GetResponseAsync( + IEnumerable messages, + ChatOptions? options = null, + CancellationToken cancellationToken = default) + => await this.GetStreamingResponseAsync(messages, options, cancellationToken).ToChatResponseAsync(cancellationToken).ConfigureAwait(false); + + public async IAsyncEnumerable GetStreamingResponseAsync( + IEnumerable messages, + ChatOptions? options = null, + [EnumeratorCancellation] CancellationToken cancellationToken = default) + { + var messageList = messages.ToList(); + var hasFunctionResults = messageList.Any(m => m.Contents.Any(c => c is FunctionResultContent)); + var messageId = Guid.NewGuid().ToString("N"); + + if (hasFunctionResults) + { + yield return new ChatResponseUpdate(ChatRole.Assistant, FinalAnswer) { MessageId = messageId }; + yield break; + } + + var tool = (options?.Tools ?? []).OfType().FirstOrDefault(); + if (tool is null) + { + yield return new ChatResponseUpdate(ChatRole.Assistant, "No tools available.") { MessageId = messageId }; + yield break; + } + + var callId = Guid.NewGuid().ToString("N"); + yield return new ChatResponseUpdate + { + MessageId = messageId, + Role = ChatRole.Assistant, + Contents = [new FunctionCallContent(callId, tool.Name, new Dictionary())], + }; + + await Task.Yield(); + } + + public object? GetService(Type serviceType, object? serviceKey = null) => null; + + public void Dispose() + { + } +} diff --git a/dotnet/tests/Microsoft.Agents.AI.Hosting.Channels.IntegrationTests/Support/ProbeChannel.cs b/dotnet/tests/Microsoft.Agents.AI.Hosting.Channels.IntegrationTests/Support/ProbeChannel.cs index 45d1decb2f8..efdd66e2411 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Hosting.Channels.IntegrationTests/Support/ProbeChannel.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Hosting.Channels.IntegrationTests/Support/ProbeChannel.cs @@ -1,8 +1,5 @@ // Copyright (c) Microsoft. All rights reserved. -using System; -using System.Collections.Generic; -using System.Threading; using System.Threading.Tasks; using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Http; diff --git a/dotnet/tests/Microsoft.Agents.AI.Hosting.Channels.IntegrationTests/Support/TestHooks.cs b/dotnet/tests/Microsoft.Agents.AI.Hosting.Channels.IntegrationTests/Support/TestHooks.cs index 40cabb96c95..9bd1f3e895a 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Hosting.Channels.IntegrationTests/Support/TestHooks.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Hosting.Channels.IntegrationTests/Support/TestHooks.cs @@ -4,7 +4,6 @@ using System.Runtime.CompilerServices; using System.Threading; using System.Threading.Tasks; -using Microsoft.Agents.AI; using Microsoft.AspNetCore.Http; using Microsoft.Extensions.AI; diff --git a/dotnet/tests/Microsoft.Agents.AI.Hosting.Channels.IntegrationTests/Support/TestHostApp.cs b/dotnet/tests/Microsoft.Agents.AI.Hosting.Channels.IntegrationTests/Support/TestHostApp.cs index 906de316147..6b8075f322b 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Hosting.Channels.IntegrationTests/Support/TestHostApp.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Hosting.Channels.IntegrationTests/Support/TestHostApp.cs @@ -4,7 +4,6 @@ using System.Net.Http; using System.Threading.Tasks; using Microsoft.AspNetCore.Builder; -using Microsoft.AspNetCore.Hosting; using Microsoft.AspNetCore.Hosting.Server; using Microsoft.AspNetCore.TestHost; using Microsoft.Extensions.DependencyInjection; From 7252e0e5c45acd8c28344c2d568e2c0d75528a0a Mon Sep 17 00:00:00 2001 From: Roger Barreto <19890735+RogerBarreto@users.noreply.github.com> Date: Wed, 24 Jun 2026 18:07:31 +0100 Subject: [PATCH 13/16] Hosting.Channels: cover both parameterless and parameterized tools in function-calling tests The earlier function-calling test used only a parameterless tool (after a binding fix). Adds explicit coverage for both shapes through POST /responses: ParameterlessTool_IsInvoked asserts a no-arg tool runs and the post-tool answer is rendered; ParameterizedTool_ReceivesArgument has the fake chat client supply a 'city' argument on the function call and asserts the bound value ('Seattle') actually reached the tool body. FakeFunctionCallingChatClient now accepts an optional argument map. Integration suite is 24 tests, all green, warnaserror-clean and format-clean. --- .../FunctionCallingTests.cs | 36 +++++++++++++++++-- .../Support/FakeFunctionCallingChatClient.cs | 10 +++++- 2 files changed, 43 insertions(+), 3 deletions(-) diff --git a/dotnet/tests/Microsoft.Agents.AI.Hosting.Channels.IntegrationTests/FunctionCallingTests.cs b/dotnet/tests/Microsoft.Agents.AI.Hosting.Channels.IntegrationTests/FunctionCallingTests.cs index 103b802bbec..6047d0a56d2 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Hosting.Channels.IntegrationTests/FunctionCallingTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Hosting.Channels.IntegrationTests/FunctionCallingTests.cs @@ -1,5 +1,6 @@ // Copyright (c) Microsoft. All rights reserved. +using System.Collections.Generic; using System.Net; using System.Net.Http; using System.Text.Json; @@ -19,9 +20,9 @@ namespace Microsoft.Agents.AI.Hosting.Channels.IntegrationTests; public class FunctionCallingTests { [Fact] - public async Task ToolIsInvoked_AndFinalAnswerRenderedAsync() + public async Task ParameterlessTool_IsInvoked_AndFinalAnswerRenderedAsync() { - // Arrange - a server-side tool the framework will execute during the function-call loop + // Arrange - a parameterless server-side tool the framework executes during the function-call loop var toolCalled = false; var weatherTool = AIFunctionFactory.Create( () => { toolCalled = true; return "sunny"; }, @@ -52,6 +53,37 @@ public async Task ToolIsInvoked_AndFinalAnswerRenderedAsync() Assert.Equal(FakeFunctionCallingChatClient.FinalAnswer, text); } + [Fact] + public async Task ParameterizedTool_ReceivesArgument_AndFinalAnswerRenderedAsync() + { + // Arrange - a tool with a required parameter; the fake supplies the argument on the function call + string? capturedCity = null; + var weatherTool = AIFunctionFactory.Create( + (string city) => { capturedCity = city; return $"sunny in {city}"; }, + name: "get_weather", + description: "Gets the weather for a city."); + + var chatClient = new FakeFunctionCallingChatClient(new Dictionary { ["city"] = "Seattle" }); + AIAgent agent = new ChatClientAgent(chatClient, instructions: null, name: "weather", tools: [weatherTool]); + + await using var app = await TestHostApp.StartAsync(b => b.AddAgentFrameworkHost(agent).AddResponsesChannel()); + + // Act + var response = await app.Client.PostAsync( + new System.Uri("http://localhost/responses"), + Json("{ \"input\": \"What is the weather in Seattle?\" }")); + var body = await response.Content.ReadAsStringAsync(); + + // Assert - the argument bound and reached the tool, and the final answer was rendered + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + Assert.Equal("Seattle", capturedCity); + + using var doc = JsonDocument.Parse(body); + Assert.Equal("completed", doc.RootElement.GetProperty("status").GetString()); + var text = doc.RootElement.GetProperty("output")[0].GetProperty("content")[0].GetProperty("text").GetString(); + Assert.Equal(FakeFunctionCallingChatClient.FinalAnswer, text); + } + [Fact] public async Task ToolCalling_StreamsFinalAnswerAsync() { diff --git a/dotnet/tests/Microsoft.Agents.AI.Hosting.Channels.IntegrationTests/Support/FakeFunctionCallingChatClient.cs b/dotnet/tests/Microsoft.Agents.AI.Hosting.Channels.IntegrationTests/Support/FakeFunctionCallingChatClient.cs index 9c22cc63c5e..5d532b1a271 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Hosting.Channels.IntegrationTests/Support/FakeFunctionCallingChatClient.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Hosting.Channels.IntegrationTests/Support/FakeFunctionCallingChatClient.cs @@ -21,6 +21,14 @@ internal sealed class FakeFunctionCallingChatClient : IChatClient { public const string FinalAnswer = "The weather in Seattle is sunny."; + private readonly IReadOnlyDictionary _arguments; + + /// Initializes the fake. are sent on the emitted function call. + public FakeFunctionCallingChatClient(IReadOnlyDictionary? arguments = null) + { + this._arguments = arguments ?? new Dictionary(); + } + public ChatClientMetadata Metadata => new("fake-function-calling"); public async Task GetResponseAsync( @@ -56,7 +64,7 @@ public async IAsyncEnumerable GetStreamingResponseAsync( { MessageId = messageId, Role = ChatRole.Assistant, - Contents = [new FunctionCallContent(callId, tool.Name, new Dictionary())], + Contents = [new FunctionCallContent(callId, tool.Name, new Dictionary(this._arguments))], }; await Task.Yield(); From ce44b0e9276683c425b9dd79671fa9564229fb55 Mon Sep 17 00:00:00 2001 From: Roger Barreto <19890735+RogerBarreto@users.noreply.github.com> Date: Thu, 25 Jun 2026 12:03:40 +0100 Subject: [PATCH 14/16] Hosting.Channels: drop unused System.Linq.AsyncEnumerable package reference --- .../Microsoft.Agents.AI.Hosting.Channels.csproj | 4 ---- 1 file changed, 4 deletions(-) diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting.Channels/Microsoft.Agents.AI.Hosting.Channels.csproj b/dotnet/src/Microsoft.Agents.AI.Hosting.Channels/Microsoft.Agents.AI.Hosting.Channels.csproj index 4c5169ba10b..bc6f155a679 100644 --- a/dotnet/src/Microsoft.Agents.AI.Hosting.Channels/Microsoft.Agents.AI.Hosting.Channels.csproj +++ b/dotnet/src/Microsoft.Agents.AI.Hosting.Channels/Microsoft.Agents.AI.Hosting.Channels.csproj @@ -20,10 +20,6 @@ - - - - From 069679c55eb7bf961c74dae75616e8a40c6871bd Mon Sep 17 00:00:00 2001 From: Roger Barreto <19890735+RogerBarreto@users.noreply.github.com> Date: Thu, 25 Jun 2026 18:28:46 +0100 Subject: [PATCH 15/16] Hosting.Channels samples: use OpenAI SDK directly instead of Azure.AI.OpenAI The Responses-channel samples don't need any Azure-specific features, so depend on the OpenAI SDK directly. Sample 01 now builds its chat client from OPENAI_API_KEY/OPENAI_MODEL via OpenAIClient instead of AzureOpenAIClient + DefaultAzureCredential. Sample 02 (echo workflow) dropped its unused Azure.AI.OpenAI, Azure.Identity, and Microsoft.Agents.AI.OpenAI references. --- .../01_ResponsesAgent.csproj | 3 +-- .../01_ResponsesAgent/Program.cs | 19 +++++++++---------- .../01_ResponsesAgent/README.md | 4 ++-- .../02_ResponsesWorkflow.csproj | 6 ------ 4 files changed, 12 insertions(+), 20 deletions(-) diff --git a/dotnet/samples/04-hosting/HostingChannels/01_ResponsesAgent/01_ResponsesAgent.csproj b/dotnet/samples/04-hosting/HostingChannels/01_ResponsesAgent/01_ResponsesAgent.csproj index f86db164c73..eb9d36b0ba9 100644 --- a/dotnet/samples/04-hosting/HostingChannels/01_ResponsesAgent/01_ResponsesAgent.csproj +++ b/dotnet/samples/04-hosting/HostingChannels/01_ResponsesAgent/01_ResponsesAgent.csproj @@ -11,8 +11,7 @@ - - + diff --git a/dotnet/samples/04-hosting/HostingChannels/01_ResponsesAgent/Program.cs b/dotnet/samples/04-hosting/HostingChannels/01_ResponsesAgent/Program.cs index e8bcb61388a..52cd9f2cdd8 100644 --- a/dotnet/samples/04-hosting/HostingChannels/01_ResponsesAgent/Program.cs +++ b/dotnet/samples/04-hosting/HostingChannels/01_ResponsesAgent/Program.cs @@ -8,23 +8,22 @@ #pragma warning disable CA1031 -using Azure.AI.OpenAI; -using Azure.Identity; +using System.ClientModel; using Microsoft.Agents.AI; using Microsoft.Agents.AI.Hosting.Channels.Responses; using Microsoft.AspNetCore.Builder; using Microsoft.Extensions.Hosting; +using OpenAI; using OpenAI.Chat; -var endpoint = Environment.GetEnvironmentVariable("AZURE_OPENAI_ENDPOINT") - ?? throw new InvalidOperationException("AZURE_OPENAI_ENDPOINT is not set."); -var deployment = Environment.GetEnvironmentVariable("AZURE_OPENAI_DEPLOYMENT_NAME") ?? "gpt-5.4-mini"; +var apiKey = Environment.GetEnvironmentVariable("OPENAI_API_KEY") + ?? throw new InvalidOperationException("OPENAI_API_KEY is not set."); +var model = Environment.GetEnvironmentVariable("OPENAI_MODEL") ?? "gpt-5.4-mini"; -// WARNING: DefaultAzureCredential is convenient for development but requires careful consideration in production. -// In production, consider using a specific credential (e.g., ManagedIdentityCredential) to avoid -// latency issues, unintended credential probing, and potential security risks from fallback mechanisms. -AIAgent agent = new AzureOpenAIClient(new Uri(endpoint), new DefaultAzureCredential()) - .GetChatClient(deployment) +// The hosting channel only needs an OpenAI-compatible chat client, so it depends on the OpenAI SDK +// directly rather than Azure.AI.OpenAI. No Azure-specific features are required here. +AIAgent agent = new OpenAIClient(new ApiKeyCredential(apiKey)) + .GetChatClient(model) .AsAIAgent(name: "Concierge", instructions: "You are a helpful concierge."); var builder = WebApplication.CreateBuilder(args); diff --git a/dotnet/samples/04-hosting/HostingChannels/01_ResponsesAgent/README.md b/dotnet/samples/04-hosting/HostingChannels/01_ResponsesAgent/README.md index 76df1b59f7c..0952d5b9945 100644 --- a/dotnet/samples/04-hosting/HostingChannels/01_ResponsesAgent/README.md +++ b/dotnet/samples/04-hosting/HostingChannels/01_ResponsesAgent/README.md @@ -13,8 +13,8 @@ Exposes an `AIAgent` on the OpenAI Responses-shaped channel from ## Requirements -* `AZURE_OPENAI_ENDPOINT` set, `az login` completed (DefaultAzureCredential) -* `AZURE_OPENAI_DEPLOYMENT_NAME` optional; defaults to `gpt-5.4-mini` +* `OPENAI_API_KEY` set +* `OPENAI_MODEL` optional; defaults to `gpt-5.4-mini` ## Try it diff --git a/dotnet/samples/04-hosting/HostingChannels/02_ResponsesWorkflow/02_ResponsesWorkflow.csproj b/dotnet/samples/04-hosting/HostingChannels/02_ResponsesWorkflow/02_ResponsesWorkflow.csproj index 5f989e46b7e..7bd287e6283 100644 --- a/dotnet/samples/04-hosting/HostingChannels/02_ResponsesWorkflow/02_ResponsesWorkflow.csproj +++ b/dotnet/samples/04-hosting/HostingChannels/02_ResponsesWorkflow/02_ResponsesWorkflow.csproj @@ -11,12 +11,6 @@ - - - - - - From 8a984e0b2ae9f8690de08fcdcede44cbd98d0b49 Mon Sep 17 00:00:00 2001 From: Roger Barreto <19890735+RogerBarreto@users.noreply.github.com> Date: Thu, 25 Jun 2026 18:28:58 +0100 Subject: [PATCH 16/16] Hosting.Channels: convert records to plain classes and trim interfaces Address PR review feedback on the public API shape of the package so it is netstandard2.0-friendly and follows the MAF mutable-class convention. Records -> classes: - Convert all 17 record types to plain classes, dropping the record, init, and required keywords entirely. Non-nullable required members become constructor parameters (get-only); value-type/nullable/defaulted members stay { get; set; }. Replace the 7 'with' expressions with copy constructors on ChannelRequest and ChannelSession. Interface trim (keep only genuine multi-impl / mix-in seams): - Collapse IChannelContext into a sealed ChannelContext class with an internal constructor: the host owns construction and channels only consume it, so the interface added nothing. - Remove the unused IIsolationKeysAccessor and the dead IsolationKeys type (registered but never consumed in the ADR-0027 scope; session isolation is carried by ChannelSession.IsolationKey). - Keep IHostStateStore, IHostedTargetRunner, and the IChannelRunHook / IChannelResponseHook / IChannelStreamTransformHook capability interfaces (the latter are mixed into Channel subclasses) and IAgentFrameworkHostBuilder. Also remove an unused using in ChannelLifecycleService flagged by format. --- .../WorkflowInputRunHook.cs | 2 +- .../ResponsesChannel.cs | 20 ++--- .../Channel.cs | 2 +- .../ChannelCommand.cs | 20 ++++- .../ChannelCommandContext.cs | 26 +++++- .../{Internal => }/ChannelContext.cs | 16 +++- .../ChannelContribution.cs | 22 ++--- .../ChannelIdentity.cs | 23 ++++-- .../ChannelRequest.cs | 80 +++++++++++++------ .../ChannelResponseContext.cs | 21 +++-- .../ChannelRunHookContext.cs | 19 +++-- .../ChannelSession.cs | 33 +++++--- ...icationBuilderHostingChannelsExtensions.cs | 1 - .../HostStatePathOptions.cs | 14 ++-- .../HostedRunResult.cs | 23 ++++-- .../HostedStreamItem.cs | 43 ++++++++-- .../IChannelContext.cs | 30 ------- .../Internal/ChannelLifecycleService.cs | 1 - .../IsolationKeys.cs | 46 ----------- .../Runners/AIAgentRunner.cs | 7 +- .../Runners/WorkflowRunner.cs | 6 +- .../WorkflowRunResult.cs | 22 ++--- .../Support/ChatMessageListRunHook.cs | 2 +- .../Support/ProbeChannel.cs | 6 +- .../Support/TestHooks.cs | 6 +- .../ChannelRequestTests.cs | 2 +- .../HostCompositionTests.cs | 4 +- .../WorkflowRunnerTests.cs | 2 +- 28 files changed, 291 insertions(+), 208 deletions(-) rename dotnet/src/Microsoft.Agents.AI.Hosting.Channels/{Internal => }/ChannelContext.cs (51%) delete mode 100644 dotnet/src/Microsoft.Agents.AI.Hosting.Channels/IChannelContext.cs delete mode 100644 dotnet/src/Microsoft.Agents.AI.Hosting.Channels/IsolationKeys.cs diff --git a/dotnet/samples/04-hosting/HostingChannels/02_ResponsesWorkflow/WorkflowInputRunHook.cs b/dotnet/samples/04-hosting/HostingChannels/02_ResponsesWorkflow/WorkflowInputRunHook.cs index 36906de4c99..c47af04c4c8 100644 --- a/dotnet/samples/04-hosting/HostingChannels/02_ResponsesWorkflow/WorkflowInputRunHook.cs +++ b/dotnet/samples/04-hosting/HostingChannels/02_ResponsesWorkflow/WorkflowInputRunHook.cs @@ -24,6 +24,6 @@ public ValueTask OnRequestAsync(ChannelRequest request, ChannelR _ => request.Input.ToString() ?? string.Empty, }; - return new(request with { Input = text }); + return new(new ChannelRequest(request) { Input = text }); } } \ No newline at end of file diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting.Channels.Responses/ResponsesChannel.cs b/dotnet/src/Microsoft.Agents.AI.Hosting.Channels.Responses/ResponsesChannel.cs index bbadf5dbc60..cba3e710c53 100644 --- a/dotnet/src/Microsoft.Agents.AI.Hosting.Channels.Responses/ResponsesChannel.cs +++ b/dotnet/src/Microsoft.Agents.AI.Hosting.Channels.Responses/ResponsesChannel.cs @@ -37,7 +37,7 @@ public ResponsesChannel(ResponsesChannelOptions options) public override string Path => this._options.Path; /// - public override ChannelContribution Contribute(IChannelContext context) + public override ChannelContribution Contribute(ChannelContext context) { Throw.IfNull(context); return new ChannelContribution @@ -46,7 +46,7 @@ public override ChannelContribution Contribute(IChannelContext context) }; } - private async Task HandleAsync(IChannelContext context, HttpContext http) + private async Task HandleAsync(ChannelContext context, HttpContext http) { ResponsesRequestModel? body; try @@ -72,18 +72,18 @@ private async Task HandleAsync(IChannelContext context, HttpContext http) return; } - var request = new ChannelRequest + var request = new ChannelRequest( + this.Name, + "message.create", + messages) { - Channel = this.Name, - Operation = "message.create", - Input = messages, Stream = body.Stream, Session = body.PreviousResponseId is null ? null : new ChannelSession { Key = body.PreviousResponseId }, }; if (this._options.RunHook is not null) { - var hookContext = new ChannelRunHookContext { Target = context.Host, ProtocolRequest = body }; + var hookContext = new ChannelRunHookContext(context.Host) { ProtocolRequest = body }; request = await this._options.RunHook.OnRequestAsync(request, hookContext, http.RequestAborted).ConfigureAwait(false); } @@ -104,7 +104,7 @@ private async Task HandleAsync(IChannelContext context, HttpContext http) } } - private async Task WriteJsonResponseAsync(IChannelContext context, ChannelRequest request, string? model, HttpContext http) + private async Task WriteJsonResponseAsync(ChannelContext context, ChannelRequest request, string? model, HttpContext http) { var result = await context.RunAsync(request, http.RequestAborted).ConfigureAwait(false); result = await this.ApplyResponseHookAsync(result, request, http.RequestAborted).ConfigureAwait(false); @@ -117,7 +117,7 @@ private async Task WriteJsonResponseAsync(IChannelContext context, ChannelReques await JsonSerializer.SerializeAsync(http.Response.Body, response, ResponsesJsonContext.Default.ResponsesResponseModel, http.RequestAborted).ConfigureAwait(false); } - private async Task WriteStreamAsync(IChannelContext context, ChannelRequest request, string? model, HttpContext http) + private async Task WriteStreamAsync(ChannelContext context, ChannelRequest request, string? model, HttpContext http) { http.Response.StatusCode = StatusCodes.Status200OK; http.Response.ContentType = "text/event-stream"; @@ -168,7 +168,7 @@ private async ValueTask ApplyResponseHookAsync(HostedRunResult { return result; } - var ctx = new ChannelResponseContext { Request = request, ChannelName = this.Name }; + var ctx = new ChannelResponseContext(request, this.Name); return await this._options.ResponseHook.OnResponseAsync(result, ctx, cancellationToken).ConfigureAwait(false); } diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting.Channels/Channel.cs b/dotnet/src/Microsoft.Agents.AI.Hosting.Channels/Channel.cs index 3b810358fcb..def9a302667 100644 --- a/dotnet/src/Microsoft.Agents.AI.Hosting.Channels/Channel.cs +++ b/dotnet/src/Microsoft.Agents.AI.Hosting.Channels/Channel.cs @@ -30,5 +30,5 @@ public virtual void ConfigureServices(IServiceCollection services) } /// Returns the channel's contribution (routes, commands, lifecycle hooks, endpoint filters). Runs post-Build. - public abstract ChannelContribution Contribute(IChannelContext context); + public abstract ChannelContribution Contribute(ChannelContext context); } diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting.Channels/ChannelCommand.cs b/dotnet/src/Microsoft.Agents.AI.Hosting.Channels/ChannelCommand.cs index 3fd0d35cf55..3a9f404e1b6 100644 --- a/dotnet/src/Microsoft.Agents.AI.Hosting.Channels/ChannelCommand.cs +++ b/dotnet/src/Microsoft.Agents.AI.Hosting.Channels/ChannelCommand.cs @@ -7,6 +7,20 @@ namespace Microsoft.Agents.AI.Hosting.Channels; /// application command, ...). Channels emit these from as /// passive metadata; native registration with the protocol is the channel's responsibility. /// -/// The command name without any leading sentinel (e.g. "new" not "/new"). -/// Short description surfaced in the protocol's UI. -public sealed record ChannelCommand(string Name, string Description); +public sealed class ChannelCommand +{ + /// Gets the command name without any leading sentinel (e.g. "new" not "/new"). + public string Name { get; } + + /// Gets the short description surfaced in the protocol's UI. + public string Description { get; } + + /// Initializes a new instance of . + /// The command name without any leading sentinel (e.g. "new" not "/new"). + /// Short description surfaced in the protocol's UI. + public ChannelCommand(string name, string description) + { + this.Name = name; + this.Description = description; + } +} diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting.Channels/ChannelCommandContext.cs b/dotnet/src/Microsoft.Agents.AI.Hosting.Channels/ChannelCommandContext.cs index 8cade776d46..5ce585ff793 100644 --- a/dotnet/src/Microsoft.Agents.AI.Hosting.Channels/ChannelCommandContext.cs +++ b/dotnet/src/Microsoft.Agents.AI.Hosting.Channels/ChannelCommandContext.cs @@ -6,7 +6,25 @@ namespace Microsoft.Agents.AI.Hosting.Channels; /// Context passed to a channel command handler when the host dispatches a recognized /// . Carries the originating request and the parsed argument string. /// -/// The originating channel request. -/// The matched command. -/// The raw argument text following the command, or . -public sealed record ChannelCommandContext(ChannelRequest Request, ChannelCommand Command, string? Arguments); +public sealed class ChannelCommandContext +{ + /// Gets the originating channel request. + public ChannelRequest Request { get; } + + /// Gets the matched command. + public ChannelCommand Command { get; } + + /// Gets the raw argument text following the command, or . + public string? Arguments { get; } + + /// Initializes a new instance of . + /// The originating channel request. + /// The matched command. + /// The raw argument text following the command, or . + public ChannelCommandContext(ChannelRequest request, ChannelCommand command, string? arguments) + { + this.Request = request; + this.Command = command; + this.Arguments = arguments; + } +} diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting.Channels/Internal/ChannelContext.cs b/dotnet/src/Microsoft.Agents.AI.Hosting.Channels/ChannelContext.cs similarity index 51% rename from dotnet/src/Microsoft.Agents.AI.Hosting.Channels/Internal/ChannelContext.cs rename to dotnet/src/Microsoft.Agents.AI.Hosting.Channels/ChannelContext.cs index 998496379ed..476aed9629f 100644 --- a/dotnet/src/Microsoft.Agents.AI.Hosting.Channels/Internal/ChannelContext.cs +++ b/dotnet/src/Microsoft.Agents.AI.Hosting.Channels/ChannelContext.cs @@ -8,21 +8,33 @@ namespace Microsoft.Agents.AI.Hosting.Channels; -internal sealed class ChannelContext : IChannelContext +/// +/// Handed to . Exposes the host's run / stream surface plus the host and +/// its state store. The host owns construction; channels consume it (they never create or derive from it), +/// so this is a sealed concrete type rather than an interface. +/// +public sealed class ChannelContext { - public ChannelContext(IServiceProvider services, AgentFrameworkHost host) + internal ChannelContext(IServiceProvider services, AgentFrameworkHost host) { this.Services = Throw.IfNull(services); this.Host = Throw.IfNull(host); } + /// Gets the application service provider. public IServiceProvider Services { get; } + + /// Gets the host this channel was added to. public AgentFrameworkHost Host { get; } + + /// Gets the host state store. public IHostStateStore StateStore => this.Host.StateStore; + /// Runs the host target and returns the (non-streaming) result. public ValueTask RunAsync(ChannelRequest request, CancellationToken cancellationToken = default) => this.Host.RunAsync(request, cancellationToken); + /// Streams the host target's response as envelopes. public IAsyncEnumerable StreamAsync(ChannelRequest request, CancellationToken cancellationToken = default) => this.Host.StreamAsync(request, cancellationToken); } diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting.Channels/ChannelContribution.cs b/dotnet/src/Microsoft.Agents.AI.Hosting.Channels/ChannelContribution.cs index abc6c82d170..9dc72f82e1a 100644 --- a/dotnet/src/Microsoft.Agents.AI.Hosting.Channels/ChannelContribution.cs +++ b/dotnet/src/Microsoft.Agents.AI.Hosting.Channels/ChannelContribution.cs @@ -14,27 +14,27 @@ namespace Microsoft.Agents.AI.Hosting.Channels; /// Returned by . Carries the routes, commands, endpoint filters, /// and lifecycle hooks the channel publishes to the running host. /// -public sealed record ChannelContribution +public sealed class ChannelContribution { /// - /// Route registration actions. The host invokes each one with an + /// Gets or sets the route registration actions. The host invokes each one with an /// rooted at via ' /// group semantics, so map paths relative to . /// - public IReadOnlyList> Routes { get; init; } = []; + public IReadOnlyList> Routes { get; set; } = []; /// - /// Endpoint filters applied to the -rooted group. Replaces Python's + /// Gets or sets the endpoint filters applied to the -rooted group. Replaces Python's /// middleware slot. /// - public IReadOnlyList EndpointFilters { get; init; } = []; + public IReadOnlyList EndpointFilters { get; set; } = []; - /// Declarative commands; channels read these and call the protocol's native registration. - public IReadOnlyList Commands { get; init; } = []; + /// Gets or sets the declarative commands; channels read these and call the protocol's native registration. + public IReadOnlyList Commands { get; set; } = []; - /// Optional startup hook invoked once after DI is built. Useful for long-poll loops. - public Func? OnStartup { get; init; } + /// Gets or sets the optional startup hook invoked once after DI is built. Useful for long-poll loops. + public Func? OnStartup { get; set; } - /// Optional shutdown hook invoked during graceful shutdown. - public Func? OnShutdown { get; init; } + /// Gets or sets the optional shutdown hook invoked during graceful shutdown. + public Func? OnShutdown { get; set; } } diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting.Channels/ChannelIdentity.cs b/dotnet/src/Microsoft.Agents.AI.Hosting.Channels/ChannelIdentity.cs index 94e91c5e2be..67deed0321a 100644 --- a/dotnet/src/Microsoft.Agents.AI.Hosting.Channels/ChannelIdentity.cs +++ b/dotnet/src/Microsoft.Agents.AI.Hosting.Channels/ChannelIdentity.cs @@ -8,13 +8,26 @@ namespace Microsoft.Agents.AI.Hosting.Channels; /// /// Channel-native user identity observed on a . /// -/// The originating channel name (matches ). -/// The channel-native USER identifier (never the chat or conversation id). -public sealed record ChannelIdentity(string Channel, string NativeId) +public sealed class ChannelIdentity { + /// Gets the originating channel name (matches ). + public string Channel { get; } + + /// Gets the channel-native USER identifier (never the chat or conversation id). + public string NativeId { get; } + /// - /// Channel-defined attributes attached to this identity (e.g. display name, language). + /// Gets or sets the channel-defined attributes attached to this identity (e.g. display name, language). /// - public IReadOnlyDictionary Attributes { get; init; } = + public IReadOnlyDictionary Attributes { get; set; } = ImmutableDictionary.Empty; + + /// Initializes a new instance of . + /// The originating channel name (matches ). + /// The channel-native USER identifier (never the chat or conversation id). + public ChannelIdentity(string channel, string nativeId) + { + this.Channel = channel; + this.NativeId = nativeId; + } } diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting.Channels/ChannelRequest.cs b/dotnet/src/Microsoft.Agents.AI.Hosting.Channels/ChannelRequest.cs index 621b63aef34..991f4fe7f3d 100644 --- a/dotnet/src/Microsoft.Agents.AI.Hosting.Channels/ChannelRequest.cs +++ b/dotnet/src/Microsoft.Agents.AI.Hosting.Channels/ChannelRequest.cs @@ -3,48 +3,78 @@ using System.Collections.Generic; using System.Collections.Immutable; using Microsoft.Extensions.AI; +using Microsoft.Shared.Diagnostics; namespace Microsoft.Agents.AI.Hosting.Channels; /// -/// Channel-neutral request envelope handed to / -/// . Channels build this from their wire format. +/// Channel-neutral request envelope handed to / +/// . Channels build this from their wire format. /// -public sealed record ChannelRequest +public sealed class ChannelRequest { - /// Originating channel name (matches ). - public required string Channel { get; init; } + /// Gets the originating channel name (matches ). + public string Channel { get; } - /// Operation kind: "message.create", "command.invoke", ... - public required string Operation { get; init; } + /// Gets the operation kind: "message.create", "command.invoke", ... + public string Operation { get; } - /// Target input: string, , sequence, or workflow input. - public required object Input { get; init; } + /// Gets or sets the target input: string, , sequence, or workflow input. + public object Input { get; set; } - /// Session hint. for ephemeral requests. - public ChannelSession? Session { get; init; } + /// Gets or sets the session hint. for ephemeral requests. + public ChannelSession? Session { get; set; } - /// Channel-native user identity. Request metadata only; not a linking, authorization, or delivery key. - public ChannelIdentity? Identity { get; init; } + /// Gets or sets the channel-native user identity. Request metadata only; not a linking, authorization, or delivery key. + public ChannelIdentity? Identity { get; set; } - /// Protocol-visible conversation / thread id, when distinct from . - public string? ConversationId { get; init; } + /// Gets or sets the protocol-visible conversation / thread id, when distinct from . + public string? ConversationId { get; set; } - /// Caller-derived chat options forwarded onto the runner's . - public ChatOptions? Options { get; init; } + /// Gets or sets the caller-derived chat options forwarded onto the runner's . + public ChatOptions? Options { get; set; } - /// How the host resolves session continuity for this request. - public SessionMode SessionMode { get; init; } = SessionMode.Auto; + /// Gets or sets how the host resolves session continuity for this request. + public SessionMode SessionMode { get; set; } = SessionMode.Auto; - /// Protocol-level metadata for telemetry. The host never reads this. - public IReadOnlyDictionary Metadata { get; init; } = ImmutableDictionary.Empty; + /// Gets or sets the protocol-level metadata for telemetry. The host never reads this. + public IReadOnlyDictionary Metadata { get; set; } = ImmutableDictionary.Empty; /// - /// Channel-specific structured values surfaced to the run hook. Reserved key for workflow targets: + /// Gets or sets the channel-specific structured values surfaced to the run hook. Reserved key for workflow targets: /// "workflow.checkpoint_id" (caller-supplied checkpoint resume). /// - public IReadOnlyDictionary Attributes { get; init; } = ImmutableDictionary.Empty; + public IReadOnlyDictionary Attributes { get; set; } = ImmutableDictionary.Empty; - /// Whether the channel is calling rather than . - public bool Stream { get; init; } + /// Gets or sets a value indicating whether the channel is calling rather than . + public bool Stream { get; set; } + + /// Initializes a new instance of . + /// Originating channel name (matches ). + /// Operation kind: "message.create", "command.invoke", ... + /// Target input: string, , sequence, or workflow input. + public ChannelRequest(string channel, string operation, object input) + { + this.Channel = Throw.IfNullOrEmpty(channel); + this.Operation = Throw.IfNullOrEmpty(operation); + this.Input = Throw.IfNull(input); + } + + /// Initializes a new instance of by copying an existing instance. + /// The instance to copy. + public ChannelRequest(ChannelRequest other) + { + Throw.IfNull(other); + this.Channel = other.Channel; + this.Operation = other.Operation; + this.Input = other.Input; + this.Session = other.Session; + this.Identity = other.Identity; + this.ConversationId = other.ConversationId; + this.Options = other.Options; + this.SessionMode = other.SessionMode; + this.Metadata = other.Metadata; + this.Attributes = other.Attributes; + this.Stream = other.Stream; + } } diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting.Channels/ChannelResponseContext.cs b/dotnet/src/Microsoft.Agents.AI.Hosting.Channels/ChannelResponseContext.cs index 9388e86565c..cc00e16e240 100644 --- a/dotnet/src/Microsoft.Agents.AI.Hosting.Channels/ChannelResponseContext.cs +++ b/dotnet/src/Microsoft.Agents.AI.Hosting.Channels/ChannelResponseContext.cs @@ -1,16 +1,27 @@ // Copyright (c) Microsoft. All rights reserved. +using Microsoft.Shared.Diagnostics; + namespace Microsoft.Agents.AI.Hosting.Channels; /// /// Context passed to . Runs after target invocation and /// before the originating channel serializes its response. /// -public sealed record ChannelResponseContext +public sealed class ChannelResponseContext { - /// The originating request. - public required ChannelRequest Request { get; init; } + /// Gets the originating request. + public ChannelRequest Request { get; } + + /// Gets the originating channel name. + public string ChannelName { get; } - /// The originating channel name. - public required string ChannelName { get; init; } + /// Initializes a new instance of . + /// The originating request. + /// The originating channel name. + public ChannelResponseContext(ChannelRequest request, string channelName) + { + this.Request = Throw.IfNull(request); + this.ChannelName = Throw.IfNullOrEmpty(channelName); + } } diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting.Channels/ChannelRunHookContext.cs b/dotnet/src/Microsoft.Agents.AI.Hosting.Channels/ChannelRunHookContext.cs index 90e2531785b..95cb0c759f0 100644 --- a/dotnet/src/Microsoft.Agents.AI.Hosting.Channels/ChannelRunHookContext.cs +++ b/dotnet/src/Microsoft.Agents.AI.Hosting.Channels/ChannelRunHookContext.cs @@ -1,15 +1,24 @@ // Copyright (c) Microsoft. All rights reserved. +using Microsoft.Shared.Diagnostics; + namespace Microsoft.Agents.AI.Hosting.Channels; /// /// Per-call context passed to . /// -public sealed record ChannelRunHookContext +public sealed class ChannelRunHookContext { - /// The runner target: an , a workflow, or a hosted-agent handle. - public required object Target { get; init; } + /// Gets the runner target: an , a workflow, or a hosted-agent handle. + public object Target { get; } + + /// Gets or sets the raw inbound payload as it arrived on the wire. Loosely typed. + public object? ProtocolRequest { get; set; } - /// The raw inbound payload as it arrived on the wire. Loosely typed. - public object? ProtocolRequest { get; init; } + /// Initializes a new instance of . + /// The runner target: an , a workflow, or a hosted-agent handle. + public ChannelRunHookContext(object target) + { + this.Target = Throw.IfNull(target); + } } diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting.Channels/ChannelSession.cs b/dotnet/src/Microsoft.Agents.AI.Hosting.Channels/ChannelSession.cs index 92d961722be..6c86b6ae026 100644 --- a/dotnet/src/Microsoft.Agents.AI.Hosting.Channels/ChannelSession.cs +++ b/dotnet/src/Microsoft.Agents.AI.Hosting.Channels/ChannelSession.cs @@ -2,6 +2,7 @@ using System.Collections.Generic; using System.Collections.Immutable; +using Microsoft.Shared.Diagnostics; namespace Microsoft.Agents.AI.Hosting.Channels; @@ -9,22 +10,36 @@ namespace Microsoft.Agents.AI.Hosting.Channels; /// Session hint carried on a . All fields nullable to support /// caller-supplied (Responses, Invocations) and host-tracked (Telegram, Activity Protocol) channels. /// -public sealed record ChannelSession +public sealed class ChannelSession { /// - /// Stable host lookup key for an . Caller-supplied channels populate + /// Gets or sets the stable host lookup key for an . Caller-supplied channels populate /// from the wire (previous_response_id, conversation_id); host-tracked channels /// leave this and let the per-isolation-key alias decide. /// - public string? Key { get; init; } + public string? Key { get; set; } - /// The protocol-visible conversation or thread identifier when one exists. - public string? ConversationId { get; init; } + /// Gets or sets the protocol-visible conversation or thread identifier when one exists. + public string? ConversationId { get; set; } - /// Opaque isolation boundary (user, tenant, chat, ...) using hosted-agent terminology. - public string? IsolationKey { get; init; } + /// Gets or sets the opaque isolation boundary (user, tenant, chat, ...) using hosted-agent terminology. + public string? IsolationKey { get; set; } - /// Channel-defined attributes; not interpreted by the host. - public IReadOnlyDictionary Attributes { get; init; } = + /// Gets or sets the channel-defined attributes; not interpreted by the host. + public IReadOnlyDictionary Attributes { get; set; } = ImmutableDictionary.Empty; + + /// Initializes a new instance of . + public ChannelSession() { } + + /// Initializes a new instance of by copying an existing instance. + /// The instance to copy. + public ChannelSession(ChannelSession other) + { + Throw.IfNull(other); + this.Key = other.Key; + this.ConversationId = other.ConversationId; + this.IsolationKey = other.IsolationKey; + this.Attributes = other.Attributes; + } } diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting.Channels/HostApplicationBuilderHostingChannelsExtensions.cs b/dotnet/src/Microsoft.Agents.AI.Hosting.Channels/HostApplicationBuilderHostingChannelsExtensions.cs index 5489f193ea4..16802419117 100644 --- a/dotnet/src/Microsoft.Agents.AI.Hosting.Channels/HostApplicationBuilderHostingChannelsExtensions.cs +++ b/dotnet/src/Microsoft.Agents.AI.Hosting.Channels/HostApplicationBuilderHostingChannelsExtensions.cs @@ -69,7 +69,6 @@ private static AgentFrameworkHostBuilder AddCore( services.TryAddSingleton(_ => new InMemoryHostStateStore()); } - services.TryAddSingleton(); services.TryAddSingleton(); services.AddHostedService(); diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting.Channels/HostStatePathOptions.cs b/dotnet/src/Microsoft.Agents.AI.Hosting.Channels/HostStatePathOptions.cs index f992f064173..9261811a4e9 100644 --- a/dotnet/src/Microsoft.Agents.AI.Hosting.Channels/HostStatePathOptions.cs +++ b/dotnet/src/Microsoft.Agents.AI.Hosting.Channels/HostStatePathOptions.cs @@ -7,14 +7,14 @@ namespace Microsoft.Agents.AI.Hosting.Channels; /// derive from when unset. v1 owns only reset-session aliases and workflow checkpoint /// path derivation. /// -public sealed record HostStatePathOptions +public sealed class HostStatePathOptions { - /// Root directory under which per-component subpaths are derived. - public string? Root { get; init; } + /// Gets or sets the root directory under which per-component subpaths are derived. + public string? Root { get; set; } - /// Path for reset-session aliases. - public string? AliasesPath { get; init; } + /// Gets or sets the path for reset-session aliases. + public string? AliasesPath { get; set; } - /// Root path for per-isolation-key workflow checkpoint derivation. - public string? CheckpointsPath { get; init; } + /// Gets or sets the root path for per-isolation-key workflow checkpoint derivation. + public string? CheckpointsPath { get; set; } } diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting.Channels/HostedRunResult.cs b/dotnet/src/Microsoft.Agents.AI.Hosting.Channels/HostedRunResult.cs index ec3810218bd..d485ae406ad 100644 --- a/dotnet/src/Microsoft.Agents.AI.Hosting.Channels/HostedRunResult.cs +++ b/dotnet/src/Microsoft.Agents.AI.Hosting.Channels/HostedRunResult.cs @@ -7,12 +7,12 @@ namespace Microsoft.Agents.AI.Hosting.Channels; /// interfaces without committing to a result type. Inspect /// or cast to for typed access. /// -public abstract record HostedRunResult +public abstract class HostedRunResult { - /// Session attached to this result, when present. - public ChannelSession? Session { get; init; } + /// Gets or sets the session attached to this result, when present. + public ChannelSession? Session { get; set; } - /// The wrapped result as a boxed object. + /// Gets the wrapped result as a boxed object. public abstract object? ResultObject { get; } } @@ -20,18 +20,25 @@ public abstract record HostedRunResult /// Typed run result envelope. is AgentRunResponse for /// agent targets and WorkflowRunResponse for workflow targets. /// -public sealed record HostedRunResult : HostedRunResult +public sealed class HostedRunResult : HostedRunResult { - /// The typed result. - public required TResult Result { get; init; } + /// Gets the typed result. + public TResult Result { get; } /// public override object? ResultObject => this.Result; + /// Initializes a new instance of . + /// The typed result. + public HostedRunResult(TResult result) + { + this.Result = result; + } + /// /// Shallow clone with a rewritten ; used by per-destination /// rebinds. /// public HostedRunResult Replace(TNew newResult) => - new() { Result = newResult, Session = this.Session }; + new(newResult) { Session = this.Session }; } diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting.Channels/HostedStreamItem.cs b/dotnet/src/Microsoft.Agents.AI.Hosting.Channels/HostedStreamItem.cs index ff46976b53b..b5f4083a016 100644 --- a/dotnet/src/Microsoft.Agents.AI.Hosting.Channels/HostedStreamItem.cs +++ b/dotnet/src/Microsoft.Agents.AI.Hosting.Channels/HostedStreamItem.cs @@ -3,7 +3,7 @@ namespace Microsoft.Agents.AI.Hosting.Channels; /// -/// Base type for items yielded by . +/// Base type for items yielded by . /// Covers both normalized agent updates () and protocol-specific /// events () behind one stream type. /// @@ -12,16 +12,49 @@ namespace Microsoft.Agents.AI.Hosting.Channels; /// for downstream bookkeeping (intended-targets envelope, /// durable push scheduling). /// -public abstract record HostedStreamItem +public abstract class HostedStreamItem { private protected HostedStreamItem() { } } /// Normalized agent stream update; lossless for messages, function calls, usage. -public sealed record HostedStreamUpdate(AgentResponseUpdate Update) : HostedStreamItem; +public sealed class HostedStreamUpdate : HostedStreamItem +{ + /// Gets the agent response update. + public AgentResponseUpdate Update { get; } + + /// Initializes a new instance of . + /// The agent response update. + public HostedStreamUpdate(AgentResponseUpdate update) + { + this.Update = update; + } +} /// Protocol-specific event the framework does not model (workflow events, AG-UI events). -public sealed record HostedStreamEvent(object Event) : HostedStreamItem; +public sealed class HostedStreamEvent : HostedStreamItem +{ + /// Gets the protocol-specific event. + public object Event { get; } + + /// Initializes a new instance of . + /// The protocol-specific event. + public HostedStreamEvent(object @event) + { + this.Event = @event; + } +} /// Terminal item carrying the final result for post-stream bookkeeping. -public sealed record HostedStreamCompleted(HostedRunResult Result) : HostedStreamItem; +public sealed class HostedStreamCompleted : HostedStreamItem +{ + /// Gets the final run result. + public HostedRunResult Result { get; } + + /// Initializes a new instance of . + /// The final run result. + public HostedStreamCompleted(HostedRunResult result) + { + this.Result = result; + } +} diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting.Channels/IChannelContext.cs b/dotnet/src/Microsoft.Agents.AI.Hosting.Channels/IChannelContext.cs deleted file mode 100644 index 9c6da89be11..00000000000 --- a/dotnet/src/Microsoft.Agents.AI.Hosting.Channels/IChannelContext.cs +++ /dev/null @@ -1,30 +0,0 @@ -// Copyright (c) Microsoft. All rights reserved. - -using System; -using System.Collections.Generic; -using System.Threading; -using System.Threading.Tasks; - -namespace Microsoft.Agents.AI.Hosting.Channels; - -/// -/// Handed to . Exposes the host's run / stream surface plus the host and -/// its state store. -/// -public interface IChannelContext -{ - /// Application service provider. - IServiceProvider Services { get; } - - /// The host this channel was added to. - AgentFrameworkHost Host { get; } - - /// The host state store. - IHostStateStore StateStore { get; } - - /// Run the host target and return the (non-streaming) result. - ValueTask RunAsync(ChannelRequest request, CancellationToken cancellationToken = default); - - /// Stream the host target's response as envelopes. - IAsyncEnumerable StreamAsync(ChannelRequest request, CancellationToken cancellationToken = default); -} diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting.Channels/Internal/ChannelLifecycleService.cs b/dotnet/src/Microsoft.Agents.AI.Hosting.Channels/Internal/ChannelLifecycleService.cs index 30d09316a32..707219d2b9e 100644 --- a/dotnet/src/Microsoft.Agents.AI.Hosting.Channels/Internal/ChannelLifecycleService.cs +++ b/dotnet/src/Microsoft.Agents.AI.Hosting.Channels/Internal/ChannelLifecycleService.cs @@ -1,6 +1,5 @@ // Copyright (c) Microsoft. All rights reserved. -using System.Collections.Generic; using System.Threading; using System.Threading.Tasks; using Microsoft.Extensions.Hosting; diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting.Channels/IsolationKeys.cs b/dotnet/src/Microsoft.Agents.AI.Hosting.Channels/IsolationKeys.cs deleted file mode 100644 index b35a01c9d2c..00000000000 --- a/dotnet/src/Microsoft.Agents.AI.Hosting.Channels/IsolationKeys.cs +++ /dev/null @@ -1,46 +0,0 @@ -// Copyright (c) Microsoft. All rights reserved. - -using System.Threading; - -namespace Microsoft.Agents.AI.Hosting.Channels; - -/// -/// Per-request partition hints carried via , lifted from -/// x-agent-user-isolation-key / x-agent-chat-isolation-key headers by host middleware only -/// when the Foundry hosting environment flag is present. Distinct from the app-level -/// . Reusing the header names does not make this the supported -/// Foundry Hosted Agents surface. -/// -public sealed record IsolationKeys(string? UserKey, string? ChatKey) -{ - /// The async-local slot. - public static AsyncLocal CurrentSlot { get; } = new(); - - /// The current per-request value, if any. - public static IsolationKeys? Current - { - get => CurrentSlot.Value; - set => CurrentSlot.Value = value; - } - - /// True when both keys are . - public bool IsEmpty => this.UserKey is null && this.ChatKey is null; - - /// Header name for the user key. - public const string UserHeader = "x-agent-user-isolation-key"; - - /// Header name for the chat key. - public const string ChatHeader = "x-agent-chat-isolation-key"; -} - -/// DI wrapper around for testability. -public interface IIsolationKeysAccessor -{ - /// Returns the current per-request keys. - IsolationKeys? Current { get; } -} - -internal sealed class IsolationKeysAccessor : IIsolationKeysAccessor -{ - public IsolationKeys? Current => IsolationKeys.Current; -} diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting.Channels/Runners/AIAgentRunner.cs b/dotnet/src/Microsoft.Agents.AI.Hosting.Channels/Runners/AIAgentRunner.cs index a6f7b77a410..cf760d315f7 100644 --- a/dotnet/src/Microsoft.Agents.AI.Hosting.Channels/Runners/AIAgentRunner.cs +++ b/dotnet/src/Microsoft.Agents.AI.Hosting.Channels/Runners/AIAgentRunner.cs @@ -43,9 +43,8 @@ public async ValueTask RunAsync(ChannelRequest request, Cancell var messages = CoerceToMessages(request.Input); var session = await this.ResolveSessionAsync(request, cancellationToken).ConfigureAwait(false); var response = await this._agent.RunAsync(messages, session, options: null, cancellationToken).ConfigureAwait(false); - return new HostedRunResult + return new HostedRunResult(response) { - Result = response, Session = request.Session, }; } @@ -67,8 +66,8 @@ public async IAsyncEnumerable StreamAsync( } var aggregate = final is null - ? new HostedRunResult { Result = null, Session = request.Session } - : (HostedRunResult)new HostedRunResult { Result = final, Session = request.Session }; + ? new HostedRunResult(null) { Session = request.Session } + : (HostedRunResult)new HostedRunResult(final) { Session = request.Session }; yield return new HostedStreamCompleted(aggregate); } diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting.Channels/Runners/WorkflowRunner.cs b/dotnet/src/Microsoft.Agents.AI.Hosting.Channels/Runners/WorkflowRunner.cs index ffb689dd6c6..44978546824 100644 --- a/dotnet/src/Microsoft.Agents.AI.Hosting.Channels/Runners/WorkflowRunner.cs +++ b/dotnet/src/Microsoft.Agents.AI.Hosting.Channels/Runners/WorkflowRunner.cs @@ -58,7 +58,7 @@ public async ValueTask RunAsync(ChannelRequest request, Cancell } } - var session = (request.Session ?? new ChannelSession()) with { Key = run.SessionId }; + var session = new ChannelSession(request.Session ?? new ChannelSession()) { Key = run.SessionId }; var result = pending is not null ? new WorkflowRunResult { Status = WorkflowRunStatus.AwaitingInput, PendingRequest = pending, Outputs = outputs, SessionId = run.SessionId } : new WorkflowRunResult { Status = WorkflowRunStatus.Completed, Outputs = outputs, SessionId = run.SessionId }; @@ -90,7 +90,7 @@ public async IAsyncEnumerable StreamAsync( } } - var session = (request.Session ?? new ChannelSession()) with { Key = run.SessionId }; + var session = new ChannelSession(request.Session ?? new ChannelSession()) { Key = run.SessionId }; var result = pending is not null ? new WorkflowRunResult { Status = WorkflowRunStatus.AwaitingInput, PendingRequest = pending, Outputs = outputs, SessionId = run.SessionId } : new WorkflowRunResult { Status = WorkflowRunStatus.Completed, Outputs = outputs, SessionId = run.SessionId }; @@ -98,5 +98,5 @@ public async IAsyncEnumerable StreamAsync( } private static HostedRunResult Build(WorkflowRunResult result, ChannelSession? session) => - new() { Result = result, Session = session }; + new(result) { Session = session }; } diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting.Channels/WorkflowRunResult.cs b/dotnet/src/Microsoft.Agents.AI.Hosting.Channels/WorkflowRunResult.cs index 1373e548112..f2016ed9588 100644 --- a/dotnet/src/Microsoft.Agents.AI.Hosting.Channels/WorkflowRunResult.cs +++ b/dotnet/src/Microsoft.Agents.AI.Hosting.Channels/WorkflowRunResult.cs @@ -14,27 +14,27 @@ namespace Microsoft.Agents.AI.Hosting.Channels; /// a status-awaiting_input envelope and the resume token lives on /// attributes under the key "workflow.resume_token". /// -public sealed record WorkflowRunResult +public sealed class WorkflowRunResult { - /// Lifecycle status the runner reached when control returned. - public required WorkflowRunStatus Status { get; init; } + /// Gets or sets the lifecycle status the runner reached when control returned. + public WorkflowRunStatus Status { get; set; } /// - /// Outputs emitted by the workflow (from WorkflowOutputEvent). Order matches event order. + /// Gets or sets the outputs emitted by the workflow (from WorkflowOutputEvent). Order matches event order. /// - public IReadOnlyList Outputs { get; init; } = []; + public IReadOnlyList Outputs { get; set; } = []; /// - /// Pending external request that paused execution. Populated when + /// Gets or sets the pending external request that paused execution. Populated when /// is . /// - public ExternalRequest? PendingRequest { get; init; } + public ExternalRequest? PendingRequest { get; set; } - /// The workflow session id this run is associated with. - public string? SessionId { get; init; } + /// Gets or sets the workflow session id this run is associated with. + public string? SessionId { get; set; } - /// Failure detail when is . - public string? Error { get; init; } + /// Gets or sets the failure detail when is . + public string? Error { get; set; } } /// Lifecycle status of a . diff --git a/dotnet/tests/Microsoft.Agents.AI.Hosting.Channels.IntegrationTests/Support/ChatMessageListRunHook.cs b/dotnet/tests/Microsoft.Agents.AI.Hosting.Channels.IntegrationTests/Support/ChatMessageListRunHook.cs index e5655a161c6..cb0a6661fe2 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Hosting.Channels.IntegrationTests/Support/ChatMessageListRunHook.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Hosting.Channels.IntegrationTests/Support/ChatMessageListRunHook.cs @@ -18,7 +18,7 @@ public ValueTask OnRequestAsync(ChannelRequest request, ChannelR { if (request.Input is IEnumerable messages) { - return new(request with { Input = messages.ToList() }); + return new(new ChannelRequest(request) { Input = messages.ToList() }); } return new(request); diff --git a/dotnet/tests/Microsoft.Agents.AI.Hosting.Channels.IntegrationTests/Support/ProbeChannel.cs b/dotnet/tests/Microsoft.Agents.AI.Hosting.Channels.IntegrationTests/Support/ProbeChannel.cs index efdd66e2411..cd1dcda1a75 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Hosting.Channels.IntegrationTests/Support/ProbeChannel.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Hosting.Channels.IntegrationTests/Support/ProbeChannel.cs @@ -29,7 +29,7 @@ public ProbeChannel(string path = "/probe") public bool ShutdownFired { get; private set; } - public override ChannelContribution Contribute(IChannelContext context) => new() + public override ChannelContribution Contribute(ChannelContext context) => new() { EndpointFilters = [new HeaderStampFilter()], OnStartup = _ => { this.StartupFired = true; return default; }, @@ -43,7 +43,7 @@ public ProbeChannel(string path = "/probe") endpoints.MapPost("/run", async (HttpContext http) => { var input = await new System.IO.StreamReader(http.Request.Body).ReadToEndAsync(http.RequestAborted).ConfigureAwait(false); - var request = new ChannelRequest { Channel = "probe", Operation = "message.create", Input = input }; + var request = new ChannelRequest("probe", "message.create", input); var result = await context.RunAsync(request, http.RequestAborted).ConfigureAwait(false); var text = (result.ResultObject as AgentResponse)?.Text ?? result.ResultObject?.ToString(); return Results.Text(text ?? string.Empty); @@ -52,7 +52,7 @@ public ProbeChannel(string path = "/probe") endpoints.MapPost("/stream", async (HttpContext http) => { var input = await new System.IO.StreamReader(http.Request.Body).ReadToEndAsync(http.RequestAborted).ConfigureAwait(false); - var request = new ChannelRequest { Channel = "probe", Operation = "message.create", Input = input, Stream = true }; + var request = new ChannelRequest("probe", "message.create", input) { Stream = true }; var updates = 0; var completed = 0; await foreach (var item in context.StreamAsync(request, http.RequestAborted).ConfigureAwait(false)) diff --git a/dotnet/tests/Microsoft.Agents.AI.Hosting.Channels.IntegrationTests/Support/TestHooks.cs b/dotnet/tests/Microsoft.Agents.AI.Hosting.Channels.IntegrationTests/Support/TestHooks.cs index 9bd1f3e895a..bf682d5ba40 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Hosting.Channels.IntegrationTests/Support/TestHooks.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Hosting.Channels.IntegrationTests/Support/TestHooks.cs @@ -13,7 +13,7 @@ namespace Microsoft.Agents.AI.Hosting.Channels.IntegrationTests.Support; internal sealed class RewriteInputRunHook(string replacement) : IChannelRunHook { public ValueTask OnRequestAsync(ChannelRequest request, ChannelRunHookContext context, CancellationToken cancellationToken) => - new(request with { Input = replacement }); + new(new ChannelRequest(request) { Input = replacement }); } /// @@ -30,8 +30,8 @@ public ValueTask OnRequestAsync(ChannelRequest request, ChannelR return new(request); } - var session = (request.Session ?? new ChannelSession()) with { IsolationKey = key }; - return new(request with { Session = session }); + var session = new ChannelSession(request.Session ?? new ChannelSession()) { IsolationKey = key }; + return new(new ChannelRequest(request) { Session = session }); } } diff --git a/dotnet/tests/Microsoft.Agents.AI.Hosting.Channels.UnitTests/ChannelRequestTests.cs b/dotnet/tests/Microsoft.Agents.AI.Hosting.Channels.UnitTests/ChannelRequestTests.cs index 179bf780979..9d7668c407e 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Hosting.Channels.UnitTests/ChannelRequestTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Hosting.Channels.UnitTests/ChannelRequestTests.cs @@ -8,7 +8,7 @@ public class ChannelRequestTests public void ChannelRequest_DefaultsAreMinimal() { // Arrange / Act - var request = new ChannelRequest { Channel = "responses", Operation = "message.create", Input = "hello" }; + var request = new ChannelRequest("responses", "message.create", "hello"); // Assert Assert.Equal(SessionMode.Auto, request.SessionMode); diff --git a/dotnet/tests/Microsoft.Agents.AI.Hosting.Channels.UnitTests/HostCompositionTests.cs b/dotnet/tests/Microsoft.Agents.AI.Hosting.Channels.UnitTests/HostCompositionTests.cs index 70f0e1bb957..90df6824c38 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Hosting.Channels.UnitTests/HostCompositionTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Hosting.Channels.UnitTests/HostCompositionTests.cs @@ -41,7 +41,7 @@ public async Task Host_RunAsync_DrivesWorkflowTargetAsync() var host = app.Services.GetRequiredService(); // Act - var result = await host.RunAsync(new ChannelRequest { Channel = "fake", Operation = "message.create", Input = "hi" }, CancellationToken.None); + var result = await host.RunAsync(new ChannelRequest("fake", "message.create", "hi"), CancellationToken.None); // Assert var typed = Assert.IsType>(result); @@ -51,7 +51,7 @@ public async Task Host_RunAsync_DrivesWorkflowTargetAsync() private sealed class FakeChannel : Channel { public override string Name => "fake"; - public override ChannelContribution Contribute(IChannelContext context) => new(); + public override ChannelContribution Contribute(ChannelContext context) => new(); } private sealed class EchoExecutor() : Executor("EchoExecutor") diff --git a/dotnet/tests/Microsoft.Agents.AI.Hosting.Channels.UnitTests/WorkflowRunnerTests.cs b/dotnet/tests/Microsoft.Agents.AI.Hosting.Channels.UnitTests/WorkflowRunnerTests.cs index 0578d4b8b29..c98fa42b727 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Hosting.Channels.UnitTests/WorkflowRunnerTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Hosting.Channels.UnitTests/WorkflowRunnerTests.cs @@ -15,7 +15,7 @@ public async Task RunAsync_EchoWorkflow_RunsToCompletionAsync() var echo = new EchoExecutor(); var workflow = new WorkflowBuilder(echo).WithOutputFrom(echo).Build(); var runner = new WorkflowRunner(workflow); - var request = new ChannelRequest { Channel = "test", Operation = "message.create", Input = "ping" }; + var request = new ChannelRequest("test", "message.create", "ping"); // Act var result = await runner.RunAsync(request, CancellationToken.None);