Compare commits

..

28 Commits

Author SHA1 Message Date
Tao Chen 6737531882 Update type ignore statements 2026-06-25 08:44:38 -07:00
Tao Chen 953f08f533 Address comments 2026-06-24 15:15:18 -07:00
Tao Chen 7c533de7e1 Fix formatting 2026-06-24 13:01:01 -07:00
Tao Chen 3ba1dd3787 Fix tests and formatting 2026-06-24 12:44:53 -07:00
Tao Chen 9ca6987a09 Reset resume flag in a finally so it can't leak across runs
_resumed_from_checkpoint was only cleared on the success path of run_until_convergence, so a failure during a resumed run (e.g. executor failure) left it True. The next fresh run then skipped the superstep-0 checkpoint and parented later checkpoints to the stale resume point. Move the reset into a finally. Add a regression test that fails a resumed run via an executor error and asserts the next fresh run creates the superstep-0 checkpoint.
2026-06-24 10:26:54 -07:00
Tao Chen 439bee46b1 Nest runtime-storage clear under the owns_run guard
Both the active-run release and the runtime-storage clear are gated on owns_run, so fold the storage clear inside the if owns_run block. No behavior change.
2026-06-24 10:18:49 -07:00
Tao Chen 080a959402 Collapse runtime-storage ownership into the active-run weakref
_runtime_storage_owner always held the same weakref as _active_run, so the two ownership conditions were equivalent. Derive ownership from a single owns_run = (_active_run is my_active_run) captured before the active-run clear, and remove the redundant field. No behavior change.
2026-06-24 10:09:18 -07:00
Tao Chen fcda092fa4 Scope runtime checkpoint storage to its owning run
Close the stream-drop race where a dropped run's deferred async-generator finalizer could leave a runtime checkpoint storage override set (inherited by a new run) or clear a successor run's storage. run() now defensively clears any stale override before starting, and _run_core only clears the override if this run still owns it (mirroring the _active_run ownership guard). Adds regression tests for both the inheritance and clobber cases.
2026-06-24 09:56:04 -07:00
Tao Chen 714016a035 Refactor runner/workflow responsibilities, add concurrency guards, and fix checkpoint ancestry bug
Move runner-state ownership out of Workflow into Runner for clearer responsibilities. Add a weakref-based concurrent-run guard in Workflow and fix the stream-drop race in run_until_convergence. Fix the checkpoint ancestry bug by tracking the previous checkpoint id as runner instance state so parent pointers persist across resumed runs. Move Runner to a deprecated lazy __getattr__ export (backward-compatible with DeprecationWarning) and export CheckpointID.
2026-06-23 17:28:58 -07:00
Amit Dhawan 5fff0df2af Python: Add load_dotenv to get-started samples and fix chat_response_… (#6691)
* Python: Add load_dotenv to get-started samples and fix chat_response_cancellation docs

* Potential fix for pull request finding

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

---------

Co-authored-by: Amit Dhawan <amit.dhawan@barco.com>
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
2026-06-23 23:11:08 +00:00
Eduard van Valkenburg acb28a63b5 Python: Fix MCP metadata and tool name handling (#6656)
* Fix MCP metadata and tool name handling

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

* Address MCP review feedback

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

---------

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-06-23 21:00:12 +00:00
Eduard van Valkenburg f2d02e58b3 Python: Add hosting core and Responses channel (#6580)
* Add Python hosting core and Responses channel

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

* Address hosting core review feedback

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

* Adopt source pyright typing setup for hosting packages

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

* Cover ResponsesChannel custom path routing

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

* Align hosting tests with package layout

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

* Fix hosting workflow fixture imports in aggregate tests

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

* Apply useful Responses channel hardening

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

* Fix hosting package typing checks

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

* Fix hosting pyright under Python 3.11

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

* Avoid static diskcache dependency in hosting

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

* Fix aggregate typing and Docker test resilience

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

* Simplify local Responses workflow sample

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

* Clarify generic hosting is not Foundry hosting

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

* Revert "Clarify generic hosting is not Foundry hosting"

This reverts commit 73b584d919053bed43a258d75dc2b76406e9c181.

* Clarify isolation key source flexibility

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

* Clarify isolation header reuse boundary

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

* Support multimodal Responses channel outputs

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

* Preserve multimodal streaming Responses output

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

* Stream Responses output items from updates

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

* Improve Responses streaming output handling

* Tighten Responses channel default option handling

- Restore full option parsing in parse_responses_request: known fields
  are remapped (max_output_tokens→max_tokens, parallel_tool_calls→
  allow_multiple_tool_calls), transport/session keys excluded, None
  values dropped, everything else forwarded as-is so run_hook can
  inspect the full set.
- Add a default _strip_options_hook on ResponsesChannel that removes
  all parsed options before reaching the agent. Callers cannot inject
  generation params (temperature, instructions, tools, …) unless the
  host explicitly allows it.
- A custom run_hook replaces the default entirely and receives the
  full ChannelRequest.options plus the raw protocol_request.
- Update tests to cover remap, default-strip, and custom-hook paths.
- Clarify host debug-log docstring to match new option flow.

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

---------

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-06-23 20:56:46 +00:00
Tao Chen 36420c515e Python: Align serialized tool format to OTel GenAI tool def format (#6556)
* Align serialized tool format to OTel GenAI tool def format

* Cache serialized tools
2026-06-23 20:47:18 +00:00
Peter Ibekwe 1109d0bf64 Get date suffix up to date with release date (#6690) 2026-06-23 18:58:19 +00:00
Peter Ibekwe e030fb53de .NET: Replace the symlink index entries with regular file entries (#6687)
* replace the symlink index entries  with regular file entries

* Fixed broken links.
2026-06-23 16:16:23 +00:00
SergeyMenshykh e6ebba1884 Add ADR 0029: Skills over MCP implementation design options (#6679)
* Add ADR 0029: Skills over MCP implementation design options

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

* Potential fix for pull request finding

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

---------

Co-authored-by: SergeyMenshykh <SergeMenshikh@outlook.com>
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
2026-06-23 15:51:05 +00:00
Tao Chen 7051a4920d Python: Add MCP as a hard dep in Foundry Hosting (#6634)
* Add MCP as a hard dep in Foundry Hosting

* Pin GitHub SDK

* Fix formatting

* Fix formatting
2026-06-23 15:07:05 +00:00
Roger Barreto 15df1152fc .NET: Add sample for per-run refreshable MCP authentication headers (#6624)
* Add sample for per-run refreshable MCP authentication headers

Adds a Foundry RAPI sample that attaches per-run, refreshable authentication headers to MCP requests using existing primitives: a DelegatingHandler on the MCP transport's HttpClient plus an AsyncLocal run scope. The same agent runs under two contexts, each minting a fresh token, proving the header is per run rather than bound at agent or connection creation time.

The handler attaches the bearer only over HTTPS to the MCP server's own origin, logs the non-secret label only, disables cookies, and checks certificate revocation. The README covers security considerations and production notes.

Fixes #1631

* Address PR review: harden redirect handling, nest-safe scope, README env vars

Disable AllowAutoRedirect on the shared handler so a redirect cannot carry the bearer past the origin check. Save and restore the prior run scope instead of clearing to null so the helper is safe under nesting. Note the Foundry env vars in the samples folder README row and update the sample README security notes.
2026-06-23 15:03:44 +00:00
westey a2018b40f9 Python: [BREAKING] Require approval for file-access tools with read-only auto-approval (#6599)
* Require approvals for file-access and expose auto approval funcs for it

* Scope file-access auto-approval rules to local tools; fix base-Agent sample

Address PR #6599 review feedback:
- read_only/all_tools auto-approval rules now reject any call carrying a
  server_label so they stay scoped to FileAccessProvider's local tools and
  never auto-approve a same-named hosted tool.
- Expand the FileAccessProvider docstring to explain the runtime effect of
  approval_mode="always_require" and point to ToolApprovalMiddleware /
  create_harness_agent.
- Fix the base-Agent file_access_data_processing sample, which would otherwise
  stop executing file tools under the new always_require defaults, by adding
  ToolApprovalMiddleware with all_tools_auto_approval_rule.
- Add tests covering hosted (server_label) calls and update docs.

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

* Clean up comments

* Update sample after merge

---------

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-06-23 09:51:12 +00:00
SergeyMenshykh e4b89373f1 .NET: Explicitly emit available_resources and available_scripts in skill content (#6672)
* .NET: Explicitly emit available_resources and available_scripts in skill content

AgentInlineSkillContentBuilder now always emits <available_resources> and
<available_scripts> elements, using self-closing tags when a skill has no
resources or scripts. This signals to the model exactly what is callable so it
does not hallucinate non-existent resource or script names. Script parameter
schemas are wrapped in a nested <parameters_schema> element.

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

* .NET: Emit available_resources block for file-backed skills

Align AgentFileSkill with inline/class skills by surfacing discovered
resources in the loaded skill content. AgentFileSkill.GetContentAsync now
appends an <available_resources> block (before <available_scripts>) listing
resource names so the model has an authoritative list and does not
hallucinate resource names. Extracted a reusable BuildAvailableResourcesBlock
helper in AgentInlineSkillContentBuilder.

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

---------

Co-authored-by: SergeyMenshykh <SergeMenshikh@outlook.com>
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-06-23 09:51:10 +00:00
SergeyMenshykh 88f0b23fb0 .NET: Change A2A default session store to NoopAgentSessionStore (#6635)
* Change A2A default session store to NoopAgentSessionStore

Align the A2A hosting layer default session store with the AG-UI
sibling by using NoopAgentSessionStore, making persistence an explicit
opt-in choice.

Update samples to document how to register a persistent session store
for multi-turn conversations.

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

* Clarify test name to specify session store default

Rename test to FallsBackToNoopSessionStoreDefaultAsync to avoid
implying all stores default to noop (task store still uses InMemory).

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

---------

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-06-23 08:55:59 +00:00
Peter Ibekwe 9ba6b3a94e Remove unnecessary declarative logging (#6677) 2026-06-23 00:35:12 +00:00
Peter Ibekwe 2999f7416f Update package release version (#6673) 2026-06-22 22:13:50 +00:00
Roger Barreto 09791533cf .NET: Emit execute_tool spans by placing OpenTelemetry below FunctionInvokingChatClient (#6667)
OpenTelemetryAgent auto-wired OpenTelemetryChatClient above FICC, producing
OTel(FICC(leaf)). FICC resolved its ActivitySource at construction time as null,
so execute_tool spans were never emitted for tool-calling agents.

This repositions OTel below FICC, producing FICC(OTel(leaf)), via a deferred
NoOp slot pre-placed as the innermost decorator in WithDefaultAgentMiddleware
and activated once at the agent level.

- Add internal DeferredOpenTelemetryChatClient: inert DelegatingChatClient whose
  Activate(sourceName) swaps its target to inner.AsBuilder().UseOpenTelemetry().Build().
- WithDefaultAgentMiddleware always registers the slot innermost so it lands below FICC.
- OpenTelemetryAgent activates the slot once in its constructor and forwards run
  options straight through, removing the per-run ChatClientFactory outer wrap.
- Add and update unit tests, including a proof that execute_tool spans are emitted
  on the agent source and parented under invoke_agent.
2026-06-22 15:09:29 -07:00
Tao Chen 7f2e19ca2f Python: Ensure spans created inside sync preparations in streaming call are correctly nested (#6552)
* Make sure spans created inside sync ops in streaming path are correctly nested

* Add tests

* Fix comments

* Fix typing
2026-06-22 19:46:11 +00:00
westey 7b6f582b13 Python: Agent Harness blog post accompanying samples part 1 (#6605)
* Add samples for harness blog post part 1

* Add readme for python samples

* Update python instructions to match dotnet instructions

* Address PR comments

* Add link to blog posts

* Fix blog post naming.

* Add more blog post links
2026-06-22 18:33:36 +01:00
Giles Odigwe dc60722cee .NET: Project ToolExecution events as FunctionCallContent/FunctionResultContent in GitHubCopilotAgent streaming (#6228)
* Project ToolExecution events as FunctionCallContent/FunctionResultContent

GitHubCopilotAgent's event-dispatch switch previously had no case for
ToolExecutionStartEvent or ToolExecutionCompleteEvent. Both fell through
to the default case and were wrapped as opaque AIContent with
RawRepresentation, preventing downstream consumers and models from
recognizing tool call results.

Add explicit cases that project:
- ToolExecutionStartEvent → FunctionCallContent (role: Assistant)
- ToolExecutionCompleteEvent → FunctionResultContent (role: Tool)

This mirrors the Python fix already shipped in #4734/#4814/#4828.

Fixes #5897

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

* fix(#5897): Address review feedback for ParseArguments robustness

- Handle non-generic IDictionary variants (Hashtable, etc.) that don't
  match IDictionary<string, object?> due to generic invariance
- Return null for empty/whitespace string arguments instead of wrapping
  them in a spurious { value = "" } dictionary, aligning with
  ParseFunctionArgumentsObject convention elsewhere in the repo
- Add test coverage for Dictionary, Hashtable, and JsonElement argument
  types
- Add edge-case test for Success=true with null Result

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

* Fix non-generic IDictionary key handling in ParseArguments (#5897)

Use direct (string) cast for dictionary keys instead of ToString()
coercion, matching the established pattern in ObjectExtensions and
PortableValueExtensions. This validates keys are actually strings
rather than silently accepting and coercing non-string keys.

Add test verifying non-string dictionary keys throw InvalidCastException.

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

* Fix missing 'using System' in ToolExecutionEventProjectionTests

Add the missing 'using System' directive needed for InvalidCastException
reference at line 375 of ToolExecutionEventProjectionTests.cs.

Fixes #5897

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

* Use source-generated JsonTypeInfo for AOT-safe argument deserialization

Replace reflection-based JsonSerializer.Deserialize<T>() calls with the
JsonTypeInfo overload that uses source-generated metadata, eliminating
IL2026/IL3050 trimming and AOT warnings without suppressions.

Changes:
- Register Dictionary<string, object?> in GitHubCopilotJsonUtilities JsonContext
- Add JsonSerializerOptions constructor parameter (defaults to
  GitHubCopilotJsonUtilities.DefaultOptions)
- Use GetTypeInfo()-based Deserialize overload in ParseArguments
- Remove [UnconditionalSuppressMessage] attributes

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

* Fix dotnet format: add 'this.' qualification to instance method call

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

* Adapt to GitHub.Copilot.SDK 1.0.0 API after merge with main

- Update ToolExecutionEventProjectionTests: Arguments is now JsonElement?
  (not object?), remove tests for string/Dictionary/Hashtable arguments
- Remove AutoStart option (removed in 1.0.0)
- Simplify ParseArguments to handle JsonElement primarily
- Add tests for empty object and nested JSON arguments

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

---------

Co-authored-by: Copilot <copilot@github.com>
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-06-22 16:45:03 +00:00
Peter Ibekwe 2f5a76ab1d Fix issue with resuming checkpoint after package version upgrade (#6636) 2026-06-22 15:18:22 +00:00
126 changed files with 17787 additions and 4890 deletions
@@ -0,0 +1,641 @@
---
status: proposed
contact: sergeymenshykh
date: 2026-06-23
deciders: sergeymenshykh
---
# Skills Over MCP: Implementation Design Options
This document explores design options for two SEP-2640 features. The decisions are not yet finalized.
- **Part 1: MCP Resource Template Skills** - skills described by a URI template with variables that must be resolved before loading.
- **Part 2: Direct Skill References** - reading `skill://` URIs referenced directly (e.g., in server instructions) without being listed in the index.
## Part 1: MCP Resource Template Skills
### Context and Problem Statement
The `AgentMcpSkillsSource` currently only supports `skill-md` type entries from `skill://index.json` (support for `archive` type is planned). The SEP-2640 specification also defines `mcp-resource-template` entries: **parameterized skill namespaces** described by a URI template with variables (e.g., `{product}`) that resolve to concrete `SKILL.md` URIs. Rather than materializing every skill in the index, the template's variables must be resolved before a skill can be loaded.
### Index Entry Format
```json
{
"$schema": "https://schemas.agentskills.io/discovery/0.2.0/schema.json",
"skills": [
{
"name": "git-workflow",
"type": "skill-md",
"description": "Follow this team's Git conventions for branching and commits",
"url": "skill://git-workflow/SKILL.md"
},
{
"type": "mcp-resource-template",
"description": "Per-product documentation skill",
"url": "skill://docs/{product}/SKILL.md"
}
]
}
```
Key differences from `skill-md`:
| Field | `skill-md` | `mcp-resource-template` |
|-------|------------|-------------------------|
| `name` | Required (the skill name) | **Omitted** (represents many skills) |
| `type` | `"skill-md"` | `"mcp-resource-template"` |
| `url` | Concrete URI to `SKILL.md` | URI template with variables |
| `description` | Describes the skill | Describes the addressable skill space |
### Use Cases
Template skills address two scenarios where listing concrete skills is impractical:
- **Large skill catalogs** - too many skills to enumerate every entry in the index.
- **Dynamically generated skills** - skill content generated on the fly from parameters, so the set of valid skills is not known at index-creation time.
### How Template Skills Are Consumed
Per SEP-2640, the consumption flow relies on the MCP `completion/complete` method:
1. **Server registers a resource template** - The MCP server registers the same `url` value (e.g., `skill://docs/{product}/SKILL.md`) as an MCP [resource template](https://modelcontextprotocol.io/specification/2025-11-25/server/resources#resource-templates), wiring template variables to the [completion API](https://modelcontextprotocol.io/specification/2025-11-25/server/utilities/completion).
2. **Host reads `skill://index.json`** - Discovers the template entry with `type: "mcp-resource-template"`.
3. **Host surfaces template in UI** - Presents the template as an interactive discovery point where the user fills in variables.
4. **Host calls `completion/complete`** - For each template variable (e.g., `{product}`), the host calls the MCP completion API to get possible values from the server:
```json
{
"method": "completion/complete",
"params": {
"ref": {
"type": "ref/resource",
"uri": "skill://docs/{product}/SKILL.md"
},
"argument": {
"name": "product",
"value": ""
}
}
}
```
The server responds with possible completions:
```json
{
"completion": {
"values": ["widgets", "billing", "auth", "payments"],
"hasMore": false,
"total": 4
}
}
```
5. **User selects a value** - The user picks a value (e.g., `"billing"`) from the list.
6. **Host resolves the URI** - The template `skill://docs/{product}/SKILL.md` becomes the concrete URI `skill://docs/billing/SKILL.md`.
7. **Host reads the resolved skill** - Calls `resources/read` with the concrete URI and proceeds as with any `skill-md` skill.
### Potential Implementation Options
### Option 1: Callback on `AgentMcpSkillsSource` for Variable Value Selection
Add a callback to `AgentMcpSkillsSource` (or its options) that is invoked for each `mcp-resource-template` entry to let the caller select variable values.
**Flow:**
1. `AgentMcpSkillsSource.GetSkillsAsync()` reads `skill://index.json`
2. For each entry with `type: "mcp-resource-template"`:
- Parse the URI template to extract variable names (e.g., `{product}`)
- Call the MCP `completion/complete` API to get possible values for each variable
- Invoke the caller-provided callback with the variable name, description, and possible values
- The callback returns a selected value and a `bool` indicating whether to include the skill
3. Resolve the URI template with the selected values
4. Create an `AgentMcpSkill` from the resolved URI and add it to the skills list
**API sketch:**
```csharp
public delegate Task<(string? SelectedValue, bool IncludeSkill)> McpTemplateVariableSelector(
string templateDescription,
string variableName,
IReadOnlyList<string> possibleValues,
CancellationToken cancellationToken);
// Usage via builder:
var provider = new AgentSkillsProviderBuilder()
.UseMcpSkills(mcpClient, options => {
options.TemplateVariableSelector = async (description, variable, values, ct) =>
{
// Present to user, return selection
var selected = PromptUser(variable, values);
return (selected, IncludeSkill: selected is not null);
};
})
.Build();
```
**Pros:**
- Simple implementation
- Easy to understand and use
**Cons:**
- Cannot be used in server-side scenarios where there is no interactive user at skill-discovery time
- Does not integrate with the agent's conversational flow
---
### Option 2: Integrate into Agent Conversation via `ChatClientAgent` Decorator
Model the template variable resolution as a request/response interaction within the agent's conversational loop.
**Flow:**
1. A `DelegatingAIAgent` decorator (e.g., `McpTemplateSkillResolutionAgent`) intercepts `RunAsync`/`RunStreamingAsync` calls and checks whether the inner agent has an `AgentSkillsProvider` with an `AgentMcpSkillsSource` containing unresolved template entries. The check is performed via `GetService<AgentMcpSkillsSource>()` on the `AgentSkillsProvider`, which delegates to a `GetService` method on the `AgentSkillsSource` base class.
2. The decorator calls an internal member on `AgentMcpSkillsSource` to get the list of `mcp-resource-template` entries from the index. The `AgentMcpSkillsSource` needs to be extended with an internal member that exposes unresolved template entries separately from concrete skills.
3. For each template entry, the decorator calls an internal member on `AgentMcpSkillsSource` to retrieve possible values for the template's variables via the MCP `completion/complete` API.
4. For each variable needing resolution, the decorator returns an `McpResourceTemplateValueRequestContent` (inherits from MEAI's `InputRequestContent`) in the agent response - bypassing the call to the inner agent. The content carries the template description, variable name, and possible values.
5. The user app receives the response, identifies the `McpResourceTemplateValueRequestContent` content type, and displays UI to the user showing the variable name and possible values, or forwards it further downstream if the user app is a service.
6. The user selects a value, and the user app calls the agent again with a corresponding `McpResourceTemplateValueResponseContent` (inherits from MEAI's `InputResponseContent`) containing the selected value. The `RequestId` property (inherited from the base classes) correlates the response with the original request.
7. The decorator identifies the response content and provides the resolved values to `AgentMcpSkillsSource` so it can use them when constructing concrete skills.
8. Having resolved all template variables, the decorator calls `RunAsync`/`RunStreamingAsync` on the inner agent.
9. The inner agent invokes the `AgentSkillsProvider`, which calls `AgentMcpSkillsSource.GetSkillsAsync()`. The source now has all resolved variable values and constructs concrete `AgentMcpSkill` instances from the resolved URIs, so it can provide the skill content if requested by the model.
**API sketch:**
```csharp
// New content types inheriting from MEAI's InputRequestContent/InputResponseContent:
public sealed class McpResourceTemplateValueRequestContent : InputRequestContent
{
public string TemplateDescription { get; }
public string VariableName { get; }
public IReadOnlyList<string> PossibleValues { get; }
public string TemplateUrl { get; }
}
public sealed class McpResourceTemplateValueResponseContent : InputResponseContent
{
public string SelectedValue { get; }
public string TemplateUrl { get; }
}
// Decorator usage:
var provider = new AgentSkillsProviderBuilder()
.UseMcpSkills(mcpClient)
.Build();
AIAgent agent = new ChatClientAgent(chatClient, new ChatClientAgentOptions
{
AIContextProviders = [provider],
});
agent = new McpTemplateSkillResolutionAgent(agent);
```
**Pros:**
- Works in server-side scenarios
- Fits the existing `DelegatingAIAgent` decorator pattern
- Can be composed with other decorators (tool approval, etc.)
**Cons:**
- Complex implementation
- Requires user app awareness of the new content types
- Users need to know that an additional decorator is required for handling MCP template skills, in addition to registering the MCP skills source
- Resolved template variable values must be persisted across conversation turns so the decorator does not re-prompt on subsequent agent runs within the same session
**Note:** This writeup is high-level and may miss details that could change the design. A POC would be needed to validate the approach.
### Open Questions
1. **Completion API limit** - The MCP completion API returns at most 100 values per request and provides no offset/cursor mechanism for enumeration. If a variable has more than 100 possible values, it's unclear how to retrieve the rest - the API only supports prefix-based filtering (typeahead), not bulk pagination.
2. **Multi-variable templates** - A template like `skill://{org}/{product}/SKILL.md` has multiple variables. Should they be resolved sequentially (org first, then product - since product values may depend on org) or presented together?
3. **Caching** - Should resolved template values be saved in the `AgentSession` so the user isn't re-prompted on every agent run? How should they be persisted between sessions?
---
## Part 2: Direct Skill References
This part covers how to let the model read `skill://` URIs referenced directly (e.g., in an MCP server's `instructions`, in a resource, or in another skill's content) without being listed in `skill://index.json`.
### How MCP Skills and Relative Links Work Today
The `AgentMcpSkillsSource` discovers skills by reading the well-known `skill://index.json` resource from the MCP server:
```json
{
"$schema": "https://schemas.agentskills.io/discovery/0.2.0/schema.json",
"skills": [
{
"name": "unit-converter",
"type": "skill-md",
"description": "Convert between common units.",
"url": "skill://unit-converter/SKILL.md"
},
{
"name": "currency-converter",
"type": "skill-md",
"description": "Convert between world currencies using live rates.",
"url": "skill://currency-converter/SKILL.md"
}
]
}
```
For each `skill-md` entry it creates an `AgentMcpSkill` instance - frontmatter (name/description) comes straight from the entry. The `AgentSkillsProvider` lists the discovered skills in the model's context (name + description):
```xml
<available_skills>
<skill>
<name>unit-converter</name>
<description>Convert between common units.</description>
</skill>
<skill>
<name>currency-converter</name>
<description>Convert between world currencies using live rates.</description>
</skill>
</available_skills>
```
It also provides functions to the model so it can load a skill and access its resources:
```csharp
// Loads the full content of a specific skill.
load_skill(string skillName)
// Reads a resource associated with a skill (references, assets, dynamic data).
read_skill_resource(string skillName, string resourceName)
```
The model calls `load_skill("unit-converter")` and receives the skill content:
```markdown
---
name: unit-converter
description: Convert between common units.
---
## Usage
For the full conversion table, see references/units-table.md.
```
The skill body references `references/units-table.md` by relative path. The model calls `read_skill_resource("unit-converter", "references/units-table.md")` and receives the resource content:
```markdown
# Unit Conversion Table
| From | To | Factor |
| miles | km | 1.60934 |
| kg | lbs | 2.20462 |
```
### Direct Reference Examples
A `skill://` URI can appear in any of these locations:
**Server instructions** - the MCP server advertises a skill the model should load:
```text
Follow our coding standards. Load skill://code-standards/SKILL.md for details.
```
**A skill body** - a skill's `SKILL.md` links to a sibling resource:
```markdown
---
name: code-standards
description: Coding standards and conventions.
---
## Naming
Follow the naming rules in skill://code-standards/references/naming.md.
```
**A resource** - the linked resource holds the actual content:
```markdown
# Naming Rules
- Use PascalCase for public members and type names.
- Use camelCase for locals and parameters.
- Prefix interfaces with `I` (e.g. `ISkillReader`).
- Suffix async methods with `Async`.
For examples, see skill://code-standards/references/naming-examples.md.
```
How can the model access content by direct reference?
### Function for Reading Direct Skill References
### Option 1: Extend existing `load_skill` and `read_skill_resource` functions
```csharp
// Added optional 'origin' and a direct skill:// URI is passed in 'skillName'.
load_skill(string skillName, string? origin = null)
// Added optional 'origin', made 'skillName' optional, and a direct skill:// URI is passed in 'resourceName'.
read_skill_resource(string resourceName, string? skillName = null, string? origin = null)
```
The optional `origin` identifies the source/MCP server that should handle the direct URI.
| Case | Call |
|------|------|
| Load skill | `load_skill("commit-guidelines")` |
| Relative resource | `read_skill_resource("commit-guidelines", "examples/COMMIT_EXAMPLES.md")` |
| `skill://` link (skill) | `load_skill(skillName: "skill://commit-guidelines/SKILL.md", origin: "DirectRefServer")` |
| `skill://` link (resource) | `read_skill_resource(resourceName: "skill://commit-guidelines/examples/COMMIT_EXAMPLES.md", origin: "DirectRefServer")` |
**Pros:**
- No new functions added: existing tool surface stays at two functions.
**Cons:**
- Unreliable on some models (gpt-4o, gpt-4.1-mini): it often omits `origin` when it should not or calls the wrong function.
- Optional parameters create silent ambiguity - the model can pass `origin` for non-MCP skills or omit it for `skill://` URIs.
### Option 2 (Proposed): Add a dedicated `read_skill_uri` function alongside existing ones
```csharp
// Existing functions stay unchanged.
load_skill(string skillName)
read_skill_resource(string skillName, string resourceName)
// New function added alongside: reads content by direct skill:// URI.
read_skill_uri(string uri, string origin)
```
| Case | Call |
|------|------|
| Load skill | `load_skill("commit-guidelines")` |
| Relative resource | `read_skill_resource("commit-guidelines", "examples/COMMIT_EXAMPLES.md")` |
| `skill://` link (skill) | `read_skill_uri(uri: "skill://commit-guidelines/SKILL.md", origin:"DirectRefServer")` |
| `skill://` link (resource) | `read_skill_uri(uri: "skill://commit-guidelines/examples/COMMIT_EXAMPLES.md", origin: "DirectRefServer")` |
**Pros:**
- Purely additive - no changes to existing functions needed; `read_skill_uri` can be deferred and added later when direct `skill://` reference support is needed.
- Granular approval: each function can have its own approval gate (like the existing `ScriptApproval` for `run_skill_script`), making per-operation approval for skill loading, resource reading, and direct URI access straightforward to add.
- Both `uri` and `origin` are required - no silent misuse through optional parameters.
- Clean split: `load_skill`/`read_skill_resource` for named skills, `read_skill_uri` for `skill://` links - no parameter ambiguity.
**Cons:**
- Three read functions (`load_skill`, `read_skill_resource`, `read_skill_uri`), not counting `run_skill_script`: larger tool surface than a single-function design.
### Option 3: Collapse `load_skill` and `read_skill_resource` into a single `read_resource` function
```csharp
// Single entrypoint for all skill content. 'uri' is required; 'origin' is optional.
read_resource(string uri, string? origin = null)
```
- `uri` - what to read: a skill name, a relative resource path, or a `skill://` link.
- `origin` - determines how `uri` is interpreted:
- **omitted** → load skill by name (`uri` is the skill name).
- **skill name** → read a relative resource (`uri` is the path within that skill).
- **server name** → read content by the `skill://` link (`uri` is handled by the source identified by the `[Origin: X]` marker).
Dispatch is ordered: null `origin` routes to Case 1; if `origin` names a known skill, routes to Case 2; otherwise tries to find an `ISkillUriReader` whose `CanRead` returns true for `origin` (Case 3).
| Case | Call |
|------|------|
| Load skill | `read_resource(uri: "commit-guidelines")` |
| Relative resource | `read_resource(uri: "examples/COMMIT_EXAMPLES.md", origin: "commit-guidelines")` |
| `skill://` link (skill) | `read_resource(uri: "skill://commit-guidelines/SKILL.md", origin: "DirectRefServer")` |
| `skill://` link (resource) | `read_resource(uri: "skill://commit-guidelines/examples/COMMIT_EXAMPLES.md", origin: "DirectRefServer")` |
**Pros:**
- Minimal tool surface: one read function instead of two or three (not counting `run_skill_script`) reduces token usage and gives the model fewer choices.
**Cons:**
- No per-operation approval: all cases (skill loading, resource reading, direct URI access) share one function, so approval cannot be scoped to individual operations.
- Unreliable on gpt-4.1-mini: omits `origin` when reading `skill://` links, passes skill name as `origin` when loading a plain skill (should be omitted), and hallucinates resource names (e.g. `API_SPECIFICATION.md`) that do not exist.
---
### Origin Marker
A `skill://` URI does not carry an origin, but the model needs to provide one when reading it. The `origin` is what routes the read call to the source that can handle the URI - the provider uses it to pick the matching source. Since the URI itself carries no such hint, the MCP source injects an `[Origin: ...]` marker wherever a `skill://` URI appears, so the model can read it back and pass it as the `origin` argument.
The marker is only added when the content actually contains `skill://` references. If a piece of content (server instructions, a skill body, or a resource) has no `skill://` URIs, there is nothing for the model to read back, so no marker is injected.
Into **server instructions**, which may mention `skill://` URIs directly:
```
[Origin: code-standards-server]
Follow our coding standards. Load skill://code-standards/SKILL.md for details.
```
Into **skill bodies**, since a `SKILL.md` may reference other `skill://` URIs (a resource file or a related skill):
```
[Origin: code-standards-server]
# Code Standards
For naming conventions, load skill://code-standards/references/naming.md.
```
Into **skill resources**, since a resource may itself reference further `skill://` URIs:
```
[Origin: code-standards-server]
# Naming Rules
- Use PascalCase for public members and type names.
- Use camelCase for locals and parameters.
For examples, see skill://code-standards/references/naming-examples.md.
```
---
### Read-by-URI Capability: Interface vs Base Class Virtual Methods
Now let's look at how an `AgentSkillsSource` can opt in to reading `skill://` URIs and signal that capability to the provider.
### Option 1: New `ISkillUriReader` interface
```csharp
public interface ISkillUriReader
{
// Returns true if this reader can handle the given skill:// URI from the given origin.
bool CanRead(string uri, string origin);
// Reads and returns the content for the given skill:// URI.
Task<object?> ReadByUriAsync(string uri, string origin, CancellationToken cancellationToken = default);
}
```
Sources that support direct `skill://` URI reads - such as `AgentMcpSkillsSource` - implement this interface to opt in.
The provider discovers readers via a service locator and dispatches to the first that can handle the URI:
```csharp
// Discover all registered readers.
var readers = source.GetService<IEnumerable<ISkillUriReader>>();
// Pick the first reader that can handle the URI.
var reader = readers.FirstOrDefault(r => r.CanRead(uri, origin))
?? throw new InvalidOperationException($"No reader can handle URI '{uri}' from origin '{origin}'.");
// Delegate the read to it.
return await reader.ReadByUriAsync(uri, origin, cancellationToken);
```
The provider may treat a source implementing `ISkillUriReader` as the signal to advertise `read_skill_uri`: if at least one registered source implements the interface, the function is exposed to the model; otherwise it is not.
### Option 2 (Proposed): Virtual methods on `AgentSkillsSource` base class
```csharp
public abstract class AgentSkillsSource
{
// New members for reading by URI.
// Whether this source can read by URI; drives whether read_skill_uri is advertised. Off by default.
public virtual bool SupportsReadByUri => false;
// Returns true if this source can handle the given skill:// URI from the given origin.
public virtual bool CanReadByUri(string uri, string origin) => false;
// Reads and returns the content for the given skill:// URI.
public virtual Task<object?> ReadByUriAsync(string uri, string origin, CancellationToken cancellationToken = default)
=> Task.FromResult<object?>(null);
// Existing member.
public abstract Task<IList<AgentSkills>> GetSkillsAsync(CancellationToken cancellationToken = default);
}
```
Sources opt in by overriding, and the provider calls them directly:
```csharp
// AgentMcpSkillsSource opts in by overriding the virtuals.
public override bool SupportsReadByUri => true;
// Handles the URI when its origin matches this source's MCP server.
public override bool CanReadByUri(string uri, string origin)
=> string.Equals(origin, this.Origin, StringComparison.OrdinalIgnoreCase);
// Reads content by skill:// URI from the MCP server.
public override Task<string?> ReadByUriAsync(string uri, string origin, CancellationToken cancellationToken)
=> /* resolve uri via the MCP server identified by origin */;
```
All sources inherit the methods, so there is no type signal - `SupportsReadByUri` fills that role. The function is advertised when any registered source returns `true`.
### Comparison
| Aspect | Option 1: Interface | Option 2: Base class virtual methods |
|--------|---------------------|--------------------------------------|
| Discovery | Service locator | Direct call on source |
| Advertising signal | Interface implementation | `SupportsReadByUri` flag |
| Adding new members | Breaking change | Non-breaking |
| Complexity | Higher | Lower |
---
### Include MCP Server Instructions Into Agent Instructions
MCP server instructions may contain the `skill://` references the model needs, so we want to surface them in the agent's instructions. But they can also carry system prompts or behavioral directives irrelevant to the agent, polluting context - so inclusion is **opt-in** via the `IncludeServerInstructions` option:
```csharp
public sealed class AgentMcpSkillsSourceOptions
{
// When true, the MCP server's instructions are injected into the agent instructions. Off by default.
public bool IncludeServerInstructions { get; set; }
}
builder.UseMcpSkills(mcpClient, options => options.IncludeServerInstructions = true);
```
When enabled, the instructions travel alongside the discovered skills on `AgentSkillsResult`:
```csharp
public class AgentSkillsResult
{
// The skills discovered from the source.
public IList<AgentSkill> Skills { get; }
// The MCP server instructions, when IncludeServerInstructions is enabled; otherwise null.
public string? Instructions { get; }
}
```
The `AgentSkillsProvider` then appends them to its own skill-usage guidance when building the agent's instructions:
```csharp
var result = await source.GetSkillsAsync(cancellationToken);
var instructions = DefaultSkillsInstructionPrompt;
if (!string.IsNullOrWhiteSpace(result.Instructions))
{
// Combine the provider's skill-usage guidance with the server instructions.
instructions += Environment.NewLine + result.Instructions;
}
```
### Enabling Direct Skill References
Following direct `skill://` references is **disabled by default** and activated via an option. When enabled, the provider advertises the read function to the model, and the source injects the `[Origin: ...]` marker into all content provided by the MCP server that contains `skill://` references. When disabled, no function is advertised and no marker is injected.
```csharp
public sealed class AgentMcpSkillsSourceOptions
{
public bool EnableDirectReferences { get; set; }
}
builder.UseMcpSkills(mcpClient, options => options.EnableDirectReferences = true);
```
## Decision Outcome
### Template Variable Resolution: Callback vs Decorator (Part 1)
**Postponed.** Deferring this decision until:
- We have a concrete list of scenarios that require template variable resolution.
- The skills-over-MCP spec is released (it is still a draft, so the design may change).
- There is a strong signal of demand from users or the ecosystem.
### Function for Reading Direct Skill References (Part 2)
**Postponed.** Leaning toward **Option 2 - dedicated `read_skill_uri` function alongside existing ones** (purely additive, and each function can have its own approval gate for granular per-operation approval), but deferring the decision until:
- The skills-over-MCP spec is released (it is still a draft, so the design may change).
- There is a strong signal of demand from users or the ecosystem.
### Read-by-URI Capability: Interface vs Base Class (Part 2)
**Postponed.** Leaning toward **Option 2 - virtual methods on `AgentSkillsSource`** (non-breaking, lower complexity, and a natural fit with the existing base class hierarchy), but deferring the decision until:
- The skills-over-MCP spec is released (it is still a draft, so the design may change).
- There is a strong signal of demand from users or the ecosystem.
The method naming (`SupportsReadByUri`, `CanReadByUri`, `ReadByUriAsync`) should also be abstracted a little more before adoption, so the same members can be reused when a similar direct-reference concept is needed for other skill types (e.g. file skills).
## References
- [SEP-2640: Skills Extension](https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2640) - Draft proposal
- [SEP-2640 Implementation Guidelines: Model-Driven Resource Loading](https://github.com/modelcontextprotocol/experimental-ext-skills/blob/main/docs/sep-draft-skills-extension.md#hosts-model-driven-resource-loading)
- [MCP Completion API](https://modelcontextprotocol.io/specification/2025-11-25/server/utilities/completion) - Used for template variable resolution
- [MCP Resource Templates](https://modelcontextprotocol.io/specification/2025-11-25/server/resources#resource-templates)
- [Skills Over MCP Working Group](https://github.com/modelcontextprotocol/experimental-ext-skills)
- [Open Question #4: Multi-server skill dependencies](https://github.com/modelcontextprotocol/experimental-ext-skills/issues/39)
- [Anthropic Agent Skills - Overview](https://platform.claude.com/docs/en/agents-and-tools/agent-skills/overview) - Prior art: single skill entrypoint + generic file reads
- [Anthropic Agent Skills in the SDK](https://code.claude.com/docs/en/agent-sdk/skills) - The `Skill` tool exposed to the model
-1
View File
@@ -1 +0,0 @@
../../../.github/skills/pull-requests
+116
View File
@@ -0,0 +1,116 @@
---
name: pull-requests
description: >
Guidance for creating pull requests and handling PR review comments in the
Agent Framework repository. Use this when writing a PR description (filling out
the PR template) or when responding to and resolving review comments on an
existing PR.
---
# Pull Request Workflow
This skill covers two tasks: (1) writing a high-quality PR description, and
(2) handling review comments on an existing PR.
## 1. Writing the PR description
Always follow the repository PR template at
[`.github/pull_request_template.md`](../../../../.github/pull_request_template.md). Keep its
exact structure and headings. Fill every section:
### `### Motivation & Context`
Explain *why* the change is needed: the problem it solves and the scenario it
contributes to. Describe the net change relative to `main` — this is implied, so
do **not** spell out "vs main" explicitly.
### `### Description & Review Guide`
Describe the changes, the overall approach, and the design. Answer the three
prompts:
- **What are the major changes?**
- **What is the impact of these changes?**
- **What do you want reviewers to focus on?** — This item is for **human
reviewers only**. Automated/AI reviewers must ignore it and review the entire
change rather than narrowing scope to it.
### `### Related Issue`
Link the issue the PR fixes using a GitHub closing keyword (`Fixes #123` /
`Closes #123`) so it closes automatically on merge. A PR with no linked issue may
be closed regardless of how valid the change is. Before opening, confirm there is
no other open PR for the same issue; if there is, explain how this PR differs.
### `### Contribution Checklist`
Check every item that applies. For the breaking-change item:
- Leave **"This is not a breaking change."** checked for the common case.
- If the change **is** breaking, add the `breaking change` label **or** put
`[BREAKING]` in the title prefix, before or after a language prefix such as
`Python:` or `.NET:` — workflows keep the label and the title prefix in sync
automatically (see `.github/workflows/label-title-prefix.yml` and
`.github/workflows/label-pr.yml`).
### Do not
- Do **not** add ad-hoc sections such as "Validation" or "Tests run"; CI/CD and
the checklist already cover validation status.
- Do **not** remove or reorder the template's headings.
### Creating the PR
Open new PRs as **drafts** until they are ready for review. Example:
```bash
gh pr create --repo microsoft/agent-framework --base main \
--head <your-fork-owner>:<branch> --draft \
--title "<concise title>" --body "<body following the template>"
```
## 2. Handling review comments
When a PR receives review comments, follow this sequence — **do not start editing
code before the user has reviewed the plan**:
1. **Review the comments.** Read every review comment and thread on the PR,
including inline code comments and general review summaries.
2. **Make a plan.** Produce a concrete plan describing how each comment will be
addressed (or why it should not be, with reasoning).
3. **Let the user review the plan.** Present the plan and wait for the user's
approval or adjustments before implementing anything.
4. **Implement.** Make the agreed changes.
5. **Reply to every comment.** Add a reply to **all** comments explaining how it
was addressed (or the agreed outcome) — leave none unanswered.
6. **Resolve resolved threads.** Mark a review thread as resolved only when the
comment has actually been addressed.
### Useful commands
List review comments and threads:
```bash
# Inline review comments
gh api repos/{owner}/{repo}/pulls/{pr}/comments
# Review threads with resolution state (GraphQL)
gh api graphql -f query='
query($owner:String!,$repo:String!,$pr:Int!){
repository(owner:$owner,name:$repo){
pullRequest(number:$pr){
reviewThreads(first:100){
nodes{ id isResolved comments(first:50){ nodes{ id body author{login} } } }
}
}
}
}' -F owner={owner} -F repo={repo} -F pr={pr}
```
Reply to an inline review comment:
```bash
gh api repos/{owner}/{repo}/pulls/{pr}/comments/{comment_id}/replies \
-f body="Addressed in <commit>: <explanation>"
```
Resolve a review thread (needs the thread node id from the GraphQL query above):
```bash
gh api graphql -f query='
mutation($threadId:ID!){
resolveReviewThread(input:{threadId:$threadId}){ thread{ isResolved } }
}' -F threadId={thread_id}
```
+2
View File
@@ -120,6 +120,7 @@
</Folder>
<Folder Name="/Samples/02-agents/Harness/">
<File Path="samples/02-agents/Harness/README.md" />
<Project Path="samples/02-agents/Harness/BuildYourOwnClaw/Claw_Step01_MeetYourClaw/Claw_Step01_MeetYourClaw.csproj" />
<Project Path="samples/02-agents/Harness/ConsoleReactiveComponents/ConsoleReactiveComponents.csproj" />
<Project Path="samples/02-agents/Harness/ConsoleReactiveFramework/ConsoleReactiveFramework.csproj" />
<Project Path="samples/02-agents/Harness/Harness_Shared_Console/Harness_Shared_Console.csproj" />
@@ -217,6 +218,7 @@
<Folder Name="/Samples/02-agents/ModelContextProtocol/">
<File Path="samples/02-agents/ModelContextProtocol/README.md" />
<Project Path="samples/02-agents/ModelContextProtocol/Agent_MCP_LongRunningTask_Client/Agent_MCP_LongRunningTask_Client.csproj" />
<Project Path="samples/02-agents/ModelContextProtocol/Agent_MCP_PerRun_AuthHeaders/Agent_MCP_PerRun_AuthHeaders.csproj" />
<Project Path="samples/02-agents/ModelContextProtocol/Agent_MCP_Server/Agent_MCP_Server.csproj" />
<Project Path="samples/02-agents/ModelContextProtocol/Agent_MCP_Server_Auth/Agent_MCP_Server_Auth.csproj" />
<Project Path="samples/02-agents/ModelContextProtocol/FoundryAgent_Hosted_MCP/FoundryAgent_Hosted_MCP.csproj" />
+3 -3
View File
@@ -1,14 +1,14 @@
<Project>
<PropertyGroup>
<!-- Central version prefix - applies to all nuget packages. -->
<VersionPrefix>1.10.0</VersionPrefix>
<VersionPrefix>1.11.0</VersionPrefix>
<RCNumber>1</RCNumber>
<DateSuffix>260610</DateSuffix>
<DateSuffix>260623</DateSuffix>
<PackageVersion Condition="'$(IsReleaseCandidate)' == 'true'">$(VersionPrefix)-rc$(RCNumber)</PackageVersion>
<PackageVersion Condition="'$(IsReleaseCandidate)' != 'true' AND '$(VersionSuffix)' != ''">$(VersionPrefix)-$(VersionSuffix).$(DateSuffix).1</PackageVersion>
<PackageVersion Condition="'$(IsReleaseCandidate)' != 'true' AND '$(VersionSuffix)' == ''">$(VersionPrefix)-preview.$(DateSuffix).1</PackageVersion>
<PackageVersion Condition="'$(IsReleased)' == 'true'">$(VersionPrefix)</PackageVersion>
<GitTag>1.10.0</GitTag>
<GitTag>1.11.0</GitTag>
<Configurations>Debug;Release;Publish</Configurations>
<IsPackable>true</IsPackable>
@@ -0,0 +1,22 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFrameworks>net10.0</TargetFrameworks>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Azure.Identity" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\..\..\..\src\Microsoft.Agents.AI.Foundry\Microsoft.Agents.AI.Foundry.csproj" />
<ProjectReference Include="..\..\..\..\..\src\Microsoft.Agents.AI.Harness\Microsoft.Agents.AI.Harness.csproj" />
<ProjectReference Include="..\..\Harness_Shared_Console\Harness_Shared_Console.csproj" />
<ProjectReference Include="..\..\Harness_Shared_Console_OpenAI\Harness_Shared_Console_OpenAI.csproj" />
</ItemGroup>
</Project>
@@ -0,0 +1,111 @@
// Copyright (c) Microsoft. All rights reserved.
// "Meet your agent harness and claw" — Post 1 of the "Build your own claw with Microsoft Agent Framework" series.
// See: https://devblogs.microsoft.com/agent-framework/meet-your-agent-harness-and-claw.
//
// This sample builds the foundation of a personal finance / investing assistant on top of a
// HarnessAgent. The harness comes pre-configured with function invocation, per-service-call
// history persistence, and planning (TodoProvider + AgentModeProvider), plus web search — so
// all we add here is:
// 1. Finance-focused instructions.
// 2. A custom get_stock_price function tool.
//
// The agent can plan a multi-step request ("Review my watchlist and recommend some stocks to add"), create a todo list, switch
// between plan and execute modes, search the web for market news, and call our stock-price tool.
//
// Special commands (handled by the shared HarnessConsole):
// /todos — Display the current todo list without invoking the agent.
// /mode — Get or set the current agent mode.
// /exit — End the session.
#pragma warning disable OPENAI001 // Suppress experimental API warnings for Responses API usage.
#pragma warning disable MAAI001 // Suppress experimental API warnings for Agents AI experiments.
using System.ClientModel.Primitives;
using Azure.AI.Projects;
using Azure.Identity;
using ClawSample;
using Harness.Shared.Console;
using Harness.Shared.Console.OpenAI;
using Harness.Shared.Console.ToolFormatters;
using Microsoft.Agents.AI;
using Microsoft.Extensions.AI;
var endpoint = Environment.GetEnvironmentVariable("FOUNDRY_PROJECT_ENDPOINT") ?? throw new InvalidOperationException("FOUNDRY_PROJECT_ENDPOINT is not set.");
var deploymentName = Environment.GetEnvironmentVariable("FOUNDRY_MODEL") ?? "gpt-5.4";
// <instructions>
var instructions =
"""
## Personal Finance Assistant Instructions
You are a personal finance and investing assistant. You help the user understand their
watchlist and the markets. When asked about a stock, look up its current price with the
get_stock_price tool, and use web search for recent news, earnings, or analyst commentary.
### Working style
- Always verify numbers with a tool rather than relying on memory. Stock prices change.
- Cite web sources inline when you use them.
- Keep the user's watchlist in a memory file called watchlist.md: read it when reviewing the
watchlist, and update it whenever the user adds or removes a ticker.
### Important
You provide information and analysis only you are not a licensed financial advisor and you
must not present your output as personalized investment advice. Remind the user to do their
own research before making decisions.
""";
// </instructions>
// <create_client>
// Construct an IChatClient. Here we use a Microsoft Foundry project: the endpoint points at the
// project, DefaultAzureCredential handles auth, and the deployment name selects the model.
// The harness works with ANY IChatClient — see the AgentProviders samples for OpenAI, Azure
// OpenAI, Anthropic, Google Gemini, Ollama, ONNX, and more.
IChatClient chatClient =
new AIProjectClient(
new Uri(endpoint),
// WARNING: DefaultAzureCredential is convenient for development but requires careful consideration in production.
// In production, consider using a specific credential (e.g., ManagedIdentityCredential) to avoid
// latency issues, unintended credential probing, and potential security risks from fallback mechanisms.
new DefaultAzureCredential(),
new AIProjectClientOptions { RetryPolicy = new ClientRetryPolicy(3) }) // Enable retries to improve resiliency.
.GetProjectOpenAIClient()
.GetResponsesClient()
.AsIChatClient(deploymentName);
// </create_client>
// <create_agent>
// Turn the chat client into a HarnessAgent. AsHarnessAgent pre-configures function invocation,
// per-service-call chat history persistence, TodoProvider, AgentModeProvider, and web search.
// We add finance instructions and our get_stock_price tool.
AIAgent agent = chatClient.AsHarnessAgent(new HarnessAgentOptions
{
ChatOptions = new ChatOptions
{
Instructions = instructions,
Tools = [StockTools.CreateGetStockPriceTool()],
Reasoning = new() { Effort = ReasoningEffort.Medium },
},
});
// </create_agent>
// <run>
// Run the interactive console session using the shared HarnessConsole helper.
await HarnessConsole.RunAgentAsync(
agent,
userPrompt: "Ask about a stock or say 'Review my watchlist and recommend some stocks to add' to get started.",
new HarnessConsoleOptions
{
Observers = [
new OpenAIResponsesWebSearchDisplayObserver(),
new OpenAIResponsesErrorObserver(),
.. HarnessConsoleOptions.BuildObserversWithPlanning(
agent,
planModeName: "plan",
executionModeName: "execute",
toolFormatters: ToolCallFormatter.BuildDefaultToolFormatters())],
CommandHandlers = HarnessConsoleOptions.BuildDefaultCommandHandlers(agent),
});
// </run>
@@ -0,0 +1,52 @@
# Meet your claw (Post 1) — .NET
The first runnable sample from the [**"Build your own agent harness and claw with Microsoft Agent Framework"** blog](https://devblogs.microsoft.com/agent-framework/build-your-own-claw-and-agent-harness-with-microsoft-agent-framework)
series. It builds the foundation of a personal finance / investing assistant on top of a
`HarnessAgent`.
## What this sample demonstrates
- **`AsHarnessAgent`** — turns an `IChatClient` into a batteries-included agent: function
invocation, per-service-call history persistence, planning
(`TodoProvider` + `AgentModeProvider`), and web search.
- **A custom function tool** — `get_stock_price` (see `StockTools.cs`), exposing local data to the
agent. Prices are illustrative mock data, not real quotes.
- **Web search** — provided automatically by the harness for market news and commentary.
- **Planning & modes** — the agent breaks a multi-step request ("Review my watchlist and recommend some stocks to add") into a todo
list and switches between *plan* and *execute* modes.
- **Shared harness console** — interactive streaming UI with `/todos`, `/mode`, and `/exit`
commands.
## Prerequisites
1. A Microsoft Foundry project with a deployed model (e.g. `gpt-5.4`).
2. Azure CLI installed and authenticated (`az login`).
## Environment variables
```bash
export FOUNDRY_PROJECT_ENDPOINT="https://your-project.services.ai.azure.com/api/projects/your-project"
# Optional (defaults to gpt-5.4)
export FOUNDRY_MODEL="gpt-5.4"
```
## Running
```bash
cd dotnet
dotnet run --project samples/02-agents/Harness/BuildYourOwnClaw/Claw_Step01_MeetYourClaw
```
## What to expect
The sample starts an interactive loop. Try these in order:
1. `/mode execute` — switch out of the default plan mode; quick lookups don't need a plan.
2. `What's the price of MSFT?` — the agent calls the `get_stock_price` tool.
3. `Any recent news on NVDA?` — the agent uses web search.
4. `Add MSFT, NVDA and SPY to my watch list` — saved to `watchlist.md` in the session's memory.
5. `/mode plan` — switch back to plan mode for a bigger, multi-step task.
6. `Review my watchlist and recommend some stocks to add` — the agent plans, then executes. Type
`/todos` to see the list and `/mode` to inspect the current mode.
Output is colored by mode: **cyan** during planning, **green** during execution.
@@ -0,0 +1,61 @@
// Copyright (c) Microsoft. All rights reserved.
using System.ComponentModel;
using Microsoft.Extensions.AI;
namespace ClawSample;
/// <summary>
/// A custom function tool that gives our "claw" access to (illustrative) stock prices.
/// </summary>
/// <remarks>
/// The prices returned here are mock data for demonstration purposes only and are not real
/// market quotes. In a real assistant you would call a market-data API instead.
/// </remarks>
internal static class StockTools
{
// <stock_quote>
/// <summary>A delayed, illustrative stock quote.</summary>
public sealed record StockQuote(string Symbol, decimal Price, string Currency, DateTimeOffset AsOf);
// </stock_quote>
// A tiny in-memory price book so the sample runs without any external dependency.
private static readonly Dictionary<string, decimal> s_priceBook = new(StringComparer.OrdinalIgnoreCase)
{
["MSFT"] = 462.97m,
["AAPL"] = 229.35m,
["GOOGL"] = 178.12m,
["AMZN"] = 201.45m,
["NVDA"] = 134.81m,
};
// <get_stock_price>
/// <summary>
/// Gets the latest (delayed, illustrative) stock price for a ticker symbol.
/// </summary>
/// <param name="symbol">The stock ticker symbol, e.g. <c>MSFT</c> or <c>AAPL</c>.</param>
[Description("Gets the latest (delayed, illustrative) stock price for a ticker symbol.")]
public static StockQuote GetStockPrice(
[Description("The stock ticker symbol, e.g. MSFT or AAPL.")] string symbol)
{
if (!s_priceBook.TryGetValue(symbol, out var price))
{
// Deterministic pseudo-price for unknown symbols so the sample stays self-contained.
// Derive a stable seed from the characters — string.GetHashCode() is randomized per
// process and Math.Abs(int.MinValue) throws, so neither is safe for repeatable output.
var seed = 0;
foreach (var ch in symbol.ToUpperInvariant())
{
seed = (seed * 31 + ch) % 1_000_000;
}
price = 50m + seed % 45000 / 100m;
}
return new StockQuote(symbol.ToUpperInvariant(), price, "USD", DateTimeOffset.UtcNow);
}
// </get_stock_price>
/// <summary>Creates the <see cref="AIFunction"/> wrapper used to expose the tool to the agent.</summary>
public static AIFunction CreateGetStockPriceTool() => AIFunctionFactory.Create(GetStockPrice, "get_stock_price");
}
@@ -64,21 +64,15 @@ public sealed class HarnessAgentRunner : IDisposable
/// <summary>
/// Replaces the current session with the specified session. Used by the UX driver
/// when importing a serialized session. Acquires the input gate to ensure no
/// concurrent agent turn is reading the session.
/// when importing a serialized session. This method is always called from within
/// a command handler (which already holds the input gate), so no additional
/// synchronization is needed.
/// </summary>
/// <param name="newSession">The new session to use.</param>
internal async Task ReplaceSessionAsync(AgentSession newSession)
internal Task ReplaceSessionAsync(AgentSession newSession)
{
await this._inputGate.WaitAsync().ConfigureAwait(false);
try
{
this._session = newSession;
}
finally
{
this._inputGate.Release();
}
this._session = newSession;
return Task.CompletedTask;
}
/// <inheritdoc/>
@@ -10,3 +10,11 @@ Samples demonstrating the [Harness AIContextProviders](../../../src/Microsoft.Ag
| [Harness_Step02_Research_WithBackgroundAgents](./Harness_Step02_Research_WithBackgroundAgents/README.md) | Using BackgroundAgentsProvider to delegate stock price lookups to a web-search background agent concurrently |
| [Harness_Step03_DataProcessing](./Harness_Step03_DataProcessing/README.md) | Using FileAccessProvider to give an agent access to CSV data files for reading, analysis, and output generation |
| [Harness_Step05_Loop](./Harness_Step05_Loop/README.md) | Wrapping a HarnessAgent with the LoopAgent decorator to re-invoke it until a configured LoopEvaluator (completion marker, predicate, AI judge, or approval-aware loop) decides to stop |
## Build your own claw blog series
Samples accompanying the [*Build your own agent harness or claw with Microsoft Agent Framework*](https://devblogs.microsoft.com/agent-framework/build-your-own-claw-and-agent-harness-with-microsoft-agent-framework) blog series, which builds a personal finance assistant step by step.
| Sample | Description |
| --- | --- |
| [Claw_Step01_MeetYourClaw](./BuildYourOwnClaw/Claw_Step01_MeetYourClaw/README.md) | Post 1 — a minimal HarnessAgent with a custom `get_stock_price` tool, web search, and planning |
@@ -0,0 +1,21 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFrameworks>net10.0</TargetFrameworks>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Azure.Identity" />
<PackageReference Include="ModelContextProtocol" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\..\..\src\Microsoft.Agents.AI\Microsoft.Agents.AI.csproj" />
<ProjectReference Include="..\..\..\..\src\Microsoft.Agents.AI.Foundry\Microsoft.Agents.AI.Foundry.csproj" />
</ItemGroup>
</Project>
@@ -0,0 +1,136 @@
// Copyright (c) Microsoft. All rights reserved.
// This sample shows how to attach per-run (refreshable) authentication headers to MCP requests.
//
// The agent connects to an MCP server with a custom HttpClient. A DelegatingHandler reads a token
// for the current run from an AsyncLocal scope and stamps it on each outbound MCP request, so a
// short-lived token (for example an OBO or cloud identity token that expires) can be refreshed on
// every run without rebuilding the agent or the MCP connection.
//
// The agent backend is Microsoft Foundry via the Responses API (RAPI). The MCP server is the public
// Microsoft Learn MCP server, which ignores the demonstration token; in production you point the
// handler at your own protected MCP server and mint a real token per run.
using System.Net.Http.Headers;
using Azure.AI.Projects;
using Azure.Identity;
using Microsoft.Agents.AI;
using Microsoft.Extensions.AI;
using ModelContextProtocol.Client;
var projectEndpoint = new Uri(Environment.GetEnvironmentVariable("FOUNDRY_PROJECT_ENDPOINT")
?? throw new InvalidOperationException("FOUNDRY_PROJECT_ENDPOINT is not set."));
var deploymentName = Environment.GetEnvironmentVariable("FOUNDRY_MODEL") ?? "gpt-5.4-mini";
var serverEndpoint = new Uri("https://learn.microsoft.com/api/mcp");
// Custom HttpClient for the MCP transport. The per-run handler attaches the bearer; the inner
// handler disables cookies (no cross-context state), disables auto-redirect (so a redirect cannot
// carry the bearer past the origin re-check), and checks certificate revocation.
using var httpClient = new HttpClient(new PerRunAuthHeaderHandler(serverEndpoint)
{
InnerHandler = new HttpClientHandler
{
UseCookies = false,
AllowAutoRedirect = false,
CheckCertificateRevocationList = true,
},
});
Console.WriteLine($"Connecting to MCP server at {serverEndpoint} ...");
await using var mcpClient = await McpClient.CreateAsync(new HttpClientTransport(new()
{
Endpoint = serverEndpoint,
Name = "Microsoft Learn MCP",
TransportMode = HttpTransportMode.StreamableHttp,
}, httpClient));
IList<McpClientTool> mcpTools = await mcpClient.ListToolsAsync();
Console.WriteLine($"MCP tools available: {string.Join(", ", mcpTools.Select(t => t.Name))}");
// Build the agent from Microsoft Foundry using the Responses API (RAPI).
// WARNING: DefaultAzureCredential is convenient for development but requires careful consideration in production.
// In production, consider using a specific credential (e.g., ManagedIdentityCredential) to avoid
// latency issues, unintended credential probing, and potential security risks from fallback mechanisms.
AIAgent agent = new AIProjectClient(projectEndpoint, new DefaultAzureCredential())
.AsAIAgent(
model: deploymentName,
instructions: "You answer Microsoft documentation questions using the available tools.",
name: "DocsAgent",
tools: [.. mcpTools.Cast<AITool>()]);
// Run the same agent twice under two different contexts. Each run gets a freshly minted token,
// proving the auth header is per-run rather than bound when the agent or MCP connection was created.
await RunForContextAsync(agent, "tenant-a", "How do I create an Azure storage account with az cli?");
await RunForContextAsync(agent, "tenant-b", "What is Azure Functions?");
static async Task RunForContextAsync(AIAgent agent, string label, string prompt)
{
// Stand-in for a real per-run token (for example an OBO or cloud identity token).
// It carries no PII and is regenerated on every run. The label is non-secret and used for logging.
McpRunContext? previous = McpRunScope.Current;
McpRunScope.Current = new McpRunContext(label, $"{label}.{Guid.NewGuid():N}");
try
{
Console.WriteLine($"\n=== Run for '{label}' (fresh per-run token) ===");
Console.WriteLine(await agent.RunAsync(prompt));
}
finally
{
// Restore the prior scope (stack-like) so this is safe to call from within an outer scope.
McpRunScope.Current = previous;
}
}
/// <summary>
/// Carries the context for the current run. <see cref="Label"/> is a non-secret identifier safe to
/// log; <see cref="Token"/> is the secret that must never be logged or persisted.
/// </summary>
internal sealed record McpRunContext(string Label, string Token);
/// <summary>
/// Flows the current <see cref="McpRunContext"/> to the MCP <see cref="DelegatingHandler"/> without
/// threading it through every call. Set it before a run and reset it afterwards.
/// </summary>
internal static class McpRunScope
{
private static readonly AsyncLocal<McpRunContext?> s_current = new();
public static McpRunContext? Current
{
get => s_current.Value;
set => s_current.Value = value;
}
}
/// <summary>
/// Attaches the current run's bearer token to outbound MCP requests. The token is read fresh on
/// every request, so refreshing it between runs needs no agent or connection rebuild.
/// </summary>
/// <remarks>
/// Security: the bearer is attached only over HTTPS and only when the request targets the configured
/// MCP server origin, which prevents the credential from leaking over plaintext or to a redirect
/// target on another origin. Only the non-secret label is logged, never the token.
/// </remarks>
internal sealed class PerRunAuthHeaderHandler(Uri serverEndpoint) : DelegatingHandler
{
private readonly string _serverOrigin = serverEndpoint.GetLeftPart(UriPartial.Authority);
protected override async Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
{
McpRunContext? context = McpRunScope.Current;
Uri? requestUri = request.RequestUri;
if (context is not null
&& requestUri is not null
&& requestUri.Scheme == Uri.UriSchemeHttps
&& string.Equals(requestUri.GetLeftPart(UriPartial.Authority), this._serverOrigin, StringComparison.OrdinalIgnoreCase))
{
request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", context.Token);
Console.WriteLine($"[mcp-auth] attached bearer for '{context.Label}' -> {request.Method} {requestUri.AbsolutePath}");
}
return await base.SendAsync(request, cancellationToken).ConfigureAwait(false);
}
}
@@ -0,0 +1,89 @@
# Per-Run MCP Authentication Headers
This sample shows how to attach per-run (refreshable) authentication headers to Model Context
Protocol (MCP) requests using existing Agent Framework primitives. It addresses scenarios where the
header value changes from one run to the next, for example a short-lived On-Behalf-Of (OBO) or cloud
identity token that expires and must be refreshed.
The agent backend is Microsoft Foundry accessed through the Responses API (RAPI). The MCP server is
the public Microsoft Learn MCP server.
## What this sample demonstrates
- A custom `HttpClient` on the MCP transport whose `DelegatingHandler` stamps an `Authorization`
header on every outbound MCP request.
- An `AsyncLocal` scope (`McpRunScope`) that carries the current run's context to the handler, set
immediately before each run and cleared in a `finally` block.
- Running the same agent twice under two different contexts, each with a freshly minted token, so the
header is per-run rather than fixed when the agent or the MCP connection was created.
Because the handler reads the token fresh on every request, an expiring token is refreshed simply by
placing a new value in scope before the next run. No agent or connection rebuild is required.
## How it works
```text
RunForContextAsync sets McpRunScope.Current
-> agent.RunAsync invokes an MCP tool
-> PerRunAuthHeaderHandler reads McpRunScope.Current
-> stamps Authorization: Bearer <token> on the MCP request
RunForContextAsync clears McpRunScope.Current in finally
```
The public Microsoft Learn MCP server is anonymous and ignores the demonstration token. In production
you point the handler at your own protected MCP server and mint a real token per run.
## Prerequisites
- .NET 10 SDK or later
- A Microsoft Foundry project endpoint and a model deployment
- An authenticated Azure identity (for example, sign in with `az login`)
Set the following environment variables:
```powershell
$env:FOUNDRY_PROJECT_ENDPOINT="https://your-foundry-service.services.ai.azure.com/api/projects/your-foundry-project"
$env:FOUNDRY_MODEL="gpt-5.4-mini"
```
## Run the sample
```powershell
dotnet run
```
## Security considerations
This sample is written to demonstrate the pattern safely. When you adapt it, keep these in place:
- **Never log the token.** Only the non-secret label is printed. Avoid printing the token even in a
masked form.
- **Attach the header over HTTPS only.** The handler skips the header when the request is not HTTPS,
so a credential is never sent over plaintext.
- **Scope the header to the MCP server origin.** The handler attaches the header only when the
request targets the configured server origin (scheme, host, and port). Auto-redirect is also
disabled (`AllowAutoRedirect = false`) so a redirect cannot carry the token to another origin
below the handler before the origin check runs.
- **Reset the scope after each run.** `McpRunScope.Current` is restored to its prior value in a
`finally` block so a token does not bleed into later, unrelated work and nesting stays safe.
- **Disable cookies on the shared handler.** `UseCookies = false` avoids cross-context state on a
shared client, and `CheckCertificateRevocationList = true` validates the server certificate.
- **Use non-identifying labels and tokens.** The labels and tokens here carry no personal data and are
regenerated per run.
- **Do not persist secrets in serialized session state.** Agent session state is serializable, so keep
raw tokens in memory or mint them per run rather than storing them there.
## Production notes
- Replace the demonstration token with a real per-request exchange inside the handler, for example an
Azure `TokenCredential`, MSAL OBO flow, or a cloud identity token. Performing the exchange per
request lets expiry self-heal because each request obtains a current token.
- The `AsyncLocal` scope isolates concurrent runs from each other, so parallel runs with different
tokens do not interfere.
- As an alternative carrier, the token can be read from `AgentSession` state by an `AIContextProvider`
that copies it into the scope at the start of each invocation. Remember the serialized-state warning
above and avoid persisting the raw secret.
- For MCP servers that implement standard OAuth, `HttpClientTransportOptions.OAuth` already handles the
authorization and refresh flow, so a custom handler is unnecessary.
- This sample attaches the same header for every tool call in a run. Selecting different headers based
on the specific tool or its arguments is intentionally out of scope here.
@@ -21,6 +21,7 @@ Before you begin, ensure you have the following prerequisites:
|---|---|
|[Agent with MCP server tools](./Agent_MCP_Server/)|This sample demonstrates how to use MCP server tools with a simple agent|
|[Agent with MCP server tools and authorization](./Agent_MCP_Server_Auth/)|This sample demonstrates how to use MCP Server tools from a protected MCP server with a simple agent|
|[Agent with per-run MCP authentication headers](./Agent_MCP_PerRun_AuthHeaders/)|This sample demonstrates how to attach per-run, refreshable authentication headers to MCP requests using a custom HttpClient handler and an AsyncLocal scope. Uses Microsoft Foundry (`FOUNDRY_PROJECT_ENDPOINT` / `FOUNDRY_MODEL`) rather than the Azure OpenAI variables in the prerequisites above.|
|[Responses Agent with Hosted MCP tool](./ResponseAgent_Hosted_MCP/)|This sample demonstrates how to use the Hosted MCP tool with the Responses Service, where the service invokes any MCP tools directly|
|[Agent with long-running MCP task (transparent polling)](./Agent_MCP_LongRunningTask_Client/)|This sample demonstrates how an agent transparently drives a long-running MCP task (SEP-2663) to completion. The wrapper polls the task internally on both `RunAsync` and `RunStreamingAsync` invocations.|
@@ -101,10 +101,15 @@ else
throw new ArgumentException("Either A2AServer:ApiKey or A2AServer:ConnectionString & agentName must be provided");
}
// When running in production, make sure to use an SessionIsolationKeyProvider, e.g. ClaimsIdentity-based
// if using Claims-based Identity for Authentication/Authorization
// IMPORTANT: In production, register a SessionIsolationKeyProvider to isolate sessions by authenticated caller.
// Without this, contextId alone is the session key — any caller who knows a contextId can access that session.
// Example using claims-based identity:
// builder.Services.UseClaimsBasedSessionIsolation(new() { ClaimType = ClaimTypes.NameIdentifier });
// By default, NoopAgentSessionStore is used — sessions are not persisted across requests.
// To enable multi-turn conversations, register a session store explicitly, e.g.:
// builder.Services.AddKeyedSingleton<AgentSessionStore>(hostA2AAgent.Name, new InMemoryAgentSessionStore());
builder.AddA2AServer(hostA2AAgent);
var app = builder.Build();
@@ -28,10 +28,15 @@ builder.AddDevUI();
builder.AddOpenAIChatCompletions();
builder.AddOpenAIResponses();
// When running in production, make sure to use an SessionIsolationKeyProvider, e.g. ClaimsIdentity-based
// if using Claims-based Identity for Authentication/Authorization
// IMPORTANT: In production, register a SessionIsolationKeyProvider to isolate sessions by authenticated caller.
// Without this, contextId alone is the session key — any caller who knows a contextId can access that session.
// Example using claims-based identity:
// builder.Services.UseClaimsBasedSessionIsolation(new() { ClaimType = ClaimTypes.NameIdentifier });
// By default, NoopAgentSessionStore is used — sessions are not persisted across requests.
// To enable multi-turn conversations, register a session store explicitly, e.g.:
// agentBuilder.WithInMemorySessionStore();
var pirateAgentBuilder = builder.AddAIAgent(
"pirate",
instructions: "You are a pirate. Speak like a pirate",
@@ -152,8 +157,9 @@ builder.Services.AddKeyedSingleton<AIAgent>("my-di-matchingname-agent", (sp, nam
pirateAgentBuilder.AddA2AServer();
knightsKnavesAgentBuilder.AddA2AServer();
// When running in production, make sure to use an SessionIsolationKeyProvider, e.g. ClaimsIdentity-based
// if using Claims-based Identity for Authentication/Authorization
// IMPORTANT: In production, register a SessionIsolationKeyProvider to isolate sessions by authenticated caller.
// Without this, contextId alone is the session key — any caller who knows a contextId can access that session.
// Example using claims-based identity:
// builder.Services.UseClaimsBasedSessionIsolation(new() { ClaimType = ClaimTypes.NameIdentifier });
var app = builder.Build();
@@ -6,6 +6,7 @@ using System.IO;
using System.Linq;
using System.Runtime.CompilerServices;
using System.Text.Json;
using System.Text.Json.Serialization.Metadata;
using System.Threading;
using System.Threading.Channels;
using System.Threading.Tasks;
@@ -29,6 +30,7 @@ public sealed class GitHubCopilotAgent : AIAgent, IAsyncDisposable
private readonly string _description;
private readonly SessionConfig? _sessionConfig;
private readonly bool _ownsClient;
private readonly JsonSerializerOptions _jsonSerializerOptions;
/// <summary>
/// Initializes a new instance of the <see cref="GitHubCopilotAgent"/> class.
@@ -39,13 +41,15 @@ public sealed class GitHubCopilotAgent : AIAgent, IAsyncDisposable
/// <param name="id">The unique identifier for the agent.</param>
/// <param name="name">The name of the agent.</param>
/// <param name="description">The description of the agent.</param>
/// <param name="jsonSerializerOptions">Optional JSON serializer options. Defaults to <see cref="GitHubCopilotJsonUtilities.DefaultOptions"/>.</param>
public GitHubCopilotAgent(
CopilotClient copilotClient,
SessionConfig? sessionConfig = null,
bool ownsClient = false,
string? id = null,
string? name = null,
string? description = null)
string? description = null,
JsonSerializerOptions? jsonSerializerOptions = null)
{
_ = Throw.IfNull(copilotClient);
@@ -55,6 +59,7 @@ public sealed class GitHubCopilotAgent : AIAgent, IAsyncDisposable
this._id = id;
this._name = name ?? DefaultName;
this._description = description ?? DefaultDescription;
this._jsonSerializerOptions = jsonSerializerOptions ?? GitHubCopilotJsonUtilities.DefaultOptions;
}
/// <summary>
@@ -67,6 +72,7 @@ public sealed class GitHubCopilotAgent : AIAgent, IAsyncDisposable
/// <param name="description">The description of the agent.</param>
/// <param name="tools">The tools to make available to the agent.</param>
/// <param name="instructions">Optional instructions to append as a system message.</param>
/// <param name="jsonSerializerOptions">Optional JSON serializer options. Defaults to <see cref="GitHubCopilotJsonUtilities.DefaultOptions"/>.</param>
public GitHubCopilotAgent(
CopilotClient copilotClient,
bool ownsClient = false,
@@ -74,14 +80,16 @@ public sealed class GitHubCopilotAgent : AIAgent, IAsyncDisposable
string? name = null,
string? description = null,
IList<AITool>? tools = null,
string? instructions = null)
string? instructions = null,
JsonSerializerOptions? jsonSerializerOptions = null)
: this(
copilotClient,
GetSessionConfig(tools, instructions),
ownsClient,
id,
name,
description)
description,
jsonSerializerOptions)
{
}
@@ -182,6 +190,14 @@ public sealed class GitHubCopilotAgent : AIAgent, IAsyncDisposable
channel.Writer.TryWrite(this.ConvertToAgentResponseUpdate(assistantMessage, isStreaming));
break;
case ToolExecutionStartEvent toolStart:
channel.Writer.TryWrite(this.ConvertToAgentResponseUpdate(toolStart));
break;
case ToolExecutionCompleteEvent toolComplete:
channel.Writer.TryWrite(this.ConvertToAgentResponseUpdate(toolComplete));
break;
case AssistantUsageEvent usageEvent:
channel.Writer.TryWrite(this.ConvertToAgentResponseUpdate(usageEvent));
break;
@@ -349,6 +365,79 @@ public sealed class GitHubCopilotAgent : AIAgent, IAsyncDisposable
};
}
internal AgentResponseUpdate ConvertToAgentResponseUpdate(ToolExecutionStartEvent toolStart)
{
IDictionary<string, object?>? arguments = this.ParseArguments(toolStart.Data?.Arguments);
FunctionCallContent content = new(
toolStart.Data?.ToolCallId ?? string.Empty,
toolStart.Data?.ToolName ?? string.Empty,
arguments)
{
RawRepresentation = toolStart
};
return new AgentResponseUpdate(ChatRole.Assistant, [content])
{
AgentId = this.Id,
CreatedAt = toolStart.Timestamp
};
}
internal AgentResponseUpdate ConvertToAgentResponseUpdate(ToolExecutionCompleteEvent toolComplete)
{
object? result = toolComplete.Data?.Success == true
? toolComplete.Data?.Result?.Content
: toolComplete.Data?.Error?.Message ?? "Tool execution failed";
FunctionResultContent content = new(
toolComplete.Data?.ToolCallId ?? string.Empty,
result)
{
RawRepresentation = toolComplete
};
return new AgentResponseUpdate(ChatRole.Tool, [content])
{
AgentId = this.Id,
CreatedAt = toolComplete.Timestamp
};
}
private IDictionary<string, object?>? ParseArguments(object? arguments)
{
if (arguments is null)
{
return null;
}
if (arguments is JsonElement jsonElement)
{
if (jsonElement.ValueKind == JsonValueKind.Null || jsonElement.ValueKind == JsonValueKind.Undefined)
{
return null;
}
var typeInfo = (JsonTypeInfo<Dictionary<string, object?>>)this._jsonSerializerOptions.GetTypeInfo(typeof(Dictionary<string, object?>));
try
{
return JsonSerializer.Deserialize(jsonElement.GetRawText(), typeInfo);
}
catch (JsonException)
{
return new Dictionary<string, object?> { ["value"] = jsonElement.ToString() };
}
}
if (arguments is IDictionary<string, object?> dict)
{
return dict;
}
return new Dictionary<string, object?> { ["value"] = arguments.ToString() };
}
private AgentResponseUpdate ConvertToAgentResponseUpdate(AssistantUsageEvent usageEvent)
{
UsageDetails usageDetails = new()
@@ -1,5 +1,6 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Collections.Generic;
using System.Diagnostics.CodeAnalysis;
using System.Text.Encodings.Web;
using System.Text.Json;
@@ -43,6 +44,7 @@ internal static partial class GitHubCopilotJsonUtilities
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
NumberHandling = JsonNumberHandling.AllowReadingFromString)]
[JsonSerializable(typeof(GitHubCopilotAgentSession))]
[JsonSerializable(typeof(Dictionary<string, object?>))]
[ExcludeFromCodeCoverage]
private sealed partial class JsonContext : JsonSerializerContext;
}
@@ -189,7 +189,7 @@ public static class A2AServerServiceCollectionExtensions
var isolationKeyProvider = serviceProvider.GetService<SessionIsolationKeyProvider>();
if (agentSessionStore?.GetService<IsolationKeyScopedAgentSessionStore>() is null)
{
agentSessionStore ??= new InMemoryAgentSessionStore();
agentSessionStore ??= new NoopAgentSessionStore();
agentSessionStore = new IsolationKeyScopedAgentSessionStore(agentSessionStore, isolationKeyProvider, new() { Strict = isolationKeyProvider != null });
}
@@ -14,7 +14,6 @@ using Microsoft.Agents.AI.Workflows.Declarative.Kit;
using Microsoft.Agents.AI.Workflows.Declarative.PowerFx;
using Microsoft.Agents.ObjectModel;
using Microsoft.Extensions.AI;
using Microsoft.Extensions.Logging;
using Microsoft.Shared.Diagnostics;
namespace Microsoft.Agents.AI.Workflows.Declarative.ObjectModel;
@@ -168,9 +167,6 @@ internal sealed class InvokeFunctionToolExecutor(
{
if (!this._approvalSnapshots.ContainsKey(approval.RequestId))
{
this.Logger.LogWarning(
"Approval response '{RequestId}' did not match any pending invocation on '{ActionId}'.",
approval.RequestId, this.Id);
await this.AssignErrorAsync(context, "No pending approval matched the response.").ConfigureAwait(false);
}
else if (!approval.Approved)
@@ -184,9 +180,6 @@ internal sealed class InvokeFunctionToolExecutor(
}
else
{
this.Logger.LogWarning(
"Approval response '{RequestId}' had no remaining pending snapshot on '{ActionId}'.",
approval.RequestId, this.Id);
await this.AssignErrorAsync(context, "No pending approval matched the response.").ConfigureAwait(false);
}
}
@@ -1,7 +1,10 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using System.IO;
using System.Reflection;
using System.Text.Json.Serialization;
using System.Text.RegularExpressions;
using Microsoft.Shared.Diagnostics;
namespace Microsoft.Agents.AI.Workflows.Checkpointing;
@@ -11,7 +14,7 @@ namespace Microsoft.Agents.AI.Workflows.Checkpointing;
/// </summary>
public sealed class TypeId : IEquatable<TypeId>
{
/// <inheritdoc cref="System.Reflection.Assembly.FullName"/>
/// <inheritdoc cref="Assembly.FullName"/>
public string AssemblyName { get; }
/// <inheritdoc cref="Type.FullName"/>
@@ -46,6 +49,11 @@ public sealed class TypeId : IEquatable<TypeId>
=> this.Equals(obj as TypeId);
/// <inheritdoc />
/// <remarks>
/// Compares the type full name and the simple assembly name. Version, culture, and public key
/// token are ignored both in <see cref="AssemblyName"/> and in any assembly-qualified generic
/// arguments embedded in <see cref="TypeName"/>.
/// </remarks>
public bool Equals(TypeId? other)
{
if (other is null)
@@ -58,11 +66,27 @@ public sealed class TypeId : IEquatable<TypeId>
return true;
}
return this.AssemblyName == other.AssemblyName && this.TypeName == other.TypeName;
if (this.NormalizedTypeName != other.NormalizedTypeName)
{
return false;
}
if (string.Equals(this.AssemblyName, other.AssemblyName, StringComparison.Ordinal))
{
return true;
}
string? thisSimpleName = this.SimpleAssemblyName;
string? otherSimpleName = other.SimpleAssemblyName;
return thisSimpleName is not null
&& string.Equals(thisSimpleName, otherSimpleName, StringComparison.Ordinal);
}
/// <inheritdoc />
public override int GetHashCode() => HashCode.Combine(this.AssemblyName, this.TypeName);
/// <remarks>Hashes the normalized type name and the simple assembly name.</remarks>
public override int GetHashCode()
=> HashCode.Combine(this.SimpleAssemblyName, this.NormalizedTypeName);
/// <inheritdoc />
public static bool operator ==(TypeId? left, TypeId? right) => left is null ? right is null : left.Equals(right);
@@ -73,13 +97,27 @@ public sealed class TypeId : IEquatable<TypeId>
/// <summary>
/// Determines whether the specified type matches both the assembly name and type name represented by this instance.
/// </summary>
/// <remarks>
/// Compares the type full name and the simple assembly name. Version, culture, and public key
/// token are ignored both in <see cref="AssemblyName"/> and in any assembly-qualified generic
/// arguments embedded in <see cref="TypeName"/>.
/// </remarks>
/// <param name="type">The type to compare against the stored assembly and type names. Cannot be null.</param>
/// <returns>true if the specified type's assembly and type names are equal to those stored in this instance; otherwise,
/// false.</returns>
/// <returns>true if the specified type's assembly simple name and normalized type full name are equal to those stored
/// in this instance; otherwise, false.</returns>
public bool IsMatch(Type type)
{
return this.AssemblyName == type.Assembly.FullName
&& this.TypeName == type.FullName;
string? runtimeNormalizedTypeName = type.FullName is null ? null : NormalizeTypeName(type.FullName);
if (this.NormalizedTypeName != runtimeNormalizedTypeName)
{
return false;
}
string? storedSimpleName = this.SimpleAssemblyName;
string? runtimeSimpleName = type.Assembly.GetName().Name;
return storedSimpleName is not null
&& string.Equals(storedSimpleName, runtimeSimpleName, StringComparison.Ordinal);
}
/// <summary>
@@ -113,4 +151,64 @@ public sealed class TypeId : IEquatable<TypeId>
/// <inheritdoc/>
public override string ToString() => $"{this.TypeName}, {this.AssemblyName}";
/// <summary>
/// The simple assembly name parsed from <see cref="AssemblyName"/>, lazily computed and cached.
/// </summary>
internal string? SimpleAssemblyName
=> field ??= GetSimpleAssemblyName(this.AssemblyName);
/// <summary>
/// The type full name with embedded assembly-qualified generic arguments stripped of
/// version, culture, and public key token. Lazily computed and cached.
/// </summary>
internal string NormalizedTypeName
=> field ??= NormalizeTypeName(this.TypeName);
private static readonly Regex s_assemblyQualifierPattern = new(
@", Version=[^,\]]+, Culture=[^,\]]+, PublicKeyToken=[^,\]]+",
RegexOptions.Compiled | RegexOptions.CultureInvariant);
/// <summary>
/// Removes <c>, Version=...</c>, <c>, Culture=...</c>, and <c>, PublicKeyToken=...</c> triplets
/// from <paramref name="typeName"/>. Returns the input unchanged when no triplet is present.
/// </summary>
internal static string NormalizeTypeName(string typeName)
{
if (typeName.IndexOf("Version=", StringComparison.Ordinal) < 0)
{
return typeName;
}
return s_assemblyQualifierPattern.Replace(typeName, string.Empty);
}
/// <summary>
/// Returns the simple assembly name parsed from an <see cref="Assembly.FullName"/>-style string,
/// or <see langword="null"/> when both parsing and the substring fallback fail.
/// </summary>
internal static string? GetSimpleAssemblyName(string assemblyFullName)
{
if (string.IsNullOrEmpty(assemblyFullName))
{
return null;
}
try
{
string? parsed = new AssemblyName(assemblyFullName).Name;
if (!string.IsNullOrEmpty(parsed))
{
return parsed;
}
}
catch (Exception ex) when (ex is FileLoadException or ArgumentException)
{
// Fall through to substring fallback.
}
int comma = assemblyFullName.IndexOf(',');
string fallback = (comma < 0 ? assemblyFullName : assemblyFullName.Substring(0, comma)).Trim();
return fallback.Length == 0 ? null : fallback;
}
}
@@ -1,6 +1,7 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using System.Collections.Concurrent;
using System.Collections.Generic;
using System.Diagnostics.CodeAnalysis;
using System.Linq;
@@ -297,12 +298,14 @@ internal sealed class WorkflowSession : AgentSession
/// 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.")]
[UnconditionalSuppressMessage("Trimming", "IL2026:Members annotated with 'RequiresUnreferencedCodeAttribute' require dynamic access", Justification = "Higher-layer envelope types are explicitly preserved by the package that defines them.")]
[UnconditionalSuppressMessage("Trimming", "IL2073:Members annotated with 'DynamicallyAccessedMembersAttribute' require dynamic access", 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);
Type? concreteType = ResolveTypeLenient(requestType);
if (concreteType is null || !typeof(IExternalRequestEnvelope).IsAssignableFrom(concreteType))
{
return false;
@@ -317,6 +320,20 @@ internal sealed class WorkflowSession : AgentSession
return true;
}
/// <summary>Caches <see cref="ResolveTypeLenient"/> results keyed by <see cref="TypeId"/>.</summary>
private static readonly ConcurrentDictionary<TypeId, Type?> s_envelopeTypeCache = new();
/// <summary>
/// Resolves a <see cref="TypeId"/> to a loaded <see cref="Type"/> using partial-name binding,
/// which matches any loaded assembly with the same simple name regardless of version. Results
/// are cached.
/// </summary>
[UnconditionalSuppressMessage("Trimming", "IL2026:Members annotated with 'RequiresUnreferencedCodeAttribute' require dynamic access", Justification = "Higher-layer envelope types are explicitly preserved by the package that defines them.")]
[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.")]
internal static Type? ResolveTypeLenient(TypeId typeId)
=> s_envelopeTypeCache.GetOrAdd(typeId, static id =>
Type.GetType($"{id.NormalizedTypeName}, {id.SimpleAssemblyName}", throwOnError: false));
/// <summary>
/// Creates the workflow-facing request content surfaced in response updates.
/// </summary>
@@ -94,6 +94,12 @@ public static class ChatClientExtensions
chatBuilder.Use(innerClient => new PerServiceCallChatHistoryPersistingChatClient(innerClient));
}
// DeferredOpenTelemetryChatClient is registered last so it sits as the innermost decorator, directly
// above the leaf client and below FunctionInvokingChatClient. It is inert until an OpenTelemetryAgent
// activates it. Placing OpenTelemetry below FICC ensures the chat span closes before tools are invoked,
// so FICC emits execute_tool spans on the agent source.
chatBuilder.Use(innerClient => new DeferredOpenTelemetryChatClient(innerClient));
var agentChatClient = chatBuilder.Build(services);
if (options?.ChatOptions?.Tools is { Count: > 0 })
@@ -0,0 +1,133 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.AI;
using Microsoft.Shared.Diagnostics;
namespace Microsoft.Agents.AI;
/// <summary>
/// A delegating chat client that reserves a position for OpenTelemetry instrumentation directly above
/// the leaf <see cref="IChatClient"/> and below the <see cref="FunctionInvokingChatClient"/> in a
/// <see cref="ChatClientAgent"/> pipeline.
/// </summary>
/// <remarks>
/// <para>
/// The slot is inert until <see cref="Activate"/> is called: it simply forwards to its inner client.
/// When the agent is wrapped by an <see cref="OpenTelemetryAgent"/>, that agent activates the slot with
/// the resolved source name, at which point the slot routes calls through an
/// <see cref="OpenTelemetryChatClient"/> so chat spans are emitted below the
/// <see cref="FunctionInvokingChatClient"/>.
/// </para>
/// <para>
/// Positioning OpenTelemetry below FICC is required for tool telemetry: the chat span then closes before
/// FICC invokes tools, so <see cref="System.Diagnostics.Activity.Current"/> is the invoke_agent span and
/// FICC emits execute_tool spans on the agent source.
/// </para>
/// </remarks>
internal sealed class DeferredOpenTelemetryChatClient : DelegatingChatClient
{
private readonly object _activationLock = new();
private volatile IChatClient _target;
private OpenTelemetryChatClient? _activatedClient;
/// <summary>
/// Initializes a new instance of the <see cref="DeferredOpenTelemetryChatClient"/> class in its inert state.
/// </summary>
/// <param name="innerClient">The underlying chat client to forward to until the slot is activated.</param>
public DeferredOpenTelemetryChatClient(IChatClient innerClient)
: base(innerClient)
{
this._target = innerClient;
}
/// <summary>Gets a value indicating whether the slot has been activated.</summary>
public bool IsActive => !ReferenceEquals(this._target, this.InnerClient);
/// <summary>
/// Gets or sets a value indicating whether the activated <see cref="OpenTelemetryChatClient"/> should
/// include potentially sensitive information (such as message content) in telemetry. Reading or writing
/// this property is a no-op while the slot is inert; the owning <see cref="OpenTelemetryAgent"/> applies
/// and propagates the value once the slot is activated.
/// </summary>
public bool EnableSensitiveData
{
get => this._activatedClient?.EnableSensitiveData ?? false;
set
{
if (this._activatedClient is { } activatedClient)
{
activatedClient.EnableSensitiveData = value;
}
}
}
/// <summary>
/// Activates the slot so that calls are routed through an <see cref="OpenTelemetryChatClient"/> wrapping
/// the inner client under the specified <paramref name="sourceName"/>. Idempotent and thread-safe; a
/// second call (or a call after another thread activated the slot) is a no-op.
/// </summary>
/// <param name="sourceName">The telemetry source name to emit chat spans under.</param>
public void Activate(string sourceName)
{
if (this.IsActive)
{
return;
}
lock (this._activationLock)
{
if (this.IsActive)
{
return;
}
var activatedTarget = this.InnerClient.AsBuilder().UseOpenTelemetry(sourceName: sourceName).Build();
// Capture the OpenTelemetryChatClient so the owning agent can propagate EnableSensitiveData to it
// (the agent's value may be set after construction, e.g. via the UseOpenTelemetry configure callback).
this._activatedClient = activatedTarget.GetService(typeof(OpenTelemetryChatClient)) as OpenTelemetryChatClient;
this._target = activatedTarget;
}
}
/// <inheritdoc/>
public override Task<ChatResponse> GetResponseAsync(
IEnumerable<ChatMessage> messages, ChatOptions? options = null, CancellationToken cancellationToken = default) =>
this._target.GetResponseAsync(messages, options, cancellationToken);
/// <inheritdoc/>
public override IAsyncEnumerable<ChatResponseUpdate> GetStreamingResponseAsync(
IEnumerable<ChatMessage> messages, ChatOptions? options = null, CancellationToken cancellationToken = default) =>
this._target.GetStreamingResponseAsync(messages, options, cancellationToken);
/// <inheritdoc/>
public override object? GetService(Type serviceType, object? serviceKey = null)
{
_ = Throw.IfNull(serviceType);
// Return this slot for its own type and base contracts; otherwise forward to the current target so
// that, once activated, queries such as OpenTelemetryChatClient and ActivitySource resolve to the
// activated instrumentation rather than the bare leaf.
return serviceKey is null && serviceType.IsInstanceOfType(this)
? this
: this._target.GetService(serviceType, serviceKey);
}
/// <inheritdoc/>
protected override void Dispose(bool disposing)
{
if (disposing && !ReferenceEquals(this._target, this.InnerClient))
{
// When activated, _target is an OpenTelemetryChatClient wrapping the inner client; dispose it so its
// own telemetry resources are released. It also disposes the inner client, which is idempotent with the
// base.Dispose call below.
this._target.Dispose();
}
base.Dispose(disposing);
}
}
@@ -46,8 +46,6 @@ public sealed class AgentModeProvider : AIContextProvider
## Agent Mode
- You can operate in different modes. Depending on the mode you are in, you will be required to follow different processes.
- You must check the current mode after any user input, since the user may have changed the mode themselves,
e.g. the user may have switched to 'plan' mode after a previous research task finished in 'execute' mode, meaning they want to review a plan first before execution.
Use the mode_get tool to check your current operating mode.
Use the mode_set tool to switch between modes as your work progresses. Only use mode_set if the user explicitly instructs/allows you to change modes.
@@ -84,9 +82,12 @@ public sealed class AgentModeProvider : AIContextProvider
new(
"execute",
"""
Use this mode when carrying out approved plans. Work autonomously using your best judgment — do not ask the user questions or wait for feedback.
Process to follow when in execute mode:
Determine the type of ask:
1. Simple question that doesn't require any further work to answer.
2. Any other work, including complex user request that requires a multi-step process to satisfy.
If 1. just answer the question directly.
If 2. Work autonomously using your best judgment — do not ask the user questions or wait for feedback and follow the following process:
1. If you don't have a plan or tasks yet, analyze the user request and create tasks and a plan. (**Skip this step if you came from plan mode**)
2. Work autonomously — use your best judgment to make decisions and keep progressing without asking the user questions. The goal is to have a complete, useful result ready when the user returns.
3. If you encounter ambiguity or an unexpected situation during execution, choose the most reasonable option, note your choice, and keep going.
@@ -46,11 +46,16 @@ public sealed class TodoProvider : AIContextProvider, IDisposable
## Todo Items
You have access to a todo list for tracking work items.
While planning, make sure that you break down complex tasks into manageable todo items and add them to the list.
When a user asks you to perform a task, follow these steps to manage your work:
1. Determine whether the ask requires multiple steps to complete (complex) or can be completed using a single step (simple).
2. If complex, turn the task into manageable todo items and add them to the list.
3. If simple, don't add a todo item, but rather just complete the task directly.
### General TODO Guidelines
Ask questions from the user where clarification is needed to create effective todos.
If the user provides feedback on your plan, adjust your todos accordingly by adding new items or removing irrelevant/old ones.
During execution, use the todo list to keep track of what needs to be done, mark items as complete when finished, and remove any items that are no longer needed.
When a user changes the topic or changes their mind, ensure that you update the todo list accordingly by removing irrelevant/old items or adding new ones as needed.
When a user changes the topic, changes their mind or switches to a new request, ensure that you update the todo list accordingly by removing irrelevant/old items, clearing the list, or adding new ones as needed.
Use these tools to manage your tasks:
- Use todos_add to break down complex work into trackable items (supports adding one or many at once).
@@ -42,6 +42,12 @@ public sealed class OpenTelemetryAgent : DelegatingAIAgent, IDisposable
/// </summary>
private readonly bool _autoWireChatClient;
/// <summary>
/// The auto-wired below-FICC telemetry slot, when one was activated. Cached so that updates to
/// <see cref="EnableSensitiveData"/> made after construction can be propagated to it.
/// </summary>
private DeferredOpenTelemetryChatClient? _innerTelemetrySlot;
/// <summary>Initializes a new instance of the <see cref="OpenTelemetryAgent"/> class.</summary>
/// <param name="innerAgent">The underlying <see cref="AIAgent"/> to be augmented with telemetry capabilities.</param>
/// <param name="sourceName">
@@ -91,6 +97,8 @@ public sealed class OpenTelemetryAgent : DelegatingAIAgent, IDisposable
this._otelClient = new OpenTelemetryChatClient(
new ForwardingChatClient(this),
sourceName: this._sourceName);
this.TryActivateInnerChatClientTelemetry();
}
/// <inheritdoc/>
@@ -120,7 +128,16 @@ public sealed class OpenTelemetryAgent : DelegatingAIAgent, IDisposable
public bool EnableSensitiveData
{
get => this._otelClient.EnableSensitiveData;
set => this._otelClient.EnableSensitiveData = value;
set
{
this._otelClient.EnableSensitiveData = value;
// Keep the auto-wired below-FICC slot in sync so its chat span captures message content too.
if (this._innerTelemetrySlot is { } slot)
{
slot.EnableSensitiveData = value;
}
}
}
/// <inheritdoc/>
@@ -204,85 +221,53 @@ public sealed class OpenTelemetryAgent : DelegatingAIAgent, IDisposable
}
/// <summary>
/// If auto-wiring is enabled and the inner agent is a <see cref="ChatClientAgent"/> whose underlying
/// <see cref="IChatClient"/> is not already instrumented with <see cref="OpenTelemetryChatClient"/>, returns a
/// new <see cref="ChatClientAgentRunOptions"/> with a <see cref="ChatClientAgentRunOptions.ChatClientFactory"/>
/// that wraps the chat client with <see cref="OpenTelemetryChatClient"/>. When <paramref name="options"/> is a
/// plain <see cref="AgentRunOptions"/> (the base type, not <see cref="ChatClientAgentRunOptions"/>), the base
/// properties are copied onto the new <see cref="ChatClientAgentRunOptions"/> so high-level callers that pass
/// the abstract <see cref="AgentRunOptions"/> still benefit from auto-wiring and propagate their settings to
/// the inner agent. Otherwise, returns <paramref name="options"/> unchanged.
/// When auto-wiring is enabled and the inner agent is a <see cref="ChatClientAgent"/> whose underlying
/// <see cref="IChatClient"/> is not already instrumented, activates the in-place
/// <see cref="DeferredOpenTelemetryChatClient"/> slot so that chat spans are emitted below the
/// <see cref="FunctionInvokingChatClient"/> under this agent's source name. Positioning OpenTelemetry below FICC
/// is what allows FICC to emit execute_tool spans on the agent source. Respects
/// <see cref="ChatClientAgentOptions.UseProvidedChatClientAsIs"/> and is a no-op when no slot is reachable.
/// </summary>
private AgentRunOptions? GetRunOptionsWithChatClientWiring(AgentRunOptions? options)
private void TryActivateInnerChatClientTelemetry()
{
if (!this._autoWireChatClient)
{
return options;
return;
}
// The auto-wiring only applies when a ChatClientAgent is reachable from the inner agent. Otherwise, no-op.
// Auto-wiring only applies when a ChatClientAgent is reachable from the inner agent. Otherwise, no-op.
// Use GetService rather than a type check so wrapping agents that expose a nested ChatClientAgent are supported.
var chatClientAgent = this.InnerAgent.GetService<ChatClientAgent>();
if (chatClientAgent is null)
{
return options;
return;
}
// Respect ChatClientAgentOptions.UseProvidedChatClientAsIs: don't decorate the chat client when the user opted out.
if (chatClientAgent.GetService<ChatClientAgentOptions>()?.UseProvidedChatClientAsIs is true)
{
return options;
return;
}
// Capture the underlying IChatClient and check whether it is already instrumented.
// Don't activate when the chat client is already instrumented (e.g. the caller added their own
// OpenTelemetryChatClient), to avoid emitting duplicate chat spans.
var chatClient = chatClientAgent.GetService<IChatClient>();
if (chatClient is null || chatClient.GetService(typeof(OpenTelemetryChatClient)) is not null)
{
return options;
return;
}
string sourceName = this._sourceName;
bool enableSensitiveData = this.EnableSensitiveData;
static IChatClient WrapIfNeeded(IChatClient cc, string sourceName, bool enableSensitiveData) =>
cc.GetService(typeof(OpenTelemetryChatClient)) is not null
? cc
: cc.AsBuilder().UseOpenTelemetry(sourceName: sourceName, configure: o => o.EnableSensitiveData = enableSensitiveData).Build();
if (options is ChatClientAgentRunOptions ccOptions)
// Activate the pre-placed slot in-place (below FICC) rather than wrapping a new OpenTelemetryChatClient
// around the whole pipeline on each run. Cache it and seed its EnableSensitiveData from the current
// value so a later change to this agent's EnableSensitiveData can be propagated to the inner chat span.
if (chatClient.GetService(typeof(DeferredOpenTelemetryChatClient)) is DeferredOpenTelemetryChatClient slot)
{
// Don't mutate the caller's options; clone and chain any caller-provided factory.
// If the user factory already returns an OpenTelemetry-instrumented client, don't double-wrap.
var clone = (ChatClientAgentRunOptions)ccOptions.Clone();
var userFactory = clone.ChatClientFactory;
clone.ChatClientFactory = cc => WrapIfNeeded(userFactory is null ? cc : userFactory(cc), sourceName, enableSensitiveData);
return clone;
slot.Activate(this._sourceName);
slot.EnableSensitiveData = this.EnableSensitiveData;
this._innerTelemetrySlot = slot;
}
// For a plain AgentRunOptions (or null), create a ChatClientAgentRunOptions and preserve
// any base AgentRunOptions properties from the caller so they reach the inner agent.
var newOptions = new ChatClientAgentRunOptions
{
ChatClientFactory = cc => WrapIfNeeded(cc, sourceName, enableSensitiveData),
};
if (options is not null)
{
CopyBaseAgentRunOptions(options, newOptions);
}
return newOptions;
}
#pragma warning disable MEAI001 // ContinuationToken is experimental; copy it through to preserve caller-provided value.
private static void CopyBaseAgentRunOptions(AgentRunOptions source, AgentRunOptions target)
{
target.ContinuationToken = source.ContinuationToken;
target.AllowBackgroundResponses = source.AllowBackgroundResponses;
target.AdditionalProperties = source.AdditionalProperties?.Clone();
target.ResponseFormat = source.ResponseFormat;
}
#pragma warning restore MEAI001
/// <summary>The stub <see cref="IChatClient"/> used to delegate from the <see cref="OpenTelemetryChatClient"/> into the inner <see cref="AIAgent"/>.</summary>
/// <param name="parentAgent"></param>
private sealed class ForwardingChatClient(OpenTelemetryAgent parentAgent) : IChatClient
@@ -295,11 +280,9 @@ public sealed class OpenTelemetryAgent : DelegatingAIAgent, IDisposable
// Update the current activity to reflect the agent invocation.
parentAgent.UpdateCurrentActivity(fo?.CurrentActivity);
// If enabled, wire the underlying chat client with OpenTelemetryChatClient via ChatClientFactory.
var runOptions = parentAgent.GetRunOptionsWithChatClientWiring(fo?.Options);
// Invoke the inner agent.
var response = await parentAgent.InnerAgent.RunAsync(messages, fo?.Session, runOptions, cancellationToken).ConfigureAwait(false);
// Invoke the inner agent. Chat-level telemetry is emitted by the in-place DeferredOpenTelemetryChatClient
// slot (below FICC), activated once at construction; no per-run chat-client wiring is needed here.
var response = await parentAgent.InnerAgent.RunAsync(messages, fo?.Session, fo?.Options, cancellationToken).ConfigureAwait(false);
// Wrap the response in a ChatResponse so we can pass it back through OpenTelemetryChatClient.
return response.AsChatResponse();
@@ -313,11 +296,9 @@ public sealed class OpenTelemetryAgent : DelegatingAIAgent, IDisposable
// Update the current activity to reflect the agent invocation.
parentAgent.UpdateCurrentActivity(fo?.CurrentActivity);
// If enabled, wire the underlying chat client with OpenTelemetryChatClient via ChatClientFactory.
var runOptions = parentAgent.GetRunOptionsWithChatClientWiring(fo?.Options);
// Invoke the inner agent.
await foreach (var update in parentAgent.InnerAgent.RunStreamingAsync(messages, fo?.Session, runOptions, cancellationToken).ConfigureAwait(false))
// Invoke the inner agent. Chat-level telemetry is emitted by the in-place DeferredOpenTelemetryChatClient
// slot (below FICC), activated once at construction; no per-run chat-client wiring is needed here.
await foreach (var update in parentAgent.InnerAgent.RunStreamingAsync(messages, fo?.Session, fo?.Options, cancellationToken).ConfigureAwait(false))
{
// Wrap the response updates in ChatResponseUpdates so we can pass them back through OpenTelemetryChatClient.
yield return update.AsChatResponseUpdate();
@@ -48,16 +48,19 @@ public sealed class AgentFileSkill : AgentSkill
/// <inheritdoc/>
/// <remarks>
/// Returns the raw SKILL.md content. When the skill has scripts, a
/// <c>&lt;script_schemas&gt;</c> block is appended describing the argument format.
/// The result is cached after the first access.
/// Returns the raw SKILL.md content with an <c>&lt;available_resources&gt;</c> and an
/// <c>&lt;available_scripts&gt;</c> block appended, so the model gets an authoritative list for each
/// category. A category with no entries is appended as a self-closing element (e.g.
/// <c>&lt;available_scripts /&gt;</c>) so the model knows none are available and does not hallucinate
/// their names. The result is cached after the first access.
/// </remarks>
public override ValueTask<string> GetContentAsync(CancellationToken cancellationToken = default)
{
var content = this._content ??= this._scripts is { Count: > 0 }
? this._originalContent + AgentInlineSkillContentBuilder.BuildScriptSchemasBlock(this._scripts)
: this._originalContent;
return new(content);
this._content ??=
this._originalContent
+ "\n" + AgentInlineSkillContentBuilder.BuildAvailableResourcesBlock(this._resources)
+ "\n" + AgentInlineSkillContentBuilder.BuildAvailableScriptsBlock(this._scripts);
return new(this._content);
}
/// <summary>
@@ -120,6 +120,7 @@ public abstract class AgentClassSkill<
this.Frontmatter.Name,
this.Frontmatter.Description,
this.Instructions,
this.Resources,
this.Scripts));
}
@@ -160,8 +161,9 @@ public abstract class AgentClassSkill<
/// Override this property in derived classes to provide skill-specific resources.
/// </para>
/// <para>
/// Resources are not automatically included in the skill body.
/// To enable discovery, reference resources by name in the skill's instructions or in other resources.
/// Resources are listed in the <c>&lt;available_resources&gt;</c> block of the skill body so the LLM
/// knows which ones can be accessed. When empty, a self-closing element is emitted to prevent
/// hallucinated resource calls.
/// </para>
/// </remarks>
public virtual IReadOnlyList<AgentSkillResource>? Resources => this._resources.Value;
@@ -178,8 +180,9 @@ public abstract class AgentClassSkill<
/// Override this property in derived classes to provide skill-specific scripts.
/// </para>
/// <para>
/// Only script parameter schemas are included in the skill body (as a <c>&lt;script_schemas&gt;</c> block).
/// To enable discovery, reference scripts by name in the skill's instructions or in a resource.
/// Scripts are listed in the <c>&lt;available_scripts&gt;</c> block of the skill body so the LLM
/// knows which ones can be called. When empty, a self-closing element is emitted to prevent
/// hallucinated script calls.
/// </para>
/// </remarks>
public virtual IReadOnlyList<AgentSkillScript>? Scripts => this._scripts.Value;
@@ -202,8 +205,9 @@ public abstract class AgentClassSkill<
/// Creates a skill resource backed by a static value.
/// </summary>
/// <remarks>
/// Resources are not automatically included in the skill body.
/// To enable discovery, reference the resource by name in the skill's instructions or in another resource.
/// The resource is listed in the <c>&lt;available_resources&gt;</c> block of the skill body so the LLM
/// knows it can be accessed. When no resources are registered, the block is emitted as a
/// self-closing element to signal that none exist, preventing hallucinated resource calls.
/// </remarks>
/// <param name="name">The resource name.</param>
/// <param name="value">The static resource value.</param>
@@ -216,8 +220,9 @@ public abstract class AgentClassSkill<
/// Creates a skill resource backed by a delegate that produces a dynamic value.
/// </summary>
/// <remarks>
/// Resources are not automatically included in the skill body.
/// To enable discovery, reference the resource by name in the skill's instructions or in another resource.
/// The resource is listed in the <c>&lt;available_resources&gt;</c> block of the skill body so the LLM
/// knows it can be accessed. When no resources are registered, the block is emitted as a
/// self-closing element to signal that none exist, preventing hallucinated resource calls.
/// </remarks>
/// <param name="name">The resource name.</param>
/// <param name="method">A method that produces the resource value when requested.</param>
@@ -234,8 +239,9 @@ public abstract class AgentClassSkill<
/// Creates a skill script backed by a delegate.
/// </summary>
/// <remarks>
/// Only the script's parameter schema is included in the skill body (as a <c>&lt;script_schemas&gt;</c> block).
/// To enable discovery, reference the script by name in the skill's instructions or in a resource.
/// The script is listed in the <c>&lt;available_scripts&gt;</c> block of the skill body so the LLM
/// knows it can be called. When no scripts are registered, the block is emitted as a
/// self-closing element to signal that none exist, preventing hallucinated script calls.
/// </remarks>
/// <param name="name">The script name.</param>
/// <param name="method">A method to execute when the script is invoked.</param>
@@ -107,7 +107,7 @@ public sealed class AgentInlineSkill : AgentSkill
/// <inheritdoc/>
public override ValueTask<string> GetContentAsync(CancellationToken cancellationToken = default)
{
return new(this._cachedContent ??= AgentInlineSkillContentBuilder.Build(this.Frontmatter.Name, this.Frontmatter.Description, this._instructions, this._scripts));
return new(this._cachedContent ??= AgentInlineSkillContentBuilder.Build(this.Frontmatter.Name, this.Frontmatter.Description, this._instructions, this._resources, this._scripts));
}
/// <inheritdoc/>
@@ -128,8 +128,9 @@ public sealed class AgentInlineSkill : AgentSkill
/// Registers a static resource with this skill.
/// </summary>
/// <remarks>
/// Resources are not automatically included in the skill body.
/// To enable discovery, reference the resource by name in the skill's instructions or in another resource.
/// The resource is listed in the <c>&lt;available_resources&gt;</c> block of the skill body so the
/// LLM knows it can be accessed. When no resources are registered, the block is emitted as a
/// self-closing element to signal that none exist, preventing hallucinated resource calls.
/// </remarks>
/// <param name="name">The resource name.</param>
/// <param name="value">The static resource value.</param>
@@ -146,8 +147,9 @@ public sealed class AgentInlineSkill : AgentSkill
/// The delegate's parameters and return type are automatically marshaled via <c>AIFunctionFactory</c>.
/// </summary>
/// <remarks>
/// Resources are not automatically included in the skill body.
/// To enable discovery, reference the resource by name in the skill's instructions or in another resource.
/// The resource is listed in the <c>&lt;available_resources&gt;</c> block of the skill body so the
/// LLM knows it can be accessed. When no resources are registered, the block is emitted as a
/// self-closing element to signal that none exist, preventing hallucinated resource calls.
/// </remarks>
/// <param name="name">The resource name.</param>
/// <param name="method">A method that produces the resource value when requested.</param>
@@ -168,8 +170,9 @@ public sealed class AgentInlineSkill : AgentSkill
/// The delegate's parameters and return type are automatically marshaled via <c>AIFunctionFactory</c>.
/// </summary>
/// <remarks>
/// Only the script's parameter schema is included in the skill body (as a <c>&lt;script_schemas&gt;</c> block).
/// To enable discovery, reference the script by name in the skill's instructions or in a resource.
/// The script is listed in the <c>&lt;available_scripts&gt;</c> block of the skill body so the
/// LLM knows it can be called. When no scripts are registered, the block is emitted as a
/// self-closing element to signal that none exist, preventing hallucinated script calls.
/// </remarks>
/// <param name="name">The script name.</param>
/// <param name="method">A method to execute when the script is invoked.</param>
@@ -12,17 +12,19 @@ namespace Microsoft.Agents.AI;
internal static class AgentInlineSkillContentBuilder
{
/// <summary>
/// Builds the complete skill content containing name, description, instructions, and script parameter schemas.
/// Builds the complete skill content containing name, description, instructions, resources, and script parameter schemas.
/// </summary>
/// <param name="name">The skill name.</param>
/// <param name="description">The skill description.</param>
/// <param name="instructions">The raw instructions text.</param>
/// <param name="resources">Optional resources associated with the skill.</param>
/// <param name="scripts">Optional scripts associated with the skill.</param>
/// <returns>An XML-structured content string.</returns>
public static string Build(
string name,
string description,
string instructions,
IReadOnlyList<AgentSkillResource>? resources,
IReadOnlyList<AgentSkillScript>? scripts)
{
_ = Throw.IfNullOrWhitespace(name);
@@ -37,34 +39,71 @@ internal static class AgentInlineSkillContentBuilder
.Append(EscapeXmlString(instructions))
.Append("\n</instructions>");
if (scripts is { Count: > 0 })
{
sb.Append('\n');
sb.Append(BuildScriptSchemasBlock(scripts));
}
sb.Append('\n');
sb.Append(BuildAvailableResourcesBlock(resources ?? []));
sb.Append('\n');
sb.Append(BuildAvailableScriptsBlock(scripts ?? []));
return sb.ToString();
}
/// <summary>
/// Builds a <c>&lt;script_schemas&gt;...&lt;/script_schemas&gt;</c> XML block for the given scripts.
/// Each script is emitted as a <c>&lt;schema script="..."&gt;</c> element containing only
/// the parameter schema. This block serves as a reference for the model to know how to
/// format arguments when calling scripts, not as a discovery mechanism.
/// Builds an <c>&lt;available_resources&gt;...&lt;/available_resources&gt;</c> XML block for the given resources.
/// Each resource is emitted as a self-closing <c>&lt;resource name="..."/&gt;</c> element. When the list is empty,
/// a self-closing <c>&lt;available_resources /&gt;</c> element is returned. This block lets the model know which
/// resources can be read (or that there are none) so it does not hallucinate resource names.
/// </summary>
/// <param name="resources">The resources to include in the block.</param>
/// <returns>
/// An XML string starting with <c>\n&lt;available_resources&gt;</c>, or <c>\n&lt;available_resources /&gt;</c> if the list is empty.
/// </returns>
public static string BuildAvailableResourcesBlock(IReadOnlyList<AgentSkillResource> resources)
{
_ = Throw.IfNull(resources);
if (resources.Count == 0)
{
// Emit an empty element so the model knows no resources are available and does not hallucinate resource names.
return "\n<available_resources />";
}
var sb = new StringBuilder();
sb.Append("\n<available_resources>\n");
foreach (var resource in resources)
{
sb.Append($" <resource name=\"{EscapeXmlString(resource.Name)}\"/>\n");
}
sb.Append("</available_resources>");
return sb.ToString();
}
/// <summary>
/// Builds an <c>&lt;available_scripts&gt;...&lt;/available_scripts&gt;</c> XML block for the given scripts.
/// Each script is emitted as a <c>&lt;script name="..."&gt;</c> element; when the script has a
/// parameter schema it is wrapped in a nested <c>&lt;parameters_schema&gt;</c> element, otherwise a
/// self-closing <c>&lt;script&gt;</c> element is used. When the list is empty, a self-closing
/// <c>&lt;available_scripts /&gt;</c> element is returned. This block lets the model know which scripts
/// can be called and how to format their arguments (or that there are none) so it does not hallucinate script names.
/// </summary>
/// <param name="scripts">The scripts to include in the block.</param>
/// <returns>An XML string starting with <c>\n&lt;script_schemas&gt;</c>, or an empty string if the list is empty.</returns>
public static string BuildScriptSchemasBlock(IReadOnlyList<AgentSkillScript> scripts)
/// <returns>
/// An XML string starting with <c>\n&lt;available_scripts&gt;</c>, or <c>\n&lt;available_scripts /&gt;</c> if the list is empty.
/// </returns>
public static string BuildAvailableScriptsBlock(IReadOnlyList<AgentSkillScript> scripts)
{
_ = Throw.IfNull(scripts);
if (scripts.Count == 0)
{
return string.Empty;
// Emit an empty element so the model knows no scripts are available and does not hallucinate script names.
return "\n<available_scripts />";
}
var sb = new StringBuilder();
sb.Append("\n<script_schemas>\n");
sb.Append("\n<available_scripts>\n");
foreach (var script in scripts)
{
@@ -72,15 +111,17 @@ internal static class AgentInlineSkillContentBuilder
if (parametersSchema is null)
{
sb.Append($" <schema script=\"{EscapeXmlString(script.Name)}\"/>\n");
sb.Append($" <script name=\"{EscapeXmlString(script.Name)}\"/>\n");
}
else
{
sb.Append($" <schema script=\"{EscapeXmlString(script.Name)}\">{EscapeXmlString(parametersSchema.Value.GetRawText(), preserveQuotes: true)}</schema>\n");
sb.Append($" <script name=\"{EscapeXmlString(script.Name)}\">\n");
sb.Append($" <parameters_schema>{EscapeXmlString(parametersSchema.Value.GetRawText(), preserveQuotes: true)}</parameters_schema>\n");
sb.Append(" </script>\n");
}
}
sb.Append("</script_schemas>");
sb.Append("</available_scripts>");
return sb.ToString();
}
@@ -0,0 +1,324 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Text.Json;
using GitHub.Copilot;
using Microsoft.Extensions.AI;
namespace Microsoft.Agents.AI.GitHub.Copilot.UnitTests;
/// <summary>
/// Unit tests for tool execution event projection in <see cref="GitHubCopilotAgent"/>.
/// </summary>
public sealed class ToolExecutionEventProjectionTests
{
private static JsonElement ParseJson(string json) => JsonDocument.Parse(json).RootElement;
[Fact]
public void ConvertToAgentResponseUpdate_ToolExecutionStartEvent_ProducesFunctionCallContent()
{
// Arrange
var copilotClient = new CopilotClient(new CopilotClientOptions());
var agent = new GitHubCopilotAgent(copilotClient, ownsClient: false, id: "agent-1", tools: null);
var startEvent = new ToolExecutionStartEvent
{
Data = new ToolExecutionStartData
{
ToolCallId = "call-123",
ToolName = "readFile",
Arguments = ParseJson("{\"path\":\"/tmp/test.txt\"}")
}
};
// Act
AgentResponseUpdate result = agent.ConvertToAgentResponseUpdate(startEvent);
// Assert
Assert.Equal(ChatRole.Assistant, result.Role);
Assert.Equal("agent-1", result.AgentId);
var content = Assert.Single(result.Contents);
var functionCall = Assert.IsType<FunctionCallContent>(content);
Assert.Equal("call-123", functionCall.CallId);
Assert.Equal("readFile", functionCall.Name);
Assert.NotNull(functionCall.Arguments);
Assert.Equal("/tmp/test.txt", functionCall.Arguments!["path"]?.ToString());
Assert.Same(startEvent, functionCall.RawRepresentation);
}
[Fact]
public void ConvertToAgentResponseUpdate_ToolExecutionStartEvent_WithNullArguments_ProducesNullArguments()
{
// Arrange
var copilotClient = new CopilotClient(new CopilotClientOptions());
var agent = new GitHubCopilotAgent(copilotClient, ownsClient: false, tools: null);
var startEvent = new ToolExecutionStartEvent
{
Data = new ToolExecutionStartData
{
ToolCallId = "call-456",
ToolName = "listTools",
Arguments = null
}
};
// Act
AgentResponseUpdate result = agent.ConvertToAgentResponseUpdate(startEvent);
// Assert
var content = Assert.Single(result.Contents);
var functionCall = Assert.IsType<FunctionCallContent>(content);
Assert.Equal("call-456", functionCall.CallId);
Assert.Equal("listTools", functionCall.Name);
Assert.Null(functionCall.Arguments);
}
[Fact]
public void ConvertToAgentResponseUpdate_ToolExecutionStartEvent_WithNullData_ProducesEmptyFunctionCall()
{
// Arrange
var copilotClient = new CopilotClient(new CopilotClientOptions());
var agent = new GitHubCopilotAgent(copilotClient, ownsClient: false, tools: null);
var startEvent = new ToolExecutionStartEvent { Data = null! };
// Act
AgentResponseUpdate result = agent.ConvertToAgentResponseUpdate(startEvent);
// Assert
var content = Assert.Single(result.Contents);
var functionCall = Assert.IsType<FunctionCallContent>(content);
Assert.Equal(string.Empty, functionCall.CallId);
Assert.Equal(string.Empty, functionCall.Name);
Assert.Null(functionCall.Arguments);
}
[Fact]
public void ConvertToAgentResponseUpdate_ToolExecutionCompleteEvent_WithSuccess_ProducesFunctionResultContent()
{
// Arrange
var copilotClient = new CopilotClient(new CopilotClientOptions());
var agent = new GitHubCopilotAgent(copilotClient, ownsClient: false, id: "agent-2", tools: null);
var completeEvent = new ToolExecutionCompleteEvent
{
Data = new ToolExecutionCompleteData
{
ToolCallId = "call-123",
Success = true,
Result = new ToolExecutionCompleteResult
{
Content = "{\"users\":[{\"name\":\"Alice\"}]}"
}
}
};
// Act
AgentResponseUpdate result = agent.ConvertToAgentResponseUpdate(completeEvent);
// Assert
Assert.Equal(ChatRole.Tool, result.Role);
Assert.Equal("agent-2", result.AgentId);
var content = Assert.Single(result.Contents);
var functionResult = Assert.IsType<FunctionResultContent>(content);
Assert.Equal("call-123", functionResult.CallId);
Assert.Equal("{\"users\":[{\"name\":\"Alice\"}]}", functionResult.Result);
Assert.Same(completeEvent, functionResult.RawRepresentation);
}
[Fact]
public void ConvertToAgentResponseUpdate_ToolExecutionCompleteEvent_WithError_ProducesErrorResult()
{
// Arrange
var copilotClient = new CopilotClient(new CopilotClientOptions());
var agent = new GitHubCopilotAgent(copilotClient, ownsClient: false, tools: null);
var completeEvent = new ToolExecutionCompleteEvent
{
Data = new ToolExecutionCompleteData
{
ToolCallId = "call-789",
Success = false,
Error = new ToolExecutionCompleteError
{
Code = "PERMISSION_DENIED",
Message = "Access denied to resource"
}
}
};
// Act
AgentResponseUpdate result = agent.ConvertToAgentResponseUpdate(completeEvent);
// Assert
Assert.Equal(ChatRole.Tool, result.Role);
var content = Assert.Single(result.Contents);
var functionResult = Assert.IsType<FunctionResultContent>(content);
Assert.Equal("call-789", functionResult.CallId);
Assert.Equal("Access denied to resource", functionResult.Result);
}
[Fact]
public void ConvertToAgentResponseUpdate_ToolExecutionCompleteEvent_WithFailureNoError_ProducesDefaultErrorMessage()
{
// Arrange
var copilotClient = new CopilotClient(new CopilotClientOptions());
var agent = new GitHubCopilotAgent(copilotClient, ownsClient: false, tools: null);
var completeEvent = new ToolExecutionCompleteEvent
{
Data = new ToolExecutionCompleteData
{
ToolCallId = "call-000",
Success = false,
Error = null
}
};
// Act
AgentResponseUpdate result = agent.ConvertToAgentResponseUpdate(completeEvent);
// Assert
var content = Assert.Single(result.Contents);
var functionResult = Assert.IsType<FunctionResultContent>(content);
Assert.Equal("call-000", functionResult.CallId);
Assert.Equal("Tool execution failed", functionResult.Result);
}
[Fact]
public void ConvertToAgentResponseUpdate_ToolExecutionCompleteEvent_WithNullData_ProducesEmptyResult()
{
// Arrange
var copilotClient = new CopilotClient(new CopilotClientOptions());
var agent = new GitHubCopilotAgent(copilotClient, ownsClient: false, tools: null);
var completeEvent = new ToolExecutionCompleteEvent { Data = null! };
// Act
AgentResponseUpdate result = agent.ConvertToAgentResponseUpdate(completeEvent);
// Assert
var content = Assert.Single(result.Contents);
var functionResult = Assert.IsType<FunctionResultContent>(content);
Assert.Equal(string.Empty, functionResult.CallId);
Assert.Equal("Tool execution failed", functionResult.Result);
}
[Fact]
public void ConvertToAgentResponseUpdate_ToolExecutionStartEvent_WithMultipleArguments_ParsesAll()
{
// Arrange
var copilotClient = new CopilotClient(new CopilotClientOptions());
var agent = new GitHubCopilotAgent(copilotClient, ownsClient: false, tools: null);
var startEvent = new ToolExecutionStartEvent
{
Data = new ToolExecutionStartData
{
ToolCallId = "call-multi",
ToolName = "queryTable",
Arguments = ParseJson("{\"table\":\"incidents\",\"limit\":10,\"filter\":\"active=true\"}")
}
};
// Act
AgentResponseUpdate result = agent.ConvertToAgentResponseUpdate(startEvent);
// Assert
var content = Assert.Single(result.Contents);
var functionCall = Assert.IsType<FunctionCallContent>(content);
Assert.Equal("call-multi", functionCall.CallId);
Assert.Equal("queryTable", functionCall.Name);
Assert.NotNull(functionCall.Arguments);
Assert.Equal("incidents", functionCall.Arguments!["table"]?.ToString());
Assert.Equal("10", functionCall.Arguments!["limit"]?.ToString());
Assert.Equal("active=true", functionCall.Arguments!["filter"]?.ToString());
}
[Fact]
public void ConvertToAgentResponseUpdate_ToolExecutionCompleteEvent_WithSuccessButNullResult_ProducesNullResult()
{
// Arrange
var copilotClient = new CopilotClient(new CopilotClientOptions());
var agent = new GitHubCopilotAgent(copilotClient, ownsClient: false, tools: null);
var completeEvent = new ToolExecutionCompleteEvent
{
Data = new ToolExecutionCompleteData
{
ToolCallId = "call-null-result",
Success = true,
Result = null
}
};
// Act
AgentResponseUpdate result = agent.ConvertToAgentResponseUpdate(completeEvent);
// Assert
var content = Assert.Single(result.Contents);
var functionResult = Assert.IsType<FunctionResultContent>(content);
Assert.Equal("call-null-result", functionResult.CallId);
Assert.Null(functionResult.Result);
}
[Fact]
public void ConvertToAgentResponseUpdate_ToolExecutionStartEvent_WithEmptyObjectArguments_ProducesEmptyDictionary()
{
// Arrange
var copilotClient = new CopilotClient(new CopilotClientOptions());
var agent = new GitHubCopilotAgent(copilotClient, ownsClient: false, tools: null);
var startEvent = new ToolExecutionStartEvent
{
Data = new ToolExecutionStartData
{
ToolCallId = "call-empty",
ToolName = "noArgsTool",
Arguments = ParseJson("{}")
}
};
// Act
AgentResponseUpdate result = agent.ConvertToAgentResponseUpdate(startEvent);
// Assert
var content = Assert.Single(result.Contents);
var functionCall = Assert.IsType<FunctionCallContent>(content);
Assert.Equal("call-empty", functionCall.CallId);
Assert.NotNull(functionCall.Arguments);
Assert.Empty(functionCall.Arguments!);
}
[Fact]
public void ConvertToAgentResponseUpdate_ToolExecutionStartEvent_WithNestedJsonArguments_ParsesTopLevel()
{
// Arrange
var copilotClient = new CopilotClient(new CopilotClientOptions());
var agent = new GitHubCopilotAgent(copilotClient, ownsClient: false, tools: null);
var startEvent = new ToolExecutionStartEvent
{
Data = new ToolExecutionStartData
{
ToolCallId = "call-nested",
ToolName = "complexTool",
Arguments = ParseJson("{\"config\":{\"timeout\":30},\"name\":\"test\"}")
}
};
// Act
AgentResponseUpdate result = agent.ConvertToAgentResponseUpdate(startEvent);
// Assert
var content = Assert.Single(result.Contents);
var functionCall = Assert.IsType<FunctionCallContent>(content);
Assert.Equal("call-nested", functionCall.CallId);
Assert.NotNull(functionCall.Arguments);
Assert.Equal("test", functionCall.Arguments!["name"]?.ToString());
Assert.NotNull(functionCall.Arguments!["config"]);
}
}
@@ -62,10 +62,10 @@ public sealed class A2AServerServiceCollectionExtensionsTests
/// <summary>
/// Verifies that when no ITaskStore or AgentSessionStore are registered,
/// AddA2AServer falls back to in-memory defaults and resolves successfully.
/// AddA2AServer falls back to noop session store default and resolves successfully.
/// </summary>
[Fact]
public async Task AddA2AServer_WithNoCustomStores_FallsBackToInMemoryDefaultsAsync()
public async Task AddA2AServer_WithNoCustomStores_FallsBackToNoopSessionStoreDefaultAsync()
{
// Arrange
const string AgentName = "default-stores-agent";
@@ -382,7 +382,7 @@ public sealed class A2AServerServiceCollectionExtensionsTests
/// <summary>
/// Verifies that when no custom stores or handlers are registered, the server uses
/// the default in-memory stores and processes requests successfully end-to-end.
/// the default noop session store and processes requests successfully end-to-end.
/// </summary>
[Fact]
public async Task AddA2AServer_WithNoCustomStores_DefaultStoresProcessRequestSuccessfullyAsync()
@@ -400,7 +400,7 @@ public sealed class A2AServerServiceCollectionExtensionsTests
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(10));
var response = await server.SendMessageAsync(CreateTestSendMessageRequest(), cts.Token);
// Assert - request was processed successfully with default in-memory stores
// Assert - request was processed successfully with default noop session store
Assert.NotNull(response);
Assert.Equal(SendMessageResponseCase.Message, response.PayloadCase);
Assert.NotNull(response.Message);
@@ -382,9 +382,10 @@ public sealed class AgentClassSkillTests
// Arrange
var skill = new AttributedFullSkill();
// Act & Assert — Content no longer includes resources in body; scripts are in script_schemas
Assert.DoesNotContain("<resources>", await skill.GetContentAsync());
Assert.Contains("<script_schemas>", await skill.GetContentAsync());
// Act & Assert — Content includes resources in body; scripts are in available_scripts
Assert.Contains("<available_resources>", await skill.GetContentAsync());
Assert.Contains("conversion-table", await skill.GetContentAsync());
Assert.Contains("<available_scripts>", await skill.GetContentAsync());
Assert.Contains("convert", await skill.GetContentAsync());
// Act & Assert — discovered members are cached
@@ -502,7 +503,7 @@ public sealed class AgentClassSkillTests
}
[Fact]
public async Task Content_DoesNotRenderResources_InBodyAsync()
public async Task Content_RendersResources_InBodyAsync()
{
// Arrange
var skill = new AttributedResourcePropertiesSkill();
@@ -510,8 +511,10 @@ public sealed class AgentClassSkillTests
// Act
var content = await skill.GetContentAsync();
// Assert — resources are no longer rendered in body content
Assert.DoesNotContain("<resources>", content);
// Assert — resources are rendered in body content by name; descriptions are not emitted
Assert.Contains("<available_resources>", content);
Assert.Contains("ref-data", content);
Assert.DoesNotContain("Some important data.", content);
}
[Fact]
@@ -122,14 +122,17 @@ public sealed class AgentFileSkillScriptTests
// Assert — content starts with original and appends per-script entries
Assert.StartsWith("Original content", content);
Assert.Contains("<script_schemas>", content);
Assert.Contains("<schema script=\"build\">", content);
Assert.Contains("<schema script=\"deploy\">", content);
Assert.Contains("</script_schemas>", content);
Assert.Contains("<available_scripts>", content);
Assert.Contains("<script name=\"build\">", content);
Assert.Contains("<script name=\"deploy\">", content);
Assert.Contains("</available_scripts>", content);
// A scripts-only skill still emits an empty resources peer so the model knows none are available
Assert.Contains("<available_resources />", content);
}
[Fact]
public async Task Content_WithoutScripts_ReturnsOriginalContentAsync()
public async Task Content_WithoutResourcesOrScripts_EmitsSelfClosingPeersAsync()
{
// Arrange
var fileSkill = new AgentFileSkill(
@@ -140,8 +143,56 @@ public sealed class AgentFileSkillScriptTests
// Act
var content = await fileSkill.GetContentAsync();
// Assert
Assert.Equal("Original content only", content);
// Assert — both blocks are always emitted as self-closing elements so the model knows none are available
Assert.StartsWith("Original content only", content);
Assert.Contains("<available_resources />", content);
Assert.Contains("<available_scripts />", content);
}
[Fact]
public async Task Content_WithResources_AppendsResourceEntriesAsync()
{
// Arrange
var fileSkill = new AgentFileSkill(
new AgentSkillFrontmatter("my-skill", "A skill"),
"Original content",
"/skills/my-skill",
resources: [new AgentInlineSkillResource("reference", "value"), new AgentInlineSkillResource("table", "value")]);
// Act
var content = await fileSkill.GetContentAsync();
// Assert — content starts with original and appends per-resource entries so the model knows what is callable
Assert.StartsWith("Original content", content);
Assert.Contains("<available_resources>", content);
Assert.Contains("<resource name=\"reference\"/>", content);
Assert.Contains("<resource name=\"table\"/>", content);
Assert.Contains("</available_resources>", content);
// A resources-only skill still emits an empty scripts peer so the model knows none are available
Assert.Contains("<available_scripts />", content);
}
[Fact]
public async Task Content_WithResourcesAndScripts_AppendsResourcesBeforeScriptsAsync()
{
// Arrange
static Task<object?> RunnerAsync(AgentFileSkill s, AgentFileSkillScript sc, JsonElement? a, IServiceProvider? sp, CancellationToken ct) => Task.FromResult<object?>(null);
var fileSkill = new AgentFileSkill(
new AgentSkillFrontmatter("my-skill", "A skill"),
"Original content",
"/skills/my-skill",
resources: [new AgentInlineSkillResource("reference", "value")],
scripts: [CreateScript("build", "/scripts/build.sh", RunnerAsync)]);
// Act
var content = await fileSkill.GetContentAsync();
// Assert — resources block precedes scripts block
var resourcesIndex = content.IndexOf("<available_resources>", StringComparison.Ordinal);
var scriptsIndex = content.IndexOf("<available_scripts>", StringComparison.Ordinal);
Assert.True(resourcesIndex >= 0 && scriptsIndex >= 0);
Assert.True(resourcesIndex < scriptsIndex);
}
[Fact]
@@ -0,0 +1,241 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
namespace Microsoft.Agents.AI.UnitTests.AgentSkills;
/// <summary>
/// Unit tests for <see cref="AgentInlineSkillContentBuilder"/>, focusing on the structure of the
/// emitted <c>&lt;available_resources&gt;</c> and <c>&lt;available_scripts&gt;</c> blocks.
/// </summary>
public sealed class AgentInlineSkillContentBuilderTests
{
[Fact]
public void Build_NullResourcesAndScripts_EmitsSelfClosingTags()
{
// Act
var content = AgentInlineSkillContentBuilder.Build("my-skill", "A skill.", "Instructions.", resources: null, scripts: null);
// Assert — explicit empty elements signal "none available" so the model does not hallucinate names
Assert.Contains("<available_resources />", content);
Assert.Contains("<available_scripts />", content);
Assert.DoesNotContain("<available_resources>", content);
Assert.DoesNotContain("<available_scripts>", content);
}
[Fact]
public void Build_EmptyResourcesAndScripts_EmitsSelfClosingTags()
{
// Act
var content = AgentInlineSkillContentBuilder.Build(
"my-skill",
"A skill.",
"Instructions.",
resources: Array.Empty<AgentSkillResource>(),
scripts: Array.Empty<AgentSkillScript>());
// Assert
Assert.Contains("<available_resources />", content);
Assert.Contains("<available_scripts />", content);
}
[Fact]
public void Build_ResourcesOnly_EmitsResourceEntriesAndSelfClosingScripts()
{
// Arrange
var resources = new AgentSkillResource[]
{
new AgentInlineSkillResource("config", "value", "A described resource."),
new AgentInlineSkillResource("table", "value"),
};
// Act
var content = AgentInlineSkillContentBuilder.Build("my-skill", "A skill.", "Instructions.", resources, scripts: null);
// Assert — resources are listed by name (no description), scripts are an empty element
Assert.Contains("<available_resources>", content);
Assert.Contains("<resource name=\"config\"/>", content);
Assert.Contains("<resource name=\"table\"/>", content);
Assert.Contains("</available_resources>", content);
Assert.DoesNotContain("A described resource.", content);
Assert.Contains("<available_scripts />", content);
}
[Fact]
public void Build_ScriptsOnly_EmitsSelfClosingResourcesAndScriptsBlock()
{
// Arrange
var scripts = new AgentSkillScript[] { new FakeScript("run", ParseSchema("{\"type\":\"object\"}")) };
// Act
var content = AgentInlineSkillContentBuilder.Build("my-skill", "A skill.", "Instructions.", resources: null, scripts);
// Assert
Assert.Contains("<available_resources />", content);
Assert.Contains("<available_scripts>", content);
Assert.Contains("<script name=\"run\">", content);
Assert.Contains("</available_scripts>", content);
}
[Fact]
public void Build_MultipleResources_RendersAllInRegistrationOrder()
{
// Arrange
var resources = new AgentSkillResource[]
{
new AgentInlineSkillResource("first", "v"),
new AgentInlineSkillResource("second", "v"),
new AgentInlineSkillResource("third", "v"),
};
// Act
var content = AgentInlineSkillContentBuilder.Build("my-skill", "A skill.", "Instructions.", resources, scripts: null);
// Assert — order is preserved
var firstIndex = content.IndexOf("first", StringComparison.Ordinal);
var secondIndex = content.IndexOf("second", StringComparison.Ordinal);
var thirdIndex = content.IndexOf("third", StringComparison.Ordinal);
Assert.True(firstIndex < secondIndex && secondIndex < thirdIndex);
}
[Fact]
public void Build_ResourceNameWithSpecialCharacters_IsXmlEscaped()
{
// Arrange
var resources = new AgentSkillResource[] { new AgentInlineSkillResource("a<b>&\"c", "v") };
// Act
var content = AgentInlineSkillContentBuilder.Build("my-skill", "A skill.", "Instructions.", resources, scripts: null);
// Assert — XML special characters in the name are escaped
Assert.Contains("<resource name=\"a&lt;b&gt;&amp;&quot;c\"/>", content);
}
[Fact]
public void BuildAvailableResourcesBlock_EmptyList_ReturnsSelfClosingElement()
{
// Act
var block = AgentInlineSkillContentBuilder.BuildAvailableResourcesBlock(Array.Empty<AgentSkillResource>());
// Assert — empty list yields a self-closing element so the model knows none are available
Assert.Equal("\n<available_resources />", block);
}
[Fact]
public void BuildAvailableResourcesBlock_WithResources_EmitsSelfClosingResourceEntries()
{
// Arrange
var resources = new AgentSkillResource[]
{
new AgentInlineSkillResource("config", "value", "A described resource."),
new AgentInlineSkillResource("table", "value"),
};
// Act
var block = AgentInlineSkillContentBuilder.BuildAvailableResourcesBlock(resources);
// Assert — resources are listed by name only (no description)
Assert.Contains("<available_resources>", block);
Assert.Contains("<resource name=\"config\"/>", block);
Assert.Contains("<resource name=\"table\"/>", block);
Assert.Contains("</available_resources>", block);
Assert.DoesNotContain("A described resource.", block);
}
[Fact]
public void BuildAvailableResourcesBlock_ResourceNameWithSpecialCharacters_IsXmlEscaped()
{
// Arrange
var resources = new AgentSkillResource[] { new AgentInlineSkillResource("a<b>&\"c", "v") };
// Act
var block = AgentInlineSkillContentBuilder.BuildAvailableResourcesBlock(resources);
// Assert
Assert.Contains("<resource name=\"a&lt;b&gt;&amp;&quot;c\"/>", block);
}
[Fact]
public void BuildAvailableResourcesBlock_NullResources_Throws() =>
Assert.Throws<ArgumentNullException>(() => AgentInlineSkillContentBuilder.BuildAvailableResourcesBlock(null!));
[Fact]
public void BuildAvailableScriptsBlock_EmptyList_ReturnsSelfClosingElement()
{
// Act
var block = AgentInlineSkillContentBuilder.BuildAvailableScriptsBlock(Array.Empty<AgentSkillScript>());
// Assert — empty list yields a self-closing element so the model knows none are available
Assert.Equal("\n<available_scripts />", block);
}
[Fact]
public void BuildAvailableScriptsBlock_ScriptWithoutSchema_UsesSelfClosingScript()
{
// Arrange — a script whose ParametersSchema is null
var scripts = new AgentSkillScript[] { new FakeScript("no-params", parametersSchema: null) };
// Act
var block = AgentInlineSkillContentBuilder.BuildAvailableScriptsBlock(scripts);
// Assert — self-closing <script> element with no nested parameters_schema
Assert.Contains("<script name=\"no-params\"/>", block);
Assert.DoesNotContain("<parameters_schema>", block);
}
[Fact]
public void BuildAvailableScriptsBlock_ScriptWithSchema_WrapsSchemaInParametersSchemaElement()
{
// Arrange
var scripts = new AgentSkillScript[] { new FakeScript("search", ParseSchema("{\"type\":\"object\",\"properties\":{\"query\":{\"type\":\"string\"}}}")) };
// Act
var block = AgentInlineSkillContentBuilder.BuildAvailableScriptsBlock(scripts);
// Assert — schema is wrapped in <parameters_schema> with preserved quotes (no CDATA)
Assert.Contains("<script name=\"search\">", block);
Assert.Contains("<parameters_schema>", block);
Assert.Contains("\"query\"", block);
Assert.Contains("</parameters_schema>", block);
Assert.Contains("</script>", block);
Assert.DoesNotContain("<![CDATA[", block);
}
[Fact]
public void BuildAvailableScriptsBlock_ScriptNameWithSpecialCharacters_IsXmlEscaped()
{
// Arrange
var scripts = new AgentSkillScript[] { new FakeScript("a<b>&\"c", parametersSchema: null) };
// Act
var block = AgentInlineSkillContentBuilder.BuildAvailableScriptsBlock(scripts);
// Assert
Assert.Contains("<script name=\"a&lt;b&gt;&amp;&quot;c\"/>", block);
}
[Fact]
public void BuildAvailableScriptsBlock_NullScripts_Throws() =>
Assert.Throws<ArgumentNullException>(() => AgentInlineSkillContentBuilder.BuildAvailableScriptsBlock(null!));
private static JsonElement ParseSchema(string json) => JsonDocument.Parse(json).RootElement.Clone();
private sealed class FakeScript : AgentSkillScript
{
private readonly JsonElement? _parametersSchema;
public FakeScript(string name, JsonElement? parametersSchema)
: base(name)
{
this._parametersSchema = parametersSchema;
}
public override JsonElement? ParametersSchema => this._parametersSchema;
public override Task<object?> RunAsync(AgentSkill skill, JsonElement? arguments, IServiceProvider? serviceProvider, CancellationToken cancellationToken = default) =>
Task.FromResult<object?>(null);
}
}
@@ -149,7 +149,7 @@ public sealed class AgentInlineSkillTests
}
[Fact]
public async Task Content_DoesNotIncludeResourcesInBodyAsync()
public async Task Content_IncludesResourcesInBodyAsync()
{
// Arrange
var skill = new AgentInlineSkill("my-skill", "A valid skill.", "Instructions.");
@@ -158,12 +158,14 @@ public sealed class AgentInlineSkillTests
// Act
var content = await skill.GetContentAsync();
// Assert — resources are no longer rendered in the body; they're accessed via GetResourceAsync
Assert.DoesNotContain("<resources>", content);
// Assert — resources are rendered in the body so the model can discover them
Assert.Contains("<available_resources>", content);
Assert.Contains("<resource name=\"config\"/>", content);
Assert.DoesNotContain("A config resource.", content);
}
[Fact]
public async Task Content_DoesNotIncludeDelegateResourcesInBodyAsync()
public async Task Content_IncludesDelegateResourcesInBodyAsync()
{
// Arrange
var skill = new AgentInlineSkill("my-skill", "A valid skill.", "Instructions.");
@@ -172,8 +174,9 @@ public sealed class AgentInlineSkillTests
// Act
var content = await skill.GetContentAsync();
// Assert — resources are no longer rendered in the body
Assert.DoesNotContain("<resources>", content);
// Assert — resources are rendered in the body
Assert.Contains("<available_resources>", content);
Assert.Contains("<resource name=\"dynamic\"/>", content);
}
[Fact]
@@ -187,8 +190,8 @@ public sealed class AgentInlineSkillTests
var content = await skill.GetContentAsync();
// Assert
Assert.Contains("<script_schemas>", content);
Assert.Contains("run", content);
Assert.Contains("<available_scripts>", content);
Assert.Contains("<script name=\"run\"", content);
}
[Fact]
@@ -218,8 +221,9 @@ public sealed class AgentInlineSkillTests
var content = await skill.GetContentAsync();
// Assert
Assert.DoesNotContain("<resources>", content);
Assert.Contains("<script_schemas>", content);
Assert.Contains("<available_resources>", content);
Assert.Contains("r1", content);
Assert.Contains("<available_scripts>", content);
Assert.Contains("s1", content);
}
@@ -233,8 +237,9 @@ public sealed class AgentInlineSkillTests
// Act
var content = await skill.GetContentAsync();
// Assert — JSON schema should be present inside <schema> element (no extra wrapper) with preserved quotes
Assert.Contains("<schema script=\"search\">", content);
// Assert — JSON schema should be present inside <parameters_schema> element with preserved quotes
Assert.Contains("<script name=\"search\">", content);
Assert.Contains("<parameters_schema>", content);
Assert.Contains("\"query\"", content);
Assert.DoesNotContain("<![CDATA[", content);
}
@@ -417,7 +422,7 @@ public sealed class AgentInlineSkillTests
}
[Fact]
public async Task Content_NoResourcesOrScripts_DoesNotContainResourcesOrScriptsTagsAsync()
public async Task Content_NoResourcesOrScripts_EmitsSelfClosingTagsAsync()
{
// Arrange
var skill = new AgentInlineSkill("my-skill", "A valid skill.", "Instructions.");
@@ -425,9 +430,9 @@ public sealed class AgentInlineSkillTests
// Act
var content = await skill.GetContentAsync();
// Assert
Assert.DoesNotContain("<resources>", content);
Assert.DoesNotContain("<script_schemas>", content);
// Assert — empty self-closing elements are emitted when no resources or scripts exist
Assert.Contains("<available_resources />", content);
Assert.Contains("<available_scripts />", content);
}
[Fact]
@@ -470,9 +475,8 @@ public sealed class AgentInlineSkillTests
// Act
var content = await skill.GetContentAsync();
// Assert — description is no longer emitted in the script_schemas block;
// the block only contains parameter schemas for calling scripts.
Assert.Contains("<schema script=\"my-script\"", content);
// Assert — only the script name is emitted; the description is not rendered as an attribute
Assert.Contains("<script name=\"my-script\"", content);
Assert.DoesNotContain("description=\"Runs something.\"", content);
}
@@ -492,7 +496,7 @@ public sealed class AgentInlineSkillTests
}
[Fact]
public async Task Content_ResourceWithDescription_NotRenderedInBodyAsync()
public async Task Content_ResourceWithDescription_RenderedInBodyWithoutDescriptionAsync()
{
// Arrange
var skill = new AgentInlineSkill("my-skill", "A valid skill.", "Instructions.");
@@ -502,10 +506,11 @@ public sealed class AgentInlineSkillTests
// Act
var content = await skill.GetContentAsync();
// Assert — resources are no longer rendered in the body
Assert.DoesNotContain("<resources>", content);
Assert.DoesNotContain("with-desc", content);
Assert.DoesNotContain("no-desc", content);
// Assert — resources are rendered by name in the body; descriptions are not emitted
Assert.Contains("<available_resources>", content);
Assert.Contains("<resource name=\"with-desc\"/>", content);
Assert.Contains("<resource name=\"no-desc\"/>", content);
Assert.DoesNotContain("A described resource.", content);
}
[Fact]
@@ -795,9 +795,9 @@ public class OpenTelemetryAgentTests
[Fact]
public async Task AutoWireChatClient_PlainAgentRunOptions_PreservesBaseProperties_Async()
{
// Auto-wiring converts a plain AgentRunOptions into a ChatClientAgentRunOptions. The base
// properties (ContinuationToken, AllowBackgroundResponses, AdditionalProperties, ResponseFormat)
// must be preserved so they reach the inner agent.
// The auto-wire no longer rewrites the caller's options (the slot below FICC is activated once at
// construction), so a plain AgentRunOptions reaches the inner agent unchanged with all base
// properties (AllowBackgroundResponses, AdditionalProperties, ResponseFormat) intact.
AgentRunOptions? observedOptions = null;
var fakeChatClient = new AutoWireTestChatClient();
var innerChatClientAgent = new ChatClientAgent(fakeChatClient);
@@ -827,17 +827,27 @@ public class OpenTelemetryAgentTests
_ = await agent.RunAsync("hi", options: inputOptions);
// Options flow through unchanged (same instance, no conversion to ChatClientAgentRunOptions).
Assert.NotNull(observedOptions);
Assert.IsType<ChatClientAgentRunOptions>(observedOptions);
Assert.Equal(true, observedOptions!.AllowBackgroundResponses);
Assert.Same(inputOptions, observedOptions);
Assert.Equal(true, observedOptions.AllowBackgroundResponses);
Assert.Same(ChatResponseFormat.Json, observedOptions.ResponseFormat);
Assert.NotNull(observedOptions.AdditionalProperties);
Assert.Equal("customValue", observedOptions.AdditionalProperties!["customKey"]);
}
[Fact]
public async Task AutoWireChatClient_UserFactoryReturnsInstrumentedClient_DoesNotDoubleWrap_Async()
public async Task AutoWireChatClient_UserFactoryAddsOwnOTel_CoexistsWithBelowFiccSlot_Async()
{
// This is NOT a single model call counted twice. One model call is observed by two independent
// OpenTelemetry layers, so each layer emits its own "chat" span:
// - the framework's slot, always activated below FICC by OpenTelemetryAgent. This below-FICC layer
// is what lets FICC emit execute_tool spans, so it must remain even when the caller adds their own
// instrumentation. Dropping it to avoid the second span would reintroduce the missing-tool-span bug.
// - the caller's per-run ChatClientFactory, which wraps the pipeline above FICC with its own
// OpenTelemetryChatClient.
// The two chat spans nest and measure different scopes (the above-FICC span covers the whole tool loop,
// the below-FICC span covers each individual model call), so both coexisting is the intended result.
var sourceName = Guid.NewGuid().ToString();
var activities = new List<Activity>();
using var tracerProvider = OpenTelemetry.Sdk.CreateTracerProviderBuilder()
@@ -849,7 +859,7 @@ public class OpenTelemetryAgentTests
var inner = new ChatClientAgent(fakeChatClient);
using var agent = new OpenTelemetryAgent(inner, sourceName);
// User factory wraps the chat client with OpenTelemetryChatClient itself.
// User factory wraps the chat client with OpenTelemetryChatClient itself (above FICC).
var runOptions = new ChatClientAgentRunOptions
{
ChatClientFactory = cc => cc.AsBuilder().UseOpenTelemetry(sourceName: sourceName).Build(),
@@ -857,8 +867,10 @@ public class OpenTelemetryAgentTests
_ = await agent.RunAsync("hi", options: runOptions);
// Expect 2 activities (invoke_agent + a single chat span). If we double-wrapped, we would see 3.
Assert.Equal(2, activities.Count);
// invoke_agent + two chat spans: one from the caller's above-FICC OTel and one from the slot below FICC.
Assert.Equal(3, activities.Count);
Assert.Contains(activities, a => a.DisplayName.StartsWith("invoke_agent", StringComparison.Ordinal));
Assert.Equal(2, activities.Count(a => string.Equals(a.GetTagItem("gen_ai.operation.name") as string, "chat", StringComparison.Ordinal)));
}
[Theory]
@@ -893,8 +905,8 @@ public class OpenTelemetryAgentTests
[Fact]
public async Task AutoWireChatClient_PlainAgentRunOptions_PreservesContinuationToken_Async()
{
// ContinuationToken is the fourth base AgentRunOptions property copied by CopyBaseAgentRunOptions
// and is not exercised by AutoWireChatClient_PlainAgentRunOptions_PreservesBaseProperties_Async.
// ContinuationToken on a plain AgentRunOptions must reach the inner agent unchanged now that the
// auto-wire passes the caller's options straight through (no conversion to ChatClientAgentRunOptions).
AgentRunOptions? observedOptions = null;
var fakeChatClient = new AutoWireTestChatClient();
var innerChatClientAgent = new ChatClientAgent(fakeChatClient);
@@ -921,8 +933,8 @@ public class OpenTelemetryAgentTests
_ = await agent.RunAsync("hi", options: inputOptions);
Assert.NotNull(observedOptions);
Assert.IsType<ChatClientAgentRunOptions>(observedOptions);
Assert.Same(token, observedOptions!.ContinuationToken);
Assert.Same(inputOptions, observedOptions);
Assert.Same(token, observedOptions.ContinuationToken);
}
#pragma warning restore MEAI001
@@ -1061,11 +1073,10 @@ public class OpenTelemetryAgentTests
[InlineData(true, true)]
public async Task AutoWireChatClient_EnableSensitiveData_PropagatedToInnerChatClient_Async(bool enableSensitiveData, bool streaming)
{
// Regression test for: when EnableSensitiveData is set on OpenTelemetryAgent, the auto-wired
// inner OpenTelemetryChatClient must also have EnableSensitiveData propagated to it. Previously,
// GetRunOptionsWithChatClientWiring created the inner client without passing EnableSensitiveData,
// so the inner chat span would never emit gen_ai.input.messages / gen_ai.output.messages even
// when the caller explicitly set EnableSensitiveData = true.
// Regression test (issue #5873): when EnableSensitiveData is set on OpenTelemetryAgent, the auto-wired
// inner OpenTelemetryChatClient (the below-FICC slot) must also have EnableSensitiveData propagated to it,
// so the inner chat span captures gen_ai.input.messages / gen_ai.output.messages. The agent sets the value
// on the slot after construction, since EnableSensitiveData is typically set via the UseOpenTelemetry callback.
var sourceName = Guid.NewGuid().ToString();
var activities = new List<Activity>();
using var tracerProvider = OpenTelemetry.Sdk.CreateTracerProviderBuilder()
@@ -1109,6 +1120,71 @@ public class OpenTelemetryAgentTests
}
}
[Fact]
public async Task AutoWireChatClient_EmitsExecuteToolSpans_Async()
{
// The core of the OTel-below-FICC fix: with the slot active below FICC, the inner chat span closes
// before FICC invokes tools, so Activity.Current is the invoke_agent span and FICC emits an
// execute_tool span on the agent source, parented under invoke_agent.
var sourceName = Guid.NewGuid().ToString();
var activities = new List<Activity>();
using var tracerProvider = OpenTelemetry.Sdk.CreateTracerProviderBuilder()
.AddSource(sourceName)
.AddInMemoryExporter(activities)
.Build();
var tool = AIFunctionFactory.Create(() => "sunny", "get_weather");
var fakeChatClient = new ToolCallingTestChatClient();
var inner = new ChatClientAgent(fakeChatClient, new ChatClientAgentOptions
{
ChatOptions = new ChatOptions { Tools = [tool] },
});
using var agent = new OpenTelemetryAgent(inner, sourceName);
_ = await agent.RunAsync("weather?");
var invokeAgent = Assert.Single(activities, a => a.DisplayName.StartsWith("invoke_agent", StringComparison.Ordinal));
var executeTool = Assert.Single(activities, a => a.DisplayName.StartsWith("execute_tool", StringComparison.Ordinal));
Assert.Equal(sourceName, executeTool.Source.Name);
Assert.Equal(invokeAgent.SpanId, executeTool.ParentSpanId);
Assert.Contains(activities, a => string.Equals(a.GetTagItem("gen_ai.operation.name") as string, "chat", StringComparison.Ordinal));
}
[Fact]
public async Task DeferredOpenTelemetryChatClient_InertUntilActivated_Async()
{
var sourceName = Guid.NewGuid().ToString();
var activities = new List<Activity>();
using var tracerProvider = OpenTelemetry.Sdk.CreateTracerProviderBuilder()
.AddSource(sourceName)
.AddInMemoryExporter(activities)
.Build();
var leaf = new AutoWireTestChatClient();
using var slot = new DeferredOpenTelemetryChatClient(leaf);
// Inert: resolves itself for its own type and forwards other lookups to the inner client. The bare
// leaf is not instrumented, so the OpenTelemetryChatClient lookup is null and no span is emitted.
Assert.False(slot.IsActive);
Assert.Same(slot, slot.GetService(typeof(DeferredOpenTelemetryChatClient)));
Assert.Null(slot.GetService(typeof(OpenTelemetryChatClient)));
_ = await slot.GetResponseAsync("hi");
Assert.Empty(activities);
// Active: routes through an OpenTelemetryChatClient that emits a chat span on the source.
slot.Activate(sourceName);
Assert.True(slot.IsActive);
Assert.NotNull(slot.GetService(typeof(OpenTelemetryChatClient)));
_ = await slot.GetResponseAsync("hi");
var chat = Assert.Single(activities);
Assert.Equal("chat", chat.GetTagItem("gen_ai.operation.name") as string);
// Idempotent: a second activation does not replace the existing wrapper.
var target = slot.GetService(typeof(OpenTelemetryChatClient));
slot.Activate(sourceName);
Assert.Same(target, slot.GetService(typeof(OpenTelemetryChatClient)));
}
private sealed class AutoWireTestChatClient : IChatClient
{
public Action<IEnumerable<ChatMessage>, ChatOptions?>? OnGetResponseAsync { get; set; }
@@ -1132,5 +1208,40 @@ public class OpenTelemetryAgentTests
public void Dispose() { }
}
private sealed class ToolCallingTestChatClient : IChatClient
{
private int _callCount;
public Task<ChatResponse> GetResponseAsync(IEnumerable<ChatMessage> messages, ChatOptions? options = null, CancellationToken cancellationToken = default)
{
// First call returns a tool call so FICC invokes the tool; the second call returns the final text.
if (Interlocked.Increment(ref this._callCount) == 1)
{
var call = new FunctionCallContent("call_1", "get_weather", new Dictionary<string, object?>());
return Task.FromResult(new ChatResponse(new ChatMessage(ChatRole.Assistant, [call])));
}
return Task.FromResult(new ChatResponse(new ChatMessage(ChatRole.Assistant, "done")));
}
public async IAsyncEnumerable<ChatResponseUpdate> GetStreamingResponseAsync(IEnumerable<ChatMessage> messages, ChatOptions? options = null, [EnumeratorCancellation] CancellationToken cancellationToken = default)
{
if (Interlocked.Increment(ref this._callCount) == 1)
{
yield return new ChatResponseUpdate(ChatRole.Assistant, [new FunctionCallContent("call_1", "get_weather", new Dictionary<string, object?>())]);
await Task.Yield();
yield break;
}
await Task.Yield();
yield return new ChatResponseUpdate(ChatRole.Assistant, "done");
}
public object? GetService(Type serviceType, object? serviceKey = null) =>
serviceType?.IsInstanceOfType(this) == true ? this : null;
public void Dispose() { }
}
#endregion
}
@@ -0,0 +1,135 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text.Json;
using System.Text.RegularExpressions;
using System.Threading;
using System.Threading.Tasks;
using FluentAssertions;
using Microsoft.Agents.AI.Workflows.Checkpointing;
using Microsoft.Agents.AI.Workflows.InProc;
namespace Microsoft.Agents.AI.Workflows.UnitTests;
/// <summary>
/// Verifies that a checkpoint serialized through <see cref="JsonCheckpointStore"/> can be restored
/// after every <c>Version=X.Y.Z.W</c> substring in the persisted JSON is rewritten to a different value.
/// </summary>
public class CheckpointVersionToleranceTests
{
private sealed class EchoExecutor() : Executor("Echo")
{
protected override ProtocolBuilder ConfigureProtocol(ProtocolBuilder protocolBuilder)
=> protocolBuilder.ConfigureRoutes(routeBuilder =>
routeBuilder.AddHandler<string>((msg, ctx) => ctx.SendMessageAsync(msg)));
}
[Theory]
[InlineData(ExecutionEnvironment.InProcess_OffThread)]
[InlineData(ExecutionEnvironment.InProcess_Lockstep)]
internal async Task Test_Checkpoint_Resumes_AfterAssemblyVersionRewriteAsync(ExecutionEnvironment environment)
{
// Arrange
RequestPort<string, string> requestPort = RequestPort.Create<string, string>("TestPort");
EchoExecutor echo = new();
Workflow workflow = new WorkflowBuilder(requestPort)
.AddEdge(requestPort, echo)
.Build();
VersionMutatingJsonStore store = new();
CheckpointManager checkpointManager = CheckpointManager.CreateJson(store);
InProcessExecutionEnvironment env = environment.ToWorkflowExecutionEnvironment();
// Run the workflow and capture a checkpoint.
CheckpointInfo? checkpoint = null;
await using (StreamingRun firstRun = await env.WithCheckpointing(checkpointManager)
.RunStreamingAsync(workflow, "Hello"))
{
await foreach (WorkflowEvent evt in firstRun.WatchStreamAsync(blockOnPendingRequest: false))
{
if (evt is SuperStepCompletedEvent step && step.CompletionInfo?.Checkpoint is { } cp)
{
checkpoint = cp;
}
}
}
checkpoint.Should().NotBeNull();
store.MutationApplied.Should().BeFalse();
// Resume against the mutated store, which rewrites every Version=X.Y.Z.W in the persisted JSON.
Func<Task> resume = async () =>
{
await using StreamingRun resumed = await env.WithCheckpointing(checkpointManager)
.ResumeStreamingAsync(workflow, checkpoint!);
using CancellationTokenSource cts = new(TimeSpan.FromSeconds(10));
await foreach (WorkflowEvent _ in resumed.WatchStreamAsync(blockOnPendingRequest: false, cts.Token))
{
}
};
await resume.Should().NotThrowAsync("resume must succeed when persisted assembly versions differ from loaded ones");
store.MutationApplied.Should().BeTrue();
}
/// <summary>
/// JSON checkpoint store that rewrites every <c>Version=N.N.N.N</c> token in the persisted
/// payload at retrieval time.
/// </summary>
private sealed class VersionMutatingJsonStore : JsonCheckpointStore
{
private static readonly Regex s_versionPattern = new(@"Version=\d+\.\d+\.\d+\.\d+", RegexOptions.Compiled);
private readonly Dictionary<string, Dictionary<string, JsonElement>> _store = [];
public string ReplacementVersion { get; init; } = "99.0.0.0";
public bool MutationApplied { get; private set; }
public override ValueTask<CheckpointInfo> CreateCheckpointAsync(string sessionId, JsonElement value, CheckpointInfo? parent = null)
{
if (!this._store.TryGetValue(sessionId, out Dictionary<string, JsonElement>? sessionStore))
{
sessionStore = this._store[sessionId] = [];
}
CheckpointInfo info = new(sessionId);
sessionStore[info.CheckpointId] = value.Clone();
return new ValueTask<CheckpointInfo>(info);
}
public override ValueTask<JsonElement> RetrieveCheckpointAsync(string sessionId, CheckpointInfo key)
{
if (!this._store.TryGetValue(sessionId, out Dictionary<string, JsonElement>? sessionStore)
|| !sessionStore.TryGetValue(key.CheckpointId, out JsonElement raw))
{
throw new KeyNotFoundException($"Could not retrieve checkpoint with id {key.CheckpointId} for session {sessionId}");
}
string rawText = raw.GetRawText();
string mutatedText = s_versionPattern.Replace(rawText, $"Version={this.ReplacementVersion}");
if (!ReferenceEquals(rawText, mutatedText) && rawText != mutatedText)
{
this.MutationApplied = true;
}
using JsonDocument doc = JsonDocument.Parse(mutatedText);
return new ValueTask<JsonElement>(doc.RootElement.Clone());
}
public override ValueTask<IEnumerable<CheckpointInfo>> RetrieveIndexAsync(string sessionId, CheckpointInfo? withParent = null)
{
if (!this._store.TryGetValue(sessionId, out Dictionary<string, JsonElement>? sessionStore))
{
return new ValueTask<IEnumerable<CheckpointInfo>>(Array.Empty<CheckpointInfo>());
}
IEnumerable<CheckpointInfo> infos = sessionStore.Keys.Select(id => new CheckpointInfo(sessionId, id));
return new ValueTask<IEnumerable<CheckpointInfo>>(infos);
}
}
}
@@ -0,0 +1,230 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using System.Collections.Generic;
using System.Diagnostics.CodeAnalysis;
using FluentAssertions;
using Microsoft.Agents.AI.Workflows.Checkpointing;
using Microsoft.Extensions.AI;
namespace Microsoft.Agents.AI.Workflows.UnitTests;
/// <summary>
/// Verifies that <see cref="TypeId.IsMatch(Type)"/> and <see cref="TypeId.Equals(object?)"/>
/// compare on the type full name and the simple assembly name, ignoring version, culture,
/// and public key token both in the outer assembly name and in any assembly-qualified generic
/// arguments embedded in the type full name.
/// </summary>
public class TypeIdVersionToleranceTests
{
[SuppressMessage("Performance", "CA1812", Justification = "Used via typeof() only; never instantiated.")]
private sealed class Probe
{
}
private static string ProbeSimpleAssemblyName => typeof(Probe).Assembly.GetName().Name!;
private static string ProbeTypeFullName => typeof(Probe).FullName!;
[Fact]
public void Test_IsMatch_RoundTripsRealType()
{
TypeId id = new(typeof(Probe));
id.IsMatch(typeof(Probe)).Should().BeTrue();
id.IsMatch<Probe>().Should().BeTrue();
}
[Fact]
public void Test_IsMatch_IgnoresAssemblyVersion()
{
string assemblyName = $"{ProbeSimpleAssemblyName}, Version=99.0.0.0, Culture=neutral, PublicKeyToken=null";
TypeId id = new(assemblyName, ProbeTypeFullName);
id.IsMatch(typeof(Probe)).Should().BeTrue("version differences in AssemblyName must not affect matching");
}
[Fact]
public void Test_IsMatch_IgnoresCultureAndPublicKeyToken()
{
string assemblyName = $"{ProbeSimpleAssemblyName}, Version=99.0.0.0, Culture=en-US, PublicKeyToken=abcdef0123456789";
TypeId id = new(assemblyName, ProbeTypeFullName);
id.IsMatch(typeof(Probe)).Should().BeTrue();
}
[Fact]
public void Test_IsMatch_AcceptsSimpleAssemblyNameOnly()
{
TypeId id = new(ProbeSimpleAssemblyName, ProbeTypeFullName);
id.IsMatch(typeof(Probe)).Should().BeTrue();
}
[Fact]
public void Test_IsMatch_RejectsDifferentSimpleAssemblyName()
{
TypeId id = new(
assemblyName: "Some.Completely.Different.Assembly, Version=1.0.0.0",
typeName: ProbeTypeFullName);
id.IsMatch(typeof(Probe)).Should().BeFalse("different simple assembly names must not match");
}
[Fact]
public void Test_IsMatch_RejectsDifferentTypeName()
{
TypeId id = new(
assemblyName: $"{ProbeSimpleAssemblyName}, Version=99.0.0.0",
typeName: "Some.Other.Namespace.Probe");
id.IsMatch(typeof(Probe)).Should().BeFalse("different type names must not match");
}
[Fact]
public void Test_IsMatch_ToleratesMalformedAssemblyName()
{
TypeId id = new(
assemblyName: $"{ProbeSimpleAssemblyName}, Version=not-a-version, Culture=??, PublicKeyToken=???",
typeName: ProbeTypeFullName);
id.IsMatch(typeof(Probe)).Should().BeTrue("the substring fallback recovers the simple name when AssemblyName parsing fails");
}
[Fact]
public void Test_IsMatchPolymorphic_IgnoresAssemblyVersion()
{
TypeId id = new(
assemblyName: $"{typeof(object).Assembly.GetName().Name}, Version=99.0.0.0",
typeName: typeof(object).FullName!);
id.IsMatchPolymorphic(typeof(Probe)).Should().BeTrue("IsMatchPolymorphic uses the same comparison rules as IsMatch");
}
[Fact]
public void Test_Equals_IgnoresAssemblyVersion()
{
TypeId v1 = new($"{ProbeSimpleAssemblyName}, Version=1.0.0.0", ProbeTypeFullName);
TypeId v2 = new($"{ProbeSimpleAssemblyName}, Version=2.0.0.0", ProbeTypeFullName);
v1.Equals(v2).Should().BeTrue();
(v1 == v2).Should().BeTrue();
v1.GetHashCode().Should().Be(v2.GetHashCode());
}
[Fact]
public void Test_Equals_RejectsDifferentSimpleAssemblyName()
{
TypeId a = new($"{ProbeSimpleAssemblyName}, Version=1.0.0.0", ProbeTypeFullName);
TypeId b = new("Some.Other.Assembly, Version=1.0.0.0", ProbeTypeFullName);
a.Equals(b).Should().BeFalse();
}
[Fact]
public void Test_Equals_RejectsDifferentTypeName()
{
TypeId a = new($"{ProbeSimpleAssemblyName}, Version=1.0.0.0", ProbeTypeFullName);
TypeId b = new($"{ProbeSimpleAssemblyName}, Version=1.0.0.0", "Some.Other.Type");
a.Equals(b).Should().BeFalse();
}
[Fact]
public void Test_Dictionary_LookupAcrossVersions()
{
TypeId live = new(typeof(Probe));
TypeId mutated = new(
assemblyName: $"{ProbeSimpleAssemblyName}, Version=99.0.0.0, Culture=neutral, PublicKeyToken=null",
typeName: ProbeTypeFullName);
Dictionary<TypeId, string> map = new() { [live] = "value" };
map.TryGetValue(mutated, out string? value).Should().BeTrue();
value.Should().Be("value");
HashSet<TypeId> set = new() { live };
set.Contains(mutated).Should().BeTrue();
}
[Fact]
public void Test_Equals_TreatsIdenticalStringsAsEqual()
{
TypeId a = new(typeof(Probe));
TypeId b = new(typeof(Probe));
a.Equals(b).Should().BeTrue();
a.GetHashCode().Should().Be(b.GetHashCode());
}
[Fact]
public void Test_NormalizeTypeName_ReturnsInputWhenNoAssemblyQualifier()
{
const string TypeName = "Microsoft.Agents.AI.Workflows.Checkpointing.TypeId";
TypeId.NormalizeTypeName(TypeName).Should().BeSameAs(TypeName);
}
[Fact]
public void Test_NormalizeTypeName_StripsVersionCultureAndPublicKeyTokenTriplets()
{
const string TypeName = "System.Collections.Generic.List`1[[Some.Type, Some.Asm, Version=1.2.3.4, Culture=neutral, PublicKeyToken=abcdef0123456789]]";
const string Expected = "System.Collections.Generic.List`1[[Some.Type, Some.Asm]]";
TypeId.NormalizeTypeName(TypeName).Should().Be(Expected);
}
[Fact]
public void Test_NormalizeTypeName_StripsTripletsFromNestedGenericArguments()
{
const string TypeName = "System.Collections.Generic.Dictionary`2[[System.String, mscorlib, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089],[System.Collections.Generic.List`1[[Some.Type, Some.Asm, Version=1.0.0.0, Culture=neutral, PublicKeyToken=null]], mscorlib, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089]]";
const string Expected = "System.Collections.Generic.Dictionary`2[[System.String, mscorlib],[System.Collections.Generic.List`1[[Some.Type, Some.Asm]], mscorlib]]";
TypeId.NormalizeTypeName(TypeName).Should().Be(Expected);
}
[Fact]
public void Test_IsMatch_IgnoresVersionInGenericArguments()
{
Type live = typeof(List<ChatMessage>);
string simpleAssemblyName = live.Assembly.GetName().Name!;
// Hand-craft a TypeName as if persisted under a different version of the generic
// argument assembly (Microsoft.Extensions.AI.Abstractions).
string innerArgSimpleName = typeof(ChatMessage).Assembly.GetName().Name!;
string mutatedTypeName = $"System.Collections.Generic.List`1[[Microsoft.Extensions.AI.ChatMessage, {innerArgSimpleName}, Version=99.0.0.0, Culture=neutral, PublicKeyToken=null]]";
TypeId id = new(simpleAssemblyName, mutatedTypeName);
id.IsMatch(live).Should().BeTrue("version differences inside generic argument names must not affect matching");
}
[Fact]
public void Test_Equals_IgnoresVersionInGenericArguments()
{
Type live = typeof(List<ChatMessage>);
TypeId fromLive = new(live);
string simpleAssemblyName = live.Assembly.GetName().Name!;
string innerArgSimpleName = typeof(ChatMessage).Assembly.GetName().Name!;
string mutatedTypeName = $"System.Collections.Generic.List`1[[Microsoft.Extensions.AI.ChatMessage, {innerArgSimpleName}, Version=99.0.0.0, Culture=neutral, PublicKeyToken=null]]";
TypeId fromMutated = new(simpleAssemblyName, mutatedTypeName);
fromLive.Equals(fromMutated).Should().BeTrue();
fromLive.GetHashCode().Should().Be(fromMutated.GetHashCode());
}
[Fact]
public void Test_Dictionary_LookupAcrossGenericArgumentVersions()
{
Type live = typeof(List<ChatMessage>);
TypeId fromLive = new(live);
string simpleAssemblyName = live.Assembly.GetName().Name!;
string innerArgSimpleName = typeof(ChatMessage).Assembly.GetName().Name!;
string mutatedTypeName = $"System.Collections.Generic.List`1[[Microsoft.Extensions.AI.ChatMessage, {innerArgSimpleName}, Version=99.0.0.0, Culture=neutral, PublicKeyToken=null]]";
TypeId fromMutated = new(simpleAssemblyName, mutatedTypeName);
Dictionary<TypeId, string> map = new() { [fromLive] = "value" };
map.TryGetValue(fromMutated, out string? value).Should().BeTrue();
value.Should().Be("value");
}
}
@@ -0,0 +1,67 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using System.Collections.Generic;
using System.Diagnostics.CodeAnalysis;
using FluentAssertions;
using Microsoft.Agents.AI.Workflows.Checkpointing;
using Microsoft.Extensions.AI;
namespace Microsoft.Agents.AI.Workflows.UnitTests;
/// <summary>
/// Verifies that <see cref="WorkflowSession.ResolveTypeLenient(TypeId)"/> resolves a
/// <see cref="TypeId"/> to a loaded <see cref="Type"/> even when the stored assembly
/// name carries a different <c>Version=</c> than the loaded assembly.
/// </summary>
public class WorkflowSessionResolveTypeLenientTests
{
[SuppressMessage("Performance", "CA1812", Justification = "Instantiated via Type.GetType in the production code path under test.")]
private sealed class TestEnvelope : IExternalRequestEnvelope
{
AIContent? IExternalRequestEnvelope.GetInnerRequestContent() => null;
object IExternalRequestEnvelope.CreateResponse(IList<ChatMessage> messages) => messages;
}
[Fact]
public void Test_ResolveTypeLenient_ResolvesWhenAssemblyNameMatchesLoadedVersion()
{
Type live = typeof(TestEnvelope);
TypeId id = new(live);
WorkflowSession.ResolveTypeLenient(id).Should().Be(live);
}
[Fact]
public void Test_ResolveTypeLenient_ResolvesAcrossAssemblyVersionMutation()
{
Type live = typeof(TestEnvelope);
string simpleAssemblyName = live.Assembly.GetName().Name!;
string mutatedAssemblyName = $"{simpleAssemblyName}, Version=99.0.0.0, Culture=neutral, PublicKeyToken=null";
TypeId mutated = new(mutatedAssemblyName, live.FullName!);
WorkflowSession.ResolveTypeLenient(mutated).Should().Be(live);
}
[Fact]
public void Test_ResolveTypeLenient_ReturnsNullForUnknownType()
{
TypeId id = new("Some.Unloaded.Assembly", "Some.Unknown.Type");
WorkflowSession.ResolveTypeLenient(id).Should().BeNull();
}
[Fact]
public void Test_ResolveTypeLenient_ResolvesAcrossGenericArgumentVersionMutation()
{
Type live = typeof(List<ChatMessage>);
string outerSimpleName = live.Assembly.GetName().Name!;
string innerSimpleName = typeof(ChatMessage).Assembly.GetName().Name!;
string mutatedTypeName = $"System.Collections.Generic.List`1[[Microsoft.Extensions.AI.ChatMessage, {innerSimpleName}, Version=99.0.0.0, Culture=neutral, PublicKeyToken=null]]";
TypeId mutated = new(outerSimpleName, mutatedTypeName);
WorkflowSession.ResolveTypeLenient(mutated).Should().Be(live);
}
}
-1
View File
@@ -1 +0,0 @@
../../../.github/skills/pull-requests
+116
View File
@@ -0,0 +1,116 @@
---
name: pull-requests
description: >
Guidance for creating pull requests and handling PR review comments in the
Agent Framework repository. Use this when writing a PR description (filling out
the PR template) or when responding to and resolving review comments on an
existing PR.
---
# Pull Request Workflow
This skill covers two tasks: (1) writing a high-quality PR description, and
(2) handling review comments on an existing PR.
## 1. Writing the PR description
Always follow the repository PR template at
[`.github/pull_request_template.md`](../../../../.github/pull_request_template.md). Keep its
exact structure and headings. Fill every section:
### `### Motivation & Context`
Explain *why* the change is needed: the problem it solves and the scenario it
contributes to. Describe the net change relative to `main` — this is implied, so
do **not** spell out "vs main" explicitly.
### `### Description & Review Guide`
Describe the changes, the overall approach, and the design. Answer the three
prompts:
- **What are the major changes?**
- **What is the impact of these changes?**
- **What do you want reviewers to focus on?** — This item is for **human
reviewers only**. Automated/AI reviewers must ignore it and review the entire
change rather than narrowing scope to it.
### `### Related Issue`
Link the issue the PR fixes using a GitHub closing keyword (`Fixes #123` /
`Closes #123`) so it closes automatically on merge. A PR with no linked issue may
be closed regardless of how valid the change is. Before opening, confirm there is
no other open PR for the same issue; if there is, explain how this PR differs.
### `### Contribution Checklist`
Check every item that applies. For the breaking-change item:
- Leave **"This is not a breaking change."** checked for the common case.
- If the change **is** breaking, add the `breaking change` label **or** put
`[BREAKING]` in the title prefix, before or after a language prefix such as
`Python:` or `.NET:` — workflows keep the label and the title prefix in sync
automatically (see `.github/workflows/label-title-prefix.yml` and
`.github/workflows/label-pr.yml`).
### Do not
- Do **not** add ad-hoc sections such as "Validation" or "Tests run"; CI/CD and
the checklist already cover validation status.
- Do **not** remove or reorder the template's headings.
### Creating the PR
Open new PRs as **drafts** until they are ready for review. Example:
```bash
gh pr create --repo microsoft/agent-framework --base main \
--head <your-fork-owner>:<branch> --draft \
--title "<concise title>" --body "<body following the template>"
```
## 2. Handling review comments
When a PR receives review comments, follow this sequence — **do not start editing
code before the user has reviewed the plan**:
1. **Review the comments.** Read every review comment and thread on the PR,
including inline code comments and general review summaries.
2. **Make a plan.** Produce a concrete plan describing how each comment will be
addressed (or why it should not be, with reasoning).
3. **Let the user review the plan.** Present the plan and wait for the user's
approval or adjustments before implementing anything.
4. **Implement.** Make the agreed changes.
5. **Reply to every comment.** Add a reply to **all** comments explaining how it
was addressed (or the agreed outcome) — leave none unanswered.
6. **Resolve resolved threads.** Mark a review thread as resolved only when the
comment has actually been addressed.
### Useful commands
List review comments and threads:
```bash
# Inline review comments
gh api repos/{owner}/{repo}/pulls/{pr}/comments
# Review threads with resolution state (GraphQL)
gh api graphql -f query='
query($owner:String!,$repo:String!,$pr:Int!){
repository(owner:$owner,name:$repo){
pullRequest(number:$pr){
reviewThreads(first:100){
nodes{ id isResolved comments(first:50){ nodes{ id body author{login} } } }
}
}
}
}' -F owner={owner} -F repo={repo} -F pr={pr}
```
Reply to an inline review comment:
```bash
gh api repos/{owner}/{repo}/pulls/{pr}/comments/{comment_id}/replies \
-f body="Addressed in <commit>: <explanation>"
```
Resolve a review thread (needs the thread node id from the GraphQL query above):
```bash
gh api graphql -f query='
mutation($threadId:ID!){
resolveReviewThread(input:{threadId:$threadId}){ thread{ isResolved } }
}' -F threadId={thread_id}
```
+2
View File
@@ -34,6 +34,8 @@ Status is grouped into these buckets:
| `agent-framework-foundry-local` | `python/packages/foundry_local` | `beta` |
| `agent-framework-gemini` | `python/packages/gemini` | `alpha` |
| `agent-framework-github-copilot` | `python/packages/github_copilot` | `rc` |
| `agent-framework-hosting` | `python/packages/hosting` | `alpha` |
| `agent-framework-hosting-responses` | `python/packages/hosting-responses` | `alpha` |
| `agent-framework-hyperlight` | `python/packages/hyperlight` | `beta` |
| `agent-framework-lab` | `python/packages/lab` | `beta` |
| `agent-framework-mem0` | `python/packages/mem0` | `beta` |
+4 -3
View File
@@ -80,8 +80,9 @@ agent_framework/
- **`MCPTool`** - Base wrapper that owns the MCP `ClientSession` and exposes the remote server's tools as `FunctionTool`s.
- **`MCPStdioTool`** / **`MCPStreamableHTTPTool`** / **`MCPWebsocketTool`** - Transport-specific subclasses.
- **Argument allowlist (`_prepare_call_kwargs`)** - Before each `tools/call`, kwargs are filtered to an **allowlist** built from the tool's declared parameters (`inputSchema.properties`) plus any user-configured extras. Framework runtime kwargs injected through the function-invocation pipeline (e.g. `thread`, `conversation_id`, `chat_options`, `options`, `response_format`) are stripped by default rather than forwarded. A tool that declares no usable `properties` (including schemas with `additionalProperties: true`) forwards only the configured extras. The `_MCP_FRAMEWORK_DENYLIST` is a safety net for framework-named params a server *declares* in its schema (those are dropped); names explicitly opted in via `additional_tool_argument_names` always win. The reserved `_meta` key is extracted as MCP request metadata, never forwarded as an argument.
- **`additional_tool_argument_names`** (constructor arg on all `MCPTool` subclasses) - Opt extra argument names back into the allowlist. Accepts a `Sequence[str]` (applied to every tool) or a `Mapping[str, Sequence[str]]` keyed by **remote tool name**, where the reserved key `"*"` denotes global extras. It is configured only in user code at construction; there is **no per-call/runtime override**, so a model-issued tool call cannot change which names pass through. To use a server that accepts `additionalProperties: true`, list the extra names here and then either (1) manually extend that tool's `inputSchema` (via the `.functions` list after connecting) so the model is prompted to supply them, or (2) supply the values yourself via `function_invocation_kwargs`. If a name is supplied by both the model and `function_invocation_kwargs`, the model-supplied value wins.
- **Argument allowlist (`_prepare_call_kwargs`)** - Before each `tools/call`, kwargs are filtered to an **allowlist** built from the tool's declared parameters (`inputSchema.properties`) plus any user-configured extras. Framework runtime kwargs injected through the function-invocation pipeline (e.g. `thread`, `conversation_id`, `chat_options`, `options`, `response_format`) are stripped by default rather than forwarded. A tool that declares no usable `properties` (including schemas with `additionalProperties: true`) forwards only the configured extras. The `_MCP_FRAMEWORK_DENYLIST` is a safety net for framework-named params a server *declares* in its schema (those are dropped); names explicitly opted in via `additional_tool_argument_names` always win. The reserved `_meta` key is never forwarded as an argument; trusted caller/runtime `_meta` is validated as MCP request metadata, model-supplied `_meta` is discarded in generated MCP functions, and metadata precedence is caller/runtime < OpenTelemetry < tools/list metadata.
- **`allowed_tools`** (constructor arg on all `MCPTool` subclasses) - Restricts exposed MCP tools by raw remote MCP tool identity. Prefixed local names remain accepted only when the raw remote name already matches its normalized form; normalized/local aliases do not authorize a different raw remote name. If multiple raw remote tool names map to the same local function name, tool loading raises `ToolExecutionException` instead of first-one-wins shadowing.
- **`additional_tool_argument_names`** (constructor arg on all `MCPTool` subclasses) - Opt extra argument names back into the allowlist. Accepts a `Sequence[str]` (applied to every tool) or a `Mapping[str, Sequence[str]]` keyed by **remote tool name**, where the reserved key `"*"` denotes global extras. It is configured only in user code at construction; there is **no per-call/runtime override**, so a model-issued tool call cannot change which names pass through. To use a server that accepts `additionalProperties: true`, list the extra names here and then either (1) manually extend that tool's `inputSchema` (via the `.functions` list after connecting) so the model is prompted to supply them, or (2) supply the values yourself via `function_invocation_kwargs`. If a normal forwarded argument name is supplied by both the model and `function_invocation_kwargs`, the model-supplied value wins; `_meta` is the exception and only trusted runtime/caller metadata is used.
- **Sampling guardrails** (`sampling_callback`) - Passing `client=` advertises `SamplingCapability` so the server can send `sampling/createMessage`. Because remote servers are untrusted (confused-deputy risk), the default `sampling_callback` is **deny-by-default** and applies, in order: a per-session rate limit (`sampling_max_requests`, default `_DEFAULT_SAMPLING_MAX_REQUESTS`), an approval gate (`sampling_approval_callback`), and a `maxTokens` cap (`sampling_max_tokens`, default `_DEFAULT_SAMPLING_MAX_TOKENS`). The approval callback (constructor arg on all subclasses; exported type alias `SamplingApprovalCallback`) receives the raw `CreateMessageRequestParams`, may be sync or async, and must return truthy to approve. When it is `None` (the default) every sampling request is denied; pass `lambda params: True` to restore legacy auto-approve as an explicit opt-in. Requests and denials are logged at WARNING (content is not logged). The per-session counter resets in `_reset_session_state`.
- **`MCPTaskOptions`** (experimental, `MCP_LONG_RUNNING_TASKS` feature, **frozen**) - Per-tool-instance options controlling the SEP-2663 long-running task lifecycle. When the server advertises a tool with `execution.taskSupport == "required"`, `MCPTool.call_tool` transparently routes through `call_tool_as_task`, which sends an augmented `tools/call`, polls `tasks/get` until terminal, and reinterprets `tasks/result` as a normal `CallToolResult`. Instances are immutable; replace via `MCPTool.task_options = MCPTaskOptions(...)`. Fields:
- `default_ttl: timedelta | None` — forwarded to the server as `params.task.ttl` (milliseconds). When `None`, the server's default applies.
@@ -98,7 +99,7 @@ agent_framework/
- **`InMemoryAgentFileStore`** - Dict-backed store suitable for tests and lightweight scenarios.
- **`FileSystemAgentFileStore`** - Disk-backed store rooted under a configurable directory. Enforces relative-path normalization, root containment, and rejects symlink/reparse-point segments to prevent escape.
- **`FileSearchResult`** / **`FileSearchMatch`** - `SerializationMixin` DTOs returned by `search_files`, carrying the matching file name, a context snippet, and the matching lines with 1-based line numbers.
- **`FileAccessProvider`** - `ContextProvider` that adds shared file-access tools (`file_access_save_file`, `file_access_read_file`, `file_access_delete_file`, `file_access_list_files`, `file_access_list_subdirectories`, `file_access_search_files`) plus default usage instructions to each invocation. `file_access_list_files`/`file_access_list_subdirectories` enumerate direct children (files / subdirectories) so the agent can walk the tree level by level; `file_access_search_files` searches recursively from the store root and returns store-root-relative `file_name` paths, scoped via an `fnmatch` glob (where `*` crosses `/`, e.g. `*.md`, `reports/*`). Unlike `MemoryContextProvider`, the store is intentionally shared across sessions and agents.
- **`FileAccessProvider`** - `ContextProvider` that adds shared file-access tools (`file_access_save_file`, `file_access_read_file`, `file_access_delete_file`, `file_access_list_files`, `file_access_list_subdirectories`, `file_access_search_files`) plus default usage instructions to each invocation. `file_access_list_files`/`file_access_list_subdirectories` enumerate direct children (files / subdirectories) so the agent can walk the tree level by level; `file_access_search_files` searches recursively from the store root and returns store-root-relative `file_name` paths, scoped via an `fnmatch` glob (where `*` crosses `/`, e.g. `*.md`, `reports/*`). All six tools are registered with `approval_mode="always_require"`, so every file operation needs host approval. To run unattended, pass one of the static auto-approval rules to `ToolApprovalMiddleware` (via `auto_approval_rules`): `FileAccessProvider.read_only_tools_auto_approval_rule` approves only the read-only tools (read, list files, list subdirectories, search), while `FileAccessProvider.all_tools_auto_approval_rule` approves every file-access tool including save and delete. Both rules reject any call carrying a `server_label` so they stay scoped to this provider's local tools and never auto-approve a same-named hosted tool. The tool names are also exposed as class constants (`SAVE_FILE_TOOL_NAME`, `READ_FILE_TOOL_NAME`, `DELETE_FILE_TOOL_NAME`, `LIST_FILES_TOOL_NAME`, `LIST_SUBDIRECTORIES_TOOL_NAME`, `SEARCH_FILES_TOOL_NAME`). Unlike `MemoryContextProvider`, the store is intentionally shared across sessions and agents.
### File Memory Harness (`_harness/_file_memory.py`)
@@ -9,7 +9,7 @@ integrations, many of which are lazy-loaded from optional packages.
"""
import importlib.metadata
from typing import Final
from typing import TYPE_CHECKING, Any, Final
try:
_version = importlib.metadata.version(__name__)
@@ -264,6 +264,7 @@ from ._workflows._agent_executor import (
)
from ._workflows._agent_utils import resolve_agent_id
from ._workflows._checkpoint import (
CheckpointID,
CheckpointStorage,
FileCheckpointStorage,
InMemoryCheckpointStorage,
@@ -307,7 +308,6 @@ from ._workflows._functional import (
workflow,
)
from ._workflows._request_info_mixin import response_handler
from ._workflows._runner import Runner
from ._workflows._runner_context import (
InProcRunnerContext,
RunnerContext,
@@ -405,6 +405,7 @@ __all__ = [
"ChatResponse",
"ChatResponseUpdate",
"CheckResult",
"CheckpointID",
"CheckpointStorage",
"ClassSkill",
"CompactionProvider",
@@ -618,3 +619,20 @@ __all__ = [
"validate_workflow_graph",
"workflow",
]
if TYPE_CHECKING:
from ._workflows._runner import Runner
def __getattr__(name: str) -> Any:
"""Lazily resolve deprecated public names, emitting a ``DeprecationWarning``.
``Runner`` remains importable from ``agent_framework`` for backward
compatibility but is deprecated and slated for removal from the public API.
"""
if name == "Runner":
from ._workflows._runner import Runner, warn_runner_deprecated
warn_runner_deprecated()
return Runner
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
@@ -37,7 +37,8 @@ from pydantic import BaseModel, Field
from .._feature_stage import ExperimentalFeature, experimental
from .._serialization import SerializationMixin
from .._sessions import AgentSession, ContextProvider, SessionContext
from .._tools import ApprovalMode, tool
from .._tools import tool
from .._types import Content
logger = logging.getLogger(__name__)
@@ -1075,15 +1076,72 @@ class FileAccessProvider(ContextProvider):
contents are visible across sessions and agents. The store is passed in by
the caller and should already be scoped to the desired folder or storage
location.
All six tools always require approval: each is registered with
``approval_mode="always_require"`` so the host must approve every file
operation the model proposes. In the auto-invocation flow this means the
model's calls to these tools are converted into
``function_approval_request`` items and the tool does **not** execute until
the host supplies a matching ``function_approval_response``. Consumers that
use the base agent directly must install
:class:`~agent_framework.ToolApprovalMiddleware` (or use
:func:`~agent_framework.create_harness_agent`, which wires it in by default)
to drive that handshake; otherwise these tools never run. To run unattended,
supply one of the static auto-approval rules to
:class:`~agent_framework.ToolApprovalMiddleware` via its
``auto_approval_rules``:
- :meth:`read_only_tools_auto_approval_rule` — auto-approves only the
read-only tools (read, list files, list subdirectories, search), while
still prompting for the tools that modify the store (save and delete).
- :meth:`all_tools_auto_approval_rule` — auto-approves every file-access
tool, including save and delete.
For example, to auto-approve only the read-only tools::
create_harness_agent(
chat_client,
auto_approval_rules=[FileAccessProvider.read_only_tools_auto_approval_rule],
)
"""
#: Name of the tool that saves a file.
SAVE_FILE_TOOL_NAME = "file_access_save_file"
#: Name of the tool that reads a file.
READ_FILE_TOOL_NAME = "file_access_read_file"
#: Name of the tool that deletes a file.
DELETE_FILE_TOOL_NAME = "file_access_delete_file"
#: Name of the tool that lists the files in a directory.
LIST_FILES_TOOL_NAME = "file_access_list_files"
#: Name of the tool that lists the subdirectories of a directory.
LIST_SUBDIRECTORIES_TOOL_NAME = "file_access_list_subdirectories"
#: Name of the tool that searches file contents.
SEARCH_FILES_TOOL_NAME = "file_access_search_files"
#: Names of the tools that only read from (never modify) the file store.
_READ_ONLY_TOOL_NAMES: frozenset[str] = frozenset({
READ_FILE_TOOL_NAME,
LIST_FILES_TOOL_NAME,
LIST_SUBDIRECTORIES_TOOL_NAME,
SEARCH_FILES_TOOL_NAME,
})
#: Names of all tools exposed by this provider.
_ALL_TOOL_NAMES: frozenset[str] = frozenset({
SAVE_FILE_TOOL_NAME,
READ_FILE_TOOL_NAME,
DELETE_FILE_TOOL_NAME,
LIST_FILES_TOOL_NAME,
LIST_SUBDIRECTORIES_TOOL_NAME,
SEARCH_FILES_TOOL_NAME,
})
def __init__(
self,
store: AgentFileStore,
*,
source_id: str = DEFAULT_FILE_ACCESS_SOURCE_ID,
instructions: str | None = None,
require_delete_approval: bool = True,
) -> None:
"""Initialize the file access provider.
@@ -1096,17 +1154,78 @@ class FileAccessProvider(ContextProvider):
source_id: Unique source ID for the provider.
instructions: Optional instruction override. When ``None`` the
default file-access instructions are used.
require_delete_approval: When ``True`` (the default) the
``file_access_delete_file`` tool is registered with
``approval_mode="always_require"`` so the host must approve every
delete the model proposes. Set to ``False`` to opt out and allow
the agent to delete files autonomously (matching the .NET
``FileAccessProvider``, which has no approval mechanism).
"""
super().__init__(source_id)
self.store = store
self.instructions = instructions or DEFAULT_FILE_ACCESS_INSTRUCTIONS
self.require_delete_approval = require_delete_approval
@staticmethod
def _is_local_tool_call(function_call: Content) -> bool:
"""Return whether a function call targets this provider's local tools.
Hosted-tool calls carry a ``server_label`` in their
``additional_properties`` and are a separate server-scoped approval
boundary that must be passed through untouched (see
:func:`agent_framework._tools._is_hosted_tool_approval`). These rules
only ever auto-approve the provider's own local tools, so any call that
carries a ``server_label`` is rejected even if its name collides with a
file-access tool name.
"""
return not function_call.additional_properties.get("server_label")
@staticmethod
def read_only_tools_auto_approval_rule(function_call: Content) -> bool:
"""Auto-approval rule that approves only the read-only file-access tools.
The tools exposed by :class:`FileAccessProvider` always require approval.
Pass this rule to :class:`~agent_framework.ToolApprovalMiddleware` (via
``auto_approval_rules``) to automatically approve the tools that read
from the store (``file_access_read_file``, ``file_access_list_files``,
``file_access_list_subdirectories``, and ``file_access_search_files``),
while still prompting for the tools that modify it
(``file_access_save_file`` and ``file_access_delete_file``).
Hosted-tool calls (those carrying a ``server_label``) are never
auto-approved, even when their name matches a file-access tool, so the
rule stays scoped to this provider's local tools.
Args:
function_call: The pending ``function_call`` content.
Returns:
``True`` for read-only file-access tools, ``False`` otherwise so that
subsequent rules continue to be evaluated.
"""
return (
FileAccessProvider._is_local_tool_call(function_call)
and function_call.name in FileAccessProvider._READ_ONLY_TOOL_NAMES
)
@staticmethod
def all_tools_auto_approval_rule(function_call: Content) -> bool:
"""Auto-approval rule that approves every file-access tool.
The tools exposed by :class:`FileAccessProvider` always require approval.
Pass this rule to :class:`~agent_framework.ToolApprovalMiddleware` (via
``auto_approval_rules``) to automatically approve every file-access tool,
including the tools that modify the store (``file_access_save_file`` and
``file_access_delete_file``).
Hosted-tool calls (those carrying a ``server_label``) are never
auto-approved, even when their name matches a file-access tool, so the
rule stays scoped to this provider's local tools.
Args:
function_call: The pending ``function_call`` content.
Returns:
``True`` for any file-access tool, ``False`` otherwise so that
subsequent rules continue to be evaluated.
"""
return (
FileAccessProvider._is_local_tool_call(function_call)
and function_call.name in FileAccessProvider._ALL_TOOL_NAMES
)
async def before_run(
self,
@@ -1118,7 +1237,7 @@ class FileAccessProvider(ContextProvider):
) -> None:
"""Inject file-access tools and instructions before the model runs."""
@tool(name="file_access_save_file", schema=_SaveFileInput, approval_mode="never_require")
@tool(name=FileAccessProvider.SAVE_FILE_TOOL_NAME, schema=_SaveFileInput, approval_mode="always_require")
async def file_access_save_file(file_name: str, content: str, overwrite: bool = False) -> str:
"""Save a file with the given name and content. By default, does not overwrite an existing file unless overwrite is set to true.""" # noqa: E501
try:
@@ -1132,7 +1251,7 @@ class FileAccessProvider(ContextProvider):
return f"Could not save file '{file_name}': {exc.strerror or exc}"
return f"File '{file_name}' saved."
@tool(name="file_access_read_file", schema=_ReadFileInput, approval_mode="never_require")
@tool(name=FileAccessProvider.READ_FILE_TOOL_NAME, schema=_ReadFileInput, approval_mode="always_require")
async def file_access_read_file(file_name: str) -> str:
"""Read the content of a file by name. Returns the file content or a message indicating the file could not be read.""" # noqa: E501
try:
@@ -1144,9 +1263,7 @@ class FileAccessProvider(ContextProvider):
return f"Could not read file '{file_name}': {exc.strerror or exc}"
return content if content is not None else f"File '{file_name}' not found."
delete_approval_mode: ApprovalMode = "always_require" if self.require_delete_approval else "never_require"
@tool(name="file_access_delete_file", schema=_DeleteFileInput, approval_mode=delete_approval_mode)
@tool(name=FileAccessProvider.DELETE_FILE_TOOL_NAME, schema=_DeleteFileInput, approval_mode="always_require")
async def file_access_delete_file(file_name: str) -> str:
"""Delete a file by name."""
try:
@@ -1158,7 +1275,7 @@ class FileAccessProvider(ContextProvider):
return f"Could not delete file '{file_name}': {exc.strerror or exc}"
return f"File '{file_name}' deleted." if deleted else f"File '{file_name}' not found."
@tool(name="file_access_list_files", schema=_ListFilesInput, approval_mode="never_require")
@tool(name=FileAccessProvider.LIST_FILES_TOOL_NAME, schema=_ListFilesInput, approval_mode="always_require")
async def file_access_list_files(directory: str | None = None) -> list[str] | str:
"""List the direct child file names of a directory. Omit ``directory`` (or pass an empty string) to list the root. To enumerate files in a subdirectory, pass its relative path, for example ``"reports"`` or ``"reports/2024"``.""" # noqa: E501
target = directory if directory and directory.strip() else ""
@@ -1169,7 +1286,11 @@ class FileAccessProvider(ContextProvider):
except OSError as exc:
return f"Could not list directory '{directory or ''}': {exc.strerror or exc}"
@tool(name="file_access_list_subdirectories", schema=_ListSubdirectoriesInput, approval_mode="never_require")
@tool(
name=FileAccessProvider.LIST_SUBDIRECTORIES_TOOL_NAME,
schema=_ListSubdirectoriesInput,
approval_mode="always_require",
)
async def file_access_list_subdirectories(directory: str | None = None) -> list[str] | str:
"""List the direct child subdirectory names of a directory.
@@ -1186,7 +1307,7 @@ class FileAccessProvider(ContextProvider):
except OSError as exc:
return f"Could not list directory '{directory or ''}': {exc.strerror or exc}"
@tool(name="file_access_search_files", schema=_SearchFilesInput, approval_mode="never_require")
@tool(name=FileAccessProvider.SEARCH_FILES_TOOL_NAME, schema=_SearchFilesInput, approval_mode="always_require")
async def file_access_search_files(
regex_pattern: str,
file_pattern: str | None = None,
@@ -15,10 +15,7 @@ DEFAULT_MODE_SOURCE_ID = "agent_mode"
DEFAULT_MODE_INSTRUCTIONS = (
"## Agent Mode\n\n"
"- You can operate in different modes. Depending on the mode you are in, "
"you will be required to follow different processes.\n"
"- You must check the current mode after any user input, since the user may have changed the mode themselves, "
"e.g. the user may have switched to 'plan' mode after a previous research task finished in 'execute' mode, "
"meaning they want to review a plan first before execution.\n\n"
"you will be required to follow different processes.\n\n"
"Use the mode_get tool to check your current operating mode.\n"
"Use the mode_set tool to switch between modes as your work progresses. "
"Only use mode_set if the user explicitly instructs/allows you to change modes.\n\n"
@@ -56,9 +53,12 @@ DEFAULT_MODE_DESCRIPTIONS: dict[str, str] = {
"and follow the steps for *Execute mode*."
),
"execute": (
"Use this mode when carrying out approved plans. Work autonomously using your best judgment — do not ask "
"the user questions or wait for feedback.\n\n"
"Process to follow when in execute mode:\n"
"Determine the type of ask:\n"
"1. Simple question that doesn't require any further work to answer.\n"
"2. Any other work, including complex user request that requires a multi-step process to satisfy.\n\n"
"If 1. just answer the question directly.\n"
"If 2. Work autonomously using your best judgment — do not ask the user questions or wait for feedback "
"and follow the following process:\n"
"1. If you don't have a plan or tasks yet, analyze the user request and create tasks and a plan. "
"(**Skip this step if you came from plan mode**)\n"
"2. Work autonomously — use your best judgment to make decisions and keep progressing without asking "
@@ -24,15 +24,19 @@ DEFAULT_TODO_SOURCE_ID = "todo"
DEFAULT_TODO_INSTRUCTIONS = (
"## Todo Items\n\n"
"You have access to a todo list for tracking work items.\n"
"While planning, make sure that you break down complex tasks into manageable todo items "
"and add them to the list.\n"
"When a user asks you to perform a task, follow these steps to manage your work:\n"
"1. Determine whether the ask requires multiple steps to complete (complex) or can be completed "
"using a single step (simple).\n"
"2. If complex, turn the task into manageable todo items and add them to the list.\n"
"3. If simple, don't add a todo item, but rather just complete the task directly.\n\n"
"### General TODO Guidelines\n"
"Ask questions from the user where clarification is needed to create effective todos.\n"
"If the user provides feedback on your plan, adjust your todos accordingly by adding new items "
"or removing irrelevant ones.\n"
"During execution, use the todo list to keep track of what needs to be done, "
"mark items as complete when finished, and remove any items that are no longer needed.\n"
"When a user changes the topic or changes their mind, ensure that you update the todo list accordingly "
"by removing irrelevant items or adding new ones as needed.\n\n"
"When a user changes the topic, changes their mind or switches to a new request, ensure that you update "
"the todo list accordingly by removing irrelevant/old items, clearing the list, or adding new ones as needed.\n\n"
"Use these tools to manage your tasks:\n"
"- Use todos_add to break down complex work into trackable items (supports adding one or many at once).\n"
"- Use todos_complete to mark items as done when finished (supports one or many at once). "
+99 -35
View File
@@ -74,6 +74,11 @@ _MCP_NORMALIZED_NAME_KEY = "_mcp_normalized_name"
# Reserved key in an ``additional_tool_argument_names`` mapping that applies its
# values to every tool on the server rather than a single named tool.
_MCP_GLOBAL_EXTRA_ARGS_KEY = "*"
_MCP_META_LABEL_PATTERN = r"[A-Za-z](?:[A-Za-z0-9-]*[A-Za-z0-9])?"
_MCP_META_KEY_PATTERN = re.compile(
rf"^(?:(?:{_MCP_META_LABEL_PATTERN})(?:\.{_MCP_META_LABEL_PATTERN})*/)?"
r"[A-Za-z0-9](?:[A-Za-z0-9_.-]*[A-Za-z0-9])?$"
)
# Framework kwargs that flow through the function-invocation pipeline (via
# ``FunctionInvocationContext.kwargs``) but must never be forwarded to an MCP
# server: they are internal objects that the MCP SDK cannot serialize. They are
@@ -205,7 +210,42 @@ def _normalize_additional_tool_argument_names(
return set(additional_tool_argument_names), {}
def _inject_otel_into_mcp_meta(meta: dict[str, Any] | None = None) -> dict[str, Any] | None:
def _mcp_config_candidate_names(*, local_name: str, normalized_name: str, remote_name: str) -> tuple[str, ...]:
"""Return safe configuration names for MCP allow/approval matching."""
names = [remote_name]
if normalized_name == remote_name and local_name != remote_name:
names.append(local_name)
return tuple(names)
def _validate_mcp_meta_key(key: str) -> None:
"""Validate an MCP ``_meta`` key against the 2025-06-18 key-name format."""
if not _MCP_META_KEY_PATTERN.fullmatch(key):
raise ToolExecutionException(f"Invalid MCP _meta key name: {key!r}.")
def _validate_mcp_meta(raw_meta: object | None) -> dict[str, Any] | None:
"""Validate and copy MCP request metadata."""
if raw_meta is None:
return None
if not isinstance(raw_meta, dict):
raise ToolExecutionException("MCP tool metadata provided via _meta must be a dict.")
raw_meta_dict = cast(Mapping[object, Any], raw_meta)
meta: dict[str, Any] = {}
for key, value in raw_meta_dict.items():
if not isinstance(key, str):
raise ToolExecutionException("MCP tool metadata provided via _meta must use string keys.")
_validate_mcp_meta_key(key)
meta[key] = value
return meta
def _inject_otel_into_mcp_meta(
meta: dict[str, Any] | None = None,
*,
overwrite: bool = False,
) -> dict[str, Any] | None:
"""Inject OpenTelemetry trace context into MCP request _meta via the global propagator(s)."""
carrier: dict[str, str] = {}
propagate.inject(carrier)
@@ -215,7 +255,8 @@ def _inject_otel_into_mcp_meta(meta: dict[str, Any] | None = None) -> dict[str,
if meta is None:
meta = {}
for key, value in carrier.items():
if key not in meta:
_validate_mcp_meta_key(key)
if overwrite or key not in meta:
meta[key] = value
return meta
@@ -381,7 +422,9 @@ class MCPTool:
approval_mode: Whether approval is required to run tools.
allowed_tools: Optional allow-list of MCP tool names to expose as functions.
``None`` (the default) exposes every tool advertised by the MCP server.
A non-empty collection exposes only the tools whose names appear in it.
A non-empty collection exposes only the raw remote tools whose names appear in it. For
compatibility, the prefixed local function name is also accepted when the raw remote name already
matches its normalized form; normalized aliases do not authorize a different raw remote tool.
An empty collection (``[]``) exposes no tools — if you simply want to
disable tool execution, prefer ``load_tools=False`` instead. ``[]`` is
useful as a runtime guard or when you want to load tool metadata for
@@ -753,11 +796,14 @@ class MCPTool:
additional_properties = func.additional_properties or {}
normalized_name = additional_properties.get(_MCP_NORMALIZED_NAME_KEY)
remote_name = additional_properties.get(_MCP_REMOTE_NAME_KEY)
if (
func.name in allowed_names
or (isinstance(normalized_name, str) and normalized_name in allowed_names)
or (isinstance(remote_name, str) and remote_name in allowed_names)
):
if not isinstance(normalized_name, str) or not isinstance(remote_name, str):
continue
candidate_names = _mcp_config_candidate_names(
local_name=func.name,
normalized_name=normalized_name,
remote_name=remote_name,
)
if any(name in allowed_names for name in candidate_names):
filtered_functions.append(func)
return filtered_functions
@@ -1381,7 +1427,13 @@ class MCPTool:
continue
input_model = _get_input_model_from_mcp_prompt(prompt)
approval_mode = self._determine_approval_mode(local_name, normalized_name, prompt.name)
approval_mode = self._determine_approval_mode(
*_mcp_config_candidate_names(
local_name=local_name,
normalized_name=normalized_name,
remote_name=prompt.name,
)
)
func: FunctionTool = FunctionTool(
func=partial(self.get_prompt, prompt.name),
name=local_name,
@@ -1422,7 +1474,11 @@ class MCPTool:
return
# Track existing function names to prevent duplicates
existing_names = {func.name for func in self._functions}
existing_remote_by_local: dict[str, str] = {}
for func in self._functions:
remote_name = (func.additional_properties or {}).get(_MCP_REMOTE_NAME_KEY)
if isinstance(remote_name, str):
existing_remote_by_local[func.name] = remote_name
tool_call_meta_by_name: dict[str, dict[str, Any]] = {}
tool_task_support_by_name: dict[str, str] = {}
tool_param_names_by_name: dict[str, set[str]] = {}
@@ -1462,7 +1518,7 @@ class MCPTool:
for tool in tool_list.tools:
if tool.meta is not None:
tool_call_meta_by_name[tool.name] = dict(tool.meta)
tool_call_meta_by_name[tool.name] = _validate_mcp_meta(tool.meta) or {}
task_support = getattr(getattr(tool, "execution", None), "taskSupport", None)
if task_support is not None:
@@ -1490,10 +1546,24 @@ class MCPTool:
local_name = _build_prefixed_mcp_name(normalized_name, self.tool_name_prefix)
# Skip if already loaded
if local_name in existing_names:
if local_name in existing_remote_by_local:
if existing_remote_by_local.get(local_name) != tool.name:
raise ToolExecutionException(
"MCP server advertised multiple tools that map to the same local function name: "
f"{existing_remote_by_local[local_name]!r} and {tool.name!r} both map to "
f"{local_name!r}."
)
continue
approval_mode = self._determine_approval_mode(local_name, normalized_name, tool.name)
existing_remote_by_local[local_name] = tool.name
approval_mode = self._determine_approval_mode(
*_mcp_config_candidate_names(
local_name=local_name,
normalized_name=normalized_name,
remote_name=tool.name,
)
)
async def _call_tool_with_runtime_kwargs(
ctx: FunctionInvocationContext,
@@ -1501,8 +1571,13 @@ class MCPTool:
_remote_tool_name: str = tool.name,
**kwargs: Any,
) -> str | list[Content]:
trusted_meta = ctx.kwargs.get("_meta")
call_kwargs = dict(ctx.kwargs)
call_kwargs.update(kwargs)
if trusted_meta is not None:
call_kwargs["_meta"] = trusted_meta
else:
call_kwargs.pop("_meta", None)
return await self.call_tool(_remote_tool_name, **call_kwargs)
# Create FunctionTools out of each tool
@@ -1518,7 +1593,6 @@ class MCPTool:
},
)
self._functions.append(func)
existing_names.add(local_name)
# Check if there are more pages
if not tool_list.nextCursor:
@@ -1636,8 +1710,8 @@ class MCPTool:
Keyword Args:
_meta: Optional ``dict[str, Any]`` of MCP request metadata. This reserved key is passed as the
``meta`` parameter of the underlying ``session.call_tool`` call rather than as a tool argument.
User-supplied keys override metadata from ``tools/list``; OpenTelemetry propagation fills in
non-conflicting keys.
OpenTelemetry propagation overrides caller-supplied keys, and metadata from ``tools/list``
overrides both.
kwargs: Remaining arguments to pass to the tool.
Returns:
@@ -1746,17 +1820,7 @@ class MCPTool:
self, tool_name: str, kwargs: dict[str, Any]
) -> tuple[dict[str, Any], dict[str, Any] | None]:
"""Filter kwargs down to the tool's arguments and build the merged MCP request metadata."""
raw_user_meta: object | None = kwargs.get("_meta")
user_meta: dict[str, Any] | None = None
if raw_user_meta is not None and not isinstance(raw_user_meta, dict):
raise ToolExecutionException("MCP tool metadata provided via _meta must be a dict.")
if isinstance(raw_user_meta, dict):
raw_user_meta_dict = cast(Mapping[object, object], raw_user_meta)
user_meta = {}
for key, value in raw_user_meta_dict.items():
if not isinstance(key, str):
raise ToolExecutionException("MCP tool metadata provided via _meta must use string keys.")
user_meta[key] = value
user_meta = _validate_mcp_meta(kwargs.get("_meta"))
# Allowlist: forward only the tool's declared parameters (from inputSchema.properties)
# plus any user-configured extra argument names. Everything else - notably the
@@ -1783,12 +1847,12 @@ class MCPTool:
}
# Some MCP proxies require their tools/list metadata to be echoed on tools/call.
tool_meta = self._tool_call_meta_by_name.get(tool_name)
request_meta = dict(tool_meta) if tool_meta is not None else None
if user_meta is not None:
request_meta = {**(request_meta or {}), **user_meta}
meta = _inject_otel_into_mcp_meta(request_meta)
return filtered_kwargs, meta
request_meta = dict(user_meta) if user_meta is not None else None
request_meta = _inject_otel_into_mcp_meta(request_meta, overwrite=True)
tool_meta = _validate_mcp_meta(self._tool_call_meta_by_name.get(tool_name))
if tool_meta is not None:
request_meta = {**(request_meta or {}), **tool_meta}
return filtered_kwargs, request_meta
async def call_tool_as_task(self, tool_name: str, **kwargs: Any) -> str | list[Content]:
"""Call an MCP tool via the long-running task lifecycle (SEP-2663).
@@ -2898,7 +2962,7 @@ class MCPWebsocketTool(MCPTool):
An async context manager for the WebSocket client transport.
"""
try:
from mcp.client.websocket import websocket_client
from mcp.client.websocket import websocket_client # pyright: ignore[reportDeprecated]
except ModuleNotFoundError as ex:
missing_name = ex.name or "mcp/websocket dependencies"
if missing_name == "mcp" or missing_name.startswith("mcp."):
@@ -2917,4 +2981,4 @@ class MCPWebsocketTool(MCPTool):
}
if self._client_kwargs:
args.update(self._client_kwargs)
return websocket_client(**args)
return websocket_client(**args) # pyright: ignore[reportDeprecated]
@@ -1004,39 +1004,6 @@ def normalize_tools(
return normalized
def _tools_to_dict( # pyright: ignore[reportUnusedFunction]
tools: ToolTypes | Callable[..., Any] | Sequence[ToolTypes | Callable[..., Any]] | None,
) -> list[str | dict[str, Any]] | None:
"""Parse the tools to a dict.
Args:
tools: The tools to parse. Can be a single tool or a sequence of tools.
Returns:
A list of tool specifications as dictionaries, or None if no tools provided.
"""
normalized_tools = normalize_tools(tools)
if not normalized_tools:
return None
results: list[str | dict[str, Any]] = []
for tool_item in normalized_tools:
if isinstance(tool_item, FunctionTool):
results.append(tool_item.to_json_schema_spec())
continue
if isinstance(tool_item, BaseModel):
results.append(tool_item.model_dump(exclude_none=True))
continue
if isinstance(tool_item, SerializationMixin):
results.append(tool_item.to_dict())
continue
if isinstance(tool_item, dict):
results.append(tool_item) # type: ignore[reportUnknownArgumentType]
continue
logger.warning("Can't parse tool.")
return results
# region AI Function Decorator
+61 -29
View File
@@ -2983,6 +2983,8 @@ class ResponseStream(AsyncIterable[UpdateT], Generic[UpdateT, FinalT]):
self._inner_stream_source: ResponseStream[Any, Any] | Awaitable[ResponseStream[Any, Any]] | None = None
self._wrap_inner: bool = False
self._map_update: Callable[[Any], UpdateT | Awaitable[UpdateT]] | None = None
self._flat_map_update: Callable[[Any], Iterable[UpdateT] | Awaitable[Iterable[UpdateT]]] | None = None
self._pending_mapped_updates: list[UpdateT] = []
self._pull_context_manager_factories: list[Callable[[], contextlib.AbstractContextManager[Any]]] = []
def map(
@@ -3029,6 +3031,23 @@ class ResponseStream(AsyncIterable[UpdateT], Generic[UpdateT, FinalT]):
stream._map_update = transform
return stream
def flat_map(
self,
transform: Callable[[UpdateT], Iterable[OuterUpdateT] | Awaitable[Iterable[OuterUpdateT]]],
finalizer: Callable[[Sequence[OuterUpdateT]], OuterFinalT | Awaitable[OuterFinalT]],
) -> ResponseStream[OuterUpdateT, OuterFinalT]:
"""Create a new stream that transforms each update into zero or more updates.
Like :meth:`map`, the returned stream delegates iteration to this stream,
preserving single consumption and inner finalization/result hooks. Use this
when one upstream update naturally expands into multiple wire-protocol events.
"""
stream: ResponseStream[OuterUpdateT, OuterFinalT] = ResponseStream(self, finalizer=finalizer)
stream._inner_stream_source = self
stream._wrap_inner = True
stream._flat_map_update = transform
return stream
def with_finalizer(
self,
finalizer: Callable[[Sequence[UpdateT]], OuterFinalT | Awaitable[OuterFinalT]],
@@ -3101,35 +3120,7 @@ class ResponseStream(AsyncIterable[UpdateT], Generic[UpdateT, FinalT]):
def __aiter__(self) -> ResponseStream[UpdateT, FinalT]:
return self
async def __anext__(self) -> UpdateT:
try:
with contextlib.ExitStack() as stack:
for factory in self._pull_context_manager_factories:
stack.enter_context(factory())
# Resolve the underlying stream inside the pull contexts so that any
# spans/contexts created during stream resolution (e.g. inner chat
# completion spans created on the first pull of a wrapped agent stream)
# inherit the active context (e.g. an outer agent invoke span).
if self._iterator is None:
stream = await self._get_stream()
self._iterator = stream.__aiter__()
update: UpdateT = await self._iterator.__anext__()
except StopAsyncIteration:
self._consumed = True
await self._run_cleanup_hooks()
await self.get_final_response()
raise
except Exception as exc:
self._stream_error = exc
try:
await self._run_cleanup_hooks()
finally:
self._stream_error = None
raise
if self._map_update is not None:
update = self._map_update(update) # type: ignore[assignment]
if isawaitable(update):
update = await update
async def _record_update(self, update: UpdateT) -> UpdateT:
self._updates.append(update)
for hook in self._transform_hooks:
hooked = hook(update)
@@ -3139,6 +3130,47 @@ class ResponseStream(AsyncIterable[UpdateT], Generic[UpdateT, FinalT]):
update = cast(UpdateT, hooked)
return update
async def __anext__(self) -> UpdateT:
while True:
if self._pending_mapped_updates:
return await self._record_update(self._pending_mapped_updates.pop(0))
try:
with contextlib.ExitStack() as stack:
for factory in self._pull_context_manager_factories:
stack.enter_context(factory())
# Resolve the underlying stream inside the pull contexts so that any
# spans/contexts created during stream resolution (e.g. inner chat
# completion spans created on the first pull of a wrapped agent stream)
# inherit the active context (e.g. an outer agent invoke span).
if self._iterator is None:
stream = await self._get_stream()
self._iterator = stream.__aiter__()
update: UpdateT = await self._iterator.__anext__()
except StopAsyncIteration:
self._consumed = True
await self._run_cleanup_hooks()
await self.get_final_response()
raise
except Exception as exc:
self._stream_error = exc
try:
await self._run_cleanup_hooks()
finally:
self._stream_error = None
raise
if self._flat_map_update is not None:
mapped_updates = self._flat_map_update(update)
if isawaitable(mapped_updates):
mapped_updates = await mapped_updates
self._pending_mapped_updates.extend(mapped_updates)
continue
if self._map_update is not None:
update = self._map_update(update) # type: ignore[assignment]
if isawaitable(update):
update = await update
return await self._record_update(update)
async def _resolve_stream_with_pull_contexts(self) -> AsyncIterable[UpdateT]:
"""Resolve the underlying stream while activating any registered pull context managers.
@@ -3,6 +3,7 @@
import asyncio
import contextlib
import logging
import warnings
from collections import defaultdict
from collections.abc import AsyncGenerator, Sequence
from typing import Any
@@ -10,7 +11,6 @@ from typing import Any
from ..exceptions import (
WorkflowCheckpointException,
WorkflowConvergenceException,
WorkflowRunnerException,
)
from ._checkpoint import CheckpointID, CheckpointStorage, WorkflowCheckpoint
from ._const import EXECUTOR_STATE_KEY
@@ -27,6 +27,21 @@ from ._state import State
logger = logging.getLogger(__name__)
def warn_runner_deprecated() -> None:
"""Emit a deprecation warning when ``Runner`` is accessed from the public API.
``Runner`` remains importable from ``agent_framework`` for backward
compatibility, but it is intended for internal use only and will be removed
from the public API in a future version.
"""
warnings.warn(
"`Runner` is deprecated and will be removed from the public API in a future version. "
"It is intended for internal use only.",
DeprecationWarning,
stacklevel=3,
)
class Runner:
"""A class to run a workflow in Pregel supersteps."""
@@ -63,25 +78,34 @@ class Runner:
self._iteration = 0
self._max_iterations = max_iterations
self._state = state
self._running = False
self._resumed_from_checkpoint = False # Track whether we resumed
# Checkpointing related attributes
self._resumed_from_checkpoint = False
self._previous_checkpoint_id: CheckpointID | None = None
@property
def context(self) -> RunnerContext:
"""Get the workflow context."""
"""Get the runner context for message, event, and checkpoint handling."""
return self._ctx
@property
def state(self) -> State:
"""Get the shared state for the workflow."""
return self._state
def reset_iteration_count(self) -> None:
"""Reset the iteration count to zero."""
"""Reset the iteration count to zero.
This is useful when the workflow resumes from a new set of messages.
Note:
When a workflow is resumed from a response (for a request_info_event)
or a checkpoint, the iteration count is normally NOT reset.
"""
self._iteration = 0
async def run_until_convergence(self) -> AsyncGenerator[WorkflowEvent, None]:
"""Run the workflow until no more messages are sent."""
if self._running:
raise WorkflowRunnerException("Runner is already running.")
self._running = True
previous_checkpoint_id: CheckpointID | None = None
try:
# Emit any events already produced prior to entering loop
if await self._ctx.has_events():
@@ -89,12 +113,12 @@ class Runner:
for event in await self._ctx.drain_events():
yield event
# Create the first checkpoint. Checkpoints are usually considered to be created at the end of an iteration,
# we can think of the first checkpoint as being created at the end of a "superstep 0" which captures the
# states after which the start executor has run. Note that we execute the start executor outside of the
# main iteration loop.
if await self._ctx.has_messages() and not self._resumed_from_checkpoint:
previous_checkpoint_id = await self._create_checkpoint_if_enabled(previous_checkpoint_id)
# Create a checkpoint before a run starts. Checkpoints are usually considered to be created at the
# end of an iteration, we can think of this checkpoint as being created at the end of "superstep 0"
# which captures the states after which the start executor has run. Note that we execute the start
# executor outside of the main iteration loop.
if await self._ctx.has_messages() and self._iteration == 0 and not self._resumed_from_checkpoint:
await self.create_checkpoint_if_enabled()
while self._iteration < self._max_iterations:
logger.info(f"Starting superstep {self._iteration + 1}")
@@ -141,7 +165,7 @@ class Runner:
self._state.commit()
# Create checkpoint after each superstep iteration
previous_checkpoint_id = await self._create_checkpoint_if_enabled(previous_checkpoint_id)
await self.create_checkpoint_if_enabled()
yield WorkflowEvent.superstep_completed(iteration=self._iteration)
@@ -149,13 +173,15 @@ class Runner:
if not await self._ctx.has_messages():
break
logger.info(f"Workflow completed after {self._iteration} supersteps")
if self._iteration >= self._max_iterations and await self._ctx.has_messages():
raise WorkflowConvergenceException(f"Runner did not converge after {self._max_iterations} iterations.")
logger.info(f"Workflow completed after {self._iteration} supersteps")
self._resumed_from_checkpoint = False # Reset resume flag for next run
finally:
self._running = False
# Reset the resume flag so stale resume state never leaks into the next run on this
# instance - even if convergence raised before completing (e.g. an executor failure
# during a resumed run).
self._resumed_from_checkpoint = False
async def _run_iteration(self) -> None:
"""Run a single iteration of the workflow.
@@ -209,40 +235,55 @@ class Runner:
]
await asyncio.gather(*tasks)
async def _create_checkpoint_if_enabled(self, previous_checkpoint_id: CheckpointID | None) -> CheckpointID | None:
async def _prepare_checkpoint_state(self) -> None:
"""Persist executor snapshots into committed shared state.
This is used by checkpoint capture paths that need a complete, restorable
state payload without necessarily writing to a checkpoint storage backend.
"""
await self._save_executor_states()
self._state.commit()
async def create_checkpoint_if_enabled(self) -> None:
"""Create a checkpoint if checkpointing is enabled and attach a label and metadata."""
if not self._ctx.has_checkpointing():
return None
return
try:
# Save executor states into the shared state before creating the checkpoint,
# so that they are included in the checkpoint payload.
await self._save_executor_states()
# `on_checkpoint_save()` writes via State.set(), which stages values in the
# pending buffer. Checkpoints serialize committed state only, so commit here
# to ensure executor snapshots are captured in this checkpoint.
self._state.commit()
# Save executor states into committed state before creating the checkpoint.
await self._prepare_checkpoint_state()
checkpoint_id = await self._ctx.create_checkpoint(
self._workflow_name,
self._graph_signature_hash,
self._state,
previous_checkpoint_id,
self._previous_checkpoint_id,
self._iteration,
)
logger.info(f"Created checkpoint: {checkpoint_id}")
return checkpoint_id
logger.info(
"Created checkpoint: %s with parent checkpoint at iteration %d: %s",
checkpoint_id,
self._iteration,
self._previous_checkpoint_id,
)
self._previous_checkpoint_id = checkpoint_id
except Exception as e:
logger.warning(f"Failed to create checkpoint: {e}")
return None
logger.warning(
"Failed to create checkpoint at iteration %d: %s. "
"Note that this does not fail the workflow run. "
"The next successfully-created checkpoint will be parented to the last successful checkpoint: %s",
self._iteration,
e,
self._previous_checkpoint_id,
)
async def restore_from_checkpoint(
self,
checkpoint_id: CheckpointID,
checkpoint_storage: CheckpointStorage | None = None,
) -> None:
"""Restore workflow state from a checkpoint.
"""Restore the runner from a checkpoint.
Args:
checkpoint_id: The ID of the checkpoint to restore from
@@ -290,7 +331,7 @@ class Runner:
# Apply the checkpoint to the context
await self._ctx.apply_checkpoint(checkpoint)
# Mark the runner as resumed
self._mark_resumed(checkpoint.iteration_count)
self._mark_resumed(checkpoint)
logger.info(f"Successfully restored workflow from checkpoint: {checkpoint_id}")
except WorkflowCheckpointException:
@@ -356,13 +397,14 @@ class Runner:
return parsed
def _mark_resumed(self, iteration: int) -> None:
def _mark_resumed(self, checkpoint: WorkflowCheckpoint) -> None:
"""Mark the runner as having resumed from a checkpoint.
Optionally set the current iteration and max iterations.
"""
self._resumed_from_checkpoint = True
self._iteration = iteration
self._iteration = checkpoint.iteration_count
self._previous_checkpoint_id = checkpoint.checkpoint_id
async def _set_executor_state(self, executor_id: str, state: dict[str, Any]) -> None:
"""Store executor state in state under a reserved key.
@@ -11,12 +11,14 @@ import logging
import types
import uuid
import warnings
import weakref
from collections.abc import AsyncIterable, Awaitable, Callable, Mapping, Sequence
from dataclasses import dataclass, field
from typing import TYPE_CHECKING, Any, Literal, overload
from .._sessions import ContextProvider
from .._types import ResponseStream
from ..exceptions import WorkflowException
from ..observability import OtelAttr, capture_exception, create_workflow_span
from ._checkpoint import CheckpointStorage
from ._const import DEFAULT_MAX_ITERATIONS, GLOBAL_KWARGS_KEY, WORKFLOW_RUN_KWARGS_KEY
@@ -346,25 +348,29 @@ class Workflow(DictConvertible):
# Store non-serializable runtime objects as private attributes
self._runner_context = runner_context
self._runner_context.set_yield_output_classifier(self._output_designation.classify)
self._state = State()
self._runner: Runner = Runner(
self.edge_groups,
self.executors,
self._state,
State(),
runner_context,
self.name,
self.graph_signature_hash,
max_iterations=max_iterations,
)
# Flag to prevent concurrent workflow executions
self._is_running = False
# Current run-level status of this workflow instance. Updated in lockstep with
# the status events emitted from `_run_workflow_with_tracing`. Defaults to IDLE
# for a freshly built workflow that has not yet been run.
self._status: WorkflowRunState = WorkflowRunState.IDLE
# Weak reference to the in-flight run's ``ResponseStream``. Used as the single
# concurrency lock: if the previous stream is still alive, ``run()`` rejects a
# new run synchronously (before any await). When the stream is fully consumed
# ``_run_core``'s finally clears this; if the caller drops the stream without
# ever iterating, the weakref dereferences to ``None`` once Python collects it,
# so a subsequent ``run()`` is allowed.
self._active_run: weakref.ref[ResponseStream[WorkflowEvent, WorkflowRunResult]] | None = None
@property
def status(self) -> WorkflowRunState:
"""Return the current run-level status of this workflow instance.
@@ -376,16 +382,6 @@ class Workflow(DictConvertible):
"""
return self._status
def _ensure_not_running(self) -> None:
"""Ensure the workflow is not already running."""
if self._is_running:
raise RuntimeError("Workflow is already running. Concurrent executions are not allowed.")
self._is_running = True
def _reset_running_flag(self) -> None:
"""Reset the running flag."""
self._is_running = False
def to_dict(self) -> dict[str, Any]:
"""Serialize the workflow definition into a JSON-ready dictionary."""
data: dict[str, Any] = {
@@ -535,13 +531,12 @@ class Workflow(DictConvertible):
yield in_progress # noqa: RUF070
# Per-run reset for fresh-message runs only. We deliberately
# do NOT clear shared workflow state (`_state.clear()`) or the
# runner context's in-flight messages (`reset_for_new_run()`)
# here - state and pending work persist across `run()` calls
# so that a `WorkflowAgent` can deliver multi-turn input on
# the same instance and have prior turns' context survive.
# Iteration counting and per-run kwargs ARE per-run though,
# so they're reset here.
# do NOT clear shared workflow state or the runner context's
# in-flight messages here - state and pending work persist
# across `run()` calls so that a `WorkflowAgent` can deliver
# multi-turn input on the same instance and have prior turns'
# context survive. Iteration counting and per-run kwargs ARE
# per-run though, so they're reset here.
if not is_continuation:
self._runner.reset_iteration_count()
@@ -564,14 +559,13 @@ class Workflow(DictConvertible):
combined_kwargs["client_kwargs"] = self._resolve_invocation_kwargs(
client_kwargs, "client_kwargs"
)
self._state.set(WORKFLOW_RUN_KWARGS_KEY, combined_kwargs)
self._runner.state.set(WORKFLOW_RUN_KWARGS_KEY, combined_kwargs)
elif not is_continuation:
self._state.set(WORKFLOW_RUN_KWARGS_KEY, {})
self._state.commit() # Commit immediately so kwargs are available
self._runner.state.set(WORKFLOW_RUN_KWARGS_KEY, {})
self._runner.state.commit() # Commit immediately so kwargs are available
# Set streaming mode (always set explicitly per run since
# reset_for_new_run() no longer runs to clear it).
self._runner_context.set_streaming(streaming)
# Explicitly set streaming mode per run
self._runner.context.set_streaming(streaming)
# Execute initial setup if provided
if initial_executor_fn:
@@ -665,7 +659,7 @@ class Workflow(DictConvertible):
await executor.execute(
message,
[self.__class__.__name__],
self._state,
self._runner.state,
self._runner.context,
trace_contexts=None,
source_span_ids=None,
@@ -745,9 +739,28 @@ class Workflow(DictConvertible):
Raises:
ValueError: If parameter combination is invalid.
"""
# Validate parameters and set running flag eagerly (before any async work)
# Validate parameters first so misuse fails before we touch any run state.
self._validate_run_params(message, responses, checkpoint_id)
self._ensure_not_running()
# Concurrency check: reject a second run synchronously - before constructing
# the ResponseStream or yielding control to the event loop - so a concurrent
# ``run`` call can't slip past the guard while the first call is suspended
# inside its async generator. The ``ResponseStream`` returned below is the
# lock: as long as the caller holds a reference to it, ``self._active_run()``
# resolves to a live object and a new ``run`` is rejected. When the stream is
# fully consumed, ``_run_core``'s finally clears the attribute. When the
# caller drops the stream without iterating, garbage collection invalidates
# the weakref, so a subsequent ``run`` is permitted.
if self._is_run_active():
raise WorkflowException(
"Workflow is already running; concurrent runs are not allowed on the same instance."
)
# No run is active, so any runtime checkpoint storage override still set on the
# context is stale - left over from a prior run whose stream was dropped before
# its async-generator finalizer ran. Clear it so this run starts clean and does
# not silently inherit the prior run's runtime checkpoint storage.
self._runner.context.clear_runtime_checkpoint_storage()
response_stream = ResponseStream[WorkflowEvent, WorkflowRunResult](
self._run_core(
@@ -760,10 +773,8 @@ class Workflow(DictConvertible):
client_kwargs=client_kwargs,
),
finalizer=functools.partial(self._finalize_events, include_status_events=include_status_events),
cleanup_hooks=[
functools.partial(self._run_cleanup, checkpoint_storage),
],
)
self._active_run = weakref.ref(response_stream)
if stream:
return response_stream
@@ -785,55 +796,79 @@ class Workflow(DictConvertible):
Yields:
WorkflowEvent: The events generated during the workflow execution.
"""
# Enable runtime checkpointing if storage provided
# Capture the weakref instance ``run()`` installed for *this* run. We
# compare by object identity in the finally so a stale finalizer (e.g.
# the caller dropped this stream after partial iteration, then started
# a new run before async-gen finalization throws ``GeneratorExit`` into
# us) does not clobber a successor run's freshly installed weakref.
# ``run()`` runs synchronously and assigns ``self._active_run`` before
# this generator's body is first iterated, so by the time we read it
# here it already points at our own ``ResponseStream``.
my_active_run = self._active_run
# Enable runtime checkpointing if storage provided.
if checkpoint_storage is not None:
self._runner.context.set_runtime_checkpoint_storage(checkpoint_storage)
# Async validation: a fresh-message run is only allowed when the
# runner context has fully drained from any prior run. If it still
# has in-flight executor messages, the prior run didn't complete -
# the caller must either resume from a checkpoint or wait for the
# prior run to drain. (Pending request_info events are intentionally
# NOT blocked here: a follow-up run with message=... is the normal
# way to deliver a response to those pending requests, e.g. via
# WorkflowAgent._process_pending_requests.)
# NOTE: _validate_run_params already enforces that ``message`` is
# mutually exclusive with both ``checkpoint_id`` and ``responses``,
# so we don't need to re-check those here.
if message is not None and await self._runner.context.has_messages():
raise RuntimeError(
"Cannot start a new run with 'message' while in-flight executor "
"messages remain from a prior run. Resume from a checkpoint "
"(checkpoint_id=...) or wait for the prior run to complete. "
"Workflows that need to recover from a mid-run failure must use "
"checkpointing; there is no in-process recovery path."
)
try:
# Async validation: a fresh-message run is only allowed when the
# runner context has fully drained from any prior run. If it still
# has in-flight executor messages, the prior run didn't complete -
# the caller must either resume from a checkpoint or wait for the
# prior run to drain. (Pending request_info events are intentionally
# NOT blocked here: a follow-up run with message=... is the normal
# way to deliver a response to those pending requests, e.g. via
# WorkflowAgent._process_pending_requests.)
# NOTE: _validate_run_params already enforces that ``message`` is
# mutually exclusive with both ``checkpoint_id`` and ``responses``,
# so we don't need to re-check those here.
if message is not None and await self._runner.context.has_messages():
raise RuntimeError(
"Cannot start a new run with 'message' while in-flight executor "
"messages remain from a prior run. Resume from a checkpoint "
"(checkpoint_id=...) or wait for the prior run to complete. "
"Workflows that need to recover from a mid-run failure must use "
"checkpointing; there is no in-process recovery path."
)
initial_executor_fn = self._resolve_execution_mode(message, responses, checkpoint_id, checkpoint_storage)
initial_executor_fn = self._resolve_execution_mode(message, responses, checkpoint_id, checkpoint_storage)
async for event in self._run_workflow_with_tracing(
initial_executor_fn=initial_executor_fn,
is_continuation=(message is None),
streaming=streaming,
function_invocation_kwargs=function_invocation_kwargs,
client_kwargs=client_kwargs,
):
if event.type == "request_info" and event.request_id in (responses or {}):
# Don't yield request_info events for which we have responses to send -
# these are considered "handled". This prevents the caller from seeing
# events for requests they are already responding to.
# This usually happens when responses are provided with a checkpoint
# (restore then send), because the request_info events are stored in the
# checkpoint and would be emitted on restoration by the runner regardless
# of if a response is provided or not.
continue
yield event
async def _run_cleanup(self, checkpoint_storage: CheckpointStorage | None) -> None:
"""Cleanup hook called after stream consumption."""
if checkpoint_storage is not None:
self._runner.context.clear_runtime_checkpoint_storage()
self._reset_running_flag()
async for event in self._run_workflow_with_tracing(
initial_executor_fn=initial_executor_fn,
is_continuation=(message is None),
streaming=streaming,
function_invocation_kwargs=function_invocation_kwargs,
client_kwargs=client_kwargs,
):
if event.type == "request_info" and event.request_id in (responses or {}):
# Don't yield request_info events for which we have responses to send -
# these are considered "handled". This prevents the caller from seeing
# events for requests they are already responding to.
# This usually happens when responses are provided with a checkpoint
# (restore then send), because the request_info events are stored in the
# checkpoint and would be emitted on restoration by the runner regardless
# of if a response is provided or not.
continue
yield event
finally:
# Whether this run is still the active one (no successor ``run()`` has
# installed a new weakref since we started). Captured once because the
# active-run clear below mutates ``self._active_run``. Used to scope both
# the run-lock release and the runtime-storage clear so a dropped run's
# deferred finalizer cannot clobber a successor run's state.
owns_run = self._active_run is my_active_run
if owns_run:
# Clear the active-run weakref so a subsequent ``run()`` is allowed.
# If the caller dropped this stream after partial iteration and a new
# ``run()`` already installed its own weakref before our async-gen
# finalizer ran, ``self._active_run`` points at the successor and we
# leave it untouched to preserve the successor's concurrency guard.
self._active_run = None
# Same ownership scoping applies to the runtime checkpoint storage:
# only clear it when this run still owns it, so a dropped run's
# deferred finalizer can't clear a successor's storage.
if checkpoint_storage is not None:
self._runner.context.clear_runtime_checkpoint_storage()
@staticmethod
def _finalize_events(
@@ -935,7 +970,7 @@ class Workflow(DictConvertible):
async def _send_responses_internal(self, responses: Mapping[str, Any]) -> None:
"""Internal method to validate and send responses to the executors."""
pending_requests = await self._runner_context.get_pending_request_info_events()
pending_requests = await self._runner.context.get_pending_request_info_events()
if not pending_requests:
raise RuntimeError("No pending requests found in workflow context.")
@@ -955,7 +990,7 @@ class Workflow(DictConvertible):
coerced_responses[request_id] = response
await asyncio.gather(*[
self._runner_context.send_request_info_response(request_id, response)
self._runner.context.send_request_info_response(request_id, response)
for request_id, response in coerced_responses.items()
])
@@ -1151,3 +1186,12 @@ class Workflow(DictConvertible):
context_providers=context_providers,
**kwargs,
)
def _is_run_active(self) -> bool:
"""Check if a workflow run is currently active.
Returns:
True if a run is active, False otherwise.
"""
existing_stream = self._active_run() if self._active_run is not None else None
return existing_stream is not None
@@ -2211,6 +2211,181 @@ def _get_instructions_from_options(options: Any) -> str | list[str] | None:
return None
# region OTel tool definitions
# Per-item in-memory cache of computed OTel tool definitions, keyed by the tool
# object's identity. Tool objects (e.g. ``FunctionTool``, ``MCPTool``) are often
# reused across runs, so caching their converted definitions avoids repeating the
# isinstance checks, schema generation, and dict construction on every invocation.
# A ``WeakKeyDictionary`` lets entries be garbage collected with their tools.
# Unhashable / non-weak-referenceable specs (e.g. plain dicts) bypass the cache.
_TOOL_OTEL_DEFINITION_CACHE: weakref.WeakKeyDictionary[Any, dict[str, Any] | None] = weakref.WeakKeyDictionary()
# Sentinel distinguishing "not cached" from a cached ``None`` (unparseable tool).
_CACHE_MISS: Final = object()
def _tools_to_dict(
tools: Any,
) -> list[dict[str, Any]] | None:
"""Convert tools into OpenTelemetry GenAI tool definitions.
The output conforms to the OTel GenAI tool-definitions schema, where each
entry is either a ``FunctionToolDefinition`` (``type="function"`` with
``name`` and optional ``description``/``parameters``) or a
``GenericToolDefinition`` (any ``type`` plus a ``name``). See
https://github.com/open-telemetry/semantic-conventions-genai/blob/main/docs/gen-ai/gen-ai-tool-definitions.json.
Args:
tools: The tools to parse. Can be a single tool or a sequence of tools.
Returns:
A list of OTel-conformant tool-definition dicts, or ``None`` when
``tools`` is empty or no tool can be represented.
"""
from ._tools import normalize_tools
normalized_tools = normalize_tools(tools)
if not normalized_tools:
return None
results: list[dict[str, Any]] = []
for tool_item in normalized_tools:
otel_def = _tool_to_otel_definition(tool_item)
if otel_def is not None:
results.append(otel_def)
return results or None
def _tool_to_otel_definition(tool_item: Any) -> dict[str, Any] | None:
"""Convert a single tool spec into an OTel GenAI tool-definition dict.
Results are cached per tool object (keyed by identity) so repeated runs that
reuse the same tool instances skip the conversion work. Specs that cannot be
weakly referenced (e.g. plain dicts) are converted without caching.
Returns ``None`` and emits a warning when the input cannot be represented
as either a ``FunctionToolDefinition`` or a ``GenericToolDefinition``.
"""
try:
cached = _TOOL_OTEL_DEFINITION_CACHE.get(tool_item, _CACHE_MISS)
except TypeError:
# Unhashable spec (e.g. a plain dict); convert without caching.
return _build_tool_otel_definition(tool_item)
if cached is not _CACHE_MISS:
return cast("dict[str, Any] | None", cached)
definition = _build_tool_otel_definition(tool_item)
with contextlib.suppress(TypeError):
# Object may not support weak references; skip caching when that is the case.
_TOOL_OTEL_DEFINITION_CACHE[tool_item] = definition
return definition
def _build_tool_otel_definition(tool_item: Any) -> dict[str, Any] | None:
"""Convert a single tool spec into an OTel GenAI tool-definition dict (uncached)."""
from pydantic import BaseModel
from ._mcp import MCPTool
from ._serialization import SerializationMixin
from ._tools import FunctionTool
if isinstance(tool_item, FunctionTool):
definition: dict[str, Any] = {"type": "function", "name": tool_item.name}
if tool_item.description:
definition["description"] = tool_item.description
parameters = tool_item.parameters()
if parameters:
definition["parameters"] = parameters
return definition
if isinstance(tool_item, MCPTool):
definition = {"type": "mcp", "name": tool_item.name}
if tool_item.description:
definition["description"] = tool_item.description
return definition
raw: Mapping[str, Any] | None = None
if isinstance(tool_item, BaseModel):
raw = tool_item.model_dump(exclude_none=True)
elif isinstance(tool_item, SerializationMixin):
raw = tool_item.to_dict()
elif isinstance(tool_item, Mapping):
raw = cast("Mapping[str, Any]", tool_item)
if raw is None:
logger.warning(
"Can't parse tool to OpenTelemetry tool definition: %s",
type(tool_item).__name__, # type: ignore[reportUnknownArgumentType]
)
return None
return _otel_definition_from_mapping(raw)
def _otel_definition_from_mapping(raw: Mapping[str, Any]) -> dict[str, Any] | None:
"""Reshape a tool spec mapping into an OTel GenAI tool-definition dict.
Handles the nested OpenAI Chat Completions function shape
(``{"type": "function", "function": {...}}``) by flattening it into the
OTel shape.
"""
# OpenAI Chat Completions nests the function spec one level deeper; flatten it.
nested_function = raw.get("function") if raw.get("type") == "function" else None
if isinstance(nested_function, Mapping):
nested = cast("Mapping[str, Any]", nested_function)
name = nested.get("name")
if not isinstance(name, str) or not name:
logger.warning("Can't parse tool to OpenTelemetry tool definition: missing 'name'.")
return None
definition: dict[str, Any] = {"type": "function", "name": name}
description = nested.get("description")
if description:
definition["description"] = description
parameters = nested.get("parameters")
if parameters:
definition["parameters"] = parameters
# Forward extra properties from both layers, preferring the inner spec.
for source in (nested, raw):
for key, value in source.items():
if key in {"type", "function", "name", "description", "parameters"}:
continue
definition.setdefault(key, value)
return definition
type_value = raw.get("type")
if not isinstance(type_value, str) or not type_value:
logger.warning("Can't parse tool to OpenTelemetry tool definition: missing 'type'.")
return None
name_value = raw.get("name")
if not isinstance(name_value, str) or not name_value:
# Hosted tools sometimes omit ``name`` (e.g. ``{"type": "code_interpreter"}``);
# fall back to the type so the OTel definition stays valid.
name_value = type_value
if type_value == "function":
definition = {"type": "function", "name": name_value}
description = raw.get("description")
if description:
definition["description"] = description
parameters = raw.get("parameters")
if parameters:
definition["parameters"] = parameters
for key, value in raw.items():
if key in {"type", "name", "description", "parameters"}:
continue
definition.setdefault(key, value)
return definition
definition = {"type": type_value, "name": name_value}
for key, value in raw.items():
if key in {"type", "name"}:
continue
definition[key] = value
return definition
# endregion
# Mapping configuration for extracting span attributes
# Each entry: source_keys -> (otel_attribute_key, transform_func, check_options_first, default_value)
# - source_keys: single key or list of keys to check (first non-None value wins)
@@ -2246,11 +2421,7 @@ OTEL_ATTR_MAP: dict[str | tuple[str, ...], tuple[str, Callable[[Any], Any] | Non
# Tools with validation - returns None if no valid tools
"tools": (
OtelAttr.TOOL_DEFINITIONS,
lambda tools: (
json.dumps(tools_dict, ensure_ascii=False)
if (tools_dict := __import__("agent_framework._tools", fromlist=["_tools_to_dict"])._tools_to_dict(tools))
else None
),
lambda tools: json.dumps(tools_dict, ensure_ascii=False) if (tools_dict := _tools_to_dict(tools)) else None,
True,
None,
),
@@ -497,10 +497,10 @@ async def test_file_access_provider_registers_tools_and_instructions(
assert any(DEFAULT_FILE_ACCESS_INSTRUCTIONS in chunk for chunk in (instructions or []))
async def test_file_access_provider_delete_approval_defaults_to_always_require(
async def test_file_access_provider_all_tools_require_approval(
chat_client_base: SupportsChatGetResponse,
) -> None:
"""By default ``file_access_delete_file`` should require host approval."""
"""Every file-access tool should require host approval."""
session = AgentSession(session_id="session-1")
provider = FileAccessProvider(store=InMemoryAgentFileStore())
agent = Agent(client=chat_client_base, context_providers=[provider])
@@ -512,36 +512,72 @@ async def test_file_access_provider_delete_approval_defaults_to_always_require(
tools = options["tools"]
assert isinstance(tools, list)
delete_file = _tool_by_name(tools, "file_access_delete_file")
assert delete_file.approval_mode == "always_require"
# The non-destructive tools should remain autonomous.
for name in (
"file_access_save_file",
"file_access_read_file",
"file_access_list_files",
"file_access_list_subdirectories",
"file_access_search_files",
FileAccessProvider.SAVE_FILE_TOOL_NAME,
FileAccessProvider.READ_FILE_TOOL_NAME,
FileAccessProvider.DELETE_FILE_TOOL_NAME,
FileAccessProvider.LIST_FILES_TOOL_NAME,
FileAccessProvider.LIST_SUBDIRECTORIES_TOOL_NAME,
FileAccessProvider.SEARCH_FILES_TOOL_NAME,
):
assert _tool_by_name(tools, name).approval_mode == "never_require"
assert _tool_by_name(tools, name).approval_mode == "always_require"
async def test_file_access_provider_delete_approval_opt_out(
chat_client_base: SupportsChatGetResponse,
) -> None:
"""``require_delete_approval=False`` should drop delete to ``never_require``."""
session = AgentSession(session_id="session-1")
provider = FileAccessProvider(store=InMemoryAgentFileStore(), require_delete_approval=False)
agent = Agent(client=chat_client_base, context_providers=[provider])
def test_read_only_tools_auto_approval_rule() -> None:
"""The read-only rule approves only the non-mutating tools."""
approved = {
FileAccessProvider.READ_FILE_TOOL_NAME,
FileAccessProvider.LIST_FILES_TOOL_NAME,
FileAccessProvider.LIST_SUBDIRECTORIES_TOOL_NAME,
FileAccessProvider.SEARCH_FILES_TOOL_NAME,
}
rejected = {
FileAccessProvider.SAVE_FILE_TOOL_NAME,
FileAccessProvider.DELETE_FILE_TOOL_NAME,
"some_other_tool",
}
for name in approved:
call = Content("function_call", call_id="c1", name=name, arguments="{}")
assert FileAccessProvider.read_only_tools_auto_approval_rule(call) is True
for name in rejected:
call = Content("function_call", call_id="c1", name=name, arguments="{}")
assert FileAccessProvider.read_only_tools_auto_approval_rule(call) is False
# A hosted tool with the same name (carrying a server_label) is NOT auto-approved.
for name in approved:
hosted = Content(
"function_call",
call_id="c1",
name=name,
arguments="{}",
additional_properties={"server_label": "remote"},
)
assert FileAccessProvider.read_only_tools_auto_approval_rule(hosted) is False
_, options = await agent._prepare_session_and_messages( # pyright: ignore[reportPrivateUsage]
session=session,
input_messages=[Message(role="user", contents=["work with files"])],
)
tools = options["tools"]
assert isinstance(tools, list)
delete_file = _tool_by_name(tools, "file_access_delete_file")
assert delete_file.approval_mode == "never_require"
def test_all_tools_auto_approval_rule() -> None:
"""The all-tools rule approves every file-access tool but nothing else."""
for name in (
FileAccessProvider.SAVE_FILE_TOOL_NAME,
FileAccessProvider.READ_FILE_TOOL_NAME,
FileAccessProvider.DELETE_FILE_TOOL_NAME,
FileAccessProvider.LIST_FILES_TOOL_NAME,
FileAccessProvider.LIST_SUBDIRECTORIES_TOOL_NAME,
FileAccessProvider.SEARCH_FILES_TOOL_NAME,
):
call = Content("function_call", call_id="c1", name=name, arguments="{}")
assert FileAccessProvider.all_tools_auto_approval_rule(call) is True
# A hosted tool with the same name (carrying a server_label) is NOT auto-approved.
hosted = Content(
"function_call",
call_id="c1",
name=name,
arguments="{}",
additional_properties={"server_label": "remote"},
)
assert FileAccessProvider.all_tools_auto_approval_rule(hosted) is False
unrelated = Content("function_call", call_id="c1", name="some_other_tool", arguments="{}")
assert FileAccessProvider.all_tools_auto_approval_rule(unrelated) is False
async def test_file_access_provider_tools_round_trip_files(
+276 -2
View File
@@ -122,6 +122,113 @@ async def test_load_tools_with_tool_name_prefix_preserves_matching_configuration
assert tool.functions[0].approval_mode == "always_require"
async def test_allowed_tools_does_not_authorize_normalized_remote_name_collision() -> None:
"""A normalized/local allowlist match must not authorize a different raw remote tool."""
tool = MCPTool(name="test_server", allowed_tools=["delete-file"]) # type: ignore[abstract]
mock_session = AsyncMock()
tool.session = mock_session
tool.load_tools_flag = True
page = Mock()
page.tools = [
types.Tool(
name="delete/file",
description="Delete a file",
inputSchema={"type": "object", "properties": {}},
),
]
page.nextCursor = None
mock_session.list_tools = AsyncMock(return_value=page)
await tool.load_tools()
assert [function.name for function in tool._functions] == ["delete-file"]
assert tool.functions == []
async def test_load_tools_rejects_colliding_normalized_tool_names() -> None:
"""A remote MCP server must not choose which raw tool backs a colliding local name."""
tool = MCPTool(name="test_server", allowed_tools=["delete-file"]) # type: ignore[abstract]
mock_session = AsyncMock()
tool.session = mock_session
tool.load_tools_flag = True
page = Mock()
page.tools = [
types.Tool(
name="delete/file",
description="Unauthorized tool",
inputSchema={"type": "object", "properties": {}},
),
types.Tool(
name="delete-file",
description="Authorized tool",
inputSchema={"type": "object", "properties": {}},
),
]
page.nextCursor = None
mock_session.list_tools = AsyncMock(return_value=page)
with pytest.raises(ToolExecutionException, match="map to the same local function name"):
await tool.load_tools()
async def test_allowed_tools_exact_raw_name_allows_normalized_function_name() -> None:
"""An exact raw remote allowlist entry still exposes that raw tool, regardless of local normalization."""
tool = MCPTool(name="test_server", allowed_tools=["delete/file"]) # type: ignore[abstract]
mock_session = AsyncMock()
tool.session = mock_session
tool.load_tools_flag = True
page = Mock()
page.tools = [
types.Tool(
name="delete/file",
description="Delete a file",
inputSchema={"type": "object", "properties": {}},
),
]
page.nextCursor = None
mock_session.list_tools = AsyncMock(return_value=page)
await tool.load_tools()
assert [function.name for function in tool.functions] == ["delete-file"]
assert tool.functions[0].additional_properties is not None
assert tool.functions[0].additional_properties["_mcp_remote_name"] == "delete/file"
async def test_approval_mode_does_not_match_normalized_colliding_name() -> None:
"""Approval rules should not apply to a different raw remote tool through normalization."""
tool = MCPTool( # type: ignore[abstract]
name="test_server",
approval_mode={"always_require_approval": ["delete-file"]},
)
mock_session = AsyncMock()
tool.session = mock_session
tool.load_tools_flag = True
page = Mock()
page.tools = [
types.Tool(
name="delete/file",
description="Delete a file",
inputSchema={"type": "object", "properties": {}},
),
]
page.nextCursor = None
mock_session.list_tools = AsyncMock(return_value=page)
await tool.load_tools()
assert tool._functions[0].name == "delete-file"
assert tool._functions[0].approval_mode == "never_require"
async def test_load_prompts_with_tool_name_prefix() -> None:
"""Prefixed MCP prompt names should be exposed with the configured prefix."""
tool = MCPTool(name="docs", tool_name_prefix="docs") # type: ignore[abstract]
@@ -3339,6 +3446,7 @@ async def test_load_tools_adds_properties_to_zero_arg_tool_schema():
none_schema_tool.name = "none_schema_tool"
none_schema_tool.description = "A tool with None inputSchema"
none_schema_tool.inputSchema = None
none_schema_tool.meta = None
page.tools.append(none_schema_tool)
page.nextCursor = None
@@ -4777,7 +4885,7 @@ async def test_mcp_tool_call_tool_forwards_tool_list_meta():
async def test_mcp_tool_call_tool_user_meta_merges_with_tool_list_meta():
"""User-provided _meta should be sent as MCP request metadata, not tool arguments."""
"""Tools/list _meta should win over caller-provided _meta on conflicts."""
from opentelemetry import trace
tool_meta = {"from_tool": "tool-value", "shared": "tool-value"}
@@ -4817,11 +4925,153 @@ async def test_mcp_tool_call_tool_user_meta_merges_with_tool_list_meta():
assert call_kwargs["meta"] == {
"from_tool": "tool-value",
"from_user": "user-value",
"shared": "user-value",
"shared": "tool-value",
}
assert user_meta == {"from_user": "user-value", "shared": "user-value"}
async def test_mcp_tool_function_invocation_strips_model_supplied_meta() -> None:
"""Model-supplied _meta should not become MCP request metadata."""
from opentelemetry import trace
class TestServer(MCPTool):
async def connect(self) -> None: # type: ignore[override] # pyrefly: ignore[bad-override] # ty: ignore[invalid-method-override]
self.session = Mock(spec=ClientSession)
self.session.list_tools = AsyncMock(
return_value=types.ListToolsResult(
tools=[
types.Tool(
name="test_tool",
description="Test tool",
inputSchema={"type": "object", "properties": {"param": {"type": "string"}}},
)
]
)
)
self.session.call_tool = AsyncMock(
return_value=types.CallToolResult(content=[types.TextContent(type="text", text="result")])
)
def get_mcp_client(self) -> _AsyncGeneratorContextManager[Any, None]:
return None # type: ignore[return-value] # pyrefly: ignore[bad-return] # ty: ignore[invalid-return-type]
server = TestServer(name="test_server")
async with server:
await server.load_tools()
with (
trace.use_span(trace.NonRecordingSpan(trace.INVALID_SPAN_CONTEXT)),
patch("agent_framework._mcp.propagate.inject", side_effect=lambda carrier: None),
):
await server.functions[0].invoke(
arguments={"param": "test_value", "_meta": {"attacker.example/route": "evil"}}
)
call_kwargs = server.session.call_tool.call_args.kwargs # type: ignore[union-attr] # ty: ignore[unresolved-attribute]
assert call_kwargs["arguments"] == {"param": "test_value"}
assert call_kwargs["meta"] is None
async def test_mcp_tool_function_invocation_preserves_trusted_meta_over_model_meta() -> None:
"""Trusted function-invocation _meta should be restored after model arguments are merged."""
from opentelemetry import trace
trusted_meta = {"trusted.example/route": "trusted"}
class TestServer(MCPTool):
async def connect(self) -> None: # type: ignore[override] # pyrefly: ignore[bad-override] # ty: ignore[invalid-method-override]
self.session = Mock(spec=ClientSession)
self.session.list_tools = AsyncMock(
return_value=types.ListToolsResult(
tools=[
types.Tool(
name="test_tool",
description="Test tool",
inputSchema={"type": "object", "properties": {"param": {"type": "string"}}},
)
]
)
)
self.session.call_tool = AsyncMock(
return_value=types.CallToolResult(content=[types.TextContent(type="text", text="result")])
)
def get_mcp_client(self) -> _AsyncGeneratorContextManager[Any, None]:
return None # type: ignore[return-value] # pyrefly: ignore[bad-return] # ty: ignore[invalid-return-type]
server = TestServer(name="test_server")
async with server:
await server.load_tools()
context = FunctionInvocationContext(
function=server.functions[0],
arguments={},
kwargs={"_meta": trusted_meta},
)
with (
trace.use_span(trace.NonRecordingSpan(trace.INVALID_SPAN_CONTEXT)),
patch("agent_framework._mcp.propagate.inject", side_effect=lambda carrier: None),
):
await server.functions[0].invoke(
arguments={"param": "test_value", "_meta": {"attacker.example/route": "evil"}},
context=context,
)
call_kwargs = server.session.call_tool.call_args.kwargs # type: ignore[union-attr] # ty: ignore[unresolved-attribute]
assert call_kwargs["arguments"] == {"param": "test_value"}
assert call_kwargs["meta"] == trusted_meta
async def test_mcp_tool_call_tool_otel_meta_overrides_user_meta_but_not_tool_list_meta() -> None:
"""OpenTelemetry should override caller metadata while tools/list metadata remains most trusted."""
from opentelemetry import trace
tool_meta = {"traceparent": "tool-traceparent", "from_tool": "tool-value"}
user_meta = {"traceparent": "user-traceparent", "from_user": "user-value"}
class TestServer(MCPTool):
async def connect(self) -> None: # type: ignore[override] # pyrefly: ignore[bad-override] # ty: ignore[invalid-method-override]
self.session = Mock(spec=ClientSession)
self.session.list_tools = AsyncMock(
return_value=types.ListToolsResult(
tools=[
types.Tool(
name="test_tool",
description="Test tool",
inputSchema={"type": "object", "properties": {"param": {"type": "string"}}},
_meta=tool_meta,
)
]
)
)
self.session.call_tool = AsyncMock(
return_value=types.CallToolResult(content=[types.TextContent(type="text", text="result")])
)
def get_mcp_client(self) -> _AsyncGeneratorContextManager[Any, None]:
return None # type: ignore[return-value] # pyrefly: ignore[bad-return] # ty: ignore[invalid-return-type]
server = TestServer(name="test_server")
async with server:
await server.load_tools()
with (
trace.use_span(trace.NonRecordingSpan(trace.INVALID_SPAN_CONTEXT)),
patch(
"agent_framework._mcp.propagate.inject",
side_effect=lambda carrier: carrier.update({"traceparent": "otel-traceparent"}),
),
):
await server.call_tool("test_tool", param="test_value", _meta=user_meta)
call_kwargs = server.session.call_tool.call_args.kwargs # type: ignore[union-attr] # ty: ignore[unresolved-attribute]
assert call_kwargs["meta"] == {
"traceparent": "tool-traceparent",
"from_tool": "tool-value",
"from_user": "user-value",
}
async def test_mcp_streamable_http_tool_hook_not_duplicated_on_repeated_get_mcp_client():
"""Test that calling get_mcp_client multiple times does not accumulate duplicate hooks."""
tool = MCPStreamableHTTPTool(
@@ -6475,6 +6725,30 @@ def test_prepare_call_kwargs_extracts_meta() -> None:
assert meta.get("trace") == "abc"
@pytest.mark.parametrize(
"key",
[
"",
"_leading-underscore",
"trailing-underscore_",
"abc/",
"1bad.example/name",
"bad..example/name",
"bad.example/_name",
"bad.example/name_",
],
)
def test_prepare_call_kwargs_rejects_invalid_meta_key_names(key: str) -> None:
server = MCPTool(name="test_server") # type: ignore[abstract]
server._tool_param_names_by_name = {"test_tool": {"param"}}
with pytest.raises(ToolExecutionException, match="Invalid MCP _meta key name"):
server._prepare_call_kwargs(
"test_tool",
{"param": "v", "_meta": {key: "value"}},
)
async def test_call_tool_forwards_only_declared_arguments() -> None:
"""End-to-end: framework runtime kwargs are stripped before reaching the server."""
@@ -3132,6 +3132,223 @@ def test_get_span_attributes_with_agent_info():
assert attrs[OtelAttr.AGENT_DESCRIPTION] == "A test agent"
def test_get_span_attributes_emits_otel_tool_definitions() -> None:
"""``tools`` are serialized to OTel GenAI tool definitions on the span."""
import json as _json
from agent_framework import tool
from agent_framework.observability import OtelAttr, _get_span_attributes
@tool(name="echo", description="Echo input")
def echo(value: str) -> str:
return value
attrs = _get_span_attributes(
operation_name="chat",
provider_name="openai",
model="gpt-4",
service_url="https://api.openai.com",
tools=[
echo,
{
"type": "function",
"function": {
"name": "lookup",
"description": "Lookup by id",
"parameters": {"type": "object", "properties": {"id": {"type": "string"}}},
},
},
{"type": "web_search", "name": "web_search"},
],
)
assert OtelAttr.TOOL_DEFINITIONS in attrs
definitions = _json.loads(attrs[OtelAttr.TOOL_DEFINITIONS])
assert definitions == [
{
"type": "function",
"name": "echo",
"description": "Echo input",
"parameters": echo.parameters(),
},
{
"type": "function",
"name": "lookup",
"description": "Lookup by id",
"parameters": {"type": "object", "properties": {"id": {"type": "string"}}},
},
{"type": "web_search", "name": "web_search"},
]
def test_get_span_attributes_omits_tool_definitions_when_unparseable() -> None:
"""When no tool can be converted, the tool definitions attribute is omitted."""
from agent_framework.observability import OtelAttr, _get_span_attributes
attrs = _get_span_attributes(
operation_name="chat",
provider_name="openai",
model="gpt-4",
service_url="https://api.openai.com",
tools=[{"kind": "not_an_otel_tool"}],
)
assert OtelAttr.TOOL_DEFINITIONS not in attrs
def test_tools_to_dict_supports_pydantic_tool_models() -> None:
"""Pydantic-based tool specs are reshaped into the OTel GenAI tool-definition shape."""
from pydantic import BaseModel
from agent_framework.observability import _tools_to_dict
class ProviderTool(BaseModel):
type: str
name: str
enabled: bool = True
note: str | None = None
result = _tools_to_dict([ProviderTool(type="web_search", name="web_search")])
assert result == [{"type": "web_search", "name": "web_search", "enabled": True}]
def test_tools_to_dict_returns_none_for_empty_input() -> None:
"""``_tools_to_dict`` returns None when no tools are supplied."""
from agent_framework.observability import _tools_to_dict
assert _tools_to_dict(None) is None
assert _tools_to_dict([]) is None
def test_tools_to_dict_function_tool_uses_otel_function_definition() -> None:
"""``FunctionTool`` instances are emitted as flat OTel FunctionToolDefinition dicts."""
from agent_framework import tool
from agent_framework.observability import _tools_to_dict
@tool(name="add", description="Add two numbers")
def add(x: int, y: int) -> int:
return x + y
result = _tools_to_dict([add])
assert result is not None
assert len(result) == 1
definition = result[0]
assert definition["type"] == "function"
assert definition["name"] == "add"
assert definition["description"] == "Add two numbers"
assert definition["parameters"]["type"] == "object"
assert set(definition["parameters"]["required"]) == {"x", "y"}
# The legacy OpenAI Chat Completions ``function`` wrapper is not part of the OTel shape.
assert "function" not in definition
def test_tools_to_dict_flattens_openai_chat_completions_function_spec() -> None:
"""OpenAI Chat Completions nested ``function`` spec is flattened to the OTel shape."""
from agent_framework.observability import _tools_to_dict
openai_spec = {
"type": "function",
"function": {
"name": "lookup_user",
"description": "Look up a user by id",
"parameters": {
"type": "object",
"properties": {"user_id": {"type": "string"}},
"required": ["user_id"],
},
"strict": True,
},
}
result = _tools_to_dict([openai_spec])
assert result == [
{
"type": "function",
"name": "lookup_user",
"description": "Look up a user by id",
"parameters": {
"type": "object",
"properties": {"user_id": {"type": "string"}},
"required": ["user_id"],
},
"strict": True,
}
]
def test_tools_to_dict_passes_through_hosted_tool_dicts() -> None:
"""Hosted-tool dicts pass through with the OTel required keys preserved."""
from agent_framework.observability import _tools_to_dict
result = _tools_to_dict([{"type": "web_search", "name": "web_search", "max_results": 5}])
assert result == [{"type": "web_search", "name": "web_search", "max_results": 5}]
def test_tools_to_dict_falls_back_to_type_when_name_missing() -> None:
"""Hosted-tool dicts without ``name`` fall back to the ``type`` value."""
from agent_framework.observability import _tools_to_dict
result = _tools_to_dict([{"type": "code_interpreter"}])
assert result == [{"type": "code_interpreter", "name": "code_interpreter"}]
def test_tools_to_dict_warns_when_type_missing(caplog: pytest.LogCaptureFixture) -> None:
"""Tools without an extractable ``type`` are skipped with a warning."""
from agent_framework.observability import _tools_to_dict
with caplog.at_level("WARNING", logger="agent_framework"):
result = _tools_to_dict([{"kind": "not_an_otel_tool"}])
assert result is None
assert any("missing 'type'" in rec.message for rec in caplog.records)
def test_tools_to_dict_warns_for_unknown_tool_object(caplog: pytest.LogCaptureFixture) -> None:
"""Tools that are neither callable, mapping, BaseModel, nor known type are skipped."""
from agent_framework.observability import _tools_to_dict
class _Opaque:
pass
with caplog.at_level("WARNING", logger="agent_framework"):
result = _tools_to_dict([_Opaque()])
assert result is None
assert any("OpenTelemetry tool definition" in rec.message for rec in caplog.records)
def test_tool_to_otel_definition_caches_per_tool_object() -> None:
"""Converting the same tool object twice reuses the cached OTel definition."""
from agent_framework import tool
from agent_framework.observability import _build_tool_otel_definition, _tool_to_otel_definition
@tool(name="add", description="Add two numbers")
def add(x: int, y: int) -> int:
return x + y
first = _tool_to_otel_definition(add)
second = _tool_to_otel_definition(add)
# The cached result is returned as the same object on subsequent conversions.
assert first is second
# A fresh (uncached) build produces an equal but distinct object.
assert _build_tool_otel_definition(add) == first
def test_tool_to_otel_definition_skips_cache_for_unhashable_specs() -> None:
"""Plain-dict tool specs are converted without raising despite being uncacheable."""
from agent_framework.observability import _tool_to_otel_definition
spec = {"type": "web_search", "name": "web_search"}
assert _tool_to_otel_definition(spec) == {"type": "web_search", "name": "web_search"}
# region Test _capture_response
@@ -19,26 +19,12 @@ from agent_framework._middleware import FunctionInvocationContext
from agent_framework._tools import (
_parse_annotation,
_parse_inputs,
_tools_to_dict,
)
from agent_framework.observability import OtelAttr
# region FunctionTool and tool decorator tests
def test_tools_to_dict_supports_pydantic_tool_models() -> None:
"""Pydantic-based tool specs are serialized without logging parse warnings."""
class ProviderTool(BaseModel):
kind: str
enabled: bool = True
note: str | None = None
result = _tools_to_dict([ProviderTool(kind="google_search")])
assert result == [{"kind": "google_search", "enabled": True}]
def test_tool_decorator():
"""Test the tool decorator."""
@@ -3860,6 +3860,61 @@ class TestResponseStreamMapAndWithFinalizer:
final = await outer.get_final_response()
assert final.text == "mapped_update_0mapped_update_1"
async def test_flat_map_expands_updates(self) -> None:
"""flat_map() can transform one update into many updates."""
inner = ResponseStream(_generate_updates(2), finalizer=_combine_updates)
def expand(update: ChatResponseUpdate) -> list[ChatResponseUpdate]:
return [
ChatResponseUpdate(contents=[Content.from_text(update.text)], role=cast(Any, update.role)),
ChatResponseUpdate(contents=[Content.from_text(f"{update.text}_extra")], role=cast(Any, update.role)),
]
outer = inner.flat_map(expand, _combine_updates)
collected: list[str] = []
async for update in outer:
collected.append(update.text or "")
assert collected == ["update_0", "update_0_extra", "update_1", "update_1_extra"]
final = await outer.get_final_response()
assert final.text == "update_0update_0_extraupdate_1update_1_extra"
async def test_flat_map_skips_empty_mappings(self) -> None:
"""flat_map() supports zero-output transforms."""
inner = ResponseStream(_generate_updates(3), finalizer=_combine_updates)
def keep_odd(update: ChatResponseUpdate) -> list[ChatResponseUpdate]:
return [update] if update.text == "update_1" else []
outer = inner.flat_map(keep_odd, _combine_updates)
collected = [update.text async for update in outer]
assert collected == ["update_1"]
final = await outer.get_final_response()
assert final.text == "update_1"
async def test_flat_map_calls_inner_result_hooks(self) -> None:
"""flat_map() preserves inner result hooks."""
inner_result_hook_called = {"value": False}
def inner_result_hook(response: ChatResponse) -> ChatResponse:
inner_result_hook_called["value"] = True
return response
inner = ResponseStream(
_generate_updates(2),
finalizer=_combine_updates,
result_hooks=[inner_result_hook], # ty: ignore[invalid-argument-type]
)
outer = inner.flat_map(lambda u: [u], _combine_updates)
await outer.get_final_response()
assert inner_result_hook_called["value"] is True
async def test_outer_transform_hooks_independent(self) -> None:
"""Outer stream has its own independent transform hooks."""
inner_hook_calls = {"value": 0}
@@ -336,6 +336,97 @@ async def test_workflow_checkpoint_chaining_via_previous_checkpoint_id():
)
async def test_workflow_checkpoint_ancestry_preserved_after_resume():
"""Resuming from a checkpoint must preserve ancestry: future checkpoints chain back to the resumed one."""
from typing_extensions import Never
from agent_framework import WorkflowBuilder, WorkflowContext, handler
from agent_framework._workflows._executor import Executor
class StartExecutor(Executor):
@handler
async def run(self, message: str, ctx: WorkflowContext[str]) -> None:
await ctx.send_message(message, target_id="middle")
class MiddleExecutor(Executor):
@handler
async def process(self, message: str, ctx: WorkflowContext[str]) -> None:
await ctx.send_message(message + "-processed", target_id="finish")
class FinishExecutor(Executor):
@handler
async def finish(self, message: str, ctx: WorkflowContext[Never, str]) -> None: # type: ignore[valid-type]
await ctx.yield_output(message + "-done")
storage = InMemoryCheckpointStorage()
def _build_workflow() -> Any:
start = StartExecutor(id="start")
middle = MiddleExecutor(id="middle")
finish = FinishExecutor(id="finish")
return (
WorkflowBuilder(
name="resume-ancestry-test",
max_iterations=10,
start_executor=start,
checkpoint_storage=storage,
)
.add_edge(start, middle)
.add_edge(middle, finish)
.build()
)
# First run: produce an initial chain of checkpoints
workflow = _build_workflow()
workflow_name = workflow.name
_ = [event async for event in workflow.run("hello", stream=True)]
initial_checkpoints = sorted(await storage.list_checkpoints(workflow_name=workflow_name), key=lambda c: c.timestamp)
assert len(initial_checkpoints) >= 3, (
f"Need at least 3 initial checkpoints to pick a middle one, got {len(initial_checkpoints)}"
)
initial_ids = {cp.checkpoint_id for cp in initial_checkpoints}
# Pick an intermediate checkpoint to resume from (not the first, not the last)
resume_from = initial_checkpoints[len(initial_checkpoints) // 2]
# Resume on a fresh workflow instance (same graph signature) and run to completion
resumed_workflow = _build_workflow()
assert resumed_workflow.name == workflow_name
_ = [event async for event in resumed_workflow.run(checkpoint_id=resume_from.checkpoint_id, stream=True)]
# Inspect new checkpoints created after resuming
all_checkpoints = sorted(await storage.list_checkpoints(workflow_name=workflow_name), key=lambda c: c.timestamp)
new_checkpoints = [cp for cp in all_checkpoints if cp.checkpoint_id not in initial_ids]
assert new_checkpoints, "Resuming from an intermediate checkpoint should produce new checkpoints"
# The very first checkpoint created after resuming must chain back to the resumed checkpoint
assert new_checkpoints[0].previous_checkpoint_id == resume_from.checkpoint_id, (
"First post-resume checkpoint must chain to the checkpoint that was resumed from; "
f"got previous_checkpoint_id={new_checkpoints[0].previous_checkpoint_id!r}, "
f"expected {resume_from.checkpoint_id!r}"
)
# Subsequent post-resume checkpoints must continue chaining
for i in range(1, len(new_checkpoints)):
assert new_checkpoints[i].previous_checkpoint_id == new_checkpoints[i - 1].checkpoint_id, (
f"Post-resume checkpoint {i} should chain to checkpoint {i - 1}"
)
# Walking the chain backwards from the most recent checkpoint must reach the original root
# without breaks (i.e. the full ancestry across the resume boundary is intact).
checkpoints_by_id = {cp.checkpoint_id: cp for cp in all_checkpoints}
chain: list[str] = []
cursor: str | None = new_checkpoints[-1].checkpoint_id
while cursor is not None:
chain.append(cursor)
cursor = checkpoints_by_id[cursor].previous_checkpoint_id
# Chain must include the resumed-from checkpoint and terminate at the original root
assert resume_from.checkpoint_id in chain
assert chain[-1] == initial_checkpoints[0].checkpoint_id
assert checkpoints_by_id[chain[-1]].previous_checkpoint_id is None
async def test_memory_checkpoint_storage_roundtrip_json_native_types():
"""Test that JSON-native types (str, int, float, bool, None) roundtrip correctly."""
storage = InMemoryCheckpointStorage()
@@ -23,7 +23,7 @@ class StartExecutor(Executor):
class FinishExecutor(Executor):
@handler
async def finish(self, message: str, ctx: WorkflowContext[Never, str]) -> None: # type: ignore[valid-type]
async def finish(self, message: str, ctx: WorkflowContext[Never, str]) -> None: # zuban: ignore
await ctx.yield_output(message)
@@ -95,7 +95,7 @@ class SubStartExecutor(Executor):
class SubFinishExecutor(Executor):
@handler
async def finish(self, message: str, ctx: WorkflowContext[Never, str]) -> None: # type: ignore[valid-type]
async def finish(self, message: str, ctx: WorkflowContext[Never, str]) -> None: # zuban: ignore
await ctx.yield_output(message)
@@ -17,7 +17,6 @@ from agent_framework import (
WorkflowContext,
WorkflowConvergenceException,
WorkflowEvent,
WorkflowRunnerException,
WorkflowRunState,
handler,
)
@@ -305,40 +304,62 @@ async def test_fanout_edge_runner_delivers_to_multiple_targets_concurrently() ->
assert probe_target.call_count == 1
async def test_runner_already_running():
"""Test that running the runner while it is already running raises an error."""
async def test_runner_run_until_convergence_runs_sequentially():
"""run_until_convergence can be invoked back-to-back on the same Runner.
The Runner itself does not enforce concurrency; that responsibility lives on
:class:`Workflow`. This test simply confirms the Runner is reusable across
sequential runs.
"""
runner = _make_runner()
async for _ in runner.run_until_convergence():
pass
async for _ in runner.run_until_convergence():
pass
def _make_runner() -> Runner:
"""Build a minimal runner for runner-level tests."""
return Runner(
[],
{},
State(),
InProcRunnerContext(),
"test_name",
graph_signature_hash="test_hash",
)
async def test_runner_accepts_new_run_after_previous_failure():
"""A failed run must not leave the Runner unable to start a new run.
After the first run raises, ``run_until_convergence()`` must be callable
again and not surface any lifecycle-related rejection.
"""
executor_a = MockExecutor(id="executor_a")
executor_b = MockExecutor(id="executor_b")
# Create a loop
edges = [
SingleEdgeGroup(executor_a.id, executor_b.id),
SingleEdgeGroup(executor_b.id, executor_a.id),
]
executors: dict[str, Executor] = {
executor_a.id: executor_a,
executor_b.id: executor_b,
}
executors: dict[str, Executor] = {executor_a.id: executor_a, executor_b.id: executor_b}
state = State()
ctx = InProcRunnerContext()
runner = Runner(edges, executors, state, ctx, "test_name", graph_signature_hash="test_hash", max_iterations=2)
runner = Runner(edges, executors, state, ctx, "test_name", graph_signature_hash="test_hash")
await executor_a.execute(MockMessage(data=0), ["START"], state, ctx)
await executor_a.execute(
MockMessage(data=0),
["START"], # source_executor_ids
state, # state
ctx, # runner_context
)
with pytest.raises(WorkflowConvergenceException):
async for _ in runner.run_until_convergence():
pass
with pytest.raises(WorkflowRunnerException, match="Runner is already running."):
async def _run():
async for _ in runner.run_until_convergence():
pass
await asyncio.gather(_run(), _run())
# A second run on the same Runner must not be blocked by stale lifecycle
# state from the failed run.
try:
async for _ in runner.run_until_convergence():
pass
except Exception as exc:
assert "Runner is already running" not in str(exc), "Runner stayed locked after a failed run"
async def test_runner_emits_runner_completion_for_agent_response_without_targets():
@@ -862,7 +883,13 @@ async def test_runner_checkpoint_with_resumed_flag():
state = State()
runner = Runner(edges, executors, state, ctx, "test_name", graph_signature_hash="test_hash")
runner._mark_resumed(5) # pyright: ignore[reportPrivateUsage]
resumed_checkpoint = WorkflowCheckpoint(
checkpoint_id="resumed-cp",
workflow_name="test_name",
graph_signature_hash="test_hash",
iteration_count=5,
)
runner._mark_resumed(resumed_checkpoint) # pyright: ignore[reportPrivateUsage]
# Add a message to trigger the checkpoint creation path
await ctx.send_message(WorkflowMessage(data=MockMessage(data=8), source_id="START"))
@@ -882,6 +909,86 @@ async def test_runner_checkpoint_with_resumed_flag():
assert runner._resumed_from_checkpoint is False # pyright: ignore[reportPrivateUsage]
async def test_runner_mark_resumed_sets_previous_checkpoint_id():
"""_mark_resumed must populate _previous_checkpoint_id so future checkpoints chain back to the resume point."""
runner = Runner(
[],
{},
State(),
InProcRunnerContext(),
"test_name",
graph_signature_hash="test_hash",
)
# Pre-condition: nothing to chain back to
assert runner._previous_checkpoint_id is None # pyright: ignore[reportPrivateUsage]
resumed_checkpoint = WorkflowCheckpoint(
checkpoint_id="resumed-cp-id",
workflow_name="test_name",
graph_signature_hash="test_hash",
iteration_count=3,
)
runner._mark_resumed(resumed_checkpoint) # pyright: ignore[reportPrivateUsage]
assert runner._resumed_from_checkpoint is True # pyright: ignore[reportPrivateUsage]
assert runner._iteration == 3 # pyright: ignore[reportPrivateUsage]
assert runner._previous_checkpoint_id == "resumed-cp-id" # pyright: ignore[reportPrivateUsage]
async def test_runner_post_resume_checkpoint_chains_to_resumed_checkpoint():
"""After resuming, the next checkpoint created must reference the resumed checkpoint as its parent."""
storage = InMemoryCheckpointStorage()
ctx = CheckpointingContext(storage)
executor_a = MockExecutor(id="executor_a")
executor_b = MockExecutor(id="executor_b")
edges = [
SingleEdgeGroup(executor_a.id, executor_b.id),
SingleEdgeGroup(executor_b.id, executor_a.id),
]
executors: dict[str, Executor] = {
executor_a.id: executor_a,
executor_b.id: executor_b,
}
state = State()
runner = Runner(edges, executors, state, ctx, "test_name", graph_signature_hash="test_hash")
# Simulate having resumed from a prior checkpoint
resumed_checkpoint = WorkflowCheckpoint(
checkpoint_id="parent-checkpoint-id",
workflow_name="test_name",
graph_signature_hash="test_hash",
iteration_count=1,
)
runner._mark_resumed(resumed_checkpoint) # pyright: ignore[reportPrivateUsage]
# Seed a message so the runner has work to do (and creates checkpoints at superstep boundaries)
await ctx.send_message(WorkflowMessage(data=MockMessage(data=8), source_id=executor_a.id))
async for _ in runner.run_until_convergence():
pass
# Find the first checkpoint created after the resume point (across all workflows tracked by storage)
new_checkpoints = sorted(
await storage.list_checkpoints(workflow_name="test_name"),
key=lambda c: c.timestamp,
)
assert new_checkpoints, "Resuming and running should produce at least one new checkpoint"
# The first new checkpoint must chain to the resumed-from checkpoint, not to None
assert new_checkpoints[0].previous_checkpoint_id == "parent-checkpoint-id", (
"First post-resume checkpoint must chain to the resumed checkpoint id; "
f"got {new_checkpoints[0].previous_checkpoint_id!r}"
)
# Subsequent post-resume checkpoints continue the chain
for i in range(1, len(new_checkpoints)):
assert new_checkpoints[i].previous_checkpoint_id == new_checkpoints[i - 1].checkpoint_id
class ExecutorThatFailsWithEvents(Executor):
"""An executor that emits events and then raises an exception after receiving messages."""
@@ -951,6 +1058,172 @@ async def test_runner_drains_events_on_iteration_exception():
assert len(output_events) >= 1
async def test_runner_resumed_flag_reset_after_failed_resumed_run():
"""A failed *resumed* run must not leak the resume flag into the next run.
The resume flag suppresses the initial "superstep 0" (entry) checkpoint when resuming from an
iteration-0 checkpoint (which already exists and must not be recreated). It used to be cleared
only on the success path, so an executor failure during a resumed run left it ``True`` and the
next fresh run wrongly skipped its entry checkpoint. The flag is now cleared in a ``finally`` so
this holds even when convergence raises.
This also verifies checkpoint creation on the re-run: the resumed (failed) run creates no entry
checkpoint, while the subsequent fresh run does.
"""
storage = InMemoryCheckpointStorage()
ctx = CheckpointingContext(storage)
executor_a = PassthroughExecutor(id="executor_a")
executor_b = ExecutorThatFailsWithEvents(id="executor_b", runner_ctx=ctx, fail_on_iteration=1)
edges = [SingleEdgeGroup(executor_a.id, executor_b.id)]
executors: dict[str, Executor] = {executor_a.id: executor_a, executor_b.id: executor_b}
state = State()
runner = Runner(edges, executors, state, ctx, "test_name", graph_signature_hash="test_hash")
# Simulate a resumed run; this marks the runner as resumed so the next run skips
# the superstep-0 checkpoint.
resumed_checkpoint = WorkflowCheckpoint(
checkpoint_id="resumed-cp",
workflow_name="test_name",
graph_signature_hash="test_hash",
iteration_count=0,
)
runner._mark_resumed(resumed_checkpoint) # pyright: ignore[reportPrivateUsage]
assert runner._resumed_from_checkpoint is True # pyright: ignore[reportPrivateUsage]
# Run the resumed turn; executor_b fails mid-iteration before any superstep
# checkpoint is created.
await executor_a.execute(MockMessage(data=0), ["START"], state, ctx)
with pytest.raises(RuntimeError, match="Executor failed with pending events"):
async for _ in runner.run_until_convergence():
pass
# The fix: the resume flag is cleared even though the run raised.
assert runner._resumed_from_checkpoint is False # pyright: ignore[reportPrivateUsage]
# The resumed (failed) run created no superstep-0 checkpoint (it was skipped).
assert await storage.list_checkpoints(workflow_name="test_name") == []
# Re-run as a fresh turn: with the flag correctly reset, the runner now creates
# the initial superstep-0 checkpoint (iteration_count == 0) before failing again.
runner.reset_iteration_count()
await executor_a.execute(MockMessage(data=0), ["START"], state, ctx)
with pytest.raises(RuntimeError, match="Executor failed with pending events"):
async for _ in runner.run_until_convergence():
pass
checkpoints = await storage.list_checkpoints(workflow_name="test_name")
assert any(cp.iteration_count == 0 for cp in checkpoints), (
"Fresh run after a failed resumed run must create the superstep-0 checkpoint; "
"a leaked resume flag would have skipped it"
)
async def test_runner_creates_entry_checkpoint_at_iteration_zero():
"""A fresh run creates the entry (superstep-0) checkpoint at iteration 0 with no parent.
This is the baseline the lineage-consistency guard must preserve: when starting from iteration 0
with messages queued and not resumed, the entry checkpoint is created and begins a new lineage
(``previous_checkpoint_id is None``).
"""
storage = InMemoryCheckpointStorage()
ctx = CheckpointingContext(storage)
# Terminal executor with no outgoing edges: the runner runs one superstep and converges.
source = MockExecutor(id="source")
state = State()
runner = Runner([], {source.id: source}, state, ctx, "test_name", graph_signature_hash="test_hash")
assert runner._iteration == 0 # pyright: ignore[reportPrivateUsage]
assert runner._resumed_from_checkpoint is False # pyright: ignore[reportPrivateUsage]
await ctx.send_message(WorkflowMessage(data=MockMessage(data=8), source_id=source.id))
async for _ in runner.run_until_convergence():
pass
checkpoints = await storage.list_checkpoints(workflow_name="test_name")
entry_checkpoints = [cp for cp in checkpoints if cp.iteration_count == 0]
assert len(entry_checkpoints) == 1, "A fresh run must create exactly one entry checkpoint at iteration 0"
assert entry_checkpoints[0].previous_checkpoint_id is None, (
"The entry checkpoint of a fresh run must begin a new lineage with no parent"
)
async def test_runner_skips_entry_checkpoint_when_iteration_nonzero():
"""The entry (superstep-0) checkpoint must only be created at iteration 0 to keep lineage consistent.
A re-run that did not reset the iteration count (and is not marked as resumed) must not write an
entry checkpoint carrying a non-zero ``iteration_count`` - doing so would place two checkpoints at
the same iteration in the lineage. The ``_iteration == 0`` guard suppresses the entry checkpoint in
this case while still allowing the normal per-superstep checkpoints to be created.
"""
storage = InMemoryCheckpointStorage()
ctx = CheckpointingContext(storage)
# Terminal executor with no outgoing edges: the runner runs one superstep and converges.
source = MockExecutor(id="source")
state = State()
runner = Runner([], {source.id: source}, state, ctx, "test_name", graph_signature_hash="test_hash")
# Simulate a re-run that kept its iteration count and is not marked as resumed.
runner._iteration = 5 # pyright: ignore[reportPrivateUsage]
assert runner._resumed_from_checkpoint is False # pyright: ignore[reportPrivateUsage]
await ctx.send_message(WorkflowMessage(data=MockMessage(data=8), source_id=source.id))
async for _ in runner.run_until_convergence():
pass
checkpoints = await storage.list_checkpoints(workflow_name="test_name")
# No entry checkpoint at the pre-existing iteration count may be created.
assert all(cp.iteration_count != 5 for cp in checkpoints), (
"Entry checkpoint must not be created at a non-zero iteration; lineage would have a duplicate iteration"
)
# The normal post-superstep checkpoint is still created (iteration advanced to 6).
assert any(cp.iteration_count == 6 for cp in checkpoints)
async def test_runner_resumed_from_iteration_zero_skips_entry_checkpoint():
"""Resuming from an iteration-0 checkpoint must not recreate the entry checkpoint.
Here ``_iteration == 0`` is true, so the iteration guard alone would not suppress the entry
checkpoint; the resume flag is what prevents recreating the checkpoint that already exists at
iteration 0.
"""
storage = InMemoryCheckpointStorage()
ctx = CheckpointingContext(storage)
source = MockExecutor(id="source")
state = State()
runner = Runner([], {source.id: source}, state, ctx, "test_name", graph_signature_hash="test_hash")
# Resume from an iteration-0 checkpoint: iteration stays 0 but the run is marked as resumed.
resumed_checkpoint = WorkflowCheckpoint(
checkpoint_id="entry-cp",
workflow_name="test_name",
graph_signature_hash="test_hash",
iteration_count=0,
)
runner._mark_resumed(resumed_checkpoint) # pyright: ignore[reportPrivateUsage]
assert runner._iteration == 0 # pyright: ignore[reportPrivateUsage]
assert runner._resumed_from_checkpoint is True # pyright: ignore[reportPrivateUsage]
await ctx.send_message(WorkflowMessage(data=MockMessage(data=8), source_id=source.id))
async for _ in runner.run_until_convergence():
pass
# The pre-loop entry checkpoint is skipped; only the post-superstep checkpoint (iteration 1) is created,
# and it chains back to the resumed entry checkpoint.
checkpoints = sorted(
await storage.list_checkpoints(workflow_name="test_name"),
key=lambda c: c.timestamp,
)
assert all(cp.checkpoint_id != "entry-cp" for cp in checkpoints), "Resumed entry checkpoint must not be recreated"
assert checkpoints, "The resumed run must still create its post-superstep checkpoint"
assert checkpoints[0].previous_checkpoint_id == "entry-cp", (
"The first post-resume checkpoint must chain back to the resumed entry checkpoint"
)
class SlowEventEmittingExecutor(Executor):
"""An executor that emits events with delays to test straggler event draining."""
@@ -1,6 +1,7 @@
# Copyright (c) Microsoft. All rights reserved.
import asyncio
import gc
import tempfile
from collections.abc import AsyncIterable, Awaitable, Sequence
from dataclasses import dataclass, field
@@ -19,6 +20,7 @@ from agent_framework import (
Content,
Executor,
FileCheckpointStorage,
InProcRunnerContext,
Message,
ResponseStream,
WorkflowBuilder,
@@ -26,6 +28,7 @@ from agent_framework import (
WorkflowContext,
WorkflowConvergenceException,
WorkflowEvent,
WorkflowException,
WorkflowMessage,
WorkflowRunState,
handler,
@@ -759,8 +762,7 @@ async def test_workflow_concurrent_execution_prevention():
# Try to start a second concurrent execution - this should fail
with pytest.raises(
RuntimeError,
match="Workflow is already running. Concurrent executions are not allowed.",
WorkflowException, match="Workflow is already running; concurrent runs are not allowed on the same instance."
):
await workflow.run(NumberMessage(data=0))
@@ -795,8 +797,7 @@ async def test_workflow_concurrent_execution_prevention_streaming():
# Try to start a second concurrent execution - this should fail
with pytest.raises(
RuntimeError,
match="Workflow is already running. Concurrent executions are not allowed.",
WorkflowException, match="Workflow is already running; concurrent runs are not allowed on the same instance."
):
await workflow.run(NumberMessage(data=0))
@@ -828,14 +829,12 @@ async def test_workflow_concurrent_execution_prevention_mixed_methods():
# Try different execution methods - all should fail
with pytest.raises(
RuntimeError,
match="Workflow is already running. Concurrent executions are not allowed.",
WorkflowException, match="Workflow is already running; concurrent runs are not allowed on the same instance."
):
await workflow.run(NumberMessage(data=0))
with pytest.raises(
RuntimeError,
match="Workflow is already running. Concurrent executions are not allowed.",
WorkflowException, match="Workflow is already running; concurrent runs are not allowed on the same instance."
):
async for _ in workflow.run(NumberMessage(data=0), stream=True):
break
@@ -848,6 +847,238 @@ async def test_workflow_concurrent_execution_prevention_mixed_methods():
assert result.get_final_state() == WorkflowRunState.IDLE
async def test_workflow_sequential_runs_after_completion() -> None:
"""A completed run must release the runner so the next ``run`` succeeds.
This is the happy-path counterpart to the concurrent-run guard tests:
those tests verify that a *concurrent* run is rejected, but they do not
verify that the lock is actually released afterwards. This test
exercises that release path explicitly across the three call shapes
(non-streaming, streaming-iterated, streaming-via-get_final_response)
and across multiple consecutive turns to catch lock leaks.
"""
executor = IncrementExecutor(id="seq_executor", limit=3, increment=1)
workflow = WorkflowBuilder(start_executor=executor).build()
# Non-streaming -> non-streaming
r1 = await workflow.run(NumberMessage(data=0))
assert r1.get_final_state() == WorkflowRunState.IDLE
r2 = await workflow.run(NumberMessage(data=0))
assert r2.get_final_state() == WorkflowRunState.IDLE
# Non-streaming -> streaming-iterated
stream_events: list[WorkflowEvent] = []
async for event in workflow.run(NumberMessage(data=0), stream=True):
stream_events.append(event)
assert any(e.type == "status" and e.state == WorkflowRunState.IDLE for e in stream_events)
# Streaming -> streaming via get_final_response (no manual iteration)
r3 = await workflow.run(NumberMessage(data=0), stream=True).get_final_response()
assert r3.get_final_state() == WorkflowRunState.IDLE
# Streaming -> non-streaming (back to the start)
r4 = await workflow.run(NumberMessage(data=0))
assert r4.get_final_state() == WorkflowRunState.IDLE
async def test_workflow_unconsumed_stream_releases_run_lock() -> None:
"""An unconsumed stream must not leak the run lock.
``Workflow.run`` reserves the runner *synchronously* so that concurrent
callers are rejected immediately. The reservation is normally released
by ``_run_core``'s ``finally`` once the stream is iterated. If the
caller never iterates the stream, a GC-time finalizer must release the
reservation instead - otherwise every subsequent ``Workflow.run`` call
on this instance would fail with the concurrent-run error.
"""
executor = IncrementExecutor(id="unconsumed_stream_exec", limit=3, increment=1)
workflow = WorkflowBuilder(start_executor=executor).build()
# Build a stream and immediately drop it without iterating.
stream = workflow.run(NumberMessage(data=0), stream=True)
assert stream is not None # silence unused-variable warnings; stream is GC'd below
del stream
gc.collect()
# Yield to the event loop so any scheduled finalizer work can run.
await asyncio.sleep(0)
# The runner should be back to IDLE; a fresh run must succeed.
result = await workflow.run(NumberMessage(data=0))
assert result.get_final_state() == WorkflowRunState.IDLE
async def test_workflow_unawaited_run_coroutine_releases_run_lock() -> None:
"""An un-awaited non-streaming ``run()`` coroutine must also not leak the lock.
``Workflow.run`` (non-streaming) returns a coroutine produced by
``ResponseStream.get_final_response``. The underlying ResponseStream is
held alive by that coroutine, so dropping the coroutine without
awaiting it must still release the reservation via the same GC-time
fallback used for unconsumed streams.
"""
executor = IncrementExecutor(id="unawaited_run_exec", limit=3, increment=1)
workflow = WorkflowBuilder(start_executor=executor).build()
coro = workflow.run(NumberMessage(data=0))
# Closing suppresses the "coroutine was never awaited" warning. We cast to
# ``Any`` because the typed return is ``Awaitable[...]``; in practice it is
# a coroutine that exposes ``close``.
cast(Any, coro).close()
del coro
gc.collect()
await asyncio.sleep(0)
result = await workflow.run(NumberMessage(data=0))
assert result.get_final_state() == WorkflowRunState.IDLE
async def test_workflow_partial_stream_does_not_clobber_successor_active_run() -> None:
"""A stale ``_run_core`` finalizer must not clear a successor's run lock.
Repro for the GC-finalizer race the user reported:
1. Start stream A and consume one event so its body is suspended at a
``yield``. Its ``finally`` is now armed and will run when the
generator is closed.
2. Drop stream A and ``gc.collect``. The ``_active_run`` weakref's
referent is gone, so a subsequent ``run()`` will pass the
concurrency guard - but stream A's async-gen finalizer hasn't
actually executed yet (``aclose`` is scheduled on the loop).
3. Synchronously start stream B; ``run()`` installs a fresh weakref
in ``_active_run``.
4. Yield to the loop so stream A's stale ``finally`` runs. Without
the identity check it unconditionally writes
``self._active_run = None``, silently disabling the concurrency
guard for stream B.
"""
executor = IncrementExecutor(id="stale_finalizer_exec", limit=100, increment=1)
workflow = WorkflowBuilder(start_executor=executor).build()
# Step 1: drive stream A's body until it's suspended at its first yield.
stream_a = workflow.run(NumberMessage(data=0), stream=True)
aiter_a = stream_a.__aiter__()
await aiter_a.__anext__()
# Step 2: drop stream A; GC invalidates the weakref and schedules
# async-gen close, but does not run the close inline.
del stream_a
del aiter_a
gc.collect()
# Step 3: synchronously start stream B *before* yielding to the loop,
# so the stale ``aclose`` for stream A hasn't fired yet.
stream_b = workflow.run(NumberMessage(data=0), stream=True)
ref_b = workflow._active_run # type: ignore[attr-defined]
assert ref_b is not None and ref_b() is stream_b
# Step 4: yield enough times for stream A's scheduled aclose to drive
# its body through ``GeneratorExit`` and into its ``finally``.
for _ in range(5):
await asyncio.sleep(0)
# With the fix, stream B's reservation is still in place. Without it,
# ``_active_run`` was clobbered to ``None`` and a concurrent run would
# be (incorrectly) accepted.
assert workflow._active_run is ref_b # type: ignore[attr-defined]
with pytest.raises(
WorkflowException,
match="Workflow is already running; concurrent runs are not allowed on the same instance.",
):
await workflow.run(NumberMessage(data=0))
# Tear down stream B without iterating it (its body never started, so
# closing it is a no-op for workflow state).
del stream_b
del ref_b
gc.collect()
await asyncio.sleep(0)
async def test_workflow_stale_runtime_checkpoint_storage_not_inherited() -> None:
"""A new run must not inherit a prior run's leftover runtime checkpoint storage.
If a run that set a runtime ``checkpoint_storage`` override is dropped before
its async-generator finalizer clears it, the override can linger on the
``RunnerContext`` while ``_is_run_active()`` already reports False. ``run()``
defensively clears that stale override so a subsequent run that does not pass
its own ``checkpoint_storage`` does not silently checkpoint into it.
"""
with tempfile.TemporaryDirectory() as temp_dir:
leftover_storage = FileCheckpointStorage(temp_dir)
executor = IncrementExecutor(id="stale_storage_exec", limit=3, increment=1)
workflow = WorkflowBuilder(start_executor=executor).build()
assert isinstance(workflow._runner.context, InProcRunnerContext) # pyright: ignore[reportPrivateUsage]
# Simulate a leftover runtime override from a dropped prior run.
workflow._runner.context.set_runtime_checkpoint_storage(leftover_storage) # pyright: ignore[reportPrivateUsage]
# A fresh run without its own checkpoint_storage must not use the leftover.
result = await workflow.run(NumberMessage(data=0))
assert result.get_final_state() == WorkflowRunState.IDLE
checkpoints = await leftover_storage.list_checkpoints(workflow_name=workflow.name)
assert checkpoints == [], "Stale runtime checkpoint storage must not be inherited by a new run"
assert workflow._runner.context._runtime_checkpoint_storage is None # pyright: ignore[reportPrivateUsage]
async def test_workflow_partial_stream_does_not_clobber_successor_runtime_storage() -> None:
"""A stale ``_run_core`` finalizer must not clear a successor's runtime storage.
Same GC-finalizer race as
``test_workflow_partial_stream_does_not_clobber_successor_active_run`` but for the
runtime checkpoint storage override: the dropped run's deferred ``finally`` must
only clear the override if it still owns it, otherwise it wipes the successor
run's storage.
"""
with (
tempfile.TemporaryDirectory() as temp_dir_a,
tempfile.TemporaryDirectory() as temp_dir_b,
):
storage_a = FileCheckpointStorage(temp_dir_a)
storage_b = FileCheckpointStorage(temp_dir_b)
executor = IncrementExecutor(id="storage_finalizer_exec", limit=100, increment=1)
workflow = WorkflowBuilder(start_executor=executor).build()
context = workflow._runner.context # pyright: ignore[reportPrivateUsage]
assert isinstance(context, InProcRunnerContext)
# Step 1: drive stream A's body to its first yield so it set storage_a.
stream_a = workflow.run(NumberMessage(data=0), checkpoint_storage=storage_a, stream=True)
aiter_a = stream_a.__aiter__()
await aiter_a.__anext__()
assert context._runtime_checkpoint_storage is storage_a # pyright: ignore[reportPrivateUsage]
# Step 2: drop stream A; the weakref dies and async-gen close is scheduled
# but not run inline.
del stream_a
del aiter_a
gc.collect()
# Step 3: synchronously start stream B with its own storage and drive it to
# its first yield so it set storage_b and took ownership of the override.
stream_b = workflow.run(NumberMessage(data=0), checkpoint_storage=storage_b, stream=True)
aiter_b = stream_b.__aiter__()
await aiter_b.__anext__()
assert context._runtime_checkpoint_storage is storage_b # pyright: ignore[reportPrivateUsage]
# Step 4: yield enough for stream A's scheduled aclose to drive its body
# through ``GeneratorExit`` and into its ``finally``.
for _ in range(5):
await asyncio.sleep(0)
# With the ownership guard, stream B's override survives. Without it, A's
# stale finalizer would have cleared it.
assert context._runtime_checkpoint_storage is storage_b # pyright: ignore[reportPrivateUsage]
# Tear down stream B.
del stream_b
del aiter_b
gc.collect()
await asyncio.sleep(0)
class _StreamingTestAgent(BaseAgent):
"""Test agent that supports both streaming and non-streaming modes."""
@@ -90,7 +90,7 @@ async def _run(yaml_def: dict[str, Any], handler: HttpRequestHandler) -> Any:
def _state(workflow: Any, events: Any) -> dict[str, Any]:
"""Read declarative state out of the workflow after run completes."""
return workflow._state.get(DECLARATIVE_STATE_KEY) or {}
return workflow._runner.state.get(DECLARATIVE_STATE_KEY) or {}
# Helper used by parametrised path tests
@@ -151,7 +151,7 @@ class TestSuccessPath:
workflow = factory.create_workflow_from_definition(_yaml(_action(method="GET", response="Local.Result")))
await workflow.run({})
decl = workflow._state.get(DECLARATIVE_STATE_KEY)
decl = workflow._runner.state.get(DECLARATIVE_STATE_KEY)
assert decl["Local"]["Result"] == {"key": "value", "number": 42}
assert handler.last_info is not None
assert handler.last_info.method == "GET"
@@ -164,7 +164,7 @@ class TestSuccessPath:
workflow = factory.create_workflow_from_definition(_yaml(_action(response="Local.Result")))
await workflow.run({})
decl = workflow._state.get(DECLARATIVE_STATE_KEY)
decl = workflow._runner.state.get(DECLARATIVE_STATE_KEY)
assert decl["Local"]["Result"] == "not-json content"
@pytest.mark.asyncio
@@ -174,7 +174,7 @@ class TestSuccessPath:
workflow = factory.create_workflow_from_definition(_yaml(_action(response="Local.Result")))
await workflow.run({})
decl = workflow._state.get(DECLARATIVE_STATE_KEY)
decl = workflow._runner.state.get(DECLARATIVE_STATE_KEY)
assert decl["Local"]["Result"] is None
@pytest.mark.asyncio
@@ -184,7 +184,7 @@ class TestSuccessPath:
workflow = factory.create_workflow_from_definition(_yaml(_action(response={"path": "Local.Result"})))
await workflow.run({})
decl = workflow._state.get(DECLARATIVE_STATE_KEY)
decl = workflow._runner.state.get(DECLARATIVE_STATE_KEY)
assert decl["Local"]["Result"] == {"x": 1}
@pytest.mark.asyncio
@@ -517,7 +517,7 @@ class TestResponseHeaders:
factory = WorkflowFactory(http_request_handler=handler)
workflow = factory.create_workflow_from_definition(_yaml(_action(response_headers="Local.H")))
await workflow.run({})
decl = workflow._state.get(DECLARATIVE_STATE_KEY)
decl = workflow._runner.state.get(DECLARATIVE_STATE_KEY)
h = decl["Local"]["H"]
assert h["Content-Type"] == "application/json"
assert h["Set-Cookie"] == "a=1,b=2"
@@ -528,7 +528,7 @@ class TestResponseHeaders:
factory = WorkflowFactory(http_request_handler=handler)
workflow = factory.create_workflow_from_definition(_yaml(_action(response_headers="Local.H")))
await workflow.run({})
decl = workflow._state.get(DECLARATIVE_STATE_KEY)
decl = workflow._runner.state.get(DECLARATIVE_STATE_KEY)
assert decl["Local"]["H"] is None
@pytest.mark.asyncio
@@ -538,7 +538,7 @@ class TestResponseHeaders:
workflow = factory.create_workflow_from_definition(_yaml(_action(response_headers="Local.H")))
with pytest.raises(DeclarativeActionError):
await workflow.run({})
decl = workflow._state.get(DECLARATIVE_STATE_KEY)
decl = workflow._runner.state.get(DECLARATIVE_STATE_KEY)
assert decl["Local"]["H"] == {"X-Trace": "abc"}
@@ -559,7 +559,7 @@ class TestConversationAppend:
)
)
await workflow.run({})
decl = workflow._state.get(DECLARATIVE_STATE_KEY)
decl = workflow._runner.state.get(DECLARATIVE_STATE_KEY)
conv = decl["System"]["conversations"].get("conv-test-1")
assert conv is not None
assert len(conv["messages"]) == 1
@@ -570,7 +570,7 @@ class TestConversationAppend:
factory = WorkflowFactory(http_request_handler=handler)
workflow = factory.create_workflow_from_definition(_yaml(_action(response="Local.Result", conversation_id="")))
await workflow.run({})
decl = workflow._state.get(DECLARATIVE_STATE_KEY)
decl = workflow._runner.state.get(DECLARATIVE_STATE_KEY)
# Auto-init creates an entry for the System.ConversationId conversation,
# but it should NOT have HTTP-appended messages from us.
for _cid, conv in decl["System"]["conversations"].items():
@@ -582,7 +582,7 @@ class TestConversationAppend:
factory = WorkflowFactory(http_request_handler=handler)
workflow = factory.create_workflow_from_definition(_yaml(_action(conversation_id="conv-test-1")))
await workflow.run({})
decl = workflow._state.get(DECLARATIVE_STATE_KEY)
decl = workflow._runner.state.get(DECLARATIVE_STATE_KEY)
# No conversation entry should have been created either.
assert "conv-test-1" not in decl["System"]["conversations"]
@@ -73,7 +73,7 @@ async def test_http_request_yaml_roundtrip() -> None:
workflow = factory.create_workflow_from_yaml_path(FIXTURE_PATH)
await workflow.run({})
decl: dict[str, Any] = workflow._state.get(DECLARATIVE_STATE_KEY) or {}
decl: dict[str, Any] = workflow._runner.state.get(DECLARATIVE_STATE_KEY) or {}
local: dict[str, Any] = decl.get("Local") or {}
assert local.get("RepoOwner") == "dotnet"
@@ -244,7 +244,7 @@ class TestOutput:
factory = WorkflowFactory(mcp_tool_handler=handler)
workflow = factory.create_workflow_from_definition(_yaml(_action(output={"result": "Local.Result"})))
await workflow.run({})
decl = workflow._state.get(DECLARATIVE_STATE_KEY)
decl = workflow._runner.state.get(DECLARATIVE_STATE_KEY)
assert decl["Local"]["Result"] == [{"k": "v", "n": 1}]
@pytest.mark.asyncio
@@ -253,7 +253,7 @@ class TestOutput:
factory = WorkflowFactory(mcp_tool_handler=handler)
workflow = factory.create_workflow_from_definition(_yaml(_action(output={"result": "Local.Result"})))
await workflow.run({})
decl = workflow._state.get(DECLARATIVE_STATE_KEY)
decl = workflow._runner.state.get(DECLARATIVE_STATE_KEY)
assert decl["Local"]["Result"] == ["plain text not json"]
@pytest.mark.asyncio
@@ -262,7 +262,7 @@ class TestOutput:
factory = WorkflowFactory(mcp_tool_handler=handler)
workflow = factory.create_workflow_from_definition(_yaml(_action(output={"messages": "Local.Messages"})))
await workflow.run({})
decl = workflow._state.get(DECLARATIVE_STATE_KEY)
decl = workflow._runner.state.get(DECLARATIVE_STATE_KEY)
msg = decl["Local"]["Messages"]
# Single Tool-role message containing both contents (parity with .NET).
assert isinstance(msg, Message)
@@ -276,7 +276,7 @@ class TestOutput:
factory = WorkflowFactory(mcp_tool_handler=handler)
workflow = factory.create_workflow_from_definition(_yaml(_action(output={"result": "Local.Result"})))
await workflow.run({})
decl = workflow._state.get(DECLARATIVE_STATE_KEY)
decl = workflow._runner.state.get(DECLARATIVE_STATE_KEY)
assert decl["Local"]["Result"] == ["https://example.com/file.txt"]
@pytest.mark.asyncio
@@ -285,7 +285,7 @@ class TestOutput:
factory = WorkflowFactory(mcp_tool_handler=handler)
workflow = factory.create_workflow_from_definition(_yaml(_action(output={"result": {"path": "Local.Result"}})))
await workflow.run({})
decl = workflow._state.get(DECLARATIVE_STATE_KEY)
decl = workflow._runner.state.get(DECLARATIVE_STATE_KEY)
assert decl["Local"]["Result"] == ["ok"]
@@ -306,7 +306,7 @@ class TestConversation:
)
)
await workflow.run({})
decl = workflow._state.get(DECLARATIVE_STATE_KEY)
decl = workflow._runner.state.get(DECLARATIVE_STATE_KEY)
conv = decl["System"]["conversations"]["conv-42"]
msgs = conv["messages"] if isinstance(conv, dict) else conv.messages
assert len(msgs) == 1
@@ -328,7 +328,7 @@ class TestConversation:
)
)
await workflow.run({})
decl = workflow._state.get(DECLARATIVE_STATE_KEY)
decl = workflow._runner.state.get(DECLARATIVE_STATE_KEY)
# Empty conversation id must not produce a `""` entry under System.conversations.
conversations = decl.get("System", {}).get("conversations", {})
assert "" not in conversations
@@ -529,7 +529,7 @@ class TestErrorHandling:
factory = WorkflowFactory(mcp_tool_handler=handler)
workflow = factory.create_workflow_from_definition(_yaml(_action(output={"result": "Local.Result"})))
await workflow.run({})
decl = workflow._state.get(DECLARATIVE_STATE_KEY)
decl = workflow._runner.state.get(DECLARATIVE_STATE_KEY)
assert decl["Local"]["Result"] == "Error: server down"
@pytest.mark.asyncio
@@ -538,7 +538,7 @@ class TestErrorHandling:
factory = WorkflowFactory(mcp_tool_handler=handler)
workflow = factory.create_workflow_from_definition(_yaml(_action(output={"result": "Local.Result"})))
await workflow.run({})
decl = workflow._state.get(DECLARATIVE_STATE_KEY)
decl = workflow._runner.state.get(DECLARATIVE_STATE_KEY)
assert decl["Local"]["Result"] == "Error: invalid arguments"
@pytest.mark.asyncio
@@ -547,7 +547,7 @@ class TestErrorHandling:
factory = WorkflowFactory(mcp_tool_handler=handler)
workflow = factory.create_workflow_from_definition(_yaml(_action(output={"result": "Local.Result"})))
await workflow.run({})
decl = workflow._state.get(DECLARATIVE_STATE_KEY)
decl = workflow._runner.state.get(DECLARATIVE_STATE_KEY)
result = decl["Local"]["Result"]
assert isinstance(result, str)
assert result.startswith("Error:")
@@ -291,11 +291,11 @@ actions:
# Stamp a marker into the declarative state between turns. The
# continuation branch must preserve it; a state-clearing run would
# wipe ``DECLARATIVE_STATE_KEY`` and force re-initialization.
state_data = workflow._state.get(DECLARATIVE_STATE_KEY)
state_data = workflow._runner.state.get(DECLARATIVE_STATE_KEY)
assert isinstance(state_data, dict), "Expected declarative state to be initialized after turn 1"
state_data["Local"] = {"persisted_marker": "kept-from-turn-1"}
workflow._state.set(DECLARATIVE_STATE_KEY, state_data)
workflow._state.commit()
workflow._runner.state.set(DECLARATIVE_STATE_KEY, state_data)
workflow._runner.state.commit()
second = await agent.run("turn-2-msg")
assert second.text == "turn-2-msg", (
@@ -305,7 +305,7 @@ actions:
# The continuation branch in ``_ensure_state_initialized`` must:
# 1. preserve the cross-turn marker we stamped above
# 2. refresh Inputs.input and System.LastMessage* to the new turn
post_state = workflow._state.get(DECLARATIVE_STATE_KEY)
post_state = workflow._runner.state.get(DECLARATIVE_STATE_KEY)
assert isinstance(post_state, dict), "declarative state vanished between turns"
local = post_state.get("Local", {})
assert local.get("persisted_marker") == "kept-from-turn-1", (
@@ -59,7 +59,7 @@ class DurableAgentTask(CompositeTask[AgentResponse], CompletableTask[AgentRespon
"""
self._response_format = response_format
self._correlation_id = correlation_id
super().__init__([entity_task]) # type: ignore
super().__init__([entity_task])
def on_child_completed(self, task: Task[Any]) -> None:
"""Handle completion of the underlying entity task.
@@ -129,7 +129,7 @@ class DurableAIAgentWorker:
# Register the entity class with the worker
# The worker.add_entity method takes a class
entity_registered: str = self._worker.add_entity(entity_class) # pyright: ignore[reportUnknownMemberType]
entity_registered: str = self._worker.add_entity(entity_class)
logger.debug(
"[DurableAIAgentWorker] Successfully registered entity class %s for agent: %s",
@@ -225,7 +225,7 @@ class DurableAIAgentWorker:
executor_activity.__name__ = activity_name
executor_activity.__qualname__ = activity_name
self._worker.add_activity(executor_activity) # type: ignore[arg-type]
self._worker.add_activity(executor_activity)
logger.debug("[DurableAIAgentWorker] Registered activity: %s", activity_name)
def _register_workflow_orchestrator(self) -> None:
@@ -16,7 +16,7 @@ from durabletask.task import (
OrchestrationContext,
Task,
when_all,
when_any, # pyright: ignore[reportUnknownVariableType]
when_any,
)
from .._executors import OrchestrationAgentExecutor
@@ -27,6 +27,7 @@ dependencies = [
"azure-ai-agentserver-core>=2.0.0b3,<3",
"azure-ai-agentserver-responses>=1.0.0b7,<2",
"azure-ai-agentserver-invocations>=1.0.0b3,<2",
"mcp>=1.24.0,<2",
]
[tool.uv]
@@ -24,7 +24,7 @@ classifiers = [
]
dependencies = [
"agent-framework-core>=1.8.0,<2",
"github-copilot-sdk>=1.0.0,<2; python_version >= '3.11'",
"github-copilot-sdk==1.0.2; python_version >= '3.11'",
]
[tool.uv]
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) Microsoft Corporation.
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE
@@ -0,0 +1,21 @@
# agent-framework-hosting-responses
OpenAI Responses-shaped channel for `agent-framework-hosting`.
Exposes a single `POST /responses` endpoint that accepts the OpenAI
Responses API request body and returns either a Responses-shaped JSON
body or a Server-Sent-Events stream when `stream=True`.
```python
from agent_framework.openai import OpenAIChatClient
from agent_framework_hosting import AgentFrameworkHost
from agent_framework_hosting_responses import ResponsesChannel
agent = OpenAIChatClient().as_agent(name="Assistant")
host = AgentFrameworkHost(target=agent, channels=[ResponsesChannel()])
host.serve(port=8000)
```
The base host plumbing lives in
[`agent-framework-hosting`](https://pypi.org/project/agent-framework-hosting/).
@@ -0,0 +1,25 @@
# Copyright (c) Microsoft. All rights reserved.
"""OpenAI Responses-shaped channel for ``agent-framework-hosting``."""
import importlib.metadata
from ._channel import ResponsesChannel
from ._parsing import (
messages_from_responses_input,
parse_responses_identity,
parse_responses_request,
)
try:
__version__ = importlib.metadata.version(__name__)
except importlib.metadata.PackageNotFoundError:
__version__ = "0.0.0"
__all__ = [
"ResponsesChannel",
"__version__",
"messages_from_responses_input",
"parse_responses_identity",
"parse_responses_request",
]
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,156 @@
# Copyright (c) Microsoft. All rights reserved.
"""Parsing helpers for the OpenAI Responses-API request body.
The Responses API accepts ``input`` as either a string or a list of "input
items". An item is either a content part (``input_text`` / ``input_image``
/ ``input_file``) or a message envelope ``{type: "message", role,
content: [...]}``. We translate that into an Agent Framework ``Message``
list and remap the generation-control fields the API also carries into
``ChatOptions``-shaped keys. The result is available to the channel's
``run_hook``; a default hook strips them before they reach the agent so
unknown fields from untrusted callers are not forwarded unless the host
developer explicitly opts in.
"""
from __future__ import annotations
from collections.abc import Mapping
from typing import Any, cast
from agent_framework import Content, Message
from agent_framework_hosting import ChannelIdentity, ChannelSession
# OpenAI Responses field name → Agent Framework ChatOptions field name.
_RESPONSES_OPTION_REMAP = {
"max_output_tokens": "max_tokens",
"parallel_tool_calls": "allow_multiple_tool_calls",
}
# Fields the Responses transport owns; they are consumed separately and must
# not also appear in options.
_RESPONSES_TRANSPORT_KEYS = frozenset({"input", "stream", "previous_response_id"})
def parse_responses_identity(body: Mapping[str, Any], channel_name: str) -> ChannelIdentity | None:
"""Surface the caller as a :class:`ChannelIdentity` so the host can record it.
OpenAI Responses replaced ``user`` with ``safety_identifier`` we use
that as the native id, falling back to the legacy ``user`` field.
"""
native = body.get("safety_identifier") or body.get("user")
if not isinstance(native, str) or not native:
return None
return ChannelIdentity(channel=channel_name, native_id=native)
def _content_from_input_item(item: Mapping[str, Any]) -> Content:
"""Convert a single OpenAI Responses ``input`` item into a :class:`Content` part.
Handles the ``input_text``/``output_text``/``text`` text variants,
``input_image`` URL references, and ``input_file`` references via either
a public URL or a hosted ``file_id``. Raises ``ValueError`` for any
unsupported item type so the surrounding parser can return a 422.
"""
item_type = item.get("type")
if item_type in ("input_text", "output_text", "text"):
return Content.from_text(text=str(item.get("text", "")))
if item_type == "input_image":
image_url: Any = item.get("image_url")
if isinstance(image_url, Mapping):
image_url = cast("Mapping[str, Any]", image_url).get("url")
if not isinstance(image_url, str):
raise ValueError("input_image requires `image_url`")
return Content.from_uri(uri=image_url, media_type="image/*")
if item_type == "input_file":
if (uri := item.get("file_url")) and isinstance(uri, str):
return Content.from_uri(uri=uri, media_type=item.get("mime_type"))
if file_id := item.get("file_id"):
return Content(type="hosted_file", file_id=str(file_id))
raise ValueError("input_file requires `file_url` or `file_id`")
raise ValueError(f"Unsupported Responses input content type: {item_type!r}")
def messages_from_responses_input(value: Any) -> list[Message]:
"""Translate ``input`` (string or list of items) into :class:`Message` objects."""
if isinstance(value, str):
return [Message("user", [Content.from_text(text=value)])]
if not isinstance(value, list) or not value:
raise ValueError("`input` must be a non-empty string or list")
messages: list[Message] = []
pending_user_parts: list[Content] = []
def flush() -> None:
"""Emit any buffered loose user content as a single user message."""
if pending_user_parts:
messages.append(Message("user", list(pending_user_parts)))
pending_user_parts.clear()
for item in cast("list[Any]", value):
if not isinstance(item, Mapping):
raise ValueError("each `input` item must be an object")
item_map = cast("Mapping[str, Any]", item)
if item_map.get("type") == "message":
flush()
role = str(item_map.get("role") or "user")
content: Any = item_map.get("content") or []
parts: list[Content]
if isinstance(content, str):
parts = [Content.from_text(text=content)]
elif isinstance(content, list):
parts = []
for content_item in cast("list[Any]", content):
if not isinstance(content_item, Mapping):
raise ValueError("each message `content` item must be an object")
parts.append(_content_from_input_item(cast("Mapping[str, Any]", content_item)))
else:
raise ValueError("message `content` must be a string or list")
messages.append(Message(role, parts))
else:
pending_user_parts.append(_content_from_input_item(item_map))
flush()
if not messages:
raise ValueError("`input` produced no messages")
return messages
def parse_responses_request(
body: Mapping[str, Any],
) -> tuple[list[Message], dict[str, Any], ChannelSession | None]:
"""Translate a Responses-API request body into Agent Framework constructs.
Returns a triple ``(messages, options, session)`` where:
- ``messages`` is the parsed conversation.
- ``options`` is a ``ChatOptions``-shaped dict with the remapped
generation-control fields. Known ResponsesChatOptions renames are
applied (e.g. ``max_output_tokens`` ``max_tokens``); transport/
session keys are excluded; ``None``-valued fields are dropped.
Unknown fields are forwarded as-is so the channel's ``run_hook``
can inspect and filter them. The default ``ResponsesChannel`` strips
all options before the agent runs; supply a custom ``run_hook`` to
selectively keep fields.
- ``session`` is a :class:`ChannelSession` keyed by
``previous_response_id`` when one was supplied, else ``None``.
"""
messages = messages_from_responses_input(body.get("input"))
options: dict[str, Any] = {}
for key, value in body.items():
if key in _RESPONSES_TRANSPORT_KEYS or value is None:
continue
options[_RESPONSES_OPTION_REMAP.get(key, key)] = value
session: ChannelSession | None = None
if (prev := body.get("previous_response_id")) and isinstance(prev, str):
session = ChannelSession(isolation_key=prev)
return messages, options, session
__all__ = [
"messages_from_responses_input",
"parse_responses_identity",
"parse_responses_request",
]
@@ -0,0 +1,80 @@
[project]
name = "agent-framework-hosting-responses"
description = "OpenAI Responses-shaped channel for agent-framework-hosting."
authors = [{ name = "Microsoft", email = "af-support@microsoft.com"}]
readme = "README.md"
requires-python = ">=3.10"
version = "1.0.0a260424"
license-files = ["LICENSE"]
urls.homepage = "https://aka.ms/agent-framework"
urls.source = "https://github.com/microsoft/agent-framework/tree/main/python"
urls.release_notes = "https://github.com/microsoft/agent-framework/releases?q=tag%3Apython-1&expanded=true"
urls.issues = "https://github.com/microsoft/agent-framework/issues"
classifiers = [
"License :: OSI Approved :: MIT License",
"Development Status :: 3 - Alpha",
"Intended Audience :: Developers",
"Programming Language :: Python :: 3",
"Programming Language :: Python :: 3.10",
"Programming Language :: Python :: 3.11",
"Programming Language :: Python :: 3.12",
"Programming Language :: Python :: 3.13",
"Programming Language :: Python :: 3.14",
"Typing :: Typed",
]
dependencies = [
"agent-framework-core>=1.2.0,<2",
"agent-framework-hosting==1.0.0a260424",
"openai>=1.99.0,<3",
]
[tool.uv]
prerelease = "if-necessary-or-explicit"
environments = [
"sys_platform == 'darwin'",
"sys_platform == 'linux'",
"sys_platform == 'win32'"
]
[tool.uv-dynamic-versioning]
fallback-version = "0.0.0"
[tool.pytest.ini_options]
testpaths = 'tests'
addopts = "-ra -q -r fEX"
asyncio_mode = "auto"
asyncio_default_fixture_loop_scope = "function"
filterwarnings = []
timeout = 120
markers = [
"integration: marks tests as integration tests that require external services",
]
[tool.ruff]
extend = "../../pyproject.toml"
[tool.coverage.run]
omit = [
"**/__init__.py"
]
[tool.pyright]
extends = "../../pyproject.toml"
include = ["agent_framework_hosting_responses"]
exclude = ['tests']
[tool.bandit]
targets = ["agent_framework_hosting_responses"]
exclude_dirs = ["tests"]
[tool.poe]
executor.type = "uv"
include = "../../shared_tasks.toml"
[tool.poe.tasks.test]
help = "Run the default unit test suite for this package."
cmd = 'pytest -m "not integration" --cov=agent_framework_hosting_responses --cov-report=term-missing:skip-covered tests'
[build-system]
requires = ["flit-core >= 3.11,<4.0"]
build-backend = "flit_core.buildapi"
@@ -0,0 +1,651 @@
# Copyright (c) Microsoft. All rights reserved.
"""End-to-end tests for :class:`ResponsesChannel` via Starlette's ``TestClient``."""
from __future__ import annotations
import json
from collections.abc import AsyncIterator
from dataclasses import dataclass
from typing import Any
from agent_framework import AgentResponse, AgentResponseUpdate, Content, Message
from agent_framework_hosting import (
AgentFrameworkHost,
HostedRunResult,
)
from starlette.testclient import TestClient
from agent_framework_hosting_responses import ResponsesChannel
from agent_framework_hosting_responses._channel import ( # pyright: ignore[reportPrivateUsage]
_result_to_output_items,
_result_to_text,
)
# --------------------------------------------------------------------------- #
# Fakes #
# --------------------------------------------------------------------------- #
@dataclass
class _FakeAgentResponse:
text: str
class _FakeStream:
"""Minimal stand-in for AF's ``ResponseStream`` returned by ``run(stream=True)``."""
def __init__(self, chunks: list[str]) -> None:
self._chunks = chunks
self._final = _FakeAgentResponse(text="".join(chunks))
def __aiter__(self) -> AsyncIterator[AgentResponseUpdate]:
async def _gen() -> AsyncIterator[AgentResponseUpdate]:
for c in self._chunks:
yield AgentResponseUpdate(contents=[Content.from_text(c)], role="assistant")
return _gen()
async def get_final_response(self) -> _FakeAgentResponse:
return self._final
class _FakeAgent:
def __init__(self, reply: Any = "hello", chunks: list[str] | None = None) -> None:
self.id = "fake-agent"
self.name: str | None = "Fake Agent"
self.description: str | None = "Test fake agent"
self._reply = reply
self._chunks = chunks or [reply]
self.calls: list[dict[str, Any]] = []
def create_session(self, *, session_id: str | None = None) -> Any:
return {"session_id": session_id}
def get_session(self, service_session_id: str, *, session_id: str | None = None) -> Any:
return {"service_session_id": service_session_id, "session_id": session_id}
def run(self, messages: Any = None, *, stream: bool = False, **kwargs: Any) -> Any:
self.calls.append({"messages": messages, "stream": stream, "kwargs": kwargs})
if stream:
return _FakeStream(self._chunks)
async def _coro() -> Any:
if not isinstance(self._reply, str):
return self._reply
return _FakeAgentResponse(text=self._reply)
return _coro()
# --------------------------------------------------------------------------- #
# Tests #
# --------------------------------------------------------------------------- #
def _make_client(
agent: _FakeAgent | None = None,
*,
path: str = "/responses",
response_id_factory: Any | None = None,
) -> tuple[TestClient, AgentFrameworkHost, _FakeAgent]:
agent = agent or _FakeAgent()
host = AgentFrameworkHost(
target=agent,
channels=[ResponsesChannel(path=path, response_id_factory=response_id_factory)],
)
return TestClient(host.app), host, agent
def _sse_payload(body: str, event_type: str) -> dict[str, Any]:
current_event: str | None = None
for line in body.splitlines():
if line.startswith("event: "):
current_event = line[len("event: ") :]
continue
if current_event == event_type and line.startswith("data: "):
return json.loads(line[len("data: ") :])
raise AssertionError(f"Missing SSE event: {event_type}")
class TestResponsesChannelNonStreaming:
def test_post_responses_returns_completed_envelope(self) -> None:
client, _host, agent = _make_client(_FakeAgent(reply="hi back"))
with client:
r = client.post("/responses", json={"input": "hi"})
assert r.status_code == 200
body = r.json()
assert body["status"] == "completed"
assert body["object"] == "response"
assert body["id"].startswith("resp_")
assert isinstance(body["created_at"], int)
assert body["output"][0]["content"][0]["text"] == "hi back"
assert len(agent.calls) == 1
def test_non_string_model_falls_back_to_agent(self) -> None:
client, _host, _agent = _make_client(_FakeAgent(reply="hi"))
with client:
r = client.post("/responses", json={"input": "hi", "model": None})
assert r.status_code == 200
assert r.json()["model"] == "agent"
def test_empty_path_mounts_at_app_root(self) -> None:
client, _host, _agent = _make_client(_FakeAgent(reply="hi back"), path="")
with client:
r = client.post("/", json={"input": "hi"})
assert r.status_code == 200
assert r.json()["output"][0]["content"][0]["text"] == "hi back"
def test_custom_path_mounts_route_under_host_path(self) -> None:
client, _host, _agent = _make_client(_FakeAgent(reply="custom"), path="/api/responses")
with client:
r = client.post("/api/responses", json={"input": "hi"})
missing = client.post("/api/responses/responses", json={"input": "hi"})
assert r.status_code == 200
assert r.json()["output"][0]["content"][0]["text"] == "custom"
assert missing.status_code == 404
def test_invalid_json_returns_400(self) -> None:
client, *_ = _make_client()
with client:
r = client.post("/responses", content=b"{not json", headers={"content-type": "application/json"})
assert r.status_code == 400
def test_non_object_json_returns_422(self) -> None:
client, *_ = _make_client()
with client:
r = client.post("/responses", json=["not", "an", "object"])
assert r.status_code == 422
assert r.json()["error"] == "request body must be a JSON object"
def test_invalid_input_returns_422(self) -> None:
client, *_ = _make_client()
with client:
r = client.post("/responses", json={"input": 42})
assert r.status_code == 422
def test_request_options_are_not_forwarded_by_default(self) -> None:
client, _host, agent = _make_client()
with client:
r = client.post(
"/responses",
json={"input": "x", "temperature": 0.5, "max_output_tokens": 64, "truncation": "auto"},
)
assert r.status_code == 200
assert "options" not in agent.calls[0]["kwargs"]
def test_custom_run_hook_can_forward_options(self) -> None:
import dataclasses
def keep_temperature(request: Any, **_: Any) -> Any:
opts = dict(request.options or {})
return dataclasses.replace(request, options={"temperature": opts.get("temperature")})
agent = _FakeAgent()
host = AgentFrameworkHost(
target=agent,
channels=[ResponsesChannel(run_hook=keep_temperature)],
)
with TestClient(host.app) as client:
r = client.post("/responses", json={"input": "x", "temperature": 0.7, "truncation": "auto"})
assert r.status_code == 200
opts = agent.calls[0]["kwargs"]["options"]
assert opts == {"temperature": 0.7}
assert "truncation" not in opts
def test_multimodal_agent_response_outputs_are_preserved(self) -> None:
response = AgentResponse(
messages=[
Message(
"assistant",
[
Content.from_text_reasoning(id="rs_1", text="checking"),
Content.from_function_call("call_1", "collect_media", arguments={"city": "Seattle"}),
Content.from_function_result(
"call_1",
result=[
Content.from_text("caption"),
Content.from_uri("https://example.com/cat.png", media_type="image/png"),
Content.from_hosted_file("file_pdf", media_type="application/pdf"),
],
),
Content.from_text("done"),
],
)
],
)
client, _host, _agent = _make_client(_FakeAgent(reply=response))
with client:
r = client.post("/responses", json={"input": "hi"})
assert r.status_code == 200
output = r.json()["output"]
assert [item["type"] for item in output] == [
"reasoning",
"function_call",
"function_call_output",
"message",
]
assert output[0]["content"][0]["text"] == "checking"
assert output[1]["name"] == "collect_media"
assert output[1]["arguments"] == '{"city": "Seattle"}'
assert output[2]["output"] == [
{"text": "caption", "type": "input_text"},
{"detail": "auto", "type": "input_image", "image_url": "https://example.com/cat.png"},
{"type": "input_file", "file_id": "file_pdf"},
]
assert output[3]["content"][0]["text"] == "done"
def test_raw_responses_output_items_are_preserved(self) -> None:
raw_item = {
"id": "ig_1",
"type": "image_generation_call",
"result": "base64-image",
"status": "completed",
}
response = AgentResponse(
messages=[
Message(
"assistant",
[
Content.from_image_generation_tool_call(image_id="ig_1", raw_representation=raw_item),
Content.from_image_generation_tool_result(
image_id="ig_1",
outputs=Content.from_uri("data:image/png;base64,base64-image", media_type="image/png"),
raw_representation=raw_item,
),
],
)
],
)
client, _host, _agent = _make_client(_FakeAgent(reply=response))
with client:
r = client.post("/responses", json={"input": "hi"})
assert r.status_code == 200
assert r.json()["output"] == [raw_item]
def test_later_raw_responses_output_item_replaces_earlier_partial_item(self) -> None:
partial = {
"id": "mcp_1",
"type": "mcp_call",
"server_label": "weather",
"name": "lookup",
"arguments": "{}",
"status": "in_progress",
}
completed = {**partial, "status": "completed", "output": "sunny"}
response = AgentResponse(
messages=[
Message(
"assistant",
[
Content.from_mcp_server_tool_call(
"mcp_1",
"lookup",
server_name="weather",
raw_representation=partial,
),
Content.from_mcp_server_tool_result("mcp_1", output="sunny", raw_representation=completed),
],
)
],
)
client, _host, _agent = _make_client(_FakeAgent(reply=response))
with client:
r = client.post("/responses", json={"input": "hi"})
assert r.status_code == 200
assert r.json()["output"] == [completed]
def test_previous_response_id_creates_session(self) -> None:
client, _host, agent = _make_client()
with client:
client.post("/responses", json={"input": "x", "previous_response_id": "resp_42"})
# AgentFrameworkHost converts the channel session into an AgentSession.
sess = agent.calls[0]["kwargs"].get("session")
assert sess is not None
# _FakeAgent.create_session stashes the session_id on the dict it returns.
assert sess["session_id"] == "resp_42"
def test_first_turn_response_id_creates_session(self) -> None:
client, _host, agent = _make_client(response_id_factory=lambda *_: "resp_first")
with client:
client.post("/responses", json={"input": "x"})
sess = agent.calls[0]["kwargs"].get("session")
assert sess is not None
assert sess["session_id"] == "resp_first"
def test_chat_isolation_header_ignored_outside_foundry(self) -> None:
client, _host, agent = _make_client(response_id_factory=lambda *_: "resp_local")
with client:
client.post(
"/responses",
json={"input": "x"},
headers={"x-agent-chat-isolation-key": "chat-abc"},
)
sess = agent.calls[0]["kwargs"].get("session")
assert sess is not None
assert sess["session_id"] == "resp_local"
def test_chat_isolation_header_creates_session_in_foundry(self, monkeypatch: Any) -> None:
"""Foundry-style ``x-agent-chat-isolation-key`` falls back to a session anchor.
First-turn requests have no ``previous_response_id`` (the client
doesn't have one yet), but Foundry Hosted Agents always inject
the isolation headers. The channel must derive a session from the
chat key so the host can build a stable per-conversation session
that history providers persist under.
"""
monkeypatch.setenv("FOUNDRY_HOSTING_ENVIRONMENT", "1")
client, _host, agent = _make_client()
with client:
client.post(
"/responses",
json={"input": "x"},
headers={"x-agent-chat-isolation-key": "chat-abc"},
)
sess = agent.calls[0]["kwargs"].get("session")
assert sess is not None
assert sess["session_id"] == "chat-abc"
def test_prev_response_id_wins_over_chat_isolation_header(self, monkeypatch: Any) -> None:
"""When both anchors are present, ``previous_response_id`` wins.
``previous_response_id`` is the protocol-native chain anchor; the
header fallback is only meant to bootstrap when no protocol
anchor exists.
"""
monkeypatch.setenv("FOUNDRY_HOSTING_ENVIRONMENT", "1")
client, _host, agent = _make_client()
with client:
client.post(
"/responses",
json={"input": "x", "previous_response_id": "resp_99"},
headers={"x-agent-chat-isolation-key": "chat-abc"},
)
sess = agent.calls[0]["kwargs"].get("session")
assert sess is not None
assert sess["session_id"] == "resp_99"
def test_response_hook_can_rewrite_originating_reply(self) -> None:
seen_kwargs: list[dict[str, Any]] = []
def hook(result: HostedRunResult, **kwargs: Any) -> HostedRunResult:
seen_kwargs.append(dict(kwargs))
return HostedRunResult(_FakeAgentResponse(text=result.result.text.upper()), session=result.session)
agent = _FakeAgent(reply="hooked")
host = AgentFrameworkHost(target=agent, channels=[ResponsesChannel(response_hook=hook)])
with TestClient(host.app) as client:
r = client.post("/responses", json={"input": "hi"})
assert r.status_code == 200
body = r.json()
assert body["output"][0]["content"][0]["text"] == "HOOKED"
assert seen_kwargs
assert seen_kwargs[0]["channel_name"] == "responses"
class TestResultTextRendering:
def test_result_text_prefers_text_property(self) -> None:
assert _result_to_text(_FakeAgentResponse(text="plain")) == "plain"
def test_result_text_projects_workflow_outputs(self) -> None:
class _WorkflowResult:
def get_outputs(self) -> list[Any]:
return [_FakeAgentResponse(text="one"), " two"]
assert _result_to_text(_WorkflowResult()) == "one two"
def test_result_output_items_project_workflow_message_and_content_outputs(self) -> None:
class _WorkflowResult:
def get_outputs(self) -> list[Any]:
return [
Message("assistant", [Content.from_text("one")]),
Content.from_function_result(
"call_1",
result=[Content.from_uri("https://example.com/cat.png", media_type="image/png")],
),
]
output = [
item.model_dump(mode="json", exclude_none=True)
for item in _result_to_output_items(_WorkflowResult(), status="completed")
]
assert output[0]["type"] == "message"
assert output[0]["content"][0]["text"] == "one"
assert output[1]["type"] == "function_call_output"
assert output[1]["output"] == [
{"detail": "auto", "type": "input_image", "image_url": "https://example.com/cat.png"}
]
def test_function_result_exception_is_preserved(self) -> None:
output = [
item.model_dump(mode="json", exclude_none=True)
for item in _result_to_output_items(
Content.from_function_result("call_1", exception="tool failed"),
status="completed",
)
]
assert output[0]["output"] == "tool failed"
def test_stateful_call_and_result_content_coalesce_to_one_output_item(self) -> None:
output = [
item.model_dump(mode="json", exclude_none=True)
for item in _result_to_output_items(
Message(
"assistant",
[
Content.from_image_generation_tool_call(image_id="ig_1"),
Content.from_image_generation_tool_result(
image_id="ig_1",
outputs=Content.from_uri("data:image/png;base64,base64-image", media_type="image/png"),
),
Content.from_mcp_server_tool_call(
"mcp_1",
"lookup",
server_name="weather",
arguments={"city": "Seattle"},
),
Content.from_mcp_server_tool_result("mcp_1", output=[Content.from_text("sunny")]),
],
),
status="completed",
)
]
assert output == [
{
"id": "ig_1",
"result": "base64-image",
"status": "completed",
"type": "image_generation_call",
},
{
"id": "mcp_1",
"arguments": '{"city": "Seattle"}',
"name": "lookup",
"output": "sunny",
"server_label": "weather",
"status": "completed",
"type": "mcp_call",
},
]
def test_stateful_call_and_result_content_coalesce_across_messages(self) -> None:
output = [
item.model_dump(mode="json", exclude_none=True)
for item in _result_to_output_items(
AgentResponse(
messages=[
Message(
"assistant",
[
Content.from_mcp_server_tool_call(
"mcp_1",
"lookup",
server_name="weather",
arguments={"city": "Seattle"},
)
],
),
Message(
"tool",
[Content.from_mcp_server_tool_result("mcp_1", output=[Content.from_text("sunny")])],
),
]
),
status="completed",
)
]
assert output == [
{
"id": "mcp_1",
"arguments": '{"city": "Seattle"}',
"name": "lookup",
"output": "sunny",
"server_label": "weather",
"status": "completed",
"type": "mcp_call",
}
]
class TestResponsesChannelStreaming:
def test_sse_emits_created_delta_completed(self) -> None:
agent = _FakeAgent(reply="hello world", chunks=["hello", " ", "world"])
host = AgentFrameworkHost(target=agent, channels=[ResponsesChannel()])
with TestClient(host.app) as client:
r = client.post("/responses", json={"input": "hi", "stream": True})
assert r.status_code == 200
body = r.text
# SSE event lines look like "event: <type>\ndata: <json>\n\n".
events = [line[len("event: ") :] for line in body.splitlines() if line.startswith("event: ")]
assert events[0] == "response.created"
assert events[-1] == "response.completed"
assert events.count("response.output_text.delta") == 3
def test_sse_transform_hook_can_rewrite_chunks(self) -> None:
agent = _FakeAgent(reply="hello", chunks=["he", "llo"])
def transform(update: AgentResponseUpdate) -> AgentResponseUpdate:
return AgentResponseUpdate(contents=[Content.from_text(update.text.upper())], role="assistant")
host = AgentFrameworkHost(target=agent, channels=[ResponsesChannel(stream_update_hook=transform)])
with TestClient(host.app) as client:
r = client.post("/responses", json={"input": "hi", "stream": True})
assert r.status_code == 200
assert '"delta":"HE"' in r.text
assert '"delta":"LLO"' in r.text
# Stream update hooks are update-only; they do not rewrite get_final_response().
assert '"text":"hello"' in r.text
def test_sse_completed_preserves_streamed_multimodal_updates_when_finalize_fails(self) -> None:
class _MultimodalStream:
def __aiter__(self) -> AsyncIterator[AgentResponseUpdate]:
async def _gen() -> AsyncIterator[AgentResponseUpdate]:
yield AgentResponseUpdate(
contents=[
Content.from_text("caption"),
Content.from_text_reasoning(id="rs_1", text="thinking"),
Content.from_function_call("call_1", "lookup", arguments={"city": "Seattle"}),
Content.from_uri("https://example.com/cat.png", media_type="image/png"),
],
role="assistant",
)
return _gen()
async def get_final_response(self) -> _FakeAgentResponse:
raise RuntimeError("finalize unavailable")
class _MultimodalAgent(_FakeAgent):
def run(self, messages: Any = None, *, stream: bool = False, **kwargs: Any) -> Any:
self.calls.append({"messages": messages, "stream": stream, "kwargs": kwargs})
if stream:
return _MultimodalStream()
raise AssertionError("non-streaming path not exercised here")
host = AgentFrameworkHost(target=_MultimodalAgent(), channels=[ResponsesChannel()])
with TestClient(host.app) as client:
r = client.post("/responses", json={"input": "hi", "stream": True})
assert r.status_code == 200
assert "event: response.output_item.added" in r.text
assert "event: response.output_item.done" in r.text
events = [line[len("event: ") :] for line in r.text.splitlines() if line.startswith("event: ")]
assert "response.content_part.added" in events
assert "response.output_text.done" in events
assert "response.reasoning_text.delta" in events
assert "response.reasoning_text.done" in events
assert "response.function_call_arguments.delta" in events
assert "response.function_call_arguments.done" in events
content_part_added = _sse_payload(r.text, "response.content_part.added")
assert content_part_added["part"] == {"annotations": [], "text": "", "type": "output_text"}
added_items = [
json.loads(line[len("data: ") :])["item"]
for line in r.text.splitlines()
if line.startswith("data: ") and '"type":"response.output_item.added"' in line
]
assert [item["type"] for item in added_items] == [
"message",
"reasoning",
"function_call",
"function_call_output",
]
assert added_items[0]["content"] == []
assert added_items[1]["content"] == []
assert added_items[2]["name"] == "lookup"
assert added_items[2]["arguments"] == ""
assert added_items[3]["output"] == [
{"detail": "auto", "type": "input_image", "image_url": "https://example.com/cat.png"}
]
completed = _sse_payload(r.text, "response.completed")
assert completed["response"]["output"][0]["content"][0]["text"] == "caption"
assert completed["response"]["output"][1]["content"][0]["text"] == "thinking"
assert completed["response"]["output"][2]["name"] == "lookup"
assert completed["response"]["output"][3]["output"] == [
{"detail": "auto", "type": "input_image", "image_url": "https://example.com/cat.png"}
]
def test_sse_emits_failed_when_stream_raises(self) -> None:
# Regression: ResponseOutputMessage.status only accepts in_progress/
# completed/incomplete, so building an OpenAIResponse with status="failed"
# used to crash with a pydantic ValidationError. The channel must map the
# nested message status to "incomplete" while keeping the top-level
# Response.status="failed".
class _BoomStream:
def __aiter__(self) -> AsyncIterator[AgentResponseUpdate]:
async def _gen() -> AsyncIterator[AgentResponseUpdate]:
yield AgentResponseUpdate(contents=[Content.from_text("partial")], role="assistant")
raise RuntimeError("upstream blew up")
return _gen()
async def get_final_response(self) -> _FakeAgentResponse: # pragma: no cover
return _FakeAgentResponse(text="")
class _BoomAgent(_FakeAgent):
def run(self, messages: Any = None, *, stream: bool = False, **kwargs: Any) -> Any:
self.calls.append({"messages": messages, "stream": stream, "kwargs": kwargs})
if stream:
return _BoomStream()
raise AssertionError("non-streaming path not exercised here")
host = AgentFrameworkHost(target=_BoomAgent(), channels=[ResponsesChannel()])
with TestClient(host.app) as client:
r = client.post("/responses", json={"input": "hi", "stream": True})
assert r.status_code == 200
body = r.text
events = [line[len("event: ") :] for line in body.splitlines() if line.startswith("event: ")]
assert events[0] == "response.created"
assert events[-1] == "response.failed"
# The failed envelope must serialize cleanly — i.e. no ValidationError raised.
assert "upstream blew up" in body
@@ -0,0 +1,169 @@
# Copyright (c) Microsoft. All rights reserved.
"""Tests for the OpenAI Responses request-body parser."""
from __future__ import annotations
import pytest
from agent_framework_hosting_responses import (
messages_from_responses_input,
parse_responses_identity,
parse_responses_request,
)
class TestMessagesFromResponsesInput:
def test_string_input_becomes_single_user_message(self) -> None:
msgs = messages_from_responses_input("hello")
assert len(msgs) == 1
assert msgs[0].role == "user"
assert msgs[0].text == "hello"
def test_input_text_items_collapse_into_one_user_message(self) -> None:
msgs = messages_from_responses_input([{"type": "input_text", "text": "a"}, {"type": "input_text", "text": "b"}])
assert len(msgs) == 1
assert msgs[0].role == "user"
assert msgs[0].text == "a b"
def test_message_envelope_with_string_content(self) -> None:
msgs = messages_from_responses_input([
{"type": "message", "role": "system", "content": "be brief"},
{"type": "message", "role": "user", "content": "hi"},
])
assert [m.role for m in msgs] == ["system", "user"]
assert msgs[0].text == "be brief"
def test_message_envelope_with_content_parts(self) -> None:
msgs = messages_from_responses_input([
{
"type": "message",
"role": "user",
"content": [{"type": "input_text", "text": "describe this"}],
}
])
assert msgs[0].text == "describe this"
def test_message_envelope_rejects_non_object_content_item(self) -> None:
with pytest.raises(ValueError, match="content.*object"):
messages_from_responses_input([{"type": "message", "role": "user", "content": ["bad"]}])
def test_message_envelope_rejects_invalid_content_shape(self) -> None:
with pytest.raises(ValueError, match="content.*string or list"):
messages_from_responses_input([{"type": "message", "role": "user", "content": 42}])
def test_input_file_via_url(self) -> None:
msgs = messages_from_responses_input([
{"type": "input_file", "file_url": "https://example.com/report.pdf", "mime_type": "application/pdf"}
])
assert msgs[0].contents[0].uri == "https://example.com/report.pdf"
def test_input_file_via_file_id(self) -> None:
msgs = messages_from_responses_input([{"type": "input_file", "file_id": "file_123"}])
assert msgs[0].contents[0].file_id == "file_123"
def test_input_file_missing_anchor_raises(self) -> None:
with pytest.raises(ValueError, match="input_file"):
messages_from_responses_input([{"type": "input_file"}])
def test_pending_text_flushes_before_message_envelope(self) -> None:
msgs = messages_from_responses_input([
{"type": "input_text", "text": "first"},
{"type": "message", "role": "user", "content": "second"},
])
assert len(msgs) == 2
assert msgs[0].text == "first"
assert msgs[1].text == "second"
def test_image_url_via_string(self) -> None:
msgs = messages_from_responses_input([{"type": "input_image", "image_url": "https://example.com/cat.png"}])
assert len(msgs) == 1
# Image content present.
assert any(getattr(c, "uri", None) == "https://example.com/cat.png" for c in msgs[0].contents)
def test_image_url_via_object(self) -> None:
msgs = messages_from_responses_input([
{"type": "input_image", "image_url": {"url": "https://example.com/cat.png"}}
])
assert any(getattr(c, "uri", None) == "https://example.com/cat.png" for c in msgs[0].contents)
def test_unknown_input_type_raises(self) -> None:
with pytest.raises(ValueError, match="Unsupported"):
messages_from_responses_input([{"type": "weird"}])
def test_empty_list_raises(self) -> None:
with pytest.raises(ValueError, match="non-empty"):
messages_from_responses_input([])
def test_non_string_non_list_raises(self) -> None:
with pytest.raises(ValueError):
messages_from_responses_input(42) # type: ignore[arg-type]
def test_image_url_missing_raises(self) -> None:
with pytest.raises(ValueError, match="image_url"):
messages_from_responses_input([{"type": "input_image"}])
class TestParseResponsesRequest:
def test_known_fields_remapped_and_unknown_forwarded(self) -> None:
_, opts, _ = parse_responses_request({
"input": "hi",
"instructions": "be brief",
"temperature": 0.4,
"top_p": 0.9,
"tool_choice": "auto",
"max_output_tokens": 256,
"parallel_tool_calls": False,
"truncation": "auto",
"reasoning": {"effort": "low"},
})
# Known remaps applied.
assert opts["max_tokens"] == 256
assert opts["allow_multiple_tool_calls"] is False
# Straight-through fields present.
assert opts["temperature"] == 0.4
assert opts["instructions"] == "be brief"
assert opts["truncation"] == "auto"
# Transport/session keys excluded.
for key in ("input", "stream", "previous_response_id"):
assert key not in opts
def test_model_passes_through_transport_keys_excluded(self) -> None:
_, opts, _ = parse_responses_request({
"input": "x",
"model": "gpt-x",
"stream": True,
"previous_response_id": "r",
})
for key in ("input", "stream", "previous_response_id"):
assert key not in opts
# model passes through — not a transport key; run_hook decides what to do with it.
assert opts["model"] == "gpt-x"
def test_none_values_dropped(self) -> None:
_, opts, _ = parse_responses_request({"input": "x", "temperature": None})
assert "temperature" not in opts
def test_previous_response_id_becomes_session(self) -> None:
_, _, sess = parse_responses_request({"input": "x", "previous_response_id": "resp_42"})
assert sess is not None
assert sess.isolation_key == "resp_42"
class TestParseResponsesIdentity:
def test_safety_identifier_preferred(self) -> None:
ident = parse_responses_identity({"safety_identifier": "abc", "user": "legacy"}, "responses")
assert ident is not None
assert ident.native_id == "abc"
assert ident.channel == "responses"
def test_fallback_to_user(self) -> None:
ident = parse_responses_identity({"user": "legacy"}, "responses")
assert ident is not None
assert ident.native_id == "legacy"
def test_returns_none_when_absent(self) -> None:
assert parse_responses_identity({}, "responses") is None
def test_returns_none_for_non_string(self) -> None:
assert parse_responses_identity({"safety_identifier": 42}, "responses") is None
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) Microsoft Corporation.
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE
+122
View File
@@ -0,0 +1,122 @@
# agent-framework-hosting
Multi-channel hosting for Microsoft Agent Framework agents.
`agent-framework-hosting` lets you serve a single agent or workflow target
through one or more **channels**. The host owns one Starlette ASGI app,
route/lifecycle composition, and per-`isolation_key` session resolution.
Each channel owns its protocol parsing and response rendering.
The base package contains only channel-neutral plumbing:
- `AgentFrameworkHost` — the Starlette host.
- `Channel` — the channel protocol.
- `ChannelRequest` / `ChannelSession` / `ChannelIdentity` — the request
envelope and optional channel metadata.
- `ChannelContext` / `ChannelContribution` / `ChannelCommand` — channel-side
hooks for invoking the target and contributing routes, commands, and
lifecycle callbacks.
- `ChannelRunHook` / `ChannelResponseHook` / `ChannelStreamUpdateHook`
host-invoked customization seams.
`ChannelStreamUpdateHook` applies to streamed updates only. It is not a
substitute for final-response redaction.
Concrete channels live in their own packages so you only install what you use:
| Package | Transport |
|---|---|
| `agent-framework-hosting-responses` | OpenAI Responses API |
Additional channel packages can build on the same host contract without adding
their protocol dependencies to the base package.
## Install
```bash
pip install agent-framework-hosting agent-framework-hosting-responses
# or with Hypercorn pre-installed for the demo `host.serve(...)` helper
pip install "agent-framework-hosting[serve]" agent-framework-hosting-responses
# add the [disk] extra to persist reset-session aliases
pip install "agent-framework-hosting[disk]"
```
## Quickstart
```python
from agent_framework.openai import OpenAIChatClient
from agent_framework_hosting import AgentFrameworkHost, Channel
agent = OpenAIChatClient().as_agent(name="Assistant")
# Add channels from sibling packages, e.g. `agent-framework-hosting-responses`
# exposes a `ResponsesChannel` that serves the OpenAI Responses API.
channels: list[Channel] = []
host = AgentFrameworkHost(target=agent, channels=channels)
host.serve(port=8000)
```
## Session state and workflow checkpoints
By default the host keeps live `AgentSession` objects and reset-session aliases
in memory. Channels opt into continuity by setting
`ChannelRequest.session = ChannelSession(isolation_key=...)`; requests with the
same isolation key reuse the same host-created session.
The host treats `isolation_key` as an opaque partition key. Each channel or
hosting environment decides where that key comes from:
- protocol headers supplied by a trusted platform,
- request body fields such as a previous response or conversation ID,
- route/path parameters,
- channel-native metadata such as chat/user IDs, or
- environment-provided context in an ephemeral host.
The host should be able to carry any of those sources as long as the channel or
platform has already authenticated and authorized the caller before passing the
key to `ChannelSession`.
The built-in request-context helper recognizes the `x-agent-user-isolation-key`
and `x-agent-chat-isolation-key` header names because some hosting
environments, including Foundry Hosted Agents, already use them. Reusing those
header names does **not** mean `agent-framework-hosting` is the supported way to
run on Foundry Hosted Agents; use `agent-framework-foundry-hosting` for that
hosting surface.
For long-running deployments that need `reset_session(...)` aliases to survive
restart, pass `state_dir`:
```python
host = AgentFrameworkHost(
target=agent,
channels=channels,
state_dir="./.host-state",
)
```
This creates `./.host-state/sessions/` and stores only lightweight alias
bookkeeping. Live `AgentSession` objects are still rehydrated lazily by the
configured history provider on the next turn.
For workflow targets, `checkpoint_location=...` is the clearest way to enable
checkpoint persistence. As a convenience, `state_dir="./.host-state"` also
derives `./.host-state/checkpoints/` for workflow targets. Use the mapping form
when you want only one component:
```python
from agent_framework_hosting import HostStatePaths
host = AgentFrameworkHost(
target=workflow,
channels=channels,
state_dir=HostStatePaths(
sessions="/var/lib/myapp/sessions",
checkpoints="/var/lib/myapp/checkpoints",
),
)
```
Cross-channel identity linking, multicast delivery, background runs,
continuation tokens, and durable delivery runners are follow-up enhancements,
not part of this v1 host contract.
@@ -0,0 +1,66 @@
# Copyright (c) Microsoft. All rights reserved.
"""Multi-channel hosting for Microsoft Agent Framework agents.
Serve a single agent target through one or more **channels** pluggable
adapters that expose the target over different transports. The base
package contains only the channel-neutral plumbing; concrete channels
ship in their own packages, such as ``agent-framework-hosting-responses``,
so users install only what they need.
"""
import importlib.metadata
from ._host import AgentFrameworkHost, ChannelContext, logger
from ._isolation import (
ISOLATION_HEADER_CHAT,
ISOLATION_HEADER_USER,
IsolationKeys,
get_current_isolation_keys,
reset_current_isolation_keys,
set_current_isolation_keys,
)
from ._types import (
Channel,
ChannelCommand,
ChannelCommandContext,
ChannelContribution,
ChannelIdentity,
ChannelRequest,
ChannelResponseHook,
ChannelRunHook,
ChannelSession,
ChannelStreamUpdateHook,
HostedRunResult,
HostStatePaths,
)
try:
__version__ = importlib.metadata.version(__name__)
except importlib.metadata.PackageNotFoundError:
__version__ = "0.0.0"
__all__ = [
"ISOLATION_HEADER_CHAT",
"ISOLATION_HEADER_USER",
"AgentFrameworkHost",
"Channel",
"ChannelCommand",
"ChannelCommandContext",
"ChannelContext",
"ChannelContribution",
"ChannelIdentity",
"ChannelRequest",
"ChannelResponseHook",
"ChannelRunHook",
"ChannelSession",
"ChannelStreamUpdateHook",
"HostStatePaths",
"HostedRunResult",
"IsolationKeys",
"__version__",
"get_current_isolation_keys",
"logger",
"reset_current_isolation_keys",
"set_current_isolation_keys",
]
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,82 @@
# Copyright (c) Microsoft. All rights reserved.
"""Per-request isolation keys for host/platform-provided request context.
``ChannelSession.isolation_key`` is the host's generic session partition key,
but different channels and platforms discover that key from different places:
protocol headers, request bodies, URL/path segments, webhook metadata, or
environment-provided context for ephemeral hosts.
This module covers the request-context case where a platform provides
isolation outside the channel payload. The Foundry Hosted Agents runtime, for
example, injects two well-known headers on requests it forwards to the user's
container:
* ``x-agent-user-isolation-key`` opaque per-user partition key
* ``x-agent-chat-isolation-key`` opaque per-conversation partition key
The generic host intentionally reuses those header names so the same isolation
context can be consumed by supporting providers. Reusing the names does **not**
mean ``agent-framework-hosting`` is a supported way to run on Foundry Hosted
Agents; use ``agent-framework-foundry-hosting`` for that hosting surface.
When those headers are present the host-installed ASGI middleware pushes them
into :data:`current_isolation_keys` for the duration of the request, then
resets it. Channels may still choose a different session key source and pass it
directly via ``ChannelSession(isolation_key=...)``.
The contextvar holds a plain :class:`IsolationKeys` mapping; conversion to
provider-specific types happens at the consuming provider so this module has no
provider dependencies.
"""
from __future__ import annotations
from contextvars import ContextVar, Token
__all__ = [
"ISOLATION_HEADER_CHAT",
"ISOLATION_HEADER_USER",
"IsolationKeys",
"current_isolation_keys",
"get_current_isolation_keys",
"reset_current_isolation_keys",
"set_current_isolation_keys",
]
ISOLATION_HEADER_USER = "x-agent-user-isolation-key"
ISOLATION_HEADER_CHAT = "x-agent-chat-isolation-key"
class IsolationKeys:
"""Per-request isolation keys lifted from host/platform context."""
def __init__(self, user_key: str | None = None, chat_key: str | None = None) -> None:
self.user_key = user_key
self.chat_key = chat_key
@property
def is_empty(self) -> bool:
return self.user_key is None and self.chat_key is None
current_isolation_keys: ContextVar[IsolationKeys | None] = ContextVar(
"agent_framework_hosting_isolation_keys",
default=None,
)
def get_current_isolation_keys() -> IsolationKeys | None:
"""Return the isolation keys bound to the current request, if any."""
return current_isolation_keys.get()
def set_current_isolation_keys(keys: IsolationKeys | None) -> Token[IsolationKeys | None]:
"""Bind ``keys`` to the current async context and return a reset token."""
return current_isolation_keys.set(keys)
def reset_current_isolation_keys(token: Token[IsolationKeys | None]) -> None:
"""Restore the isolation contextvar to its prior value."""
current_isolation_keys.reset(token)
@@ -0,0 +1,128 @@
# Copyright (c) Microsoft. All rights reserved.
"""Shared persistence primitives for the hosting package.
The simplified hosting core keeps disk persistence only for session aliases
created by :meth:`AgentFrameworkHost.reset_session` and for workflow
checkpoint path derivation. The on-disk session-alias store uses the optional
``diskcache`` package installed via the ``[disk]`` extra.
"""
from __future__ import annotations
import contextlib
import importlib
import os
import sys
from collections.abc import Mapping
from pathlib import Path
from typing import TYPE_CHECKING, Any
if TYPE_CHECKING:
from ._types import HostStatePaths
_KNOWN_COMPONENTS: tuple[str, ...] = ("sessions", "checkpoints")
def load_diskcache() -> Any:
"""Lazy-import :mod:`diskcache` with a helpful error when missing."""
try:
return importlib.import_module("diskcache")
except ImportError as exc: # pragma: no cover - exercised via tests by monkeypatching
raise ImportError(
"agent-framework-hosting was asked to persist session aliases to disk "
"(state_dir['sessions'] is set) but the optional `diskcache` dependency "
"is not installed. Install the disk extra: "
"`pip install 'agent-framework-hosting[disk]`."
) from exc
def acquire_state_dir_lock(component_dir: Path) -> Any:
"""Acquire an exclusive single-owner lock on a component's state dir.
Raises:
RuntimeError: If another process already holds the lock.
"""
component_dir.mkdir(parents=True, exist_ok=True)
lock_path = component_dir / ".lock"
fh = open(lock_path, "a+", encoding="utf-8") # noqa: SIM115 - kept open for lifetime
try:
if sys.platform == "win32":
import msvcrt
try:
msvcrt.locking(fh.fileno(), msvcrt.LK_NBLCK, 1)
except OSError as exc:
fh.close()
raise RuntimeError(
f"Another process already holds the hosting state lock at {lock_path}. "
"Point each host at its own state_dir."
) from exc
else:
import fcntl
try:
fcntl.flock(fh.fileno(), fcntl.LOCK_EX | fcntl.LOCK_NB)
except OSError as exc:
fh.close()
raise RuntimeError(
f"Another process already holds the hosting state lock at {lock_path}. "
"Point each host at its own state_dir."
) from exc
except RuntimeError:
raise
except Exception:
fh.close()
raise
return fh
def release_state_dir_lock(handle: Any) -> None:
"""Release a lock previously acquired by :func:`acquire_state_dir_lock`."""
if handle is None:
return
with contextlib.suppress(Exception):
handle.close()
def normalize_state_dir(
state_dir: str | os.PathLike[str] | HostStatePaths | Mapping[str, str | os.PathLike[str]] | None,
) -> dict[str, Path | None]:
"""Resolve the host-level ``state_dir`` parameter into a per-component map.
Accepts ``None``, a single root path, or a mapping with ``sessions`` and
``checkpoints`` keys. Unknown keys raise ``ValueError`` so obsolete
``runner`` / ``links`` configuration is rejected instead of silently
doing nothing.
"""
result: dict[str, Path | None] = {name: None for name in _KNOWN_COMPONENTS}
if state_dir is None:
return result
if isinstance(state_dir, (str, os.PathLike)):
root = Path(os.fspath(state_dir))
for name in _KNOWN_COMPONENTS:
result[name] = root / name
return result
if isinstance(state_dir, Mapping):
unknown = [k for k in state_dir if k not in _KNOWN_COMPONENTS]
if unknown:
raise ValueError(
f"state_dir mapping contains unknown component key(s): {unknown!r}. "
f"Known components are: {list(_KNOWN_COMPONENTS)!r}."
)
for name in _KNOWN_COMPONENTS:
raw_value: Any = state_dir.get(name)
if raw_value is None:
result[name] = None
continue
if isinstance(raw_value, (str, os.PathLike)):
result[name] = Path(os.fspath(raw_value))
else:
raise TypeError(f"state_dir[{name!r}] must be a str or PathLike — got {type(raw_value).__name__}")
return result
raise TypeError(
f"state_dir must be a str, PathLike, HostStatePaths mapping, or None — got {type(state_dir).__name__}"
)
@@ -0,0 +1,146 @@
# Copyright (c) Microsoft. All rights reserved.
"""Disk-backed wrapper for the host's session-alias map.
``AgentFrameworkHost.reset_session(isolation_key)`` rotates future requests for
that isolation key onto a new session id. Persisting the alias map lets that
rotation survive a host restart without introducing cross-channel identity or
delivery state into the core host.
"""
from __future__ import annotations
import logging
import os
from collections.abc import Mapping
from pathlib import Path
from typing import Any, TypeVar
from ._persistence import (
acquire_state_dir_lock,
load_diskcache,
release_state_dir_lock,
)
logger = logging.getLogger(__name__)
_V = TypeVar("_V")
_ALIASES_PREFIX = "aliases:"
class SessionsStateStore:
"""One disk cache + lock for host-side session aliases."""
def __init__(self, sessions_dir: str | os.PathLike[str]) -> None:
self._sessions_dir: Path = Path(os.fspath(sessions_dir))
diskcache = load_diskcache()
self._lock_handle: Any = acquire_state_dir_lock(self._sessions_dir)
try:
self._cache: Any = diskcache.Cache(str(self._sessions_dir))
except Exception:
release_state_dir_lock(self._lock_handle)
self._lock_handle = None
raise
@property
def cache(self) -> Any:
"""Return the underlying :mod:`diskcache` Cache."""
return self._cache
def close(self) -> None:
"""Close the cache and release the directory lock."""
if self._cache is not None:
try:
self._cache.close()
except Exception: # pragma: no cover - close errors aren't actionable
logger.exception("SessionsStateStore: failed to close cache cleanly")
self._cache = None
if self._lock_handle is not None:
release_state_dir_lock(self._lock_handle)
self._lock_handle = None
class _PersistedDict(dict[str, _V]):
"""Drop-in :class:`dict` whose mutations mirror to a diskcache prefix."""
def __init__(
self,
store: SessionsStateStore,
key_prefix: str,
initial: Mapping[str, _V] | None = None,
) -> None:
super().__init__()
self._store = store
self._prefix = key_prefix
cache: Any = store.cache
for raw_key in cache.iterkeys():
if not isinstance(raw_key, str) or not raw_key.startswith(key_prefix):
continue
try:
value: Any = cache.get(raw_key)
except Exception:
logger.exception("SessionsStateStore: failed to rehydrate %s; skipping", raw_key)
continue
logical_key = raw_key[len(key_prefix) :]
super().__setitem__(logical_key, value)
if initial:
for key, value in initial.items():
self[key] = value
def __setitem__(self, key: str, value: _V) -> None:
super().__setitem__(key, value)
try:
self._store.cache.set(self._prefix + key, value)
except Exception: # pragma: no cover - cache write failures aren't actionable
logger.exception("SessionsStateStore: failed to persist %s%s", self._prefix, key)
def __delitem__(self, key: str) -> None:
super().__delitem__(key)
try:
del self._store.cache[self._prefix + key]
except KeyError:
pass
except Exception: # pragma: no cover - cache write failures aren't actionable
logger.exception("SessionsStateStore: failed to evict %s%s", self._prefix, key)
def pop(self, key: str, *args: Any) -> _V:
"""Mirror ``dict.pop`` to disk."""
value: _V = super().pop(key, *args)
try:
del self._store.cache[self._prefix + key]
except KeyError:
pass
except Exception: # pragma: no cover
logger.exception("SessionsStateStore: failed to evict %s%s", self._prefix, key)
return value
def clear(self) -> None:
"""Mirror ``dict.clear`` to disk."""
keys = list(self.keys())
super().clear()
cache = self._store.cache
for key in keys:
try:
del cache[self._prefix + key]
except KeyError:
pass
except Exception: # pragma: no cover
logger.exception("SessionsStateStore: failed to evict %s%s during clear", self._prefix, key)
def update( # type: ignore[override]
self,
other: Mapping[str, _V] | None = None,
/,
**kwargs: _V,
) -> None:
"""Mirror ``dict.update`` to disk one item at a time."""
if other is not None:
for key in other:
self[key] = other[key]
for key, value in kwargs.items():
self[key] = value
def build_session_aliases(store: SessionsStateStore) -> dict[str, str]:
"""Return the disk-backed session-alias map for ``store``."""
return _PersistedDict[str](store, _ALIASES_PREFIX)
@@ -0,0 +1,212 @@
# Copyright (c) Microsoft. All rights reserved.
# ``ChannelRequest`` is the only intentional dataclass here (callers use
# ``dataclasses.replace`` on it in run hooks). The other types are plain
# Python classes by preference, so the "could be a dataclass" lint is muted
# at the file level.
# ruff: noqa: B903
"""Channel-neutral request envelope and channel protocol types.
These types form the boundary between the host and individual channels.
A channel parses its native payload, builds a :class:`ChannelRequest`, and
hands it to :class:`ChannelContext.run` (or ``run_stream``) on the host.
The channel owns rendering the result back onto its originating protocol.
"""
from __future__ import annotations
import os
from collections.abc import Awaitable, Callable, Mapping, Sequence
from dataclasses import dataclass, field
from typing import TYPE_CHECKING, Any, Generic, Protocol, TypedDict, TypeVar, runtime_checkable
from agent_framework import (
AgentResponseUpdate,
AgentRunInputs,
)
from starlette.routing import BaseRoute
if TYPE_CHECKING:
from ._host import ChannelContext
class ChannelSession:
"""Channel-supplied session hint.
The host turns this into an ``AgentSession`` keyed by ``isolation_key`` so
every distinct end user gets their own context-provider state (e.g. one
``FileHistoryProvider`` JSONL file per user).
"""
def __init__(self, isolation_key: str | None = None) -> None:
self.isolation_key = isolation_key
class ChannelIdentity:
"""Channel-native identity metadata observed on a request.
The simplified hosting core records this only on the persisted input
message's ``additional_properties["hosting"]`` block and forwards it
through run/response hooks. Cross-channel linking and recipient lookup are
follow-up concerns, not part of the v1 host contract.
"""
def __init__(
self,
channel: str,
native_id: str,
attributes: Mapping[str, Any] | None = None,
) -> None:
self.channel = channel
self.native_id = native_id
self.attributes: Mapping[str, Any] = attributes if attributes is not None else dict()
@dataclass
class ChannelRequest:
"""Uniform invocation envelope every channel produces from its native payload.
Kept as a dataclass so app authors can use ``dataclasses.replace(...)`` in
run hooks to produce a modified envelope without re-listing every field.
"""
channel: str
operation: str
input: AgentRunInputs
session: ChannelSession | None = None
options: Mapping[str, Any] | None = None
session_mode: str = "auto"
metadata: Mapping[str, Any] = field(default_factory=lambda: {})
attributes: Mapping[str, Any] = field(default_factory=lambda: {})
stream: bool = False
identity: ChannelIdentity | None = None
class ChannelCommand:
"""A discoverable command a channel exposes to its users (e.g. ``/reset``)."""
def __init__(
self,
name: str,
description: str,
handle: Callable[[ChannelCommandContext], Awaitable[None]],
) -> None:
self.name = name
self.description = description
self.handle = handle
class ChannelCommandContext:
"""Context passed to a :class:`ChannelCommand` handler."""
def __init__(
self,
request: ChannelRequest,
reply: Callable[[str], Awaitable[None]],
) -> None:
self.request = request
self.reply = reply
_EMPTY_ROUTES: tuple[BaseRoute, ...] = ()
_EMPTY_COMMANDS: tuple[ChannelCommand, ...] = ()
_EMPTY_LIFECYCLE: tuple[Callable[[], Awaitable[None]], ...] = ()
class ChannelContribution:
"""Routes, commands, and lifecycle hooks a channel contributes to the host."""
def __init__(
self,
routes: Sequence[BaseRoute] = _EMPTY_ROUTES,
commands: Sequence[ChannelCommand] = _EMPTY_COMMANDS,
on_startup: Sequence[Callable[[], Awaitable[None]]] = _EMPTY_LIFECYCLE,
on_shutdown: Sequence[Callable[[], Awaitable[None]]] = _EMPTY_LIFECYCLE,
) -> None:
self.routes = routes
self.commands = commands
self.on_startup = on_startup
self.on_shutdown = on_shutdown
class _Unset:
"""Sentinel for ``HostedRunResult.replace`` overrides.
Distinguishes "caller did not pass this kwarg" from "caller passed
``None`` explicitly" — needed because ``session`` is ``None`` in
many envelopes and we want the no-arg call to preserve it.
"""
_UNSET = _Unset()
TResult = TypeVar("TResult")
class HostedRunResult(Generic[TResult]):
"""Channel-neutral envelope around the target's full-fidelity result.
The host does not flatten or pre-shape the target output. Channels and
response hooks read the underlying result type directly and serialize the
subset their wire format can carry.
"""
def __init__(
self,
result: TResult,
*,
session: Any | None = None,
) -> None:
self.result = result
self.session = session
def replace(
self,
*,
result: TResult | _Unset = _UNSET,
session: Any | _Unset | None = _UNSET,
) -> HostedRunResult[TResult]:
"""Return a shallow copy with the supplied fields overridden."""
new: HostedRunResult[TResult] = HostedRunResult.__new__(HostedRunResult) # pyright: ignore[reportUnknownVariableType]
new.result = self.result if isinstance(result, _Unset) else result
new.session = self.session if isinstance(session, _Unset) else session
return new
class HostStatePaths(TypedDict, total=False):
"""Per-component disk paths for host-managed state.
Only session aliases and workflow checkpoints remain in the simplified
host. Linking stores, active-channel maps, identity registries, and runner
queues are follow-up concerns.
"""
sessions: str | os.PathLike[str]
"""Where the host persists session aliases created by ``reset_session``."""
checkpoints: str | os.PathLike[str]
"""Where the host persists workflow checkpoints for ``Workflow`` targets."""
ChannelStreamUpdateHook = Callable[
[AgentResponseUpdate],
"AgentResponseUpdate | Awaitable[AgentResponseUpdate | None] | None",
]
ChannelRunHook = Callable[..., "Awaitable[ChannelRequest] | ChannelRequest"]
ChannelResponseHook = Callable[..., "Awaitable[HostedRunResult[Any]] | HostedRunResult[Any]"]
@runtime_checkable
class Channel(Protocol):
"""A pluggable adapter that exposes one transport on the host."""
name: str
path: str
def contribute(self, context: ChannelContext) -> ChannelContribution: ...
+92
View File
@@ -0,0 +1,92 @@
[project]
name = "agent-framework-hosting"
description = "Multi-channel hosting for Microsoft Agent Framework agents."
authors = [{ name = "Microsoft", email = "af-support@microsoft.com"}]
readme = "README.md"
requires-python = ">=3.10"
version = "1.0.0a260424"
license-files = ["LICENSE"]
urls.homepage = "https://aka.ms/agent-framework"
urls.source = "https://github.com/microsoft/agent-framework/tree/main/python"
urls.release_notes = "https://github.com/microsoft/agent-framework/releases?q=tag%3Apython-1&expanded=true"
urls.issues = "https://github.com/microsoft/agent-framework/issues"
classifiers = [
"License :: OSI Approved :: MIT License",
"Development Status :: 3 - Alpha",
"Intended Audience :: Developers",
"Programming Language :: Python :: 3",
"Programming Language :: Python :: 3.10",
"Programming Language :: Python :: 3.11",
"Programming Language :: Python :: 3.12",
"Programming Language :: Python :: 3.13",
"Programming Language :: Python :: 3.14",
"Typing :: Typed",
]
dependencies = [
"agent-framework-core>=1.2.0,<2",
"starlette>=0.37",
]
[project.optional-dependencies]
serve = [
"hypercorn>=0.17",
]
disk = [
"diskcache>=5.6",
]
[tool.uv]
prerelease = "if-necessary-or-explicit"
environments = [
"sys_platform == 'darwin'",
"sys_platform == 'linux'",
"sys_platform == 'win32'"
]
[tool.uv-dynamic-versioning]
fallback-version = "0.0.0"
[tool.pytest.ini_options]
testpaths = 'tests'
addopts = "-ra -q -r fEX"
asyncio_mode = "auto"
asyncio_default_fixture_loop_scope = "function"
filterwarnings = []
timeout = 120
markers = [
"integration: marks tests as integration tests that require external services",
]
[tool.ruff]
extend = "../../pyproject.toml"
[tool.coverage.run]
omit = [
"**/__init__.py"
]
[tool.pyright]
extends = "../../pyproject.toml"
include = ["agent_framework_hosting"]
exclude = ['tests']
[tool.bandit]
targets = ["agent_framework_hosting"]
exclude_dirs = ["tests"]
[tool.poe]
executor.type = "uv"
include = "../../shared_tasks.toml"
[tool.poe.tasks.test]
help = "Run the default unit test suite for this package."
cmd = 'pytest -m "not integration" --cov=agent_framework_hosting --cov-report=term-missing:skip-covered tests'
[build-system]
requires = ["flit-core >= 3.11,<4.0"]
build-backend = "flit_core.buildapi"
[dependency-groups]
dev = [
"httpx>=0.28.1",
]
@@ -0,0 +1,45 @@
# Copyright (c) Microsoft. All rights reserved.
"""Workflow fixtures for hosting tests.
Defined in a module that does not use ``from __future__ import annotations``
because the workflow handler validation reflects on real annotation objects
rather than stringified forms.
"""
from typing import Any
from agent_framework import Executor, Workflow, WorkflowBuilder, WorkflowContext, handler
class _UpperExecutor(Executor):
@handler
async def handle(self, text: str, ctx: WorkflowContext[Any, str]) -> None:
await ctx.yield_output(text.upper())
class _EchoExecutor(Executor):
@handler
async def handle(self, text: str, ctx: WorkflowContext[Any, str]) -> None:
await ctx.yield_output(text)
def build_upper_workflow() -> Workflow:
return WorkflowBuilder(start_executor=_UpperExecutor(id="upper")).build()
def build_echo_workflow() -> Workflow:
return WorkflowBuilder(start_executor=_EchoExecutor(id="echo")).build()
class _MultiChunkExecutor(Executor):
"""Yields three separate ``output`` events so streaming has something to chew on."""
@handler
async def handle(self, text: str, ctx: WorkflowContext[Any, str]) -> None:
for chunk in (f"{text}-1", f"{text}-2", f"{text}-3"):
await ctx.yield_output(chunk)
def build_multi_chunk_workflow() -> Workflow:
return WorkflowBuilder(start_executor=_MultiChunkExecutor(id="multi")).build()
@@ -0,0 +1,25 @@
# Copyright (c) Microsoft. All rights reserved.
"""Pytest configuration for hosting tests."""
from __future__ import annotations
import importlib.util
import sys
from pathlib import Path
def pytest_configure() -> None:
"""Make workflow fixtures importable in package-local and aggregate test modes."""
module_name = "hosting_workflow_fixtures"
if module_name in sys.modules:
return
fixture_path = Path(__file__).with_name("_workflow_fixtures.py")
spec = importlib.util.spec_from_file_location(module_name, fixture_path)
if spec is None or spec.loader is None:
raise ImportError(f"Unable to load workflow fixtures from {fixture_path}")
module = importlib.util.module_from_spec(spec)
sys.modules[module_name] = module
spec.loader.exec_module(module)
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,239 @@
# Copyright (c) Microsoft. All rights reserved.
"""Tests for narrowed ``state_dir`` support in :class:`AgentFrameworkHost`."""
from __future__ import annotations
import importlib
from pathlib import Path
from typing import Any, cast
import pytest
from agent_framework import AgentSession
from agent_framework_hosting import AgentFrameworkHost, ChannelContext, ChannelContribution
pytest.importorskip("diskcache")
class _AgentStub:
"""Bare-minimum SupportsAgentRun stub for host construction."""
id = "agent-stub"
name: str | None = "Agent Stub"
description: str | None = "Test agent stub"
def create_session(self, *, session_id: str | None = None) -> AgentSession:
return AgentSession(session_id=session_id)
def get_session(self, service_session_id: str, *, session_id: str | None = None) -> AgentSession:
return AgentSession(service_session_id=service_session_id, session_id=session_id)
def run(self, *_args: Any, **_kwargs: Any) -> Any: # pragma: no cover - unused
raise RuntimeError("not invoked")
class _ChannelStub:
name = "stub"
path = "/stub"
def contribute(self, context: ChannelContext) -> ChannelContribution:
del context
return ChannelContribution()
def _close_host_disk(host: AgentFrameworkHost) -> None:
"""Release any session-alias store held by ``host``."""
if host._sessions_store is not None:
host._sessions_store.close()
def test_state_dir_none_keeps_plain_alias_dict(tmp_path: Path) -> None:
"""No store, no alias persistence, no files written."""
host = AgentFrameworkHost(target=_AgentStub(), channels=[_ChannelStub()])
assert host._sessions_store is None
assert isinstance(host._session_aliases, dict)
assert list(tmp_path.iterdir()) == []
def test_string_state_dir_creates_sessions_subfolder_only(tmp_path: Path) -> None:
"""Passing a single path expands to ``sessions/`` plus lazy checkpoint path."""
host = AgentFrameworkHost(
target=_AgentStub(),
channels=[_ChannelStub()],
state_dir=tmp_path,
)
try:
assert host._sessions_store is not None
assert (tmp_path / "sessions").is_dir()
assert not (tmp_path / "runner").exists()
assert not (tmp_path / "links").exists()
# Checkpoint path is derived but not created for agent targets.
assert not (tmp_path / "checkpoints").exists()
finally:
_close_host_disk(host)
def test_per_component_session_path(tmp_path: Path) -> None:
"""Dict form lets callers route session aliases to a specific root."""
sessions_dir = tmp_path / "state"
host = AgentFrameworkHost(
target=_AgentStub(),
channels=[_ChannelStub()],
state_dir={"sessions": sessions_dir},
)
try:
assert sessions_dir.is_dir()
assert host._sessions_store is not None
assert host._checkpoint_location is None
finally:
_close_host_disk(host)
@pytest.mark.parametrize("key", ["runner", "links", "active", "identities"])
def test_removed_state_dir_component_keys_raise(tmp_path: Path, key: str) -> None:
"""Obsolete follow-up components should fail loudly instead of becoming no-ops."""
with pytest.raises(ValueError, match="unknown"):
AgentFrameworkHost(
target=_AgentStub(),
channels=[_ChannelStub()],
state_dir=cast(Any, {key: tmp_path / key}),
)
def test_session_aliases_survive_restart(tmp_path: Path) -> None:
"""Aliases written on host #1 must be visible to host #2."""
state_dir = tmp_path / "state"
host1 = AgentFrameworkHost(target=_AgentStub(), channels=[_ChannelStub()], state_dir=state_dir)
host1._session_aliases["user-1"] = "sess-abc"
host1._session_aliases["user-2"] = "sess-def"
_close_host_disk(host1)
host2 = AgentFrameworkHost(target=_AgentStub(), channels=[_ChannelStub()], state_dir=state_dir)
try:
assert host2._session_aliases["user-1"] == "sess-abc"
assert host2._session_aliases["user-2"] == "sess-def"
finally:
_close_host_disk(host2)
def _build_simple_workflow() -> Any:
"""Build a no-op workflow for checkpoint-wiring tests."""
build_upper_workflow = importlib.import_module("hosting_workflow_fixtures").build_upper_workflow
return build_upper_workflow()
def test_single_path_state_dir_wires_workflow_checkpoints(tmp_path: Path) -> None:
"""``state_dir="/foo"`` + workflow target → ``/foo/checkpoints/`` is used."""
workflow = _build_simple_workflow()
host = AgentFrameworkHost(
target=workflow,
channels=[_ChannelStub()],
state_dir=tmp_path,
)
try:
assert host._checkpoint_location == tmp_path / "checkpoints"
finally:
_close_host_disk(host)
def test_mapping_state_dir_checkpoints_key_wires_workflow_checkpoints(tmp_path: Path) -> None:
"""``state_dir={"checkpoints": ...}`` + workflow target → that path is used."""
workflow = _build_simple_workflow()
ckpt_dir = tmp_path / "ck"
host = AgentFrameworkHost(
target=workflow,
channels=[_ChannelStub()],
state_dir={"checkpoints": ckpt_dir},
)
try:
assert host._checkpoint_location == ckpt_dir
assert host._sessions_store is None
finally:
_close_host_disk(host)
def test_mapping_state_dir_omits_checkpoints_for_workflow(tmp_path: Path) -> None:
"""Mapping form lets workflow callers opt out of checkpoint persistence."""
workflow = _build_simple_workflow()
host = AgentFrameworkHost(
target=workflow,
channels=[_ChannelStub()],
state_dir={"sessions": tmp_path / "s"},
)
try:
assert host._checkpoint_location is None
finally:
_close_host_disk(host)
def test_explicit_checkpoint_location_wins_over_state_dir(tmp_path: Path, caplog: pytest.LogCaptureFixture) -> None:
"""``checkpoint_location`` + ``state_dir`` → explicit param wins + warn."""
workflow = _build_simple_workflow()
explicit = tmp_path / "explicit-ck"
with caplog.at_level("WARNING", logger="agent_framework.hosting"):
host = AgentFrameworkHost(
target=workflow,
channels=[_ChannelStub()],
checkpoint_location=explicit,
state_dir=tmp_path,
)
try:
assert host._checkpoint_location == explicit
assert any(
"state_dir['checkpoints']" in rec.message and "checkpoint_location" in rec.message for rec in caplog.records
)
finally:
_close_host_disk(host)
def test_state_dir_checkpoints_for_agent_target_silent_for_single_path(tmp_path: Path) -> None:
"""Single-path state_dir + agent target → no checkpoint, no warning."""
host = AgentFrameworkHost(
target=_AgentStub(),
channels=[_ChannelStub()],
state_dir=tmp_path,
)
try:
assert host._checkpoint_location is None
assert not (tmp_path / "checkpoints").exists()
finally:
_close_host_disk(host)
def test_state_dir_checkpoints_for_agent_target_warns_when_explicit(
tmp_path: Path, caplog: pytest.LogCaptureFixture
) -> None:
"""Mapping form with ``checkpoints`` + agent target → warn."""
with caplog.at_level("WARNING", logger="agent_framework.hosting"):
host = AgentFrameworkHost(
target=_AgentStub(),
channels=[_ChannelStub()],
state_dir={"checkpoints": tmp_path / "ck"},
)
try:
assert host._checkpoint_location is None
assert any(
"state_dir['checkpoints']" in rec.message and "not a Workflow" in rec.message for rec in caplog.records
)
finally:
_close_host_disk(host)
def test_state_dir_checkpoints_conflicts_with_workflow_own_storage(tmp_path: Path) -> None:
"""Derived checkpoint path triggers the same conflict guard as explicit."""
from agent_framework import InMemoryCheckpointStorage, WorkflowBuilder
_UpperExecutor = importlib.import_module("hosting_workflow_fixtures")._UpperExecutor
workflow = WorkflowBuilder(
start_executor=_UpperExecutor(id="upper"),
checkpoint_storage=InMemoryCheckpointStorage(),
).build()
with pytest.raises(RuntimeError, match="already has checkpoint storage"):
AgentFrameworkHost(
target=workflow,
channels=[_ChannelStub()],
state_dir=tmp_path,
)
@@ -0,0 +1,316 @@
# Copyright (c) Microsoft. All rights reserved.
"""Tests for the per-request isolation contextvar surface in
:mod:`agent_framework_hosting._isolation`.
The isolation keys are the ONLY seam Foundry-aware providers use to
find partition keys, and the host's ASGI middleware lifts them off the
two well-known headers on every inbound HTTP request. A regression
that drops the lookup, mistypes a header name, or fails to reset the
contextvar would silently misroute writes / leak per-request state
across requests, with zero unit-test signal so cover the surface
fully here.
"""
from __future__ import annotations
import asyncio
from typing import Any
import pytest
from agent_framework import AgentSession
from starlette.requests import Request
from starlette.responses import JSONResponse
from starlette.routing import BaseRoute, Route
from starlette.testclient import TestClient
from agent_framework_hosting import (
AgentFrameworkHost,
Channel,
ChannelContext,
ChannelContribution,
IsolationKeys,
get_current_isolation_keys,
reset_current_isolation_keys,
set_current_isolation_keys,
)
from agent_framework_hosting._isolation import ( # pyright: ignore[reportPrivateUsage]
ISOLATION_HEADER_CHAT,
ISOLATION_HEADER_USER,
current_isolation_keys,
)
class TestIsolationKeys:
def test_defaults_to_none_pair(self) -> None:
keys = IsolationKeys()
assert keys.user_key is None
assert keys.chat_key is None
assert keys.is_empty is True
def test_partial_with_only_user_is_not_empty(self) -> None:
keys = IsolationKeys(user_key="alice")
assert keys.user_key == "alice"
assert keys.chat_key is None
assert keys.is_empty is False
def test_partial_with_only_chat_is_not_empty(self) -> None:
keys = IsolationKeys(chat_key="general")
assert keys.is_empty is False
def test_full_pair_is_not_empty(self) -> None:
keys = IsolationKeys(user_key="alice", chat_key="general")
assert keys.is_empty is False
class TestContextVarHelpers:
def test_default_is_none(self) -> None:
# Each test gets a fresh contextvar value because pytest runs
# tests in fresh contexts. ``get`` returns the default.
assert get_current_isolation_keys() is None
def test_set_and_get_round_trip(self) -> None:
token = set_current_isolation_keys(IsolationKeys(user_key="alice", chat_key="general"))
try:
current = get_current_isolation_keys()
assert current is not None
assert current.user_key == "alice"
assert current.chat_key == "general"
finally:
reset_current_isolation_keys(token)
# Reset restores prior value (None in the default context).
assert get_current_isolation_keys() is None
def test_set_with_none_clears(self) -> None:
outer = set_current_isolation_keys(IsolationKeys(user_key="alice"))
try:
inner = set_current_isolation_keys(None)
try:
assert get_current_isolation_keys() is None
finally:
reset_current_isolation_keys(inner)
# Reset surfaces the outer value again.
current = get_current_isolation_keys()
assert current is not None
assert current.user_key == "alice"
finally:
reset_current_isolation_keys(outer)
def test_module_level_contextvar_is_the_same_instance(self) -> None:
"""Direct contextvar access (used by the ASGI middleware) and the
public `get_current_isolation_keys()` helper read from the SAME
underlying contextvar. A regression that introduced a second
contextvar would silently break the middleware provider hop."""
token = current_isolation_keys.set(IsolationKeys(user_key="bob"))
try:
via_helper = get_current_isolation_keys()
assert via_helper is not None
assert via_helper.user_key == "bob"
finally:
current_isolation_keys.reset(token)
class TestHeaderConstants:
"""The two header names are part of the public contract — they
match the ones the Foundry Hosted Agents runtime stamps on every
inbound request. A typo here would silently misroute partition
writes."""
def test_user_header_value(self) -> None:
assert ISOLATION_HEADER_USER == "x-agent-user-isolation-key"
def test_chat_header_value(self) -> None:
assert ISOLATION_HEADER_CHAT == "x-agent-chat-isolation-key"
# --------------------------------------------------------------------------- #
# End-to-end: ASGI middleware lifts the headers into the contextvar.
# --------------------------------------------------------------------------- #
class _IsolationProbeChannel:
"""A minimal Channel that exposes a single GET route which captures
the contextvar value INSIDE the request and returns it as JSON.
Tests use this to exercise the full middleware contextvar
handler hop end-to-end.
"""
name = "probe"
path = ""
def __init__(self) -> None:
self.captured: list[IsolationKeys | None] = []
async def _handler(_request: Request) -> JSONResponse:
keys = get_current_isolation_keys()
self.captured.append(keys)
payload: dict[str, str | bool | None]
payload = (
{"user": keys.user_key, "chat": keys.chat_key}
if keys is not None
else {"user": None, "chat": None, "_present": False}
)
return JSONResponse(payload)
self._routes: list[BaseRoute] = [Route("/probe", _handler)]
def contribute(self, context: ChannelContext) -> ChannelContribution:
del context
return ChannelContribution(routes=self._routes)
def _make_host_with_probe() -> tuple[AgentFrameworkHost, _IsolationProbeChannel]:
class _NoopAgent:
id = "noop-agent"
name: str | None = "Noop Agent"
description: str | None = "Test noop agent"
def create_session(self, *, session_id: str | None = None) -> AgentSession:
return AgentSession(session_id=session_id)
def get_session(self, service_session_id: str, *, session_id: str | None = None) -> AgentSession:
return AgentSession(service_session_id=service_session_id, session_id=session_id)
def run(self, *_args: object, **_kwargs: object) -> Any: # pragma: no cover - never called
raise RuntimeError("not invoked")
probe = _IsolationProbeChannel()
assert isinstance(probe, Channel)
host = AgentFrameworkHost(target=_NoopAgent(), channels=[probe])
return host, probe
class TestIsolationMiddlewareEndToEnd:
def test_headers_ignored_outside_foundry_environment(self) -> None:
host, probe = _make_host_with_probe()
with TestClient(host.app) as client: # type: ignore[attr-defined]
r = client.get(
"/probe",
headers={
ISOLATION_HEADER_USER: "alice-uid",
ISOLATION_HEADER_CHAT: "general-cid",
},
)
assert r.status_code == 200
assert r.json() == {"user": None, "chat": None, "_present": False}
assert probe.captured == [None]
def test_both_headers_lifted_into_contextvar(self, monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setenv("FOUNDRY_HOSTING_ENVIRONMENT", "1")
host, probe = _make_host_with_probe()
with TestClient(host.app) as client: # type: ignore[attr-defined]
r = client.get(
"/probe",
headers={
ISOLATION_HEADER_USER: "alice-uid",
ISOLATION_HEADER_CHAT: "general-cid",
},
)
assert r.status_code == 200
assert r.json() == {"user": "alice-uid", "chat": "general-cid"}
assert len(probe.captured) == 1
captured = probe.captured[0]
assert captured is not None
assert captured.user_key == "alice-uid"
assert captured.chat_key == "general-cid"
def test_only_user_header_lifted(self, monkeypatch: pytest.MonkeyPatch) -> None:
"""One-header-only branch: the middleware still binds (chat=None)."""
monkeypatch.setenv("FOUNDRY_HOSTING_ENVIRONMENT", "1")
host, probe = _make_host_with_probe()
with TestClient(host.app) as client: # type: ignore[attr-defined]
r = client.get("/probe", headers={ISOLATION_HEADER_USER: "alice-uid"})
assert r.status_code == 200
assert r.json() == {"user": "alice-uid", "chat": None}
def test_only_chat_header_lifted(self, monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setenv("FOUNDRY_HOSTING_ENVIRONMENT", "1")
host, probe = _make_host_with_probe()
with TestClient(host.app) as client: # type: ignore[attr-defined]
r = client.get("/probe", headers={ISOLATION_HEADER_CHAT: "general-cid"})
assert r.status_code == 200
assert r.json() == {"user": None, "chat": "general-cid"}
def test_no_headers_keeps_contextvar_none(self) -> None:
"""Local-dev path: with neither header present the middleware is
a no-op and the contextvar stays at its default ``None``
providers see "no isolation" and route to the in-memory
fallback rather than picking up stale per-request state."""
host, probe = _make_host_with_probe()
with TestClient(host.app) as client: # type: ignore[attr-defined]
r = client.get("/probe")
assert r.status_code == 200
assert r.json() == {"user": None, "chat": None, "_present": False}
assert probe.captured == [None]
def test_empty_header_value_treated_as_absent(self, monkeypatch: pytest.MonkeyPatch) -> None:
"""A header that's present but empty must not bind an empty key —
``IsolationContext`` rejects empty strings on the read side."""
monkeypatch.setenv("FOUNDRY_HOSTING_ENVIRONMENT", "1")
host, probe = _make_host_with_probe()
with TestClient(host.app) as client: # type: ignore[attr-defined]
r = client.get(
"/probe",
headers={
ISOLATION_HEADER_USER: "",
ISOLATION_HEADER_CHAT: "general-cid",
},
)
assert r.status_code == 200
# Empty user header decodes to None; chat key stays bound.
assert r.json() == {"user": None, "chat": "general-cid"}
def test_contextvar_resets_after_request(self, monkeypatch: pytest.MonkeyPatch) -> None:
"""The middleware must call ``reset_current_isolation_keys`` in
a ``finally`` so per-request state never leaks across requests
or back into the calling thread's context."""
monkeypatch.setenv("FOUNDRY_HOSTING_ENVIRONMENT", "1")
host, probe = _make_host_with_probe()
with TestClient(host.app) as client: # type: ignore[attr-defined]
r1 = client.get("/probe", headers={ISOLATION_HEADER_USER: "alice-uid"})
assert r1.status_code == 200
# Reading the contextvar OUTSIDE the request scope must see
# the default — not the value the prior request bound.
assert get_current_isolation_keys() is None
# And a follow-up request without headers gets a clean
# ``None`` rather than inheriting alice-uid.
r2 = client.get("/probe")
assert r2.json() == {"user": None, "chat": None, "_present": False}
def test_concurrent_requests_get_isolated_contextvars(self, monkeypatch: pytest.MonkeyPatch) -> None:
"""Different requests run in different async contexts; binding
from request A must NOT leak into a concurrent request B."""
monkeypatch.setenv("FOUNDRY_HOSTING_ENVIRONMENT", "1")
host, probe = _make_host_with_probe()
async def _drive() -> None:
# Run two requests in parallel asyncio tasks against the
# same TestClient and assert their captures don't bleed
# into each other.
async def _hit(user_key: str) -> dict[str, str | None]:
with TestClient(host.app) as client: # type: ignore[attr-defined]
r = client.get("/probe", headers={ISOLATION_HEADER_USER: user_key})
return r.json() # type: ignore[no-any-return]
r_alice, r_bob = await asyncio.gather(_hit("alice-uid"), _hit("bob-uid"))
assert r_alice == {"user": "alice-uid", "chat": None}
assert r_bob == {"user": "bob-uid", "chat": None}
asyncio.run(_drive())
class TestNonHttpScopesPassThrough:
"""The middleware intentionally only inspects ``http`` scopes;
lifespan / websocket scopes are forwarded untouched. A regression
that touched lifespan scopes here would crash boot."""
async def test_lifespan_scope_does_not_consult_headers(self) -> None:
# The TestClient context manager exercises the lifespan scope
# implicitly; if the middleware tried to decode headers on a
# non-http scope this would raise. Exercise it without binding
# any contextvar work.
host, _probe = _make_host_with_probe()
with TestClient(host.app): # type: ignore[attr-defined]
# Just enter / exit; no requests.
pass
@@ -0,0 +1,50 @@
# Copyright (c) Microsoft. All rights reserved.
"""Tests for the channel-neutral envelope types in :mod:`agent_framework_hosting._types`."""
from __future__ import annotations
from agent_framework_hosting import (
ChannelIdentity,
ChannelRequest,
ChannelSession,
)
class TestChannelRequest:
def test_required_fields_only(self) -> None:
req = ChannelRequest(channel="responses", operation="message.create", input="hi")
assert req.channel == "responses"
assert req.operation == "message.create"
assert req.input == "hi"
assert req.session is None
assert req.options is None
assert req.session_mode == "auto"
assert req.metadata == {}
assert req.attributes == {}
assert req.stream is False
assert req.identity is None
def test_with_session_and_identity(self) -> None:
req = ChannelRequest(
channel="telegram",
operation="message.create",
input="hi",
session=ChannelSession(isolation_key="user:42"),
identity=ChannelIdentity(channel="telegram", native_id="42"),
)
assert req.session is not None
assert req.session.isolation_key == "user:42"
assert req.identity is not None
assert req.identity.channel == "telegram"
assert req.identity.native_id == "42"
class TestChannelIdentity:
def test_attributes_default_empty_mapping(self) -> None:
ident = ChannelIdentity(channel="teams", native_id="abc")
assert dict(ident.attributes) == {}
def test_attributes_passthrough(self) -> None:
ident = ChannelIdentity(channel="teams", native_id="abc", attributes={"role": "user"})
assert dict(ident.attributes) == {"role": "user"}
@@ -10,6 +10,7 @@ available in CI / dev sandboxes).
from __future__ import annotations
import subprocess
import sys
import pytest
@@ -24,11 +25,27 @@ from agent_framework_tools.shell._docker import (
build_run_argv,
)
def _docker_image_available(image: str) -> bool:
if not is_docker_available():
return False
try:
result = subprocess.run(
["docker", "image", "inspect", image],
capture_output=True,
check=False,
timeout=5.0,
)
except (OSError, subprocess.TimeoutExpired):
return False
return result.returncode == 0
# Integration tests use Linux container images (alpine) that don't run
# under Docker Desktop's default Windows-container mode.
_skip_if_no_linux_docker = pytest.mark.skipif(
not is_docker_available() or sys.platform == "win32",
reason="docker daemon unavailable or running Windows containers",
not _docker_image_available("alpine:3") or sys.platform == "win32",
reason="docker daemon unavailable, alpine:3 image missing, or running Windows containers",
)
# --------------------------------------------------------------------- argv builders
+2
View File
@@ -91,6 +91,8 @@ agent-framework-foundry-hosting = { workspace = true }
agent-framework-foundry-local = { workspace = true }
agent-framework-gemini = { workspace = true }
agent-framework-github-copilot = { workspace = true }
agent-framework-hosting = { workspace = true }
agent-framework-hosting-responses = { workspace = true }
agent-framework-hyperlight = { workspace = true }
agent-framework-lab = { workspace = true }
agent-framework-mem0 = { workspace = true }
@@ -12,6 +12,10 @@ import asyncio
from agent_framework import Agent, workflow
from agent_framework.foundry import FoundryChatClient
from azure.identity import AzureCliCredential
from dotenv import load_dotenv
# Load environment variables from .env file (e.g., FOUNDRY_PROJECT_ENDPOINT, FOUNDRY_MODEL)
load_dotenv()
# <create_agents>
client = FoundryChatClient(credential=AzureCliCredential())

Some files were not shown because too many files have changed in this diff Show More