Compare commits

..

5 Commits

Author SHA1 Message Date
Tao Chen 9e4e4a8fab Fix typing 2026-06-22 08:42:23 -07:00
Tao Chen 7b5ef68abc Merge branch 'main' into local-branch-5559 2026-06-22 08:25:44 -07:00
Tao Chen bd32e3142c Fix comments 2026-06-16 11:03:57 -07:00
Tao Chen 1a698f92ba Add tests 2026-06-16 10:52:03 -07:00
Tao Chen f70c58fa7c Make sure spans created inside sync ops in streaming path are correctly nested 2026-06-16 10:42:13 -07:00
83 changed files with 4851 additions and 9502 deletions
@@ -1,641 +0,0 @@
---
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
@@ -0,0 +1 @@
../../../.github/skills/pull-requests
-116
View File
@@ -1,116 +0,0 @@
---
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,7 +120,6 @@
</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" />
@@ -218,7 +217,6 @@
<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.11.0</VersionPrefix>
<VersionPrefix>1.10.0</VersionPrefix>
<RCNumber>1</RCNumber>
<DateSuffix>260622</DateSuffix>
<DateSuffix>260610</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.11.0</GitTag>
<GitTag>1.10.0</GitTag>
<Configurations>Debug;Release;Publish</Configurations>
<IsPackable>true</IsPackable>
@@ -1,22 +0,0 @@
<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>
@@ -1,111 +0,0 @@
// 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>
@@ -1,52 +0,0 @@
# 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.
@@ -1,61 +0,0 @@
// 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,15 +64,21 @@ public sealed class HarnessAgentRunner : IDisposable
/// <summary>
/// Replaces the current session with the specified session. Used by the UX driver
/// 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.
/// when importing a serialized session. Acquires the input gate to ensure no
/// concurrent agent turn is reading the session.
/// </summary>
/// <param name="newSession">The new session to use.</param>
internal Task ReplaceSessionAsync(AgentSession newSession)
internal async Task ReplaceSessionAsync(AgentSession newSession)
{
this._session = newSession;
return Task.CompletedTask;
await this._inputGate.WaitAsync().ConfigureAwait(false);
try
{
this._session = newSession;
}
finally
{
this._inputGate.Release();
}
}
/// <inheritdoc/>
@@ -10,11 +10,3 @@ 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 |
@@ -1,21 +0,0 @@
<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>
@@ -1,136 +0,0 @@
// 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);
}
}
@@ -1,89 +0,0 @@
# 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,7 +21,6 @@ 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,15 +101,10 @@ else
throw new ArgumentException("Either A2AServer:ApiKey or A2AServer:ConnectionString & agentName must be provided");
}
// 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:
// When running in production, make sure to use an SessionIsolationKeyProvider, e.g. ClaimsIdentity-based
// if using Claims-based Identity for Authentication/Authorization
// 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,15 +28,10 @@ builder.AddDevUI();
builder.AddOpenAIChatCompletions();
builder.AddOpenAIResponses();
// 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:
// When running in production, make sure to use an SessionIsolationKeyProvider, e.g. ClaimsIdentity-based
// if using Claims-based Identity for Authentication/Authorization
// 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",
@@ -157,9 +152,8 @@ builder.Services.AddKeyedSingleton<AIAgent>("my-di-matchingname-agent", (sp, nam
pirateAgentBuilder.AddA2AServer();
knightsKnavesAgentBuilder.AddA2AServer();
// 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:
// When running in production, make sure to use an SessionIsolationKeyProvider, e.g. ClaimsIdentity-based
// if using Claims-based Identity for Authentication/Authorization
// builder.Services.UseClaimsBasedSessionIsolation(new() { ClaimType = ClaimTypes.NameIdentifier });
var app = builder.Build();
@@ -6,7 +6,6 @@ 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;
@@ -30,7 +29,6 @@ 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.
@@ -41,15 +39,13 @@ 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,
JsonSerializerOptions? jsonSerializerOptions = null)
string? description = null)
{
_ = Throw.IfNull(copilotClient);
@@ -59,7 +55,6 @@ public sealed class GitHubCopilotAgent : AIAgent, IAsyncDisposable
this._id = id;
this._name = name ?? DefaultName;
this._description = description ?? DefaultDescription;
this._jsonSerializerOptions = jsonSerializerOptions ?? GitHubCopilotJsonUtilities.DefaultOptions;
}
/// <summary>
@@ -72,7 +67,6 @@ 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,
@@ -80,16 +74,14 @@ public sealed class GitHubCopilotAgent : AIAgent, IAsyncDisposable
string? name = null,
string? description = null,
IList<AITool>? tools = null,
string? instructions = null,
JsonSerializerOptions? jsonSerializerOptions = null)
string? instructions = null)
: this(
copilotClient,
GetSessionConfig(tools, instructions),
ownsClient,
id,
name,
description,
jsonSerializerOptions)
description)
{
}
@@ -190,14 +182,6 @@ 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;
@@ -365,79 +349,6 @@ 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,6 +1,5 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Collections.Generic;
using System.Diagnostics.CodeAnalysis;
using System.Text.Encodings.Web;
using System.Text.Json;
@@ -44,7 +43,6 @@ 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 NoopAgentSessionStore();
agentSessionStore ??= new InMemoryAgentSessionStore();
agentSessionStore = new IsolationKeyScopedAgentSessionStore(agentSessionStore, isolationKeyProvider, new() { Strict = isolationKeyProvider != null });
}
@@ -14,6 +14,7 @@ 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;
@@ -167,6 +168,9 @@ 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)
@@ -180,6 +184,9 @@ 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,10 +1,7 @@
// 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;
@@ -14,7 +11,7 @@ namespace Microsoft.Agents.AI.Workflows.Checkpointing;
/// </summary>
public sealed class TypeId : IEquatable<TypeId>
{
/// <inheritdoc cref="Assembly.FullName"/>
/// <inheritdoc cref="System.Reflection.Assembly.FullName"/>
public string AssemblyName { get; }
/// <inheritdoc cref="Type.FullName"/>
@@ -49,11 +46,6 @@ 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)
@@ -66,27 +58,11 @@ public sealed class TypeId : IEquatable<TypeId>
return true;
}
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);
return this.AssemblyName == other.AssemblyName && this.TypeName == other.TypeName;
}
/// <inheritdoc />
/// <remarks>Hashes the normalized type name and the simple assembly name.</remarks>
public override int GetHashCode()
=> HashCode.Combine(this.SimpleAssemblyName, this.NormalizedTypeName);
public override int GetHashCode() => HashCode.Combine(this.AssemblyName, this.TypeName);
/// <inheritdoc />
public static bool operator ==(TypeId? left, TypeId? right) => left is null ? right is null : left.Equals(right);
@@ -97,27 +73,13 @@ 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 simple name and normalized type full name are equal to those stored
/// in this instance; otherwise, false.</returns>
/// <returns>true if the specified type's assembly and type names are equal to those stored in this instance; otherwise,
/// false.</returns>
public bool IsMatch(Type type)
{
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);
return this.AssemblyName == type.Assembly.FullName
&& this.TypeName == type.FullName;
}
/// <summary>
@@ -151,64 +113,4 @@ 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,7 +1,6 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using System.Collections.Concurrent;
using System.Collections.Generic;
using System.Diagnostics.CodeAnalysis;
using System.Linq;
@@ -298,14 +297,12 @@ 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 = ResolveTypeLenient(requestType);
Type? concreteType = Type.GetType($"{requestType.TypeName}, {requestType.AssemblyName}", throwOnError: false);
if (concreteType is null || !typeof(IExternalRequestEnvelope).IsAssignableFrom(concreteType))
{
return false;
@@ -320,20 +317,6 @@ 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,12 +94,6 @@ 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 })
@@ -1,133 +0,0 @@
// 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,6 +46,8 @@ 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.
@@ -82,12 +84,9 @@ public sealed class AgentModeProvider : AIContextProvider
new(
"execute",
"""
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:
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:
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,16 +46,11 @@ public sealed class TodoProvider : AIContextProvider, IDisposable
## Todo Items
You have access to a todo list for tracking work items.
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
While planning, make sure that you break down complex tasks into manageable todo items and add them to the list.
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, 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.
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.
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,12 +42,6 @@ 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">
@@ -97,8 +91,6 @@ public sealed class OpenTelemetryAgent : DelegatingAIAgent, IDisposable
this._otelClient = new OpenTelemetryChatClient(
new ForwardingChatClient(this),
sourceName: this._sourceName);
this.TryActivateInnerChatClientTelemetry();
}
/// <inheritdoc/>
@@ -128,16 +120,7 @@ public sealed class OpenTelemetryAgent : DelegatingAIAgent, IDisposable
public bool EnableSensitiveData
{
get => this._otelClient.EnableSensitiveData;
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;
}
}
set => this._otelClient.EnableSensitiveData = value;
}
/// <inheritdoc/>
@@ -221,53 +204,85 @@ public sealed class OpenTelemetryAgent : DelegatingAIAgent, IDisposable
}
/// <summary>
/// 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.
/// 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.
/// </summary>
private void TryActivateInnerChatClientTelemetry()
private AgentRunOptions? GetRunOptionsWithChatClientWiring(AgentRunOptions? options)
{
if (!this._autoWireChatClient)
{
return;
return options;
}
// Auto-wiring only applies when a ChatClientAgent is reachable from the inner agent. Otherwise, no-op.
// The 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;
return options;
}
// Respect ChatClientAgentOptions.UseProvidedChatClientAsIs: don't decorate the chat client when the user opted out.
if (chatClientAgent.GetService<ChatClientAgentOptions>()?.UseProvidedChatClientAsIs is true)
{
return;
return options;
}
// Don't activate when the chat client is already instrumented (e.g. the caller added their own
// OpenTelemetryChatClient), to avoid emitting duplicate chat spans.
// Capture the underlying IChatClient and check whether it is already instrumented.
var chatClient = chatClientAgent.GetService<IChatClient>();
if (chatClient is null || chatClient.GetService(typeof(OpenTelemetryChatClient)) is not null)
{
return;
return options;
}
// 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)
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)
{
slot.Activate(this._sourceName);
slot.EnableSensitiveData = this.EnableSensitiveData;
this._innerTelemetrySlot = 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;
}
// 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
@@ -280,9 +295,11 @@ public sealed class OpenTelemetryAgent : DelegatingAIAgent, IDisposable
// Update the current activity to reflect the agent invocation.
parentAgent.UpdateCurrentActivity(fo?.CurrentActivity);
// 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);
// 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);
// Wrap the response in a ChatResponse so we can pass it back through OpenTelemetryChatClient.
return response.AsChatResponse();
@@ -296,9 +313,11 @@ public sealed class OpenTelemetryAgent : DelegatingAIAgent, IDisposable
// Update the current activity to reflect the agent invocation.
parentAgent.UpdateCurrentActivity(fo?.CurrentActivity);
// 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))
// 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))
{
// Wrap the response updates in ChatResponseUpdates so we can pass them back through OpenTelemetryChatClient.
yield return update.AsChatResponseUpdate();
@@ -48,19 +48,16 @@ public sealed class AgentFileSkill : AgentSkill
/// <inheritdoc/>
/// <remarks>
/// 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.
/// 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.
/// </remarks>
public override ValueTask<string> GetContentAsync(CancellationToken cancellationToken = default)
{
this._content ??=
this._originalContent
+ "\n" + AgentInlineSkillContentBuilder.BuildAvailableResourcesBlock(this._resources)
+ "\n" + AgentInlineSkillContentBuilder.BuildAvailableScriptsBlock(this._scripts);
return new(this._content);
var content = this._content ??= this._scripts is { Count: > 0 }
? this._originalContent + AgentInlineSkillContentBuilder.BuildScriptSchemasBlock(this._scripts)
: this._originalContent;
return new(content);
}
/// <summary>
@@ -120,7 +120,6 @@ public abstract class AgentClassSkill<
this.Frontmatter.Name,
this.Frontmatter.Description,
this.Instructions,
this.Resources,
this.Scripts));
}
@@ -161,9 +160,8 @@ public abstract class AgentClassSkill<
/// Override this property in derived classes to provide skill-specific resources.
/// </para>
/// <para>
/// 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.
/// 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.
/// </para>
/// </remarks>
public virtual IReadOnlyList<AgentSkillResource>? Resources => this._resources.Value;
@@ -180,9 +178,8 @@ public abstract class AgentClassSkill<
/// Override this property in derived classes to provide skill-specific scripts.
/// </para>
/// <para>
/// 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.
/// 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.
/// </para>
/// </remarks>
public virtual IReadOnlyList<AgentSkillScript>? Scripts => this._scripts.Value;
@@ -205,9 +202,8 @@ public abstract class AgentClassSkill<
/// Creates a skill resource backed by a static value.
/// </summary>
/// <remarks>
/// 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.
/// 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.
/// </remarks>
/// <param name="name">The resource name.</param>
/// <param name="value">The static resource value.</param>
@@ -220,9 +216,8 @@ public abstract class AgentClassSkill<
/// Creates a skill resource backed by a delegate that produces a dynamic value.
/// </summary>
/// <remarks>
/// 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.
/// 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.
/// </remarks>
/// <param name="name">The resource name.</param>
/// <param name="method">A method that produces the resource value when requested.</param>
@@ -239,9 +234,8 @@ public abstract class AgentClassSkill<
/// Creates a skill script backed by a delegate.
/// </summary>
/// <remarks>
/// 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.
/// 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.
/// </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._resources, this._scripts));
return new(this._cachedContent ??= AgentInlineSkillContentBuilder.Build(this.Frontmatter.Name, this.Frontmatter.Description, this._instructions, this._scripts));
}
/// <inheritdoc/>
@@ -128,9 +128,8 @@ public sealed class AgentInlineSkill : AgentSkill
/// Registers a static resource with this skill.
/// </summary>
/// <remarks>
/// 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.
/// 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.
/// </remarks>
/// <param name="name">The resource name.</param>
/// <param name="value">The static resource value.</param>
@@ -147,9 +146,8 @@ public sealed class AgentInlineSkill : AgentSkill
/// The delegate's parameters and return type are automatically marshaled via <c>AIFunctionFactory</c>.
/// </summary>
/// <remarks>
/// 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.
/// 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.
/// </remarks>
/// <param name="name">The resource name.</param>
/// <param name="method">A method that produces the resource value when requested.</param>
@@ -170,9 +168,8 @@ public sealed class AgentInlineSkill : AgentSkill
/// The delegate's parameters and return type are automatically marshaled via <c>AIFunctionFactory</c>.
/// </summary>
/// <remarks>
/// 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.
/// 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.
/// </remarks>
/// <param name="name">The script name.</param>
/// <param name="method">A method to execute when the script is invoked.</param>
@@ -12,19 +12,17 @@ namespace Microsoft.Agents.AI;
internal static class AgentInlineSkillContentBuilder
{
/// <summary>
/// Builds the complete skill content containing name, description, instructions, resources, and script parameter schemas.
/// Builds the complete skill content containing name, description, instructions, 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);
@@ -39,71 +37,34 @@ internal static class AgentInlineSkillContentBuilder
.Append(EscapeXmlString(instructions))
.Append("\n</instructions>");
sb.Append('\n');
sb.Append(BuildAvailableResourcesBlock(resources ?? []));
sb.Append('\n');
sb.Append(BuildAvailableScriptsBlock(scripts ?? []));
if (scripts is { Count: > 0 })
{
sb.Append('\n');
sb.Append(BuildScriptSchemasBlock(scripts));
}
return sb.ToString();
}
/// <summary>
/// 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.
/// 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.
/// </summary>
/// <param name="scripts">The scripts to include in the block.</param>
/// <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)
/// <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)
{
_ = Throw.IfNull(scripts);
if (scripts.Count == 0)
{
// Emit an empty element so the model knows no scripts are available and does not hallucinate script names.
return "\n<available_scripts />";
return string.Empty;
}
var sb = new StringBuilder();
sb.Append("\n<available_scripts>\n");
sb.Append("\n<script_schemas>\n");
foreach (var script in scripts)
{
@@ -111,17 +72,15 @@ internal static class AgentInlineSkillContentBuilder
if (parametersSchema is null)
{
sb.Append($" <script name=\"{EscapeXmlString(script.Name)}\"/>\n");
sb.Append($" <schema script=\"{EscapeXmlString(script.Name)}\"/>\n");
}
else
{
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($" <schema script=\"{EscapeXmlString(script.Name)}\">{EscapeXmlString(parametersSchema.Value.GetRawText(), preserveQuotes: true)}</schema>\n");
}
}
sb.Append("</available_scripts>");
sb.Append("</script_schemas>");
return sb.ToString();
}
@@ -1,324 +0,0 @@
// 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 noop session store default and resolves successfully.
/// AddA2AServer falls back to in-memory defaults and resolves successfully.
/// </summary>
[Fact]
public async Task AddA2AServer_WithNoCustomStores_FallsBackToNoopSessionStoreDefaultAsync()
public async Task AddA2AServer_WithNoCustomStores_FallsBackToInMemoryDefaultsAsync()
{
// 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 noop session store and processes requests successfully end-to-end.
/// the default in-memory stores 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 noop session store
// Assert - request was processed successfully with default in-memory stores
Assert.NotNull(response);
Assert.Equal(SendMessageResponseCase.Message, response.PayloadCase);
Assert.NotNull(response.Message);
@@ -382,10 +382,9 @@ public sealed class AgentClassSkillTests
// Arrange
var skill = new AttributedFullSkill();
// 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());
// 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());
Assert.Contains("convert", await skill.GetContentAsync());
// Act & Assert — discovered members are cached
@@ -503,7 +502,7 @@ public sealed class AgentClassSkillTests
}
[Fact]
public async Task Content_RendersResources_InBodyAsync()
public async Task Content_DoesNotRenderResources_InBodyAsync()
{
// Arrange
var skill = new AttributedResourcePropertiesSkill();
@@ -511,10 +510,8 @@ public sealed class AgentClassSkillTests
// Act
var content = await skill.GetContentAsync();
// 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);
// Assert — resources are no longer rendered in body content
Assert.DoesNotContain("<resources>", content);
}
[Fact]
@@ -122,17 +122,14 @@ public sealed class AgentFileSkillScriptTests
// Assert — content starts with original and appends per-script entries
Assert.StartsWith("Original content", 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);
Assert.Contains("<script_schemas>", content);
Assert.Contains("<schema script=\"build\">", content);
Assert.Contains("<schema script=\"deploy\">", content);
Assert.Contains("</script_schemas>", content);
}
[Fact]
public async Task Content_WithoutResourcesOrScripts_EmitsSelfClosingPeersAsync()
public async Task Content_WithoutScripts_ReturnsOriginalContentAsync()
{
// Arrange
var fileSkill = new AgentFileSkill(
@@ -143,56 +140,8 @@ public sealed class AgentFileSkillScriptTests
// Act
var content = await fileSkill.GetContentAsync();
// 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);
// Assert
Assert.Equal("Original content only", content);
}
[Fact]
@@ -1,241 +0,0 @@
// 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_IncludesResourcesInBodyAsync()
public async Task Content_DoesNotIncludeResourcesInBodyAsync()
{
// Arrange
var skill = new AgentInlineSkill("my-skill", "A valid skill.", "Instructions.");
@@ -158,14 +158,12 @@ public sealed class AgentInlineSkillTests
// Act
var content = await skill.GetContentAsync();
// 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);
// Assert — resources are no longer rendered in the body; they're accessed via GetResourceAsync
Assert.DoesNotContain("<resources>", content);
}
[Fact]
public async Task Content_IncludesDelegateResourcesInBodyAsync()
public async Task Content_DoesNotIncludeDelegateResourcesInBodyAsync()
{
// Arrange
var skill = new AgentInlineSkill("my-skill", "A valid skill.", "Instructions.");
@@ -174,9 +172,8 @@ public sealed class AgentInlineSkillTests
// Act
var content = await skill.GetContentAsync();
// Assert — resources are rendered in the body
Assert.Contains("<available_resources>", content);
Assert.Contains("<resource name=\"dynamic\"/>", content);
// Assert — resources are no longer rendered in the body
Assert.DoesNotContain("<resources>", content);
}
[Fact]
@@ -190,8 +187,8 @@ public sealed class AgentInlineSkillTests
var content = await skill.GetContentAsync();
// Assert
Assert.Contains("<available_scripts>", content);
Assert.Contains("<script name=\"run\"", content);
Assert.Contains("<script_schemas>", content);
Assert.Contains("run", content);
}
[Fact]
@@ -221,9 +218,8 @@ public sealed class AgentInlineSkillTests
var content = await skill.GetContentAsync();
// Assert
Assert.Contains("<available_resources>", content);
Assert.Contains("r1", content);
Assert.Contains("<available_scripts>", content);
Assert.DoesNotContain("<resources>", content);
Assert.Contains("<script_schemas>", content);
Assert.Contains("s1", content);
}
@@ -237,9 +233,8 @@ public sealed class AgentInlineSkillTests
// Act
var content = await skill.GetContentAsync();
// 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 — JSON schema should be present inside <schema> element (no extra wrapper) with preserved quotes
Assert.Contains("<schema script=\"search\">", content);
Assert.Contains("\"query\"", content);
Assert.DoesNotContain("<![CDATA[", content);
}
@@ -422,7 +417,7 @@ public sealed class AgentInlineSkillTests
}
[Fact]
public async Task Content_NoResourcesOrScripts_EmitsSelfClosingTagsAsync()
public async Task Content_NoResourcesOrScripts_DoesNotContainResourcesOrScriptsTagsAsync()
{
// Arrange
var skill = new AgentInlineSkill("my-skill", "A valid skill.", "Instructions.");
@@ -430,9 +425,9 @@ public sealed class AgentInlineSkillTests
// Act
var content = await skill.GetContentAsync();
// Assert — empty self-closing elements are emitted when no resources or scripts exist
Assert.Contains("<available_resources />", content);
Assert.Contains("<available_scripts />", content);
// Assert
Assert.DoesNotContain("<resources>", content);
Assert.DoesNotContain("<script_schemas>", content);
}
[Fact]
@@ -475,8 +470,9 @@ public sealed class AgentInlineSkillTests
// Act
var content = await skill.GetContentAsync();
// Assert — only the script name is emitted; the description is not rendered as an attribute
Assert.Contains("<script name=\"my-script\"", content);
// 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.DoesNotContain("description=\"Runs something.\"", content);
}
@@ -496,7 +492,7 @@ public sealed class AgentInlineSkillTests
}
[Fact]
public async Task Content_ResourceWithDescription_RenderedInBodyWithoutDescriptionAsync()
public async Task Content_ResourceWithDescription_NotRenderedInBodyAsync()
{
// Arrange
var skill = new AgentInlineSkill("my-skill", "A valid skill.", "Instructions.");
@@ -506,11 +502,10 @@ public sealed class AgentInlineSkillTests
// Act
var content = await skill.GetContentAsync();
// 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);
// Assert — resources are no longer rendered in the body
Assert.DoesNotContain("<resources>", content);
Assert.DoesNotContain("with-desc", content);
Assert.DoesNotContain("no-desc", content);
}
[Fact]
@@ -795,9 +795,9 @@ public class OpenTelemetryAgentTests
[Fact]
public async Task AutoWireChatClient_PlainAgentRunOptions_PreservesBaseProperties_Async()
{
// 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.
// 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.
AgentRunOptions? observedOptions = null;
var fakeChatClient = new AutoWireTestChatClient();
var innerChatClientAgent = new ChatClientAgent(fakeChatClient);
@@ -827,27 +827,17 @@ public class OpenTelemetryAgentTests
_ = await agent.RunAsync("hi", options: inputOptions);
// Options flow through unchanged (same instance, no conversion to ChatClientAgentRunOptions).
Assert.NotNull(observedOptions);
Assert.Same(inputOptions, observedOptions);
Assert.Equal(true, observedOptions.AllowBackgroundResponses);
Assert.IsType<ChatClientAgentRunOptions>(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_UserFactoryAddsOwnOTel_CoexistsWithBelowFiccSlot_Async()
public async Task AutoWireChatClient_UserFactoryReturnsInstrumentedClient_DoesNotDoubleWrap_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()
@@ -859,7 +849,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 (above FICC).
// User factory wraps the chat client with OpenTelemetryChatClient itself.
var runOptions = new ChatClientAgentRunOptions
{
ChatClientFactory = cc => cc.AsBuilder().UseOpenTelemetry(sourceName: sourceName).Build(),
@@ -867,10 +857,8 @@ public class OpenTelemetryAgentTests
_ = await agent.RunAsync("hi", options: runOptions);
// 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)));
// Expect 2 activities (invoke_agent + a single chat span). If we double-wrapped, we would see 3.
Assert.Equal(2, activities.Count);
}
[Theory]
@@ -905,8 +893,8 @@ public class OpenTelemetryAgentTests
[Fact]
public async Task AutoWireChatClient_PlainAgentRunOptions_PreservesContinuationToken_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).
// ContinuationToken is the fourth base AgentRunOptions property copied by CopyBaseAgentRunOptions
// and is not exercised by AutoWireChatClient_PlainAgentRunOptions_PreservesBaseProperties_Async.
AgentRunOptions? observedOptions = null;
var fakeChatClient = new AutoWireTestChatClient();
var innerChatClientAgent = new ChatClientAgent(fakeChatClient);
@@ -933,8 +921,8 @@ public class OpenTelemetryAgentTests
_ = await agent.RunAsync("hi", options: inputOptions);
Assert.NotNull(observedOptions);
Assert.Same(inputOptions, observedOptions);
Assert.Same(token, observedOptions.ContinuationToken);
Assert.IsType<ChatClientAgentRunOptions>(observedOptions);
Assert.Same(token, observedOptions!.ContinuationToken);
}
#pragma warning restore MEAI001
@@ -1073,10 +1061,11 @@ public class OpenTelemetryAgentTests
[InlineData(true, true)]
public async Task AutoWireChatClient_EnableSensitiveData_PropagatedToInnerChatClient_Async(bool enableSensitiveData, bool streaming)
{
// 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.
// 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.
var sourceName = Guid.NewGuid().ToString();
var activities = new List<Activity>();
using var tracerProvider = OpenTelemetry.Sdk.CreateTracerProviderBuilder()
@@ -1120,71 +1109,6 @@ 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; }
@@ -1208,40 +1132,5 @@ 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
}
@@ -1,135 +0,0 @@
// 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);
}
}
}
@@ -1,230 +0,0 @@
// 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");
}
}
@@ -1,67 +0,0 @@
// 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
@@ -0,0 +1 @@
../../../.github/skills/pull-requests
-116
View File
@@ -1,116 +0,0 @@
---
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}
```
+1 -1
View File
@@ -98,7 +98,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/*`). 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.
- **`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.
### 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 TYPE_CHECKING, Any, Final
from typing import Final
try:
_version = importlib.metadata.version(__name__)
@@ -264,7 +264,6 @@ from ._workflows._agent_executor import (
)
from ._workflows._agent_utils import resolve_agent_id
from ._workflows._checkpoint import (
CheckpointID,
CheckpointStorage,
FileCheckpointStorage,
InMemoryCheckpointStorage,
@@ -308,6 +307,7 @@ 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,7 +405,6 @@ __all__ = [
"ChatResponse",
"ChatResponseUpdate",
"CheckResult",
"CheckpointID",
"CheckpointStorage",
"ClassSkill",
"CompactionProvider",
@@ -619,20 +618,3 @@ __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,8 +37,7 @@ from pydantic import BaseModel, Field
from .._feature_stage import ExperimentalFeature, experimental
from .._serialization import SerializationMixin
from .._sessions import AgentSession, ContextProvider, SessionContext
from .._tools import tool
from .._types import Content
from .._tools import ApprovalMode, tool
logger = logging.getLogger(__name__)
@@ -1076,72 +1075,15 @@ 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.
@@ -1154,78 +1096,17 @@ 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
@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
)
self.require_delete_approval = require_delete_approval
async def before_run(
self,
@@ -1237,7 +1118,7 @@ class FileAccessProvider(ContextProvider):
) -> None:
"""Inject file-access tools and instructions before the model runs."""
@tool(name=FileAccessProvider.SAVE_FILE_TOOL_NAME, schema=_SaveFileInput, approval_mode="always_require")
@tool(name="file_access_save_file", schema=_SaveFileInput, approval_mode="never_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:
@@ -1251,7 +1132,7 @@ class FileAccessProvider(ContextProvider):
return f"Could not save file '{file_name}': {exc.strerror or exc}"
return f"File '{file_name}' saved."
@tool(name=FileAccessProvider.READ_FILE_TOOL_NAME, schema=_ReadFileInput, approval_mode="always_require")
@tool(name="file_access_read_file", schema=_ReadFileInput, approval_mode="never_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:
@@ -1263,7 +1144,9 @@ 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."
@tool(name=FileAccessProvider.DELETE_FILE_TOOL_NAME, schema=_DeleteFileInput, approval_mode="always_require")
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)
async def file_access_delete_file(file_name: str) -> str:
"""Delete a file by name."""
try:
@@ -1275,7 +1158,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=FileAccessProvider.LIST_FILES_TOOL_NAME, schema=_ListFilesInput, approval_mode="always_require")
@tool(name="file_access_list_files", schema=_ListFilesInput, approval_mode="never_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 ""
@@ -1286,11 +1169,7 @@ class FileAccessProvider(ContextProvider):
except OSError as exc:
return f"Could not list directory '{directory or ''}': {exc.strerror or exc}"
@tool(
name=FileAccessProvider.LIST_SUBDIRECTORIES_TOOL_NAME,
schema=_ListSubdirectoriesInput,
approval_mode="always_require",
)
@tool(name="file_access_list_subdirectories", schema=_ListSubdirectoriesInput, approval_mode="never_require")
async def file_access_list_subdirectories(directory: str | None = None) -> list[str] | str:
"""List the direct child subdirectory names of a directory.
@@ -1307,7 +1186,7 @@ class FileAccessProvider(ContextProvider):
except OSError as exc:
return f"Could not list directory '{directory or ''}': {exc.strerror or exc}"
@tool(name=FileAccessProvider.SEARCH_FILES_TOOL_NAME, schema=_SearchFilesInput, approval_mode="always_require")
@tool(name="file_access_search_files", schema=_SearchFilesInput, approval_mode="never_require")
async def file_access_search_files(
regex_pattern: str,
file_pattern: str | None = None,
@@ -15,7 +15,10 @@ 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\n"
"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"
"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"
@@ -53,12 +56,9 @@ DEFAULT_MODE_DESCRIPTIONS: dict[str, str] = {
"and follow the steps for *Execute mode*."
),
"execute": (
"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"
"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"
"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,19 +24,15 @@ DEFAULT_TODO_SOURCE_ID = "todo"
DEFAULT_TODO_INSTRUCTIONS = (
"## Todo Items\n\n"
"You have access to a todo list for tracking work items.\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"
"While planning, make sure that you break down complex tasks into manageable todo items "
"and add them to the list.\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, 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"
"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"
"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). "
+2 -2
View File
@@ -2898,7 +2898,7 @@ class MCPWebsocketTool(MCPTool):
An async context manager for the WebSocket client transport.
"""
try:
from mcp.client.websocket import websocket_client # pyright: ignore[reportDeprecated]
from mcp.client.websocket import websocket_client
except ModuleNotFoundError as ex:
missing_name = ex.name or "mcp/websocket dependencies"
if missing_name == "mcp" or missing_name.startswith("mcp."):
@@ -2917,4 +2917,4 @@ class MCPWebsocketTool(MCPTool):
}
if self._client_kwargs:
args.update(self._client_kwargs)
return websocket_client(**args) # pyright: ignore[reportDeprecated]
return websocket_client(**args)
@@ -3,7 +3,6 @@
import asyncio
import contextlib
import logging
import warnings
from collections import defaultdict
from collections.abc import AsyncGenerator, Sequence
from typing import Any
@@ -11,7 +10,7 @@ from typing import Any
from ..exceptions import (
WorkflowCheckpointException,
WorkflowConvergenceException,
WorkflowException,
WorkflowRunnerException,
)
from ._checkpoint import CheckpointID, CheckpointStorage, WorkflowCheckpoint
from ._const import EXECUTOR_STATE_KEY
@@ -28,21 +27,6 @@ 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."""
@@ -79,123 +63,99 @@ class Runner:
self._iteration = 0
self._max_iterations = max_iterations
self._state = state
# Checkpointing related attributes
self._resumed_from_checkpoint = False
self._previous_checkpoint_id: CheckpointID | None = None
self._running = False
self._resumed_from_checkpoint = False # Track whether we resumed
@property
def context(self) -> RunnerContext:
"""Get the runner context for message, event, and checkpoint handling."""
"""Get the workflow context."""
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.
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.
"""
"""Reset the iteration count to zero."""
self._iteration = 0
def reset_runtime_state(
self,
*,
iteration: int = 0,
previous_checkpoint_id: CheckpointID | None = None,
resumed_from_checkpoint: bool = False,
) -> None:
"""Reset runner runtime bookkeeping to a known baseline.
Args:
iteration: Iteration value to restore.
previous_checkpoint_id: Checkpoint parent pointer for subsequent saves.
resumed_from_checkpoint: Whether to treat next run as resumed.
"""
self._iteration = iteration
self._previous_checkpoint_id = previous_checkpoint_id
self._resumed_from_checkpoint = resumed_from_checkpoint
async def run_until_convergence(self) -> AsyncGenerator[WorkflowEvent, None]:
"""Run the workflow until no more messages are sent."""
# Emit any events already produced prior to entering loop
if await self._ctx.has_events():
logger.info("Yielding pre-loop events")
for event in await self._ctx.drain_events():
yield event
if self._running:
raise WorkflowRunnerException("Runner is already running.")
# 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 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}")
yield WorkflowEvent.superstep_started(iteration=self._iteration + 1)
# Run iteration concurrently with live event streaming: we poll
# for new events while the iteration coroutine progresses.
iteration_task = asyncio.create_task(self._run_iteration())
try:
while not iteration_task.done():
try:
# Wait briefly for any new event; timeout allows progress checks
event = await asyncio.wait_for(self._ctx.next_event(), timeout=0.05)
yield event
except asyncio.TimeoutError:
# Periodically continue to let iteration advance
continue
except asyncio.CancelledError:
# Propagate cancellation to the iteration task to avoid orphaned work
iteration_task.cancel()
with contextlib.suppress(asyncio.CancelledError):
await iteration_task
raise
# Propagate errors from iteration, but first surface any pending events
try:
await iteration_task
except Exception:
# Make sure failure-related events (like ExecutorFailedEvent) are surfaced
if await self._ctx.has_events():
for event in await self._ctx.drain_events():
yield event
raise
self._iteration += 1
# Drain any straggler events emitted at tail end
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():
logger.info("Yielding pre-loop events")
for event in await self._ctx.drain_events():
yield event
logger.info(f"Completed superstep {self._iteration}")
# 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)
# Commit pending state changes at superstep boundary
self._state.commit()
while self._iteration < self._max_iterations:
logger.info(f"Starting superstep {self._iteration + 1}")
yield WorkflowEvent.superstep_started(iteration=self._iteration + 1)
# Create checkpoint after each superstep iteration
await self.create_checkpoint_if_enabled()
# Run iteration concurrently with live event streaming: we poll
# for new events while the iteration coroutine progresses.
iteration_task = asyncio.create_task(self._run_iteration())
try:
while not iteration_task.done():
try:
# Wait briefly for any new event; timeout allows progress checks
event = await asyncio.wait_for(self._ctx.next_event(), timeout=0.05)
yield event
except asyncio.TimeoutError:
# Periodically continue to let iteration advance
continue
except asyncio.CancelledError:
# Propagate cancellation to the iteration task to avoid orphaned work
iteration_task.cancel()
with contextlib.suppress(asyncio.CancelledError):
await iteration_task
raise
yield WorkflowEvent.superstep_completed(iteration=self._iteration)
# Propagate errors from iteration, but first surface any pending events
try:
await iteration_task
except Exception:
# Make sure failure-related events (like ExecutorFailedEvent) are surfaced
if await self._ctx.has_events():
for event in await self._ctx.drain_events():
yield event
raise
self._iteration += 1
# Check for convergence: no more messages to process
if not await self._ctx.has_messages():
break
# Drain any straggler events emitted at tail end
if await self._ctx.has_events():
for event in await self._ctx.drain_events():
yield event
logger.info(f"Workflow completed after {self._iteration} supersteps")
self._resumed_from_checkpoint = False # Reset resume flag for next run
logger.info(f"Completed superstep {self._iteration}")
if self._iteration >= self._max_iterations and await self._ctx.has_messages():
raise WorkflowConvergenceException(f"Runner did not converge after {self._max_iterations} iterations.")
# Commit pending state changes at superstep boundary
self._state.commit()
# Create checkpoint after each superstep iteration
previous_checkpoint_id = await self._create_checkpoint_if_enabled(previous_checkpoint_id)
yield WorkflowEvent.superstep_completed(iteration=self._iteration)
# Check for convergence: no more messages to process
if not await self._ctx.has_messages():
break
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
async def _run_iteration(self) -> None:
"""Run a single iteration of the workflow.
@@ -249,121 +209,40 @@ class Runner:
]
await asyncio.gather(*tasks)
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 capture_checkpoint_object(self, *, metadata: dict[str, Any] | None = None) -> WorkflowCheckpoint:
"""Capture the current runner state as an in-memory checkpoint object.
Persists executor snapshots into committed state and builds a
``WorkflowCheckpoint`` from the current committed state. The checkpoint is
not written to any storage backend; the caller owns its lifetime (for
example, the workflow's captured initial checkpoint used by reset).
This is only valid when the runner is quiescent: it rejects capture when
in-flight executor messages or pending request_info events are present,
since those represent mid-run state that would not form a clean baseline.
Args:
metadata: Optional metadata to attach to the checkpoint.
Returns:
A ``WorkflowCheckpoint`` snapshot of the current runner state.
Raises:
WorkflowException: If in-flight messages or pending requests are present.
"""
if await self._ctx.has_messages():
raise WorkflowException("Cannot capture checkpoint while in-flight messages are present.")
pending_requests = await self._ctx.get_pending_request_info_events()
if pending_requests:
raise WorkflowException("Cannot capture checkpoint while pending requests are present.")
await self._prepare_checkpoint_state()
return WorkflowCheckpoint(
workflow_name=self._workflow_name,
graph_signature_hash=self._graph_signature_hash,
previous_checkpoint_id=None,
messages={},
state=self._state.export_state(),
pending_request_info_events={},
iteration_count=0,
metadata=metadata or {},
)
async def create_checkpoint_if_enabled(self) -> None:
async def _create_checkpoint_if_enabled(self, previous_checkpoint_id: CheckpointID | None) -> CheckpointID | None:
"""Create a checkpoint if checkpointing is enabled and attach a label and metadata."""
if not self._ctx.has_checkpointing():
return
return None
try:
# Save executor states into committed state before creating the checkpoint.
await self._prepare_checkpoint_state()
# 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()
checkpoint_id = await self._ctx.create_checkpoint(
self._workflow_name,
self._graph_signature_hash,
self._state,
self._previous_checkpoint_id,
previous_checkpoint_id,
self._iteration,
)
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
logger.info(f"Created checkpoint: {checkpoint_id}")
return checkpoint_id
except Exception as e:
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_object(self, checkpoint: WorkflowCheckpoint) -> None:
"""Restore runner state from an in-memory checkpoint object.
Unlike :meth:`restore_from_checkpoint`, this does not load from storage or
validate the graph signature; it applies a checkpoint that the caller already
holds (for example, the workflow's captured initial checkpoint used by reset).
This clears any runtime checkpoint storage override and resets the context for a
fresh run, then restores shared state, executor snapshots, and runtime bookkeeping
from the checkpoint.
Args:
checkpoint: The checkpoint whose state should be restored.
"""
self._ctx.clear_runtime_checkpoint_storage()
self._ctx.reset_for_new_run()
self._state.clear()
self._state.import_state(checkpoint.state)
await self._restore_executor_states()
self.reset_runtime_state(
iteration=checkpoint.iteration_count,
previous_checkpoint_id=checkpoint.previous_checkpoint_id,
resumed_from_checkpoint=False,
)
logger.warning(f"Failed to create checkpoint: {e}")
return None
async def restore_from_checkpoint(
self,
checkpoint_id: CheckpointID,
checkpoint_storage: CheckpointStorage | None = None,
) -> None:
"""Restore the runner from a checkpoint.
"""Restore workflow state from a checkpoint.
Args:
checkpoint_id: The ID of the checkpoint to restore from
@@ -411,7 +290,7 @@ class Runner:
# Apply the checkpoint to the context
await self._ctx.apply_checkpoint(checkpoint)
# Mark the runner as resumed
self._mark_resumed(checkpoint)
self._mark_resumed(checkpoint.iteration_count)
logger.info(f"Successfully restored workflow from checkpoint: {checkpoint_id}")
except WorkflowCheckpointException:
@@ -477,14 +356,13 @@ class Runner:
return parsed
def _mark_resumed(self, checkpoint: WorkflowCheckpoint) -> None:
def _mark_resumed(self, iteration: int) -> 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 = checkpoint.iteration_count
self._previous_checkpoint_id = checkpoint.checkpoint_id
self._iteration = iteration
async def _set_executor_state(self, executor_id: str, state: dict[str, Any]) -> None:
"""Store executor state in state under a reserved key.
@@ -403,14 +403,12 @@ class InProcRunnerContext:
def reset_for_new_run(self) -> None:
"""Reset the context for a new workflow run.
Clears messages, the pending event queue, the pending request_info
correlation map, and the streaming flag. Runtime checkpoint storage is
NOT cleared here as it's managed at the workflow level.
This clears messages, events, and resets streaming flag.
Runtime checkpoint storage is NOT cleared here as it's managed at the workflow level.
"""
self._messages.clear()
# Clear any pending events (best-effort) by recreating the queue
self._event_queue = asyncio.Queue()
self._pending_request_info_events.clear()
self._streaming = False # Reset streaming flag
async def apply_checkpoint(self, checkpoint: WorkflowCheckpoint) -> None:
@@ -11,16 +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, WorkflowCheckpoint
from ._checkpoint import CheckpointStorage
from ._const import DEFAULT_MAX_ITERATIONS, GLOBAL_KWARGS_KEY, WORKFLOW_RUN_KWARGS_KEY
from ._edge import (
EdgeGroup,
@@ -348,33 +346,25 @@ 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,
State(),
self._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
# In-memory initial checkpoint captured from the just-built workflow state.
# This is internal-only and used by ``reset()``.
self._initial_checkpoint: WorkflowCheckpoint | None = None
@property
def status(self) -> WorkflowRunState:
"""Return the current run-level status of this workflow instance.
@@ -386,6 +376,16 @@ 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] = {
@@ -478,44 +478,6 @@ class Workflow(DictConvertible):
"""Get the list of executors in the workflow."""
return list(self.executors.values())
async def _ensure_initial_checkpoint(self) -> None:
"""Capture the in-memory initial checkpoint once for this workflow instance."""
if self._initial_checkpoint is not None:
return
self._initial_checkpoint = await self._runner.capture_checkpoint_object(
metadata={"kind": "initial_in_memory"},
)
async def reset(self) -> None:
"""Reset the workflow instance to its captured initial checkpoint state.
The initial checkpoint is captured in memory once per workflow instance and
is not persisted to external checkpoint storage.
Raises:
WorkflowException: If called while a workflow run is active.
"""
if self._is_run_active():
raise WorkflowException(
"Cannot reset workflow while a run is active. "
"Reset is only allowed between runs when the workflow is idle."
)
# Capture the baseline if it doesn't exist yet. This is idempotent: on a
# normal reset after one or more runs it's a no-op (the snapshot was taken
# before the first run); when reset is the first operation it captures the
# pristine just-built state so the workflow stays runnable.
await self._ensure_initial_checkpoint()
if self._initial_checkpoint is None:
raise WorkflowException("Workflow initial checkpoint is unavailable.")
# Restore runner state, executor snapshots, and runtime bookkeeping from the
# in-memory initial checkpoint.
await self._runner.restore_from_checkpoint_object(self._initial_checkpoint)
self._status = WorkflowRunState.IDLE
async def _run_workflow_with_tracing(
self,
initial_executor_fn: Callable[[], Awaitable[None]] | None = None,
@@ -573,12 +535,13 @@ class Workflow(DictConvertible):
yield in_progress # noqa: RUF070
# Per-run reset for fresh-message runs only. We deliberately
# 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.
# 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.
if not is_continuation:
self._runner.reset_iteration_count()
@@ -601,13 +564,14 @@ class Workflow(DictConvertible):
combined_kwargs["client_kwargs"] = self._resolve_invocation_kwargs(
client_kwargs, "client_kwargs"
)
self._runner.state.set(WORKFLOW_RUN_KWARGS_KEY, combined_kwargs)
self._state.set(WORKFLOW_RUN_KWARGS_KEY, combined_kwargs)
elif not is_continuation:
self._runner.state.set(WORKFLOW_RUN_KWARGS_KEY, {})
self._runner.state.commit() # Commit immediately so kwargs are available
self._state.set(WORKFLOW_RUN_KWARGS_KEY, {})
self._state.commit() # Commit immediately so kwargs are available
# Explicitly set streaming mode per run
self._runner.context.set_streaming(streaming)
# 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)
# Execute initial setup if provided
if initial_executor_fn:
@@ -701,7 +665,7 @@ class Workflow(DictConvertible):
await executor.execute(
message,
[self.__class__.__name__],
self._runner.state,
self._state,
self._runner.context,
trace_contexts=None,
source_span_ids=None,
@@ -781,22 +745,9 @@ class Workflow(DictConvertible):
Raises:
ValueError: If parameter combination is invalid.
"""
# Validate parameters first so misuse fails before we touch any run state.
# Validate parameters and set running flag eagerly (before any async work)
self._validate_run_params(message, responses, checkpoint_id)
# 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."
)
self._ensure_not_running()
response_stream = ResponseStream[WorkflowEvent, WorkflowRunResult](
self._run_core(
@@ -809,8 +760,10 @@ 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
@@ -836,69 +789,51 @@ class Workflow(DictConvertible):
if checkpoint_storage is not None:
self._runner.context.set_runtime_checkpoint_storage(checkpoint_storage)
# 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
# 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)
await self._ensure_initial_checkpoint()
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
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
finally:
# Clear the active-run weakref so a subsequent ``run()`` is allowed,
# but only if the slot still holds *our* weakref. 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`` now points at the successor; clearing
# it would silently break the successor's concurrency guard.
if self._active_run is my_active_run:
self._active_run = None
if checkpoint_storage is not None:
self._runner.context.clear_runtime_checkpoint_storage()
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()
@staticmethod
def _finalize_events(
@@ -1000,7 +935,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.")
@@ -1020,7 +955,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()
])
@@ -1216,12 +1151,3 @@ 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
@@ -517,10 +517,6 @@ class WorkflowExecutor(Executor):
self._execution_contexts = execution_contexts
self._request_to_execution = request_to_execution
# Reset the sub workflow to its initial state. This must be done before pumping
# the request info events back into the sub workflow.
await self.workflow.reset()
# Add the `request_info_event`s back to the sub workflow.
# This is only a temporary solution to rehydrate the sub workflow with the requests.
# The proper way would be to rehydrate the workflow from a checkpoint on a Workflow
@@ -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_all_tools_require_approval(
async def test_file_access_provider_delete_approval_defaults_to_always_require(
chat_client_base: SupportsChatGetResponse,
) -> None:
"""Every file-access tool should require host approval."""
"""By default ``file_access_delete_file`` should require host approval."""
session = AgentSession(session_id="session-1")
provider = FileAccessProvider(store=InMemoryAgentFileStore())
agent = Agent(client=chat_client_base, context_providers=[provider])
@@ -512,72 +512,36 @@ async def test_file_access_provider_all_tools_require_approval(
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 (
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,
"file_access_save_file",
"file_access_read_file",
"file_access_list_files",
"file_access_list_subdirectories",
"file_access_search_files",
):
assert _tool_by_name(tools, name).approval_mode == "always_require"
assert _tool_by_name(tools, name).approval_mode == "never_require"
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
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])
_, options = await agent._prepare_session_and_messages( # pyright: ignore[reportPrivateUsage]
session=session,
input_messages=[Message(role="user", contents=["work with files"])],
)
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
tools = options["tools"]
assert isinstance(tools, list)
delete_file = _tool_by_name(tools, "file_access_delete_file")
assert delete_file.approval_mode == "never_require"
async def test_file_access_provider_tools_round_trip_files(
@@ -336,97 +336,6 @@ 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:
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()
@@ -17,6 +17,7 @@ from agent_framework import (
WorkflowContext,
WorkflowConvergenceException,
WorkflowEvent,
WorkflowRunnerException,
WorkflowRunState,
handler,
)
@@ -304,62 +305,40 @@ async def test_fanout_edge_runner_delivers_to_multiple_targets_concurrently() ->
assert probe_target.call_count == 1
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.
"""
async def test_runner_already_running():
"""Test that running the runner while it is already running raises an error."""
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)
await executor_a.execute(MockMessage(data=0), ["START"], state, ctx)
runner = Runner(edges, executors, state, ctx, "test_name", graph_signature_hash="test_hash")
with pytest.raises(WorkflowConvergenceException):
async for _ in runner.run_until_convergence():
pass
await executor_a.execute(
MockMessage(data=0),
["START"], # source_executor_ids
state, # state
ctx, # runner_context
)
# 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"
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())
async def test_runner_emits_runner_completion_for_agent_response_without_targets():
@@ -883,13 +862,7 @@ async def test_runner_checkpoint_with_resumed_flag():
state = State()
runner = Runner(edges, executors, state, ctx, "test_name", graph_signature_hash="test_hash")
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]
runner._mark_resumed(5) # pyright: ignore[reportPrivateUsage]
# Add a message to trigger the checkpoint creation path
await ctx.send_message(WorkflowMessage(data=MockMessage(data=8), source_id="START"))
@@ -909,86 +882,6 @@ 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."""
@@ -1,72 +0,0 @@
# Copyright (c) Microsoft. All rights reserved.
"""Tests for `InProcRunnerContext`."""
import pytest
from agent_framework import (
InProcRunnerContext,
WorkflowEvent,
WorkflowMessage,
)
def _make_request_info_event(request_id: str, source_executor_id: str = "executor") -> WorkflowEvent[str]:
return WorkflowEvent.request_info(
request_id=request_id,
source_executor_id=source_executor_id,
request_data="please respond",
response_type=str,
)
class TestInProcRunnerContextResetForNewRun:
"""Verify `reset_for_new_run` clears per-run state, including pending request_info events."""
async def test_reset_clears_pending_request_info_events(self) -> None:
ctx = InProcRunnerContext()
await ctx.add_request_info_event(_make_request_info_event("req-1"))
await ctx.add_request_info_event(_make_request_info_event("req-2"))
assert set((await ctx.get_pending_request_info_events()).keys()) == {"req-1", "req-2"}
ctx.reset_for_new_run()
assert await ctx.get_pending_request_info_events() == {}
async def test_reset_clears_pending_request_info_events_when_already_empty(self) -> None:
ctx = InProcRunnerContext()
assert await ctx.get_pending_request_info_events() == {}
ctx.reset_for_new_run()
assert await ctx.get_pending_request_info_events() == {}
async def test_reset_after_pending_event_blocks_response_correlation(self) -> None:
"""After `reset_for_new_run`, prior request ids must no longer correlate to a response."""
ctx = InProcRunnerContext()
await ctx.add_request_info_event(_make_request_info_event("req-1"))
ctx.reset_for_new_run()
with pytest.raises(ValueError, match="No pending request found for request_id: req-1"):
await ctx.send_request_info_response("req-1", "answer")
async def test_reset_clears_messages_events_and_streaming_flag(self) -> None:
"""Sanity-check the other state `reset_for_new_run` is documented to clear."""
ctx = InProcRunnerContext()
await ctx.send_message(WorkflowMessage(data="hello", source_id="executor"))
await ctx.add_event(WorkflowEvent("status", data="running"))
ctx.set_streaming(True)
assert await ctx.has_messages() is True
assert await ctx.has_events() is True
assert ctx.is_streaming() is True
ctx.reset_for_new_run()
assert await ctx.has_messages() is False
assert await ctx.has_events() is False
assert ctx.is_streaming() is False
@@ -1,7 +1,6 @@
# Copyright (c) Microsoft. All rights reserved.
import asyncio
import gc
import tempfile
from collections.abc import AsyncIterable, Awaitable, Sequence
from dataclasses import dataclass, field
@@ -27,7 +26,6 @@ from agent_framework import (
WorkflowContext,
WorkflowConvergenceException,
WorkflowEvent,
WorkflowException,
WorkflowMessage,
WorkflowRunState,
handler,
@@ -761,7 +759,8 @@ async def test_workflow_concurrent_execution_prevention():
# Try to start a second concurrent execution - this should fail
with pytest.raises(
WorkflowException, match="Workflow is already running; concurrent runs are not allowed on the same instance."
RuntimeError,
match="Workflow is already running. Concurrent executions are not allowed.",
):
await workflow.run(NumberMessage(data=0))
@@ -796,7 +795,8 @@ async def test_workflow_concurrent_execution_prevention_streaming():
# Try to start a second concurrent execution - this should fail
with pytest.raises(
WorkflowException, match="Workflow is already running; concurrent runs are not allowed on the same instance."
RuntimeError,
match="Workflow is already running. Concurrent executions are not allowed.",
):
await workflow.run(NumberMessage(data=0))
@@ -828,12 +828,14 @@ async def test_workflow_concurrent_execution_prevention_mixed_methods():
# Try different execution methods - all should fail
with pytest.raises(
WorkflowException, match="Workflow is already running; concurrent runs are not allowed on the same instance."
RuntimeError,
match="Workflow is already running. Concurrent executions are not allowed.",
):
await workflow.run(NumberMessage(data=0))
with pytest.raises(
WorkflowException, match="Workflow is already running; concurrent runs are not allowed on the same instance."
RuntimeError,
match="Workflow is already running. Concurrent executions are not allowed.",
):
async for _ in workflow.run(NumberMessage(data=0), stream=True):
break
@@ -846,154 +848,6 @@ 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)
class _StreamingTestAgent(BaseAgent):
"""Test agent that supports both streaming and non-streaming modes."""
@@ -1415,85 +1269,3 @@ async def test_output_executors_filtering_with_run_responses_streaming() -> None
# endregion
# region Workflow.reset
class CounterStateExecutor(Executor):
"""Executor with local mutable state used to verify checkpoint-based reset."""
def __init__(self, id: str) -> None:
super().__init__(id=id)
self.counter = 0
@handler
async def handle(self, message: str, ctx: WorkflowContext[str, int]) -> None:
self.counter += 1
await ctx.yield_output(self.counter)
async def on_checkpoint_save(self) -> dict[str, Any]:
return {"counter": self.counter}
async def on_checkpoint_restore(self, state: dict[str, Any]) -> None:
self.counter = int(state.get("counter", 0))
class TestWorkflowReset:
"""Tests for :meth:`Workflow.reset`."""
async def test_reset_restores_initial_shared_state(self) -> None:
"""Reset clears accumulated workflow state back to the initial baseline."""
executor = StateTrackingExecutor(id="state_executor")
workflow = WorkflowBuilder(start_executor=executor).add_edge(executor, executor).build()
result1 = await workflow.run(StateTrackingMessage(data="message1", run_id="run1"))
assert result1.get_outputs()[0] == ["run1:message1"]
result2 = await workflow.run(StateTrackingMessage(data="message2", run_id="run2"))
assert result2.get_outputs()[0] == ["run1:message1", "run2:message2"]
await workflow.reset()
result3 = await workflow.run(StateTrackingMessage(data="message3", run_id="run3"))
assert result3.get_outputs()[0] == ["run3:message3"]
async def test_reset_restores_executor_checkpoint_state(self) -> None:
"""Reset restores per-executor local state captured in the initial checkpoint."""
executor = CounterStateExecutor(id="counter_executor")
workflow = WorkflowBuilder(start_executor=executor).add_edge(executor, executor).build()
result1 = await workflow.run("one")
assert result1.get_outputs() == [1]
result2 = await workflow.run("two")
assert result2.get_outputs() == [2]
await workflow.reset()
result3 = await workflow.run("three")
assert result3.get_outputs() == [1]
async def test_reset_before_first_run_is_allowed(self, simple_executor: Executor) -> None:
"""Reset can be called before the first run and leaves workflow runnable."""
workflow = WorkflowBuilder(start_executor=simple_executor).add_edge(simple_executor, simple_executor).build()
await workflow.reset()
result = await workflow.run("hello")
assert result.get_final_state() == WorkflowRunState.IDLE
async def test_reset_raises_while_run_active(self, simple_executor: Executor) -> None:
"""Reset must reject while a workflow run is active."""
workflow = WorkflowBuilder(start_executor=simple_executor).add_edge(simple_executor, simple_executor).build()
active_stream = workflow.run(WorkflowMessage(data="hi", source_id="test"), stream=True)
try:
with pytest.raises(WorkflowException, match="Cannot reset workflow while a run is active"):
await workflow.reset()
finally:
async for _ in active_stream:
pass
# endregion
@@ -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._runner.state.get(DECLARATIVE_STATE_KEY) or {}
return workflow._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._runner.state.get(DECLARATIVE_STATE_KEY)
decl = workflow._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._runner.state.get(DECLARATIVE_STATE_KEY)
decl = workflow._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._runner.state.get(DECLARATIVE_STATE_KEY)
decl = workflow._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._runner.state.get(DECLARATIVE_STATE_KEY)
decl = workflow._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._runner.state.get(DECLARATIVE_STATE_KEY)
decl = workflow._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._runner.state.get(DECLARATIVE_STATE_KEY)
decl = workflow._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._runner.state.get(DECLARATIVE_STATE_KEY)
decl = workflow._state.get(DECLARATIVE_STATE_KEY)
assert decl["Local"]["H"] == {"X-Trace": "abc"}
@@ -559,7 +559,7 @@ class TestConversationAppend:
)
)
await workflow.run({})
decl = workflow._runner.state.get(DECLARATIVE_STATE_KEY)
decl = workflow._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._runner.state.get(DECLARATIVE_STATE_KEY)
decl = workflow._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._runner.state.get(DECLARATIVE_STATE_KEY)
decl = workflow._state.get(DECLARATIVE_STATE_KEY)
# No conversation entry should have been created either.
assert "conv-test-1" not in decl["System"]["conversations"]
@@ -73,8 +73,8 @@ 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._runner.state.get(DECLARATIVE_STATE_KEY) or {}
local = decl.get("Local") or {}
decl: dict[str, Any] = workflow._state.get(DECLARATIVE_STATE_KEY) or {}
local: dict[str, Any] = decl.get("Local") or {}
assert local.get("RepoOwner") == "dotnet"
repo_info = local.get("RepoInfo")
@@ -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._runner.state.get(DECLARATIVE_STATE_KEY)
decl = workflow._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._runner.state.get(DECLARATIVE_STATE_KEY)
decl = workflow._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._runner.state.get(DECLARATIVE_STATE_KEY)
decl = workflow._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._runner.state.get(DECLARATIVE_STATE_KEY)
decl = workflow._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._runner.state.get(DECLARATIVE_STATE_KEY)
decl = workflow._state.get(DECLARATIVE_STATE_KEY)
assert decl["Local"]["Result"] == ["ok"]
@@ -306,7 +306,7 @@ class TestConversation:
)
)
await workflow.run({})
decl = workflow._runner.state.get(DECLARATIVE_STATE_KEY)
decl = workflow._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._runner.state.get(DECLARATIVE_STATE_KEY)
decl = workflow._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._runner.state.get(DECLARATIVE_STATE_KEY)
decl = workflow._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._runner.state.get(DECLARATIVE_STATE_KEY)
decl = workflow._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._runner.state.get(DECLARATIVE_STATE_KEY)
decl = workflow._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._runner.state.get(DECLARATIVE_STATE_KEY)
state_data = workflow._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._runner.state.set(DECLARATIVE_STATE_KEY, state_data)
workflow._runner.state.commit()
workflow._state.set(DECLARATIVE_STATE_KEY, state_data)
workflow._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._runner.state.get(DECLARATIVE_STATE_KEY)
post_state = workflow._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])
super().__init__([entity_task]) # type: ignore
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)
entity_registered: str = self._worker.add_entity(entity_class) # pyright: ignore[reportUnknownMemberType]
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)
self._worker.add_activity(executor_activity) # type: ignore[arg-type]
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,
when_any, # pyright: ignore[reportUnknownVariableType]
)
from .._executors import OrchestrationAgentExecutor
@@ -386,6 +386,7 @@ class ResponsesHostServer(ResponsesAgentServerHost):
)
self._is_workflow_agent = False
self._checkpoint_storage_path = None
if isinstance(agent, WorkflowAgent):
if agent.workflow._runner_context.has_checkpointing(): # pyright: ignore[reportPrivateUsage]
raise RuntimeError(
@@ -579,6 +580,8 @@ class ResponsesHostServer(ResponsesAgentServerHost):
# The following should never happen due to the checks above.
# This is for type safety and defensive programming.
if self._checkpoint_storage_path is None:
raise RuntimeError("Checkpoint storage path is not configured for workflow agent.")
if not isinstance(self._agent, WorkflowAgent):
raise RuntimeError("Agent is not a workflow agent.")
@@ -596,27 +599,43 @@ class ResponsesHostServer(ResponsesAgentServerHost):
# the only place that state lives is the workflow checkpoint, so
# on every turn we restore the latest checkpoint and feed the new
# input back into the start executor as a continuation rather than
# a fresh run. If no conversation_id or previous_response_id is
# supplied (or no checkpoint exists for that context), reset the
# workflow to its in-memory initial baseline to avoid context bleed
# between requests.
# a fresh run.
latest_checkpoint_id: str | None = None
restore_storage: FileCheckpointStorage | None = None
if context_id is not None:
context_storage = _checkpoint_storage_for_context(self._checkpoint_storage_path, context_id)
latest_checkpoint = await context_storage.get_latest(workflow_name=self._agent.workflow.name)
restore_storage = _checkpoint_storage_for_context(self._checkpoint_storage_path, context_id)
latest_checkpoint = await restore_storage.get_latest(workflow_name=self._agent.workflow.name)
if latest_checkpoint is not None:
latest_checkpoint_id = latest_checkpoint.checkpoint_id
restore_storage = context_storage
# Restore the workflow to the latest checkpoint and run it with the
# new input. Events (including request info events) will not be emitted
# during restoration (in streaming) or after restoration (in non-streaming)
# since we assume the client had already seen those events and we don't want
# to emit duplicates.
if latest_checkpoint_id is None or restore_storage is None:
await self._agent.workflow.reset()
else:
# Storage that will receive checkpoints written during this turn.
# When the caller chains with previous_response_id, the next turn
# will reference the current response_id as its previous_response_id,
# so new checkpoints must land under the current response_id (or the
# conversation_id when set). When conversation_id is set, this
# matches restore_storage; when only previous_response_id was
# supplied, restore_storage points at the *prior* response's
# directory and write_storage points at the *current* response's.
write_context_id = context.conversation_id or context.response_id
write_storage = _checkpoint_storage_for_context(self._checkpoint_storage_path, write_context_id)
# Multi-turn pattern: when we have a prior checkpoint, restore it
# first (drive the workflow back to idle with prior state intact),
# then make a separate call that delivers the new user input. This
# depends on Workflow.run preserving shared state across calls. The
# restore-only call may yield events from any pending in-flight
# work in the checkpoint; we consume those internally here so they
# don't surface to the response stream as duplicates.
#
# If the restored checkpoint had pending request_info events, the
# restore-only call replays them through
# ``WorkflowAgent._convert_workflow_event_to_agent_response_updates``
# and populates ``self._agent.pending_requests``. That is the correct
# state: those requests are genuinely outstanding, and the next
# ``run(input_messages, ...)`` call may contain ``function_call_output``
# items (carried as FunctionResult/FunctionApprovalResponse content)
# that fulfill them via :meth:`WorkflowAgent._process_pending_requests`.
if latest_checkpoint_id is not None:
if is_streaming_request:
async for _ in self._agent.run(
stream=True,
@@ -631,17 +650,6 @@ class ResponsesHostServer(ResponsesAgentServerHost):
checkpoint_storage=restore_storage,
)
# Storage that will receive checkpoints written during this turn.
# When the caller chains with previous_response_id, the next turn
# will reference the current response_id as its previous_response_id,
# so new checkpoints must land under the current response_id (or the
# conversation_id when set). When conversation_id is set, this
# matches restore_storage; when only previous_response_id was
# supplied, restore_storage points at the *prior* response's
# directory and write_storage points at the *current* response's.
write_context_id = context.conversation_id or context.response_id
write_storage = _checkpoint_storage_for_context(self._checkpoint_storage_path, write_context_id)
if not is_streaming_request:
# Run the agent in non-streaming mode with the new user input.
response = await self._agent.run(
@@ -27,7 +27,6 @@ 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]
@@ -3062,7 +3062,6 @@ class TestCheckpointContextPathValidation:
agent.workflow = MagicMock()
agent.workflow.name = "wf"
agent.workflow._runner_context.has_checkpointing = MagicMock(return_value=False)
agent.workflow.reset = AsyncMock()
agent.run = AsyncMock(
side_effect=[
AgentResponse(messages=[]),
@@ -3093,136 +3092,6 @@ class TestCheckpointContextPathValidation:
assert new_turn_messages[0].text == "next turn"
assert new_turn_call.kwargs["checkpoint_storage"].storage_path == (root / response_id).resolve()
async def test_handle_inner_workflow_resets_when_no_context_id(self, tmp_path: Any) -> None:
"""When no context id is supplied, the workflow resets to its initial in-memory state."""
from agent_framework import WorkflowAgent
from azure.ai.agentserver.responses import ResponseContext
from azure.ai.agentserver.responses.models import CreateResponse, ItemMessage
response_id = "resp_current"
root = tmp_path / "root"
root.mkdir()
agent = MagicMock(spec=WorkflowAgent)
agent.id = "wf-agent"
agent.name = "wf"
agent.description = ""
agent.context_providers = []
agent.workflow = MagicMock()
agent.workflow.name = "wf"
agent.workflow._runner_context.has_checkpointing = MagicMock(return_value=False)
agent.workflow.reset = AsyncMock()
agent.run = AsyncMock(
return_value=AgentResponse(messages=[Message(role="assistant", contents=[Content.from_text("ok")])])
)
server = ResponsesHostServer(agent, store=InMemoryResponseProvider())
server._checkpoint_storage_path = str(root) # pyright: ignore[reportPrivateUsage]
# No previous_response_id and no conversation_id.
request = CreateResponse(model="m", input="hi")
context = ResponseContext(response_id=response_id, mode_flags=MagicMock())
input_item = ItemMessage({"type": "message", "role": "user", "content": "fresh turn"})
with patch.object(ResponseContext, "get_input_items", new=AsyncMock(return_value=[input_item])):
async for _ in server._handle_inner_workflow(request, context): # pyright: ignore[reportPrivateUsage]
pass
# No checkpoint restore is attempted; workflow resets in memory.
assert agent.workflow.reset.await_count == 1
assert agent.run.call_count == 1
# The single run() call delivers the new input; checkpoints land under response_id
# (the write-sink directory keyed by the current response id).
new_turn_call = agent.run.call_args_list[0]
new_turn_messages = new_turn_call.args[0]
assert len(new_turn_messages) == 1
assert new_turn_messages[0].text == "fresh turn"
assert new_turn_call.kwargs["checkpoint_storage"].storage_path == (root / response_id).resolve()
async def test_handle_inner_workflow_resets_each_request_without_context_id(self, tmp_path: Any) -> None:
"""Requests without context ids reset workflow state per request."""
from agent_framework import WorkflowAgent
from azure.ai.agentserver.responses import ResponseContext
from azure.ai.agentserver.responses.models import CreateResponse, ItemMessage
root = tmp_path / "root"
root.mkdir()
agent = MagicMock(spec=WorkflowAgent)
agent.id = "wf-agent"
agent.name = "wf"
agent.description = ""
agent.context_providers = []
agent.workflow = MagicMock()
agent.workflow.name = "wf"
agent.workflow._runner_context.has_checkpointing = MagicMock(return_value=False)
agent.workflow.reset = AsyncMock()
# Two run() calls total: one new turn per request.
agent.run = AsyncMock(return_value=AgentResponse(messages=[]))
server = ResponsesHostServer(agent, store=InMemoryResponseProvider())
server._checkpoint_storage_path = str(root) # pyright: ignore[reportPrivateUsage]
request1 = CreateResponse(model="m", input="hi")
context1 = ResponseContext(response_id="resp_first", mode_flags=MagicMock())
request2 = CreateResponse(model="m", input="hi again")
context2 = ResponseContext(response_id="resp_second", mode_flags=MagicMock())
input_item = ItemMessage({"type": "message", "role": "user", "content": "turn"})
with patch.object(ResponseContext, "get_input_items", new=AsyncMock(return_value=[input_item])):
async for _ in server._handle_inner_workflow(request1, context1): # pyright: ignore[reportPrivateUsage]
pass
async for _ in server._handle_inner_workflow(request2, context2): # pyright: ignore[reportPrivateUsage]
pass
assert agent.workflow.reset.await_count == 2
assert agent.run.call_count == 2
async def test_handle_inner_workflow_resets_when_context_dir_is_empty(self, tmp_path: Any) -> None:
"""When previous_response_id has no checkpoint, workflow resets instead of restoring."""
from agent_framework import WorkflowAgent
from azure.ai.agentserver.responses import ResponseContext
from azure.ai.agentserver.responses.models import CreateResponse, ItemMessage
previous_response_id = "resp_previous"
response_id = "resp_current"
root = tmp_path / "root"
root.mkdir()
# The per-context storage exists but contains no checkpoints.
(root / previous_response_id).mkdir()
agent = MagicMock(spec=WorkflowAgent)
agent.id = "wf-agent"
agent.name = "wf"
agent.description = ""
agent.context_providers = []
agent.workflow = MagicMock()
agent.workflow.name = "wf"
agent.workflow._runner_context.has_checkpointing = MagicMock(return_value=False)
agent.workflow.reset = AsyncMock()
agent.run = AsyncMock(
return_value=AgentResponse(messages=[Message(role="assistant", contents=[Content.from_text("ok")])])
)
server = ResponsesHostServer(agent, store=InMemoryResponseProvider())
server._checkpoint_storage_path = str(root) # pyright: ignore[reportPrivateUsage]
request = CreateResponse(model="m", input="hi", previous_response_id=previous_response_id)
context = ResponseContext(
response_id=response_id, previous_response_id=previous_response_id, mode_flags=MagicMock()
)
input_item = ItemMessage({"type": "message", "role": "user", "content": "next turn"})
with patch.object(ResponseContext, "get_input_items", new=AsyncMock(return_value=[input_item])):
async for _ in server._handle_inner_workflow(request, context): # pyright: ignore[reportPrivateUsage]
pass
assert agent.workflow.reset.await_count == 1
assert agent.run.call_count == 1
# The new turn writes checkpoints under the current response id.
new_turn_call = agent.run.call_args_list[0]
assert new_turn_call.kwargs["checkpoint_storage"].storage_path == (root / response_id).resolve()
@pytest.mark.parametrize(
"bad_id",
[
@@ -3316,8 +3185,6 @@ class TestCheckpointContextPathValidation:
agent.workflow = MagicMock()
agent.workflow.name = "wf"
agent.workflow._runner_context.has_checkpointing = MagicMock(return_value=False)
agent.workflow.reset = AsyncMock()
agent.run = AsyncMock(return_value=AgentResponse(messages=[]))
# Constructor inspects WorkflowAgent.workflow internals; bypass setup
# by feeding a configured mock through a normal init.
@@ -24,7 +24,7 @@ classifiers = [
]
dependencies = [
"agent-framework-core>=1.8.0,<2",
"github-copilot-sdk==1.0.2; python_version >= '3.11'",
"github-copilot-sdk>=1.0.0,<2; python_version >= '3.11'",
]
[tool.uv]
@@ -7,12 +7,6 @@ This sample demonstrates how to attach :class:`FileAccessProvider` (backed by
data, perform analysis, and write summary output back to the same folder via
the ``file_access_*`` tools.
The file-access tools all require approval (``approval_mode="always_require"``),
so a base ``Agent`` installs :class:`ToolApprovalMiddleware` to drive the
approval handshake. Because this sample is non-interactive, it auto-approves
every file-access tool via
:meth:`FileAccessProvider.all_tools_auto_approval_rule`.
The sibling ``working/`` folder contains ``sales.csv`` ~50 rows of sales
transactions (date, product, category, quantity, unit_price, region,
salesperson). The agent is asked, in a single session, to: list available
@@ -28,7 +22,7 @@ import asyncio
import os
from pathlib import Path
from agent_framework import Agent, FileAccessProvider, FileSystemAgentFileStore, ToolApprovalMiddleware
from agent_framework import Agent, FileAccessProvider, FileSystemAgentFileStore
from agent_framework.foundry import FoundryChatClient
from azure.identity import AzureCliCredential
from dotenv import load_dotenv
@@ -98,19 +92,13 @@ async def main() -> None:
# agent for the duration of each run.
file_access = FileAccessProvider(store=FileSystemAgentFileStore(working_dir))
# 4. Create the agent and attach the provider. The file-access tools all
# require approval (approval_mode="always_require"). Developers can
# present these to the user for approval, or like in this case, auto-approve
# them via FileAccessProvider.all_tools_auto_approval_rule. Note that
# to use tool approval rules, the agent must have ToolApprovalMiddleware
# in its middleware stack.
# 4. Create the agent and attach the provider.
async with Agent(
client=client,
name="DataAnalyst",
description="A data analyst assistant that reads, analyzes, and processes data files.",
instructions=INSTRUCTIONS,
context_providers=[file_access],
middleware=[ToolApprovalMiddleware(auto_approval_rules=[FileAccessProvider.all_tools_auto_approval_rule])],
) as agent:
# 5. Run all prompts inside one session so the conversation remains
# coherent across turns.
+2 -23
View File
@@ -29,7 +29,6 @@ Each feature can be disabled or customized via keyword arguments.
| File | Description |
|------|-------------|
| `harness_research.py` | Interactive research assistant with web search, a plan/execute workflow, and an execute-mode loop that re-invokes the agent until every todo is complete |
| `harness_data_processing.py` | Data-processing assistant over a folder of CSV files, demonstrating file-access tools and tool approval |
## Running
@@ -41,30 +40,10 @@ export FOUNDRY_MODEL="your-model-deployment-name"
# Authenticate with Azure (required for AzureCliCredential)
az login
# Run a sample against the released agent-framework (PEP 723 isolated env)
uv run samples/02-agents/harness/harness_research.py
# Run the research sample
python samples/02-agents/harness/harness_research.py
```
### Running against the local repo
To run a sample against your **local** `agent-framework` checkout (so it picks
up uncommitted changes), use the workspace environment instead of the isolated
PEP 723 env. From the `python/` directory, run the script with `uv run python`
and add the `textual` UI dependency the harness console needs:
```bash
uv run --with textual python samples/02-agents/harness/harness_research.py
uv run --with textual python samples/02-agents/harness/harness_data_processing.py
```
The workspace environment already provides the editable `agent-framework`
packages plus the samples' other dependencies (`rich`, `python-dotenv`,
`azure-identity`); only `textual` needs to be supplied with `--with`.
> Note: invoking `uv run python <script>` (with `python`) bypasses the PEP 723
> metadata and uses the workspace env; `uv run <script>` (without `python`)
> uses the isolated env with the released package.
## Key Concepts
### Minimal Setup
@@ -1,61 +0,0 @@
# Build your own agent harness and claw — Python samples
Runnable Python samples for 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. Each step builds a personal finance / investing assistant on top of
`create_harness_agent`, reusing the shared harness `console` package in the parent `harness/`
directory.
- **Part 1 — `claw_step01_meet_your_claw.py`** — the minimal harness.
## Prerequisites
1. A Microsoft Foundry project with a deployed model.
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"
export FOUNDRY_MODEL="your-model-deployment-name"
```
---
## Part 1 — Meet your claw
Builds the foundation of the assistant on top of `create_harness_agent`.
### What this sample demonstrates
- **`create_harness_agent`** — a factory that builds a batteries-included agent: function
invocation, per-service-call history persistence, planning (`TodoProvider` +
`AgentModeProvider`), and web search.
- **A custom function tool**`get_stock_price`, 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") into a todo
list and switches between *plan* and *execute* modes.
- **Shared harness console** — interactive streaming UI (reused from the parent `harness/console`
package) with `/todos`, `/mode`, and `/exit` commands.
### Running
```bash
# From the repository root, using a PEP 723 compatible runner:
uv run python/samples/02-agents/harness/build_your_own_claw/claw_step01_meet_your_claw.py
```
### 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.
---
@@ -1,146 +0,0 @@
# /// script
# requires-python = ">=3.10"
# dependencies = [
# "agent-framework",
# "textual>=6.2.1",
# "rich>=13.7.1",
# "azure-identity",
# "python-dotenv",
# ]
# ///
# Run with any PEP 723 compatible runner, e.g.:
# uv run python/samples/02-agents/harness/build_your_own_claw/claw_step01_meet_your_claw.py
# Copyright (c) Microsoft. All rights reserved.
"""Meet your agent harness and claw (Post 1) — Python.
The first runnable sample from the "Build your own claw with Microsoft Agent Framework" blog
series. See: https://devblogs.microsoft.com/agent-framework/meet-your-agent-harness-and-claw.
It builds the foundation of a personal finance / investing assistant on top of
``create_harness_agent``.
``create_harness_agent`` is a factory that wires up a batteries-included agent: function
invocation, per-service-call history persistence, planning (TodoProvider +
AgentModeProvider), and web search. All we add here is finance-focused
instructions and a custom ``get_stock_price`` tool.
This sample reuses the shared harness ``console`` package that lives in the parent
``harness/`` directory.
Environment variables:
FOUNDRY_PROJECT_ENDPOINT Microsoft Foundry project endpoint URL
FOUNDRY_MODEL Model deployment name
Authentication:
Run ``az login`` before running this sample.
"""
import asyncio
import sys
from datetime import datetime, timezone
from pathlib import Path
from typing import Annotated
from agent_framework import create_harness_agent
from agent_framework.foundry import FoundryChatClient
from azure.identity import AzureCliCredential
from dotenv import load_dotenv
# Reuse the shared harness console that lives in the parent ``harness/`` directory.
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
from console import build_observers_with_planning, run_agent_async # noqa: E402
FINANCE_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.
"""
# A tiny in-memory price book so the sample runs without any external dependency.
# These are illustrative mock prices, not real market quotes.
_PRICE_BOOK: dict[str, float] = {
"MSFT": 462.97,
"AAPL": 229.35,
"GOOGL": 178.12,
"AMZN": 201.45,
"NVDA": 134.81,
}
# <get_stock_price>
def get_stock_price(
symbol: Annotated[str, "The stock ticker symbol, e.g. MSFT or AAPL."],
) -> dict[str, object]:
"""Get the latest (delayed, illustrative) stock price for a ticker symbol."""
ticker = symbol.upper()
price = _PRICE_BOOK.get(ticker)
if price is None:
# Deterministic pseudo-price for unknown symbols so the sample stays self-contained.
# Derive a stable seed from the characters — the built-in hash() is randomized per
# process (PYTHONHASHSEED), so it would give different prices on every run.
seed = 0
for ch in ticker:
seed = (seed * 31 + ord(ch)) % 1_000_000
price = 50.0 + (seed % 45000) / 100.0
return {
"symbol": ticker,
"price": round(price, 2),
"currency": "USD",
"as_of": datetime.now(timezone.utc).isoformat(),
}
# </get_stock_price>
async def main() -> None:
load_dotenv()
# <create_client>
# Construct a chat client. FoundryChatClient reads FOUNDRY_PROJECT_ENDPOINT and FOUNDRY_MODEL
# from the environment; AzureCliCredential handles auth (run `az login`, or swap in another
# credential). The harness works with ANY chat client — see the providers samples for OpenAI,
# Azure OpenAI, Anthropic, Ollama, and more.
client = FoundryChatClient(credential=AzureCliCredential())
# </create_client>
# <create_agent>
# Turn the chat client into a harness agent with finance instructions and our custom
# stock-price tool. Planning (todo + mode) and web search are configured automatically.
agent = create_harness_agent(
client=client,
agent_instructions=FINANCE_INSTRUCTIONS,
tools=get_stock_price,
)
# </create_agent>
# <run>
# Run the interactive console session using the shared harness console helper.
await run_agent_async(
agent,
session=agent.create_session(),
observers=build_observers_with_planning(agent),
initial_mode="plan",
title="💹 Finance Assistant",
placeholder="Ask about a stock or say 'review my watchlist'...",
)
# </run>
if __name__ == "__main__":
asyncio.run(main())
@@ -13,40 +13,25 @@
# Copyright (c) Microsoft. All rights reserved.
"""Harness Data Processing Assistant with Console UI and tool approvals.
"""Harness Data Processing Assistant with Console UI.
Demonstrates ``create_harness_agent`` configured with a ``FileAccessProvider``
to give an agent access to a folder of CSV data files. The agent can read,
analyze, and extract information from the data, then write results back as new
files via the ``file_access_*`` tools.
This sample also demonstrates **tool approval**. The ``FileAccessProvider``
registers all of its tools with ``approval_mode="always_require"``, so every
file operation would normally prompt the host for approval. To keep read-only
exploration frictionless while still guarding mutations, the agent is given the
:meth:`FileAccessProvider.read_only_tools_auto_approval_rule` auto-approval
rule. With this rule:
- Read-only tools (read, list files, list subdirectories, search) are
auto-approved and run without prompting.
- Write tools (save and delete) still require explicit approval, so you are
asked before the agent modifies the file store.
Demonstrates ``create_harness_agent`` configured with the default
``FileAccessProvider`` to give an agent access to a folder of CSV data files.
The agent can read, analyze, and extract information from the data, then write
results back as new files via the ``file_access_*`` tools.
The sample includes a pre-populated ``working/`` folder with sales transaction
data. The ``FileAccessProvider`` is pointed at that folder (resolved relative to
this script) so it works regardless of the current working directory. Ask the
agent to analyze the data, produce summaries, or create new output files. For
example::
data. The ``file_access_store`` is set explicitly to that folder (resolved
relative to this script) so it works regardless of the current working
directory. Ask the agent to analyze the data, produce summaries, or create new
output files. For example::
Please process the sales.csv file by first filtering it to only North region
sales, and then calculating the sum of sales by person. I'd like to write the
results of the processing to north_region_totals.csv
When the agent reads ``sales.csv`` it proceeds automatically, but when it tries
to save ``north_region_totals.csv`` you are prompted to approve the write.
Unused harness features (todos, plan/execute mode, web search) are disabled to
keep this a simple, conversational data-interaction sample.
Unused harness features (file memory, todos, plan/execute mode, web search) are
disabled to keep this a simple, conversational data-interaction sample.
Environment variables:
FOUNDRY_PROJECT_ENDPOINT Azure AI Foundry project endpoint URL
@@ -59,7 +44,7 @@ Authentication:
import asyncio
from pathlib import Path
from agent_framework import FileAccessProvider, FileSystemAgentFileStore, create_harness_agent
from agent_framework import FileSystemAgentFileStore, create_harness_agent
from agent_framework.foundry import FoundryChatClient
from azure.identity import AzureCliCredential
from console import build_default_observers, run_agent_async
@@ -106,9 +91,9 @@ async def main() -> None:
# with your preferred authentication option.
client = FoundryChatClient(credential=AzureCliCredential())
# Create a harness agent with data-analyst instructions. Unused features are
# disabled. The read_only_tools_auto_approval_rule auto-approves the
# FileAccessProvider's read-only tools, so only write operations prompt.
# Create a harness agent with data-analyst instructions. The FileAccessProvider
# is explicitly pointed at the sample's working/ folder so it works regardless
# of the current working directory. Unused features are disabled.
agent = create_harness_agent(
client=client,
max_context_window_tokens=MAX_CONTEXT_WINDOW_TOKENS,
@@ -117,7 +102,7 @@ async def main() -> None:
description="A data analyst assistant that reads, analyzes, and processes data files.",
agent_instructions=DATA_ANALYST_INSTRUCTIONS,
file_access_store=FileSystemAgentFileStore(working_dir),
auto_approval_rules=[FileAccessProvider.read_only_tools_auto_approval_rule],
disable_file_memory=True,
disable_todo=True,
disable_mode=True,
disable_web_search=True,
@@ -29,8 +29,7 @@ def main():
logger.debug("Starting Durable Task Agent Sample (Combined Worker + Client)...")
silent_handler = logging.NullHandler()
# Create and start the worker using helper function and context manager
dts_worker = get_worker(log_handler=silent_handler)
with dts_worker:
with get_worker(log_handler=silent_handler) as dts_worker:
# Register agents using helper function
setup_worker(dts_worker)
# Start the worker
@@ -29,8 +29,7 @@ def main():
logger.debug("Starting Durable Task Multi-Agent Sample (Combined Worker + Client)...")
silent_handler = logging.NullHandler()
# Create and start the worker using helper function and context manager
dts_worker = get_worker(log_handler=silent_handler)
with dts_worker:
with get_worker(log_handler=silent_handler) as dts_worker:
# Register agents using helper function
setup_worker(dts_worker)
# Start the worker
@@ -31,8 +31,7 @@ def main():
logger.debug("Starting Durable Task Agent Sample with Redis Streaming...")
silent_handler = logging.NullHandler()
# Create and start the worker using helper function and context manager
dts_worker = get_worker(log_handler=silent_handler)
with dts_worker:
with get_worker(log_handler=silent_handler) as dts_worker:
# Register agents and callbacks using helper function
setup_worker(dts_worker)
# Start the worker
@@ -34,8 +34,7 @@ def main():
logger.debug("Starting Single Agent Orchestration Chaining Sample...")
silent_handler = logging.NullHandler()
# Create and start the worker using helper function and context manager
dts_worker = get_worker(log_handler=silent_handler)
with dts_worker:
with get_worker(log_handler=silent_handler) as dts_worker:
# Register agents and orchestrations using helper function
setup_worker(dts_worker)
# Start the worker
@@ -31,8 +31,7 @@ def main():
logger.debug("Starting Durable Task Multi-Agent Orchestration Sample (Combined Worker + Client)...")
silent_handler = logging.NullHandler()
# Create and start the worker using helper function and context manager
dts_worker = get_worker(log_handler=silent_handler)
with dts_worker:
with get_worker(log_handler=silent_handler) as dts_worker:
# Register agents and orchestrations using helper function
setup_worker(dts_worker)
# Start the worker
@@ -35,8 +35,7 @@ def main():
silent_handler = logging.NullHandler()
# Create and start the worker using helper function and context manager
dts_worker = get_worker(log_handler=silent_handler)
with dts_worker:
with get_worker(log_handler=silent_handler) as dts_worker:
# Register agents, orchestrations, and activities using helper function
setup_worker(dts_worker)
@@ -31,8 +31,7 @@ def main():
logger.debug("Starting Durable Task HITL Content Generation Sample (Combined Worker + Client)...")
silent_handler = logging.NullHandler()
# Create and start the worker using helper function and context manager
dts_worker = get_worker(log_handler=silent_handler)
with dts_worker:
with get_worker(log_handler=silent_handler) as dts_worker:
# Register agent, orchestration, and activities using helper function
setup_worker(dts_worker)
# Start the worker
+4281 -4741
View File
File diff suppressed because it is too large Load Diff