Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
142 changes: 142 additions & 0 deletions dotnet/src/Microsoft.Agents.AI/Harness/AsyncRequestBroker.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,142 @@
// Copyright (c) Microsoft. All rights reserved.

using System;
using System.Collections.Concurrent;
using System.Collections.Generic;
using System.Diagnostics.CodeAnalysis;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Shared.DiagnosticIds;

namespace Microsoft.Agents.AI;

/// <summary>
/// A generic async pause-and-wait broker for request/response patterns.
/// </summary>
/// <remarks>
/// Allows server-side code to suspend on a request ID and await a client response
/// delivered via <see cref="Resolve"/> or <see cref="Reject"/>, without polling or
/// blocking a thread.
/// </remarks>
/// <typeparam name="TResult">The type of the result delivered to the waiter.</typeparam>
[Experimental(DiagnosticIds.Experiments.AgentsAIExperiments)]
public sealed class AsyncRequestBroker<TResult>
{
private const int DefaultSettledQueueSize = 256;

private readonly ConcurrentDictionary<string, TaskCompletionSource<TResult>> _pending = new();
private readonly Queue<string> _settledQueue = new();
private readonly HashSet<string> _settledSet = new();
private readonly int _settledQueueSize;
private readonly object _settledLock = new();

/// <summary>
/// Initializes a new instance of the <see cref="AsyncRequestBroker{TResult}"/> class.
/// </summary>
/// <param name="settledQueueSize">Maximum number of recently settled request IDs to track for late-response handling.</param>
public AsyncRequestBroker(int settledQueueSize = DefaultSettledQueueSize)
{
if (settledQueueSize < 1)
{
throw new ArgumentOutOfRangeException(nameof(settledQueueSize), "Settled queue size must be at least 1.");
}

this._settledQueueSize = settledQueueSize;
}

/// <summary>Gets the number of requests currently awaiting a response.</summary>
public int PendingCount => this._pending.Count;

/// <summary>Suspend until the request is resolved, rejected, or cancelled.</summary>
/// <param name="requestId">Unique identifier for this request.</param>
/// <param name="timeout">Optional timeout. <see langword="null"/> waits indefinitely.</param>
/// <param name="cancellationToken">Token to cancel the wait.</param>
/// <returns>The result passed to <see cref="Resolve"/>.</returns>
public async Task<TResult> RequestAsync(string requestId, TimeSpan? timeout = null, CancellationToken cancellationToken = default)
{
var tcs = new TaskCompletionSource<TResult>(TaskCreationOptions.RunContinuationsAsynchronously);

lock (this._settledLock)
{
if (this._settledSet.Contains(requestId))
{
throw new InvalidOperationException($"Request '{requestId}' was already settled.");
}

if (!this._pending.TryAdd(requestId, tcs))
{
throw new InvalidOperationException($"Request '{requestId}' is already pending.");
}
}

using var cts = timeout.HasValue
? CancellationTokenSource.CreateLinkedTokenSource(cancellationToken)
: null;

if (cts is not null && timeout.HasValue)
{
cts.CancelAfter(timeout.Value);
}

var effectiveToken = cts?.Token ?? cancellationToken;
using var registration = effectiveToken.Register(() =>
{
// Atomic key+value removal: only cancel our own TCS, never a replacement registered under the same ID.
if (((ICollection<KeyValuePair<string, TaskCompletionSource<TResult>>>)this._pending)
.Remove(new KeyValuePair<string, TaskCompletionSource<TResult>>(requestId, tcs)))
{
this.TrackSettled(requestId);
tcs.TrySetCanceled(effectiveToken);
}
});

return await tcs.Task.ConfigureAwait(false);
}

/// <summary>Deliver a successful result to a waiting <see cref="RequestAsync"/> call.</summary>
/// <param name="requestId">The request identifier.</param>
/// <param name="result">The result to deliver.</param>
/// <returns><see langword="true"/> if a waiter was notified; <see langword="false"/> if the request was not found.</returns>
public bool Resolve(string requestId, TResult result)
{
if (!this._pending.TryRemove(requestId, out var tcs))
{
return false;
}

this.TrackSettled(requestId);
tcs.TrySetResult(result);
return true;
}

/// <summary>Deliver an exception to a waiting <see cref="RequestAsync"/> call.</summary>
/// <param name="requestId">The request identifier.</param>
/// <param name="exception">The exception to deliver.</param>
/// <returns><see langword="true"/> if a waiter was notified.</returns>
public bool Reject(string requestId, Exception exception)
{
if (!this._pending.TryRemove(requestId, out var tcs))
{
return false;
}

this.TrackSettled(requestId);
tcs.TrySetException(exception);
return true;
}

private void TrackSettled(string requestId)
{
lock (this._settledLock)
{
if (this._settledQueue.Count >= this._settledQueueSize)
{
string oldest = this._settledQueue.Dequeue();
this._settledSet.Remove(oldest);
}

this._settledQueue.Enqueue(requestId);
this._settledSet.Add(requestId);
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -24,3 +24,9 @@ Microsoft.Agents.AI.AgentModeProviderOptions.DisableModeSetTool.set -> void
[MAAI001]virtual Microsoft.Agents.AI.AgentFileStore.SearchAsync(string! directory, string! regexPattern, string? globPattern = null, bool recursive = false, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task<System.Collections.Generic.IReadOnlyList<Microsoft.Agents.AI.FileSearchResult!>!>!
[MAAI001]Microsoft.Agents.AI.FunctionInvocationContextExtensions
[MAAI001]static Microsoft.Agents.AI.FunctionInvocationContextExtensions.WrapWithPendingMiddleware(this Microsoft.Extensions.AI.FunctionInvocationContext! context, Microsoft.Extensions.AI.AIFunction! function) -> Microsoft.Extensions.AI.AIFunction!
[MAAI001]Microsoft.Agents.AI.AsyncRequestBroker<TResult>
[MAAI001]Microsoft.Agents.AI.AsyncRequestBroker<TResult>.AsyncRequestBroker(int settledQueueSize = 256) -> void
[MAAI001]Microsoft.Agents.AI.AsyncRequestBroker<TResult>.PendingCount.get -> int
[MAAI001]Microsoft.Agents.AI.AsyncRequestBroker<TResult>.Reject(string! requestId, System.Exception! exception) -> bool
[MAAI001]Microsoft.Agents.AI.AsyncRequestBroker<TResult>.RequestAsync(string! requestId, System.TimeSpan? timeout = null, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task<TResult>!
[MAAI001]Microsoft.Agents.AI.AsyncRequestBroker<TResult>.Resolve(string! requestId, TResult result) -> bool
Original file line number Diff line number Diff line change
Expand Up @@ -24,3 +24,9 @@ Microsoft.Agents.AI.AgentModeProviderOptions.DisableModeSetTool.set -> void
[MAAI001]virtual Microsoft.Agents.AI.AgentFileStore.SearchAsync(string! directory, string! regexPattern, string? globPattern = null, bool recursive = false, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task<System.Collections.Generic.IReadOnlyList<Microsoft.Agents.AI.FileSearchResult!>!>!
[MAAI001]Microsoft.Agents.AI.FunctionInvocationContextExtensions
[MAAI001]static Microsoft.Agents.AI.FunctionInvocationContextExtensions.WrapWithPendingMiddleware(this Microsoft.Extensions.AI.FunctionInvocationContext! context, Microsoft.Extensions.AI.AIFunction! function) -> Microsoft.Extensions.AI.AIFunction!
[MAAI001]Microsoft.Agents.AI.AsyncRequestBroker<TResult>
[MAAI001]Microsoft.Agents.AI.AsyncRequestBroker<TResult>.AsyncRequestBroker(int settledQueueSize = 256) -> void
[MAAI001]Microsoft.Agents.AI.AsyncRequestBroker<TResult>.PendingCount.get -> int
[MAAI001]Microsoft.Agents.AI.AsyncRequestBroker<TResult>.Reject(string! requestId, System.Exception! exception) -> bool
[MAAI001]Microsoft.Agents.AI.AsyncRequestBroker<TResult>.RequestAsync(string! requestId, System.TimeSpan? timeout = null, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task<TResult>!
[MAAI001]Microsoft.Agents.AI.AsyncRequestBroker<TResult>.Resolve(string! requestId, TResult result) -> bool
Original file line number Diff line number Diff line change
Expand Up @@ -24,3 +24,9 @@ Microsoft.Agents.AI.AgentModeProviderOptions.DisableModeSetTool.set -> void
[MAAI001]virtual Microsoft.Agents.AI.AgentFileStore.SearchAsync(string! directory, string! regexPattern, string? globPattern = null, bool recursive = false, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task<System.Collections.Generic.IReadOnlyList<Microsoft.Agents.AI.FileSearchResult!>!>!
[MAAI001]Microsoft.Agents.AI.FunctionInvocationContextExtensions
[MAAI001]static Microsoft.Agents.AI.FunctionInvocationContextExtensions.WrapWithPendingMiddleware(this Microsoft.Extensions.AI.FunctionInvocationContext! context, Microsoft.Extensions.AI.AIFunction! function) -> Microsoft.Extensions.AI.AIFunction!
[MAAI001]Microsoft.Agents.AI.AsyncRequestBroker<TResult>
[MAAI001]Microsoft.Agents.AI.AsyncRequestBroker<TResult>.AsyncRequestBroker(int settledQueueSize = 256) -> void
[MAAI001]Microsoft.Agents.AI.AsyncRequestBroker<TResult>.PendingCount.get -> int
[MAAI001]Microsoft.Agents.AI.AsyncRequestBroker<TResult>.Reject(string! requestId, System.Exception! exception) -> bool
[MAAI001]Microsoft.Agents.AI.AsyncRequestBroker<TResult>.RequestAsync(string! requestId, System.TimeSpan? timeout = null, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task<TResult>!
[MAAI001]Microsoft.Agents.AI.AsyncRequestBroker<TResult>.Resolve(string! requestId, TResult result) -> bool
Original file line number Diff line number Diff line change
Expand Up @@ -24,3 +24,9 @@ Microsoft.Agents.AI.AgentModeProviderOptions.DisableModeSetTool.set -> void
[MAAI001]virtual Microsoft.Agents.AI.AgentFileStore.SearchAsync(string! directory, string! regexPattern, string? globPattern = null, bool recursive = false, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task<System.Collections.Generic.IReadOnlyList<Microsoft.Agents.AI.FileSearchResult!>!>!
[MAAI001]Microsoft.Agents.AI.FunctionInvocationContextExtensions
[MAAI001]static Microsoft.Agents.AI.FunctionInvocationContextExtensions.WrapWithPendingMiddleware(this Microsoft.Extensions.AI.FunctionInvocationContext! context, Microsoft.Extensions.AI.AIFunction! function) -> Microsoft.Extensions.AI.AIFunction!
[MAAI001]Microsoft.Agents.AI.AsyncRequestBroker<TResult>
[MAAI001]Microsoft.Agents.AI.AsyncRequestBroker<TResult>.AsyncRequestBroker(int settledQueueSize = 256) -> void
[MAAI001]Microsoft.Agents.AI.AsyncRequestBroker<TResult>.PendingCount.get -> int
[MAAI001]Microsoft.Agents.AI.AsyncRequestBroker<TResult>.Reject(string! requestId, System.Exception! exception) -> bool
[MAAI001]Microsoft.Agents.AI.AsyncRequestBroker<TResult>.RequestAsync(string! requestId, System.TimeSpan? timeout = null, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task<TResult>!
[MAAI001]Microsoft.Agents.AI.AsyncRequestBroker<TResult>.Resolve(string! requestId, TResult result) -> bool
Original file line number Diff line number Diff line change
Expand Up @@ -24,3 +24,9 @@ Microsoft.Agents.AI.AgentModeProviderOptions.DisableModeSetTool.set -> void
[MAAI001]virtual Microsoft.Agents.AI.AgentFileStore.SearchAsync(string! directory, string! regexPattern, string? globPattern = null, bool recursive = false, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task<System.Collections.Generic.IReadOnlyList<Microsoft.Agents.AI.FileSearchResult!>!>!
[MAAI001]Microsoft.Agents.AI.FunctionInvocationContextExtensions
[MAAI001]static Microsoft.Agents.AI.FunctionInvocationContextExtensions.WrapWithPendingMiddleware(this Microsoft.Extensions.AI.FunctionInvocationContext! context, Microsoft.Extensions.AI.AIFunction! function) -> Microsoft.Extensions.AI.AIFunction!
[MAAI001]Microsoft.Agents.AI.AsyncRequestBroker<TResult>
[MAAI001]Microsoft.Agents.AI.AsyncRequestBroker<TResult>.AsyncRequestBroker(int settledQueueSize = 256) -> void
[MAAI001]Microsoft.Agents.AI.AsyncRequestBroker<TResult>.PendingCount.get -> int
[MAAI001]Microsoft.Agents.AI.AsyncRequestBroker<TResult>.Reject(string! requestId, System.Exception! exception) -> bool
[MAAI001]Microsoft.Agents.AI.AsyncRequestBroker<TResult>.RequestAsync(string! requestId, System.TimeSpan? timeout = null, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task<TResult>!
[MAAI001]Microsoft.Agents.AI.AsyncRequestBroker<TResult>.Resolve(string! requestId, TResult result) -> bool
4 changes: 4 additions & 0 deletions python/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

### Added
- **agent-framework-core**: Add `AsyncRequestBroker` — a generic asyncio pause-and-wait utility for any server-side request/response pattern. Provides `request_async()`, `resolve()`, and `reject()` with timeout and race-condition handling.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

please remove this, we add all relevant changes in version update PR's

- **agent-framework-tools**: Add `ShellAuditLog` and `ShellCommandRecord` to `LocalShellTool`; every command evaluation is recorded with policy decision, exit code, and timestamp.

## [1.20.0] - 2026-10-02

### Added
Expand Down
2 changes: 2 additions & 0 deletions python/packages/core/agent_framework/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -148,6 +148,7 @@
"MemoryTopicRecord",
),
"._harness._mode": ("DEFAULT_MODE_SOURCE_ID", "AgentModeProvider", "get_agent_mode", "set_agent_mode"),
"._harness._request_broker": ("AsyncRequestBroker",),
"._harness._todo": (
"DEFAULT_TODO_SOURCE_ID",
"TodoFileStore",
Expand Down Expand Up @@ -451,6 +452,7 @@
"AgentSession",
"AggregatingSkillsSource",
"Annotation",
"AsyncRequestBroker",
"BackgroundAgentsProvider",
"BackgroundTaskInfo",
"BackgroundTaskStatus",
Expand Down
2 changes: 2 additions & 0 deletions python/packages/core/agent_framework/__init__.pyi
Original file line number Diff line number Diff line change
Expand Up @@ -106,6 +106,7 @@ from ._harness._memory import (
MemoryTopicRecord,
)
from ._harness._mode import DEFAULT_MODE_SOURCE_ID, AgentModeProvider, get_agent_mode, set_agent_mode
from ._harness._request_broker import AsyncRequestBroker
from ._harness._todo import (
DEFAULT_TODO_SOURCE_ID,
TodoFileStore,
Expand Down Expand Up @@ -406,6 +407,7 @@ __all__ = [
"AgentSession",
"AggregatingSkillsSource",
"Annotation",
"AsyncRequestBroker",
"BackgroundAgentsProvider",
"BackgroundTaskInfo",
"BackgroundTaskStatus",
Expand Down
121 changes: 121 additions & 0 deletions python/packages/core/agent_framework/_harness/_request_broker.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,121 @@
# Copyright (c) Microsoft. All rights reserved.

"""AsyncRequestBroker: a generic asyncio pause-and-wait primitive.

Provides :class:`AsyncRequestBroker`, which lets any server-side code suspend
execution on a request ID and wait asynchronously for a client to deliver the
matching result via :meth:`AsyncRequestBroker.resolve` or
:meth:`AsyncRequestBroker.reject`.

The broker tracks recently settled request IDs in a bounded deque (default
256 entries) so that re-using a settled ID is reported instead of silently
hanging. Late responses for unknown IDs return ``False`` rather than raising.

Usage::

broker = AsyncRequestBroker()

# Server side - suspend until answered (or timeout)
result = await broker.request_async("req-123", timeout=30.0)

# Client side - deliver the answer
broker.resolve("req-123", decision)
"""

from __future__ import annotations

import asyncio
import collections
from typing import Generic, TypeVar

from .._feature_stage import ExperimentalFeature, experimental

__all__ = ["AsyncRequestBroker"]

T = TypeVar("T")


@experimental(feature_id=ExperimentalFeature.HARNESS)
class AsyncRequestBroker(Generic[T]):
"""Generic asyncio pause-and-wait broker for request/response patterns.

Allows server-side code to suspend on a request ID and await a client
response delivered via :meth:`resolve` or :meth:`reject`, without
polling or blocking a thread.

Thread-safety: designed for single-threaded asyncio use. The broker must
be used within a single event loop.
"""

DEFAULT_SETTLED_DEQUE_SIZE = 256

def __init__(self, settled_deque_size: int = DEFAULT_SETTLED_DEQUE_SIZE) -> None:
self._pending: dict[str, asyncio.Future[T]] = {}
self._settled: collections.deque[str] = collections.deque(maxlen=settled_deque_size)

async def request_async(self, request_id: str, *, timeout: float | None = None) -> T:

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Across python MAF we never use async in method names, since almost everything is async anyway

Suggested change
async def request_async(self, request_id: str, *, timeout: float | None = None) -> T:
async def request(self, request_id: str, *, timeout: float | None = None) -> T:

"""Suspend until the request is resolved or rejected.

Args:
request_id: Unique identifier for this request.

Keyword Args:
timeout: Maximum seconds to wait. ``None`` waits indefinitely.

Returns:
The result passed to :meth:`resolve`.

Raises:
ValueError: If the ID is already pending or was recently settled.
asyncio.TimeoutError: If ``timeout`` elapses before a response arrives.
asyncio.CancelledError: If the waiting task is cancelled.
Exception: Any exception passed to :meth:`reject`.
"""
if request_id in self._settled:
raise ValueError(f"Request '{request_id}' was already settled.")
if request_id in self._pending:
raise ValueError(f"Request '{request_id}' is already pending.")
loop = asyncio.get_running_loop()
future: asyncio.Future[T] = loop.create_future()
self._pending[request_id] = future
try:
return await asyncio.wait_for(asyncio.shield(future), timeout=timeout)
except BaseException:
if self._pending.get(request_id) is future:
del self._pending[request_id]
future.cancel()
raise

def resolve(self, request_id: str, result: T) -> bool:
"""Deliver a successful result to a waiting :meth:`request_async` call.

Returns:
``True`` if a waiter was notified, ``False`` if the request was not found
(already settled or never registered).
"""
future = self._pending.pop(request_id, None)
if future is None:
return False
Comment thread
marklicata marked this conversation as resolved.
self._settled.append(request_id)
if not future.done():
future.set_result(result)
return True

def reject(self, request_id: str, exception: BaseException) -> bool:
"""Deliver an exception to a waiting :meth:`request_async` call.

Returns:
``True`` if a waiter was notified.
"""
future = self._pending.pop(request_id, None)
if future is None:
return False
self._settled.append(request_id)
if not future.done():
future.set_exception(exception)
return True

@property
def pending_count(self) -> int:
"""Number of outstanding requests currently awaiting a response."""
return len(self._pending)
Loading
Loading