Compare commits

..
Author SHA1 Message Date
Peter Ibekwe a1883c6cb3 Addressed failing integration test and promptcount 2026-05-04 22:53:13 -07:00
Peter Ibekwe 21e844688f Fix QuestionExecutor looping after GotoAction re-entry in declarative workflows 2026-05-04 20:14:34 -07:00
chetantoshniwalGitHubCopilotchetantoshniwalEvan Mattsoncopilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
7476049d7e docs: enhance README with 1.0 features and improved structure (#5534)
* docs: enhance README with 1.0 features and improved structure

- Add GitHub star badge button for easier community engagement
- Reorganize highlights to emphasize Foundry Hosted Agents, Agent Skills, and Orchestration Patterns
- Add CodeAct callout in AF Labs experimental features
- Improve Community & Feedback section with clearer call-to-action structure
- Add Table of Contents for better navigation
- Fix 'quickstar' typo to 'quickstart'
- Reorder sections for improved readability (docs before code examples)

* Apply suggestions from code review

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

* docs: fix .NET quickstart description to match JokerAgent code

Agent-Logs-Url: https://github.com/microsoft/agent-framework/sessions/a0616215-1b8a-44ea-9a35-3ef33b97bdce

Co-authored-by: chetantoshniwal <255221507+chetantoshniwal@users.noreply.github.com>

* docs: add required NuGet packages for .NET Foundry quickstart

Agent-Logs-Url: https://github.com/microsoft/agent-framework/sessions/a035ce2c-e2e0-4b8d-b340-550704220975

Co-authored-by: chetantoshniwal <255221507+chetantoshniwal@users.noreply.github.com>

* docs: sync and apply local README changes

* Update README.md

Co-authored-by: Evan Mattson <35585003+moonbox3@users.noreply.github.com>

* docs: remove emojis from README

* docs: refine README intro paragraph

---------

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: chetantoshniwal <255221507+chetantoshniwal@users.noreply.github.com>
Co-authored-by: Evan Mattson <35585003+moonbox3@users.noreply.github.com>
2026-05-05 01:19:57 +00:00
Tao ChenandGitHub 5a087885a2 Python: Add hosted agent sample with observability (#5608)
* Add hosted agent sample with observability

* Address comments

* Remove unneeded changes

* Update README
2026-05-04 22:31:47 +00:00
4b5a8478de .NET: Hosting updates to declarative workflows (#5589)
* Make DeclarativeWorkflowExecutor ChatProtocol-compatible for AsAIAgent hosting

Extends the existing DeclarativeWorkflowExecutor<TInput> root executor with
additional ChatProtocol-compatible input routes (string, ChatMessage,
IEnumerable<ChatMessage>, ChatMessage[], TurnToken) so that workflows built
via DeclarativeWorkflowBuilder.Build<TInput>(...) work both for direct
invocation and when hosted via Workflow.AsAIAgent(...).

- Each input message advances the declarative graph immediately; the
  TurnToken that the host sends after the message batch is treated as a
  no-op since the message has already been processed.
- Conversation id resolution now prefers persisted workflow system state,
  then DeclarativeWorkflowOptions.ConversationId, then a newly created
  conversation. This makes multi-turn invocations reuse the prior
  conversation rather than creating a fresh one each turn.
- The separate DeclarativeChatProtocolStartExecutor and
  DeclarativeWorkflowBuilder.BuildChatProtocol overloads introduced
  earlier are removed; callers continue to use Build<TInput>(...).

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* fix: use DeclarativeWorkflowContext when reading workflow conversation id

GetWorkflowConversation() requires a DeclarativeWorkflowContext (it calls ReadState which dynamic-casts via the DeclarativeContext helper). The chat-protocol auxiliary handlers receive a BoundWorkflowContext, so calling the extension on the raw IWorkflowContext throws `Invalid workflow context: BoundWorkflowContext`. Use the wrapped declarativeContext that we already constructed.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* fix: surface ExecutorFailedEvent as ErrorContent in AsAIAgent response

WorkflowSession.InvokeStageAsync only converted WorkflowErrorEvent into an ErrorContent payload. ExecutorFailedEvent fell through to the default branch which emits an empty AgentResponseUpdate carrying the event in RawRepresentation. OutputConverter then mapped that to a workflow_action item with status=failed and dropped the exception entirely, so callers got status=completed and error=null even when an executor threw.

- WorkflowSession.cs: add ExecutorFailedEvent case mirroring WorkflowErrorEvent. Honors _includeExceptionDetails.

- OutputConverter.cs: when an update carries both a WorkflowEvent in RawRepresentation and non-empty Contents, fall through to content processing so the unwrapped error (or any future content payload from a workflow event) is actually emitted.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* improve: walk inner exceptions when surfacing ExecutorFailedEvent

DeclarativeActionExecutor wraps inner exceptions in DeclarativeActionException with a generic `Unhandled workflow failure` message, hiding the real cause. Walk InnerException so the response shows the full chain (e.g. the underlying HTTP 400 / auth error).

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* Surface declarative SendActivity output as chat content

SendActivityExecutor now emits AgentResponseEvent in addition to
MessageActivityEvent so chat protocols (e.g. AsAIAgent) receive the
formatted activity text. The existing MessageActivityEvent is preserved
for DevUI/observability.

Also extend WorkflowSession.WorkflowOutputEvent handling to accept
AgentResponse payloads, mapping them to their constituent ChatMessages.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* Persist hosted-agent sessions to disk; fix System.LastMessageText

Adds FileSystemAgentSessionStore that writes the serialized AgentSession JSON

(which already embeds the workflow's in-memory checkpoint manager) to a per-

conversation file under /.checkpoints when running in a Foundry hosted env

or {cwd}/.checkpoints locally. Mirrors the python foundry_hosting._responses

FileCheckpointStorage pattern so multi-turn workflow state survives process

restarts without requiring callers to wire up storage themselves.

AddFoundryResponses now defaults to FileSystemAgentSessionStore.CreateDefault()

instead of InMemoryAgentSessionStore; callers can still override via DI.

Also fixes {System.LastMessageText} resolving empty: DeclarativeWorkflowExecutor

.AdvanceAsync was passing the message rehydrated from CreateMessageAsync to

SetLastMessageAsync, but ResponseItem -> ChatMessage round-trip drops the .Text

extension content. Use the original input ChatMessage (which still has the

user-supplied text) and copy the server-assigned MessageId across when present.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* Close multi-modal input parity gaps with python foundry_hosting

InputConverter now mirrors the python _responses.py content handling:

- ComputerScreenshotContent maps to UriContent/HostedFileContent (was dropped).

- Plain TextContent and SummaryTextContent map to MEAI TextContent.

- MessageContentReasoningTextContent maps to MEAI TextReasoningContent.

- input_file with text/* file_data data URIs is decoded inline into

  TextContent with a [File: name] prefix, matching python _convert_file_data

  so {System.LastMessageText} surfaces the file body. Non-text data URIs and

  hosted/url file references preserve filename as AdditionalProperties.

Image/file extraction logic is extracted into shared AppendImageContent and

AppendFileContent helpers used by both the fresh-input and history-replay

switches. Existing 37 InputConverter tests still pass.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* Foundry hosting: round-trip tool-approval (HITL) content as mcp_approval_request/response

Closes the gap where Microsoft.Agents.AI.Foundry.Hosting silently dropped
MEAI ToolApprovalRequestContent/ToolApprovalResponseContent in both
directions. We now serialize them onto the wire as the standard Responses
API mcp_approval_request/mcp_approval_response items with
server_label='agent_framework', and parse the symmetric inbound shapes
back into MEAI content.

Wire format:
- The Responses API only standardizes mcp_approval_* as the approval
  primitive. We declare AF as a virtual MCP server via the server_label
  field, which is honest for AF's server-side tool-call holding pattern.
- The SDK enforces a strict {prefix}_{50hex} wire-id format, so we hash
  the AF RequestId and persist a wireId<->afRequestId mapping in
  AgentSession.StateBag so a later mcp_approval_response can be matched
  back to the originating workflow request.

Coexists with the existing ConsentAwareMcpClientAIFunction flow
(AgentFrameworkResponseHandler.cs) which emits mcp_approval_request from
a side-channel, not via OutputConverter's content switch.

Known follow-up: python (foundry_hosting/_responses.py) has the same
output-side gap (ToolApprovalRequestContent emission). Out of scope here.

Tests: +9 unit tests covering both fresh-input and history-replay shapes,
StateBag mapping resolution, and the non-FunctionCallContent skip path.
Existing 108 converter tests still pass; full suite 370/370.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* Address PR review feedback for hosted-declarative-dotnet

FileSystemAgentSessionStore reliability/scoping:

- Bound Sanitize() stackalloc at 256 chars, fall back to ArrayPool for longer ids so a long conversationId can no longer crash the hosting process with StackOverflowException.

- Use a Guid-suffixed temp file (\{path}.{guid}.tmp\) so concurrent SaveSessionAsync calls on the same conversation can no longer race on the same temp file. Best-effort temp cleanup on failure.

- Bucket session files by agent.Name when set so two keyed agents that happen to share a conversationId no longer overwrite each other's persisted state. Single-agent / unnamed-agent cases keep the original flat layout (Python parity).

DeclarativeWorkflowExecutor chat-protocol routing:

- ConfigureChatProtocolRoutes uses IsAssignableFrom rather than exact type equality so a broader TInput (object, base interfaces) does not have its inherited inputTransform shadowed by handlers we register here.

- HandleChatMessagesAsync / HandleChatMessageArrayAsync now advance through every message in the batch instead of keeping only the trailing one, so multi-message turns and replayed history are no longer silently truncated. AdvanceAsync gains a finalizeTurn flag so only the last message in the batch sends the result.

Tests:

- New FileSystemAgentSessionStoreTests covering constructor, fresh-session fallback for missing/empty files, root-directory creation, save/get round-trip, agent-Name scoping isolation, long conversationId, invalid-character sanitization, and concurrent-save behavior.

- New InputConverterTests covering AppendFileContent: text/* data URI decode (with and without filename prefix), non-text data URI passthrough, malformed data URI fallback, and filename propagation onto UriContent / HostedFileContent.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* Add tests for remaining PR review feedback (C2, D1, E1)

C2: InputConverter — add 9 tests covering SDK content types that previously
had no coverage:
  - SdkTextContent → TextContent (input + output paths)
  - SummaryTextContent → TextContent (input + output paths)
  - MessageContentReasoningTextContent → TextReasoningContent (input + output)
  - ComputerScreenshotContent (HTTP URL → UriContent, data: URI → DataContent,
    output path → UriContent)

D1: OutputConverter — add 2 tests for the WorkflowEvent + Contents fall-through:
  - WorkflowEvent in RawRepresentation with text Contents must flow through
    the content-processing path (text-delta event emitted).
  - WorkflowEvent + ErrorContent must produce a failed event rather than be
    swallowed by the workflow branch.

E1: SendActivityExecutor — extend CaptureActivityAsync to assert that the
executor emits an AgentResponseEvent carrying the activity text with the
correct ExecutorId and ChatRole.Assistant role.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* Defense-in-depth: neutralize dot-segments in Sanitize and cap TryDecodeTextDataUri input size

Addresses claude-opus-4.6 security review on PR #5589:

- FileSystemAgentSessionStore.Sanitize now replaces all-dot segments
  (., .., ...) with underscores so a developer-controlled agent.Name
  cannot escape the root directory on Linux (where Path.GetInvalidFileNameChars
  only contains NUL and '/').

- InputConverter.TryDecodeTextDataUri rejects encoded payloads larger than
  16 MiB before calling Convert.FromBase64String, preventing a single
  oversized data URI from triggering a multi-megabyte allocation.

- Adds unit tests covering both fixes.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* Fix Linux-only failure in SaveSessionAsync_SanitizesInvalidPathCharactersAsync

'?' is in Path.GetInvalidFileNameChars only on Windows, not on Linux/macOS,
so the test failed on Ubuntu in CI. Use Path.GetInvalidFileNameChars()[0]
(skipping NUL) to pick a guaranteed-invalid character for the running OS,
and assert the result no longer contains it.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* Address claude-opus-4.6 security/reliability review feedback

WorkflowSession.cs:
- ExecutorFailedEvent handler no longer leaks the internal executor ID
  in error messages. Mirror the WorkflowErrorEvent pattern: surface the
  exception's Message when _includeExceptionDetails is true, fall back
  to the generic 'An error occurred while executing the workflow.' otherwise.
  This also resolves the failing WorkflowHostSmokeTests assertions.

FileSystemAgentSessionStore.cs:
- GetSessionPath no longer has a write side effect. Directory.CreateDirectory
  for the per-agent bucket is now performed only on the SaveSessionAsync
  path, so a read miss on GetSessionAsync no longer leaves an empty
  directory on disk.
- Adds GetSessionAsync_NoExistingFile_DoesNotCreateAgentDirectoryAsync
  to lock in the no-side-effect-on-read contract.

OutputConverterTests.cs:
- Strengthen ConvertUpdatesToEventsAsync_ToolApprovalRequest_NonFunctionToolCall_SkippedAsync
  to assert exactly one event (the terminal ResponseCompletedEvent) so a
  spurious output-item-added/-done leak would now fail the test.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* Address PR review: clean up comments and rename TryParseArguments

- Remove Python-codebase references from C# XML docs and inline comments.
- Drop fix-history comments referring to previously-resolved issues.
- Drop `Defense-in-depth:` prefixes; keep the concrete `what & why`.
- Drop `previously we kept only the trailing message` comment in
  DeclarativeWorkflowExecutor; just describe current loop behavior.
- Rename InputConverter.TryParseArguments to ParseFunctionArgumentsObject
  to make the intent obvious at the call site.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* Address PR review: collision-free Sanitize, MAF-style refactors

- FileSystemAgentSessionStore.Sanitize now percent-encodes invalid chars
  (and `%` itself) instead of replacing them with `_`, eliminating
  collisions like `foo/bar` vs `foo_bar` mapping to the same bucket.
  All-dot segments encode every dot so Windows trailing-dot trimming
  cannot reintroduce a navigable name.
- AddFoundryResponses XML doc updated to accurately describe the default
  store root (/.checkpoints when hosted, {cwd}/.checkpoints locally).
- DeclarativeWorkflowExecutor.ConfigureChatProtocolRoutes now uses exact
  type equality instead of IsAssignableFrom so a broad TInput (e.g.
  object) does not skip registering IEnumerable<ChatMessage>, which
  ChatProtocolExtensions.IsChatProtocol requires verbatim.
- SendActivityExecutor uses context.YieldOutputAsync(response) instead
  of manually constructing AgentResponseEvent, so the activity will
  participate in any future OutputFilter coverage.
- WorkflowSession handles AgentResponseEvent in its own switch case,
  avoiding the second typecheck against output.Data.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* fix(workflows): bridge declarative HITL through Foundry hosting via IExternalRequestEnvelope

Introduce a new public interface IExternalRequestEnvelope in
Microsoft.Agents.AI.Workflows that lets the runtime peek through a
declarative-layer envelope without taking a circular reference back into
the declarative package. ExternalInputRequest (declarative) implements
it; ExternalInputResponse is constructed via the request's CreateResponse
factory. WorkflowSession unwraps inner AIContent on the request side and
rewraps the client's ChatMessage reply into an ExternalInputResponse on
the response side. PortableValue cannot deserialize directly into an
interface, so TryGetRequestEnvelope resolves the concrete type via
RequestPortInfo.RequestType (TypeId -> Type.GetType) before casting.

Public WorkflowHarness contract preserved: InvokeFunctionToolExecutor
and WorkflowActionVisitor are unchanged from upstream, so public
InvokeToolWorkflowTest scenarios continue to drive
ExternalInputRequest / ExternalInputResponse directly through the
harness.

AgentFrameworkResponseHandler: skip prior conversation history replay
when an existing session is being resumed (workflow checkpoint already
holds the prior messages).

WorkflowSession: when includeExceptionDetails is opted in, also unwrap
DeclarativeActionException so HITL failures are debuggable.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

---------

Co-authored-by: alliscode <bentho@microsoft.com>
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-05-04 22:09:54 +00:00
330d3d7165 fix(openai): drop completed continuation_token from shared options in tool loop (#5462)
Fixes #5394.

When `background=True` is combined with local function tools,
`FunctionInvocationLayer` calls `_inner_get_response(options=mutable_options)`
repeatedly with the same dict reference across loop iterations. Once the
first poll retrieves a completed background response, `continuation_token`
stays in `mutable_options`, so every subsequent iteration takes the
`continuation_token is not None` branch and `GET`s the same completed
response instead of `POST`ing the tool results. The loop exits after
`max_iterations` with empty text and the model never sees any tool output.

After the retrieve, if the returned `ChatResponse.continuation_token` is
`None` (the background response is no longer in progress), pop
`continuation_token` and `background` from the shared options dict in
place. The next loop iteration then falls through to the normal
`responses.create`/`parse` path and posts tool results.

The diagnosis and a verified runtime monkeypatch are in the issue; this
is the same fix moved in-tree.

Co-authored-by: Yufeng He <40085740+universeplayer@users.noreply.github.com>
2026-05-04 21:22:56 +00:00
Evan MattsonandGitHub f3db60fa65 Python: Support GPT-5 verbosity option and restore Foundry agent_reference (#5619)
* Python: Support GPT-5 verbosity option and restore Foundry agent_reference

Adds verbosity as a typed Literal["low","medium","high"] field on
OpenAIChatOptions (Responses API) and OpenAIChatCompletionOptions (Chat
Completions API), set in the same way as the existing reasoning options.
For the Responses API, top-level verbosity is translated to the nested
text.verbosity shape the OpenAI service expects. The same field flows
through to FoundryChatClient via the existing FoundryChatOptions alias.

Also fixes #5582: PR #5447 removed the agent_reference injection from
RawFoundryAgentChatClient._prepare_options, so first-turn calls against
a Foundry Prompt Agent went out without model and without agent_reference
and were rejected by the Responses API with "Missing required parameter:
'model'". Restores the injection on the non-preview path
(allow_preview=False) and adds a guard test that asserts the preview
path does not inject agent_reference, since the preview SDK injects it
via project_client.get_openai_client(agent_name=...).

Closes #5516
Closes #5582

* Python: Address Copilot review on PR #5619

- Foundry verbosity sample docstring: replace the misleading "set deployment
  name on model=" instruction with the actual env-var pattern the sample relies
  on (FOUNDRY_PROJECT_ENDPOINT and FOUNDRY_MODEL).
- _build_agent_reference docstring: clarify the helper is used for both
  Prompt Agents and HostedAgents on the non-preview path.
- Add a Responses API test that locks in the documented precedence rule:
  when both top-level verbosity and text["verbosity"] are supplied, the
  top-level value wins.

* Python: Drop redundant Foundry verbosity sample and list OpenAI sample in README

- Remove samples/02-agents/providers/foundry/foundry_chat_client_verbosity.py
  per review feedback. The verbosity functionality is identical across the
  OpenAI and Foundry clients (FoundryChatOptions is an alias of
  OpenAIChatOptions), so a single sample on the OpenAI side is sufficient.
- Add the new client_verbosity.py entry to the OpenAI samples README.
2026-05-04 21:21:40 +00:00
4a2da953ca Python: Core: add experimental memory harness context provider (#5613)
* Python: Core: add experimental memory harness context provider

Adds MemoryContextProvider with topic-indexed long-term memory and
chat-driven compaction. Pluggable MemoryStore backends include
MemoryFileStore. Public types: MemoryIndexEntry, MemoryTopicRecord.
Behind @experimental(ExperimentalFeature.HARNESS).

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* Python: Core: address review feedback on memory harness

- mark MemoryStore as @experimental(HARNESS) for surface consistency
- safely encode owner id and verify path containment (matches FileHistoryProvider pattern)
- namespace MemoryFileStore on-disk layout by source_id to avoid cross-provider collisions
- before_run computes index_entries once and only rewrites MEMORY.md when content changes
- asyncio locks around topic/state read-modify-write to avoid concurrent-write races

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* Address PR feedback: harden memory store IO + consolidation behavior

- Atomic writes via os.replace + temp sibling for topic, state, and index files so
  crashes/disk-full failures cannot leave a truncated half-written file.
- Stop creating directories on read paths: list_topics/read_state/search_transcripts
  and get_messages return empty when nothing has been written. mkdir is deferred to
  the actual save path (write_topic/write_state/save_messages).
- Escape lines that look like markdown headings on render and unescape them on parse,
  so a memory or summary containing '## Summary'/'## Memories' cannot tamper with the
  topic file structure.
- Narrow extraction/consolidation chat-client failure handling to ChatClientException,
  asyncio.TimeoutError, and OSError. Programmer errors (AttributeError, TypeError, ...)
  now propagate so misconfigured clients fail loudly.
- Log a payload-prefix preview for every silent shape branch in _extract_memories and
  _consolidate_topic so unparsable extractor output is debuggable instead of invisible.
- Restructure _run_consolidation: read maintenance state and topic snapshot under the
  state lock, run the LLM consolidation loop without holding the state lock, and only
  advance last_consolidated_at/sessions_since_consolidation if at least one topic
  succeeded. Transient consolidation failures now leave the maintenance window in
  place so the next after_run retries instead of silently sliding forward.
- Add regression tests for: markdown-marker round-trip, atomic-write recovery on
  os.replace failure, no-mkdir on pure read paths, transient consolidation failure
  preserves state, and propagation of programmer errors.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

---------

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-05-04 21:19:50 +00:00
58 changed files with 5105 additions and 938 deletions
+77 -79
View File
@@ -6,8 +6,12 @@
[![MS Learn Documentation](https://img.shields.io/badge/MS%20Learn-Documentation-blue)](https://learn.microsoft.com/en-us/agent-framework/)
[![PyPI](https://img.shields.io/pypi/v/agent-framework)](https://pypi.org/project/agent-framework/)
[![NuGet](https://img.shields.io/nuget/v/Microsoft.Agents.AI)](https://www.nuget.org/profiles/MicrosoftAgentFramework/)
[![GitHub stars](https://img.shields.io/github/stars/microsoft/agent-framework?style=social)](https://github.com/microsoft/agent-framework/stargazers)
Welcome to Microsoft's comprehensive multi-language framework for building, orchestrating, and deploying AI agents with support for both .NET and Python implementations. This framework provides everything from simple chat agents to complex multi-agent workflows with graph-based orchestration.
Microsoft Agent Framework (MAF) is an open, multi-language framework for building **production-grade AI agents and multi-agent workflows** in **.NET and Python**.
Microsoft Agent Framework is built for teams taking agents from prototype to production. It provides a consistent foundation for building, orchestrating, and operating agent systems across Python and .NET, while keeping architecture choices open as requirements evolve, and supports a broad ecosystem including Microsoft Foundry, Azure OpenAI, OpenAI, and the GitHub Copilot SDK, with samples and hosting patterns for both local development and cloud deployment.
<p align="center">
<a href="https://www.youtube.com/watch?v=AAgdMhftj8w" title="Watch the full Agent Framework introduction (30 min)">
@@ -21,10 +25,54 @@ Welcome to Microsoft's comprehensive multi-language framework for building, orch
</a>
</p>
## 📋 Getting Started
## Is this the right framework for you?
### 📦 Installation
MAF is a strong fit if you:
- are building agents and workflows you expect to run in production,
- need orchestration beyond a single prompt or stateless chat loop,
- want graph-based patterns such as sequential, concurrent, handoff, and group collaboration,
- care about durability, restartability, observability, governance, or human-in-the-loop control,
- need provider flexibility so your architecture can evolve without major rewrites.
## Key Features
Explore new MAF capabilities and real implementation patterns on the [official blog](https://devblogs.microsoft.com/agent-framework/).
- **Python and C#/.NET Support**: Full framework support for both Python and C#/.NET implementations with consistent APIs
- [Python packages](./python/packages/) | [.NET source](./dotnet/src/)
- **Multiple Agent Provider Support**: Support for various LLM providers with more being added continuously
- [Python examples](./python/samples/02-agents/providers/) | [.NET examples](./dotnet/samples/02-agents/AgentProviders/)
- **Middleware**: Flexible middleware system for request/response processing, exception handling, and custom pipelines
- [Python middleware](./python/samples/02-agents/middleware/) | [.NET middleware](./dotnet/samples/02-agents/Agents/Agent_Step11_Middleware/)
- **Orchestration Patterns & Workflows**: Build multi-agent systems with graph-based workflows supporting sequential, concurrent, handoff, and group collaboration patterns; includes checkpointing, streaming, human-in-the-loop, and time-travel
- [Python workflows](./python/samples/03-workflows/) | [.NET workflows](./dotnet/samples/03-workflows/)
- **Foundry Hosted Agents (new)**: Deploy and host your agents to Foundry-hosted infrastructure with just 2 additional lines of code
- [Python samples](./python/samples/04-hosting/foundry-hosted-agents/) | [.NET samples](./dotnet/samples/04-hosting/FoundryHostedAgents/)
- **Observability**: Built-in OpenTelemetry integration for distributed tracing, monitoring, and debugging
- [Python observability](./python/samples/02-agents/observability/) | [.NET telemetry](./dotnet/samples/02-agents/AgentOpenTelemetry/)
- **Declarative Agents**: Define agents using YAML for faster setup and versioning
- [Declarative agent samples](./declarative-agents/)
- **Agent Skills**: Build domain-specific knowledge bases from multiple sources—files, inline code, class libraries—for agents to discover and use
- [Skills design](./docs/decisions/0021-agent-skills-design.md)
- **AF Labs**: Experimental packages for cutting-edge features including benchmarking, reinforcement learning, and research initiatives
- [Labs directory](./python/packages/lab/)
- **DevUI**: Interactive developer UI for agent development, testing, and debugging workflows
- [See the DevUI in action](https://www.youtube.com/watch?v=mOAaGY4WPvc)
## Table of Contents
- [Getting Started](#getting-started)
- [Installation](#installation)
- [Learning Resources](#learning-resources)
- [Quickstart](#quickstart)
- [Basic Agent - Python](#basic-agent---python)
- [Basic Agent - .NET](#basic-agent---net)
- [More Examples & Samples](#more-examples--samples)
- [Community & Feedback](#community--feedback)
- [Troubleshooting](#troubleshooting)
- [Contributor Resources](#contributor-resources)
## Getting Started
### Installation
Python
```bash
@@ -37,9 +85,13 @@ pip install agent-framework
```bash
dotnet add package Microsoft.Agents.AI
# For Foundry integration (used in the .NET quickstart below):
dotnet add package Microsoft.Agents.AI.Foundry
dotnet add package Azure.AI.Projects
dotnet add package Azure.Identity
```
### 📚 Documentation
### Learning Resources
- **[Overview](https://learn.microsoft.com/agent-framework/overview/agent-framework-overview)** - High level overview of the framework
- **[Quick Start](https://learn.microsoft.com/agent-framework/tutorials/quick-start)** - Get started with a simple agent
@@ -48,44 +100,9 @@ dotnet add package Microsoft.Agents.AI
- **[Migration from Semantic Kernel](https://learn.microsoft.com/en-us/agent-framework/migration-guide/from-semantic-kernel)** - Guide to migrate from Semantic Kernel
- **[Migration from AutoGen](https://learn.microsoft.com/en-us/agent-framework/migration-guide/from-autogen)** - Guide to migrate from AutoGen
Still have questions? Join our [weekly office hours](./COMMUNITY.md#public-community-office-hours) or ask questions in our [Discord channel](https://discord.gg/b5zjErwbQM) to get help from the team and other users.
### Quickstart
### ✨ **Highlights**
- **Graph-based Workflows**: Connect agents and deterministic functions using data flows with streaming, checkpointing, human-in-the-loop, and time-travel capabilities
- [Python workflows](./python/samples/03-workflows/) | [.NET workflows](./dotnet/samples/03-workflows/)
- **AF Labs**: Experimental packages for cutting-edge features including benchmarking, reinforcement learning, and research initiatives
- [Labs directory](./python/packages/lab/)
- **DevUI**: Interactive developer UI for agent development, testing, and debugging workflows
- [DevUI package](./python/packages/devui/)
<p align="center">
<a href="https://www.youtube.com/watch?v=mOAaGY4WPvc">
<img src="https://img.youtube.com/vi/mOAaGY4WPvc/hqdefault.jpg" alt="See the DevUI in action" width="480">
</a>
</p>
<p align="center">
<a href="https://www.youtube.com/watch?v=mOAaGY4WPvc">
See the DevUI in action (1 min)
</a>
</p>
- **Python and C#/.NET Support**: Full framework support for both Python and C#/.NET implementations with consistent APIs
- [Python packages](./python/packages/) | [.NET source](./dotnet/src/)
- **Observability**: Built-in OpenTelemetry integration for distributed tracing, monitoring, and debugging
- [Python observability](./python/samples/02-agents/observability/) | [.NET telemetry](./dotnet/samples/02-agents/AgentOpenTelemetry/)
- **Multiple Agent Provider Support**: Support for various LLM providers with more being added continuously
- [Python examples](./python/samples/02-agents/providers/) | [.NET examples](./dotnet/samples/02-agents/AgentProviders/)
- **Middleware**: Flexible middleware system for request/response processing, exception handling, and custom pipelines
- [Python middleware](./python/samples/02-agents/middleware/) | [.NET middleware](./dotnet/samples/02-agents/Agents/Agent_Step11_Middleware/)
### 💬 **We want your feedback!**
- For bugs, please file a [GitHub issue](https://github.com/microsoft/agent-framework/issues).
## Quickstart
### Basic Agent - Python
#### Basic Agent - Python
Create a simple Azure Responses Agent that writes a haiku about the Microsoft Agent Framework
@@ -109,7 +126,7 @@ async def main():
# project_endpoint=os.environ["FOUNDRY_PROJECT_ENDPOINT"],
# model=os.environ["FOUNDRY_MODEL_DEPLOYMENT_NAME"],
),
name="HaikuBot",
name="HaikuAgent",
instructions="You are an upbeat assistant that writes beautifully.",
)
@@ -119,40 +136,24 @@ if __name__ == "__main__":
asyncio.run(main())
```
### Basic Agent - .NET
Create a simple Agent, using Microsoft Foundry with token-based auth, that writes a haiku about the Microsoft Agent Framework
#### Basic Agent - .NET
Create a simple Agent, using Microsoft Foundry that writes a haiku about the Microsoft Agent Framework
```c#
// dotnet add package Microsoft.Agents.AI.Foundry
// Use `az login` to authenticate with Azure CLI
using Azure.AI.Projects;
using Azure.Identity;
using System;
// This sample shows how to create and run a basic agent with AIProjectClient.AsAIAgent(...).
using Azure.AI.Projects;
using Azure.Identity;
using Microsoft.Agents.AI;
var endpoint = Environment.GetEnvironmentVariable("AZURE_AI_PROJECT_ENDPOINT") ?? throw new InvalidOperationException("AZURE_AI_PROJECT_ENDPOINT is not set.");
var deploymentName = Environment.GetEnvironmentVariable("AZURE_AI_MODEL_DEPLOYMENT_NAME") ?? "gpt-5.4-mini";
string endpoint = Environment.GetEnvironmentVariable("AZURE_AI_PROJECT_ENDPOINT") ?? throw new InvalidOperationException("AZURE_AI_PROJECT_ENDPOINT is not set.");
string deploymentName = Environment.GetEnvironmentVariable("AZURE_AI_MODEL_DEPLOYMENT_NAME") ?? "gpt-5.4-mini";
var agent = new AIProjectClient(new Uri(endpoint), new DefaultAzureCredential())
.AsAIAgent(model: deploymentName, name: "HaikuBot", instructions: "You are an upbeat assistant that writes beautifully.");
Console.WriteLine(await agent.RunAsync("Write a haiku about Microsoft Agent Framework."));
```
Create a simple Agent, using OpenAI Responses, that writes a haiku about the Microsoft Agent Framework
```c#
// dotnet add package Microsoft.Agents.AI.OpenAI
using System;
using OpenAI;
using OpenAI.Responses;
// Replace the <apikey> with your OpenAI API key.
var agent = new OpenAIClient("<apikey>")
.GetResponsesClient()
.AsAIAgent(model: "gpt-5.4-mini", name: "HaikuBot", instructions: "You are an upbeat assistant that writes beautifully.");
AIAgent agent =
new AIProjectClient(new Uri(endpoint), new DefaultAzureCredential())
.AsAIAgent(model: deploymentName, instructions: "You are an upbeat assistant that writes beautifully.", name: "HaikuAgent");
// Once you have the agent, you can invoke it like any other AIAgent.
Console.WriteLine(await agent.RunAsync("Write a haiku about Microsoft Agent Framework."));
```
@@ -175,6 +176,12 @@ Console.WriteLine(await agent.RunAsync("Write a haiku about Microsoft Agent Fram
- [Hosting](./dotnet/samples/04-hosting): A2A, Durable Agents, Durable Workflows
- [End-to-End](./dotnet/samples/05-end-to-end): full applications and demos
## Community & Feedback
- **Found a bug?** File a [GitHub issue](https://github.com/microsoft/agent-framework/issues) to help us improve.
- **Enjoying MAF?** [![GitHub stars](https://img.shields.io/badge/Star-us%20on%20GitHub-yellow)](https://github.com/microsoft/agent-framework) to show your support and help others discover the project.
- **Have questions?** Join our [Discord](https://discord.gg/b5zjErwbQM) or visit [weekly office hours](./COMMUNITY.md#public-community-office-hours).
## Troubleshooting
### Authentication
@@ -187,16 +194,7 @@ Console.WriteLine(await agent.RunAsync("Write a haiku about Microsoft Agent Fram
> **Tip:** `DefaultAzureCredential` is convenient for development but in production, consider using a specific credential (e.g., `ManagedIdentityCredential`) to avoid latency issues, unintended credential probing, and potential security risks from fallback mechanisms.
### Environment Variables
The samples typically read configuration from environment variables. Common required variables:
| Variable | Used by | Purpose |
|----------|---------|---------|
| `AZURE_OPENAI_ENDPOINT` | Azure OpenAI samples | Your Azure OpenAI resource URL |
| `AZURE_OPENAI_DEPLOYMENT_NAME` | Azure OpenAI samples | Model deployment name (e.g. `gpt-4o-mini`) |
| `AZURE_AI_PROJECT_ENDPOINT` | Microsoft Foundry samples | Your Microsoft Foundry project endpoint |
| `AZURE_AI_MODEL_DEPLOYMENT_NAME` | Microsoft Foundry samples | Model deployment name |
| `OPENAI_API_KEY` | OpenAI (non-Azure) samples | Your OpenAI platform API key |
For environment variable configuration specific to each sample, refer to the README in the sample directory ([Python samples](./python/samples/) | [.NET samples](./dotnet/samples/)).
## Contributor Resources
@@ -19,8 +19,7 @@ namespace Azure.AI.Projects;
/// Foundry toolbox definitions as server-side tools.
/// </summary>
/// <remarks>
/// These extensions mirror Python's <c>FoundryChatClient.get_toolbox()</c> pattern,
/// allowing a single call on the project client to retrieve tools ready for use
/// Provides a single call on the project client to retrieve tools ready for use
/// with <c>AsAIAgent(model, instructions, tools: ...)</c>.
/// </remarks>
[Experimental(DiagnosticIds.Experiments.AIOpenAIResponses)]
@@ -77,23 +77,31 @@ public class AgentFrameworkResponseHandler : ResponseHandler
// 4. Convert input: history + current input → ChatMessage[]
var messages = new List<ChatMessage>();
// Load conversation history if available
var history = await context.GetHistoryAsync(cancellationToken).ConfigureAwait(false);
if (history.Count > 0)
// Load conversation history only for fresh sessions. When a session already exists
// (e.g. resuming a workflow paused at an external-input port), the workflow's
// checkpointed state already contains the prior turns' messages — replaying history
// would re-drive completed actions and break HITL resume semantics.
var isResume = !string.IsNullOrWhiteSpace(sessionConversationId)
&& session?.StateBag?.Count > 0;
if (!isResume)
{
messages.AddRange(InputConverter.ConvertOutputItemsToMessages(history));
var history = await context.GetHistoryAsync(cancellationToken).ConfigureAwait(false);
if (history.Count > 0)
{
messages.AddRange(InputConverter.ConvertOutputItemsToMessages(history, session?.StateBag));
}
}
// Load and convert current input items
var inputItems = await context.GetInputItemsAsync(cancellationToken: cancellationToken).ConfigureAwait(false);
if (inputItems.Count > 0)
{
messages.AddRange(InputConverter.ConvertItemsToMessages(inputItems));
messages.AddRange(InputConverter.ConvertItemsToMessages(inputItems, session?.StateBag));
}
else
{
// Fall back to raw request input
messages.AddRange(InputConverter.ConvertInputToMessages(request));
messages.AddRange(InputConverter.ConvertInputToMessages(request, session?.StateBag));
}
// 5. Build chat options
@@ -191,6 +199,7 @@ public class AgentFrameworkResponseHandler : ResponseHandler
var enumerator = OutputConverter.ConvertUpdatesToEventsAsync(
agent.RunStreamingAsync(messages, session, options: options, cancellationToken: consentCts.Token),
stream,
session?.StateBag,
cancellationToken).GetAsyncEnumerator(cancellationToken);
try
{
@@ -0,0 +1,261 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using System.Buffers;
using System.Diagnostics.CodeAnalysis;
using System.IO;
using System.Linq;
using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Shared.DiagnosticIds;
namespace Microsoft.Agents.AI.Foundry.Hosting;
/// <summary>
/// Provides a file-system backed implementation of <see cref="AgentSessionStore"/> that persists
/// the agent-framework's serialized <see cref="AgentSession"/> state for each (agent, conversation)
/// pair to disk. This complements Foundry storage (which owns conversation messages, agent
/// definitions, and threads) — it is not a replacement for it.
/// </summary>
/// <remarks>
/// <para>
/// The session JSON stored here is the AF runtime's own state (workflow checkpoint manager,
/// pending external requests, internal port state) that is required to resume an
/// <see cref="AgentSession"/> across HTTP requests or process restarts but is not part of
/// Foundry's data model.
/// </para>
/// <para>
/// When running in a Foundry hosted environment, sessions are stored under the well-known
/// <c>/.checkpoints</c> path; locally, they fall under <c>{cwd}/.checkpoints</c>. The session
/// JSON produced when the agent serializes the session already contains the workflow's
/// in-memory checkpoint manager state, so a single file per (agent, conversation) pair is
/// sufficient to resume long-running workflows across process restarts.
/// </para>
/// <para>
/// Files are written atomically via a temp-file + <see cref="File.Move(string, string, bool)"/>
/// rename so a partially-written file cannot be observed by a concurrent reader.
/// </para>
/// </remarks>
[Experimental(DiagnosticIds.Experiments.AIOpenAIResponses)]
public sealed class FileSystemAgentSessionStore : AgentSessionStore
{
/// <summary>
/// The well-known absolute path used when running inside a Foundry hosted environment.
/// </summary>
public const string HostedCheckpointDirectory = "/.checkpoints";
/// <summary>
/// The directory name used under the current working directory when running locally.
/// </summary>
public const string LocalCheckpointDirectoryName = ".checkpoints";
/// <summary>
/// Initializes a new instance of the <see cref="FileSystemAgentSessionStore"/> class
/// that stores serialized sessions under <paramref name="rootDirectory"/>.
/// </summary>
/// <param name="rootDirectory">
/// The absolute or relative directory where session files will be written.
/// The directory is created on first write if it does not already exist.
/// </param>
public FileSystemAgentSessionStore(string rootDirectory)
{
ArgumentException.ThrowIfNullOrWhiteSpace(rootDirectory);
this.RootDirectory = Path.GetFullPath(rootDirectory);
}
/// <summary>
/// Gets the root directory under which session files are written.
/// </summary>
public string RootDirectory { get; }
/// <summary>
/// Creates a <see cref="FileSystemAgentSessionStore"/> rooted at the default location:
/// <see cref="HostedCheckpointDirectory"/> when running in a Foundry hosted environment,
/// otherwise <see cref="LocalCheckpointDirectoryName"/> under the current working directory.
/// </summary>
/// <returns>A new <see cref="FileSystemAgentSessionStore"/> instance.</returns>
public static FileSystemAgentSessionStore CreateDefault()
{
string root = FoundryEnvironment.IsHosted
? HostedCheckpointDirectory
: Path.Combine(Environment.CurrentDirectory, LocalCheckpointDirectoryName);
return new FileSystemAgentSessionStore(root);
}
/// <inheritdoc/>
public override async ValueTask SaveSessionAsync(AIAgent agent, string conversationId, AgentSession session, CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(agent);
ArgumentException.ThrowIfNullOrWhiteSpace(conversationId);
ArgumentNullException.ThrowIfNull(session);
JsonElement serialized = await agent.SerializeSessionAsync(session, cancellationToken: cancellationToken).ConfigureAwait(false);
Directory.CreateDirectory(this.RootDirectory);
string path = this.GetSessionPath(agent, conversationId);
string? parentDir = Path.GetDirectoryName(path);
if (!string.IsNullOrEmpty(parentDir))
{
Directory.CreateDirectory(parentDir);
}
// Each save writes to its own temp file before atomically renaming over the
// destination. Last writer wins for the final file, but no reader can observe
// a torn or partially-written JSON document.
string tempPath = $"{path}.{Guid.NewGuid():N}.tmp";
try
{
using (FileStream stream = new(tempPath, FileMode.Create, FileAccess.Write, FileShare.None))
using (Utf8JsonWriter writer = new(stream))
{
serialized.WriteTo(writer);
}
File.Move(tempPath, path, overwrite: true);
}
catch
{
try { File.Delete(tempPath); } catch { /* best-effort cleanup */ }
throw;
}
}
/// <inheritdoc/>
public override async ValueTask<AgentSession> GetSessionAsync(AIAgent agent, string conversationId, CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(agent);
ArgumentException.ThrowIfNullOrWhiteSpace(conversationId);
string path = this.GetSessionPath(agent, conversationId);
if (!File.Exists(path))
{
return await agent.CreateSessionAsync(cancellationToken).ConfigureAwait(false);
}
byte[] bytes = await File.ReadAllBytesAsync(path, cancellationToken).ConfigureAwait(false);
if (bytes.Length == 0)
{
return await agent.CreateSessionAsync(cancellationToken).ConfigureAwait(false);
}
// Parse and clone so the document buffer can be released.
using JsonDocument document = JsonDocument.Parse(bytes);
JsonElement element = document.RootElement.Clone();
return await agent.DeserializeSessionAsync(element, cancellationToken: cancellationToken).ConfigureAwait(false);
}
private string GetSessionPath(AIAgent agent, string conversationId)
{
// When agent.Name is set we bucket sessions into a per-agent subdirectory so
// multiple keyed agents sharing a single in-process default store cannot
// collide on the same conversationId. agent.Id is intentionally NOT used
// because it is regenerated on every startup for in-memory-defined agents.
string fileName = $"{Sanitize(conversationId)}.json";
if (string.IsNullOrEmpty(agent.Name))
{
return Path.Combine(this.RootDirectory, fileName);
}
string agentDir = Path.Combine(this.RootDirectory, Sanitize(agent.Name!));
return Path.Combine(agentDir, fileName);
}
private static string Sanitize(string value)
{
// Percent-encode every character that is invalid in a filename, plus '%' itself
// so the encoding is unambiguous. This is reversible and avoids the collision
// hazard of a lossy character substitution (e.g. "foo/bar" and "foo_bar" sharing
// a sanitized name).
char[] invalid = Path.GetInvalidFileNameChars();
int encodedLength = ComputeEncodedLength(value, invalid);
// stackalloc is bounded so an externally-controlled length cannot crash the
// hosting process with StackOverflowException.
const int StackLimit = 512;
string sanitized;
if (encodedLength <= StackLimit)
{
Span<char> buffer = stackalloc char[encodedLength];
SanitizeCore(value, invalid, buffer);
sanitized = new string(buffer);
}
else
{
char[] rented = ArrayPool<char>.Shared.Rent(encodedLength);
try
{
Span<char> buffer = rented.AsSpan(0, encodedLength);
SanitizeCore(value, invalid, buffer);
sanitized = new string(buffer);
}
finally
{
ArrayPool<char>.Shared.Return(rented);
}
}
// '.' and '..' are valid filename characters but resolve to current/parent
// directory when used as a bare path component. Windows additionally strips
// trailing dots from filenames, so a segment like "..." would survive on disk
// as "" and a partial-encode like "%2E.." would survive as "%2E". Encode every
// dot in any all-dot segment so the result has no special meaning to the OS.
if (sanitized.Length > 0 && IsAllDots(sanitized))
{
return string.Concat(Enumerable.Repeat("%2E", sanitized.Length));
}
return sanitized;
}
private static int ComputeEncodedLength(string value, char[] invalid)
{
int extra = 0;
for (int i = 0; i < value.Length; i++)
{
char c = value[i];
if (c == '%' || Array.IndexOf(invalid, c) >= 0)
{
extra += 2; // 1 char ('%' or invalid) becomes 3 chars ("%XX")
}
}
return value.Length + extra;
}
private static bool IsAllDots(string value)
{
for (int i = 0; i < value.Length; i++)
{
if (value[i] != '.')
{
return false;
}
}
return true;
}
private static void SanitizeCore(string value, char[] invalid, Span<char> buffer)
{
int j = 0;
for (int i = 0; i < value.Length; i++)
{
char c = value[i];
if (c == '%' || Array.IndexOf(invalid, c) >= 0)
{
buffer[j++] = '%';
buffer[j++] = HexChar((c >> 4) & 0xF);
buffer[j++] = HexChar(c & 0xF);
}
else
{
buffer[j++] = c;
}
}
}
private static char HexChar(int n) => (char)(n < 10 ? '0' + n : 'A' + n - 10);
}
@@ -32,9 +32,6 @@ namespace Microsoft.Agents.AI.Foundry.Hosting;
/// they are sent as server-side tool definitions in the Responses API request. The Foundry platform
/// handles tool execution — the agent process does not invoke tools locally.
/// </para>
/// <para>
/// This is the dotnet equivalent of Python's <c>FoundryChatClient.get_toolbox()</c> pattern.
/// </para>
/// </remarks>
[Experimental(DiagnosticIds.Experiments.AIOpenAIResponses)]
public static class FoundryToolbox
@@ -3,10 +3,12 @@
using System;
using System.Collections.Generic;
using System.Diagnostics.CodeAnalysis;
using System.Text;
using System.Text.Json;
using Azure.AI.AgentServer.Responses.Models;
using Microsoft.Extensions.AI;
using MeaiTextContent = Microsoft.Extensions.AI.TextContent;
using SdkTextContent = Azure.AI.AgentServer.Responses.Models.TextContent;
namespace Microsoft.Agents.AI.Foundry.Hosting;
@@ -19,14 +21,15 @@ internal static class InputConverter
/// Converts the SDK <see cref="CreateResponse"/> request input items into a list of <see cref="ChatMessage"/>.
/// </summary>
/// <param name="request">The create response request from the SDK.</param>
/// <param name="stateBag">Optional session state bag carrying the tool-approval id mapping.</param>
/// <returns>A list of chat messages representing the request input.</returns>
public static List<ChatMessage> ConvertInputToMessages(CreateResponse request)
public static List<ChatMessage> ConvertInputToMessages(CreateResponse request, AgentSessionStateBag? stateBag = null)
{
var messages = new List<ChatMessage>();
foreach (var item in request.GetInputExpanded())
{
var message = ConvertInputItemToMessage(item);
var message = ConvertInputItemToMessage(item, stateBag);
if (message is not null)
{
messages.Add(message);
@@ -40,14 +43,15 @@ internal static class InputConverter
/// Converts resolved SDK <see cref="Item"/> input items into <see cref="ChatMessage"/> instances.
/// </summary>
/// <param name="items">The resolved input items from the SDK context.</param>
/// <param name="stateBag">Optional session state bag carrying the tool-approval id mapping.</param>
/// <returns>A list of chat messages.</returns>
public static List<ChatMessage> ConvertItemsToMessages(IReadOnlyList<Item> items)
public static List<ChatMessage> ConvertItemsToMessages(IReadOnlyList<Item> items, AgentSessionStateBag? stateBag = null)
{
var messages = new List<ChatMessage>();
foreach (var item in items)
{
var message = ConvertInputItemToMessage(item);
var message = ConvertInputItemToMessage(item, stateBag);
if (message is not null)
{
messages.Add(message);
@@ -61,14 +65,15 @@ internal static class InputConverter
/// Converts resolved SDK <see cref="OutputItem"/> history/input items into <see cref="ChatMessage"/> instances.
/// </summary>
/// <param name="items">The resolved output items from the SDK context.</param>
/// <param name="stateBag">Optional session state bag carrying the tool-approval id mapping.</param>
/// <returns>A list of chat messages.</returns>
public static List<ChatMessage> ConvertOutputItemsToMessages(IReadOnlyList<OutputItem> items)
public static List<ChatMessage> ConvertOutputItemsToMessages(IReadOnlyList<OutputItem> items, AgentSessionStateBag? stateBag = null)
{
var messages = new List<ChatMessage>();
foreach (var item in items)
{
var message = ConvertOutputItemToMessage(item);
var message = ConvertOutputItemToMessage(item, stateBag);
if (message is not null)
{
messages.Add(message);
@@ -128,13 +133,15 @@ internal static class InputConverter
return markers;
}
private static ChatMessage? ConvertInputItemToMessage(Item item)
private static ChatMessage? ConvertInputItemToMessage(Item item, AgentSessionStateBag? stateBag)
{
return item switch
{
ItemMessage msg => ConvertItemMessage(msg),
FunctionCallOutputItemParam funcOutput => ConvertFunctionCallOutput(funcOutput),
ItemFunctionToolCall funcCall => ConvertItemFunctionToolCall(funcCall),
ItemMcpApprovalRequest approvalRequest => ConvertMcpApprovalRequest(approvalRequest.Id, approvalRequest.Name, approvalRequest.Arguments),
MCPApprovalResponse approvalResponse => ConvertMcpApprovalResponse(approvalResponse.ApprovalRequestId, approvalResponse.Approve, stateBag),
ItemReferenceParam => null,
_ => null
};
@@ -152,43 +159,23 @@ internal static class InputConverter
case MessageContentInputTextContent textContent:
contents.Add(new MeaiTextContent(textContent.Text));
break;
case SdkTextContent textContent:
contents.Add(new MeaiTextContent(textContent.Text));
break;
case SummaryTextContent summary:
contents.Add(new MeaiTextContent(summary.Text));
break;
case MessageContentReasoningTextContent reasoning:
contents.Add(new TextReasoningContent(reasoning.Text));
break;
case MessageContentInputImageContent imageContent:
if (imageContent.ImageUrl is not null)
{
var url = imageContent.ImageUrl.ToString();
if (url.StartsWith("data:", StringComparison.OrdinalIgnoreCase))
{
contents.Add(new DataContent(url, "image/*"));
}
else
{
contents.Add(new UriContent(imageContent.ImageUrl, "image/*"));
}
}
else if (!string.IsNullOrEmpty(imageContent.FileId))
{
contents.Add(new HostedFileContent(imageContent.FileId));
}
AppendImageContent(contents, imageContent.ImageUrl, imageContent.FileId);
break;
case MessageContentInputFileContent fileContent:
if (fileContent.FileUrl is not null)
{
contents.Add(new UriContent(fileContent.FileUrl, "application/octet-stream"));
}
else if (!string.IsNullOrEmpty(fileContent.FileData))
{
contents.Add(new DataContent(fileContent.FileData, "application/octet-stream"));
}
else if (!string.IsNullOrEmpty(fileContent.FileId))
{
contents.Add(new HostedFileContent(fileContent.FileId));
}
else if (!string.IsNullOrEmpty(fileContent.Filename))
{
contents.Add(new MeaiTextContent($"[File: {fileContent.Filename}]"));
}
AppendFileContent(contents, fileContent.FileUrl, fileContent.FileData, fileContent.FileId, fileContent.Filename);
break;
case ComputerScreenshotContent screenshot:
AppendImageContent(contents, screenshot.ImageUrl, screenshot.FileId);
break;
}
}
@@ -231,13 +218,63 @@ internal static class InputConverter
[new FunctionCallContent(funcCall.CallId, funcCall.Name, arguments)]);
}
private static ChatMessage? ConvertOutputItemToMessage(OutputItem item)
/// <summary>
/// Converts an inbound <c>mcp_approval_request</c> wire item (from history replay
/// or fresh-input) to a <see cref="ToolApprovalRequestContent"/> wrapping a
/// <see cref="FunctionCallContent"/>.
/// </summary>
private static ChatMessage ConvertMcpApprovalRequest(string id, string name, string? arguments)
{
var functionCall = new FunctionCallContent(id, name, ParseFunctionArgumentsObject(arguments));
return new ChatMessage(
ChatRole.Assistant,
[new ToolApprovalRequestContent(id, functionCall)]);
}
/// <summary>
/// Converts an inbound <c>mcp_approval_response</c> wire item to a
/// <see cref="ToolApprovalResponseContent"/>. Looks up the original AF request id
/// via <see cref="ToolApprovalIdMap"/>; falls back to the wire id when the mapping
/// is unavailable. Carries a placeholder <see cref="FunctionCallContent"/> because
/// the original tool-call details are not echoed by clients in the response item.
/// </summary>
private static ChatMessage ConvertMcpApprovalResponse(string approvalRequestId, bool approve, AgentSessionStateBag? stateBag)
{
var afRequestId = ToolApprovalIdMap.Resolve(stateBag, approvalRequestId);
var placeholderFunctionCall = new FunctionCallContent(afRequestId, "mcp_approval");
return new ChatMessage(
ChatRole.User,
[new ToolApprovalResponseContent(afRequestId, approve, placeholderFunctionCall)]);
}
[UnconditionalSuppressMessage("Trimming", "IL2026", Justification = "Deserializing tool-call arguments from SDK input.")]
[UnconditionalSuppressMessage("AOT", "IL3050", Justification = "Deserializing tool-call arguments from SDK input.")]
private static Dictionary<string, object?>? ParseFunctionArgumentsObject(string? arguments)
{
if (string.IsNullOrWhiteSpace(arguments))
{
return null;
}
try
{
return JsonSerializer.Deserialize<Dictionary<string, object?>>(arguments);
}
catch (JsonException)
{
return new Dictionary<string, object?> { ["_raw"] = arguments };
}
}
private static ChatMessage? ConvertOutputItemToMessage(OutputItem item, AgentSessionStateBag? stateBag)
{
return item switch
{
OutputItemMessage msg => ConvertOutputItemMessageToChat(msg),
OutputItemFunctionToolCall funcCall => ConvertOutputItemFunctionCall(funcCall),
OutputItemFunctionToolCallOutput funcOutput => ConvertFunctionToolCallOutput(funcOutput),
OutputItemMcpApprovalRequest approvalRequest => ConvertMcpApprovalRequest(approvalRequest.Id, approvalRequest.Name, approvalRequest.Arguments),
OutputItemMcpApprovalResponseResource approvalResponse => ConvertMcpApprovalResponse(approvalResponse.ApprovalRequestId, approvalResponse.Approve, stateBag),
OutputItemReasoningItem => null,
_ => null
};
@@ -258,46 +295,26 @@ internal static class InputConverter
case MessageContentOutputTextContent textContent:
contents.Add(new MeaiTextContent(textContent.Text));
break;
case SdkTextContent textContent:
contents.Add(new MeaiTextContent(textContent.Text));
break;
case SummaryTextContent summary:
contents.Add(new MeaiTextContent(summary.Text));
break;
case MessageContentReasoningTextContent reasoning:
contents.Add(new TextReasoningContent(reasoning.Text));
break;
case MessageContentRefusalContent refusal:
contents.Add(new MeaiTextContent($"[Refusal: {refusal.Refusal}]"));
break;
case MessageContentInputImageContent imageContent:
if (imageContent.ImageUrl is not null)
{
var url = imageContent.ImageUrl.ToString();
if (url.StartsWith("data:", StringComparison.OrdinalIgnoreCase))
{
contents.Add(new DataContent(url, "image/*"));
}
else
{
contents.Add(new UriContent(imageContent.ImageUrl, "image/*"));
}
}
else if (!string.IsNullOrEmpty(imageContent.FileId))
{
contents.Add(new HostedFileContent(imageContent.FileId));
}
AppendImageContent(contents, imageContent.ImageUrl, imageContent.FileId);
break;
case MessageContentInputFileContent fileContent:
if (fileContent.FileUrl is not null)
{
contents.Add(new UriContent(fileContent.FileUrl, "application/octet-stream"));
}
else if (!string.IsNullOrEmpty(fileContent.FileData))
{
contents.Add(new DataContent(fileContent.FileData, "application/octet-stream"));
}
else if (!string.IsNullOrEmpty(fileContent.FileId))
{
contents.Add(new HostedFileContent(fileContent.FileId));
}
else if (!string.IsNullOrEmpty(fileContent.Filename))
{
contents.Add(new MeaiTextContent($"[File: {fileContent.Filename}]"));
}
AppendFileContent(contents, fileContent.FileUrl, fileContent.FileData, fileContent.FileId, fileContent.Filename);
break;
case ComputerScreenshotContent screenshot:
AppendImageContent(contents, screenshot.ImageUrl, screenshot.FileId);
break;
}
}
@@ -310,6 +327,127 @@ internal static class InputConverter
return new ChatMessage(role, contents);
}
private static void AppendImageContent(List<AIContent> contents, Uri? imageUrl, string? fileId)
{
if (imageUrl is not null)
{
var url = imageUrl.ToString();
if (url.StartsWith("data:", StringComparison.OrdinalIgnoreCase))
{
contents.Add(new DataContent(url, "image/*"));
}
else
{
contents.Add(new UriContent(imageUrl, "image/*"));
}
}
else if (!string.IsNullOrEmpty(fileId))
{
contents.Add(new HostedFileContent(fileId));
}
}
private static void AppendFileContent(List<AIContent> contents, Uri? fileUrl, string? fileData, string? fileId, string? filename)
{
if (fileUrl is not null)
{
var content = new UriContent(fileUrl, "application/octet-stream");
if (!string.IsNullOrEmpty(filename))
{
content.AdditionalProperties = new AdditionalPropertiesDictionary { ["filename"] = filename };
}
contents.Add(content);
return;
}
if (!string.IsNullOrEmpty(fileData))
{
// If the data URI carries text/* content, decode it inline as TextContent so
// {System.LastMessageText} (and other text-only consumers) sees the file's
// body rather than an opaque blob.
if (TryDecodeTextDataUri(fileData, filename, out var decodedText))
{
contents.Add(new MeaiTextContent(decodedText));
}
else
{
var dataContent = new DataContent(fileData, "application/octet-stream");
if (!string.IsNullOrEmpty(filename))
{
dataContent.AdditionalProperties = new AdditionalPropertiesDictionary { ["filename"] = filename };
}
contents.Add(dataContent);
}
return;
}
if (!string.IsNullOrEmpty(fileId))
{
var hosted = new HostedFileContent(fileId);
if (!string.IsNullOrEmpty(filename))
{
hosted.AdditionalProperties = new AdditionalPropertiesDictionary { ["filename"] = filename };
}
contents.Add(hosted);
return;
}
if (!string.IsNullOrEmpty(filename))
{
contents.Add(new MeaiTextContent($"[File: {filename}]"));
}
}
private static bool TryDecodeTextDataUri(string dataUri, string? filename, out string text)
{
// Cap the encoded payload so an oversized client-supplied data URI cannot
// trigger an unbounded allocation in Convert.FromBase64String. 16 MiB
// encoded → ~12 MiB decoded, well above any realistic text/* file we'd
// want to inline as content while still bounding the worst case.
const int MaxEncodedLength = 16 * 1024 * 1024;
text = string.Empty;
if (!dataUri.StartsWith("data:", StringComparison.OrdinalIgnoreCase))
{
return false;
}
const string Marker = ";base64,";
int markerIndex = dataUri.IndexOf(Marker, StringComparison.OrdinalIgnoreCase);
if (markerIndex < 0)
{
return false;
}
string mediaType = dataUri.Substring("data:".Length, markerIndex - "data:".Length);
if (!mediaType.StartsWith("text/", StringComparison.OrdinalIgnoreCase))
{
return false;
}
string encoded = dataUri.Substring(markerIndex + Marker.Length);
if (encoded.Length > MaxEncodedLength)
{
return false;
}
try
{
byte[] bytes = Convert.FromBase64String(encoded);
string decoded = Encoding.UTF8.GetString(bytes);
text = string.IsNullOrEmpty(filename) ? decoded : $"[File: {filename}]\n{decoded}";
return true;
}
catch (FormatException)
{
return false;
}
catch (DecoderFallbackException)
{
return false;
}
}
[UnconditionalSuppressMessage("Trimming", "IL2026", Justification = "Deserializing function call arguments from SDK output history.")]
[UnconditionalSuppressMessage("AOT", "IL3050", Justification = "Deserializing function call arguments from SDK output history.")]
private static ChatMessage ConvertOutputItemFunctionCall(OutputItemFunctionToolCall funcCall)
@@ -30,6 +30,7 @@ internal static class OutputConverter
/// </summary>
/// <param name="updates">The agent response updates to convert.</param>
/// <param name="stream">The SDK event stream builder.</param>
/// <param name="stateBag">Optional session state bag used to persist tool-approval id mappings across turns.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>An async enumerable of SDK response stream events (excluding lifecycle events).</returns>
[UnconditionalSuppressMessage("Trimming", "IL2026", Justification = "Serializing function call arguments dictionary.")]
@@ -37,6 +38,7 @@ internal static class OutputConverter
public static async IAsyncEnumerable<ResponseStreamEvent> ConvertUpdatesToEventsAsync(
IAsyncEnumerable<AgentResponseUpdate> updates,
ResponseEventStream stream,
AgentSessionStateBag? stateBag = null,
[EnumeratorCancellation] CancellationToken cancellationToken = default)
{
ResponseUsage? accumulatedUsage = null;
@@ -51,8 +53,11 @@ internal static class OutputConverter
{
cancellationToken.ThrowIfCancellationRequested();
// Handle workflow events from RawRepresentation
if (update.RawRepresentation is WorkflowEvent workflowEvent)
// Handle workflow events from RawRepresentation.
// If the update also carries Contents (e.g. WorkflowSession unwrapped a
// WorkflowErrorEvent or ExecutorFailedEvent into an ErrorContent payload),
// fall through to the content-processing path below so those are emitted.
if (update.RawRepresentation is WorkflowEvent workflowEvent && update.Contents.Count == 0)
{
// Close any open message builder before emitting workflow items
foreach (var evt in CloseCurrentMessage(currentMessageBuilder, currentTextBuilder, accumulatedText))
@@ -166,6 +171,54 @@ internal static class OutputConverter
break;
}
case ToolApprovalRequestContent approvalRequest when approvalRequest.ToolCall is FunctionCallContent approvalFunctionCall:
{
foreach (var evt in CloseCurrentMessage(currentMessageBuilder, currentTextBuilder, accumulatedText))
{
yield return evt;
}
currentTextBuilder = null;
currentMessageBuilder = null;
accumulatedText = null;
previousMessageId = null;
// The Responses API only standardizes the MCP-flavored approval primitive.
// We emit the AF tool-approval request as `mcp_approval_request` with
// server_label="agent_framework" — declaring the AF runtime as the virtual
// server holding this call. The SDK requires a strict {prefix}_{50hex}
// wire-id format, so we hash the AF RequestId and persist the
// wireId↔afRequestId mapping in the session state bag for later lookup
// when the matching `mcp_approval_response` arrives on a subsequent turn.
var wireId = ToolApprovalIdMap.ComputeWireId(approvalRequest.RequestId);
ToolApprovalIdMap.Record(stateBag, wireId, approvalRequest.RequestId);
var approvalArguments = approvalFunctionCall.Arguments is not null
? JsonSerializer.Serialize(approvalFunctionCall.Arguments)
: "{}";
var approvalItem = new OutputItemMcpApprovalRequest(
wireId,
"agent_framework",
approvalFunctionCall.Name,
approvalArguments);
var approvalBuilder = stream.AddOutputItem<OutputItemMcpApprovalRequest>(wireId);
yield return approvalBuilder.EmitAdded(approvalItem);
yield return approvalBuilder.EmitDone(approvalItem);
break;
}
case ToolApprovalRequestContent:
// Approval requests must wrap a FunctionCallContent (handled above).
// Any other shape has no representation in the Responses wire format.
break;
case ToolApprovalResponseContent:
// Approval responses originate from the client and travel inbound; the
// workflow does not re-emit them. Skip silently if encountered.
break;
case UsageContent usageContent when usageContent.Details is not null:
{
accumulatedUsage = ConvertUsage(usageContent.Details, accumulatedUsage);
@@ -49,7 +49,7 @@ public static class FoundryHostingExtensions
{
ArgumentNullException.ThrowIfNull(services);
services.AddResponsesServer();
services.TryAddSingleton<AgentSessionStore, InMemoryAgentSessionStore>();
services.TryAddSingleton<AgentSessionStore>(_ => FileSystemAgentSessionStore.CreateDefault());
services.TryAddSingleton<ResponseHandler, AgentFrameworkResponseHandler>();
return services;
}
@@ -76,7 +76,7 @@ public static class FoundryHostingExtensions
/// </remarks>
/// <param name="services">The service collection.</param>
/// <param name="agent">The agent instance to register.</param>
/// <param name="agentSessionStore">The agent session store to use for managing agent sessions server-side. If null, an in-memory session store will be used.</param>
/// <param name="agentSessionStore">The agent session store to use for managing agent sessions server-side. If null, a file-system session store is used, rooted at <c>/.checkpoints</c> when running in a Foundry hosted environment and <c>{cwd}/.checkpoints</c> locally.</param>
/// <returns>The service collection for chaining.</returns>
public static IServiceCollection AddFoundryResponses(this IServiceCollection services, AIAgent agent, AgentSessionStore? agentSessionStore = null)
{
@@ -84,7 +84,7 @@ public static class FoundryHostingExtensions
ArgumentNullException.ThrowIfNull(agent);
services.AddResponsesServer();
agentSessionStore ??= new InMemoryAgentSessionStore();
agentSessionStore ??= FileSystemAgentSessionStore.CreateDefault();
if (!string.IsNullOrWhiteSpace(agent.Name))
{
@@ -185,8 +185,6 @@ public static class FoundryHostingExtensions
/// <summary>
/// The ActivitySource name for the Responses hosting pipeline.
/// Matches the value previously exposed by <c>AgentHostTelemetry.ResponsesSourceName</c>
/// in <c>Azure.AI.AgentServer.Core</c>.
/// </summary>
private const string ResponsesSourceName = "Azure.AI.AgentServer.Responses";
@@ -0,0 +1,73 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using System.Collections.Generic;
using System.Security.Cryptography;
using System.Text;
namespace Microsoft.Agents.AI.Foundry.Hosting;
/// <summary>
/// Helper for translating between agent-framework tool-approval request ids and the
/// strict-format wire ids required by the Responses Server SDK <c>mcp_approval_request</c>
/// item type. The mapping is persisted in <see cref="AgentSessionStateBag"/> so an
/// approval request emitted on one HTTP turn can be matched to the response posted
/// back on the next turn.
/// </summary>
internal static class ToolApprovalIdMap
{
/// <summary>
/// State-bag key used to store the wire-id ↔ AF-request-id mapping.
/// </summary>
public const string StateBagKey = "Microsoft.Agents.AI.Foundry.Hosting.ToolApprovalIdMap";
/// <summary>
/// SDK item-id format constraints: <c>{prefix}_{50_or_48_chars}</c>. We use the
/// canonical <c>mcpr_</c> prefix and a SHA-256 truncated to 50 hex chars (25 bytes)
/// for deterministic, format-safe wire ids.
/// </summary>
public static string ComputeWireId(string afRequestId)
{
ArgumentNullException.ThrowIfNull(afRequestId);
#if NET10_0_OR_GREATER
Span<byte> hash = stackalloc byte[32];
SHA256.HashData(Encoding.UTF8.GetBytes(afRequestId), hash);
#else
byte[] hash = SHA256.HashData(Encoding.UTF8.GetBytes(afRequestId));
#endif
// 25 bytes = 50 hex chars (matches SDK body length 50).
return "mcpr_" + Convert.ToHexString(hash).AsSpan(0, 50).ToString();
}
/// <summary>
/// Records the wire-id → AF-request-id mapping in the supplied state bag.
/// </summary>
public static void Record(AgentSessionStateBag? stateBag, string wireId, string afRequestId)
{
if (stateBag is null)
{
return;
}
var map = stateBag.GetValue<Dictionary<string, string>>(StateBagKey)
?? new Dictionary<string, string>(StringComparer.Ordinal);
map[wireId] = afRequestId;
stateBag.SetValue(StateBagKey, map);
}
/// <summary>
/// Looks up the AF request id for a given wire id. Returns the wire id verbatim
/// when no mapping is present (best-effort fallback that keeps converters total).
/// </summary>
public static string Resolve(AgentSessionStateBag? stateBag, string wireId)
{
if (stateBag?.GetValue<Dictionary<string, string>>(StateBagKey) is { } map
&& map.TryGetValue(wireId, out var afRequestId))
{
return afRequestId;
}
return wireId;
}
}
@@ -56,6 +56,13 @@ public static class DeclarativeWorkflowBuilder
/// <param name="options">Configuration options for workflow execution.</param>
/// <param name="inputTransform">An optional function to transform the input message into a <see cref="ChatMessage"/>.</param>
/// <returns>The <see cref="Workflow"/> that corresponds with the YAML object model.</returns>
/// <remarks>
/// The returned workflow's root executor accepts <typeparamref name="TInput"/>,
/// <see cref="ChatMessage"/>, <see cref="System.Collections.Generic.IEnumerable{T}"/> of
/// <see cref="ChatMessage"/>, <see cref="string"/>, and <see cref="TurnToken"/>. This
/// makes the workflow usable both for direct invocation and for hosting via
/// <see cref="WorkflowHostingExtensions.AsAIAgent(Workflow, string?, string?, string?, IWorkflowExecutionEnvironment?, bool, bool)"/>.
/// </remarks>
public static Workflow Build<TInput>(
TextReader yamlReader,
DeclarativeWorkflowOptions options,
@@ -1,5 +1,6 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Collections.Generic;
using System.Text.Json.Serialization;
using Microsoft.Extensions.AI;
@@ -8,7 +9,7 @@ namespace Microsoft.Agents.AI.Workflows.Declarative.Events;
/// <summary>
/// Represents a request for external input.
/// </summary>
public sealed class ExternalInputRequest
public sealed class ExternalInputRequest : IExternalRequestEnvelope
{
/// <summary>
/// The source message that triggered the request for external input.
@@ -30,4 +31,47 @@ public sealed class ExternalInputRequest
{
this.AgentResponse = new AgentResponse(new ChatMessage(ChatRole.User, text));
}
/// <inheritdoc />
/// <remarks>
/// Prefers <see cref="ToolApprovalRequestContent"/> (when the workflow declared
/// <c>requireApproval: true</c>) over <see cref="FunctionCallContent"/> so that
/// hosts which speak the approval protocol see the approval-bearing content.
/// </remarks>
AIContent? IExternalRequestEnvelope.GetInnerRequestContent()
{
IList<ChatMessage>? messages = this.AgentResponse?.Messages;
if (messages is null)
{
return null;
}
foreach (ChatMessage message in messages)
{
foreach (AIContent content in message.Contents)
{
if (content is ToolApprovalRequestContent toolApprovalRequest)
{
return toolApprovalRequest;
}
}
}
foreach (ChatMessage message in messages)
{
foreach (AIContent content in message.Contents)
{
if (content is FunctionCallContent functionCall)
{
return functionCall;
}
}
}
return null;
}
/// <inheritdoc />
object IExternalRequestEnvelope.CreateResponse(IList<ChatMessage> messages)
=> new ExternalInputResponse(messages);
}
@@ -1,6 +1,7 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Agents.AI.Workflows.Declarative.Extensions;
@@ -13,6 +14,24 @@ namespace Microsoft.Agents.AI.Workflows.Declarative.Interpreter;
/// <summary>
/// The root executor for a declarative workflow.
/// </summary>
/// <remarks>
/// In addition to the strongly-typed <typeparamref name="TInput"/> route inherited from
/// <see cref="Executor{TInput}"/>, this executor also accepts <see cref="string"/>,
/// <see cref="ChatMessage"/>, <see cref="IEnumerable{T}"/> of <see cref="ChatMessage"/>,
/// <see cref="ChatMessage"/><c>[]</c>, and <see cref="TurnToken"/> so that the workflow
/// satisfies <see cref="ChatProtocolExtensions.IsChatProtocol"/>. This makes the workflow
/// usable both for direct <c>Run.SendMessageAsync(input)</c> invocations and for hosting
/// via <see cref="WorkflowHostingExtensions.AsAIAgent(Workflow, string?, string?, string?, IWorkflowExecutionEnvironment?, bool, bool)"/>.
///
/// <para>
/// Each non-<see cref="TurnToken"/> input drives the declarative graph forward
/// immediately. The host's <see cref="TurnToken"/> arrives after the message batch and
/// is treated as a no-op because the inbound message has already been processed.
/// External responses (HITL function results) bypass the start executor entirely
/// (they are routed via <c>WorkflowSession.SendResponseAsync</c> to request-info
/// executors), so the start executor only ever sees a single inbound batch per turn.
/// </para>
/// </remarks>
internal sealed class DeclarativeWorkflowExecutor<TInput>(
string workflowId,
DeclarativeWorkflowOptions options,
@@ -26,29 +45,143 @@ internal sealed class DeclarativeWorkflowExecutor<TInput>(
return default;
}
/// <inheritdoc/>
[SendsMessage(typeof(ActionExecutorResult))]
public override async ValueTask HandleAsync(TInput message, IWorkflowContext context, CancellationToken cancellationToken = default)
public override ValueTask HandleAsync(TInput message, IWorkflowContext context, CancellationToken cancellationToken = default)
{
ChatMessage input = inputTransform.Invoke(message);
return this.AdvanceAsync(input, context, cancellationToken);
}
/// <inheritdoc/>
protected override ProtocolBuilder ConfigureProtocol(ProtocolBuilder protocolBuilder)
{
// Inherit the TInput route + method/class attributes (e.g. SendsMessage on HandleAsync).
ProtocolBuilder result = base.ConfigureProtocol(protocolBuilder);
// Add the chat-protocol input shapes so the workflow satisfies IsChatProtocol
// and can be hosted via AsAIAgent. Skip any shape that already matches TInput
// (the inherited route handles that case via inputTransform).
return result.ConfigureRoutes(this.ConfigureChatProtocolRoutes)
.SendsMessage<ActionExecutorResult>();
}
private void ConfigureChatProtocolRoutes(RouteBuilder routeBuilder)
{
Type tInput = typeof(TInput);
// Skip an exact-type match because RouteBuilder.AddHandler throws on duplicate
// registrations for the same message type. Equality (not IsAssignableFrom) is
// also what ChatProtocolExtensions.IsChatProtocol checks, so always registering
// IEnumerable<ChatMessage> when TInput is broader (e.g. object) keeps the
// workflow chat-protocol-compliant.
if (tInput != typeof(string))
{
routeBuilder.AddHandler<string>(this.HandleStringAsync);
}
if (tInput != typeof(ChatMessage))
{
routeBuilder.AddHandler<ChatMessage>(this.HandleChatMessageAsync);
}
if (tInput != typeof(IEnumerable<ChatMessage>))
{
routeBuilder.AddHandler<IEnumerable<ChatMessage>>(this.HandleChatMessagesAsync);
}
if (tInput != typeof(ChatMessage[]))
{
routeBuilder.AddHandler<ChatMessage[]>(this.HandleChatMessageArrayAsync);
}
if (tInput != typeof(TurnToken))
{
routeBuilder.AddHandler<TurnToken>(this.HandleTurnTokenAsync);
}
}
private ValueTask HandleStringAsync(string message, IWorkflowContext context, CancellationToken cancellationToken)
{
return this.AdvanceAsync(new ChatMessage(ChatRole.User, message), context, cancellationToken);
}
private ValueTask HandleChatMessageAsync(ChatMessage message, IWorkflowContext context, CancellationToken cancellationToken)
{
return this.AdvanceAsync(message, context, cancellationToken);
}
private async ValueTask HandleChatMessagesAsync(IEnumerable<ChatMessage> messages, IWorkflowContext context, CancellationToken cancellationToken)
{
var list = messages as IList<ChatMessage> ?? new List<ChatMessage>(messages);
if (list.Count == 0)
{
return;
}
for (int i = 0; i < list.Count; i++)
{
await this.AdvanceAsync(list[i], context, cancellationToken, finalizeTurn: i == list.Count - 1).ConfigureAwait(false);
}
}
private async ValueTask HandleChatMessageArrayAsync(ChatMessage[] messages, IWorkflowContext context, CancellationToken cancellationToken)
{
if (messages.Length == 0)
{
return;
}
for (int i = 0; i < messages.Length; i++)
{
await this.AdvanceAsync(messages[i], context, cancellationToken, finalizeTurn: i == messages.Length - 1).ConfigureAwait(false);
}
}
// The host sends a TurnToken after the message batch; the message has already
// driven the graph forward, so we treat the token as a no-op here.
private ValueTask HandleTurnTokenAsync(TurnToken token, IWorkflowContext context, CancellationToken cancellationToken)
{
return default;
}
private async ValueTask AdvanceAsync(ChatMessage input, IWorkflowContext context, CancellationToken cancellationToken, bool finalizeTurn = true)
{
// No state to restore if we're starting from the beginning.
state.SetInitialized();
DeclarativeWorkflowContext declarativeContext = new(context, state);
ChatMessage input = inputTransform.Invoke(message);
string? conversationId = options.ConversationId;
// Conversation id resolution prefers state already persisted by a prior turn,
// so multi-turn invocations reuse the same backend conversation rather than
// creating a fresh one each turn.
string? conversationId = declarativeContext.GetWorkflowConversation();
if (string.IsNullOrWhiteSpace(conversationId))
{
conversationId = options.ConversationId;
}
bool conversationCreated = false;
if (string.IsNullOrWhiteSpace(conversationId))
{
conversationId = await options.AgentProvider.CreateConversationAsync(cancellationToken).ConfigureAwait(false);
conversationCreated = true;
}
await declarativeContext.QueueConversationUpdateAsync(conversationId, isExternal: true, cancellationToken).ConfigureAwait(false);
ChatMessage inputMessage = await options.AgentProvider.CreateMessageAsync(conversationId, input, cancellationToken).ConfigureAwait(false);
if (conversationCreated || !string.Equals(declarativeContext.GetWorkflowConversation(), conversationId, StringComparison.Ordinal))
{
await declarativeContext.QueueConversationUpdateAsync(conversationId!, isExternal: true, cancellationToken).ConfigureAwait(false);
}
ChatMessage inputMessage = await options.AgentProvider.CreateMessageAsync(conversationId!, input, cancellationToken).ConfigureAwait(false);
// Use the original input for System.LastMessage to ensure Text is preserved (the
// service may strip text on round-trip), but substitute server-side media references
// (e.g., HostedFileContent) so subsequent actions don't re-upload large blobs.
await declarativeContext.SetLastMessageAsync(input.MergeForLastMessage(inputMessage)).ConfigureAwait(false);
await context.SendResultMessageAsync(this.Id, cancellationToken).ConfigureAwait(false);
if (finalizeTurn)
{
await context.SendResultMessageAsync(this.Id, cancellationToken).ConfigureAwait(false);
}
}
}
@@ -43,10 +43,11 @@ internal sealed class QuestionExecutor(Question model, ResponseAgentProvider age
protected override async ValueTask<object?> ExecuteAsync(IWorkflowContext context, CancellationToken cancellationToken = default)
{
await this._promptCount.WriteAsync(context, 0).ConfigureAwait(false);
InitializablePropertyPath variable = Throw.IfNull(this.Model.Variable);
bool isValueUndefined = context.ReadState(variable.Path) is BlankValue;
// Snapshot prior-execution state before we mutate it below so the SkipQuestionMode
// evaluation reflects whether this is the first time the action has run.
bool hasExecutedPreviously = await this._hasExecuted.ReadAsync(context).ConfigureAwait(false);
bool proceed = this.Evaluator.GetValue(this.Model.AlwaysPrompt).Value;
if (!proceed)
@@ -55,16 +56,23 @@ internal sealed class QuestionExecutor(Question model, ResponseAgentProvider age
proceed =
mode switch
{
SkipQuestionMode.SkipOnFirstExecutionIfVariableHasValue => isValueUndefined && !await this._hasExecuted.ReadAsync(context).ConfigureAwait(false),
SkipQuestionMode.SkipOnFirstExecutionIfVariableHasValue => isValueUndefined || hasExecutedPreviously,
SkipQuestionMode.AlwaysSkipIfVariableHasValue => isValueUndefined,
SkipQuestionMode.AlwaysAsk => true,
_ => true,
};
}
// Record that the action has executed in the same executor scope as the read above.
// (CaptureResponseAsync runs in a different executor's state scope, so writing it there
// would not be visible to subsequent ExecuteAsync invocations triggered by GotoAction.)
await this._hasExecuted.WriteAsync(context, true).ConfigureAwait(false);
if (proceed)
{
await this.PromptAsync(context, cancellationToken).ConfigureAwait(false);
// Initial prompt: count is 0 because no responses have been received yet for this turn.
// _promptCount itself is tracked in CaptureResponseAsync's scope (see comment on _promptCount).
await this.PromptAsync(context, actualCount: 0, cancellationToken).ConfigureAwait(false);
}
else
{
@@ -76,14 +84,18 @@ internal sealed class QuestionExecutor(Question model, ResponseAgentProvider age
public async ValueTask PrepareResponseAsync(IWorkflowContext context, ActionExecutorResult message, CancellationToken cancellationToken)
{
int count = await this._promptCount.ReadAsync(context).ConfigureAwait(false);
ExternalInputRequest inputRequest = new(this.FormatPrompt(this.Model.Prompt));
await context.SendMessageAsync(inputRequest, cancellationToken).ConfigureAwait(false);
await this._promptCount.WriteAsync(context, count + 1).ConfigureAwait(false);
}
public async ValueTask CaptureResponseAsync(IWorkflowContext context, ExternalInputResponse response, CancellationToken cancellationToken)
{
// _promptCount is tracked in this (Capture) executor's scope so reads and writes are coherent.
// Each Capture invocation represents an attempt to satisfy the question; increment up front
// and pass the value to PromptAsync explicitly so the retry/default decision is scope-independent.
int promptCount = await this._promptCount.ReadAsync(context).ConfigureAwait(false) + 1;
await this._promptCount.WriteAsync(context, promptCount).ConfigureAwait(false);
FormulaValue? extractedValue = null;
if (!response.HasMessages)
{
@@ -106,10 +118,12 @@ internal sealed class QuestionExecutor(Question model, ResponseAgentProvider age
if (extractedValue is null)
{
await this.PromptAsync(context, cancellationToken).ConfigureAwait(false);
await this.PromptAsync(context, promptCount, cancellationToken).ConfigureAwait(false);
}
else
{
// Reset for any subsequent Question turn (e.g. via GotoAction re-entry) so the next attempt starts fresh.
await this._promptCount.WriteAsync(context, 0).ConfigureAwait(false);
bool autoSend = true;
if (this.Model.ExtensionData?.Properties.TryGetValue("autoSend", out DataValue? autoSendValue) ?? false)
@@ -133,7 +147,6 @@ internal sealed class QuestionExecutor(Question model, ResponseAgentProvider age
}
await this.AssignAsync(Throw.IfNull(this.Model.Variable).Path, extractedValue, context).ConfigureAwait(false);
await this._hasExecuted.WriteAsync(context, true).ConfigureAwait(false);
await context.SendResultMessageAsync(this.Id, cancellationToken).ConfigureAwait(false);
}
}
@@ -143,10 +156,9 @@ internal sealed class QuestionExecutor(Question model, ResponseAgentProvider age
await context.RaiseCompletionEventAsync(this.Model, cancellationToken).ConfigureAwait(false);
}
private async ValueTask PromptAsync(IWorkflowContext context, CancellationToken cancellationToken)
private async ValueTask PromptAsync(IWorkflowContext context, int actualCount, CancellationToken cancellationToken)
{
long repeatCount = this.Evaluator.GetValue(this.Model.RepeatCount).Value;
int actualCount = await this._promptCount.ReadAsync(context).ConfigureAwait(false);
if (actualCount >= repeatCount)
{
DataValue defaultValue = DataValue.Blank();
@@ -158,6 +170,8 @@ internal sealed class QuestionExecutor(Question model, ResponseAgentProvider age
await this.AssignAsync(Throw.IfNull(this.Model.Variable).Path, defaultValue.ToFormula(), context).ConfigureAwait(false);
string defaultValueResponse = this.FormatPrompt(this.Model.DefaultValueResponse);
await context.AddEventAsync(new MessageActivityEvent(defaultValueResponse.Trim()), cancellationToken).ConfigureAwait(false);
// Reset for any subsequent Question turn (e.g. via GotoAction re-entry) so the next attempt starts fresh.
await this._promptCount.WriteAsync(context, 0).ConfigureAwait(false);
await context.SendResultMessageAsync(this.Id, cancellationToken).ConfigureAwait(false);
}
else
@@ -6,6 +6,7 @@ using Microsoft.Agents.AI.Workflows.Declarative.Extensions;
using Microsoft.Agents.AI.Workflows.Declarative.Interpreter;
using Microsoft.Agents.AI.Workflows.Declarative.PowerFx;
using Microsoft.Agents.ObjectModel;
using Microsoft.Extensions.AI;
namespace Microsoft.Agents.AI.Workflows.Declarative.ObjectModel;
@@ -19,6 +20,14 @@ internal sealed class SendActivityExecutor(SendActivity model, WorkflowFormulaSt
string activityText = this.Engine.Format(messageActivity.Text).Trim();
await context.AddEventAsync(new MessageActivityEvent(activityText.Trim()), cancellationToken).ConfigureAwait(false);
// Route through YieldOutputAsync so the activity participates in the workflow's
// output-filter pipeline. The runner currently special-cases AgentResponse to
// produce an AgentResponseEvent identical to the one we'd build by hand, so this
// is behavior-preserving today and forward-compatible if filtering is ever
// applied to agent responses.
AgentResponse response = new([new ChatMessage(ChatRole.Assistant, activityText)]);
await context.YieldOutputAsync(response, cancellationToken).ConfigureAwait(false);
}
return default;
@@ -0,0 +1,47 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Collections.Generic;
using Microsoft.Extensions.AI;
namespace Microsoft.Agents.AI.Workflows;
/// <summary>
/// Optional interface implemented by request payload types that wrap underlying
/// AI content (such as <see cref="FunctionCallContent"/> or
/// <see cref="ToolApprovalRequestContent"/>) and define a paired response envelope.
/// </summary>
/// <remarks>
/// <para>
/// This abstraction allows higher-level layers (e.g., declarative workflows) to define
/// their own request/response envelope types while still allowing
/// <c>WorkflowSession</c> to surface the inner content to hosts on the request side
/// and to wrap incoming responses back into the envelope on the response side -
/// without the runtime taking a reference back to the higher-level layer.
/// </para>
/// <para>
/// When an <c>ExternalRequest.Data</c> payload implements this interface, the
/// runtime uses <see cref="GetInnerRequestContent"/> to drive wire serialization
/// for hosts (so a host receives a normal <see cref="FunctionCallContent"/> or
/// <see cref="ToolApprovalRequestContent"/>), and uses <see cref="CreateResponse"/>
/// to wrap the host's response payload back into the envelope expected by the
/// workflow's request port.
/// </para>
/// </remarks>
public interface IExternalRequestEnvelope
{
/// <summary>
/// Returns the inner AI content that should be delivered to the host on the wire.
/// Typically a <see cref="FunctionCallContent"/> or <see cref="ToolApprovalRequestContent"/>.
/// </summary>
/// <returns>The inner content, or <c>null</c> if no suitable inner content is available.</returns>
AIContent? GetInnerRequestContent();
/// <summary>
/// Wraps the supplied response messages into the envelope's matching response type
/// for delivery to the workflow's request port.
/// </summary>
/// <param name="messages">The response messages, typically containing a
/// <see cref="FunctionResultContent"/> and/or <see cref="ToolApprovalResponseContent"/>.</param>
/// <returns>An instance of the envelope's response type wrapping <paramref name="messages"/>.</returns>
object CreateResponse(IList<ChatMessage> messages);
}
@@ -287,24 +287,93 @@ internal sealed class WorkflowSession : AgentSession
hasMatchedResponseForStartExecutor);
}
/// <summary>
/// Resolves the concrete request payload type from <see cref="RequestPortInfo.RequestType"/>
/// and returns it as an <see cref="IExternalRequestEnvelope"/> if the type implements that
/// abstraction. Resolving via the concrete <see cref="TypeId"/> (rather than asking the
/// PortableValue to deserialize directly to <see cref="IExternalRequestEnvelope"/>) is
/// required because checkpointed payloads round-trip as JSON which cannot be deserialized
/// to an interface; the concrete type populates the deserialization cache so subsequent
/// interface assignment succeeds.
/// </summary>
[UnconditionalSuppressMessage("Trimming", "IL2057:Unrecognized value passed to the parameter of method", Justification = "Higher-layer envelope types are explicitly preserved by the package that defines them.")]
private static bool TryGetRequestEnvelope(ExternalRequest request, [NotNullWhen(true)] out IExternalRequestEnvelope? envelope)
{
envelope = null;
TypeId requestType = request.PortInfo.RequestType;
Type? concreteType = Type.GetType($"{requestType.TypeName}, {requestType.AssemblyName}", throwOnError: false);
if (concreteType is null || !typeof(IExternalRequestEnvelope).IsAssignableFrom(concreteType))
{
return false;
}
if (!request.TryGetDataAs(concreteType, out object? data) || data is not IExternalRequestEnvelope env)
{
return false;
}
envelope = env;
return true;
}
/// <summary>
/// Creates the workflow-facing request content surfaced in response updates.
/// </summary>
private static AIContent CreateRequestContentForDelivery(ExternalRequest request) => request switch
private static AIContent CreateRequestContentForDelivery(ExternalRequest request)
{
ExternalRequest externalRequest when externalRequest.TryGetDataAs(out FunctionCallContent? functionCallContent)
=> CloneFunctionCallContent(functionCallContent, externalRequest.RequestId),
ExternalRequest externalRequest when externalRequest.TryGetDataAs(out ToolApprovalRequestContent? toolApprovalRequestContent)
=> CloneToolApprovalRequestContent(toolApprovalRequestContent, externalRequest.RequestId),
ExternalRequest externalRequest
=> externalRequest.ToFunctionCall(),
};
// If the request payload is a higher-layer envelope (e.g., a declarative
// ExternalInputRequest), surface its inner FCC/TARC to the host on the wire.
if (TryGetRequestEnvelope(request, out IExternalRequestEnvelope? envelope))
{
AIContent? inner = envelope.GetInnerRequestContent();
if (inner is ToolApprovalRequestContent toolApprovalRequest)
{
return CloneToolApprovalRequestContent(toolApprovalRequest, request.RequestId);
}
if (inner is FunctionCallContent functionCall)
{
return CloneFunctionCallContent(functionCall, request.RequestId);
}
}
return request switch
{
ExternalRequest externalRequest when externalRequest.TryGetDataAs(out FunctionCallContent? functionCallContent)
=> CloneFunctionCallContent(functionCallContent, externalRequest.RequestId),
ExternalRequest externalRequest when externalRequest.TryGetDataAs(out ToolApprovalRequestContent? toolApprovalRequestContent)
=> CloneToolApprovalRequestContent(toolApprovalRequestContent, externalRequest.RequestId),
ExternalRequest externalRequest
=> externalRequest.ToFunctionCall(),
};
}
/// <summary>
/// Rewrites workflow-facing response content back to the original agent-owned content ID.
/// </summary>
private static object NormalizeResponseContentForDelivery(AIContent content, ExternalRequest request)
{
// If the request payload is a higher-layer envelope, recover the original
// CallId/RequestId from the inner content and ask the envelope to wrap the
// response back into its paired response type for delivery to the request port.
if (TryGetRequestEnvelope(request, out IExternalRequestEnvelope? envelope))
{
AIContent? inner = envelope.GetInnerRequestContent();
AIContent payload = (content, inner) switch
{
(FunctionResultContent functionResult, FunctionCallContent functionCall)
=> CloneFunctionResultContent(functionResult, functionCall.CallId),
(FunctionResultContent functionResult, ToolApprovalRequestContent toolApprovalRequest)
=> CloneFunctionResultContent(functionResult, toolApprovalRequest.ToolCall.CallId),
(ToolApprovalResponseContent toolApprovalResponse, ToolApprovalRequestContent toolApprovalRequest)
=> CloneToolApprovalResponseContent(toolApprovalResponse, toolApprovalRequest.RequestId),
_ => content,
};
ChatMessage message = new(ChatRole.Tool, [payload]);
return envelope.CreateResponse([message]);
}
switch (content)
{
// If we got a FRC, and were expecting a FRC (because the request started out as a FCC, rather than getting converted to
@@ -427,10 +496,41 @@ internal sealed class WorkflowSession : AgentSession
break;
case ExecutorFailedEvent executorFailed:
// Mirror WorkflowErrorEvent: never expose internal workflow graph
// identifiers (executor ID) to the client. Surface the exception
// message only when the host opts in via _includeExceptionDetails.
Exception? executorException = executorFailed.Data;
while (executorException is { InnerException: not null }
&& (executorException is TargetInvocationException
|| executorException.GetType().Name == "DeclarativeActionException"))
{
executorException = executorException.InnerException;
}
string executorMessage = this._includeExceptionDetails && executorException != null
? executorException.Message
: "An error occurred while executing the workflow.";
yield return this.CreateUpdate(this.LastResponseId, evt, new ErrorContent(executorMessage));
break;
case SuperStepCompletedEvent stepCompleted:
this.LastCheckpoint = stepCompleted.CompletionInfo?.Checkpoint;
goto default;
case AgentResponseEvent agentResponse:
if (!this._includeWorkflowOutputsInResponse)
{
goto default;
}
foreach (ChatMessage message in agentResponse.Response.Messages)
{
yield return this.CreateUpdate(this.LastResponseId, evt, message);
}
break;
case WorkflowOutputEvent output:
IEnumerable<ChatMessage>? updateMessages = output.Data switch
{
@@ -0,0 +1,302 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using System.Collections.Generic;
using System.IO;
using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Agents.AI.Foundry.Hosting;
namespace Microsoft.Agents.AI.Foundry.UnitTests.Hosting;
public sealed class FileSystemAgentSessionStoreTests : IDisposable
{
private readonly string _root;
public FileSystemAgentSessionStoreTests()
{
this._root = Path.Combine(Path.GetTempPath(), "fs-session-store-tests-" + Guid.NewGuid().ToString("N"));
}
public void Dispose()
{
try
{
if (Directory.Exists(this._root))
{
Directory.Delete(this._root, recursive: true);
}
}
catch
{
// best-effort cleanup
}
}
[Fact]
public void Constructor_ResolvesRootDirectoryToFullPath()
{
var store = new FileSystemAgentSessionStore(this._root);
Assert.Equal(Path.GetFullPath(this._root), store.RootDirectory);
}
[Fact]
public void Constructor_NullOrWhitespaceRoot_Throws()
{
Assert.Throws<ArgumentNullException>(() => new FileSystemAgentSessionStore(null!));
Assert.Throws<ArgumentException>(() => new FileSystemAgentSessionStore(""));
Assert.Throws<ArgumentException>(() => new FileSystemAgentSessionStore(" "));
}
[Fact]
public async Task GetSessionAsync_NoFileOnDisk_ReturnsFreshSessionFromAgentAsync()
{
var store = new FileSystemAgentSessionStore(this._root);
var agent = new TestAgent();
var session = await store.GetSessionAsync(agent, "conv-1");
Assert.NotNull(session);
Assert.Equal(1, agent.CreateCalls);
Assert.Equal(0, agent.DeserializeCalls);
}
[Fact]
public async Task GetSessionAsync_EmptyFileOnDisk_ReturnsFreshSessionAsync()
{
var store = new FileSystemAgentSessionStore(this._root);
Directory.CreateDirectory(store.RootDirectory);
File.WriteAllText(Path.Combine(store.RootDirectory, "conv-empty.json"), string.Empty);
var agent = new TestAgent();
var session = await store.GetSessionAsync(agent, "conv-empty");
Assert.NotNull(session);
Assert.Equal(1, agent.CreateCalls);
Assert.Equal(0, agent.DeserializeCalls);
}
[Fact]
public async Task SaveSessionAsync_CreatesRootDirectoryIfMissingAsync()
{
var nested = Path.Combine(this._root, "nested", "deeper");
var store = new FileSystemAgentSessionStore(nested);
Assert.False(Directory.Exists(nested));
var agent = new TestAgent("{\"workflow\":\"x\"}");
await store.SaveSessionAsync(agent, "conv-2", NewSession());
Assert.True(Directory.Exists(nested));
Assert.True(File.Exists(Path.Combine(nested, "conv-2.json")));
}
[Fact]
public async Task SaveSessionAsync_ThenGetSessionAsync_RoundTripsViaAgentSerializerAsync()
{
var store = new FileSystemAgentSessionStore(this._root);
var agent = new TestAgent("{\"foo\":42}");
await store.SaveSessionAsync(agent, "round-trip", NewSession());
await store.GetSessionAsync(agent, "round-trip");
Assert.Equal(1, agent.SerializeCalls);
Assert.Equal(1, agent.DeserializeCalls);
Assert.NotNull(agent.LastDeserialized);
Assert.Equal(JsonValueKind.Object, agent.LastDeserialized!.Value.ValueKind);
Assert.Equal(42, agent.LastDeserialized!.Value.GetProperty("foo").GetInt32());
}
[Fact]
public async Task SaveSessionAsync_TwoAgentsSameConversationId_DoNotCollideAsync()
{
var store = new FileSystemAgentSessionStore(this._root);
var agentA = new TestAgent("{\"who\":\"a\"}", name: "AgentA");
var agentB = new TestAgent("{\"who\":\"b\"}", name: "AgentB");
await store.SaveSessionAsync(agentA, "shared-conv", NewSession());
await store.SaveSessionAsync(agentB, "shared-conv", NewSession());
// Agents with distinct Names get distinct subdirectories so neither overwrites the other.
var pathA = Path.Combine(store.RootDirectory, "AgentA", "shared-conv.json");
var pathB = Path.Combine(store.RootDirectory, "AgentB", "shared-conv.json");
Assert.True(File.Exists(pathA));
Assert.True(File.Exists(pathB));
Assert.Contains("\"a\"", File.ReadAllText(pathA), StringComparison.Ordinal);
Assert.Contains("\"b\"", File.ReadAllText(pathB), StringComparison.Ordinal);
}
[Fact]
public async Task SaveSessionAsync_LongConversationId_DoesNotStackOverflowAsync()
{
// Keep the value < typical OS file-name limits (~255 chars) so the file write
// succeeds, but long enough to force Sanitize past its small-input fast path.
var store = new FileSystemAgentSessionStore(this._root);
var conversationId = new string('a', 200);
var agent = new TestAgent();
await store.SaveSessionAsync(agent, conversationId, NewSession());
var files = Directory.GetFiles(store.RootDirectory, "*.json");
Assert.Single(files);
}
[Fact]
public async Task SaveSessionAsync_SanitizesInvalidPathCharactersAsync()
{
var store = new FileSystemAgentSessionStore(this._root);
var agent = new TestAgent();
// Pick an invalid filename char for the current OS. The set differs by platform
// (e.g. '?' is invalid on Windows but not on Linux), so we must select dynamically.
var invalidChars = Path.GetInvalidFileNameChars();
Assert.NotEmpty(invalidChars);
char invalid = invalidChars[0];
// Avoid NUL specifically because some shells/loggers handle it oddly; prefer
// the next character if available.
if (invalid == '\0' && invalidChars.Length > 1)
{
invalid = invalidChars[1];
}
var conversationId = $"id-with{invalid}invalid-chars";
await store.SaveSessionAsync(agent, conversationId, NewSession());
var files = Directory.GetFiles(store.RootDirectory, "*.json");
Assert.Single(files);
var fileName = Path.GetFileName(files[0]);
Assert.DoesNotContain(invalid.ToString(), fileName, StringComparison.Ordinal);
Assert.Contains("id-with", fileName, StringComparison.Ordinal);
Assert.Contains("invalid-chars", fileName, StringComparison.Ordinal);
}
[Fact]
public async Task SaveSessionAsync_ConcurrentSavesOnSameConversation_DoNotCollideOnTempFileAsync()
{
var store = new FileSystemAgentSessionStore(this._root);
var agent = new TestAgent("{\"x\":1}");
// Fan out N concurrent saves; with a fixed temp filename ("path.tmp") this would
// race on FileMode.Create / Move. Verify they all complete successfully.
var tasks = new List<Task>();
for (int i = 0; i < 16; i++)
{
tasks.Add(store.SaveSessionAsync(agent, "concurrent", NewSession()).AsTask());
}
await Task.WhenAll(tasks);
Assert.True(File.Exists(Path.Combine(store.RootDirectory, "concurrent.json")));
var leftoverTempFiles = Directory.GetFiles(store.RootDirectory, "*.tmp");
Assert.Empty(leftoverTempFiles);
}
[Theory]
[InlineData(".")]
[InlineData("..")]
[InlineData("...")]
public async Task SaveSessionAsync_AgentNameIsDotSegment_DoesNotEscapeRootAsync(string agentName)
{
var store = new FileSystemAgentSessionStore(this._root);
var agent = new TestAgent(name: agentName);
await store.SaveSessionAsync(agent, "conv-dots", NewSession());
// The session file must land inside RootDirectory, not in (or above) it as a sibling.
var allFiles = Directory.GetFiles(store.RootDirectory, "*.json", SearchOption.AllDirectories);
Assert.Single(allFiles);
var fullPath = Path.GetFullPath(allFiles[0]);
Assert.StartsWith(Path.GetFullPath(this._root) + Path.DirectorySeparatorChar, fullPath, StringComparison.Ordinal);
// The bucket directory name must not be a navigable dot-segment. After
// percent-encoding every dot in an all-dot segment, names like ".", "..", and
// "..." become "%2E", "%2E%2E", "%2E%2E%2E" — distinct, OS-neutral filenames.
var bucketName = Path.GetFileName(Path.GetDirectoryName(fullPath)!);
Assert.NotEmpty(bucketName);
Assert.NotEqual(".", bucketName);
Assert.NotEqual("..", bucketName);
Assert.DoesNotContain(bucketName, c => c == '.');
}
[Fact]
public async Task SaveSessionAsync_DistinctNamesWithInvalidChars_ProduceDistinctFilesAsync()
{
// Percent-encoding must keep otherwise-colliding inputs distinct: under the
// earlier underscore-substitution scheme, "foo/bar" and "foo_bar" both sanitized
// to "foo_bar" and would have shared a session bucket on disk.
var store = new FileSystemAgentSessionStore(this._root);
var agentSlash = new TestAgent(name: "foo/bar");
var agentUnderscore = new TestAgent(name: "foo_bar");
await store.SaveSessionAsync(agentSlash, "conv-1", NewSession());
await store.SaveSessionAsync(agentUnderscore, "conv-1", NewSession());
var bucketDirs = Directory.GetDirectories(store.RootDirectory);
Assert.Equal(2, bucketDirs.Length);
}
[Fact]
public async Task GetSessionAsync_NoExistingFile_DoesNotCreateAgentDirectoryAsync()
{
// Read operations must not have side effects on the file system.
var store = new FileSystemAgentSessionStore(this._root);
var agent = new TestAgent(name: "agent-with-bucket");
var session = await store.GetSessionAsync(agent, "missing-id");
Assert.NotNull(session);
Assert.False(Directory.Exists(this._root), "Read miss must not create the root directory.");
}
private static TestSession NewSession() => new();
private sealed class TestSession : AgentSession
{
}
private sealed class TestAgent : AIAgent
{
private readonly string _serializedJson;
private readonly string? _name;
public TestAgent(string serializedJson = "{}", string? name = null)
{
this._serializedJson = serializedJson;
this._name = name;
}
public override string? Name => this._name;
public int CreateCalls { get; private set; }
public int SerializeCalls { get; private set; }
public int DeserializeCalls { get; private set; }
public JsonElement? LastDeserialized { get; private set; }
protected override ValueTask<AgentSession> CreateSessionCoreAsync(CancellationToken cancellationToken = default)
{
this.CreateCalls++;
return new ValueTask<AgentSession>(NewSession());
}
protected override ValueTask<JsonElement> SerializeSessionCoreAsync(AgentSession session, JsonSerializerOptions? jsonSerializerOptions = null, CancellationToken cancellationToken = default)
{
this.SerializeCalls++;
using var doc = JsonDocument.Parse(this._serializedJson);
return new ValueTask<JsonElement>(doc.RootElement.Clone());
}
protected override ValueTask<AgentSession> DeserializeSessionCoreAsync(JsonElement serializedState, JsonSerializerOptions? jsonSerializerOptions = null, CancellationToken cancellationToken = default)
{
this.DeserializeCalls++;
this.LastDeserialized = serializedState.Clone();
return new ValueTask<AgentSession>(NewSession());
}
protected override Task<AgentResponse> RunCoreAsync(IEnumerable<Extensions.AI.ChatMessage> messages, AgentSession? session = null, AgentRunOptions? options = null, CancellationToken cancellationToken = default)
=> throw new NotSupportedException();
protected override IAsyncEnumerable<AgentResponseUpdate> RunCoreStreamingAsync(IEnumerable<Extensions.AI.ChatMessage> messages, AgentSession? session = null, AgentRunOptions? options = null, CancellationToken cancellationToken = default)
=> throw new NotSupportedException();
}
}
@@ -757,4 +757,467 @@ public class InputConverterTests
Assert.Equal("box-b", markers[1].Name);
Assert.Equal("2025-01", markers[1].Version);
}
// === Tool-approval (HITL) wire-format coverage ===
[Fact]
public void ConvertItemsToMessages_McpApprovalRequest_ProducesToolApprovalRequest()
{
var item = new ItemMcpApprovalRequest(
id: "mcpr_" + new string('a', 50),
serverLabel: "agent_framework",
name: "get_weather",
arguments: "{\"city\":\"Seattle\"}");
var messages = InputConverter.ConvertItemsToMessages([item]);
var content = Assert.IsType<ToolApprovalRequestContent>(Assert.Single(messages[0].Contents));
Assert.Equal(item.Id, content.RequestId);
var fc = Assert.IsType<FunctionCallContent>(content.ToolCall);
Assert.Equal("get_weather", fc.Name);
Assert.NotNull(fc.Arguments);
Assert.Equal("Seattle", fc.Arguments!["city"]?.ToString());
}
[Fact]
public void ConvertItemsToMessages_McpApprovalResponse_ProducesToolApprovalResponse_FallsBackToWireIdWhenNoMapping()
{
var wireId = "mcpr_" + new string('a', 50);
var item = new MCPApprovalResponse(approvalRequestId: wireId, approve: true);
var messages = InputConverter.ConvertItemsToMessages([item]);
var content = Assert.IsType<ToolApprovalResponseContent>(Assert.Single(messages[0].Contents));
Assert.Equal(wireId, content.RequestId);
Assert.True(content.Approved);
}
[Fact]
public void ConvertItemsToMessages_McpApprovalResponse_ResolvesAfRequestIdFromStateBag()
{
const string AfRequestId = "af_request_xyz";
var wireId = ToolApprovalIdMap.ComputeWireId(AfRequestId);
var stateBag = new AgentSessionStateBag();
ToolApprovalIdMap.Record(stateBag, wireId, AfRequestId);
var item = new MCPApprovalResponse(approvalRequestId: wireId, approve: false);
var messages = InputConverter.ConvertItemsToMessages([item], stateBag);
var content = Assert.IsType<ToolApprovalResponseContent>(Assert.Single(messages[0].Contents));
Assert.Equal(AfRequestId, content.RequestId);
Assert.False(content.Approved);
}
[Fact]
public void ConvertOutputItemsToMessages_McpApprovalRequest_ProducesToolApprovalRequest()
{
var item = new OutputItemMcpApprovalRequest(
id: "mcpr_" + new string('b', 50),
serverLabel: "agent_framework",
name: "delete_file",
arguments: "{}");
var messages = InputConverter.ConvertOutputItemsToMessages([item]);
var content = Assert.IsType<ToolApprovalRequestContent>(Assert.Single(messages[0].Contents));
Assert.Equal(item.Id, content.RequestId);
Assert.Equal("delete_file", Assert.IsType<FunctionCallContent>(content.ToolCall).Name);
}
[Fact]
public void ConvertOutputItemsToMessages_McpApprovalResponse_ProducesToolApprovalResponse()
{
const string AfRequestId = "af_request_history";
var wireId = ToolApprovalIdMap.ComputeWireId(AfRequestId);
var stateBag = new AgentSessionStateBag();
ToolApprovalIdMap.Record(stateBag, wireId, AfRequestId);
var item = new OutputItemMcpApprovalResponseResource(
id: "ar_history_id",
approvalRequestId: wireId,
approve: true);
var messages = InputConverter.ConvertOutputItemsToMessages([item], stateBag);
var content = Assert.IsType<ToolApprovalResponseContent>(Assert.Single(messages[0].Contents));
Assert.Equal(AfRequestId, content.RequestId);
Assert.True(content.Approved);
}
[Fact]
public void ConvertItemsToMessages_McpApprovalRequest_MalformedArguments_PreservesRaw()
{
var item = new ItemMcpApprovalRequest(
id: "mcpr_" + new string('c', 50),
serverLabel: "agent_framework",
name: "noisy",
arguments: "not valid json");
var messages = InputConverter.ConvertItemsToMessages([item]);
var content = Assert.IsType<ToolApprovalRequestContent>(Assert.Single(messages[0].Contents));
var fc = Assert.IsType<FunctionCallContent>(content.ToolCall);
Assert.NotNull(fc.Arguments);
Assert.Equal("not valid json", fc.Arguments!["_raw"]?.ToString());
}
// ── input_file data-URI decoding (TryDecodeTextDataUri) ──
[Fact]
public void ConvertInputToMessages_FileContentWithTextDataUri_DecodesToTextContent()
{
var encoded = Convert.ToBase64String(System.Text.Encoding.UTF8.GetBytes("hello world"));
var input = new[]
{
new
{
type = "message",
id = "msg_text_uri",
status = "completed",
role = "user",
content = new[] { new { type = "input_file", file_data = $"data:text/plain;base64,{encoded}" } }
}
};
var request = new CreateResponse();
request.Input = BinaryData.FromObjectAsJson(input);
var messages = InputConverter.ConvertInputToMessages(request);
var text = Assert.IsType<MeaiTextContent>(Assert.Single(messages[0].Contents));
Assert.Equal("hello world", text.Text);
}
[Fact]
public void ConvertInputToMessages_FileContentWithTextDataUriAndFilename_PrefixesFilenameInDecodedText()
{
var encoded = Convert.ToBase64String(System.Text.Encoding.UTF8.GetBytes("body"));
var input = new[]
{
new
{
type = "message",
id = "msg_text_uri_name",
status = "completed",
role = "user",
content = new[]
{
new
{
type = "input_file",
filename = "notes.txt",
file_data = $"data:text/plain;base64,{encoded}"
}
}
}
};
var request = new CreateResponse();
request.Input = BinaryData.FromObjectAsJson(input);
var messages = InputConverter.ConvertInputToMessages(request);
var text = Assert.IsType<MeaiTextContent>(Assert.Single(messages[0].Contents));
Assert.StartsWith("[File: notes.txt]", text.Text, StringComparison.Ordinal);
Assert.Contains("body", text.Text, StringComparison.Ordinal);
}
[Fact]
public void ConvertInputToMessages_FileContentWithNonTextDataUri_RemainsDataContent()
{
// image/png data URIs must NOT be decoded as text — only text/* is decoded inline.
var input = new[]
{
new
{
type = "message",
id = "msg_image_uri",
status = "completed",
role = "user",
content = new[]
{
new { type = "input_file", file_data = "data:image/png;base64,iVBORw0KGgo=" }
}
}
};
var request = new CreateResponse();
request.Input = BinaryData.FromObjectAsJson(input);
var messages = InputConverter.ConvertInputToMessages(request);
Assert.IsType<DataContent>(Assert.Single(messages[0].Contents));
}
[Fact]
public void ConvertInputToMessages_FileContentWithMalformedDataUri_FallsBackToDataContent()
{
// Missing ;base64, marker — TryDecodeTextDataUri should return false and the
// original payload survives as DataContent.
var input = new[]
{
new
{
type = "message",
id = "msg_bad_uri",
status = "completed",
role = "user",
content = new[]
{
new { type = "input_file", file_data = "data:text/plain,not-base64-payload" }
}
}
};
var request = new CreateResponse();
request.Input = BinaryData.FromObjectAsJson(input);
var messages = InputConverter.ConvertInputToMessages(request);
Assert.IsType<DataContent>(Assert.Single(messages[0].Contents));
}
[Fact]
public void ConvertInputToMessages_FileContentWithFileUrlAndFilename_PropagatesFilename()
{
var input = new[]
{
new
{
type = "message",
id = "msg_url_name",
status = "completed",
role = "user",
content = new[]
{
new
{
type = "input_file",
file_url = "https://example.com/doc.pdf",
filename = "doc.pdf"
}
}
}
};
var request = new CreateResponse();
request.Input = BinaryData.FromObjectAsJson(input);
var messages = InputConverter.ConvertInputToMessages(request);
var uri = Assert.IsType<UriContent>(Assert.Single(messages[0].Contents));
Assert.NotNull(uri.AdditionalProperties);
Assert.Equal("doc.pdf", uri.AdditionalProperties!["filename"]);
}
[Fact]
public void ConvertInputToMessages_FileContentWithFileIdAndFilename_PropagatesFilename()
{
var input = new[]
{
new
{
type = "message",
id = "msg_id_name",
status = "completed",
role = "user",
content = new[]
{
new
{
type = "input_file",
file_id = "file_abc123",
filename = "doc.pdf"
}
}
}
};
var request = new CreateResponse();
request.Input = BinaryData.FromObjectAsJson(input);
var messages = InputConverter.ConvertInputToMessages(request);
var hosted = Assert.IsType<HostedFileContent>(Assert.Single(messages[0].Contents));
Assert.NotNull(hosted.AdditionalProperties);
Assert.Equal("doc.pdf", hosted.AdditionalProperties!["filename"]);
}
// ── C2: SDK content types passing through ItemMessage / OutputItemMessage ──
[Fact]
public void ConvertItemsToMessages_SdkTextContent_ProducesTextContent()
{
var msg = new ItemMessage(
MessageRole.User,
new MessageContent[] { new Azure.AI.AgentServer.Responses.Models.TextContent("plain text") });
var messages = InputConverter.ConvertItemsToMessages([msg]);
var text = Assert.IsType<MeaiTextContent>(Assert.Single(messages[0].Contents));
Assert.Equal("plain text", text.Text);
}
[Fact]
public void ConvertItemsToMessages_SummaryTextContent_ProducesTextContent()
{
var msg = new ItemMessage(
MessageRole.Assistant,
new MessageContent[] { new SummaryTextContent("a summary") });
var messages = InputConverter.ConvertItemsToMessages([msg]);
var text = Assert.IsType<MeaiTextContent>(Assert.Single(messages[0].Contents));
Assert.Equal("a summary", text.Text);
}
[Fact]
public void ConvertItemsToMessages_ReasoningTextContent_ProducesTextReasoningContent()
{
var msg = new ItemMessage(
MessageRole.Assistant,
new MessageContent[] { new MessageContentReasoningTextContent("internal reasoning") });
var messages = InputConverter.ConvertItemsToMessages([msg]);
var reasoning = Assert.IsType<TextReasoningContent>(Assert.Single(messages[0].Contents));
Assert.Equal("internal reasoning", reasoning.Text);
}
[Fact]
public void ConvertItemsToMessages_ComputerScreenshotContent_HttpUrl_ProducesUriContent()
{
var screenshot = new ComputerScreenshotContent(
imageUrl: new Uri("https://example.com/screen.png"),
fileId: null!,
detail: default);
var msg = new ItemMessage(MessageRole.User, new MessageContent[] { screenshot });
var messages = InputConverter.ConvertItemsToMessages([msg]);
var uri = Assert.IsType<UriContent>(Assert.Single(messages[0].Contents));
Assert.Equal("https://example.com/screen.png", uri.Uri.ToString());
}
[Fact]
public void ConvertItemsToMessages_ComputerScreenshotContent_DataUri_ProducesDataContent()
{
var screenshot = new ComputerScreenshotContent(
imageUrl: new Uri("data:image/png;base64,iVBORw0KGgo="),
fileId: null!,
detail: default);
var msg = new ItemMessage(MessageRole.User, new MessageContent[] { screenshot });
var messages = InputConverter.ConvertItemsToMessages([msg]);
var data = Assert.IsType<DataContent>(Assert.Single(messages[0].Contents));
Assert.StartsWith("data:image", data.Uri);
}
[Fact]
public void ConvertOutputItemsToMessages_SummaryTextContent_ProducesTextContent()
{
var outputMsg = new OutputItemMessage(
id: "out_summary",
role: MessageRole.Assistant,
content: new MessageContent[] { new SummaryTextContent("output summary") },
status: MessageStatus.Completed);
var messages = InputConverter.ConvertOutputItemsToMessages([outputMsg]);
var text = Assert.IsType<MeaiTextContent>(Assert.Single(messages[0].Contents));
Assert.Equal("output summary", text.Text);
}
[Fact]
public void ConvertOutputItemsToMessages_ReasoningTextContent_ProducesTextReasoningContent()
{
var outputMsg = new OutputItemMessage(
id: "out_reasoning",
role: MessageRole.Assistant,
content: new MessageContent[] { new MessageContentReasoningTextContent("output reasoning") },
status: MessageStatus.Completed);
var messages = InputConverter.ConvertOutputItemsToMessages([outputMsg]);
var reasoning = Assert.IsType<TextReasoningContent>(Assert.Single(messages[0].Contents));
Assert.Equal("output reasoning", reasoning.Text);
}
[Fact]
public void ConvertOutputItemsToMessages_ComputerScreenshotContent_ProducesUriContent()
{
var screenshot = new ComputerScreenshotContent(
imageUrl: new Uri("https://example.com/output-screen.png"),
fileId: null!,
detail: default);
var outputMsg = new OutputItemMessage(
id: "out_screenshot",
role: MessageRole.Assistant,
content: new MessageContent[] { screenshot },
status: MessageStatus.Completed);
var messages = InputConverter.ConvertOutputItemsToMessages([outputMsg]);
var uri = Assert.IsType<UriContent>(Assert.Single(messages[0].Contents));
Assert.Equal("https://example.com/output-screen.png", uri.Uri.ToString());
}
[Fact]
public void ConvertOutputItemsToMessages_SdkTextContent_ProducesTextContent()
{
var outputMsg = new OutputItemMessage(
id: "out_text",
role: MessageRole.Assistant,
content: new MessageContent[] { new Azure.AI.AgentServer.Responses.Models.TextContent("sdk text") },
status: MessageStatus.Completed);
var messages = InputConverter.ConvertOutputItemsToMessages([outputMsg]);
var text = Assert.IsType<MeaiTextContent>(Assert.Single(messages[0].Contents));
Assert.Equal("sdk text", text.Text);
}
[Fact]
public void ConvertInputToMessages_OversizedTextDataUri_FallsBackToDataContent()
{
// The decoder must reject oversized base64 payloads so a malicious or
// misconfigured client cannot trigger a multi-megabyte allocation.
// We construct a base64 payload whose encoded length exceeds the 16 MiB cap
// (using a tiny but valid base64 unit repeated to keep the test fast).
const int OverLimit = (16 * 1024 * 1024) + 4;
var encoded = new string('A', OverLimit);
var dataUri = "data:text/plain;base64," + encoded;
var input = new[]
{
new
{
type = "message",
id = "msg_oversize",
status = "completed",
role = "user",
content = new[]
{
new
{
type = "input_file",
file_data = dataUri,
filename = "huge.txt",
}
}
}
};
var request = new CreateResponse();
request.Input = BinaryData.FromObjectAsJson(input);
var messages = InputConverter.ConvertInputToMessages(request);
// Should NOT have decoded into a TextContent (which would have allocated).
Assert.DoesNotContain(messages[0].Contents, c => c is MeaiTextContent t && t.Text.Length > 1024);
// Should have fallen back to DataContent (carrying the original opaque blob).
Assert.Contains(messages[0].Contents, c => c is DataContent);
}
}
@@ -204,7 +204,7 @@ public class OutputConverterTests
await Assert.ThrowsAnyAsync<OperationCanceledException>(async () =>
{
await foreach (var evt in OutputConverter.ConvertUpdatesToEventsAsync(updates, stream, cts.Token))
await foreach (var evt in OutputConverter.ConvertUpdatesToEventsAsync(updates, stream, cancellationToken: cts.Token))
{
// Should throw before yielding
}
@@ -1068,6 +1068,133 @@ public class OutputConverterTests
Assert.IsType<ResponseCompletedEvent>(events[0]);
}
// === Tool-approval (HITL) wire-format coverage ===
[Fact]
public async Task ConvertUpdatesToEventsAsync_ToolApprovalRequest_EmitsMcpApprovalRequestAsync()
{
var (stream, _) = CreateTestStream();
var stateBag = new AgentSessionStateBag();
const string AfRequestId = "af_request_abc";
var functionCall = new FunctionCallContent("call_1", "delete_resource",
new Dictionary<string, object?> { ["target"] = "db" });
var approval = new ToolApprovalRequestContent(AfRequestId, functionCall);
var update = new AgentResponseUpdate { Contents = [approval] };
var events = new List<ResponseStreamEvent>();
await foreach (var evt in OutputConverter.ConvertUpdatesToEventsAsync(ToAsync(new[] { update }), stream, stateBag))
{
events.Add(evt);
}
var added = Assert.Single(events.OfType<ResponseOutputItemAddedEvent>());
var item = Assert.IsType<OutputItemMcpApprovalRequest>(added.Item);
Assert.Equal("agent_framework", item.ServerLabel);
Assert.Equal("delete_resource", item.Name);
Assert.Contains("\"target\":\"db\"", item.Arguments);
Assert.StartsWith("mcpr_", item.Id);
// Mapping persisted to state bag.
Assert.Equal(AfRequestId, ToolApprovalIdMap.Resolve(stateBag, item.Id));
}
[Fact]
public async Task ConvertUpdatesToEventsAsync_ToolApprovalRequest_NonFunctionToolCall_SkippedAsync()
{
// ToolCall implementations that aren't FunctionCallContent (e.g. raw MCP calls)
// are intentionally NOT emitted — mirrors the OpenAI Hosting layer's behavior.
var (stream, _) = CreateTestStream();
var unknownTool = new RawToolCallContent("call_x");
var approval = new ToolApprovalRequestContent("af_x", unknownTool);
var update = new AgentResponseUpdate { Contents = [approval] };
var events = new List<ResponseStreamEvent>();
await foreach (var evt in OutputConverter.ConvertUpdatesToEventsAsync(ToAsync(new[] { update }), stream))
{
events.Add(evt);
}
Assert.DoesNotContain(events.OfType<ResponseOutputItemAddedEvent>(),
e => e.Item is OutputItemMcpApprovalRequest);
// Defense in depth: only the terminal ResponseCompletedEvent should be emitted.
// No spurious output-item-added/output-item-done events should leak for the
// unsupported tool-call shape.
Assert.Single(events);
Assert.IsType<ResponseCompletedEvent>(events[0]);
}
[Fact]
public async Task ConvertUpdatesToEventsAsync_ToolApprovalResponse_NotReEmittedAsync()
{
var (stream, _) = CreateTestStream();
var fc = new FunctionCallContent("call_1", "noop");
var response = new ToolApprovalResponseContent("af_x", true, fc);
var update = new AgentResponseUpdate { Contents = [response] };
var events = new List<ResponseStreamEvent>();
await foreach (var evt in OutputConverter.ConvertUpdatesToEventsAsync(ToAsync(new[] { update }), stream))
{
events.Add(evt);
}
// Approval responses are inbound-only; output side should silently drop them
// and emit only the terminal completed event.
Assert.Single(events);
Assert.IsType<ResponseCompletedEvent>(events[0]);
}
// D1: WorkflowEvent in RawRepresentation but Contents is non-empty → fall through to content path.
[Fact]
public async Task ConvertUpdatesToEventsAsync_WorkflowEventWithTextContent_FlowsThroughContentPathAsync()
{
var (stream, _) = CreateTestStream();
var update = new AgentResponseUpdate
{
MessageId = "msg_workflow_text",
RawRepresentation = new ExecutorInvokedEvent("exec_x", "invoked"),
Contents = [new MeaiTextContent("payload from workflow event")],
};
var events = new List<ResponseStreamEvent>();
await foreach (var evt in OutputConverter.ConvertUpdatesToEventsAsync(ToAsync(new[] { update }), stream))
{
events.Add(evt);
}
// Content path must have been taken: a text-delta event must be emitted from the payload.
Assert.Contains(events, e => e is ResponseTextDeltaEvent);
Assert.IsType<ResponseCompletedEvent>(events[^1]);
}
[Fact]
public async Task ConvertUpdatesToEventsAsync_WorkflowEventWithErrorContent_EmitsFailedAsync()
{
var (stream, _) = CreateTestStream();
var update = new AgentResponseUpdate
{
RawRepresentation = new ExecutorFailedEvent("exec_y", new InvalidOperationException("boom")),
Contents = [new ErrorContent("boom")],
};
var events = new List<ResponseStreamEvent>();
await foreach (var evt in OutputConverter.ConvertUpdatesToEventsAsync(ToAsync(new[] { update }), stream))
{
events.Add(evt);
}
// ErrorContent should drive a failed event rather than being swallowed by the workflow branch.
Assert.Contains(events, e => e is ResponseFailedEvent);
}
private sealed class RawToolCallContent : ToolCallContent
{
public RawToolCallContent(string callId) : base(callId) { }
}
private static async IAsyncEnumerable<T> ToAsync<T>(IEnumerable<T> source)
{
foreach (var item in source)
@@ -33,7 +33,7 @@ public sealed class DeclarativeWorkflowTest(ITestOutputHelper output) : Workflow
public Task ValidateScenarioAsync(string workflowFileName, string testcaseFileName, bool externalConveration = false) =>
this.RunWorkflowAsync(GetWorkflowPath(workflowFileName, isSample: true), testcaseFileName, externalConveration);
[Theory(Skip = "Multi-turn tests hang in CI - needs investigation")]
[Theory]
[InlineData("ConfirmInput.yaml", "ConfirmInput.json", false)]
[InlineData("RequestExternalInput.yaml", "RequestExternalInput.json", false)]
public Task ValidateMultiTurnAsync(string workflowFileName, string testcaseFileName, bool isSample) =>
@@ -1,11 +1,15 @@
{
"description": "Human in the loop sample - RequestExternalInput.yaml.",
"description": "Human in the loop sample - ConfirmInput.yaml. First response mismatches to exercise the GotoAction re-prompt path; second response matches and completes the workflow.",
"setup": {
"input": {
"type": "String",
"value": "1234"
},
"responses": [
{
"type": "String",
"value": "9999"
},
{
"type": "String",
"value": "1234"
@@ -14,12 +18,21 @@
},
"validation": {
"conversation_count": 1,
"min_action_count": 4,
"min_action_count": 6,
"max_action_count": -1,
"min_response_count": 0,
"min_response_count": 2,
"max_response_count": -1,
"min_message_count": 0,
"max_message_count": -1,
"actions": {
"start": [
"set_project"
"set_project",
"question_confirm"
],
"repeat": [
"sendActivity_mismatch",
"goto_again",
"question_confirm"
],
"final": [
"sendActivity_confirmed"
@@ -11,6 +11,7 @@
"min_action_count": 8,
"min_message_count": 1,
"min_response_count": 1,
"max_response_count": 4,
"actions": {
"start": [
"conversation_create1",
@@ -11,7 +11,7 @@
"min_action_count": 6,
"max_action_count": -1,
"min_response_count": 2,
"max_response_count": 8,
"max_response_count": 9,
"min_message_count": 4,
"max_message_count": -1,
"actions": {
@@ -15,7 +15,7 @@
"validation": {
"conversation_count": 1,
"min_action_count": 2,
"min_response_count": 0,
"min_response_count": 1,
"min_message_count": 1,
"actions": {
"start": [
@@ -9,7 +9,10 @@
"validation": {
"conversation_count": 1,
"min_action_count": 3,
"min_response_count": 0,
"min_message_count": 0,
"max_message_count": 0,
"min_response_count": 1,
"max_response_count": 1,
"actions": {
"start": [
"set_user_input",
@@ -1,8 +1,10 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Linq;
using System.Threading.Tasks;
using Microsoft.Agents.AI.Workflows.Declarative.ObjectModel;
using Microsoft.Agents.ObjectModel;
using Microsoft.Extensions.AI;
namespace Microsoft.Agents.AI.Workflows.Declarative.UnitTests.ObjectModel;
@@ -27,6 +29,14 @@ public sealed class SendActivityExecutorTest(ITestOutputHelper output) : Workflo
// Assert
VerifyModel(model, action);
Assert.Contains(events, e => e is MessageActivityEvent);
// The executor must also emit an AgentResponseEvent carrying the activity text
// so workflow consumers (hosting runtime, UIs) can surface it as an agent turn.
AgentResponseEvent agentEvent = Assert.Single(events.OfType<AgentResponseEvent>());
Assert.Equal(action.Id, agentEvent.ExecutorId);
ChatMessage message = Assert.Single(agentEvent.Response.Messages);
Assert.Equal(ChatRole.Assistant, message.Role);
Assert.Equal("Test activity message", message.Text);
}
private SendActivity CreateModel(string displayName, string activityMessage, string? summary = null)
@@ -79,6 +79,14 @@ from ._evaluation import (
tool_calls_present,
)
from ._feature_stage import ExperimentalFeature, ReleaseCandidateFeature
from ._harness._memory import (
DEFAULT_MEMORY_SOURCE_ID,
MemoryContextProvider,
MemoryFileStore,
MemoryIndexEntry,
MemoryStore,
MemoryTopicRecord,
)
from ._mcp import MCPStdioTool, MCPStreamableHTTPTool, MCPWebsocketTool
from ._middleware import (
AgentContext,
@@ -261,6 +269,7 @@ __all__ = [
"APP_INFO",
"COMPACTION_STATE_KEY",
"DEFAULT_MAX_ITERATIONS",
"DEFAULT_MEMORY_SOURCE_ID",
"EXCLUDED_KEY",
"EXCLUDE_REASON_KEY",
"GROUP_ANNOTATION_KEY",
@@ -355,6 +364,11 @@ __all__ = [
"MCPStdioTool",
"MCPStreamableHTTPTool",
"MCPWebsocketTool",
"MemoryContextProvider",
"MemoryFileStore",
"MemoryIndexEntry",
"MemoryStore",
"MemoryTopicRecord",
"Message",
"MiddlewareException",
"MiddlewareTermination",
@@ -49,6 +49,7 @@ class ExperimentalFeature(str, Enum):
EVALS = "EVALS"
FILE_HISTORY = "FILE_HISTORY"
FUNCTIONAL_WORKFLOWS = "FUNCTIONAL_WORKFLOWS"
HARNESS = "HARNESS"
SKILLS = "SKILLS"
TOOLBOXES = "TOOLBOXES"
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,770 @@
# Copyright (c) Microsoft. All rights reserved.
from __future__ import annotations
import asyncio
import json
from collections.abc import Mapping, Sequence
from datetime import datetime, timedelta, timezone
from typing import Any
import pytest
from agent_framework import (
DEFAULT_MEMORY_SOURCE_ID,
Agent,
AgentSession,
ChatResponse,
Content,
ExperimentalFeature,
FileHistoryProvider,
MemoryContextProvider,
MemoryFileStore,
MemoryIndexEntry,
MemoryStore,
MemoryTopicRecord,
Message,
)
def _tool_by_name(tools: list[object], name: str) -> object:
"""Return the tool with the requested name from a prepared tool list."""
for tool in tools:
if getattr(tool, "name", None) == name:
return tool
raise AssertionError(f"Tool {name!r} was not found.")
class _MemoryHarnessClient:
"""Deterministic chat client used by the memory harness tests."""
additional_properties: dict[str, Any]
def __init__(
self,
*,
extraction_payload: dict[str, Any] | None = None,
consolidation_payload: dict[str, Any] | None = None,
default_text: str = "Assistant reply.",
) -> None:
self.additional_properties = {}
self.extraction_payload = extraction_payload or {
"memories": [
{
"topic": "preferences",
"memory": "Prefers concise answers.",
}
]
}
self.consolidation_payload = consolidation_payload or {
"summary": "Prefers concise answers.",
"memories": ["Prefers concise answers."],
}
self.default_text = default_text
self.calls: list[str] = []
async def get_response(
self,
messages: Sequence[Message],
*,
stream: bool = False,
options: Mapping[str, Any] | None = None,
compaction_strategy: object | None = None,
tokenizer: object | None = None,
function_invocation_kwargs: Mapping[str, Any] | None = None,
client_kwargs: Mapping[str, Any] | None = None,
) -> ChatResponse[Any]:
del options, compaction_strategy, tokenizer, function_invocation_kwargs, client_kwargs
assert not stream
system_text = messages[0].text if messages and messages[0].role == "system" else ""
if "extract durable memory candidates" in system_text.lower():
self.calls.append("extract")
return ChatResponse(messages=[Message(role="assistant", contents=[json.dumps(self.extraction_payload)])])
if "consolidate one topic memory file" in system_text.lower():
self.calls.append("consolidate")
return ChatResponse(messages=[Message(role="assistant", contents=[json.dumps(self.consolidation_payload)])])
self.calls.append("agent")
return ChatResponse(messages=[Message(role="assistant", contents=[self.default_text])])
def test_memory_index_entry_round_trips_and_trims_pointer_lines() -> None:
"""Memory index entries should preserve value equality and trim pointer lines."""
raw_entry = {
"topic": "Architecture Decisions",
"slug": "architecture-decisions",
"summary": (
"PostgreSQL was chosen because it keeps the relational model while supporting flexible JSONB fields."
),
"updated_at": "2026-04-21T10:00:00+00:00",
}
entry = MemoryIndexEntry.from_dict(raw_entry)
assert entry == MemoryIndexEntry(**raw_entry)
assert entry.to_dict() == raw_entry
assert len(entry.to_pointer_line(max_length=80)) <= 80
assert "MemoryIndexEntry(" in repr(entry)
def test_memory_topic_record_round_trips_through_dict_and_markdown() -> None:
"""Topic memory records should preserve their structured content and markdown form."""
raw_record = {
"topic": "preferences",
"slug": "preferences",
"summary": "Prefers concise answers.",
"memories": ["Prefers concise answers.", "Prefers aisle seats."],
"updated_at": "2026-04-21T10:05:00+00:00",
"session_ids": ["session-1", "session-2"],
}
record = MemoryTopicRecord.from_dict(raw_record)
reparsed_record = MemoryTopicRecord.from_markdown(record.to_markdown())
assert record == MemoryTopicRecord(**raw_record)
assert record.to_dict() == raw_record
assert reparsed_record == record
assert "MemoryTopicRecord(" in repr(record)
async def test_memory_file_store_writes_topics_index_state_and_transcripts(tmp_path) -> None:
"""The file-backed memory store should manage topics, ``MEMORY.md``, state, and transcript search."""
store = MemoryFileStore(
tmp_path,
kind="memories",
owner_prefix="user_",
owner_state_key="owner_id",
dumps=lambda value: json.dumps(value, separators=(",", ":"), sort_keys=True),
loads=json.loads,
)
session = AgentSession(session_id="session-1")
session.state["owner_id"] = "alice"
updated_at = datetime(2026, 4, 21, tzinfo=timezone.utc).replace(microsecond=0).isoformat()
preferences_record = MemoryTopicRecord(
topic="preferences",
summary="Prefers concise answers.",
memories=["Prefers concise answers.", "Prefers aisle seats."],
updated_at=updated_at,
session_ids=["session-1"],
)
travel_record = MemoryTopicRecord(
topic="travel",
summary="Planning a Norway trip.",
memories=["Visit Oslo in June."],
updated_at=updated_at,
session_ids=["session-1"],
)
store.write_topic(session, preferences_record, source_id=DEFAULT_MEMORY_SOURCE_ID)
store.write_topic(session, travel_record, source_id=DEFAULT_MEMORY_SOURCE_ID)
entries = store.rebuild_index(
session,
source_id=DEFAULT_MEMORY_SOURCE_ID,
line_limit=200,
line_length=150,
)
assert [entry.topic for entry in entries] == ["preferences", "travel"]
assert "preferences" in store.get_index_text(
session,
source_id=DEFAULT_MEMORY_SOURCE_ID,
line_limit=200,
line_length=150,
)
assert store.read_state(session, source_id=DEFAULT_MEMORY_SOURCE_ID) == {
"last_consolidated_at": None,
"sessions_since_consolidation": [],
}
store.write_state(
session,
{
"last_consolidated_at": updated_at,
"sessions_since_consolidation": ["session-1"],
},
source_id=DEFAULT_MEMORY_SOURCE_ID,
)
assert store.read_state(
session,
source_id=DEFAULT_MEMORY_SOURCE_ID,
)["sessions_since_consolidation"] == ["session-1"]
history_provider = FileHistoryProvider(
store.get_transcripts_directory(session, source_id=DEFAULT_MEMORY_SOURCE_ID),
dumps=lambda value: json.dumps(value, separators=(",", ":"), sort_keys=True),
loads=json.loads,
)
await history_provider.save_messages(
session.session_id,
[
Message(role="user", contents=["I prefer aisle seats."]),
Message(role="assistant", contents=["Recorded."]),
],
)
assert store.search_transcripts(session, source_id=DEFAULT_MEMORY_SOURCE_ID, query="aisle") == [
{
"session_id": "session-1",
"line_number": 1,
"role": "user",
"text": "I prefer aisle seats.",
}
]
def test_memory_file_store_rejects_owner_path_traversal(tmp_path) -> None:
"""Owner IDs with path traversal segments should not escape ``base_path``."""
session = AgentSession(session_id="session-1")
session.state["owner_id"] = "../escape"
store = MemoryFileStore(tmp_path, owner_state_key="owner_id")
record = MemoryTopicRecord(
topic="preferences",
summary="Prefers concise answers.",
memories=["Prefers concise answers."],
updated_at=datetime(2026, 4, 21, tzinfo=timezone.utc).replace(microsecond=0).isoformat(),
)
with pytest.raises(ValueError, match="path traversal"):
store.write_topic(session, record, source_id=DEFAULT_MEMORY_SOURCE_ID)
assert not (tmp_path.parent / "escape").exists()
async def test_memory_file_store_namespaces_topics_state_and_transcripts_by_source_id(tmp_path) -> None:
"""Providers sharing one file store should not collide when they use different source IDs."""
session = AgentSession(session_id="session-1")
session.state["owner_id"] = "alice"
store = MemoryFileStore(
tmp_path,
owner_state_key="owner_id",
dumps=lambda value: json.dumps(value, separators=(",", ":"), sort_keys=True),
loads=json.loads,
)
updated_at = datetime(2026, 4, 21, tzinfo=timezone.utc).replace(microsecond=0).isoformat()
store.write_topic(
session,
MemoryTopicRecord(
topic="preferences",
summary="Source A summary.",
memories=["Source A memory."],
updated_at=updated_at,
),
source_id="source-a",
)
store.write_topic(
session,
MemoryTopicRecord(
topic="preferences",
summary="Source B summary.",
memories=["Source B memory."],
updated_at=updated_at,
),
source_id="source-b",
)
store.write_state(
session, {"last_consolidated_at": updated_at, "sessions_since_consolidation": ["a"]}, source_id="source-a"
)
store.write_state(
session, {"last_consolidated_at": None, "sessions_since_consolidation": ["b"]}, source_id="source-b"
)
await FileHistoryProvider(store.get_transcripts_directory(session, source_id="source-a")).save_messages(
"session-1", [Message(role="user", contents=["Source A transcript."])]
)
await FileHistoryProvider(store.get_transcripts_directory(session, source_id="source-b")).save_messages(
"session-1", [Message(role="user", contents=["Source B transcript."])]
)
assert store.get_topic(session, source_id="source-a", topic="preferences").memories == ["Source A memory."]
assert store.get_topic(session, source_id="source-b", topic="preferences").memories == ["Source B memory."]
assert store.read_state(session, source_id="source-a")["sessions_since_consolidation"] == ["a"]
assert store.read_state(session, source_id="source-b")["sessions_since_consolidation"] == ["b"]
assert (
store.search_transcripts(session, source_id="source-a", query="transcript")[0]["text"] == "Source A transcript."
)
assert (
store.search_transcripts(session, source_id="source-b", query="transcript")[0]["text"] == "Source B transcript."
)
async def test_memory_context_provider_does_not_rewrite_unchanged_index(tmp_path) -> None:
"""A second before-run pass with unchanged memories should preserve ``MEMORY.md`` mtime."""
session = AgentSession(session_id="session-1")
session.state["owner_id"] = "alice"
store = MemoryFileStore(tmp_path, owner_state_key="owner_id")
agent = Agent(
client=_MemoryHarnessClient(),
context_providers=[MemoryContextProvider(store=store)],
default_options={"store": False},
)
await agent._prepare_session_and_messages( # type: ignore[reportPrivateUsage]
session=session,
input_messages=[Message(role="user", contents=["Current question"])],
)
index_path = next(tmp_path.rglob("MEMORY.md"))
first_mtime_ns = index_path.stat().st_mtime_ns
await agent._prepare_session_and_messages( # type: ignore[reportPrivateUsage]
session=session,
input_messages=[Message(role="user", contents=["Current question"])],
)
assert index_path.stat().st_mtime_ns == first_mtime_ns
async def test_memory_context_provider_tools_and_automation(tmp_path) -> None:
"""The memory provider should expose tools and automate extraction plus consolidation."""
session = AgentSession(session_id="session-1")
session.state["owner_id"] = "alice"
store = MemoryFileStore(
tmp_path,
kind="memories",
owner_prefix="user_",
owner_state_key="owner_id",
dumps=lambda value: json.dumps(value, separators=(",", ":"), sort_keys=True),
loads=json.loads,
)
provider = MemoryContextProvider(
store=store,
consolidation_min_sessions=1,
consolidation_interval=timedelta(0),
)
agent = Agent(
client=_MemoryHarnessClient(),
context_providers=[provider],
default_options={"store": False},
)
_, options = await agent._prepare_session_and_messages( # type: ignore[reportPrivateUsage]
session=session,
input_messages=[Message(role="user", contents=["Remember this."])],
)
tools = options["tools"]
assert isinstance(tools, list)
write_memory = _tool_by_name(tools, "write_memory")
list_memory_topics = _tool_by_name(tools, "list_memory_topics")
search_memory_transcripts = _tool_by_name(tools, "search_memory_transcripts")
consolidate_memories = _tool_by_name(tools, "consolidate_memories")
write_result = await write_memory.invoke(arguments={"topic": "travel", "memory": "Visit Oslo in June."})
created_topic = json.loads(write_result[0].text)
assert created_topic["topic"] == "travel"
list_result = await list_memory_topics.invoke()
assert [entry["topic"] for entry in json.loads(list_result[0].text)] == ["travel"]
await agent.run("Please remember that I prefer concise answers.", session=session)
serialized_session = session.to_dict()
assert serialized_session["state"][DEFAULT_MEMORY_SOURCE_ID] == {"owner_id": "alice"}
preferences_topic = store.get_topic(session, source_id=DEFAULT_MEMORY_SOURCE_ID, topic="preferences")
assert preferences_topic.summary == "Prefers concise answers."
assert preferences_topic.memories == ["Prefers concise answers."]
transcript_search_result = await search_memory_transcripts.invoke(arguments={"query": "concise", "limit": 5})
search_payload = json.loads(transcript_search_result[0].text)
assert search_payload[0]["role"] == "user"
assert "concise answers" in search_payload[0]["text"]
consolidate_result = await consolidate_memories.invoke()
assert json.loads(consolidate_result[0].text)["consolidated_topics"] >= 1
async def test_memory_context_provider_injects_recent_turns(tmp_path) -> None:
"""The memory provider should inject only the configured recent transcript turns."""
session = AgentSession(session_id="session-1")
session.state["owner_id"] = "alice"
store = MemoryFileStore(
tmp_path,
kind="memories",
owner_prefix="user_",
owner_state_key="owner_id",
dumps=lambda value: json.dumps(value, separators=(",", ":"), sort_keys=True),
loads=json.loads,
)
provider = MemoryContextProvider(store=store, recent_turns=2)
provider_state = store.export_provider_state(session)
await provider.save_messages(
session.session_id,
[
Message(role="user", contents=["First question"]),
Message(role="assistant", contents=["First answer"]),
Message(role="user", contents=["Second question"]),
Message(role="assistant", contents=["Second answer"]),
Message(role="user", contents=["Third question"]),
Message(role="assistant", contents=["Third answer"]),
],
state=provider_state,
)
agent = Agent(
client=_MemoryHarnessClient(),
context_providers=[provider],
default_options={"store": False},
)
session_context, _ = await agent._prepare_session_and_messages( # type: ignore[reportPrivateUsage]
session=session,
input_messages=[Message(role="user", contents=["Current question"])],
)
prepared_messages = session_context.get_messages(include_input=True)
assert [message.text for message in prepared_messages[:4]] == [
"Second question",
"Second answer",
"Third question",
"Third answer",
]
assert "First question" not in [message.text for message in prepared_messages]
assert "### MEMORY.md" in prepared_messages[4].text
assert prepared_messages[-1].text == "Current question"
async def test_memory_context_provider_recent_turns_can_skip_tool_call_groups(tmp_path) -> None:
"""Recent-turn loading should follow compaction grouping and optionally skip tool-call groups."""
session = AgentSession(session_id="session-1")
session.state["owner_id"] = "alice"
store = MemoryFileStore(
tmp_path,
kind="memories",
owner_prefix="user_",
owner_state_key="owner_id",
dumps=lambda value: json.dumps(value, separators=(",", ":"), sort_keys=True),
loads=json.loads,
)
provider_state = store.export_provider_state(session)
await MemoryContextProvider(store=store).save_messages(
session.session_id,
[
Message(role="user", contents=["First question"]),
Message(role="assistant", contents=["First answer"]),
Message(role="user", contents=["Second question"]),
Message(role="assistant", contents=[Content.from_text_reasoning(text="Let me check that.")]),
Message(
role="assistant",
contents=[
Content.from_function_call(call_id="call-1", name="lookup_answer", arguments='{"topic":"second"}')
],
),
Message(role="tool", contents=[Content.from_function_result(call_id="call-1", result="Tool result")]),
Message(role="assistant", contents=["Second final answer"]),
Message(role="user", contents=["Third question"]),
Message(role="assistant", contents=["Third answer"]),
],
state=provider_state,
)
with_tools_agent = Agent(
client=_MemoryHarnessClient(),
context_providers=[MemoryContextProvider(store=store, recent_turns=2, load_tool_turns=True)],
default_options={"store": False},
)
without_tools_agent = Agent(
client=_MemoryHarnessClient(),
context_providers=[MemoryContextProvider(store=store, recent_turns=2, load_tool_turns=False)],
default_options={"store": False},
)
with_tools_context, _ = await with_tools_agent._prepare_session_and_messages( # type: ignore[reportPrivateUsage]
session=session,
input_messages=[Message(role="user", contents=["Current question"])],
)
without_tools_context, _ = await without_tools_agent._prepare_session_and_messages( # type: ignore[reportPrivateUsage]
session=session,
input_messages=[Message(role="user", contents=["Current question"])],
)
with_tools_messages = with_tools_context.get_messages(include_input=True)
without_tools_messages = without_tools_context.get_messages(include_input=True)
assert [message.text for message in without_tools_messages[:4]] == [
"Second question",
"Second final answer",
"Third question",
"Third answer",
]
assert not any(message.role == "tool" for message in without_tools_messages)
assert not any(
any(content.type == "function_call" for content in message.contents) for message in without_tools_messages
)
assert not any(
any(content.type == "text_reasoning" for content in message.contents) for message in without_tools_messages
)
assert with_tools_messages[0].text == "Second question"
assert with_tools_messages[1].contents[0].type == "text_reasoning"
assert with_tools_messages[2].contents[0].type == "function_call"
assert with_tools_messages[3].role == "tool"
assert with_tools_messages[3].contents[0].type == "function_result"
assert with_tools_messages[4].text == "Second final answer"
async def test_memory_context_provider_uses_explicit_consolidation_client(tmp_path) -> None:
"""The memory provider should use the explicit consolidation client when one is configured."""
session = AgentSession(session_id="session-1")
session.state["owner_id"] = "alice"
store = MemoryFileStore(
tmp_path,
kind="memories",
owner_prefix="user_",
owner_state_key="owner_id",
dumps=lambda value: json.dumps(value, separators=(",", ":"), sort_keys=True),
loads=json.loads,
)
main_client = _MemoryHarnessClient()
consolidation_client = _MemoryHarnessClient(
consolidation_payload={
"summary": "Consolidated by the cheaper client.",
"memories": ["Visit Oslo in June."],
}
)
provider = MemoryContextProvider(
store=store,
consolidation_client=consolidation_client,
)
agent = Agent(
client=main_client,
context_providers=[provider],
default_options={"store": False},
)
_, options = await agent._prepare_session_and_messages( # type: ignore[reportPrivateUsage]
session=session,
input_messages=[Message(role="user", contents=["Remember this."])],
)
tools = options["tools"]
assert isinstance(tools, list)
write_memory = _tool_by_name(tools, "write_memory")
consolidate_memories = _tool_by_name(tools, "consolidate_memories")
await write_memory.invoke(arguments={"topic": "travel", "memory": "Visit Oslo in June."})
await consolidate_memories.invoke()
travel_topic = store.get_topic(session, source_id=DEFAULT_MEMORY_SOURCE_ID, topic="travel")
assert travel_topic.summary == "Consolidated by the cheaper client."
assert main_client.calls == []
assert consolidation_client.calls == ["consolidate"]
async def test_memory_context_provider_preserves_concurrent_writes_to_same_topic(tmp_path) -> None:
"""Concurrent writes to one topic should preserve every memory line."""
session = AgentSession(session_id="session-1")
session.state["owner_id"] = "alice"
store = MemoryFileStore(tmp_path, owner_state_key="owner_id")
provider = MemoryContextProvider(store=store)
agent = Agent(client=_MemoryHarnessClient(), context_providers=[provider], default_options={"store": False})
_, options = await agent._prepare_session_and_messages( # type: ignore[reportPrivateUsage]
session=session,
input_messages=[Message(role="user", contents=["Remember these."])],
)
tools = options["tools"]
assert isinstance(tools, list)
write_memory = _tool_by_name(tools, "write_memory")
memories = [f"Concurrent memory {index}." for index in range(20)]
await asyncio.gather(
*(write_memory.invoke(arguments={"topic": "preferences", "memory": memory}) for memory in memories)
)
topic = store.get_topic(session, source_id=DEFAULT_MEMORY_SOURCE_ID, topic="preferences")
assert sorted(topic.memories) == sorted(memories)
def test_memory_harness_classes_are_marked_experimental() -> None:
"""Memory harness public classes should expose HARNESS experimental metadata."""
assert MemoryIndexEntry.__feature_id__ == ExperimentalFeature.HARNESS.value
assert MemoryTopicRecord.__feature_id__ == ExperimentalFeature.HARNESS.value
assert MemoryStore.__feature_id__ == ExperimentalFeature.HARNESS.value
assert MemoryFileStore.__feature_id__ == ExperimentalFeature.HARNESS.value
assert MemoryContextProvider.__feature_id__ == ExperimentalFeature.HARNESS.value
assert ".. warning:: Experimental" in MemoryContextProvider.__doc__
def test_memory_topic_record_round_trips_when_text_contains_section_markers() -> None:
"""Embedded ``## Summary``/``## Memories`` markers must not be re-interpreted as headings."""
record = MemoryTopicRecord(
topic="weird",
summary="Multi line summary.\n## Summary\nstill summary",
memories=[
"## Memories pretend",
"Real memory.",
" ## Memories nested",
],
updated_at="2026-04-21T10:00:00+00:00",
session_ids=["session-1"],
)
reparsed = MemoryTopicRecord.from_markdown(record.to_markdown())
assert reparsed.summary == record.summary
assert reparsed.memories == record.memories
async def test_memory_file_store_atomic_write_preserves_prior_topic_on_failure(tmp_path, monkeypatch) -> None:
"""If ``os.replace`` fails mid-write, the previous topic file must remain intact."""
from agent_framework._harness import _memory as memory_module
store = MemoryFileStore(tmp_path, owner_state_key="owner_id")
session = AgentSession(session_id="session-1")
session.state["owner_id"] = "alice"
original = MemoryTopicRecord(
topic="preferences",
summary="Prefers concise answers.",
memories=["Prefers concise answers."],
updated_at="2026-04-21T10:00:00+00:00",
session_ids=["session-1"],
)
store.write_topic(session, original, source_id=DEFAULT_MEMORY_SOURCE_ID)
real_replace = memory_module.os.replace
def _boom(*args: object, **kwargs: object) -> None:
raise OSError("simulated disk-full")
monkeypatch.setattr(memory_module.os, "replace", _boom)
with pytest.raises(OSError, match="simulated disk-full"):
store.write_topic(
session,
MemoryTopicRecord(
topic="preferences",
summary="Updated.",
memories=["Updated."],
updated_at="2026-04-21T11:00:00+00:00",
session_ids=["session-1"],
),
source_id=DEFAULT_MEMORY_SOURCE_ID,
)
monkeypatch.setattr(memory_module.os, "replace", real_replace)
surviving = store.get_topic(session, source_id=DEFAULT_MEMORY_SOURCE_ID, topic="preferences")
assert surviving.summary == "Prefers concise answers."
# Temp file should not be left behind.
topics_dir = surviving_dir = tmp_path
leftover = [path for path in topics_dir.rglob("*.tmp.*")]
assert leftover == []
del surviving_dir
async def test_memory_file_store_does_not_mkdir_on_pure_read_paths(tmp_path) -> None:
"""List/read calls on a never-written session should not create any directories."""
store = MemoryFileStore(tmp_path, owner_state_key="owner_id")
session = AgentSession(session_id="session-1")
session.state["owner_id"] = "alice"
assert store.list_topics(session, source_id=DEFAULT_MEMORY_SOURCE_ID) == []
assert store.read_state(session, source_id=DEFAULT_MEMORY_SOURCE_ID) == {
"last_consolidated_at": None,
"sessions_since_consolidation": [],
}
assert store.search_transcripts(session, source_id=DEFAULT_MEMORY_SOURCE_ID, query="anything") == []
# tmp_path itself was passed in by pytest so it exists; assert no children were created.
assert list(tmp_path.iterdir()) == []
class _RaisingMemoryClient:
"""Chat client that raises a transient error for every consolidation request."""
additional_properties: dict[str, Any]
def __init__(self) -> None:
from agent_framework.exceptions import ChatClientException
self.additional_properties = {}
self.error_class = ChatClientException
self.calls: list[str] = []
async def get_response(
self,
messages: Sequence[Message],
*,
stream: bool = False,
options: Mapping[str, Any] | None = None,
compaction_strategy: object | None = None,
tokenizer: object | None = None,
function_invocation_kwargs: Mapping[str, Any] | None = None,
client_kwargs: Mapping[str, Any] | None = None,
) -> ChatResponse[Any]:
del messages, stream, options, compaction_strategy, tokenizer
del function_invocation_kwargs, client_kwargs
self.calls.append("call")
raise self.error_class("simulated transient failure")
class _ProgrammerErrorMemoryClient:
"""Chat client whose ``get_response`` raises a non-transient programmer error."""
additional_properties: dict[str, Any]
def __init__(self) -> None:
self.additional_properties = {}
async def get_response(self, *args: object, **kwargs: object) -> ChatResponse[Any]:
del args, kwargs
raise AttributeError("misconfigured client")
async def test_memory_consolidation_transient_failure_preserves_state(tmp_path) -> None:
"""A transient consolidation failure must not advance the maintenance window."""
session = AgentSession(session_id="session-1")
session.state["owner_id"] = "alice"
store = MemoryFileStore(tmp_path, owner_state_key="owner_id")
raising_client = _RaisingMemoryClient()
provider = MemoryContextProvider(store=store, consolidation_client=raising_client)
pre_state = {
"last_consolidated_at": "2026-04-20T09:00:00+00:00",
"sessions_since_consolidation": ["queued-session"],
}
store.write_state(session, pre_state, source_id=DEFAULT_MEMORY_SOURCE_ID)
store.write_topic(
session,
MemoryTopicRecord(
topic="preferences",
summary="Prefers concise answers.",
memories=["Prefers concise answers."],
updated_at="2026-04-21T10:00:00+00:00",
session_ids=["session-1"],
),
source_id=DEFAULT_MEMORY_SOURCE_ID,
)
consolidated_count = await provider._run_consolidation( # type: ignore[reportPrivateUsage]
client=raising_client,
session=session,
force=True,
now=datetime(2026, 4, 22, tzinfo=timezone.utc),
)
assert consolidated_count == 0
assert raising_client.calls == ["call"]
assert store.read_state(session, source_id=DEFAULT_MEMORY_SOURCE_ID) == pre_state
surviving = store.get_topic(session, source_id=DEFAULT_MEMORY_SOURCE_ID, topic="preferences")
assert surviving.summary == "Prefers concise answers."
async def test_memory_extraction_propagates_programmer_errors(tmp_path) -> None:
"""Non-transient errors from the chat client must surface so misconfigurations fail loudly."""
session = AgentSession(session_id="session-1")
session.state["owner_id"] = "alice"
store = MemoryFileStore(tmp_path, owner_state_key="owner_id")
provider = MemoryContextProvider(store=store)
bad_client = _ProgrammerErrorMemoryClient()
from agent_framework import AgentResponse
from agent_framework._sessions import SessionContext
context = SessionContext(
input_messages=[Message(role="user", contents=["q"])],
)
context._response = AgentResponse(messages=[Message(role="assistant", contents=["a"])]) # type: ignore[reportPrivateUsage]
with pytest.raises(AttributeError, match="misconfigured client"):
await provider._extract_memories( # type: ignore[reportPrivateUsage]
client=bad_client,
session=session,
context=context,
now=datetime(2026, 4, 22, tzinfo=timezone.utc),
)
@@ -135,6 +135,18 @@ def _uses_foundry_agent_session(conversation_id: Any) -> bool:
)
def _build_agent_reference(agent_name: str, agent_version: str | None) -> dict[str, str]:
"""Build the Responses API ``agent_reference`` payload for non-preview Foundry agent calls.
Used for both Prompt Agents and HostedAgents on the ``allow_preview=False`` code path —
the preview branch instead injects identity via ``project_client.get_openai_client(agent_name=...)``.
"""
ref: dict[str, str] = {"name": agent_name, "type": "agent_reference"}
if agent_version:
ref["version"] = agent_version
return ref
class RawFoundryAgentChatClient( # type: ignore[misc]
RawOpenAIChatClient[FoundryAgentOptionsT],
Generic[FoundryAgentOptionsT],
@@ -342,6 +354,12 @@ class RawFoundryAgentChatClient( # type: ignore[misc]
run_options.pop("previous_response_id", None)
run_options.pop("conversation", None)
extra_body["agent_session_id"] = conversation_id
# Non-preview Prompt/Hosted Agent calls need agent_reference in the request body to
# tell the Responses API which Foundry agent (and version) is in use, since ``model``
# is stripped below. The preview path injects the reference via the OpenAI client kwarg
# ``agent_name`` instead, so skip there. See issue #5582.
if not self.allow_preview:
extra_body.setdefault("agent_reference", _build_agent_reference(self.agent_name, self.agent_version))
if extra_body:
run_options["extra_body"] = extra_body
@@ -196,7 +196,10 @@ async def test_raw_foundry_agent_chat_client_prepare_options_accepts_function_to
options={"tools": [my_func]},
)
assert result == {}
# agent_reference is required so the Responses API can resolve model server-side; see #5582.
assert result == {
"extra_body": {"agent_reference": {"name": "test-agent", "type": "agent_reference"}},
}
async def test_raw_foundry_agent_chat_client_prepare_options_strips_client_side_fields() -> None:
@@ -236,7 +239,128 @@ async def test_raw_foundry_agent_chat_client_prepare_options_strips_client_side_
assert "tools" not in result
assert "tool_choice" not in result
assert "parallel_tool_calls" not in result
assert result == {}
# agent_reference is required so the Responses API can resolve model server-side; see #5582.
assert result == {
"extra_body": {"agent_reference": {"name": "test-agent", "type": "agent_reference"}},
}
async def test_raw_foundry_agent_chat_client_prepare_options_injects_agent_reference_first_turn() -> None:
"""First-turn (no conversation_id) Prompt Agent calls must carry agent_reference in extra_body.
Regression test for https://github.com/microsoft/agent-framework/issues/5582 — without this
the Responses API rejects with "Missing required parameter: 'model'", because both ``model``
and ``agent_reference`` are absent from the request body.
"""
mock_project = MagicMock()
mock_project.get_openai_client.return_value = MagicMock()
client = RawFoundryAgentChatClient(
project_client=mock_project,
agent_name="test-agent",
agent_version="2",
)
with patch(
"agent_framework_openai._chat_client.RawOpenAIChatClient._prepare_options",
new_callable=AsyncMock,
return_value={"model": "gpt-4.1"},
):
result = await client._prepare_options(
messages=[Message(role="user", contents="hi")],
options={},
)
assert "model" not in result
assert result["extra_body"] == {
"agent_reference": {"name": "test-agent", "type": "agent_reference", "version": "2"},
}
async def test_raw_foundry_agent_chat_client_prepare_options_agent_reference_omits_version_when_unset() -> None:
"""When agent_version is unset, agent_reference should omit the version key entirely."""
mock_project = MagicMock()
mock_project.get_openai_client.return_value = MagicMock()
client = RawFoundryAgentChatClient(
project_client=mock_project,
agent_name="hosted-agent",
)
with patch(
"agent_framework_openai._chat_client.RawOpenAIChatClient._prepare_options",
new_callable=AsyncMock,
return_value={"model": "gpt-4.1"},
):
result = await client._prepare_options(
messages=[Message(role="user", contents="hi")],
options={},
)
assert result["extra_body"] == {
"agent_reference": {"name": "hosted-agent", "type": "agent_reference"},
}
async def test_raw_foundry_agent_chat_client_prepare_options_skips_agent_reference_when_allow_preview() -> None:
"""Hosted-agent (allow_preview=True) requests must NOT add agent_reference in the body.
The preview path injects the agent identity via ``project_client.get_openai_client(agent_name=...)``
at the SDK wrapper level. Adding it again in extra_body would either duplicate or conflict
with the wrapper's injection. Keep this gate aligned with the constructor branch in
``RawFoundryAgentChatClient.__init__``.
"""
mock_project = MagicMock()
mock_project.get_openai_client.return_value = MagicMock()
client = RawFoundryAgentChatClient(
project_client=mock_project,
agent_name="hosted-agent",
agent_version="3",
allow_preview=True,
)
with patch(
"agent_framework_openai._chat_client.RawOpenAIChatClient._prepare_options",
new_callable=AsyncMock,
return_value={"model": "gpt-4.1"},
):
result = await client._prepare_options(
messages=[Message(role="user", contents="hi")],
options={},
)
assert "model" not in result
# No extra_body at all is the cleanest signal — agent_reference must not be injected here.
assert "extra_body" not in result
async def test_raw_foundry_agent_chat_client_prepare_options_respects_caller_agent_reference() -> None:
"""A caller-supplied extra_body['agent_reference'] should not be overwritten."""
mock_project = MagicMock()
mock_project.get_openai_client.return_value = MagicMock()
client = RawFoundryAgentChatClient(
project_client=mock_project,
agent_name="default-agent",
)
caller_reference = {"name": "override-agent", "type": "agent_reference", "version": "5"}
with patch(
"agent_framework_openai._chat_client.RawOpenAIChatClient._prepare_options",
new_callable=AsyncMock,
return_value={"model": "gpt-4.1", "extra_body": {"agent_reference": caller_reference}},
):
result = await client._prepare_options(
messages=[Message(role="user", contents="hi")],
options={"extra_body": {"agent_reference": caller_reference}},
)
assert result["extra_body"]["agent_reference"] == caller_reference
async def test_raw_foundry_agent_chat_client_prepare_options_maps_agent_session_id_to_extra_body() -> None:
@@ -267,6 +391,7 @@ async def test_raw_foundry_agent_chat_client_prepare_options_maps_agent_session_
assert result["extra_body"] == {
"custom": "value",
"agent_session_id": "agent-session-123",
"agent_reference": {"name": "test-agent", "type": "agent_reference"},
}
assert "previous_response_id" not in result
assert "conversation" not in result
@@ -7,11 +7,8 @@ import base64
import json
import logging
import os
import tempfile
import threading
from collections.abc import AsyncIterable, AsyncIterator, Generator, Mapping, Sequence
from contextlib import suppress
from typing import Protocol, cast
from typing import cast
from agent_framework import (
ChatOptions,
@@ -112,105 +109,11 @@ from typing_extensions import Any
logger = logging.getLogger(__name__)
class ApprovalStorage(Protocol):
"""Storage for saving function approval requests."""
async def save_approval_request(self, approval_request_id: str, request: Content) -> None:
"""Save a function approval request under the given ID."""
...
async def load_approval_request(self, approval_request_id: str) -> Content:
"""Load a function approval request by its ID."""
...
class InMemoryFunctionApprovalStorage:
"""An in-memory storage for function approval requests."""
def __init__(self) -> None:
self._store: dict[str, Content] = {}
async def save_approval_request(self, approval_request_id: str, request: Content) -> None:
if approval_request_id in self._store:
raise ValueError(f"Approval request with ID '{approval_request_id}' already exists.")
self._store[approval_request_id] = request
async def load_approval_request(self, approval_request_id: str) -> Content:
if approval_request_id not in self._store:
raise KeyError(f"Approval request with ID '{approval_request_id}' does not exist.")
return self._store[approval_request_id]
class FileBasedFunctionApprovalStorage:
"""A simple file-based storage for function approval requests.
Concurrent writes from multiple threads in the same process are
serialized by a ``threading.Lock``, and the on-disk JSON file is
updated atomically (write to a temp file, then ``os.replace``) so a
crash mid-write cannot leave a partially written file behind.
"""
def __init__(self, storage_path: str) -> None:
self._storage_path = storage_path
self._lock = threading.Lock()
def _create_storage_file_if_not_exists_sync(self) -> None:
"""Lazy-create the storage file (and its parent directory) if it does not already exist.
Uses exclusive-create mode (``"x"``) so a concurrent creator cannot
be truncated by an ``open(..., "w")`` after a stale existence check.
"""
os.makedirs(os.path.dirname(self._storage_path) or ".", exist_ok=True)
with suppress(FileExistsError), open(self._storage_path, "x") as f:
json.dump({}, f)
def _atomic_write(self, data: dict[str, Any]) -> None:
"""Atomically replace the storage file with the serialized ``data``."""
directory = os.path.dirname(self._storage_path) or "."
# Serialize first so any error doesn't leave a partial file behind.
serialized = json.dumps(data)
fd, tmp_path = tempfile.mkstemp(prefix=".approvals-", suffix=".tmp", dir=directory)
try:
with os.fdopen(fd, "w") as tmp:
tmp.write(serialized)
os.replace(tmp_path, self._storage_path)
except BaseException:
with suppress(OSError):
os.unlink(tmp_path)
raise
def _save_sync(self, approval_request_id: str, request: Content) -> None:
with self._lock:
self._create_storage_file_if_not_exists_sync()
with open(self._storage_path) as f:
data = json.load(f)
if approval_request_id in data:
raise ValueError(f"Approval request with ID '{approval_request_id}' already exists.")
data[approval_request_id] = request.to_dict()
self._atomic_write(data)
def _load_sync(self, approval_request_id: str) -> Content:
with self._lock:
self._create_storage_file_if_not_exists_sync()
with open(self._storage_path) as f:
data = json.load(f)
if approval_request_id not in data:
raise KeyError(f"Approval request with ID '{approval_request_id}' does not exist.")
return Content.from_dict(data[approval_request_id])
async def save_approval_request(self, approval_request_id: str, request: Content) -> None:
await asyncio.to_thread(self._save_sync, approval_request_id, request)
async def load_approval_request(self, approval_request_id: str) -> Content:
return await asyncio.to_thread(self._load_sync, approval_request_id)
class ResponsesHostServer(ResponsesAgentServerHost):
"""A responses server host for an agent."""
# TODO(@taochen): Allow a different checkpoint storage that stores checkpoints externally
CHECKPOINT_STORAGE_PATH = "/.checkpoints"
FUNCTION_APPROVAL_STORAGE_PATH = "/.function_approvals/approval_requests.json"
def __init__(
self,
@@ -268,11 +171,6 @@ class ResponsesHostServer(ResponsesAgentServerHost):
self._is_workflow_agent = True
self._agent = agent
self._approval_storage = (
FileBasedFunctionApprovalStorage(self.FUNCTION_APPROVAL_STORAGE_PATH)
if self.config.is_hosted
else InMemoryFunctionApprovalStorage()
)
self.response_handler(self._handle_response) # pyright: ignore[reportUnknownMemberType]
async def _handle_response(
@@ -294,15 +192,10 @@ class ResponsesHostServer(ResponsesAgentServerHost):
) -> AsyncIterable[ResponseStreamEvent | dict[str, Any]]:
"""Handle the creation of a response for a regular (non-workflow) agent."""
input_items = await context.get_input_items()
input_messages = await _items_to_messages(input_items, approval_storage=self._approval_storage)
input_messages = _items_to_messages(input_items)
history = await context.get_history()
run_kwargs: dict[str, Any] = {
"messages": [
*(await _output_items_to_messages(history, approval_storage=self._approval_storage)),
*input_messages,
]
}
run_kwargs: dict[str, Any] = {"messages": [*_output_items_to_messages(history), *input_messages]}
is_streaming_request = request.stream is not None and request.stream is True
chat_options, are_options_set = _to_chat_options(request)
@@ -323,11 +216,7 @@ class ResponsesHostServer(ResponsesAgentServerHost):
for message in response.messages:
for content in message.contents:
async for item in _to_outputs(
response_event_stream,
content,
approval_storage=self._approval_storage,
):
async for item in _to_outputs(response_event_stream, content):
yield item
yield response_event_stream.emit_completed()
@@ -343,11 +232,7 @@ class ResponsesHostServer(ResponsesAgentServerHost):
for event in tracker.handle(content):
yield event
if tracker.needs_async:
async for item in _to_outputs(
response_event_stream,
content,
approval_storage=self._approval_storage,
):
async for item in _to_outputs(response_event_stream, content):
yield item
tracker.needs_async = False
@@ -369,7 +254,7 @@ class ResponsesHostServer(ResponsesAgentServerHost):
by the hosting infrastructure or files will be preserved upon deactivation.
"""
input_items = await context.get_input_items()
input_messages = await _items_to_messages(input_items)
input_messages = _items_to_messages(input_items)
is_streaming_request = request.stream is not None and request.stream is True
_, are_options_set = _to_chat_options(request)
@@ -696,32 +581,26 @@ def _to_chat_options(request: CreateResponse) -> tuple[ChatOptions, bool]:
# region Input Message Conversion
async def _items_to_messages(
input_items: Sequence[Item], *, approval_storage: ApprovalStorage | None = None
) -> list[Message]:
def _items_to_messages(input_items: Sequence[Item]) -> list[Message]:
"""Converts a sequence of input items to a list of Messages, one per item.
Args:
input_items: The input items to convert.
approval_storage: An optional ApprovalStorage instance used to look up
approval requests when converting MCP approval response items.
Returns:
A list of Messages, one per supported input item.
"""
messages: list[Message] = []
for item in input_items:
messages.append(await _item_to_message(item, approval_storage=approval_storage))
messages.append(_item_to_message(item))
return messages
async def _item_to_message(item: Item, *, approval_storage: ApprovalStorage | None = None) -> Message:
def _item_to_message(item: Item) -> Message:
"""Converts an Item to a Message.
Args:
item: The Item to convert.
approval_storage: An optional ApprovalStorage instance used to look up
approval requests when converting MCP approval response items.
Returns:
The converted Message.
@@ -780,26 +659,27 @@ async def _item_to_message(item: Item, *, approval_storage: ApprovalStorage | No
if item.type == "mcp_approval_request":
mcp_req = cast(ItemMcpApprovalRequest, item)
if approval_storage is not None:
function_approval_request_content = await approval_storage.load_approval_request(mcp_req.id)
else:
raise ValueError("ApprovalStorage is required to load approval request.")
mcp_call_content = Content.from_mcp_server_tool_call(
mcp_req.id,
mcp_req.name,
server_name=mcp_req.server_label,
arguments=mcp_req.arguments,
)
return Message(
role="assistant",
contents=[function_approval_request_content],
contents=[Content.from_function_approval_request(mcp_req.id, mcp_call_content)],
)
if item.type == "mcp_approval_response":
mcp_resp = cast(MCPApprovalResponse, item)
if approval_storage is not None:
function_approval_request_content = await approval_storage.load_approval_request(
mcp_resp.approval_request_id
)
else:
raise ValueError("ApprovalStorage is required to load approval request.")
placeholder_content = Content.from_function_call(mcp_resp.approval_request_id, "mcp_approval")
return Message(
role="user",
contents=[function_approval_request_content.to_function_approval_response(mcp_resp.approve)],
contents=[
Content.from_function_approval_response(
mcp_resp.approve, mcp_resp.approval_request_id, placeholder_content
)
],
)
if item.type == "code_interpreter_call":
@@ -966,34 +846,26 @@ async def _item_to_message(item: Item, *, approval_storage: ApprovalStorage | No
raise ValueError(f"Unsupported Item type: {item.type}")
async def _output_items_to_messages(
history: Sequence[OutputItem],
*,
approval_storage: ApprovalStorage | None = None,
) -> list[Message]:
def _output_items_to_messages(history: Sequence[OutputItem]) -> list[Message]:
"""Converts a sequence of OutputItem objects to a list of Message objects.
Args:
history (Sequence[OutputItem]): The sequence of OutputItem objects to convert.
approval_storage (ApprovalStorage | None, optional): The approval storage to use for
resolving MCP approval requests. Defaults to None.
Returns:
list[Message]: The list of Message objects.
"""
messages: list[Message] = []
for item in history:
messages.append(await _output_item_to_message(item, approval_storage=approval_storage))
messages.append(_output_item_to_message(item))
return messages
async def _output_item_to_message(item: OutputItem, *, approval_storage: ApprovalStorage | None = None) -> Message:
def _output_item_to_message(item: OutputItem) -> Message:
"""Converts an OutputItem to a Message.
Args:
item (OutputItem): The OutputItem to convert.
approval_storage (ApprovalStorage | None, optional): The approval storage to use for
resolving MCP approval requests. Defaults to None.
Returns:
Message: The converted Message.
@@ -1050,27 +922,24 @@ async def _output_item_to_message(item: OutputItem, *, approval_storage: Approva
if item.type == "mcp_approval_request":
mcp_req = cast(OutputItemMcpApprovalRequest, item)
if approval_storage is not None:
function_approval_request_content = await approval_storage.load_approval_request(mcp_req.id)
else:
raise ValueError("ApprovalStorage is required to load approval request.")
mcp_call_content = Content.from_mcp_server_tool_call(
mcp_req.id,
mcp_req.name,
server_name=mcp_req.server_label,
arguments=mcp_req.arguments,
)
return Message(
role="assistant",
contents=[function_approval_request_content],
contents=[Content.from_function_approval_request(mcp_req.id, mcp_call_content)],
)
if item.type == "mcp_approval_response":
mcp_resp = cast(OutputItemMcpApprovalResponseResource, item)
if approval_storage is not None:
function_approval_request_content = await approval_storage.load_approval_request(
mcp_resp.approval_request_id
)
else:
raise ValueError("ApprovalStorage is required to load approval request.")
# Build a placeholder function_call Content since the original call details are not available
placeholder_content = Content.from_function_call(mcp_resp.approval_request_id, "mcp_approval")
return Message(
role="user",
contents=[function_approval_request_content.to_function_approval_response(mcp_resp.approve)],
contents=[Content.from_function_approval_response(mcp_resp.approve, mcp_resp.id, placeholder_content)],
)
if item.type == "code_interpreter_call":
@@ -1368,18 +1237,12 @@ def _arguments_to_str(arguments: str | Mapping[str, Any] | None) -> str:
return json.dumps(arguments)
async def _to_outputs(
stream: ResponseEventStream,
content: Content,
*,
approval_storage: ApprovalStorage | None = None,
) -> AsyncIterator[ResponseStreamEvent]:
async def _to_outputs(stream: ResponseEventStream, content: Content) -> AsyncIterator[ResponseStreamEvent]:
"""Converts a Content object to an async sequence of ResponseStreamEvent objects.
Args:
stream: The ResponseEventStream to use for building events.
content: The Content to convert.
approval_storage: An optional ApprovalStorage instance to use for saving and loading function approval requests.
Yields:
ResponseStreamEvent: The converted event objects.
@@ -1457,31 +1320,6 @@ async def _to_outputs(
max_output_length=content.max_output_length,
):
yield event
elif content.type == "function_approval_request":
function_call: Content = content.function_call # type: ignore
server_label = function_call.additional_properties.get("server_label", "agent_framework")
request_saved = False
async for event in stream.aoutput_item_mcp_approval_request(
server_label,
function_call.name, # type: ignore
_arguments_to_str(function_call.arguments),
):
if approval_storage is not None and not request_saved:
# Extract the approval request ID generated by the infrastructure
# when the approval request item is added to the stream. Save the
# approval request to the approval storage so it can be retrieved later
# for round trips where the original approval request needs to be looked up.
item = getattr(event, "item", None)
if item is not None and getattr(item, "id", None) is not None:
approval_request_id = cast(str, item.id) # type: ignore
await approval_storage.save_approval_request(approval_request_id, content)
request_saved = True
yield event
if approval_storage is not None and not request_saved:
logger.warning(
"Approval request was not saved to approval storage because the approval request ID "
"could not be extracted from the stream event."
)
else:
# Log a warning for unsupported content types instead of raising an error to avoid breaking the response stream.
logger.warning(f"Content type '{content.type}' is not supported yet. This is usually safe to ignore.")
File diff suppressed because it is too large Load Diff
@@ -204,6 +204,11 @@ class OpenAIChatOptions(ChatOptions[ResponseFormatT], Generic[ResponseFormatT],
"""Configuration for reasoning models (gpt-5, o-series).
See: https://platform.openai.com/docs/guides/reasoning"""
verbosity: Literal["low", "medium", "high"]
"""Output verbosity for GPT-5 family models. Lower values yield shorter responses.
Translated to ``text.verbosity`` when sent to the Responses API.
See: https://developers.openai.com/cookbook/examples/gpt-5/gpt-5_new_params_and_tools#1-verbosity-parameter"""
safety_identifier: str
"""A stable identifier for detecting policy violations.
Recommend hashing username/email to avoid sending identifying info."""
@@ -662,7 +667,16 @@ class RawOpenAIChatClient( # type: ignore[misc]
response = await client.responses.retrieve(continuation_token["response_id"])
except Exception as ex:
self._handle_request_error(ex)
return self._parse_response_from_openai(response, options=validated_options)
chat_response = self._parse_response_from_openai(response, options=validated_options)
# Once the background response completes, drop the continuation_token from
# the caller's options dict. FunctionInvocationLayer reuses the same dict
# across tool-loop iterations, so leaving it in place makes the next iteration
# retrieve the same completed response again instead of POSTing tool results
# (issue #5394). Keep `background` so subsequent iterations still create
# background responses.
if chat_response.continuation_token is None and isinstance(options, dict):
options.pop("continuation_token", None)
return chat_response
client, run_options, validated_options = await self._prepare_request(messages, options)
try:
if "text_format" in run_options:
@@ -1322,6 +1336,11 @@ class RawOpenAIChatClient( # type: ignore[misc]
response_format, text_config = self._prepare_response_and_text_format(
response_format=response_format, text_config=text_config
)
# The Responses API nests verbosity under ``text.verbosity``; surface it as a
# top-level option for parity with ``reasoning`` and translate here.
if (verbosity := run_options.pop("verbosity", None)) is not None:
text_config = dict(text_config) if text_config else {}
text_config["verbosity"] = verbosity
if text_config:
run_options["text"] = text_config
if response_format:
@@ -145,6 +145,9 @@ class OpenAIChatCompletionOptions(ChatOptions[ResponseModelT], Generic[ResponseM
logprobs: bool
top_logprobs: int
prediction: Prediction
verbosity: Literal["low", "medium", "high"]
"""Output verbosity for GPT-5 family models. Lower values yield shorter responses.
See: https://developers.openai.com/cookbook/examples/gpt-5/gpt-5_new_params_and_tools#1-verbosity-parameter"""
OpenAIChatCompletionOptionsT = TypeVar(
@@ -343,6 +343,76 @@ async def test_get_response_with_all_parameters() -> None:
assert run_options["input"][1]["content"][0]["text"] == "Test message"
def test_openai_chat_options_declares_verbosity_field() -> None:
"""OpenAIChatOptions declares verbosity as a typed Literal field."""
from typing import get_args, get_type_hints
from agent_framework_openai import OpenAIChatOptions
annotations = get_type_hints(OpenAIChatOptions)
assert "verbosity" in annotations
assert {"low", "medium", "high"} <= set(get_args(annotations["verbosity"]))
async def test_verbosity_option_translates_to_text_field() -> None:
"""Top-level verbosity is translated to text.verbosity for the Responses API."""
client = OpenAIChatClient(model="test-model", api_key="test-key")
_, run_options, _ = await client._prepare_request(
messages=[Message(role="user", contents=["Test message"])],
options={"verbosity": "low"},
)
assert "verbosity" not in run_options
assert run_options["text"] == {"verbosity": "low"}
async def test_verbosity_option_merges_with_response_format() -> None:
"""Verbosity merges into text config alongside response_format-derived format."""
client = OpenAIChatClient(model="test-model", api_key="test-key")
_, run_options, _ = await client._prepare_request(
messages=[Message(role="user", contents=["Test message"])],
options={
"verbosity": "high",
"response_format": OutputStruct,
},
)
assert "verbosity" not in run_options
assert run_options["text"]["verbosity"] == "high"
assert run_options["text_format"] is OutputStruct
async def test_verbosity_option_top_level_overrides_nested_text_verbosity() -> None:
"""When both top-level and text['verbosity'] are set, the top-level value wins."""
client = OpenAIChatClient(model="test-model", api_key="test-key")
_, run_options, _ = await client._prepare_request(
messages=[Message(role="user", contents=["Test message"])],
options={
"verbosity": "high",
"text": {"verbosity": "low"},
},
)
assert "verbosity" not in run_options
assert run_options["text"]["verbosity"] == "high"
async def test_verbosity_option_merges_with_explicit_text_config() -> None:
"""Verbosity merges into a user-provided text config without overwriting other keys."""
client = OpenAIChatClient(model="test-model", api_key="test-key")
_, run_options, _ = await client._prepare_request(
messages=[Message(role="user", contents=["Test message"])],
options={
"verbosity": "medium",
"text": {"format": {"type": "text"}},
},
)
assert "verbosity" not in run_options
assert run_options["text"]["verbosity"] == "medium"
assert run_options["text"]["format"] == {"type": "text"}
@pytest.mark.asyncio
async def test_web_search_tool_with_location() -> None:
"""Test web search tool with location parameters."""
@@ -1563,6 +1563,27 @@ def test_prepare_options_removes_parallel_tool_calls_when_no_tools(
assert "parallel_tool_calls" not in prepared_options
def test_openai_chat_completion_options_declares_verbosity_field() -> None:
"""OpenAIChatCompletionOptions declares verbosity as a typed Literal field."""
from typing import get_args, get_type_hints
from agent_framework_openai import OpenAIChatCompletionOptions
annotations = get_type_hints(OpenAIChatCompletionOptions)
assert "verbosity" in annotations
assert {"low", "medium", "high"} <= set(get_args(annotations["verbosity"]))
def test_prepare_options_forwards_verbosity(openai_unit_test_env: dict[str, str]) -> None:
"""Verbosity passes through unchanged for the Chat Completions API."""
client = OpenAIChatCompletionClient()
messages = [Message(role="user", contents=["test"])]
prepared_options = client._prepare_options(messages, {"verbosity": "low"})
assert prepared_options["verbosity"] == "low"
def test_prepare_options_excludes_conversation_id(openai_unit_test_env: dict[str, str]) -> None:
"""Test that conversation_id is excluded from prepared options for chat completions."""
client = OpenAIChatCompletionClient()
@@ -24,6 +24,7 @@ This folder contains OpenAI provider samples for the generic clients in
| [`client_image_generation.py`](client_image_generation.py) | Generate images from text prompts. |
| [`client_reasoning.py`](client_reasoning.py) | Reasoning-focused sample for models such as `gpt-5`. |
| [`client_streaming_image_generation.py`](client_streaming_image_generation.py) | Streaming image generation sample. |
| [`client_verbosity.py`](client_verbosity.py) | GPT-5 `verbosity` option (`low`/`medium`/`high`) with default and per-call overrides. |
| [`client_with_agent_as_tool.py`](client_with_agent_as_tool.py) | Agent-as-tool orchestration pattern. |
| [`client_with_code_interpreter.py`](client_with_code_interpreter.py) | Code interpreter sample. |
| [`client_with_code_interpreter_files.py`](client_with_code_interpreter_files.py) | Code interpreter sample with uploaded files. |
@@ -0,0 +1,73 @@
# Copyright (c) Microsoft. All rights reserved.
import asyncio
from typing import Literal
from agent_framework import Agent
from agent_framework.openai import OpenAIChatClient, OpenAIChatOptions
from dotenv import load_dotenv
Verbosity = Literal["low", "medium", "high"]
load_dotenv()
"""
OpenAI Chat Client Verbosity Example
Demonstrates the GPT-5 ``verbosity`` parameter on the Responses API. ``verbosity``
controls how concise or detailed the model's natural-language output is and accepts
``"low"``, ``"medium"``, or ``"high"``.
The framework exposes ``verbosity`` as a top-level option on ``OpenAIChatOptions``
(parallel to ``reasoning``) and translates it to ``text.verbosity`` when calling the
Responses API.
"""
PROMPT = "Explain in your own words what photosynthesis is and why it matters."
async def run_with_verbosity(level: Verbosity) -> None:
"""Run the same prompt with a different verbosity setting and print the output length."""
agent = Agent(
client=OpenAIChatClient[OpenAIChatOptions](model="gpt-5"),
name=f"Explainer-{level}",
instructions="You are a friendly science explainer.",
default_options={"verbosity": level},
)
print(f"\033[92m=== verbosity={level!r} ===\033[0m")
response = await agent.run(PROMPT)
text = response.text or ""
print(text)
print(f"\n[chars: {len(text)}]\n")
async def run_per_call_override() -> None:
"""Show that verbosity can be overridden per ``run`` call."""
agent = Agent(
client=OpenAIChatClient[OpenAIChatOptions](model="gpt-5"),
name="Explainer-default",
instructions="You are a friendly science explainer.",
default_options={"verbosity": "high"},
)
print("\033[92m=== per-call override: verbosity='low' ===\033[0m")
response = await agent.run(PROMPT, options={"verbosity": "low"})
text = response.text or ""
print(text)
print(f"\n[chars: {len(text)}]\n")
async def main() -> None:
print("\033[92m=== OpenAI Chat Client Verbosity Example ===\033[0m\n")
levels: tuple[Verbosity, ...] = ("low", "medium", "high")
for level in levels:
await run_with_verbosity(level)
await run_per_call_override()
if __name__ == "__main__":
asyncio.run(main())
@@ -14,7 +14,8 @@ This directory contains samples that demonstrate how to use hosted [Agent Framew
| 4 | [Foundry Toolbox](responses/04_foundry_toolbox/) | An agent using Azure Foundry Toolbox, demonstrating toolbox provisioning and querying available tools at runtime. |
| 5 | [Workflows](responses/05_workflows/) | An agent with a multi-step orchestrated workflow, demonstrating chaining prompts through an orchestrated flow. |
| 6 | [Files](responses/06_files/) | An agent demonstrating how to work with files in a hosted agent session, including uploading files to a hosted agent session and having the agent read and manipulate those files at runtime. |
| 7 | [Using deployed agent](responses/using_deployed_agent.py) | A sample demonstrating how to invoke an agent that has already been deployed to Foundry, showing how to interact with a hosted agent in code. |
| 7 | [Observability](responses/07_observability/) | A sample demonstrating how to enable observability for the agent deployed to Foundry. |
| 8 | [Using deployed agent](responses/using_deployed_agent.py) | A sample demonstrating how to invoke an agent that has already been deployed to Foundry, showing how to interact with a hosted agent in code. |
### Invocations API
@@ -156,7 +157,7 @@ cd agent-framework/python/samples/04-hosting/foundry-hosted-agents/responses
2. Install dependencies:
```bash
uv pip install -r requirements.txt
pip install -r requirements.txt
```
3. Create a `.env` file with your Foundry configuration following the `env.example` file in the sample.
@@ -3,4 +3,5 @@ __pycache__
*.pyc
*.pyo
*.pyd
.Python
.Python
.env
@@ -3,4 +3,5 @@ __pycache__
*.pyc
*.pyo
*.pyd
.Python
.Python
.env
@@ -10,16 +10,6 @@ The agent uses `FoundryChatClient` from the Agent Framework to create a Response
See [main.py](main.py) for the full implementation.
### Tools
Local tools are Python functions decorated with the Agent Framework's `@tool` decorator and registered with the agent. When the model chooses to call a tool during a conversation, the agent executes the corresponding function and returns the result to the model.
Each tool can be configured with one of two approval modes: **always_require** or **never_require**. With **always_require**, the agent requests explicit user approval before every invocation; with **never_require**, the agent invokes the tool automatically. To illustrate both behaviors, this sample defines two tools—one using `always_require` and the other using `never_require`.
When a tool is set to `always_require`, the agent host emits an `mcp_approval_request` output containing the approval request ID and details of the pending tool call. The client must reply with an `mcp_approval_response` indicating the same request ID and whether the user approved or denied the call before the agent will proceed.
> IMPORTANT: We are temporarily reusing the **mcp_approval_request** and **mcp_approval_response** message types defined in the [AzureAI AgentServer SDK](https://github.com/Azure/azure-sdk-for-python/blob/main/sdk/agentserver/azure-ai-agentserver-responses/docs/handler-implementation-guide.md#other-tool-call-types) because they map closely to this approval flow. They will likely be superseded by a more formal tool-approval content type in the Responses protocol in the future.
### Agent Hosting
The agent is hosted using the [Agent Framework](https://github.com/microsoft/agent-framework) with the `ResponsesHostServer`, which provisions a REST API endpoint compatible with the OpenAI Responses protocol.
@@ -38,24 +28,6 @@ Send a POST request to the server with a JSON body containing an `"input"` field
curl -X POST http://localhost:8088/responses -H "Content-Type: application/json" -d '{"input": "What is the weather in Seattle?"}'
```
Send a POST request that triggers a tool call configured with `always_require` to see the approval flow in action:
```bash
curl -X POST http://localhost:8088/responses -H "Content-Type: application/json" -d '{"input": "List all the files in the current directory."}'
```
Sample output:
```bash
{"id":"caresp_3b6cba8c972b1d2f00bXmjpUGzfgSFsmgjtlgqUwqvROwl5lyG","object":"response","output":[{"type":"function_call","id":"fc_3b6cba8c972b1d2f00JIAQktGC1upcB6Dgxp1AVVLp0MoyRTX4","call_id":"call_hWwwZ8lqVQCAuo8ZyY4LXIya","name":"run_bash","arguments":"{\"command\":\"ls -la\"}","status":"completed","response_id":"caresp_3b6cba8c972b1d2f00bXmjpUGzfgSFsmgjtlgqUwqvROwl5lyG","agent_reference":null},{"type":"mcp_approval_request","id":"mcpr_3b6cba8c972b1d2f00IdqsjB6iidFmtsuYp6oI1AoAtUKQZxje","server_label":"agent_framework","name":"run_bash","arguments":"{\"command\":\"ls -la\"}","response_id":"caresp_3b6cba8c972b1d2f00bXmjpUGzfgSFsmgjtlgqUwqvROwl5lyG","agent_reference":null}],"created_at":1778021855,"model":"","status":"completed","completed_at":1778021865,"response_id":"caresp_3b6cba8c972b1d2f00bXmjpUGzfgSFsmgjtlgqUwqvROwl5lyG","agent_reference":{"type":"agent_reference"},"agent_session_id":"8caaaa19598306a1f2fb6d8939ef06874c52c63a83b57681ea4e4b75cf6a179","background":false}
```
To approve:
```bash
curl -X POST http://localhost:8088/responses -H "Content-Type: application/json" -d '{"input": [{"type": "mcp_approval_response", "approval_request_id": "mcpr_3b6cba8c972b1d2f00IdqsjB6iidFmtsuYp6oI1AoAtUKQZxje", "approve": true}], "previous_response_id": "caresp_3b6cba8c972b1d2f00bXmjpUGzfgSFsmgjtlgqUwqvROwl5lyG"}'
```
## Deploying the Agent to Foundry
To host the agent on Foundry, follow the instructions in the [Deploying the Agent to Foundry](../../README.md#deploying-the-agent-to-foundry) section of the README in the parent directory.
@@ -25,7 +25,7 @@ def get_weather(
return f"The weather in {location} is {conditions[randint(0, 3)]} with a high of {randint(10, 30)}°C."
@tool(approval_mode="always_require")
@tool(approval_mode="never_require")
def run_bash(command: str) -> str:
"""Execute a shell command locally and return stdout, stderr, and exit code."""
try:
@@ -3,4 +3,5 @@ __pycache__
*.pyc
*.pyo
*.pyd
.Python
.Python
.env
@@ -3,4 +3,5 @@ __pycache__
*.pyc
*.pyo
*.pyd
.Python
.Python
.env
@@ -3,4 +3,5 @@ __pycache__
*.pyc
*.pyo
*.pyd
.Python
.Python
.env
@@ -4,6 +4,7 @@ __pycache__
*.pyo
*.pyd
.Python
.env
# Local-only client tooling and sample data; not needed inside the agent image.
resources/
@@ -0,0 +1,7 @@
.venv
__pycache__
*.pyc
*.pyo
*.pyd
.Python
.env
@@ -0,0 +1,4 @@
FOUNDRY_PROJECT_ENDPOINT="..."
AZURE_AI_MODEL_DEPLOYMENT_NAME="..."
ENABLE_INSTRUMENTATION=true
ENABLE_SENSITIVE_DATA=true
@@ -0,0 +1,16 @@
FROM python:3.12-slim
WORKDIR /app
COPY . user_agent/
WORKDIR /app/user_agent
RUN if [ -f requirements.txt ]; then \
pip install -r requirements.txt; \
else \
echo "No requirements.txt found"; \
fi
EXPOSE 8088
CMD ["python", "main.py"]
@@ -0,0 +1,51 @@
# What this sample demonstrates
An instrumented [Agent Framework](https://github.com/microsoft/agent-framework) agent hosted using the **Responses protocol**.
## How It Works
### Model Integration
The agent uses `FoundryChatClient` from the Agent Framework to create a Responses client from the project endpoint and model deployment. The agent supports both streaming (SSE events) and non-streaming (JSON) response modes.
See [main.py](main.py) for the full implementation.
### Agent Hosting
The agent is hosted using the [Agent Framework](https://github.com/microsoft/agent-framework) with the `ResponsesHostServer`, which provisions a REST API endpoint compatible with the OpenAI Responses protocol.
### Instrumentation
Agent Framework is [**natively instrumented**](https://learn.microsoft.com/en-us/agent-framework/agents/observability?pivots=programming-language-python) to capture diagnostics and telemetry for agent execution, but it's turned off by default. This sample demonstrates how to enable instrumentation via environment variables in `agent.manifest.yaml` and `agent.yaml`. The relevant environment variables are `ENABLE_INSTRUMENTATION` and `ENABLE_SENSITIVE_DATA`, which can be set to `true` to enable diagnostics and capture sensitive events respectively.
Foundry Hosted Agent has built-in observability thus you don't need to set up exporters manually to capture telemetry from your code. The traces, metrics, and logs generated by the agent are automatically collected and made available through Foundry's observability stack via Azure Monitor/Application Insights. The `APPLICATIONINSIGHTS_CONNECTION_STRING` environment variable is injected when the agent is deployed to Foundry, however it is still required to be set in your environment if you want to run the agent host locally and have telemetry sent to Application Insights from your local environment.
## Running the Agent Host
Follow the instructions in the [Running the Agent Host Locally](../../README.md#running-the-agent-host-locally) section of the README in the parent directory to run the agent host.
## Interacting with the agent
> Because the observability exporters are managed by Foundry, this sample must be run using `azd ai agent run`. Run this sample using `python main.py` will not send telemetry to Application Insights.
```bash
azd ai agent run --local "What is the current weather?"
```
A couple of spans will be created for this request from Agent Framework's instrumentation, representing the generation of the response by the agent:
- `invoke_agent`: This span represents the invocation of the agent itself, capturing the start and end of the agent's processing for this request.
- `chat`: This span represents the call to the underlying model.
- `execute_tool`: This span represents the execution of any tools invoked by the agent as part of generating the response.
> For more information on the spans, refer to the [OpenTelemetry GenAI Semantic Conventions](https://opentelemetry.io/docs/specs/semconv/gen-ai/)
## Deploying the Agent to Foundry
To host the agent on Foundry, follow the instructions in the [Deploying the Agent to Foundry](../../README.md#deploying-the-agent-to-foundry) section of the README in the parent directory.
### Viewing Telemetry in Foundry
Once the agent is deployed to Foundry, the telemetry generated by the agent (traces, metrics, and logs) will be automatically collected and sent to Azure Monitor/Application Insights. You can view this telemetry by navigating to the Application Insights resource associated with your Foundry project or directly from the Foundry UI.
In the Foundry UI, next to the **Playground** tab is the **Traces** tab, where you can find the conversations and their corresponding trace IDs. Clicking on a trace ID will allow you to drill into the detailed trace information for that particular conversation.
@@ -0,0 +1,27 @@
name: agent-framework-agent-observability-responses
description: >
A basic Agent Framework agent hosted by Foundry.
metadata:
tags:
- Agent Framework
- AI Agent Hosting
- Azure AI AgentServer
- Responses Protocol
- Streaming
template:
name: agent-framework-agent-observability-responses
kind: hosted
protocols:
- protocol: responses
version: 1.0.0
environment_variables:
- name: AZURE_AI_MODEL_DEPLOYMENT_NAME
value: "{{AZURE_AI_MODEL_DEPLOYMENT_NAME}}"
- name: ENABLE_INSTRUMENTATION
value: true
- name: ENABLE_SENSITIVE_DATA
value: true
resources:
- kind: model
id: gpt-4.1-mini
name: AZURE_AI_MODEL_DEPLOYMENT_NAME
@@ -0,0 +1,16 @@
# yaml-language-server: $schema=https://raw.githubusercontent.com/microsoft/AgentSchema/refs/heads/main/schemas/v1.0/ContainerAgent.yaml
kind: hosted
name: agent-framework-agent-observability-responses
protocols:
- protocol: responses
version: 1.0.0
resources:
cpu: '0.25'
memory: '0.5Gi'
environment_variables:
- name: AZURE_AI_MODEL_DEPLOYMENT_NAME
value: ${AZURE_AI_MODEL_DEPLOYMENT_NAME}
- name: ENABLE_INSTRUMENTATION
value: true
- name: ENABLE_SENSITIVE_DATA
value: true
@@ -0,0 +1,57 @@
# Copyright (c) Microsoft. All rights reserved.
import asyncio
import os
from random import randint
from typing import Annotated
from agent_framework import Agent, tool
from agent_framework.foundry import FoundryChatClient
from agent_framework_foundry_hosting import ResponsesHostServer
from azure.identity import DefaultAzureCredential
from dotenv import load_dotenv
from pydantic import Field
# Load environment variables from .env file
load_dotenv()
@tool(approval_mode="never_require", description="Get the current location of the user.")
def get_current_location() -> str:
"""Get the current location of the agent."""
locations = ["New York", "London", "Paris", "Tokyo"]
return locations[randint(0, len(locations) - 1)]
@tool(approval_mode="never_require")
def get_weather(
location: Annotated[str, Field(description="The location to get the weather for.")],
) -> str:
"""Get the weather for a given location."""
conditions = ["sunny", "cloudy", "rainy", "stormy"]
return f"The weather in {location} is {conditions[randint(0, 3)]} with a high of {randint(10, 30)}°C."
async def main():
client = FoundryChatClient(
project_endpoint=os.environ["FOUNDRY_PROJECT_ENDPOINT"],
model=os.environ["AZURE_AI_MODEL_DEPLOYMENT_NAME"],
credential=DefaultAzureCredential(),
)
agent = Agent(
client=client,
instructions="You are a friendly assistant. Keep your answers brief.",
tools=[get_weather, get_current_location],
# History will be managed by the hosting infrastructure, thus there
# is no need to store history by the service. Learn more at:
# https://developers.openai.com/api/reference/resources/responses/methods/create
default_options={"store": False},
)
server = ResponsesHostServer(agent)
await server.run_async()
if __name__ == "__main__":
asyncio.run(main())
@@ -0,0 +1,2 @@
agent-framework
agent-framework-foundry-hosting