Compare commits
28 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 6737531882 | |||
| 953f08f533 | |||
| 7c533de7e1 | |||
| 3ba1dd3787 | |||
| 9ca6987a09 | |||
| 439bee46b1 | |||
| 080a959402 | |||
| fcda092fa4 | |||
| 714016a035 | |||
| 5fff0df2af | |||
| acb28a63b5 | |||
| f2d02e58b3 | |||
| 36420c515e | |||
| 1109d0bf64 | |||
| e030fb53de | |||
| e6ebba1884 | |||
| 7051a4920d | |||
| 15df1152fc | |||
| a2018b40f9 | |||
| e4b89373f1 | |||
| 88f0b23fb0 | |||
| 9ba6b3a94e | |||
| 2999f7416f | |||
| 09791533cf | |||
| 7f2e19ca2f | |||
| 7b6f582b13 | |||
| dc60722cee | |||
| 2f5a76ab1d |
@@ -0,0 +1,641 @@
|
||||
---
|
||||
status: proposed
|
||||
contact: sergeymenshykh
|
||||
date: 2026-06-23
|
||||
deciders: sergeymenshykh
|
||||
---
|
||||
|
||||
# Skills Over MCP: Implementation Design Options
|
||||
|
||||
This document explores design options for two SEP-2640 features. The decisions are not yet finalized.
|
||||
|
||||
- **Part 1: MCP Resource Template Skills** - skills described by a URI template with variables that must be resolved before loading.
|
||||
- **Part 2: Direct Skill References** - reading `skill://` URIs referenced directly (e.g., in server instructions) without being listed in the index.
|
||||
|
||||
## Part 1: MCP Resource Template Skills
|
||||
|
||||
### Context and Problem Statement
|
||||
|
||||
The `AgentMcpSkillsSource` currently only supports `skill-md` type entries from `skill://index.json` (support for `archive` type is planned). The SEP-2640 specification also defines `mcp-resource-template` entries: **parameterized skill namespaces** described by a URI template with variables (e.g., `{product}`) that resolve to concrete `SKILL.md` URIs. Rather than materializing every skill in the index, the template's variables must be resolved before a skill can be loaded.
|
||||
|
||||
### Index Entry Format
|
||||
|
||||
```json
|
||||
{
|
||||
"$schema": "https://schemas.agentskills.io/discovery/0.2.0/schema.json",
|
||||
"skills": [
|
||||
{
|
||||
"name": "git-workflow",
|
||||
"type": "skill-md",
|
||||
"description": "Follow this team's Git conventions for branching and commits",
|
||||
"url": "skill://git-workflow/SKILL.md"
|
||||
},
|
||||
{
|
||||
"type": "mcp-resource-template",
|
||||
"description": "Per-product documentation skill",
|
||||
"url": "skill://docs/{product}/SKILL.md"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
Key differences from `skill-md`:
|
||||
|
||||
| Field | `skill-md` | `mcp-resource-template` |
|
||||
|-------|------------|-------------------------|
|
||||
| `name` | Required (the skill name) | **Omitted** (represents many skills) |
|
||||
| `type` | `"skill-md"` | `"mcp-resource-template"` |
|
||||
| `url` | Concrete URI to `SKILL.md` | URI template with variables |
|
||||
| `description` | Describes the skill | Describes the addressable skill space |
|
||||
|
||||
### Use Cases
|
||||
|
||||
Template skills address two scenarios where listing concrete skills is impractical:
|
||||
|
||||
- **Large skill catalogs** - too many skills to enumerate every entry in the index.
|
||||
- **Dynamically generated skills** - skill content generated on the fly from parameters, so the set of valid skills is not known at index-creation time.
|
||||
|
||||
### How Template Skills Are Consumed
|
||||
|
||||
Per SEP-2640, the consumption flow relies on the MCP `completion/complete` method:
|
||||
|
||||
1. **Server registers a resource template** - The MCP server registers the same `url` value (e.g., `skill://docs/{product}/SKILL.md`) as an MCP [resource template](https://modelcontextprotocol.io/specification/2025-11-25/server/resources#resource-templates), wiring template variables to the [completion API](https://modelcontextprotocol.io/specification/2025-11-25/server/utilities/completion).
|
||||
|
||||
2. **Host reads `skill://index.json`** - Discovers the template entry with `type: "mcp-resource-template"`.
|
||||
|
||||
3. **Host surfaces template in UI** - Presents the template as an interactive discovery point where the user fills in variables.
|
||||
|
||||
4. **Host calls `completion/complete`** - For each template variable (e.g., `{product}`), the host calls the MCP completion API to get possible values from the server:
|
||||
```json
|
||||
{
|
||||
"method": "completion/complete",
|
||||
"params": {
|
||||
"ref": {
|
||||
"type": "ref/resource",
|
||||
"uri": "skill://docs/{product}/SKILL.md"
|
||||
},
|
||||
"argument": {
|
||||
"name": "product",
|
||||
"value": ""
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
The server responds with possible completions:
|
||||
```json
|
||||
{
|
||||
"completion": {
|
||||
"values": ["widgets", "billing", "auth", "payments"],
|
||||
"hasMore": false,
|
||||
"total": 4
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
5. **User selects a value** - The user picks a value (e.g., `"billing"`) from the list.
|
||||
|
||||
6. **Host resolves the URI** - The template `skill://docs/{product}/SKILL.md` becomes the concrete URI `skill://docs/billing/SKILL.md`.
|
||||
|
||||
7. **Host reads the resolved skill** - Calls `resources/read` with the concrete URI and proceeds as with any `skill-md` skill.
|
||||
|
||||
### Potential Implementation Options
|
||||
|
||||
### Option 1: Callback on `AgentMcpSkillsSource` for Variable Value Selection
|
||||
|
||||
Add a callback to `AgentMcpSkillsSource` (or its options) that is invoked for each `mcp-resource-template` entry to let the caller select variable values.
|
||||
|
||||
**Flow:**
|
||||
|
||||
1. `AgentMcpSkillsSource.GetSkillsAsync()` reads `skill://index.json`
|
||||
2. For each entry with `type: "mcp-resource-template"`:
|
||||
- Parse the URI template to extract variable names (e.g., `{product}`)
|
||||
- Call the MCP `completion/complete` API to get possible values for each variable
|
||||
- Invoke the caller-provided callback with the variable name, description, and possible values
|
||||
- The callback returns a selected value and a `bool` indicating whether to include the skill
|
||||
3. Resolve the URI template with the selected values
|
||||
4. Create an `AgentMcpSkill` from the resolved URI and add it to the skills list
|
||||
|
||||
**API sketch:**
|
||||
|
||||
```csharp
|
||||
public delegate Task<(string? SelectedValue, bool IncludeSkill)> McpTemplateVariableSelector(
|
||||
string templateDescription,
|
||||
string variableName,
|
||||
IReadOnlyList<string> possibleValues,
|
||||
CancellationToken cancellationToken);
|
||||
|
||||
// Usage via builder:
|
||||
var provider = new AgentSkillsProviderBuilder()
|
||||
.UseMcpSkills(mcpClient, options => {
|
||||
options.TemplateVariableSelector = async (description, variable, values, ct) =>
|
||||
{
|
||||
// Present to user, return selection
|
||||
var selected = PromptUser(variable, values);
|
||||
return (selected, IncludeSkill: selected is not null);
|
||||
};
|
||||
})
|
||||
.Build();
|
||||
```
|
||||
|
||||
**Pros:**
|
||||
- Simple implementation
|
||||
- Easy to understand and use
|
||||
|
||||
**Cons:**
|
||||
- Cannot be used in server-side scenarios where there is no interactive user at skill-discovery time
|
||||
- Does not integrate with the agent's conversational flow
|
||||
|
||||
---
|
||||
|
||||
### Option 2: Integrate into Agent Conversation via `ChatClientAgent` Decorator
|
||||
|
||||
Model the template variable resolution as a request/response interaction within the agent's conversational loop.
|
||||
|
||||
**Flow:**
|
||||
|
||||
1. A `DelegatingAIAgent` decorator (e.g., `McpTemplateSkillResolutionAgent`) intercepts `RunAsync`/`RunStreamingAsync` calls and checks whether the inner agent has an `AgentSkillsProvider` with an `AgentMcpSkillsSource` containing unresolved template entries. The check is performed via `GetService<AgentMcpSkillsSource>()` on the `AgentSkillsProvider`, which delegates to a `GetService` method on the `AgentSkillsSource` base class.
|
||||
|
||||
2. The decorator calls an internal member on `AgentMcpSkillsSource` to get the list of `mcp-resource-template` entries from the index. The `AgentMcpSkillsSource` needs to be extended with an internal member that exposes unresolved template entries separately from concrete skills.
|
||||
|
||||
3. For each template entry, the decorator calls an internal member on `AgentMcpSkillsSource` to retrieve possible values for the template's variables via the MCP `completion/complete` API.
|
||||
|
||||
4. For each variable needing resolution, the decorator returns an `McpResourceTemplateValueRequestContent` (inherits from MEAI's `InputRequestContent`) in the agent response - bypassing the call to the inner agent. The content carries the template description, variable name, and possible values.
|
||||
|
||||
5. The user app receives the response, identifies the `McpResourceTemplateValueRequestContent` content type, and displays UI to the user showing the variable name and possible values, or forwards it further downstream if the user app is a service.
|
||||
|
||||
6. The user selects a value, and the user app calls the agent again with a corresponding `McpResourceTemplateValueResponseContent` (inherits from MEAI's `InputResponseContent`) containing the selected value. The `RequestId` property (inherited from the base classes) correlates the response with the original request.
|
||||
|
||||
7. The decorator identifies the response content and provides the resolved values to `AgentMcpSkillsSource` so it can use them when constructing concrete skills.
|
||||
|
||||
8. Having resolved all template variables, the decorator calls `RunAsync`/`RunStreamingAsync` on the inner agent.
|
||||
|
||||
9. The inner agent invokes the `AgentSkillsProvider`, which calls `AgentMcpSkillsSource.GetSkillsAsync()`. The source now has all resolved variable values and constructs concrete `AgentMcpSkill` instances from the resolved URIs, so it can provide the skill content if requested by the model.
|
||||
|
||||
**API sketch:**
|
||||
|
||||
```csharp
|
||||
// New content types inheriting from MEAI's InputRequestContent/InputResponseContent:
|
||||
public sealed class McpResourceTemplateValueRequestContent : InputRequestContent
|
||||
{
|
||||
public string TemplateDescription { get; }
|
||||
public string VariableName { get; }
|
||||
public IReadOnlyList<string> PossibleValues { get; }
|
||||
public string TemplateUrl { get; }
|
||||
}
|
||||
|
||||
public sealed class McpResourceTemplateValueResponseContent : InputResponseContent
|
||||
{
|
||||
public string SelectedValue { get; }
|
||||
public string TemplateUrl { get; }
|
||||
}
|
||||
|
||||
// Decorator usage:
|
||||
var provider = new AgentSkillsProviderBuilder()
|
||||
.UseMcpSkills(mcpClient)
|
||||
.Build();
|
||||
|
||||
AIAgent agent = new ChatClientAgent(chatClient, new ChatClientAgentOptions
|
||||
{
|
||||
AIContextProviders = [provider],
|
||||
});
|
||||
agent = new McpTemplateSkillResolutionAgent(agent);
|
||||
```
|
||||
|
||||
**Pros:**
|
||||
- Works in server-side scenarios
|
||||
- Fits the existing `DelegatingAIAgent` decorator pattern
|
||||
- Can be composed with other decorators (tool approval, etc.)
|
||||
|
||||
**Cons:**
|
||||
- Complex implementation
|
||||
- Requires user app awareness of the new content types
|
||||
- Users need to know that an additional decorator is required for handling MCP template skills, in addition to registering the MCP skills source
|
||||
- Resolved template variable values must be persisted across conversation turns so the decorator does not re-prompt on subsequent agent runs within the same session
|
||||
|
||||
**Note:** This writeup is high-level and may miss details that could change the design. A POC would be needed to validate the approach.
|
||||
|
||||
### Open Questions
|
||||
|
||||
1. **Completion API limit** - The MCP completion API returns at most 100 values per request and provides no offset/cursor mechanism for enumeration. If a variable has more than 100 possible values, it's unclear how to retrieve the rest - the API only supports prefix-based filtering (typeahead), not bulk pagination.
|
||||
|
||||
2. **Multi-variable templates** - A template like `skill://{org}/{product}/SKILL.md` has multiple variables. Should they be resolved sequentially (org first, then product - since product values may depend on org) or presented together?
|
||||
|
||||
3. **Caching** - Should resolved template values be saved in the `AgentSession` so the user isn't re-prompted on every agent run? How should they be persisted between sessions?
|
||||
|
||||
---
|
||||
|
||||
## Part 2: Direct Skill References
|
||||
|
||||
This part covers how to let the model read `skill://` URIs referenced directly (e.g., in an MCP server's `instructions`, in a resource, or in another skill's content) without being listed in `skill://index.json`.
|
||||
|
||||
### How MCP Skills and Relative Links Work Today
|
||||
|
||||
The `AgentMcpSkillsSource` discovers skills by reading the well-known `skill://index.json` resource from the MCP server:
|
||||
|
||||
```json
|
||||
{
|
||||
"$schema": "https://schemas.agentskills.io/discovery/0.2.0/schema.json",
|
||||
"skills": [
|
||||
{
|
||||
"name": "unit-converter",
|
||||
"type": "skill-md",
|
||||
"description": "Convert between common units.",
|
||||
"url": "skill://unit-converter/SKILL.md"
|
||||
},
|
||||
{
|
||||
"name": "currency-converter",
|
||||
"type": "skill-md",
|
||||
"description": "Convert between world currencies using live rates.",
|
||||
"url": "skill://currency-converter/SKILL.md"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
For each `skill-md` entry it creates an `AgentMcpSkill` instance - frontmatter (name/description) comes straight from the entry. The `AgentSkillsProvider` lists the discovered skills in the model's context (name + description):
|
||||
|
||||
```xml
|
||||
<available_skills>
|
||||
<skill>
|
||||
<name>unit-converter</name>
|
||||
<description>Convert between common units.</description>
|
||||
</skill>
|
||||
<skill>
|
||||
<name>currency-converter</name>
|
||||
<description>Convert between world currencies using live rates.</description>
|
||||
</skill>
|
||||
</available_skills>
|
||||
```
|
||||
|
||||
It also provides functions to the model so it can load a skill and access its resources:
|
||||
|
||||
```csharp
|
||||
// Loads the full content of a specific skill.
|
||||
load_skill(string skillName)
|
||||
|
||||
// Reads a resource associated with a skill (references, assets, dynamic data).
|
||||
read_skill_resource(string skillName, string resourceName)
|
||||
```
|
||||
|
||||
The model calls `load_skill("unit-converter")` and receives the skill content:
|
||||
|
||||
```markdown
|
||||
---
|
||||
name: unit-converter
|
||||
description: Convert between common units.
|
||||
---
|
||||
## Usage
|
||||
|
||||
For the full conversion table, see references/units-table.md.
|
||||
```
|
||||
|
||||
The skill body references `references/units-table.md` by relative path. The model calls `read_skill_resource("unit-converter", "references/units-table.md")` and receives the resource content:
|
||||
|
||||
```markdown
|
||||
# Unit Conversion Table
|
||||
|
||||
| From | To | Factor |
|
||||
| miles | km | 1.60934 |
|
||||
| kg | lbs | 2.20462 |
|
||||
```
|
||||
|
||||
### Direct Reference Examples
|
||||
|
||||
A `skill://` URI can appear in any of these locations:
|
||||
|
||||
**Server instructions** - the MCP server advertises a skill the model should load:
|
||||
|
||||
```text
|
||||
Follow our coding standards. Load skill://code-standards/SKILL.md for details.
|
||||
```
|
||||
|
||||
**A skill body** - a skill's `SKILL.md` links to a sibling resource:
|
||||
|
||||
```markdown
|
||||
---
|
||||
name: code-standards
|
||||
description: Coding standards and conventions.
|
||||
---
|
||||
## Naming
|
||||
|
||||
Follow the naming rules in skill://code-standards/references/naming.md.
|
||||
```
|
||||
|
||||
**A resource** - the linked resource holds the actual content:
|
||||
|
||||
```markdown
|
||||
# Naming Rules
|
||||
|
||||
- Use PascalCase for public members and type names.
|
||||
- Use camelCase for locals and parameters.
|
||||
- Prefix interfaces with `I` (e.g. `ISkillReader`).
|
||||
- Suffix async methods with `Async`.
|
||||
|
||||
For examples, see skill://code-standards/references/naming-examples.md.
|
||||
```
|
||||
|
||||
How can the model access content by direct reference?
|
||||
|
||||
### Function for Reading Direct Skill References
|
||||
|
||||
### Option 1: Extend existing `load_skill` and `read_skill_resource` functions
|
||||
|
||||
```csharp
|
||||
// Added optional 'origin' and a direct skill:// URI is passed in 'skillName'.
|
||||
load_skill(string skillName, string? origin = null)
|
||||
|
||||
// Added optional 'origin', made 'skillName' optional, and a direct skill:// URI is passed in 'resourceName'.
|
||||
read_skill_resource(string resourceName, string? skillName = null, string? origin = null)
|
||||
```
|
||||
|
||||
The optional `origin` identifies the source/MCP server that should handle the direct URI.
|
||||
|
||||
| Case | Call |
|
||||
|------|------|
|
||||
| Load skill | `load_skill("commit-guidelines")` |
|
||||
| Relative resource | `read_skill_resource("commit-guidelines", "examples/COMMIT_EXAMPLES.md")` |
|
||||
| `skill://` link (skill) | `load_skill(skillName: "skill://commit-guidelines/SKILL.md", origin: "DirectRefServer")` |
|
||||
| `skill://` link (resource) | `read_skill_resource(resourceName: "skill://commit-guidelines/examples/COMMIT_EXAMPLES.md", origin: "DirectRefServer")` |
|
||||
|
||||
**Pros:**
|
||||
|
||||
- No new functions added: existing tool surface stays at two functions.
|
||||
|
||||
**Cons:**
|
||||
|
||||
- Unreliable on some models (gpt-4o, gpt-4.1-mini): it often omits `origin` when it should not or calls the wrong function.
|
||||
- Optional parameters create silent ambiguity - the model can pass `origin` for non-MCP skills or omit it for `skill://` URIs.
|
||||
|
||||
### Option 2 (Proposed): Add a dedicated `read_skill_uri` function alongside existing ones
|
||||
|
||||
```csharp
|
||||
// Existing functions stay unchanged.
|
||||
load_skill(string skillName)
|
||||
read_skill_resource(string skillName, string resourceName)
|
||||
|
||||
// New function added alongside: reads content by direct skill:// URI.
|
||||
read_skill_uri(string uri, string origin)
|
||||
```
|
||||
|
||||
| Case | Call |
|
||||
|------|------|
|
||||
| Load skill | `load_skill("commit-guidelines")` |
|
||||
| Relative resource | `read_skill_resource("commit-guidelines", "examples/COMMIT_EXAMPLES.md")` |
|
||||
| `skill://` link (skill) | `read_skill_uri(uri: "skill://commit-guidelines/SKILL.md", origin:"DirectRefServer")` |
|
||||
| `skill://` link (resource) | `read_skill_uri(uri: "skill://commit-guidelines/examples/COMMIT_EXAMPLES.md", origin: "DirectRefServer")` |
|
||||
|
||||
**Pros:**
|
||||
|
||||
- Purely additive - no changes to existing functions needed; `read_skill_uri` can be deferred and added later when direct `skill://` reference support is needed.
|
||||
- Granular approval: each function can have its own approval gate (like the existing `ScriptApproval` for `run_skill_script`), making per-operation approval for skill loading, resource reading, and direct URI access straightforward to add.
|
||||
- Both `uri` and `origin` are required - no silent misuse through optional parameters.
|
||||
- Clean split: `load_skill`/`read_skill_resource` for named skills, `read_skill_uri` for `skill://` links - no parameter ambiguity.
|
||||
|
||||
**Cons:**
|
||||
|
||||
- Three read functions (`load_skill`, `read_skill_resource`, `read_skill_uri`), not counting `run_skill_script`: larger tool surface than a single-function design.
|
||||
|
||||
### Option 3: Collapse `load_skill` and `read_skill_resource` into a single `read_resource` function
|
||||
|
||||
```csharp
|
||||
// Single entrypoint for all skill content. 'uri' is required; 'origin' is optional.
|
||||
read_resource(string uri, string? origin = null)
|
||||
```
|
||||
|
||||
- `uri` - what to read: a skill name, a relative resource path, or a `skill://` link.
|
||||
- `origin` - determines how `uri` is interpreted:
|
||||
- **omitted** → load skill by name (`uri` is the skill name).
|
||||
- **skill name** → read a relative resource (`uri` is the path within that skill).
|
||||
- **server name** → read content by the `skill://` link (`uri` is handled by the source identified by the `[Origin: X]` marker).
|
||||
|
||||
Dispatch is ordered: null `origin` routes to Case 1; if `origin` names a known skill, routes to Case 2; otherwise tries to find an `ISkillUriReader` whose `CanRead` returns true for `origin` (Case 3).
|
||||
|
||||
| Case | Call |
|
||||
|------|------|
|
||||
| Load skill | `read_resource(uri: "commit-guidelines")` |
|
||||
| Relative resource | `read_resource(uri: "examples/COMMIT_EXAMPLES.md", origin: "commit-guidelines")` |
|
||||
| `skill://` link (skill) | `read_resource(uri: "skill://commit-guidelines/SKILL.md", origin: "DirectRefServer")` |
|
||||
| `skill://` link (resource) | `read_resource(uri: "skill://commit-guidelines/examples/COMMIT_EXAMPLES.md", origin: "DirectRefServer")` |
|
||||
|
||||
**Pros:**
|
||||
|
||||
- Minimal tool surface: one read function instead of two or three (not counting `run_skill_script`) reduces token usage and gives the model fewer choices.
|
||||
|
||||
**Cons:**
|
||||
|
||||
- No per-operation approval: all cases (skill loading, resource reading, direct URI access) share one function, so approval cannot be scoped to individual operations.
|
||||
- Unreliable on gpt-4.1-mini: omits `origin` when reading `skill://` links, passes skill name as `origin` when loading a plain skill (should be omitted), and hallucinates resource names (e.g. `API_SPECIFICATION.md`) that do not exist.
|
||||
|
||||
|
||||
---
|
||||
|
||||
### Origin Marker
|
||||
|
||||
A `skill://` URI does not carry an origin, but the model needs to provide one when reading it. The `origin` is what routes the read call to the source that can handle the URI - the provider uses it to pick the matching source. Since the URI itself carries no such hint, the MCP source injects an `[Origin: ...]` marker wherever a `skill://` URI appears, so the model can read it back and pass it as the `origin` argument.
|
||||
|
||||
The marker is only added when the content actually contains `skill://` references. If a piece of content (server instructions, a skill body, or a resource) has no `skill://` URIs, there is nothing for the model to read back, so no marker is injected.
|
||||
|
||||
Into **server instructions**, which may mention `skill://` URIs directly:
|
||||
|
||||
```
|
||||
[Origin: code-standards-server]
|
||||
Follow our coding standards. Load skill://code-standards/SKILL.md for details.
|
||||
```
|
||||
|
||||
Into **skill bodies**, since a `SKILL.md` may reference other `skill://` URIs (a resource file or a related skill):
|
||||
|
||||
```
|
||||
[Origin: code-standards-server]
|
||||
# Code Standards
|
||||
|
||||
For naming conventions, load skill://code-standards/references/naming.md.
|
||||
```
|
||||
|
||||
Into **skill resources**, since a resource may itself reference further `skill://` URIs:
|
||||
|
||||
```
|
||||
[Origin: code-standards-server]
|
||||
# Naming Rules
|
||||
|
||||
- Use PascalCase for public members and type names.
|
||||
- Use camelCase for locals and parameters.
|
||||
|
||||
For examples, see skill://code-standards/references/naming-examples.md.
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Read-by-URI Capability: Interface vs Base Class Virtual Methods
|
||||
|
||||
Now let's look at how an `AgentSkillsSource` can opt in to reading `skill://` URIs and signal that capability to the provider.
|
||||
|
||||
### Option 1: New `ISkillUriReader` interface
|
||||
|
||||
```csharp
|
||||
public interface ISkillUriReader
|
||||
{
|
||||
// Returns true if this reader can handle the given skill:// URI from the given origin.
|
||||
bool CanRead(string uri, string origin);
|
||||
|
||||
// Reads and returns the content for the given skill:// URI.
|
||||
Task<object?> ReadByUriAsync(string uri, string origin, CancellationToken cancellationToken = default);
|
||||
}
|
||||
```
|
||||
|
||||
Sources that support direct `skill://` URI reads - such as `AgentMcpSkillsSource` - implement this interface to opt in.
|
||||
|
||||
The provider discovers readers via a service locator and dispatches to the first that can handle the URI:
|
||||
|
||||
```csharp
|
||||
// Discover all registered readers.
|
||||
var readers = source.GetService<IEnumerable<ISkillUriReader>>();
|
||||
|
||||
// Pick the first reader that can handle the URI.
|
||||
var reader = readers.FirstOrDefault(r => r.CanRead(uri, origin))
|
||||
?? throw new InvalidOperationException($"No reader can handle URI '{uri}' from origin '{origin}'.");
|
||||
|
||||
// Delegate the read to it.
|
||||
return await reader.ReadByUriAsync(uri, origin, cancellationToken);
|
||||
```
|
||||
|
||||
The provider may treat a source implementing `ISkillUriReader` as the signal to advertise `read_skill_uri`: if at least one registered source implements the interface, the function is exposed to the model; otherwise it is not.
|
||||
|
||||
### Option 2 (Proposed): Virtual methods on `AgentSkillsSource` base class
|
||||
|
||||
```csharp
|
||||
public abstract class AgentSkillsSource
|
||||
{
|
||||
// New members for reading by URI.
|
||||
|
||||
// Whether this source can read by URI; drives whether read_skill_uri is advertised. Off by default.
|
||||
public virtual bool SupportsReadByUri => false;
|
||||
|
||||
// Returns true if this source can handle the given skill:// URI from the given origin.
|
||||
public virtual bool CanReadByUri(string uri, string origin) => false;
|
||||
|
||||
// Reads and returns the content for the given skill:// URI.
|
||||
public virtual Task<object?> ReadByUriAsync(string uri, string origin, CancellationToken cancellationToken = default)
|
||||
=> Task.FromResult<object?>(null);
|
||||
|
||||
// Existing member.
|
||||
public abstract Task<IList<AgentSkills>> GetSkillsAsync(CancellationToken cancellationToken = default);
|
||||
}
|
||||
```
|
||||
|
||||
Sources opt in by overriding, and the provider calls them directly:
|
||||
|
||||
```csharp
|
||||
// AgentMcpSkillsSource opts in by overriding the virtuals.
|
||||
public override bool SupportsReadByUri => true;
|
||||
|
||||
// Handles the URI when its origin matches this source's MCP server.
|
||||
public override bool CanReadByUri(string uri, string origin)
|
||||
=> string.Equals(origin, this.Origin, StringComparison.OrdinalIgnoreCase);
|
||||
|
||||
// Reads content by skill:// URI from the MCP server.
|
||||
public override Task<string?> ReadByUriAsync(string uri, string origin, CancellationToken cancellationToken)
|
||||
=> /* resolve uri via the MCP server identified by origin */;
|
||||
```
|
||||
|
||||
All sources inherit the methods, so there is no type signal - `SupportsReadByUri` fills that role. The function is advertised when any registered source returns `true`.
|
||||
|
||||
### Comparison
|
||||
|
||||
| Aspect | Option 1: Interface | Option 2: Base class virtual methods |
|
||||
|--------|---------------------|--------------------------------------|
|
||||
| Discovery | Service locator | Direct call on source |
|
||||
| Advertising signal | Interface implementation | `SupportsReadByUri` flag |
|
||||
| Adding new members | Breaking change | Non-breaking |
|
||||
| Complexity | Higher | Lower |
|
||||
|
||||
---
|
||||
|
||||
### Include MCP Server Instructions Into Agent Instructions
|
||||
|
||||
MCP server instructions may contain the `skill://` references the model needs, so we want to surface them in the agent's instructions. But they can also carry system prompts or behavioral directives irrelevant to the agent, polluting context - so inclusion is **opt-in** via the `IncludeServerInstructions` option:
|
||||
|
||||
```csharp
|
||||
public sealed class AgentMcpSkillsSourceOptions
|
||||
{
|
||||
// When true, the MCP server's instructions are injected into the agent instructions. Off by default.
|
||||
public bool IncludeServerInstructions { get; set; }
|
||||
}
|
||||
|
||||
builder.UseMcpSkills(mcpClient, options => options.IncludeServerInstructions = true);
|
||||
```
|
||||
|
||||
When enabled, the instructions travel alongside the discovered skills on `AgentSkillsResult`:
|
||||
|
||||
```csharp
|
||||
public class AgentSkillsResult
|
||||
{
|
||||
// The skills discovered from the source.
|
||||
public IList<AgentSkill> Skills { get; }
|
||||
|
||||
// The MCP server instructions, when IncludeServerInstructions is enabled; otherwise null.
|
||||
public string? Instructions { get; }
|
||||
}
|
||||
```
|
||||
|
||||
The `AgentSkillsProvider` then appends them to its own skill-usage guidance when building the agent's instructions:
|
||||
|
||||
```csharp
|
||||
var result = await source.GetSkillsAsync(cancellationToken);
|
||||
|
||||
var instructions = DefaultSkillsInstructionPrompt;
|
||||
if (!string.IsNullOrWhiteSpace(result.Instructions))
|
||||
{
|
||||
// Combine the provider's skill-usage guidance with the server instructions.
|
||||
instructions += Environment.NewLine + result.Instructions;
|
||||
}
|
||||
```
|
||||
|
||||
### Enabling Direct Skill References
|
||||
|
||||
Following direct `skill://` references is **disabled by default** and activated via an option. When enabled, the provider advertises the read function to the model, and the source injects the `[Origin: ...]` marker into all content provided by the MCP server that contains `skill://` references. When disabled, no function is advertised and no marker is injected.
|
||||
|
||||
```csharp
|
||||
public sealed class AgentMcpSkillsSourceOptions
|
||||
{
|
||||
public bool EnableDirectReferences { get; set; }
|
||||
}
|
||||
|
||||
builder.UseMcpSkills(mcpClient, options => options.EnableDirectReferences = true);
|
||||
```
|
||||
|
||||
## Decision Outcome
|
||||
|
||||
### Template Variable Resolution: Callback vs Decorator (Part 1)
|
||||
|
||||
**Postponed.** Deferring this decision until:
|
||||
|
||||
- We have a concrete list of scenarios that require template variable resolution.
|
||||
- The skills-over-MCP spec is released (it is still a draft, so the design may change).
|
||||
- There is a strong signal of demand from users or the ecosystem.
|
||||
|
||||
### Function for Reading Direct Skill References (Part 2)
|
||||
|
||||
**Postponed.** Leaning toward **Option 2 - dedicated `read_skill_uri` function alongside existing ones** (purely additive, and each function can have its own approval gate for granular per-operation approval), but deferring the decision until:
|
||||
|
||||
- The skills-over-MCP spec is released (it is still a draft, so the design may change).
|
||||
- There is a strong signal of demand from users or the ecosystem.
|
||||
|
||||
### Read-by-URI Capability: Interface vs Base Class (Part 2)
|
||||
|
||||
**Postponed.** Leaning toward **Option 2 - virtual methods on `AgentSkillsSource`** (non-breaking, lower complexity, and a natural fit with the existing base class hierarchy), but deferring the decision until:
|
||||
|
||||
- The skills-over-MCP spec is released (it is still a draft, so the design may change).
|
||||
- There is a strong signal of demand from users or the ecosystem.
|
||||
|
||||
The method naming (`SupportsReadByUri`, `CanReadByUri`, `ReadByUriAsync`) should also be abstracted a little more before adoption, so the same members can be reused when a similar direct-reference concept is needed for other skill types (e.g. file skills).
|
||||
|
||||
## References
|
||||
|
||||
- [SEP-2640: Skills Extension](https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2640) - Draft proposal
|
||||
- [SEP-2640 Implementation Guidelines: Model-Driven Resource Loading](https://github.com/modelcontextprotocol/experimental-ext-skills/blob/main/docs/sep-draft-skills-extension.md#hosts-model-driven-resource-loading)
|
||||
- [MCP Completion API](https://modelcontextprotocol.io/specification/2025-11-25/server/utilities/completion) - Used for template variable resolution
|
||||
- [MCP Resource Templates](https://modelcontextprotocol.io/specification/2025-11-25/server/resources#resource-templates)
|
||||
- [Skills Over MCP Working Group](https://github.com/modelcontextprotocol/experimental-ext-skills)
|
||||
- [Open Question #4: Multi-server skill dependencies](https://github.com/modelcontextprotocol/experimental-ext-skills/issues/39)
|
||||
- [Anthropic Agent Skills - Overview](https://platform.claude.com/docs/en/agents-and-tools/agent-skills/overview) - Prior art: single skill entrypoint + generic file reads
|
||||
- [Anthropic Agent Skills in the SDK](https://code.claude.com/docs/en/agent-sdk/skills) - The `Skill` tool exposed to the model
|
||||
Vendored
-1
@@ -1 +0,0 @@
|
||||
../../../.github/skills/pull-requests
|
||||
+116
@@ -0,0 +1,116 @@
|
||||
---
|
||||
name: pull-requests
|
||||
description: >
|
||||
Guidance for creating pull requests and handling PR review comments in the
|
||||
Agent Framework repository. Use this when writing a PR description (filling out
|
||||
the PR template) or when responding to and resolving review comments on an
|
||||
existing PR.
|
||||
---
|
||||
|
||||
# Pull Request Workflow
|
||||
|
||||
This skill covers two tasks: (1) writing a high-quality PR description, and
|
||||
(2) handling review comments on an existing PR.
|
||||
|
||||
## 1. Writing the PR description
|
||||
|
||||
Always follow the repository PR template at
|
||||
[`.github/pull_request_template.md`](../../../../.github/pull_request_template.md). Keep its
|
||||
exact structure and headings. Fill every section:
|
||||
|
||||
### `### Motivation & Context`
|
||||
Explain *why* the change is needed: the problem it solves and the scenario it
|
||||
contributes to. Describe the net change relative to `main` — this is implied, so
|
||||
do **not** spell out "vs main" explicitly.
|
||||
|
||||
### `### Description & Review Guide`
|
||||
Describe the changes, the overall approach, and the design. Answer the three
|
||||
prompts:
|
||||
- **What are the major changes?**
|
||||
- **What is the impact of these changes?**
|
||||
- **What do you want reviewers to focus on?** — This item is for **human
|
||||
reviewers only**. Automated/AI reviewers must ignore it and review the entire
|
||||
change rather than narrowing scope to it.
|
||||
|
||||
### `### Related Issue`
|
||||
Link the issue the PR fixes using a GitHub closing keyword (`Fixes #123` /
|
||||
`Closes #123`) so it closes automatically on merge. A PR with no linked issue may
|
||||
be closed regardless of how valid the change is. Before opening, confirm there is
|
||||
no other open PR for the same issue; if there is, explain how this PR differs.
|
||||
|
||||
### `### Contribution Checklist`
|
||||
Check every item that applies. For the breaking-change item:
|
||||
- Leave **"This is not a breaking change."** checked for the common case.
|
||||
- If the change **is** breaking, add the `breaking change` label **or** put
|
||||
`[BREAKING]` in the title prefix, before or after a language prefix such as
|
||||
`Python:` or `.NET:` — workflows keep the label and the title prefix in sync
|
||||
automatically (see `.github/workflows/label-title-prefix.yml` and
|
||||
`.github/workflows/label-pr.yml`).
|
||||
|
||||
### Do not
|
||||
- Do **not** add ad-hoc sections such as "Validation" or "Tests run"; CI/CD and
|
||||
the checklist already cover validation status.
|
||||
- Do **not** remove or reorder the template's headings.
|
||||
|
||||
### Creating the PR
|
||||
Open new PRs as **drafts** until they are ready for review. Example:
|
||||
|
||||
```bash
|
||||
gh pr create --repo microsoft/agent-framework --base main \
|
||||
--head <your-fork-owner>:<branch> --draft \
|
||||
--title "<concise title>" --body "<body following the template>"
|
||||
```
|
||||
|
||||
## 2. Handling review comments
|
||||
|
||||
When a PR receives review comments, follow this sequence — **do not start editing
|
||||
code before the user has reviewed the plan**:
|
||||
|
||||
1. **Review the comments.** Read every review comment and thread on the PR,
|
||||
including inline code comments and general review summaries.
|
||||
2. **Make a plan.** Produce a concrete plan describing how each comment will be
|
||||
addressed (or why it should not be, with reasoning).
|
||||
3. **Let the user review the plan.** Present the plan and wait for the user's
|
||||
approval or adjustments before implementing anything.
|
||||
4. **Implement.** Make the agreed changes.
|
||||
5. **Reply to every comment.** Add a reply to **all** comments explaining how it
|
||||
was addressed (or the agreed outcome) — leave none unanswered.
|
||||
6. **Resolve resolved threads.** Mark a review thread as resolved only when the
|
||||
comment has actually been addressed.
|
||||
|
||||
### Useful commands
|
||||
|
||||
List review comments and threads:
|
||||
|
||||
```bash
|
||||
# Inline review comments
|
||||
gh api repos/{owner}/{repo}/pulls/{pr}/comments
|
||||
|
||||
# Review threads with resolution state (GraphQL)
|
||||
gh api graphql -f query='
|
||||
query($owner:String!,$repo:String!,$pr:Int!){
|
||||
repository(owner:$owner,name:$repo){
|
||||
pullRequest(number:$pr){
|
||||
reviewThreads(first:100){
|
||||
nodes{ id isResolved comments(first:50){ nodes{ id body author{login} } } }
|
||||
}
|
||||
}
|
||||
}
|
||||
}' -F owner={owner} -F repo={repo} -F pr={pr}
|
||||
```
|
||||
|
||||
Reply to an inline review comment:
|
||||
|
||||
```bash
|
||||
gh api repos/{owner}/{repo}/pulls/{pr}/comments/{comment_id}/replies \
|
||||
-f body="Addressed in <commit>: <explanation>"
|
||||
```
|
||||
|
||||
Resolve a review thread (needs the thread node id from the GraphQL query above):
|
||||
|
||||
```bash
|
||||
gh api graphql -f query='
|
||||
mutation($threadId:ID!){
|
||||
resolveReviewThread(input:{threadId:$threadId}){ thread{ isResolved } }
|
||||
}' -F threadId={thread_id}
|
||||
```
|
||||
@@ -120,6 +120,7 @@
|
||||
</Folder>
|
||||
<Folder Name="/Samples/02-agents/Harness/">
|
||||
<File Path="samples/02-agents/Harness/README.md" />
|
||||
<Project Path="samples/02-agents/Harness/BuildYourOwnClaw/Claw_Step01_MeetYourClaw/Claw_Step01_MeetYourClaw.csproj" />
|
||||
<Project Path="samples/02-agents/Harness/ConsoleReactiveComponents/ConsoleReactiveComponents.csproj" />
|
||||
<Project Path="samples/02-agents/Harness/ConsoleReactiveFramework/ConsoleReactiveFramework.csproj" />
|
||||
<Project Path="samples/02-agents/Harness/Harness_Shared_Console/Harness_Shared_Console.csproj" />
|
||||
@@ -217,6 +218,7 @@
|
||||
<Folder Name="/Samples/02-agents/ModelContextProtocol/">
|
||||
<File Path="samples/02-agents/ModelContextProtocol/README.md" />
|
||||
<Project Path="samples/02-agents/ModelContextProtocol/Agent_MCP_LongRunningTask_Client/Agent_MCP_LongRunningTask_Client.csproj" />
|
||||
<Project Path="samples/02-agents/ModelContextProtocol/Agent_MCP_PerRun_AuthHeaders/Agent_MCP_PerRun_AuthHeaders.csproj" />
|
||||
<Project Path="samples/02-agents/ModelContextProtocol/Agent_MCP_Server/Agent_MCP_Server.csproj" />
|
||||
<Project Path="samples/02-agents/ModelContextProtocol/Agent_MCP_Server_Auth/Agent_MCP_Server_Auth.csproj" />
|
||||
<Project Path="samples/02-agents/ModelContextProtocol/FoundryAgent_Hosted_MCP/FoundryAgent_Hosted_MCP.csproj" />
|
||||
|
||||
@@ -1,14 +1,14 @@
|
||||
<Project>
|
||||
<PropertyGroup>
|
||||
<!-- Central version prefix - applies to all nuget packages. -->
|
||||
<VersionPrefix>1.10.0</VersionPrefix>
|
||||
<VersionPrefix>1.11.0</VersionPrefix>
|
||||
<RCNumber>1</RCNumber>
|
||||
<DateSuffix>260610</DateSuffix>
|
||||
<DateSuffix>260623</DateSuffix>
|
||||
<PackageVersion Condition="'$(IsReleaseCandidate)' == 'true'">$(VersionPrefix)-rc$(RCNumber)</PackageVersion>
|
||||
<PackageVersion Condition="'$(IsReleaseCandidate)' != 'true' AND '$(VersionSuffix)' != ''">$(VersionPrefix)-$(VersionSuffix).$(DateSuffix).1</PackageVersion>
|
||||
<PackageVersion Condition="'$(IsReleaseCandidate)' != 'true' AND '$(VersionSuffix)' == ''">$(VersionPrefix)-preview.$(DateSuffix).1</PackageVersion>
|
||||
<PackageVersion Condition="'$(IsReleased)' == 'true'">$(VersionPrefix)</PackageVersion>
|
||||
<GitTag>1.10.0</GitTag>
|
||||
<GitTag>1.11.0</GitTag>
|
||||
|
||||
<Configurations>Debug;Release;Publish</Configurations>
|
||||
<IsPackable>true</IsPackable>
|
||||
|
||||
+22
@@ -0,0 +1,22 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<OutputType>Exe</OutputType>
|
||||
<TargetFrameworks>net10.0</TargetFrameworks>
|
||||
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Azure.Identity" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\..\..\..\src\Microsoft.Agents.AI.Foundry\Microsoft.Agents.AI.Foundry.csproj" />
|
||||
<ProjectReference Include="..\..\..\..\..\src\Microsoft.Agents.AI.Harness\Microsoft.Agents.AI.Harness.csproj" />
|
||||
<ProjectReference Include="..\..\Harness_Shared_Console\Harness_Shared_Console.csproj" />
|
||||
<ProjectReference Include="..\..\Harness_Shared_Console_OpenAI\Harness_Shared_Console_OpenAI.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
+111
@@ -0,0 +1,111 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
// "Meet your agent harness and claw" — Post 1 of the "Build your own claw with Microsoft Agent Framework" series.
|
||||
// See: https://devblogs.microsoft.com/agent-framework/meet-your-agent-harness-and-claw.
|
||||
//
|
||||
// This sample builds the foundation of a personal finance / investing assistant on top of a
|
||||
// HarnessAgent. The harness comes pre-configured with function invocation, per-service-call
|
||||
// history persistence, and planning (TodoProvider + AgentModeProvider), plus web search — so
|
||||
// all we add here is:
|
||||
// 1. Finance-focused instructions.
|
||||
// 2. A custom get_stock_price function tool.
|
||||
//
|
||||
// The agent can plan a multi-step request ("Review my watchlist and recommend some stocks to add"), create a todo list, switch
|
||||
// between plan and execute modes, search the web for market news, and call our stock-price tool.
|
||||
//
|
||||
// Special commands (handled by the shared HarnessConsole):
|
||||
// /todos — Display the current todo list without invoking the agent.
|
||||
// /mode — Get or set the current agent mode.
|
||||
// /exit — End the session.
|
||||
|
||||
#pragma warning disable OPENAI001 // Suppress experimental API warnings for Responses API usage.
|
||||
#pragma warning disable MAAI001 // Suppress experimental API warnings for Agents AI experiments.
|
||||
|
||||
using System.ClientModel.Primitives;
|
||||
using Azure.AI.Projects;
|
||||
using Azure.Identity;
|
||||
using ClawSample;
|
||||
using Harness.Shared.Console;
|
||||
using Harness.Shared.Console.OpenAI;
|
||||
using Harness.Shared.Console.ToolFormatters;
|
||||
using Microsoft.Agents.AI;
|
||||
using Microsoft.Extensions.AI;
|
||||
|
||||
var endpoint = Environment.GetEnvironmentVariable("FOUNDRY_PROJECT_ENDPOINT") ?? throw new InvalidOperationException("FOUNDRY_PROJECT_ENDPOINT is not set.");
|
||||
var deploymentName = Environment.GetEnvironmentVariable("FOUNDRY_MODEL") ?? "gpt-5.4";
|
||||
|
||||
// <instructions>
|
||||
var instructions =
|
||||
"""
|
||||
## Personal Finance Assistant Instructions
|
||||
|
||||
You are a personal finance and investing assistant. You help the user understand their
|
||||
watchlist and the markets. When asked about a stock, look up its current price with the
|
||||
get_stock_price tool, and use web search for recent news, earnings, or analyst commentary.
|
||||
|
||||
### Working style
|
||||
|
||||
- Always verify numbers with a tool rather than relying on memory. Stock prices change.
|
||||
- Cite web sources inline when you use them.
|
||||
- Keep the user's watchlist in a memory file called watchlist.md: read it when reviewing the
|
||||
watchlist, and update it whenever the user adds or removes a ticker.
|
||||
|
||||
### Important
|
||||
|
||||
You provide information and analysis only — you are not a licensed financial advisor and you
|
||||
must not present your output as personalized investment advice. Remind the user to do their
|
||||
own research before making decisions.
|
||||
""";
|
||||
// </instructions>
|
||||
|
||||
// <create_client>
|
||||
// Construct an IChatClient. Here we use a Microsoft Foundry project: the endpoint points at the
|
||||
// project, DefaultAzureCredential handles auth, and the deployment name selects the model.
|
||||
// The harness works with ANY IChatClient — see the AgentProviders samples for OpenAI, Azure
|
||||
// OpenAI, Anthropic, Google Gemini, Ollama, ONNX, and more.
|
||||
IChatClient chatClient =
|
||||
new AIProjectClient(
|
||||
new Uri(endpoint),
|
||||
// WARNING: DefaultAzureCredential is convenient for development but requires careful consideration in production.
|
||||
// In production, consider using a specific credential (e.g., ManagedIdentityCredential) to avoid
|
||||
// latency issues, unintended credential probing, and potential security risks from fallback mechanisms.
|
||||
new DefaultAzureCredential(),
|
||||
new AIProjectClientOptions { RetryPolicy = new ClientRetryPolicy(3) }) // Enable retries to improve resiliency.
|
||||
.GetProjectOpenAIClient()
|
||||
.GetResponsesClient()
|
||||
.AsIChatClient(deploymentName);
|
||||
// </create_client>
|
||||
|
||||
// <create_agent>
|
||||
// Turn the chat client into a HarnessAgent. AsHarnessAgent pre-configures function invocation,
|
||||
// per-service-call chat history persistence, TodoProvider, AgentModeProvider, and web search.
|
||||
// We add finance instructions and our get_stock_price tool.
|
||||
AIAgent agent = chatClient.AsHarnessAgent(new HarnessAgentOptions
|
||||
{
|
||||
ChatOptions = new ChatOptions
|
||||
{
|
||||
Instructions = instructions,
|
||||
Tools = [StockTools.CreateGetStockPriceTool()],
|
||||
Reasoning = new() { Effort = ReasoningEffort.Medium },
|
||||
},
|
||||
});
|
||||
// </create_agent>
|
||||
|
||||
// <run>
|
||||
// Run the interactive console session using the shared HarnessConsole helper.
|
||||
await HarnessConsole.RunAgentAsync(
|
||||
agent,
|
||||
userPrompt: "Ask about a stock or say 'Review my watchlist and recommend some stocks to add' to get started.",
|
||||
new HarnessConsoleOptions
|
||||
{
|
||||
Observers = [
|
||||
new OpenAIResponsesWebSearchDisplayObserver(),
|
||||
new OpenAIResponsesErrorObserver(),
|
||||
.. HarnessConsoleOptions.BuildObserversWithPlanning(
|
||||
agent,
|
||||
planModeName: "plan",
|
||||
executionModeName: "execute",
|
||||
toolFormatters: ToolCallFormatter.BuildDefaultToolFormatters())],
|
||||
CommandHandlers = HarnessConsoleOptions.BuildDefaultCommandHandlers(agent),
|
||||
});
|
||||
// </run>
|
||||
@@ -0,0 +1,52 @@
|
||||
# Meet your claw (Post 1) — .NET
|
||||
|
||||
The first runnable sample from the [**"Build your own agent harness and claw with Microsoft Agent Framework"** blog](https://devblogs.microsoft.com/agent-framework/build-your-own-claw-and-agent-harness-with-microsoft-agent-framework)
|
||||
series. It builds the foundation of a personal finance / investing assistant on top of a
|
||||
`HarnessAgent`.
|
||||
|
||||
## What this sample demonstrates
|
||||
|
||||
- **`AsHarnessAgent`** — turns an `IChatClient` into a batteries-included agent: function
|
||||
invocation, per-service-call history persistence, planning
|
||||
(`TodoProvider` + `AgentModeProvider`), and web search.
|
||||
- **A custom function tool** — `get_stock_price` (see `StockTools.cs`), exposing local data to the
|
||||
agent. Prices are illustrative mock data, not real quotes.
|
||||
- **Web search** — provided automatically by the harness for market news and commentary.
|
||||
- **Planning & modes** — the agent breaks a multi-step request ("Review my watchlist and recommend some stocks to add") into a todo
|
||||
list and switches between *plan* and *execute* modes.
|
||||
- **Shared harness console** — interactive streaming UI with `/todos`, `/mode`, and `/exit`
|
||||
commands.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
1. A Microsoft Foundry project with a deployed model (e.g. `gpt-5.4`).
|
||||
2. Azure CLI installed and authenticated (`az login`).
|
||||
|
||||
## Environment variables
|
||||
|
||||
```bash
|
||||
export FOUNDRY_PROJECT_ENDPOINT="https://your-project.services.ai.azure.com/api/projects/your-project"
|
||||
# Optional (defaults to gpt-5.4)
|
||||
export FOUNDRY_MODEL="gpt-5.4"
|
||||
```
|
||||
|
||||
## Running
|
||||
|
||||
```bash
|
||||
cd dotnet
|
||||
dotnet run --project samples/02-agents/Harness/BuildYourOwnClaw/Claw_Step01_MeetYourClaw
|
||||
```
|
||||
|
||||
## What to expect
|
||||
|
||||
The sample starts an interactive loop. Try these in order:
|
||||
|
||||
1. `/mode execute` — switch out of the default plan mode; quick lookups don't need a plan.
|
||||
2. `What's the price of MSFT?` — the agent calls the `get_stock_price` tool.
|
||||
3. `Any recent news on NVDA?` — the agent uses web search.
|
||||
4. `Add MSFT, NVDA and SPY to my watch list` — saved to `watchlist.md` in the session's memory.
|
||||
5. `/mode plan` — switch back to plan mode for a bigger, multi-step task.
|
||||
6. `Review my watchlist and recommend some stocks to add` — the agent plans, then executes. Type
|
||||
`/todos` to see the list and `/mode` to inspect the current mode.
|
||||
|
||||
Output is colored by mode: **cyan** during planning, **green** during execution.
|
||||
+61
@@ -0,0 +1,61 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System.ComponentModel;
|
||||
using Microsoft.Extensions.AI;
|
||||
|
||||
namespace ClawSample;
|
||||
|
||||
/// <summary>
|
||||
/// A custom function tool that gives our "claw" access to (illustrative) stock prices.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// The prices returned here are mock data for demonstration purposes only and are not real
|
||||
/// market quotes. In a real assistant you would call a market-data API instead.
|
||||
/// </remarks>
|
||||
internal static class StockTools
|
||||
{
|
||||
// <stock_quote>
|
||||
/// <summary>A delayed, illustrative stock quote.</summary>
|
||||
public sealed record StockQuote(string Symbol, decimal Price, string Currency, DateTimeOffset AsOf);
|
||||
// </stock_quote>
|
||||
|
||||
// A tiny in-memory price book so the sample runs without any external dependency.
|
||||
private static readonly Dictionary<string, decimal> s_priceBook = new(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
["MSFT"] = 462.97m,
|
||||
["AAPL"] = 229.35m,
|
||||
["GOOGL"] = 178.12m,
|
||||
["AMZN"] = 201.45m,
|
||||
["NVDA"] = 134.81m,
|
||||
};
|
||||
|
||||
// <get_stock_price>
|
||||
/// <summary>
|
||||
/// Gets the latest (delayed, illustrative) stock price for a ticker symbol.
|
||||
/// </summary>
|
||||
/// <param name="symbol">The stock ticker symbol, e.g. <c>MSFT</c> or <c>AAPL</c>.</param>
|
||||
[Description("Gets the latest (delayed, illustrative) stock price for a ticker symbol.")]
|
||||
public static StockQuote GetStockPrice(
|
||||
[Description("The stock ticker symbol, e.g. MSFT or AAPL.")] string symbol)
|
||||
{
|
||||
if (!s_priceBook.TryGetValue(symbol, out var price))
|
||||
{
|
||||
// Deterministic pseudo-price for unknown symbols so the sample stays self-contained.
|
||||
// Derive a stable seed from the characters — string.GetHashCode() is randomized per
|
||||
// process and Math.Abs(int.MinValue) throws, so neither is safe for repeatable output.
|
||||
var seed = 0;
|
||||
foreach (var ch in symbol.ToUpperInvariant())
|
||||
{
|
||||
seed = (seed * 31 + ch) % 1_000_000;
|
||||
}
|
||||
|
||||
price = 50m + seed % 45000 / 100m;
|
||||
}
|
||||
|
||||
return new StockQuote(symbol.ToUpperInvariant(), price, "USD", DateTimeOffset.UtcNow);
|
||||
}
|
||||
// </get_stock_price>
|
||||
|
||||
/// <summary>Creates the <see cref="AIFunction"/> wrapper used to expose the tool to the agent.</summary>
|
||||
public static AIFunction CreateGetStockPriceTool() => AIFunctionFactory.Create(GetStockPrice, "get_stock_price");
|
||||
}
|
||||
@@ -64,21 +64,15 @@ public sealed class HarnessAgentRunner : IDisposable
|
||||
|
||||
/// <summary>
|
||||
/// Replaces the current session with the specified session. Used by the UX driver
|
||||
/// when importing a serialized session. Acquires the input gate to ensure no
|
||||
/// concurrent agent turn is reading the session.
|
||||
/// when importing a serialized session. This method is always called from within
|
||||
/// a command handler (which already holds the input gate), so no additional
|
||||
/// synchronization is needed.
|
||||
/// </summary>
|
||||
/// <param name="newSession">The new session to use.</param>
|
||||
internal async Task ReplaceSessionAsync(AgentSession newSession)
|
||||
internal Task ReplaceSessionAsync(AgentSession newSession)
|
||||
{
|
||||
await this._inputGate.WaitAsync().ConfigureAwait(false);
|
||||
try
|
||||
{
|
||||
this._session = newSession;
|
||||
}
|
||||
finally
|
||||
{
|
||||
this._inputGate.Release();
|
||||
}
|
||||
this._session = newSession;
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
|
||||
@@ -10,3 +10,11 @@ Samples demonstrating the [Harness AIContextProviders](../../../src/Microsoft.Ag
|
||||
| [Harness_Step02_Research_WithBackgroundAgents](./Harness_Step02_Research_WithBackgroundAgents/README.md) | Using BackgroundAgentsProvider to delegate stock price lookups to a web-search background agent concurrently |
|
||||
| [Harness_Step03_DataProcessing](./Harness_Step03_DataProcessing/README.md) | Using FileAccessProvider to give an agent access to CSV data files for reading, analysis, and output generation |
|
||||
| [Harness_Step05_Loop](./Harness_Step05_Loop/README.md) | Wrapping a HarnessAgent with the LoopAgent decorator to re-invoke it until a configured LoopEvaluator (completion marker, predicate, AI judge, or approval-aware loop) decides to stop |
|
||||
|
||||
## Build your own claw blog series
|
||||
|
||||
Samples accompanying the [*Build your own agent harness or claw with Microsoft Agent Framework*](https://devblogs.microsoft.com/agent-framework/build-your-own-claw-and-agent-harness-with-microsoft-agent-framework) blog series, which builds a personal finance assistant step by step.
|
||||
|
||||
| Sample | Description |
|
||||
| --- | --- |
|
||||
| [Claw_Step01_MeetYourClaw](./BuildYourOwnClaw/Claw_Step01_MeetYourClaw/README.md) | Post 1 — a minimal HarnessAgent with a custom `get_stock_price` tool, web search, and planning |
|
||||
|
||||
+21
@@ -0,0 +1,21 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<OutputType>Exe</OutputType>
|
||||
<TargetFrameworks>net10.0</TargetFrameworks>
|
||||
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Azure.Identity" />
|
||||
<PackageReference Include="ModelContextProtocol" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\..\..\src\Microsoft.Agents.AI\Microsoft.Agents.AI.csproj" />
|
||||
<ProjectReference Include="..\..\..\..\src\Microsoft.Agents.AI.Foundry\Microsoft.Agents.AI.Foundry.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
+136
@@ -0,0 +1,136 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
// This sample shows how to attach per-run (refreshable) authentication headers to MCP requests.
|
||||
//
|
||||
// The agent connects to an MCP server with a custom HttpClient. A DelegatingHandler reads a token
|
||||
// for the current run from an AsyncLocal scope and stamps it on each outbound MCP request, so a
|
||||
// short-lived token (for example an OBO or cloud identity token that expires) can be refreshed on
|
||||
// every run without rebuilding the agent or the MCP connection.
|
||||
//
|
||||
// The agent backend is Microsoft Foundry via the Responses API (RAPI). The MCP server is the public
|
||||
// Microsoft Learn MCP server, which ignores the demonstration token; in production you point the
|
||||
// handler at your own protected MCP server and mint a real token per run.
|
||||
|
||||
using System.Net.Http.Headers;
|
||||
using Azure.AI.Projects;
|
||||
using Azure.Identity;
|
||||
using Microsoft.Agents.AI;
|
||||
using Microsoft.Extensions.AI;
|
||||
using ModelContextProtocol.Client;
|
||||
|
||||
var projectEndpoint = new Uri(Environment.GetEnvironmentVariable("FOUNDRY_PROJECT_ENDPOINT")
|
||||
?? throw new InvalidOperationException("FOUNDRY_PROJECT_ENDPOINT is not set."));
|
||||
var deploymentName = Environment.GetEnvironmentVariable("FOUNDRY_MODEL") ?? "gpt-5.4-mini";
|
||||
|
||||
var serverEndpoint = new Uri("https://learn.microsoft.com/api/mcp");
|
||||
|
||||
// Custom HttpClient for the MCP transport. The per-run handler attaches the bearer; the inner
|
||||
// handler disables cookies (no cross-context state), disables auto-redirect (so a redirect cannot
|
||||
// carry the bearer past the origin re-check), and checks certificate revocation.
|
||||
using var httpClient = new HttpClient(new PerRunAuthHeaderHandler(serverEndpoint)
|
||||
{
|
||||
InnerHandler = new HttpClientHandler
|
||||
{
|
||||
UseCookies = false,
|
||||
AllowAutoRedirect = false,
|
||||
CheckCertificateRevocationList = true,
|
||||
},
|
||||
});
|
||||
|
||||
Console.WriteLine($"Connecting to MCP server at {serverEndpoint} ...");
|
||||
|
||||
await using var mcpClient = await McpClient.CreateAsync(new HttpClientTransport(new()
|
||||
{
|
||||
Endpoint = serverEndpoint,
|
||||
Name = "Microsoft Learn MCP",
|
||||
TransportMode = HttpTransportMode.StreamableHttp,
|
||||
}, httpClient));
|
||||
|
||||
IList<McpClientTool> mcpTools = await mcpClient.ListToolsAsync();
|
||||
Console.WriteLine($"MCP tools available: {string.Join(", ", mcpTools.Select(t => t.Name))}");
|
||||
|
||||
// Build the agent from Microsoft Foundry using the Responses API (RAPI).
|
||||
// WARNING: DefaultAzureCredential is convenient for development but requires careful consideration in production.
|
||||
// In production, consider using a specific credential (e.g., ManagedIdentityCredential) to avoid
|
||||
// latency issues, unintended credential probing, and potential security risks from fallback mechanisms.
|
||||
AIAgent agent = new AIProjectClient(projectEndpoint, new DefaultAzureCredential())
|
||||
.AsAIAgent(
|
||||
model: deploymentName,
|
||||
instructions: "You answer Microsoft documentation questions using the available tools.",
|
||||
name: "DocsAgent",
|
||||
tools: [.. mcpTools.Cast<AITool>()]);
|
||||
|
||||
// Run the same agent twice under two different contexts. Each run gets a freshly minted token,
|
||||
// proving the auth header is per-run rather than bound when the agent or MCP connection was created.
|
||||
await RunForContextAsync(agent, "tenant-a", "How do I create an Azure storage account with az cli?");
|
||||
await RunForContextAsync(agent, "tenant-b", "What is Azure Functions?");
|
||||
|
||||
static async Task RunForContextAsync(AIAgent agent, string label, string prompt)
|
||||
{
|
||||
// Stand-in for a real per-run token (for example an OBO or cloud identity token).
|
||||
// It carries no PII and is regenerated on every run. The label is non-secret and used for logging.
|
||||
McpRunContext? previous = McpRunScope.Current;
|
||||
McpRunScope.Current = new McpRunContext(label, $"{label}.{Guid.NewGuid():N}");
|
||||
try
|
||||
{
|
||||
Console.WriteLine($"\n=== Run for '{label}' (fresh per-run token) ===");
|
||||
Console.WriteLine(await agent.RunAsync(prompt));
|
||||
}
|
||||
finally
|
||||
{
|
||||
// Restore the prior scope (stack-like) so this is safe to call from within an outer scope.
|
||||
McpRunScope.Current = previous;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Carries the context for the current run. <see cref="Label"/> is a non-secret identifier safe to
|
||||
/// log; <see cref="Token"/> is the secret that must never be logged or persisted.
|
||||
/// </summary>
|
||||
internal sealed record McpRunContext(string Label, string Token);
|
||||
|
||||
/// <summary>
|
||||
/// Flows the current <see cref="McpRunContext"/> to the MCP <see cref="DelegatingHandler"/> without
|
||||
/// threading it through every call. Set it before a run and reset it afterwards.
|
||||
/// </summary>
|
||||
internal static class McpRunScope
|
||||
{
|
||||
private static readonly AsyncLocal<McpRunContext?> s_current = new();
|
||||
|
||||
public static McpRunContext? Current
|
||||
{
|
||||
get => s_current.Value;
|
||||
set => s_current.Value = value;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Attaches the current run's bearer token to outbound MCP requests. The token is read fresh on
|
||||
/// every request, so refreshing it between runs needs no agent or connection rebuild.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Security: the bearer is attached only over HTTPS and only when the request targets the configured
|
||||
/// MCP server origin, which prevents the credential from leaking over plaintext or to a redirect
|
||||
/// target on another origin. Only the non-secret label is logged, never the token.
|
||||
/// </remarks>
|
||||
internal sealed class PerRunAuthHeaderHandler(Uri serverEndpoint) : DelegatingHandler
|
||||
{
|
||||
private readonly string _serverOrigin = serverEndpoint.GetLeftPart(UriPartial.Authority);
|
||||
|
||||
protected override async Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
|
||||
{
|
||||
McpRunContext? context = McpRunScope.Current;
|
||||
Uri? requestUri = request.RequestUri;
|
||||
|
||||
if (context is not null
|
||||
&& requestUri is not null
|
||||
&& requestUri.Scheme == Uri.UriSchemeHttps
|
||||
&& string.Equals(requestUri.GetLeftPart(UriPartial.Authority), this._serverOrigin, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", context.Token);
|
||||
Console.WriteLine($"[mcp-auth] attached bearer for '{context.Label}' -> {request.Method} {requestUri.AbsolutePath}");
|
||||
}
|
||||
|
||||
return await base.SendAsync(request, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
# Per-Run MCP Authentication Headers
|
||||
|
||||
This sample shows how to attach per-run (refreshable) authentication headers to Model Context
|
||||
Protocol (MCP) requests using existing Agent Framework primitives. It addresses scenarios where the
|
||||
header value changes from one run to the next, for example a short-lived On-Behalf-Of (OBO) or cloud
|
||||
identity token that expires and must be refreshed.
|
||||
|
||||
The agent backend is Microsoft Foundry accessed through the Responses API (RAPI). The MCP server is
|
||||
the public Microsoft Learn MCP server.
|
||||
|
||||
## What this sample demonstrates
|
||||
|
||||
- A custom `HttpClient` on the MCP transport whose `DelegatingHandler` stamps an `Authorization`
|
||||
header on every outbound MCP request.
|
||||
- An `AsyncLocal` scope (`McpRunScope`) that carries the current run's context to the handler, set
|
||||
immediately before each run and cleared in a `finally` block.
|
||||
- Running the same agent twice under two different contexts, each with a freshly minted token, so the
|
||||
header is per-run rather than fixed when the agent or the MCP connection was created.
|
||||
|
||||
Because the handler reads the token fresh on every request, an expiring token is refreshed simply by
|
||||
placing a new value in scope before the next run. No agent or connection rebuild is required.
|
||||
|
||||
## How it works
|
||||
|
||||
```text
|
||||
RunForContextAsync sets McpRunScope.Current
|
||||
-> agent.RunAsync invokes an MCP tool
|
||||
-> PerRunAuthHeaderHandler reads McpRunScope.Current
|
||||
-> stamps Authorization: Bearer <token> on the MCP request
|
||||
RunForContextAsync clears McpRunScope.Current in finally
|
||||
```
|
||||
|
||||
The public Microsoft Learn MCP server is anonymous and ignores the demonstration token. In production
|
||||
you point the handler at your own protected MCP server and mint a real token per run.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- .NET 10 SDK or later
|
||||
- A Microsoft Foundry project endpoint and a model deployment
|
||||
- An authenticated Azure identity (for example, sign in with `az login`)
|
||||
|
||||
Set the following environment variables:
|
||||
|
||||
```powershell
|
||||
$env:FOUNDRY_PROJECT_ENDPOINT="https://your-foundry-service.services.ai.azure.com/api/projects/your-foundry-project"
|
||||
$env:FOUNDRY_MODEL="gpt-5.4-mini"
|
||||
```
|
||||
|
||||
## Run the sample
|
||||
|
||||
```powershell
|
||||
dotnet run
|
||||
```
|
||||
|
||||
## Security considerations
|
||||
|
||||
This sample is written to demonstrate the pattern safely. When you adapt it, keep these in place:
|
||||
|
||||
- **Never log the token.** Only the non-secret label is printed. Avoid printing the token even in a
|
||||
masked form.
|
||||
- **Attach the header over HTTPS only.** The handler skips the header when the request is not HTTPS,
|
||||
so a credential is never sent over plaintext.
|
||||
- **Scope the header to the MCP server origin.** The handler attaches the header only when the
|
||||
request targets the configured server origin (scheme, host, and port). Auto-redirect is also
|
||||
disabled (`AllowAutoRedirect = false`) so a redirect cannot carry the token to another origin
|
||||
below the handler before the origin check runs.
|
||||
- **Reset the scope after each run.** `McpRunScope.Current` is restored to its prior value in a
|
||||
`finally` block so a token does not bleed into later, unrelated work and nesting stays safe.
|
||||
- **Disable cookies on the shared handler.** `UseCookies = false` avoids cross-context state on a
|
||||
shared client, and `CheckCertificateRevocationList = true` validates the server certificate.
|
||||
- **Use non-identifying labels and tokens.** The labels and tokens here carry no personal data and are
|
||||
regenerated per run.
|
||||
- **Do not persist secrets in serialized session state.** Agent session state is serializable, so keep
|
||||
raw tokens in memory or mint them per run rather than storing them there.
|
||||
|
||||
## Production notes
|
||||
|
||||
- Replace the demonstration token with a real per-request exchange inside the handler, for example an
|
||||
Azure `TokenCredential`, MSAL OBO flow, or a cloud identity token. Performing the exchange per
|
||||
request lets expiry self-heal because each request obtains a current token.
|
||||
- The `AsyncLocal` scope isolates concurrent runs from each other, so parallel runs with different
|
||||
tokens do not interfere.
|
||||
- As an alternative carrier, the token can be read from `AgentSession` state by an `AIContextProvider`
|
||||
that copies it into the scope at the start of each invocation. Remember the serialized-state warning
|
||||
above and avoid persisting the raw secret.
|
||||
- For MCP servers that implement standard OAuth, `HttpClientTransportOptions.OAuth` already handles the
|
||||
authorization and refresh flow, so a custom handler is unnecessary.
|
||||
- This sample attaches the same header for every tool call in a run. Selecting different headers based
|
||||
on the specific tool or its arguments is intentionally out of scope here.
|
||||
@@ -21,6 +21,7 @@ Before you begin, ensure you have the following prerequisites:
|
||||
|---|---|
|
||||
|[Agent with MCP server tools](./Agent_MCP_Server/)|This sample demonstrates how to use MCP server tools with a simple agent|
|
||||
|[Agent with MCP server tools and authorization](./Agent_MCP_Server_Auth/)|This sample demonstrates how to use MCP Server tools from a protected MCP server with a simple agent|
|
||||
|[Agent with per-run MCP authentication headers](./Agent_MCP_PerRun_AuthHeaders/)|This sample demonstrates how to attach per-run, refreshable authentication headers to MCP requests using a custom HttpClient handler and an AsyncLocal scope. Uses Microsoft Foundry (`FOUNDRY_PROJECT_ENDPOINT` / `FOUNDRY_MODEL`) rather than the Azure OpenAI variables in the prerequisites above.|
|
||||
|[Responses Agent with Hosted MCP tool](./ResponseAgent_Hosted_MCP/)|This sample demonstrates how to use the Hosted MCP tool with the Responses Service, where the service invokes any MCP tools directly|
|
||||
|[Agent with long-running MCP task (transparent polling)](./Agent_MCP_LongRunningTask_Client/)|This sample demonstrates how an agent transparently drives a long-running MCP task (SEP-2663) to completion. The wrapper polls the task internally on both `RunAsync` and `RunStreamingAsync` invocations.|
|
||||
|
||||
|
||||
@@ -101,10 +101,15 @@ else
|
||||
throw new ArgumentException("Either A2AServer:ApiKey or A2AServer:ConnectionString & agentName must be provided");
|
||||
}
|
||||
|
||||
// When running in production, make sure to use an SessionIsolationKeyProvider, e.g. ClaimsIdentity-based
|
||||
// if using Claims-based Identity for Authentication/Authorization
|
||||
// IMPORTANT: In production, register a SessionIsolationKeyProvider to isolate sessions by authenticated caller.
|
||||
// Without this, contextId alone is the session key — any caller who knows a contextId can access that session.
|
||||
// Example using claims-based identity:
|
||||
// builder.Services.UseClaimsBasedSessionIsolation(new() { ClaimType = ClaimTypes.NameIdentifier });
|
||||
|
||||
// By default, NoopAgentSessionStore is used — sessions are not persisted across requests.
|
||||
// To enable multi-turn conversations, register a session store explicitly, e.g.:
|
||||
// builder.Services.AddKeyedSingleton<AgentSessionStore>(hostA2AAgent.Name, new InMemoryAgentSessionStore());
|
||||
|
||||
builder.AddA2AServer(hostA2AAgent);
|
||||
|
||||
var app = builder.Build();
|
||||
|
||||
@@ -28,10 +28,15 @@ builder.AddDevUI();
|
||||
builder.AddOpenAIChatCompletions();
|
||||
builder.AddOpenAIResponses();
|
||||
|
||||
// When running in production, make sure to use an SessionIsolationKeyProvider, e.g. ClaimsIdentity-based
|
||||
// if using Claims-based Identity for Authentication/Authorization
|
||||
// IMPORTANT: In production, register a SessionIsolationKeyProvider to isolate sessions by authenticated caller.
|
||||
// Without this, contextId alone is the session key — any caller who knows a contextId can access that session.
|
||||
// Example using claims-based identity:
|
||||
// builder.Services.UseClaimsBasedSessionIsolation(new() { ClaimType = ClaimTypes.NameIdentifier });
|
||||
|
||||
// By default, NoopAgentSessionStore is used — sessions are not persisted across requests.
|
||||
// To enable multi-turn conversations, register a session store explicitly, e.g.:
|
||||
// agentBuilder.WithInMemorySessionStore();
|
||||
|
||||
var pirateAgentBuilder = builder.AddAIAgent(
|
||||
"pirate",
|
||||
instructions: "You are a pirate. Speak like a pirate",
|
||||
@@ -152,8 +157,9 @@ builder.Services.AddKeyedSingleton<AIAgent>("my-di-matchingname-agent", (sp, nam
|
||||
pirateAgentBuilder.AddA2AServer();
|
||||
knightsKnavesAgentBuilder.AddA2AServer();
|
||||
|
||||
// When running in production, make sure to use an SessionIsolationKeyProvider, e.g. ClaimsIdentity-based
|
||||
// if using Claims-based Identity for Authentication/Authorization
|
||||
// IMPORTANT: In production, register a SessionIsolationKeyProvider to isolate sessions by authenticated caller.
|
||||
// Without this, contextId alone is the session key — any caller who knows a contextId can access that session.
|
||||
// Example using claims-based identity:
|
||||
// builder.Services.UseClaimsBasedSessionIsolation(new() { ClaimType = ClaimTypes.NameIdentifier });
|
||||
|
||||
var app = builder.Build();
|
||||
|
||||
@@ -6,6 +6,7 @@ using System.IO;
|
||||
using System.Linq;
|
||||
using System.Runtime.CompilerServices;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization.Metadata;
|
||||
using System.Threading;
|
||||
using System.Threading.Channels;
|
||||
using System.Threading.Tasks;
|
||||
@@ -29,6 +30,7 @@ public sealed class GitHubCopilotAgent : AIAgent, IAsyncDisposable
|
||||
private readonly string _description;
|
||||
private readonly SessionConfig? _sessionConfig;
|
||||
private readonly bool _ownsClient;
|
||||
private readonly JsonSerializerOptions _jsonSerializerOptions;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="GitHubCopilotAgent"/> class.
|
||||
@@ -39,13 +41,15 @@ public sealed class GitHubCopilotAgent : AIAgent, IAsyncDisposable
|
||||
/// <param name="id">The unique identifier for the agent.</param>
|
||||
/// <param name="name">The name of the agent.</param>
|
||||
/// <param name="description">The description of the agent.</param>
|
||||
/// <param name="jsonSerializerOptions">Optional JSON serializer options. Defaults to <see cref="GitHubCopilotJsonUtilities.DefaultOptions"/>.</param>
|
||||
public GitHubCopilotAgent(
|
||||
CopilotClient copilotClient,
|
||||
SessionConfig? sessionConfig = null,
|
||||
bool ownsClient = false,
|
||||
string? id = null,
|
||||
string? name = null,
|
||||
string? description = null)
|
||||
string? description = null,
|
||||
JsonSerializerOptions? jsonSerializerOptions = null)
|
||||
{
|
||||
_ = Throw.IfNull(copilotClient);
|
||||
|
||||
@@ -55,6 +59,7 @@ public sealed class GitHubCopilotAgent : AIAgent, IAsyncDisposable
|
||||
this._id = id;
|
||||
this._name = name ?? DefaultName;
|
||||
this._description = description ?? DefaultDescription;
|
||||
this._jsonSerializerOptions = jsonSerializerOptions ?? GitHubCopilotJsonUtilities.DefaultOptions;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -67,6 +72,7 @@ public sealed class GitHubCopilotAgent : AIAgent, IAsyncDisposable
|
||||
/// <param name="description">The description of the agent.</param>
|
||||
/// <param name="tools">The tools to make available to the agent.</param>
|
||||
/// <param name="instructions">Optional instructions to append as a system message.</param>
|
||||
/// <param name="jsonSerializerOptions">Optional JSON serializer options. Defaults to <see cref="GitHubCopilotJsonUtilities.DefaultOptions"/>.</param>
|
||||
public GitHubCopilotAgent(
|
||||
CopilotClient copilotClient,
|
||||
bool ownsClient = false,
|
||||
@@ -74,14 +80,16 @@ public sealed class GitHubCopilotAgent : AIAgent, IAsyncDisposable
|
||||
string? name = null,
|
||||
string? description = null,
|
||||
IList<AITool>? tools = null,
|
||||
string? instructions = null)
|
||||
string? instructions = null,
|
||||
JsonSerializerOptions? jsonSerializerOptions = null)
|
||||
: this(
|
||||
copilotClient,
|
||||
GetSessionConfig(tools, instructions),
|
||||
ownsClient,
|
||||
id,
|
||||
name,
|
||||
description)
|
||||
description,
|
||||
jsonSerializerOptions)
|
||||
{
|
||||
}
|
||||
|
||||
@@ -182,6 +190,14 @@ public sealed class GitHubCopilotAgent : AIAgent, IAsyncDisposable
|
||||
channel.Writer.TryWrite(this.ConvertToAgentResponseUpdate(assistantMessage, isStreaming));
|
||||
break;
|
||||
|
||||
case ToolExecutionStartEvent toolStart:
|
||||
channel.Writer.TryWrite(this.ConvertToAgentResponseUpdate(toolStart));
|
||||
break;
|
||||
|
||||
case ToolExecutionCompleteEvent toolComplete:
|
||||
channel.Writer.TryWrite(this.ConvertToAgentResponseUpdate(toolComplete));
|
||||
break;
|
||||
|
||||
case AssistantUsageEvent usageEvent:
|
||||
channel.Writer.TryWrite(this.ConvertToAgentResponseUpdate(usageEvent));
|
||||
break;
|
||||
@@ -349,6 +365,79 @@ public sealed class GitHubCopilotAgent : AIAgent, IAsyncDisposable
|
||||
};
|
||||
}
|
||||
|
||||
internal AgentResponseUpdate ConvertToAgentResponseUpdate(ToolExecutionStartEvent toolStart)
|
||||
{
|
||||
IDictionary<string, object?>? arguments = this.ParseArguments(toolStart.Data?.Arguments);
|
||||
|
||||
FunctionCallContent content = new(
|
||||
toolStart.Data?.ToolCallId ?? string.Empty,
|
||||
toolStart.Data?.ToolName ?? string.Empty,
|
||||
arguments)
|
||||
{
|
||||
RawRepresentation = toolStart
|
||||
};
|
||||
|
||||
return new AgentResponseUpdate(ChatRole.Assistant, [content])
|
||||
{
|
||||
AgentId = this.Id,
|
||||
CreatedAt = toolStart.Timestamp
|
||||
};
|
||||
}
|
||||
|
||||
internal AgentResponseUpdate ConvertToAgentResponseUpdate(ToolExecutionCompleteEvent toolComplete)
|
||||
{
|
||||
object? result = toolComplete.Data?.Success == true
|
||||
? toolComplete.Data?.Result?.Content
|
||||
: toolComplete.Data?.Error?.Message ?? "Tool execution failed";
|
||||
|
||||
FunctionResultContent content = new(
|
||||
toolComplete.Data?.ToolCallId ?? string.Empty,
|
||||
result)
|
||||
{
|
||||
RawRepresentation = toolComplete
|
||||
};
|
||||
|
||||
return new AgentResponseUpdate(ChatRole.Tool, [content])
|
||||
{
|
||||
AgentId = this.Id,
|
||||
CreatedAt = toolComplete.Timestamp
|
||||
};
|
||||
}
|
||||
|
||||
private IDictionary<string, object?>? ParseArguments(object? arguments)
|
||||
{
|
||||
if (arguments is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
if (arguments is JsonElement jsonElement)
|
||||
{
|
||||
if (jsonElement.ValueKind == JsonValueKind.Null || jsonElement.ValueKind == JsonValueKind.Undefined)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var typeInfo = (JsonTypeInfo<Dictionary<string, object?>>)this._jsonSerializerOptions.GetTypeInfo(typeof(Dictionary<string, object?>));
|
||||
|
||||
try
|
||||
{
|
||||
return JsonSerializer.Deserialize(jsonElement.GetRawText(), typeInfo);
|
||||
}
|
||||
catch (JsonException)
|
||||
{
|
||||
return new Dictionary<string, object?> { ["value"] = jsonElement.ToString() };
|
||||
}
|
||||
}
|
||||
|
||||
if (arguments is IDictionary<string, object?> dict)
|
||||
{
|
||||
return dict;
|
||||
}
|
||||
|
||||
return new Dictionary<string, object?> { ["value"] = arguments.ToString() };
|
||||
}
|
||||
|
||||
private AgentResponseUpdate ConvertToAgentResponseUpdate(AssistantUsageEvent usageEvent)
|
||||
{
|
||||
UsageDetails usageDetails = new()
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System.Collections.Generic;
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
using System.Text.Encodings.Web;
|
||||
using System.Text.Json;
|
||||
@@ -43,6 +44,7 @@ internal static partial class GitHubCopilotJsonUtilities
|
||||
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
|
||||
NumberHandling = JsonNumberHandling.AllowReadingFromString)]
|
||||
[JsonSerializable(typeof(GitHubCopilotAgentSession))]
|
||||
[JsonSerializable(typeof(Dictionary<string, object?>))]
|
||||
[ExcludeFromCodeCoverage]
|
||||
private sealed partial class JsonContext : JsonSerializerContext;
|
||||
}
|
||||
|
||||
@@ -189,7 +189,7 @@ public static class A2AServerServiceCollectionExtensions
|
||||
var isolationKeyProvider = serviceProvider.GetService<SessionIsolationKeyProvider>();
|
||||
if (agentSessionStore?.GetService<IsolationKeyScopedAgentSessionStore>() is null)
|
||||
{
|
||||
agentSessionStore ??= new InMemoryAgentSessionStore();
|
||||
agentSessionStore ??= new NoopAgentSessionStore();
|
||||
agentSessionStore = new IsolationKeyScopedAgentSessionStore(agentSessionStore, isolationKeyProvider, new() { Strict = isolationKeyProvider != null });
|
||||
}
|
||||
|
||||
|
||||
-7
@@ -14,7 +14,6 @@ using Microsoft.Agents.AI.Workflows.Declarative.Kit;
|
||||
using Microsoft.Agents.AI.Workflows.Declarative.PowerFx;
|
||||
using Microsoft.Agents.ObjectModel;
|
||||
using Microsoft.Extensions.AI;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Shared.Diagnostics;
|
||||
|
||||
namespace Microsoft.Agents.AI.Workflows.Declarative.ObjectModel;
|
||||
@@ -168,9 +167,6 @@ internal sealed class InvokeFunctionToolExecutor(
|
||||
{
|
||||
if (!this._approvalSnapshots.ContainsKey(approval.RequestId))
|
||||
{
|
||||
this.Logger.LogWarning(
|
||||
"Approval response '{RequestId}' did not match any pending invocation on '{ActionId}'.",
|
||||
approval.RequestId, this.Id);
|
||||
await this.AssignErrorAsync(context, "No pending approval matched the response.").ConfigureAwait(false);
|
||||
}
|
||||
else if (!approval.Approved)
|
||||
@@ -184,9 +180,6 @@ internal sealed class InvokeFunctionToolExecutor(
|
||||
}
|
||||
else
|
||||
{
|
||||
this.Logger.LogWarning(
|
||||
"Approval response '{RequestId}' had no remaining pending snapshot on '{ActionId}'.",
|
||||
approval.RequestId, this.Id);
|
||||
await this.AssignErrorAsync(context, "No pending approval matched the response.").ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System;
|
||||
using System.IO;
|
||||
using System.Reflection;
|
||||
using System.Text.Json.Serialization;
|
||||
using System.Text.RegularExpressions;
|
||||
using Microsoft.Shared.Diagnostics;
|
||||
|
||||
namespace Microsoft.Agents.AI.Workflows.Checkpointing;
|
||||
@@ -11,7 +14,7 @@ namespace Microsoft.Agents.AI.Workflows.Checkpointing;
|
||||
/// </summary>
|
||||
public sealed class TypeId : IEquatable<TypeId>
|
||||
{
|
||||
/// <inheritdoc cref="System.Reflection.Assembly.FullName"/>
|
||||
/// <inheritdoc cref="Assembly.FullName"/>
|
||||
public string AssemblyName { get; }
|
||||
|
||||
/// <inheritdoc cref="Type.FullName"/>
|
||||
@@ -46,6 +49,11 @@ public sealed class TypeId : IEquatable<TypeId>
|
||||
=> this.Equals(obj as TypeId);
|
||||
|
||||
/// <inheritdoc />
|
||||
/// <remarks>
|
||||
/// Compares the type full name and the simple assembly name. Version, culture, and public key
|
||||
/// token are ignored both in <see cref="AssemblyName"/> and in any assembly-qualified generic
|
||||
/// arguments embedded in <see cref="TypeName"/>.
|
||||
/// </remarks>
|
||||
public bool Equals(TypeId? other)
|
||||
{
|
||||
if (other is null)
|
||||
@@ -58,11 +66,27 @@ public sealed class TypeId : IEquatable<TypeId>
|
||||
return true;
|
||||
}
|
||||
|
||||
return this.AssemblyName == other.AssemblyName && this.TypeName == other.TypeName;
|
||||
if (this.NormalizedTypeName != other.NormalizedTypeName)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (string.Equals(this.AssemblyName, other.AssemblyName, StringComparison.Ordinal))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
string? thisSimpleName = this.SimpleAssemblyName;
|
||||
string? otherSimpleName = other.SimpleAssemblyName;
|
||||
|
||||
return thisSimpleName is not null
|
||||
&& string.Equals(thisSimpleName, otherSimpleName, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public override int GetHashCode() => HashCode.Combine(this.AssemblyName, this.TypeName);
|
||||
/// <remarks>Hashes the normalized type name and the simple assembly name.</remarks>
|
||||
public override int GetHashCode()
|
||||
=> HashCode.Combine(this.SimpleAssemblyName, this.NormalizedTypeName);
|
||||
|
||||
/// <inheritdoc />
|
||||
public static bool operator ==(TypeId? left, TypeId? right) => left is null ? right is null : left.Equals(right);
|
||||
@@ -73,13 +97,27 @@ public sealed class TypeId : IEquatable<TypeId>
|
||||
/// <summary>
|
||||
/// Determines whether the specified type matches both the assembly name and type name represented by this instance.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Compares the type full name and the simple assembly name. Version, culture, and public key
|
||||
/// token are ignored both in <see cref="AssemblyName"/> and in any assembly-qualified generic
|
||||
/// arguments embedded in <see cref="TypeName"/>.
|
||||
/// </remarks>
|
||||
/// <param name="type">The type to compare against the stored assembly and type names. Cannot be null.</param>
|
||||
/// <returns>true if the specified type's assembly and type names are equal to those stored in this instance; otherwise,
|
||||
/// false.</returns>
|
||||
/// <returns>true if the specified type's assembly simple name and normalized type full name are equal to those stored
|
||||
/// in this instance; otherwise, false.</returns>
|
||||
public bool IsMatch(Type type)
|
||||
{
|
||||
return this.AssemblyName == type.Assembly.FullName
|
||||
&& this.TypeName == type.FullName;
|
||||
string? runtimeNormalizedTypeName = type.FullName is null ? null : NormalizeTypeName(type.FullName);
|
||||
if (this.NormalizedTypeName != runtimeNormalizedTypeName)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
string? storedSimpleName = this.SimpleAssemblyName;
|
||||
string? runtimeSimpleName = type.Assembly.GetName().Name;
|
||||
|
||||
return storedSimpleName is not null
|
||||
&& string.Equals(storedSimpleName, runtimeSimpleName, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -113,4 +151,64 @@ public sealed class TypeId : IEquatable<TypeId>
|
||||
|
||||
/// <inheritdoc/>
|
||||
public override string ToString() => $"{this.TypeName}, {this.AssemblyName}";
|
||||
|
||||
/// <summary>
|
||||
/// The simple assembly name parsed from <see cref="AssemblyName"/>, lazily computed and cached.
|
||||
/// </summary>
|
||||
internal string? SimpleAssemblyName
|
||||
=> field ??= GetSimpleAssemblyName(this.AssemblyName);
|
||||
|
||||
/// <summary>
|
||||
/// The type full name with embedded assembly-qualified generic arguments stripped of
|
||||
/// version, culture, and public key token. Lazily computed and cached.
|
||||
/// </summary>
|
||||
internal string NormalizedTypeName
|
||||
=> field ??= NormalizeTypeName(this.TypeName);
|
||||
|
||||
private static readonly Regex s_assemblyQualifierPattern = new(
|
||||
@", Version=[^,\]]+, Culture=[^,\]]+, PublicKeyToken=[^,\]]+",
|
||||
RegexOptions.Compiled | RegexOptions.CultureInvariant);
|
||||
|
||||
/// <summary>
|
||||
/// Removes <c>, Version=...</c>, <c>, Culture=...</c>, and <c>, PublicKeyToken=...</c> triplets
|
||||
/// from <paramref name="typeName"/>. Returns the input unchanged when no triplet is present.
|
||||
/// </summary>
|
||||
internal static string NormalizeTypeName(string typeName)
|
||||
{
|
||||
if (typeName.IndexOf("Version=", StringComparison.Ordinal) < 0)
|
||||
{
|
||||
return typeName;
|
||||
}
|
||||
|
||||
return s_assemblyQualifierPattern.Replace(typeName, string.Empty);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns the simple assembly name parsed from an <see cref="Assembly.FullName"/>-style string,
|
||||
/// or <see langword="null"/> when both parsing and the substring fallback fail.
|
||||
/// </summary>
|
||||
internal static string? GetSimpleAssemblyName(string assemblyFullName)
|
||||
{
|
||||
if (string.IsNullOrEmpty(assemblyFullName))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
string? parsed = new AssemblyName(assemblyFullName).Name;
|
||||
if (!string.IsNullOrEmpty(parsed))
|
||||
{
|
||||
return parsed;
|
||||
}
|
||||
}
|
||||
catch (Exception ex) when (ex is FileLoadException or ArgumentException)
|
||||
{
|
||||
// Fall through to substring fallback.
|
||||
}
|
||||
|
||||
int comma = assemblyFullName.IndexOf(',');
|
||||
string fallback = (comma < 0 ? assemblyFullName : assemblyFullName.Substring(0, comma)).Trim();
|
||||
return fallback.Length == 0 ? null : fallback;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System;
|
||||
using System.Collections.Concurrent;
|
||||
using System.Collections.Generic;
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
using System.Linq;
|
||||
@@ -297,12 +298,14 @@ internal sealed class WorkflowSession : AgentSession
|
||||
/// interface assignment succeeds.
|
||||
/// </summary>
|
||||
[UnconditionalSuppressMessage("Trimming", "IL2057:Unrecognized value passed to the parameter of method", Justification = "Higher-layer envelope types are explicitly preserved by the package that defines them.")]
|
||||
[UnconditionalSuppressMessage("Trimming", "IL2026:Members annotated with 'RequiresUnreferencedCodeAttribute' require dynamic access", Justification = "Higher-layer envelope types are explicitly preserved by the package that defines them.")]
|
||||
[UnconditionalSuppressMessage("Trimming", "IL2073:Members annotated with 'DynamicallyAccessedMembersAttribute' require dynamic access", Justification = "Higher-layer envelope types are explicitly preserved by the package that defines them.")]
|
||||
private static bool TryGetRequestEnvelope(ExternalRequest request, [NotNullWhen(true)] out IExternalRequestEnvelope? envelope)
|
||||
{
|
||||
envelope = null;
|
||||
|
||||
TypeId requestType = request.PortInfo.RequestType;
|
||||
Type? concreteType = Type.GetType($"{requestType.TypeName}, {requestType.AssemblyName}", throwOnError: false);
|
||||
Type? concreteType = ResolveTypeLenient(requestType);
|
||||
if (concreteType is null || !typeof(IExternalRequestEnvelope).IsAssignableFrom(concreteType))
|
||||
{
|
||||
return false;
|
||||
@@ -317,6 +320,20 @@ internal sealed class WorkflowSession : AgentSession
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <summary>Caches <see cref="ResolveTypeLenient"/> results keyed by <see cref="TypeId"/>.</summary>
|
||||
private static readonly ConcurrentDictionary<TypeId, Type?> s_envelopeTypeCache = new();
|
||||
|
||||
/// <summary>
|
||||
/// Resolves a <see cref="TypeId"/> to a loaded <see cref="Type"/> using partial-name binding,
|
||||
/// which matches any loaded assembly with the same simple name regardless of version. Results
|
||||
/// are cached.
|
||||
/// </summary>
|
||||
[UnconditionalSuppressMessage("Trimming", "IL2026:Members annotated with 'RequiresUnreferencedCodeAttribute' require dynamic access", Justification = "Higher-layer envelope types are explicitly preserved by the package that defines them.")]
|
||||
[UnconditionalSuppressMessage("Trimming", "IL2057:Unrecognized value passed to the parameter of method", Justification = "Higher-layer envelope types are explicitly preserved by the package that defines them.")]
|
||||
internal static Type? ResolveTypeLenient(TypeId typeId)
|
||||
=> s_envelopeTypeCache.GetOrAdd(typeId, static id =>
|
||||
Type.GetType($"{id.NormalizedTypeName}, {id.SimpleAssemblyName}", throwOnError: false));
|
||||
|
||||
/// <summary>
|
||||
/// Creates the workflow-facing request content surfaced in response updates.
|
||||
/// </summary>
|
||||
|
||||
@@ -94,6 +94,12 @@ public static class ChatClientExtensions
|
||||
chatBuilder.Use(innerClient => new PerServiceCallChatHistoryPersistingChatClient(innerClient));
|
||||
}
|
||||
|
||||
// DeferredOpenTelemetryChatClient is registered last so it sits as the innermost decorator, directly
|
||||
// above the leaf client and below FunctionInvokingChatClient. It is inert until an OpenTelemetryAgent
|
||||
// activates it. Placing OpenTelemetry below FICC ensures the chat span closes before tools are invoked,
|
||||
// so FICC emits execute_tool spans on the agent source.
|
||||
chatBuilder.Use(innerClient => new DeferredOpenTelemetryChatClient(innerClient));
|
||||
|
||||
var agentChatClient = chatBuilder.Build(services);
|
||||
|
||||
if (options?.ChatOptions?.Tools is { Count: > 0 })
|
||||
|
||||
@@ -0,0 +1,133 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Extensions.AI;
|
||||
using Microsoft.Shared.Diagnostics;
|
||||
|
||||
namespace Microsoft.Agents.AI;
|
||||
|
||||
/// <summary>
|
||||
/// A delegating chat client that reserves a position for OpenTelemetry instrumentation directly above
|
||||
/// the leaf <see cref="IChatClient"/> and below the <see cref="FunctionInvokingChatClient"/> in a
|
||||
/// <see cref="ChatClientAgent"/> pipeline.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// The slot is inert until <see cref="Activate"/> is called: it simply forwards to its inner client.
|
||||
/// When the agent is wrapped by an <see cref="OpenTelemetryAgent"/>, that agent activates the slot with
|
||||
/// the resolved source name, at which point the slot routes calls through an
|
||||
/// <see cref="OpenTelemetryChatClient"/> so chat spans are emitted below the
|
||||
/// <see cref="FunctionInvokingChatClient"/>.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Positioning OpenTelemetry below FICC is required for tool telemetry: the chat span then closes before
|
||||
/// FICC invokes tools, so <see cref="System.Diagnostics.Activity.Current"/> is the invoke_agent span and
|
||||
/// FICC emits execute_tool spans on the agent source.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
internal sealed class DeferredOpenTelemetryChatClient : DelegatingChatClient
|
||||
{
|
||||
private readonly object _activationLock = new();
|
||||
private volatile IChatClient _target;
|
||||
private OpenTelemetryChatClient? _activatedClient;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="DeferredOpenTelemetryChatClient"/> class in its inert state.
|
||||
/// </summary>
|
||||
/// <param name="innerClient">The underlying chat client to forward to until the slot is activated.</param>
|
||||
public DeferredOpenTelemetryChatClient(IChatClient innerClient)
|
||||
: base(innerClient)
|
||||
{
|
||||
this._target = innerClient;
|
||||
}
|
||||
|
||||
/// <summary>Gets a value indicating whether the slot has been activated.</summary>
|
||||
public bool IsActive => !ReferenceEquals(this._target, this.InnerClient);
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets a value indicating whether the activated <see cref="OpenTelemetryChatClient"/> should
|
||||
/// include potentially sensitive information (such as message content) in telemetry. Reading or writing
|
||||
/// this property is a no-op while the slot is inert; the owning <see cref="OpenTelemetryAgent"/> applies
|
||||
/// and propagates the value once the slot is activated.
|
||||
/// </summary>
|
||||
public bool EnableSensitiveData
|
||||
{
|
||||
get => this._activatedClient?.EnableSensitiveData ?? false;
|
||||
set
|
||||
{
|
||||
if (this._activatedClient is { } activatedClient)
|
||||
{
|
||||
activatedClient.EnableSensitiveData = value;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Activates the slot so that calls are routed through an <see cref="OpenTelemetryChatClient"/> wrapping
|
||||
/// the inner client under the specified <paramref name="sourceName"/>. Idempotent and thread-safe; a
|
||||
/// second call (or a call after another thread activated the slot) is a no-op.
|
||||
/// </summary>
|
||||
/// <param name="sourceName">The telemetry source name to emit chat spans under.</param>
|
||||
public void Activate(string sourceName)
|
||||
{
|
||||
if (this.IsActive)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
lock (this._activationLock)
|
||||
{
|
||||
if (this.IsActive)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var activatedTarget = this.InnerClient.AsBuilder().UseOpenTelemetry(sourceName: sourceName).Build();
|
||||
|
||||
// Capture the OpenTelemetryChatClient so the owning agent can propagate EnableSensitiveData to it
|
||||
// (the agent's value may be set after construction, e.g. via the UseOpenTelemetry configure callback).
|
||||
this._activatedClient = activatedTarget.GetService(typeof(OpenTelemetryChatClient)) as OpenTelemetryChatClient;
|
||||
this._target = activatedTarget;
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public override Task<ChatResponse> GetResponseAsync(
|
||||
IEnumerable<ChatMessage> messages, ChatOptions? options = null, CancellationToken cancellationToken = default) =>
|
||||
this._target.GetResponseAsync(messages, options, cancellationToken);
|
||||
|
||||
/// <inheritdoc/>
|
||||
public override IAsyncEnumerable<ChatResponseUpdate> GetStreamingResponseAsync(
|
||||
IEnumerable<ChatMessage> messages, ChatOptions? options = null, CancellationToken cancellationToken = default) =>
|
||||
this._target.GetStreamingResponseAsync(messages, options, cancellationToken);
|
||||
|
||||
/// <inheritdoc/>
|
||||
public override object? GetService(Type serviceType, object? serviceKey = null)
|
||||
{
|
||||
_ = Throw.IfNull(serviceType);
|
||||
|
||||
// Return this slot for its own type and base contracts; otherwise forward to the current target so
|
||||
// that, once activated, queries such as OpenTelemetryChatClient and ActivitySource resolve to the
|
||||
// activated instrumentation rather than the bare leaf.
|
||||
return serviceKey is null && serviceType.IsInstanceOfType(this)
|
||||
? this
|
||||
: this._target.GetService(serviceType, serviceKey);
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
protected override void Dispose(bool disposing)
|
||||
{
|
||||
if (disposing && !ReferenceEquals(this._target, this.InnerClient))
|
||||
{
|
||||
// When activated, _target is an OpenTelemetryChatClient wrapping the inner client; dispose it so its
|
||||
// own telemetry resources are released. It also disposes the inner client, which is idempotent with the
|
||||
// base.Dispose call below.
|
||||
this._target.Dispose();
|
||||
}
|
||||
|
||||
base.Dispose(disposing);
|
||||
}
|
||||
}
|
||||
@@ -46,8 +46,6 @@ public sealed class AgentModeProvider : AIContextProvider
|
||||
## Agent Mode
|
||||
|
||||
- You can operate in different modes. Depending on the mode you are in, you will be required to follow different processes.
|
||||
- You must check the current mode after any user input, since the user may have changed the mode themselves,
|
||||
e.g. the user may have switched to 'plan' mode after a previous research task finished in 'execute' mode, meaning they want to review a plan first before execution.
|
||||
|
||||
Use the mode_get tool to check your current operating mode.
|
||||
Use the mode_set tool to switch between modes as your work progresses. Only use mode_set if the user explicitly instructs/allows you to change modes.
|
||||
@@ -84,9 +82,12 @@ public sealed class AgentModeProvider : AIContextProvider
|
||||
new(
|
||||
"execute",
|
||||
"""
|
||||
Use this mode when carrying out approved plans. Work autonomously using your best judgment — do not ask the user questions or wait for feedback.
|
||||
|
||||
Process to follow when in execute mode:
|
||||
Determine the type of ask:
|
||||
1. Simple question that doesn't require any further work to answer.
|
||||
2. Any other work, including complex user request that requires a multi-step process to satisfy.
|
||||
|
||||
If 1. just answer the question directly.
|
||||
If 2. Work autonomously using your best judgment — do not ask the user questions or wait for feedback and follow the following process:
|
||||
1. If you don't have a plan or tasks yet, analyze the user request and create tasks and a plan. (**Skip this step if you came from plan mode**)
|
||||
2. Work autonomously — use your best judgment to make decisions and keep progressing without asking the user questions. The goal is to have a complete, useful result ready when the user returns.
|
||||
3. If you encounter ambiguity or an unexpected situation during execution, choose the most reasonable option, note your choice, and keep going.
|
||||
|
||||
@@ -46,11 +46,16 @@ public sealed class TodoProvider : AIContextProvider, IDisposable
|
||||
## Todo Items
|
||||
|
||||
You have access to a todo list for tracking work items.
|
||||
While planning, make sure that you break down complex tasks into manageable todo items and add them to the list.
|
||||
When a user asks you to perform a task, follow these steps to manage your work:
|
||||
1. Determine whether the ask requires multiple steps to complete (complex) or can be completed using a single step (simple).
|
||||
2. If complex, turn the task into manageable todo items and add them to the list.
|
||||
3. If simple, don't add a todo item, but rather just complete the task directly.
|
||||
|
||||
### General TODO Guidelines
|
||||
Ask questions from the user where clarification is needed to create effective todos.
|
||||
If the user provides feedback on your plan, adjust your todos accordingly by adding new items or removing irrelevant/old ones.
|
||||
During execution, use the todo list to keep track of what needs to be done, mark items as complete when finished, and remove any items that are no longer needed.
|
||||
When a user changes the topic or changes their mind, ensure that you update the todo list accordingly by removing irrelevant/old items or adding new ones as needed.
|
||||
When a user changes the topic, changes their mind or switches to a new request, ensure that you update the todo list accordingly by removing irrelevant/old items, clearing the list, or adding new ones as needed.
|
||||
|
||||
Use these tools to manage your tasks:
|
||||
- Use todos_add to break down complex work into trackable items (supports adding one or many at once).
|
||||
|
||||
@@ -42,6 +42,12 @@ public sealed class OpenTelemetryAgent : DelegatingAIAgent, IDisposable
|
||||
/// </summary>
|
||||
private readonly bool _autoWireChatClient;
|
||||
|
||||
/// <summary>
|
||||
/// The auto-wired below-FICC telemetry slot, when one was activated. Cached so that updates to
|
||||
/// <see cref="EnableSensitiveData"/> made after construction can be propagated to it.
|
||||
/// </summary>
|
||||
private DeferredOpenTelemetryChatClient? _innerTelemetrySlot;
|
||||
|
||||
/// <summary>Initializes a new instance of the <see cref="OpenTelemetryAgent"/> class.</summary>
|
||||
/// <param name="innerAgent">The underlying <see cref="AIAgent"/> to be augmented with telemetry capabilities.</param>
|
||||
/// <param name="sourceName">
|
||||
@@ -91,6 +97,8 @@ public sealed class OpenTelemetryAgent : DelegatingAIAgent, IDisposable
|
||||
this._otelClient = new OpenTelemetryChatClient(
|
||||
new ForwardingChatClient(this),
|
||||
sourceName: this._sourceName);
|
||||
|
||||
this.TryActivateInnerChatClientTelemetry();
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
@@ -120,7 +128,16 @@ public sealed class OpenTelemetryAgent : DelegatingAIAgent, IDisposable
|
||||
public bool EnableSensitiveData
|
||||
{
|
||||
get => this._otelClient.EnableSensitiveData;
|
||||
set => this._otelClient.EnableSensitiveData = value;
|
||||
set
|
||||
{
|
||||
this._otelClient.EnableSensitiveData = value;
|
||||
|
||||
// Keep the auto-wired below-FICC slot in sync so its chat span captures message content too.
|
||||
if (this._innerTelemetrySlot is { } slot)
|
||||
{
|
||||
slot.EnableSensitiveData = value;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
@@ -204,85 +221,53 @@ public sealed class OpenTelemetryAgent : DelegatingAIAgent, IDisposable
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// If auto-wiring is enabled and the inner agent is a <see cref="ChatClientAgent"/> whose underlying
|
||||
/// <see cref="IChatClient"/> is not already instrumented with <see cref="OpenTelemetryChatClient"/>, returns a
|
||||
/// new <see cref="ChatClientAgentRunOptions"/> with a <see cref="ChatClientAgentRunOptions.ChatClientFactory"/>
|
||||
/// that wraps the chat client with <see cref="OpenTelemetryChatClient"/>. When <paramref name="options"/> is a
|
||||
/// plain <see cref="AgentRunOptions"/> (the base type, not <see cref="ChatClientAgentRunOptions"/>), the base
|
||||
/// properties are copied onto the new <see cref="ChatClientAgentRunOptions"/> so high-level callers that pass
|
||||
/// the abstract <see cref="AgentRunOptions"/> still benefit from auto-wiring and propagate their settings to
|
||||
/// the inner agent. Otherwise, returns <paramref name="options"/> unchanged.
|
||||
/// When auto-wiring is enabled and the inner agent is a <see cref="ChatClientAgent"/> whose underlying
|
||||
/// <see cref="IChatClient"/> is not already instrumented, activates the in-place
|
||||
/// <see cref="DeferredOpenTelemetryChatClient"/> slot so that chat spans are emitted below the
|
||||
/// <see cref="FunctionInvokingChatClient"/> under this agent's source name. Positioning OpenTelemetry below FICC
|
||||
/// is what allows FICC to emit execute_tool spans on the agent source. Respects
|
||||
/// <see cref="ChatClientAgentOptions.UseProvidedChatClientAsIs"/> and is a no-op when no slot is reachable.
|
||||
/// </summary>
|
||||
private AgentRunOptions? GetRunOptionsWithChatClientWiring(AgentRunOptions? options)
|
||||
private void TryActivateInnerChatClientTelemetry()
|
||||
{
|
||||
if (!this._autoWireChatClient)
|
||||
{
|
||||
return options;
|
||||
return;
|
||||
}
|
||||
|
||||
// The auto-wiring only applies when a ChatClientAgent is reachable from the inner agent. Otherwise, no-op.
|
||||
// Auto-wiring only applies when a ChatClientAgent is reachable from the inner agent. Otherwise, no-op.
|
||||
// Use GetService rather than a type check so wrapping agents that expose a nested ChatClientAgent are supported.
|
||||
var chatClientAgent = this.InnerAgent.GetService<ChatClientAgent>();
|
||||
if (chatClientAgent is null)
|
||||
{
|
||||
return options;
|
||||
return;
|
||||
}
|
||||
|
||||
// Respect ChatClientAgentOptions.UseProvidedChatClientAsIs: don't decorate the chat client when the user opted out.
|
||||
if (chatClientAgent.GetService<ChatClientAgentOptions>()?.UseProvidedChatClientAsIs is true)
|
||||
{
|
||||
return options;
|
||||
return;
|
||||
}
|
||||
|
||||
// Capture the underlying IChatClient and check whether it is already instrumented.
|
||||
// Don't activate when the chat client is already instrumented (e.g. the caller added their own
|
||||
// OpenTelemetryChatClient), to avoid emitting duplicate chat spans.
|
||||
var chatClient = chatClientAgent.GetService<IChatClient>();
|
||||
if (chatClient is null || chatClient.GetService(typeof(OpenTelemetryChatClient)) is not null)
|
||||
{
|
||||
return options;
|
||||
return;
|
||||
}
|
||||
|
||||
string sourceName = this._sourceName;
|
||||
bool enableSensitiveData = this.EnableSensitiveData;
|
||||
static IChatClient WrapIfNeeded(IChatClient cc, string sourceName, bool enableSensitiveData) =>
|
||||
cc.GetService(typeof(OpenTelemetryChatClient)) is not null
|
||||
? cc
|
||||
: cc.AsBuilder().UseOpenTelemetry(sourceName: sourceName, configure: o => o.EnableSensitiveData = enableSensitiveData).Build();
|
||||
|
||||
if (options is ChatClientAgentRunOptions ccOptions)
|
||||
// Activate the pre-placed slot in-place (below FICC) rather than wrapping a new OpenTelemetryChatClient
|
||||
// around the whole pipeline on each run. Cache it and seed its EnableSensitiveData from the current
|
||||
// value so a later change to this agent's EnableSensitiveData can be propagated to the inner chat span.
|
||||
if (chatClient.GetService(typeof(DeferredOpenTelemetryChatClient)) is DeferredOpenTelemetryChatClient slot)
|
||||
{
|
||||
// Don't mutate the caller's options; clone and chain any caller-provided factory.
|
||||
// If the user factory already returns an OpenTelemetry-instrumented client, don't double-wrap.
|
||||
var clone = (ChatClientAgentRunOptions)ccOptions.Clone();
|
||||
var userFactory = clone.ChatClientFactory;
|
||||
clone.ChatClientFactory = cc => WrapIfNeeded(userFactory is null ? cc : userFactory(cc), sourceName, enableSensitiveData);
|
||||
return clone;
|
||||
slot.Activate(this._sourceName);
|
||||
slot.EnableSensitiveData = this.EnableSensitiveData;
|
||||
this._innerTelemetrySlot = slot;
|
||||
}
|
||||
|
||||
// For a plain AgentRunOptions (or null), create a ChatClientAgentRunOptions and preserve
|
||||
// any base AgentRunOptions properties from the caller so they reach the inner agent.
|
||||
var newOptions = new ChatClientAgentRunOptions
|
||||
{
|
||||
ChatClientFactory = cc => WrapIfNeeded(cc, sourceName, enableSensitiveData),
|
||||
};
|
||||
|
||||
if (options is not null)
|
||||
{
|
||||
CopyBaseAgentRunOptions(options, newOptions);
|
||||
}
|
||||
|
||||
return newOptions;
|
||||
}
|
||||
|
||||
#pragma warning disable MEAI001 // ContinuationToken is experimental; copy it through to preserve caller-provided value.
|
||||
private static void CopyBaseAgentRunOptions(AgentRunOptions source, AgentRunOptions target)
|
||||
{
|
||||
target.ContinuationToken = source.ContinuationToken;
|
||||
target.AllowBackgroundResponses = source.AllowBackgroundResponses;
|
||||
target.AdditionalProperties = source.AdditionalProperties?.Clone();
|
||||
target.ResponseFormat = source.ResponseFormat;
|
||||
}
|
||||
#pragma warning restore MEAI001
|
||||
|
||||
/// <summary>The stub <see cref="IChatClient"/> used to delegate from the <see cref="OpenTelemetryChatClient"/> into the inner <see cref="AIAgent"/>.</summary>
|
||||
/// <param name="parentAgent"></param>
|
||||
private sealed class ForwardingChatClient(OpenTelemetryAgent parentAgent) : IChatClient
|
||||
@@ -295,11 +280,9 @@ public sealed class OpenTelemetryAgent : DelegatingAIAgent, IDisposable
|
||||
// Update the current activity to reflect the agent invocation.
|
||||
parentAgent.UpdateCurrentActivity(fo?.CurrentActivity);
|
||||
|
||||
// If enabled, wire the underlying chat client with OpenTelemetryChatClient via ChatClientFactory.
|
||||
var runOptions = parentAgent.GetRunOptionsWithChatClientWiring(fo?.Options);
|
||||
|
||||
// Invoke the inner agent.
|
||||
var response = await parentAgent.InnerAgent.RunAsync(messages, fo?.Session, runOptions, cancellationToken).ConfigureAwait(false);
|
||||
// Invoke the inner agent. Chat-level telemetry is emitted by the in-place DeferredOpenTelemetryChatClient
|
||||
// slot (below FICC), activated once at construction; no per-run chat-client wiring is needed here.
|
||||
var response = await parentAgent.InnerAgent.RunAsync(messages, fo?.Session, fo?.Options, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
// Wrap the response in a ChatResponse so we can pass it back through OpenTelemetryChatClient.
|
||||
return response.AsChatResponse();
|
||||
@@ -313,11 +296,9 @@ public sealed class OpenTelemetryAgent : DelegatingAIAgent, IDisposable
|
||||
// Update the current activity to reflect the agent invocation.
|
||||
parentAgent.UpdateCurrentActivity(fo?.CurrentActivity);
|
||||
|
||||
// If enabled, wire the underlying chat client with OpenTelemetryChatClient via ChatClientFactory.
|
||||
var runOptions = parentAgent.GetRunOptionsWithChatClientWiring(fo?.Options);
|
||||
|
||||
// Invoke the inner agent.
|
||||
await foreach (var update in parentAgent.InnerAgent.RunStreamingAsync(messages, fo?.Session, runOptions, cancellationToken).ConfigureAwait(false))
|
||||
// Invoke the inner agent. Chat-level telemetry is emitted by the in-place DeferredOpenTelemetryChatClient
|
||||
// slot (below FICC), activated once at construction; no per-run chat-client wiring is needed here.
|
||||
await foreach (var update in parentAgent.InnerAgent.RunStreamingAsync(messages, fo?.Session, fo?.Options, cancellationToken).ConfigureAwait(false))
|
||||
{
|
||||
// Wrap the response updates in ChatResponseUpdates so we can pass them back through OpenTelemetryChatClient.
|
||||
yield return update.AsChatResponseUpdate();
|
||||
|
||||
@@ -48,16 +48,19 @@ public sealed class AgentFileSkill : AgentSkill
|
||||
|
||||
/// <inheritdoc/>
|
||||
/// <remarks>
|
||||
/// Returns the raw SKILL.md content. When the skill has scripts, a
|
||||
/// <c><script_schemas></c> block is appended describing the argument format.
|
||||
/// The result is cached after the first access.
|
||||
/// Returns the raw SKILL.md content with an <c><available_resources></c> and an
|
||||
/// <c><available_scripts></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><available_scripts /></c>) so the model knows none are available and does not hallucinate
|
||||
/// their names. The result is cached after the first access.
|
||||
/// </remarks>
|
||||
public override ValueTask<string> GetContentAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
var content = this._content ??= this._scripts is { Count: > 0 }
|
||||
? this._originalContent + AgentInlineSkillContentBuilder.BuildScriptSchemasBlock(this._scripts)
|
||||
: this._originalContent;
|
||||
return new(content);
|
||||
this._content ??=
|
||||
this._originalContent
|
||||
+ "\n" + AgentInlineSkillContentBuilder.BuildAvailableResourcesBlock(this._resources)
|
||||
+ "\n" + AgentInlineSkillContentBuilder.BuildAvailableScriptsBlock(this._scripts);
|
||||
return new(this._content);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
||||
@@ -120,6 +120,7 @@ public abstract class AgentClassSkill<
|
||||
this.Frontmatter.Name,
|
||||
this.Frontmatter.Description,
|
||||
this.Instructions,
|
||||
this.Resources,
|
||||
this.Scripts));
|
||||
}
|
||||
|
||||
@@ -160,8 +161,9 @@ public abstract class AgentClassSkill<
|
||||
/// Override this property in derived classes to provide skill-specific resources.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Resources are not automatically included in the skill body.
|
||||
/// To enable discovery, reference resources by name in the skill's instructions or in other resources.
|
||||
/// Resources are listed in the <c><available_resources></c> block of the skill body so the LLM
|
||||
/// knows which ones can be accessed. When empty, a self-closing element is emitted to prevent
|
||||
/// hallucinated resource calls.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public virtual IReadOnlyList<AgentSkillResource>? Resources => this._resources.Value;
|
||||
@@ -178,8 +180,9 @@ public abstract class AgentClassSkill<
|
||||
/// Override this property in derived classes to provide skill-specific scripts.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Only script parameter schemas are included in the skill body (as a <c><script_schemas></c> block).
|
||||
/// To enable discovery, reference scripts by name in the skill's instructions or in a resource.
|
||||
/// Scripts are listed in the <c><available_scripts></c> block of the skill body so the LLM
|
||||
/// knows which ones can be called. When empty, a self-closing element is emitted to prevent
|
||||
/// hallucinated script calls.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public virtual IReadOnlyList<AgentSkillScript>? Scripts => this._scripts.Value;
|
||||
@@ -202,8 +205,9 @@ public abstract class AgentClassSkill<
|
||||
/// Creates a skill resource backed by a static value.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Resources are not automatically included in the skill body.
|
||||
/// To enable discovery, reference the resource by name in the skill's instructions or in another resource.
|
||||
/// The resource is listed in the <c><available_resources></c> block of the skill body so the LLM
|
||||
/// knows it can be accessed. When no resources are registered, the block is emitted as a
|
||||
/// self-closing element to signal that none exist, preventing hallucinated resource calls.
|
||||
/// </remarks>
|
||||
/// <param name="name">The resource name.</param>
|
||||
/// <param name="value">The static resource value.</param>
|
||||
@@ -216,8 +220,9 @@ public abstract class AgentClassSkill<
|
||||
/// Creates a skill resource backed by a delegate that produces a dynamic value.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Resources are not automatically included in the skill body.
|
||||
/// To enable discovery, reference the resource by name in the skill's instructions or in another resource.
|
||||
/// The resource is listed in the <c><available_resources></c> block of the skill body so the LLM
|
||||
/// knows it can be accessed. When no resources are registered, the block is emitted as a
|
||||
/// self-closing element to signal that none exist, preventing hallucinated resource calls.
|
||||
/// </remarks>
|
||||
/// <param name="name">The resource name.</param>
|
||||
/// <param name="method">A method that produces the resource value when requested.</param>
|
||||
@@ -234,8 +239,9 @@ public abstract class AgentClassSkill<
|
||||
/// Creates a skill script backed by a delegate.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Only the script's parameter schema is included in the skill body (as a <c><script_schemas></c> block).
|
||||
/// To enable discovery, reference the script by name in the skill's instructions or in a resource.
|
||||
/// The script is listed in the <c><available_scripts></c> block of the skill body so the LLM
|
||||
/// knows it can be called. When no scripts are registered, the block is emitted as a
|
||||
/// self-closing element to signal that none exist, preventing hallucinated script calls.
|
||||
/// </remarks>
|
||||
/// <param name="name">The script name.</param>
|
||||
/// <param name="method">A method to execute when the script is invoked.</param>
|
||||
|
||||
@@ -107,7 +107,7 @@ public sealed class AgentInlineSkill : AgentSkill
|
||||
/// <inheritdoc/>
|
||||
public override ValueTask<string> GetContentAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
return new(this._cachedContent ??= AgentInlineSkillContentBuilder.Build(this.Frontmatter.Name, this.Frontmatter.Description, this._instructions, this._scripts));
|
||||
return new(this._cachedContent ??= AgentInlineSkillContentBuilder.Build(this.Frontmatter.Name, this.Frontmatter.Description, this._instructions, this._resources, this._scripts));
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
@@ -128,8 +128,9 @@ public sealed class AgentInlineSkill : AgentSkill
|
||||
/// Registers a static resource with this skill.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Resources are not automatically included in the skill body.
|
||||
/// To enable discovery, reference the resource by name in the skill's instructions or in another resource.
|
||||
/// The resource is listed in the <c><available_resources></c> block of the skill body so the
|
||||
/// LLM knows it can be accessed. When no resources are registered, the block is emitted as a
|
||||
/// self-closing element to signal that none exist, preventing hallucinated resource calls.
|
||||
/// </remarks>
|
||||
/// <param name="name">The resource name.</param>
|
||||
/// <param name="value">The static resource value.</param>
|
||||
@@ -146,8 +147,9 @@ public sealed class AgentInlineSkill : AgentSkill
|
||||
/// The delegate's parameters and return type are automatically marshaled via <c>AIFunctionFactory</c>.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Resources are not automatically included in the skill body.
|
||||
/// To enable discovery, reference the resource by name in the skill's instructions or in another resource.
|
||||
/// The resource is listed in the <c><available_resources></c> block of the skill body so the
|
||||
/// LLM knows it can be accessed. When no resources are registered, the block is emitted as a
|
||||
/// self-closing element to signal that none exist, preventing hallucinated resource calls.
|
||||
/// </remarks>
|
||||
/// <param name="name">The resource name.</param>
|
||||
/// <param name="method">A method that produces the resource value when requested.</param>
|
||||
@@ -168,8 +170,9 @@ public sealed class AgentInlineSkill : AgentSkill
|
||||
/// The delegate's parameters and return type are automatically marshaled via <c>AIFunctionFactory</c>.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Only the script's parameter schema is included in the skill body (as a <c><script_schemas></c> block).
|
||||
/// To enable discovery, reference the script by name in the skill's instructions or in a resource.
|
||||
/// The script is listed in the <c><available_scripts></c> block of the skill body so the
|
||||
/// LLM knows it can be called. When no scripts are registered, the block is emitted as a
|
||||
/// self-closing element to signal that none exist, preventing hallucinated script calls.
|
||||
/// </remarks>
|
||||
/// <param name="name">The script name.</param>
|
||||
/// <param name="method">A method to execute when the script is invoked.</param>
|
||||
|
||||
+58
-17
@@ -12,17 +12,19 @@ namespace Microsoft.Agents.AI;
|
||||
internal static class AgentInlineSkillContentBuilder
|
||||
{
|
||||
/// <summary>
|
||||
/// Builds the complete skill content containing name, description, instructions, and script parameter schemas.
|
||||
/// Builds the complete skill content containing name, description, instructions, resources, and script parameter schemas.
|
||||
/// </summary>
|
||||
/// <param name="name">The skill name.</param>
|
||||
/// <param name="description">The skill description.</param>
|
||||
/// <param name="instructions">The raw instructions text.</param>
|
||||
/// <param name="resources">Optional resources associated with the skill.</param>
|
||||
/// <param name="scripts">Optional scripts associated with the skill.</param>
|
||||
/// <returns>An XML-structured content string.</returns>
|
||||
public static string Build(
|
||||
string name,
|
||||
string description,
|
||||
string instructions,
|
||||
IReadOnlyList<AgentSkillResource>? resources,
|
||||
IReadOnlyList<AgentSkillScript>? scripts)
|
||||
{
|
||||
_ = Throw.IfNullOrWhitespace(name);
|
||||
@@ -37,34 +39,71 @@ internal static class AgentInlineSkillContentBuilder
|
||||
.Append(EscapeXmlString(instructions))
|
||||
.Append("\n</instructions>");
|
||||
|
||||
if (scripts is { Count: > 0 })
|
||||
{
|
||||
sb.Append('\n');
|
||||
sb.Append(BuildScriptSchemasBlock(scripts));
|
||||
}
|
||||
sb.Append('\n');
|
||||
sb.Append(BuildAvailableResourcesBlock(resources ?? []));
|
||||
sb.Append('\n');
|
||||
sb.Append(BuildAvailableScriptsBlock(scripts ?? []));
|
||||
|
||||
return sb.ToString();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Builds a <c><script_schemas>...</script_schemas></c> XML block for the given scripts.
|
||||
/// Each script is emitted as a <c><schema script="..."></c> element containing only
|
||||
/// the parameter schema. This block serves as a reference for the model to know how to
|
||||
/// format arguments when calling scripts, not as a discovery mechanism.
|
||||
/// Builds an <c><available_resources>...</available_resources></c> XML block for the given resources.
|
||||
/// Each resource is emitted as a self-closing <c><resource name="..."/></c> element. When the list is empty,
|
||||
/// a self-closing <c><available_resources /></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<available_resources></c>, or <c>\n<available_resources /></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><available_scripts>...</available_scripts></c> XML block for the given scripts.
|
||||
/// Each script is emitted as a <c><script name="..."></c> element; when the script has a
|
||||
/// parameter schema it is wrapped in a nested <c><parameters_schema></c> element, otherwise a
|
||||
/// self-closing <c><script></c> element is used. When the list is empty, a self-closing
|
||||
/// <c><available_scripts /></c> element is returned. This block lets the model know which scripts
|
||||
/// can be called and how to format their arguments (or that there are none) so it does not hallucinate script names.
|
||||
/// </summary>
|
||||
/// <param name="scripts">The scripts to include in the block.</param>
|
||||
/// <returns>An XML string starting with <c>\n<script_schemas></c>, or an empty string if the list is empty.</returns>
|
||||
public static string BuildScriptSchemasBlock(IReadOnlyList<AgentSkillScript> scripts)
|
||||
/// <returns>
|
||||
/// An XML string starting with <c>\n<available_scripts></c>, or <c>\n<available_scripts /></c> if the list is empty.
|
||||
/// </returns>
|
||||
public static string BuildAvailableScriptsBlock(IReadOnlyList<AgentSkillScript> scripts)
|
||||
{
|
||||
_ = Throw.IfNull(scripts);
|
||||
|
||||
if (scripts.Count == 0)
|
||||
{
|
||||
return string.Empty;
|
||||
// Emit an empty element so the model knows no scripts are available and does not hallucinate script names.
|
||||
return "\n<available_scripts />";
|
||||
}
|
||||
|
||||
var sb = new StringBuilder();
|
||||
sb.Append("\n<script_schemas>\n");
|
||||
sb.Append("\n<available_scripts>\n");
|
||||
|
||||
foreach (var script in scripts)
|
||||
{
|
||||
@@ -72,15 +111,17 @@ internal static class AgentInlineSkillContentBuilder
|
||||
|
||||
if (parametersSchema is null)
|
||||
{
|
||||
sb.Append($" <schema script=\"{EscapeXmlString(script.Name)}\"/>\n");
|
||||
sb.Append($" <script name=\"{EscapeXmlString(script.Name)}\"/>\n");
|
||||
}
|
||||
else
|
||||
{
|
||||
sb.Append($" <schema script=\"{EscapeXmlString(script.Name)}\">{EscapeXmlString(parametersSchema.Value.GetRawText(), preserveQuotes: true)}</schema>\n");
|
||||
sb.Append($" <script name=\"{EscapeXmlString(script.Name)}\">\n");
|
||||
sb.Append($" <parameters_schema>{EscapeXmlString(parametersSchema.Value.GetRawText(), preserveQuotes: true)}</parameters_schema>\n");
|
||||
sb.Append(" </script>\n");
|
||||
}
|
||||
}
|
||||
|
||||
sb.Append("</script_schemas>");
|
||||
sb.Append("</available_scripts>");
|
||||
|
||||
return sb.ToString();
|
||||
}
|
||||
|
||||
+324
@@ -0,0 +1,324 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System.Text.Json;
|
||||
using GitHub.Copilot;
|
||||
using Microsoft.Extensions.AI;
|
||||
|
||||
namespace Microsoft.Agents.AI.GitHub.Copilot.UnitTests;
|
||||
|
||||
/// <summary>
|
||||
/// Unit tests for tool execution event projection in <see cref="GitHubCopilotAgent"/>.
|
||||
/// </summary>
|
||||
public sealed class ToolExecutionEventProjectionTests
|
||||
{
|
||||
private static JsonElement ParseJson(string json) => JsonDocument.Parse(json).RootElement;
|
||||
|
||||
[Fact]
|
||||
public void ConvertToAgentResponseUpdate_ToolExecutionStartEvent_ProducesFunctionCallContent()
|
||||
{
|
||||
// Arrange
|
||||
var copilotClient = new CopilotClient(new CopilotClientOptions());
|
||||
var agent = new GitHubCopilotAgent(copilotClient, ownsClient: false, id: "agent-1", tools: null);
|
||||
|
||||
var startEvent = new ToolExecutionStartEvent
|
||||
{
|
||||
Data = new ToolExecutionStartData
|
||||
{
|
||||
ToolCallId = "call-123",
|
||||
ToolName = "readFile",
|
||||
Arguments = ParseJson("{\"path\":\"/tmp/test.txt\"}")
|
||||
}
|
||||
};
|
||||
|
||||
// Act
|
||||
AgentResponseUpdate result = agent.ConvertToAgentResponseUpdate(startEvent);
|
||||
|
||||
// Assert
|
||||
Assert.Equal(ChatRole.Assistant, result.Role);
|
||||
Assert.Equal("agent-1", result.AgentId);
|
||||
|
||||
var content = Assert.Single(result.Contents);
|
||||
var functionCall = Assert.IsType<FunctionCallContent>(content);
|
||||
Assert.Equal("call-123", functionCall.CallId);
|
||||
Assert.Equal("readFile", functionCall.Name);
|
||||
Assert.NotNull(functionCall.Arguments);
|
||||
Assert.Equal("/tmp/test.txt", functionCall.Arguments!["path"]?.ToString());
|
||||
Assert.Same(startEvent, functionCall.RawRepresentation);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ConvertToAgentResponseUpdate_ToolExecutionStartEvent_WithNullArguments_ProducesNullArguments()
|
||||
{
|
||||
// Arrange
|
||||
var copilotClient = new CopilotClient(new CopilotClientOptions());
|
||||
var agent = new GitHubCopilotAgent(copilotClient, ownsClient: false, tools: null);
|
||||
|
||||
var startEvent = new ToolExecutionStartEvent
|
||||
{
|
||||
Data = new ToolExecutionStartData
|
||||
{
|
||||
ToolCallId = "call-456",
|
||||
ToolName = "listTools",
|
||||
Arguments = null
|
||||
}
|
||||
};
|
||||
|
||||
// Act
|
||||
AgentResponseUpdate result = agent.ConvertToAgentResponseUpdate(startEvent);
|
||||
|
||||
// Assert
|
||||
var content = Assert.Single(result.Contents);
|
||||
var functionCall = Assert.IsType<FunctionCallContent>(content);
|
||||
Assert.Equal("call-456", functionCall.CallId);
|
||||
Assert.Equal("listTools", functionCall.Name);
|
||||
Assert.Null(functionCall.Arguments);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ConvertToAgentResponseUpdate_ToolExecutionStartEvent_WithNullData_ProducesEmptyFunctionCall()
|
||||
{
|
||||
// Arrange
|
||||
var copilotClient = new CopilotClient(new CopilotClientOptions());
|
||||
var agent = new GitHubCopilotAgent(copilotClient, ownsClient: false, tools: null);
|
||||
|
||||
var startEvent = new ToolExecutionStartEvent { Data = null! };
|
||||
|
||||
// Act
|
||||
AgentResponseUpdate result = agent.ConvertToAgentResponseUpdate(startEvent);
|
||||
|
||||
// Assert
|
||||
var content = Assert.Single(result.Contents);
|
||||
var functionCall = Assert.IsType<FunctionCallContent>(content);
|
||||
Assert.Equal(string.Empty, functionCall.CallId);
|
||||
Assert.Equal(string.Empty, functionCall.Name);
|
||||
Assert.Null(functionCall.Arguments);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ConvertToAgentResponseUpdate_ToolExecutionCompleteEvent_WithSuccess_ProducesFunctionResultContent()
|
||||
{
|
||||
// Arrange
|
||||
var copilotClient = new CopilotClient(new CopilotClientOptions());
|
||||
var agent = new GitHubCopilotAgent(copilotClient, ownsClient: false, id: "agent-2", tools: null);
|
||||
|
||||
var completeEvent = new ToolExecutionCompleteEvent
|
||||
{
|
||||
Data = new ToolExecutionCompleteData
|
||||
{
|
||||
ToolCallId = "call-123",
|
||||
Success = true,
|
||||
Result = new ToolExecutionCompleteResult
|
||||
{
|
||||
Content = "{\"users\":[{\"name\":\"Alice\"}]}"
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Act
|
||||
AgentResponseUpdate result = agent.ConvertToAgentResponseUpdate(completeEvent);
|
||||
|
||||
// Assert
|
||||
Assert.Equal(ChatRole.Tool, result.Role);
|
||||
Assert.Equal("agent-2", result.AgentId);
|
||||
|
||||
var content = Assert.Single(result.Contents);
|
||||
var functionResult = Assert.IsType<FunctionResultContent>(content);
|
||||
Assert.Equal("call-123", functionResult.CallId);
|
||||
Assert.Equal("{\"users\":[{\"name\":\"Alice\"}]}", functionResult.Result);
|
||||
Assert.Same(completeEvent, functionResult.RawRepresentation);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ConvertToAgentResponseUpdate_ToolExecutionCompleteEvent_WithError_ProducesErrorResult()
|
||||
{
|
||||
// Arrange
|
||||
var copilotClient = new CopilotClient(new CopilotClientOptions());
|
||||
var agent = new GitHubCopilotAgent(copilotClient, ownsClient: false, tools: null);
|
||||
|
||||
var completeEvent = new ToolExecutionCompleteEvent
|
||||
{
|
||||
Data = new ToolExecutionCompleteData
|
||||
{
|
||||
ToolCallId = "call-789",
|
||||
Success = false,
|
||||
Error = new ToolExecutionCompleteError
|
||||
{
|
||||
Code = "PERMISSION_DENIED",
|
||||
Message = "Access denied to resource"
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Act
|
||||
AgentResponseUpdate result = agent.ConvertToAgentResponseUpdate(completeEvent);
|
||||
|
||||
// Assert
|
||||
Assert.Equal(ChatRole.Tool, result.Role);
|
||||
|
||||
var content = Assert.Single(result.Contents);
|
||||
var functionResult = Assert.IsType<FunctionResultContent>(content);
|
||||
Assert.Equal("call-789", functionResult.CallId);
|
||||
Assert.Equal("Access denied to resource", functionResult.Result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ConvertToAgentResponseUpdate_ToolExecutionCompleteEvent_WithFailureNoError_ProducesDefaultErrorMessage()
|
||||
{
|
||||
// Arrange
|
||||
var copilotClient = new CopilotClient(new CopilotClientOptions());
|
||||
var agent = new GitHubCopilotAgent(copilotClient, ownsClient: false, tools: null);
|
||||
|
||||
var completeEvent = new ToolExecutionCompleteEvent
|
||||
{
|
||||
Data = new ToolExecutionCompleteData
|
||||
{
|
||||
ToolCallId = "call-000",
|
||||
Success = false,
|
||||
Error = null
|
||||
}
|
||||
};
|
||||
|
||||
// Act
|
||||
AgentResponseUpdate result = agent.ConvertToAgentResponseUpdate(completeEvent);
|
||||
|
||||
// Assert
|
||||
var content = Assert.Single(result.Contents);
|
||||
var functionResult = Assert.IsType<FunctionResultContent>(content);
|
||||
Assert.Equal("call-000", functionResult.CallId);
|
||||
Assert.Equal("Tool execution failed", functionResult.Result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ConvertToAgentResponseUpdate_ToolExecutionCompleteEvent_WithNullData_ProducesEmptyResult()
|
||||
{
|
||||
// Arrange
|
||||
var copilotClient = new CopilotClient(new CopilotClientOptions());
|
||||
var agent = new GitHubCopilotAgent(copilotClient, ownsClient: false, tools: null);
|
||||
|
||||
var completeEvent = new ToolExecutionCompleteEvent { Data = null! };
|
||||
|
||||
// Act
|
||||
AgentResponseUpdate result = agent.ConvertToAgentResponseUpdate(completeEvent);
|
||||
|
||||
// Assert
|
||||
var content = Assert.Single(result.Contents);
|
||||
var functionResult = Assert.IsType<FunctionResultContent>(content);
|
||||
Assert.Equal(string.Empty, functionResult.CallId);
|
||||
Assert.Equal("Tool execution failed", functionResult.Result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ConvertToAgentResponseUpdate_ToolExecutionStartEvent_WithMultipleArguments_ParsesAll()
|
||||
{
|
||||
// Arrange
|
||||
var copilotClient = new CopilotClient(new CopilotClientOptions());
|
||||
var agent = new GitHubCopilotAgent(copilotClient, ownsClient: false, tools: null);
|
||||
|
||||
var startEvent = new ToolExecutionStartEvent
|
||||
{
|
||||
Data = new ToolExecutionStartData
|
||||
{
|
||||
ToolCallId = "call-multi",
|
||||
ToolName = "queryTable",
|
||||
Arguments = ParseJson("{\"table\":\"incidents\",\"limit\":10,\"filter\":\"active=true\"}")
|
||||
}
|
||||
};
|
||||
|
||||
// Act
|
||||
AgentResponseUpdate result = agent.ConvertToAgentResponseUpdate(startEvent);
|
||||
|
||||
// Assert
|
||||
var content = Assert.Single(result.Contents);
|
||||
var functionCall = Assert.IsType<FunctionCallContent>(content);
|
||||
Assert.Equal("call-multi", functionCall.CallId);
|
||||
Assert.Equal("queryTable", functionCall.Name);
|
||||
Assert.NotNull(functionCall.Arguments);
|
||||
Assert.Equal("incidents", functionCall.Arguments!["table"]?.ToString());
|
||||
Assert.Equal("10", functionCall.Arguments!["limit"]?.ToString());
|
||||
Assert.Equal("active=true", functionCall.Arguments!["filter"]?.ToString());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ConvertToAgentResponseUpdate_ToolExecutionCompleteEvent_WithSuccessButNullResult_ProducesNullResult()
|
||||
{
|
||||
// Arrange
|
||||
var copilotClient = new CopilotClient(new CopilotClientOptions());
|
||||
var agent = new GitHubCopilotAgent(copilotClient, ownsClient: false, tools: null);
|
||||
|
||||
var completeEvent = new ToolExecutionCompleteEvent
|
||||
{
|
||||
Data = new ToolExecutionCompleteData
|
||||
{
|
||||
ToolCallId = "call-null-result",
|
||||
Success = true,
|
||||
Result = null
|
||||
}
|
||||
};
|
||||
|
||||
// Act
|
||||
AgentResponseUpdate result = agent.ConvertToAgentResponseUpdate(completeEvent);
|
||||
|
||||
// Assert
|
||||
var content = Assert.Single(result.Contents);
|
||||
var functionResult = Assert.IsType<FunctionResultContent>(content);
|
||||
Assert.Equal("call-null-result", functionResult.CallId);
|
||||
Assert.Null(functionResult.Result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ConvertToAgentResponseUpdate_ToolExecutionStartEvent_WithEmptyObjectArguments_ProducesEmptyDictionary()
|
||||
{
|
||||
// Arrange
|
||||
var copilotClient = new CopilotClient(new CopilotClientOptions());
|
||||
var agent = new GitHubCopilotAgent(copilotClient, ownsClient: false, tools: null);
|
||||
|
||||
var startEvent = new ToolExecutionStartEvent
|
||||
{
|
||||
Data = new ToolExecutionStartData
|
||||
{
|
||||
ToolCallId = "call-empty",
|
||||
ToolName = "noArgsTool",
|
||||
Arguments = ParseJson("{}")
|
||||
}
|
||||
};
|
||||
|
||||
// Act
|
||||
AgentResponseUpdate result = agent.ConvertToAgentResponseUpdate(startEvent);
|
||||
|
||||
// Assert
|
||||
var content = Assert.Single(result.Contents);
|
||||
var functionCall = Assert.IsType<FunctionCallContent>(content);
|
||||
Assert.Equal("call-empty", functionCall.CallId);
|
||||
Assert.NotNull(functionCall.Arguments);
|
||||
Assert.Empty(functionCall.Arguments!);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ConvertToAgentResponseUpdate_ToolExecutionStartEvent_WithNestedJsonArguments_ParsesTopLevel()
|
||||
{
|
||||
// Arrange
|
||||
var copilotClient = new CopilotClient(new CopilotClientOptions());
|
||||
var agent = new GitHubCopilotAgent(copilotClient, ownsClient: false, tools: null);
|
||||
|
||||
var startEvent = new ToolExecutionStartEvent
|
||||
{
|
||||
Data = new ToolExecutionStartData
|
||||
{
|
||||
ToolCallId = "call-nested",
|
||||
ToolName = "complexTool",
|
||||
Arguments = ParseJson("{\"config\":{\"timeout\":30},\"name\":\"test\"}")
|
||||
}
|
||||
};
|
||||
|
||||
// Act
|
||||
AgentResponseUpdate result = agent.ConvertToAgentResponseUpdate(startEvent);
|
||||
|
||||
// Assert
|
||||
var content = Assert.Single(result.Contents);
|
||||
var functionCall = Assert.IsType<FunctionCallContent>(content);
|
||||
Assert.Equal("call-nested", functionCall.CallId);
|
||||
Assert.NotNull(functionCall.Arguments);
|
||||
Assert.Equal("test", functionCall.Arguments!["name"]?.ToString());
|
||||
Assert.NotNull(functionCall.Arguments!["config"]);
|
||||
}
|
||||
}
|
||||
+4
-4
@@ -62,10 +62,10 @@ public sealed class A2AServerServiceCollectionExtensionsTests
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that when no ITaskStore or AgentSessionStore are registered,
|
||||
/// AddA2AServer falls back to in-memory defaults and resolves successfully.
|
||||
/// AddA2AServer falls back to noop session store default and resolves successfully.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task AddA2AServer_WithNoCustomStores_FallsBackToInMemoryDefaultsAsync()
|
||||
public async Task AddA2AServer_WithNoCustomStores_FallsBackToNoopSessionStoreDefaultAsync()
|
||||
{
|
||||
// Arrange
|
||||
const string AgentName = "default-stores-agent";
|
||||
@@ -382,7 +382,7 @@ public sealed class A2AServerServiceCollectionExtensionsTests
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that when no custom stores or handlers are registered, the server uses
|
||||
/// the default in-memory stores and processes requests successfully end-to-end.
|
||||
/// the default noop session store and processes requests successfully end-to-end.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task AddA2AServer_WithNoCustomStores_DefaultStoresProcessRequestSuccessfullyAsync()
|
||||
@@ -400,7 +400,7 @@ public sealed class A2AServerServiceCollectionExtensionsTests
|
||||
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(10));
|
||||
var response = await server.SendMessageAsync(CreateTestSendMessageRequest(), cts.Token);
|
||||
|
||||
// Assert - request was processed successfully with default in-memory stores
|
||||
// Assert - request was processed successfully with default noop session store
|
||||
Assert.NotNull(response);
|
||||
Assert.Equal(SendMessageResponseCase.Message, response.PayloadCase);
|
||||
Assert.NotNull(response.Message);
|
||||
|
||||
@@ -382,9 +382,10 @@ public sealed class AgentClassSkillTests
|
||||
// Arrange
|
||||
var skill = new AttributedFullSkill();
|
||||
|
||||
// Act & Assert — Content no longer includes resources in body; scripts are in script_schemas
|
||||
Assert.DoesNotContain("<resources>", await skill.GetContentAsync());
|
||||
Assert.Contains("<script_schemas>", await skill.GetContentAsync());
|
||||
// Act & Assert — Content includes resources in body; scripts are in available_scripts
|
||||
Assert.Contains("<available_resources>", await skill.GetContentAsync());
|
||||
Assert.Contains("conversion-table", await skill.GetContentAsync());
|
||||
Assert.Contains("<available_scripts>", await skill.GetContentAsync());
|
||||
Assert.Contains("convert", await skill.GetContentAsync());
|
||||
|
||||
// Act & Assert — discovered members are cached
|
||||
@@ -502,7 +503,7 @@ public sealed class AgentClassSkillTests
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Content_DoesNotRenderResources_InBodyAsync()
|
||||
public async Task Content_RendersResources_InBodyAsync()
|
||||
{
|
||||
// Arrange
|
||||
var skill = new AttributedResourcePropertiesSkill();
|
||||
@@ -510,8 +511,10 @@ public sealed class AgentClassSkillTests
|
||||
// Act
|
||||
var content = await skill.GetContentAsync();
|
||||
|
||||
// Assert — resources are no longer rendered in body content
|
||||
Assert.DoesNotContain("<resources>", content);
|
||||
// Assert — resources are rendered in body content by name; descriptions are not emitted
|
||||
Assert.Contains("<available_resources>", content);
|
||||
Assert.Contains("ref-data", content);
|
||||
Assert.DoesNotContain("Some important data.", content);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
|
||||
+58
-7
@@ -122,14 +122,17 @@ public sealed class AgentFileSkillScriptTests
|
||||
|
||||
// Assert — content starts with original and appends per-script entries
|
||||
Assert.StartsWith("Original content", content);
|
||||
Assert.Contains("<script_schemas>", content);
|
||||
Assert.Contains("<schema script=\"build\">", content);
|
||||
Assert.Contains("<schema script=\"deploy\">", content);
|
||||
Assert.Contains("</script_schemas>", content);
|
||||
Assert.Contains("<available_scripts>", content);
|
||||
Assert.Contains("<script name=\"build\">", content);
|
||||
Assert.Contains("<script name=\"deploy\">", content);
|
||||
Assert.Contains("</available_scripts>", content);
|
||||
|
||||
// A scripts-only skill still emits an empty resources peer so the model knows none are available
|
||||
Assert.Contains("<available_resources />", content);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Content_WithoutScripts_ReturnsOriginalContentAsync()
|
||||
public async Task Content_WithoutResourcesOrScripts_EmitsSelfClosingPeersAsync()
|
||||
{
|
||||
// Arrange
|
||||
var fileSkill = new AgentFileSkill(
|
||||
@@ -140,8 +143,56 @@ public sealed class AgentFileSkillScriptTests
|
||||
// Act
|
||||
var content = await fileSkill.GetContentAsync();
|
||||
|
||||
// Assert
|
||||
Assert.Equal("Original content only", content);
|
||||
// Assert — both blocks are always emitted as self-closing elements so the model knows none are available
|
||||
Assert.StartsWith("Original content only", content);
|
||||
Assert.Contains("<available_resources />", content);
|
||||
Assert.Contains("<available_scripts />", content);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Content_WithResources_AppendsResourceEntriesAsync()
|
||||
{
|
||||
// Arrange
|
||||
var fileSkill = new AgentFileSkill(
|
||||
new AgentSkillFrontmatter("my-skill", "A skill"),
|
||||
"Original content",
|
||||
"/skills/my-skill",
|
||||
resources: [new AgentInlineSkillResource("reference", "value"), new AgentInlineSkillResource("table", "value")]);
|
||||
|
||||
// Act
|
||||
var content = await fileSkill.GetContentAsync();
|
||||
|
||||
// Assert — content starts with original and appends per-resource entries so the model knows what is callable
|
||||
Assert.StartsWith("Original content", content);
|
||||
Assert.Contains("<available_resources>", content);
|
||||
Assert.Contains("<resource name=\"reference\"/>", content);
|
||||
Assert.Contains("<resource name=\"table\"/>", content);
|
||||
Assert.Contains("</available_resources>", content);
|
||||
|
||||
// A resources-only skill still emits an empty scripts peer so the model knows none are available
|
||||
Assert.Contains("<available_scripts />", content);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Content_WithResourcesAndScripts_AppendsResourcesBeforeScriptsAsync()
|
||||
{
|
||||
// Arrange
|
||||
static Task<object?> RunnerAsync(AgentFileSkill s, AgentFileSkillScript sc, JsonElement? a, IServiceProvider? sp, CancellationToken ct) => Task.FromResult<object?>(null);
|
||||
var fileSkill = new AgentFileSkill(
|
||||
new AgentSkillFrontmatter("my-skill", "A skill"),
|
||||
"Original content",
|
||||
"/skills/my-skill",
|
||||
resources: [new AgentInlineSkillResource("reference", "value")],
|
||||
scripts: [CreateScript("build", "/scripts/build.sh", RunnerAsync)]);
|
||||
|
||||
// Act
|
||||
var content = await fileSkill.GetContentAsync();
|
||||
|
||||
// Assert — resources block precedes scripts block
|
||||
var resourcesIndex = content.IndexOf("<available_resources>", StringComparison.Ordinal);
|
||||
var scriptsIndex = content.IndexOf("<available_scripts>", StringComparison.Ordinal);
|
||||
Assert.True(resourcesIndex >= 0 && scriptsIndex >= 0);
|
||||
Assert.True(resourcesIndex < scriptsIndex);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
|
||||
+241
@@ -0,0 +1,241 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System;
|
||||
using System.Text.Json;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace Microsoft.Agents.AI.UnitTests.AgentSkills;
|
||||
|
||||
/// <summary>
|
||||
/// Unit tests for <see cref="AgentInlineSkillContentBuilder"/>, focusing on the structure of the
|
||||
/// emitted <c><available_resources></c> and <c><available_scripts></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<b>&"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<b>&"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<b>&"c\"/>", block);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BuildAvailableScriptsBlock_NullScripts_Throws() =>
|
||||
Assert.Throws<ArgumentNullException>(() => AgentInlineSkillContentBuilder.BuildAvailableScriptsBlock(null!));
|
||||
|
||||
private static JsonElement ParseSchema(string json) => JsonDocument.Parse(json).RootElement.Clone();
|
||||
|
||||
private sealed class FakeScript : AgentSkillScript
|
||||
{
|
||||
private readonly JsonElement? _parametersSchema;
|
||||
|
||||
public FakeScript(string name, JsonElement? parametersSchema)
|
||||
: base(name)
|
||||
{
|
||||
this._parametersSchema = parametersSchema;
|
||||
}
|
||||
|
||||
public override JsonElement? ParametersSchema => this._parametersSchema;
|
||||
|
||||
public override Task<object?> RunAsync(AgentSkill skill, JsonElement? arguments, IServiceProvider? serviceProvider, CancellationToken cancellationToken = default) =>
|
||||
Task.FromResult<object?>(null);
|
||||
}
|
||||
}
|
||||
@@ -149,7 +149,7 @@ public sealed class AgentInlineSkillTests
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Content_DoesNotIncludeResourcesInBodyAsync()
|
||||
public async Task Content_IncludesResourcesInBodyAsync()
|
||||
{
|
||||
// Arrange
|
||||
var skill = new AgentInlineSkill("my-skill", "A valid skill.", "Instructions.");
|
||||
@@ -158,12 +158,14 @@ public sealed class AgentInlineSkillTests
|
||||
// Act
|
||||
var content = await skill.GetContentAsync();
|
||||
|
||||
// Assert — resources are no longer rendered in the body; they're accessed via GetResourceAsync
|
||||
Assert.DoesNotContain("<resources>", content);
|
||||
// Assert — resources are rendered in the body so the model can discover them
|
||||
Assert.Contains("<available_resources>", content);
|
||||
Assert.Contains("<resource name=\"config\"/>", content);
|
||||
Assert.DoesNotContain("A config resource.", content);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Content_DoesNotIncludeDelegateResourcesInBodyAsync()
|
||||
public async Task Content_IncludesDelegateResourcesInBodyAsync()
|
||||
{
|
||||
// Arrange
|
||||
var skill = new AgentInlineSkill("my-skill", "A valid skill.", "Instructions.");
|
||||
@@ -172,8 +174,9 @@ public sealed class AgentInlineSkillTests
|
||||
// Act
|
||||
var content = await skill.GetContentAsync();
|
||||
|
||||
// Assert — resources are no longer rendered in the body
|
||||
Assert.DoesNotContain("<resources>", content);
|
||||
// Assert — resources are rendered in the body
|
||||
Assert.Contains("<available_resources>", content);
|
||||
Assert.Contains("<resource name=\"dynamic\"/>", content);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -187,8 +190,8 @@ public sealed class AgentInlineSkillTests
|
||||
var content = await skill.GetContentAsync();
|
||||
|
||||
// Assert
|
||||
Assert.Contains("<script_schemas>", content);
|
||||
Assert.Contains("run", content);
|
||||
Assert.Contains("<available_scripts>", content);
|
||||
Assert.Contains("<script name=\"run\"", content);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -218,8 +221,9 @@ public sealed class AgentInlineSkillTests
|
||||
var content = await skill.GetContentAsync();
|
||||
|
||||
// Assert
|
||||
Assert.DoesNotContain("<resources>", content);
|
||||
Assert.Contains("<script_schemas>", content);
|
||||
Assert.Contains("<available_resources>", content);
|
||||
Assert.Contains("r1", content);
|
||||
Assert.Contains("<available_scripts>", content);
|
||||
Assert.Contains("s1", content);
|
||||
}
|
||||
|
||||
@@ -233,8 +237,9 @@ public sealed class AgentInlineSkillTests
|
||||
// Act
|
||||
var content = await skill.GetContentAsync();
|
||||
|
||||
// Assert — JSON schema should be present inside <schema> element (no extra wrapper) with preserved quotes
|
||||
Assert.Contains("<schema script=\"search\">", content);
|
||||
// Assert — JSON schema should be present inside <parameters_schema> element with preserved quotes
|
||||
Assert.Contains("<script name=\"search\">", content);
|
||||
Assert.Contains("<parameters_schema>", content);
|
||||
Assert.Contains("\"query\"", content);
|
||||
Assert.DoesNotContain("<![CDATA[", content);
|
||||
}
|
||||
@@ -417,7 +422,7 @@ public sealed class AgentInlineSkillTests
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Content_NoResourcesOrScripts_DoesNotContainResourcesOrScriptsTagsAsync()
|
||||
public async Task Content_NoResourcesOrScripts_EmitsSelfClosingTagsAsync()
|
||||
{
|
||||
// Arrange
|
||||
var skill = new AgentInlineSkill("my-skill", "A valid skill.", "Instructions.");
|
||||
@@ -425,9 +430,9 @@ public sealed class AgentInlineSkillTests
|
||||
// Act
|
||||
var content = await skill.GetContentAsync();
|
||||
|
||||
// Assert
|
||||
Assert.DoesNotContain("<resources>", content);
|
||||
Assert.DoesNotContain("<script_schemas>", content);
|
||||
// Assert — empty self-closing elements are emitted when no resources or scripts exist
|
||||
Assert.Contains("<available_resources />", content);
|
||||
Assert.Contains("<available_scripts />", content);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -470,9 +475,8 @@ public sealed class AgentInlineSkillTests
|
||||
// Act
|
||||
var content = await skill.GetContentAsync();
|
||||
|
||||
// Assert — description is no longer emitted in the script_schemas block;
|
||||
// the block only contains parameter schemas for calling scripts.
|
||||
Assert.Contains("<schema script=\"my-script\"", content);
|
||||
// Assert — only the script name is emitted; the description is not rendered as an attribute
|
||||
Assert.Contains("<script name=\"my-script\"", content);
|
||||
Assert.DoesNotContain("description=\"Runs something.\"", content);
|
||||
}
|
||||
|
||||
@@ -492,7 +496,7 @@ public sealed class AgentInlineSkillTests
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Content_ResourceWithDescription_NotRenderedInBodyAsync()
|
||||
public async Task Content_ResourceWithDescription_RenderedInBodyWithoutDescriptionAsync()
|
||||
{
|
||||
// Arrange
|
||||
var skill = new AgentInlineSkill("my-skill", "A valid skill.", "Instructions.");
|
||||
@@ -502,10 +506,11 @@ public sealed class AgentInlineSkillTests
|
||||
// Act
|
||||
var content = await skill.GetContentAsync();
|
||||
|
||||
// Assert — resources are no longer rendered in the body
|
||||
Assert.DoesNotContain("<resources>", content);
|
||||
Assert.DoesNotContain("with-desc", content);
|
||||
Assert.DoesNotContain("no-desc", content);
|
||||
// Assert — resources are rendered by name in the body; descriptions are not emitted
|
||||
Assert.Contains("<available_resources>", content);
|
||||
Assert.Contains("<resource name=\"with-desc\"/>", content);
|
||||
Assert.Contains("<resource name=\"no-desc\"/>", content);
|
||||
Assert.DoesNotContain("A described resource.", content);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
|
||||
@@ -795,9 +795,9 @@ public class OpenTelemetryAgentTests
|
||||
[Fact]
|
||||
public async Task AutoWireChatClient_PlainAgentRunOptions_PreservesBaseProperties_Async()
|
||||
{
|
||||
// Auto-wiring converts a plain AgentRunOptions into a ChatClientAgentRunOptions. The base
|
||||
// properties (ContinuationToken, AllowBackgroundResponses, AdditionalProperties, ResponseFormat)
|
||||
// must be preserved so they reach the inner agent.
|
||||
// The auto-wire no longer rewrites the caller's options (the slot below FICC is activated once at
|
||||
// construction), so a plain AgentRunOptions reaches the inner agent unchanged with all base
|
||||
// properties (AllowBackgroundResponses, AdditionalProperties, ResponseFormat) intact.
|
||||
AgentRunOptions? observedOptions = null;
|
||||
var fakeChatClient = new AutoWireTestChatClient();
|
||||
var innerChatClientAgent = new ChatClientAgent(fakeChatClient);
|
||||
@@ -827,17 +827,27 @@ public class OpenTelemetryAgentTests
|
||||
|
||||
_ = await agent.RunAsync("hi", options: inputOptions);
|
||||
|
||||
// Options flow through unchanged (same instance, no conversion to ChatClientAgentRunOptions).
|
||||
Assert.NotNull(observedOptions);
|
||||
Assert.IsType<ChatClientAgentRunOptions>(observedOptions);
|
||||
Assert.Equal(true, observedOptions!.AllowBackgroundResponses);
|
||||
Assert.Same(inputOptions, observedOptions);
|
||||
Assert.Equal(true, observedOptions.AllowBackgroundResponses);
|
||||
Assert.Same(ChatResponseFormat.Json, observedOptions.ResponseFormat);
|
||||
Assert.NotNull(observedOptions.AdditionalProperties);
|
||||
Assert.Equal("customValue", observedOptions.AdditionalProperties!["customKey"]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AutoWireChatClient_UserFactoryReturnsInstrumentedClient_DoesNotDoubleWrap_Async()
|
||||
public async Task AutoWireChatClient_UserFactoryAddsOwnOTel_CoexistsWithBelowFiccSlot_Async()
|
||||
{
|
||||
// This is NOT a single model call counted twice. One model call is observed by two independent
|
||||
// OpenTelemetry layers, so each layer emits its own "chat" span:
|
||||
// - the framework's slot, always activated below FICC by OpenTelemetryAgent. This below-FICC layer
|
||||
// is what lets FICC emit execute_tool spans, so it must remain even when the caller adds their own
|
||||
// instrumentation. Dropping it to avoid the second span would reintroduce the missing-tool-span bug.
|
||||
// - the caller's per-run ChatClientFactory, which wraps the pipeline above FICC with its own
|
||||
// OpenTelemetryChatClient.
|
||||
// The two chat spans nest and measure different scopes (the above-FICC span covers the whole tool loop,
|
||||
// the below-FICC span covers each individual model call), so both coexisting is the intended result.
|
||||
var sourceName = Guid.NewGuid().ToString();
|
||||
var activities = new List<Activity>();
|
||||
using var tracerProvider = OpenTelemetry.Sdk.CreateTracerProviderBuilder()
|
||||
@@ -849,7 +859,7 @@ public class OpenTelemetryAgentTests
|
||||
var inner = new ChatClientAgent(fakeChatClient);
|
||||
using var agent = new OpenTelemetryAgent(inner, sourceName);
|
||||
|
||||
// User factory wraps the chat client with OpenTelemetryChatClient itself.
|
||||
// User factory wraps the chat client with OpenTelemetryChatClient itself (above FICC).
|
||||
var runOptions = new ChatClientAgentRunOptions
|
||||
{
|
||||
ChatClientFactory = cc => cc.AsBuilder().UseOpenTelemetry(sourceName: sourceName).Build(),
|
||||
@@ -857,8 +867,10 @@ public class OpenTelemetryAgentTests
|
||||
|
||||
_ = await agent.RunAsync("hi", options: runOptions);
|
||||
|
||||
// Expect 2 activities (invoke_agent + a single chat span). If we double-wrapped, we would see 3.
|
||||
Assert.Equal(2, activities.Count);
|
||||
// invoke_agent + two chat spans: one from the caller's above-FICC OTel and one from the slot below FICC.
|
||||
Assert.Equal(3, activities.Count);
|
||||
Assert.Contains(activities, a => a.DisplayName.StartsWith("invoke_agent", StringComparison.Ordinal));
|
||||
Assert.Equal(2, activities.Count(a => string.Equals(a.GetTagItem("gen_ai.operation.name") as string, "chat", StringComparison.Ordinal)));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
@@ -893,8 +905,8 @@ public class OpenTelemetryAgentTests
|
||||
[Fact]
|
||||
public async Task AutoWireChatClient_PlainAgentRunOptions_PreservesContinuationToken_Async()
|
||||
{
|
||||
// ContinuationToken is the fourth base AgentRunOptions property copied by CopyBaseAgentRunOptions
|
||||
// and is not exercised by AutoWireChatClient_PlainAgentRunOptions_PreservesBaseProperties_Async.
|
||||
// ContinuationToken on a plain AgentRunOptions must reach the inner agent unchanged now that the
|
||||
// auto-wire passes the caller's options straight through (no conversion to ChatClientAgentRunOptions).
|
||||
AgentRunOptions? observedOptions = null;
|
||||
var fakeChatClient = new AutoWireTestChatClient();
|
||||
var innerChatClientAgent = new ChatClientAgent(fakeChatClient);
|
||||
@@ -921,8 +933,8 @@ public class OpenTelemetryAgentTests
|
||||
_ = await agent.RunAsync("hi", options: inputOptions);
|
||||
|
||||
Assert.NotNull(observedOptions);
|
||||
Assert.IsType<ChatClientAgentRunOptions>(observedOptions);
|
||||
Assert.Same(token, observedOptions!.ContinuationToken);
|
||||
Assert.Same(inputOptions, observedOptions);
|
||||
Assert.Same(token, observedOptions.ContinuationToken);
|
||||
}
|
||||
#pragma warning restore MEAI001
|
||||
|
||||
@@ -1061,11 +1073,10 @@ public class OpenTelemetryAgentTests
|
||||
[InlineData(true, true)]
|
||||
public async Task AutoWireChatClient_EnableSensitiveData_PropagatedToInnerChatClient_Async(bool enableSensitiveData, bool streaming)
|
||||
{
|
||||
// Regression test for: when EnableSensitiveData is set on OpenTelemetryAgent, the auto-wired
|
||||
// inner OpenTelemetryChatClient must also have EnableSensitiveData propagated to it. Previously,
|
||||
// GetRunOptionsWithChatClientWiring created the inner client without passing EnableSensitiveData,
|
||||
// so the inner chat span would never emit gen_ai.input.messages / gen_ai.output.messages even
|
||||
// when the caller explicitly set EnableSensitiveData = true.
|
||||
// Regression test (issue #5873): when EnableSensitiveData is set on OpenTelemetryAgent, the auto-wired
|
||||
// inner OpenTelemetryChatClient (the below-FICC slot) must also have EnableSensitiveData propagated to it,
|
||||
// so the inner chat span captures gen_ai.input.messages / gen_ai.output.messages. The agent sets the value
|
||||
// on the slot after construction, since EnableSensitiveData is typically set via the UseOpenTelemetry callback.
|
||||
var sourceName = Guid.NewGuid().ToString();
|
||||
var activities = new List<Activity>();
|
||||
using var tracerProvider = OpenTelemetry.Sdk.CreateTracerProviderBuilder()
|
||||
@@ -1109,6 +1120,71 @@ public class OpenTelemetryAgentTests
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AutoWireChatClient_EmitsExecuteToolSpans_Async()
|
||||
{
|
||||
// The core of the OTel-below-FICC fix: with the slot active below FICC, the inner chat span closes
|
||||
// before FICC invokes tools, so Activity.Current is the invoke_agent span and FICC emits an
|
||||
// execute_tool span on the agent source, parented under invoke_agent.
|
||||
var sourceName = Guid.NewGuid().ToString();
|
||||
var activities = new List<Activity>();
|
||||
using var tracerProvider = OpenTelemetry.Sdk.CreateTracerProviderBuilder()
|
||||
.AddSource(sourceName)
|
||||
.AddInMemoryExporter(activities)
|
||||
.Build();
|
||||
|
||||
var tool = AIFunctionFactory.Create(() => "sunny", "get_weather");
|
||||
var fakeChatClient = new ToolCallingTestChatClient();
|
||||
var inner = new ChatClientAgent(fakeChatClient, new ChatClientAgentOptions
|
||||
{
|
||||
ChatOptions = new ChatOptions { Tools = [tool] },
|
||||
});
|
||||
using var agent = new OpenTelemetryAgent(inner, sourceName);
|
||||
|
||||
_ = await agent.RunAsync("weather?");
|
||||
|
||||
var invokeAgent = Assert.Single(activities, a => a.DisplayName.StartsWith("invoke_agent", StringComparison.Ordinal));
|
||||
var executeTool = Assert.Single(activities, a => a.DisplayName.StartsWith("execute_tool", StringComparison.Ordinal));
|
||||
Assert.Equal(sourceName, executeTool.Source.Name);
|
||||
Assert.Equal(invokeAgent.SpanId, executeTool.ParentSpanId);
|
||||
Assert.Contains(activities, a => string.Equals(a.GetTagItem("gen_ai.operation.name") as string, "chat", StringComparison.Ordinal));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DeferredOpenTelemetryChatClient_InertUntilActivated_Async()
|
||||
{
|
||||
var sourceName = Guid.NewGuid().ToString();
|
||||
var activities = new List<Activity>();
|
||||
using var tracerProvider = OpenTelemetry.Sdk.CreateTracerProviderBuilder()
|
||||
.AddSource(sourceName)
|
||||
.AddInMemoryExporter(activities)
|
||||
.Build();
|
||||
|
||||
var leaf = new AutoWireTestChatClient();
|
||||
using var slot = new DeferredOpenTelemetryChatClient(leaf);
|
||||
|
||||
// Inert: resolves itself for its own type and forwards other lookups to the inner client. The bare
|
||||
// leaf is not instrumented, so the OpenTelemetryChatClient lookup is null and no span is emitted.
|
||||
Assert.False(slot.IsActive);
|
||||
Assert.Same(slot, slot.GetService(typeof(DeferredOpenTelemetryChatClient)));
|
||||
Assert.Null(slot.GetService(typeof(OpenTelemetryChatClient)));
|
||||
_ = await slot.GetResponseAsync("hi");
|
||||
Assert.Empty(activities);
|
||||
|
||||
// Active: routes through an OpenTelemetryChatClient that emits a chat span on the source.
|
||||
slot.Activate(sourceName);
|
||||
Assert.True(slot.IsActive);
|
||||
Assert.NotNull(slot.GetService(typeof(OpenTelemetryChatClient)));
|
||||
_ = await slot.GetResponseAsync("hi");
|
||||
var chat = Assert.Single(activities);
|
||||
Assert.Equal("chat", chat.GetTagItem("gen_ai.operation.name") as string);
|
||||
|
||||
// Idempotent: a second activation does not replace the existing wrapper.
|
||||
var target = slot.GetService(typeof(OpenTelemetryChatClient));
|
||||
slot.Activate(sourceName);
|
||||
Assert.Same(target, slot.GetService(typeof(OpenTelemetryChatClient)));
|
||||
}
|
||||
|
||||
private sealed class AutoWireTestChatClient : IChatClient
|
||||
{
|
||||
public Action<IEnumerable<ChatMessage>, ChatOptions?>? OnGetResponseAsync { get; set; }
|
||||
@@ -1132,5 +1208,40 @@ public class OpenTelemetryAgentTests
|
||||
public void Dispose() { }
|
||||
}
|
||||
|
||||
private sealed class ToolCallingTestChatClient : IChatClient
|
||||
{
|
||||
private int _callCount;
|
||||
|
||||
public Task<ChatResponse> GetResponseAsync(IEnumerable<ChatMessage> messages, ChatOptions? options = null, CancellationToken cancellationToken = default)
|
||||
{
|
||||
// First call returns a tool call so FICC invokes the tool; the second call returns the final text.
|
||||
if (Interlocked.Increment(ref this._callCount) == 1)
|
||||
{
|
||||
var call = new FunctionCallContent("call_1", "get_weather", new Dictionary<string, object?>());
|
||||
return Task.FromResult(new ChatResponse(new ChatMessage(ChatRole.Assistant, [call])));
|
||||
}
|
||||
|
||||
return Task.FromResult(new ChatResponse(new ChatMessage(ChatRole.Assistant, "done")));
|
||||
}
|
||||
|
||||
public async IAsyncEnumerable<ChatResponseUpdate> GetStreamingResponseAsync(IEnumerable<ChatMessage> messages, ChatOptions? options = null, [EnumeratorCancellation] CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (Interlocked.Increment(ref this._callCount) == 1)
|
||||
{
|
||||
yield return new ChatResponseUpdate(ChatRole.Assistant, [new FunctionCallContent("call_1", "get_weather", new Dictionary<string, object?>())]);
|
||||
await Task.Yield();
|
||||
yield break;
|
||||
}
|
||||
|
||||
await Task.Yield();
|
||||
yield return new ChatResponseUpdate(ChatRole.Assistant, "done");
|
||||
}
|
||||
|
||||
public object? GetService(Type serviceType, object? serviceKey = null) =>
|
||||
serviceType?.IsInstanceOfType(this) == true ? this : null;
|
||||
|
||||
public void Dispose() { }
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
|
||||
+135
@@ -0,0 +1,135 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Text.Json;
|
||||
using System.Text.RegularExpressions;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using FluentAssertions;
|
||||
using Microsoft.Agents.AI.Workflows.Checkpointing;
|
||||
using Microsoft.Agents.AI.Workflows.InProc;
|
||||
|
||||
namespace Microsoft.Agents.AI.Workflows.UnitTests;
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that a checkpoint serialized through <see cref="JsonCheckpointStore"/> can be restored
|
||||
/// after every <c>Version=X.Y.Z.W</c> substring in the persisted JSON is rewritten to a different value.
|
||||
/// </summary>
|
||||
public class CheckpointVersionToleranceTests
|
||||
{
|
||||
private sealed class EchoExecutor() : Executor("Echo")
|
||||
{
|
||||
protected override ProtocolBuilder ConfigureProtocol(ProtocolBuilder protocolBuilder)
|
||||
=> protocolBuilder.ConfigureRoutes(routeBuilder =>
|
||||
routeBuilder.AddHandler<string>((msg, ctx) => ctx.SendMessageAsync(msg)));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(ExecutionEnvironment.InProcess_OffThread)]
|
||||
[InlineData(ExecutionEnvironment.InProcess_Lockstep)]
|
||||
internal async Task Test_Checkpoint_Resumes_AfterAssemblyVersionRewriteAsync(ExecutionEnvironment environment)
|
||||
{
|
||||
// Arrange
|
||||
RequestPort<string, string> requestPort = RequestPort.Create<string, string>("TestPort");
|
||||
EchoExecutor echo = new();
|
||||
|
||||
Workflow workflow = new WorkflowBuilder(requestPort)
|
||||
.AddEdge(requestPort, echo)
|
||||
.Build();
|
||||
|
||||
VersionMutatingJsonStore store = new();
|
||||
CheckpointManager checkpointManager = CheckpointManager.CreateJson(store);
|
||||
InProcessExecutionEnvironment env = environment.ToWorkflowExecutionEnvironment();
|
||||
|
||||
// Run the workflow and capture a checkpoint.
|
||||
CheckpointInfo? checkpoint = null;
|
||||
await using (StreamingRun firstRun = await env.WithCheckpointing(checkpointManager)
|
||||
.RunStreamingAsync(workflow, "Hello"))
|
||||
{
|
||||
await foreach (WorkflowEvent evt in firstRun.WatchStreamAsync(blockOnPendingRequest: false))
|
||||
{
|
||||
if (evt is SuperStepCompletedEvent step && step.CompletionInfo?.Checkpoint is { } cp)
|
||||
{
|
||||
checkpoint = cp;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
checkpoint.Should().NotBeNull();
|
||||
store.MutationApplied.Should().BeFalse();
|
||||
|
||||
// Resume against the mutated store, which rewrites every Version=X.Y.Z.W in the persisted JSON.
|
||||
Func<Task> resume = async () =>
|
||||
{
|
||||
await using StreamingRun resumed = await env.WithCheckpointing(checkpointManager)
|
||||
.ResumeStreamingAsync(workflow, checkpoint!);
|
||||
using CancellationTokenSource cts = new(TimeSpan.FromSeconds(10));
|
||||
await foreach (WorkflowEvent _ in resumed.WatchStreamAsync(blockOnPendingRequest: false, cts.Token))
|
||||
{
|
||||
}
|
||||
};
|
||||
|
||||
await resume.Should().NotThrowAsync("resume must succeed when persisted assembly versions differ from loaded ones");
|
||||
store.MutationApplied.Should().BeTrue();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// JSON checkpoint store that rewrites every <c>Version=N.N.N.N</c> token in the persisted
|
||||
/// payload at retrieval time.
|
||||
/// </summary>
|
||||
private sealed class VersionMutatingJsonStore : JsonCheckpointStore
|
||||
{
|
||||
private static readonly Regex s_versionPattern = new(@"Version=\d+\.\d+\.\d+\.\d+", RegexOptions.Compiled);
|
||||
|
||||
private readonly Dictionary<string, Dictionary<string, JsonElement>> _store = [];
|
||||
|
||||
public string ReplacementVersion { get; init; } = "99.0.0.0";
|
||||
|
||||
public bool MutationApplied { get; private set; }
|
||||
|
||||
public override ValueTask<CheckpointInfo> CreateCheckpointAsync(string sessionId, JsonElement value, CheckpointInfo? parent = null)
|
||||
{
|
||||
if (!this._store.TryGetValue(sessionId, out Dictionary<string, JsonElement>? sessionStore))
|
||||
{
|
||||
sessionStore = this._store[sessionId] = [];
|
||||
}
|
||||
|
||||
CheckpointInfo info = new(sessionId);
|
||||
sessionStore[info.CheckpointId] = value.Clone();
|
||||
return new ValueTask<CheckpointInfo>(info);
|
||||
}
|
||||
|
||||
public override ValueTask<JsonElement> RetrieveCheckpointAsync(string sessionId, CheckpointInfo key)
|
||||
{
|
||||
if (!this._store.TryGetValue(sessionId, out Dictionary<string, JsonElement>? sessionStore)
|
||||
|| !sessionStore.TryGetValue(key.CheckpointId, out JsonElement raw))
|
||||
{
|
||||
throw new KeyNotFoundException($"Could not retrieve checkpoint with id {key.CheckpointId} for session {sessionId}");
|
||||
}
|
||||
|
||||
string rawText = raw.GetRawText();
|
||||
string mutatedText = s_versionPattern.Replace(rawText, $"Version={this.ReplacementVersion}");
|
||||
|
||||
if (!ReferenceEquals(rawText, mutatedText) && rawText != mutatedText)
|
||||
{
|
||||
this.MutationApplied = true;
|
||||
}
|
||||
|
||||
using JsonDocument doc = JsonDocument.Parse(mutatedText);
|
||||
return new ValueTask<JsonElement>(doc.RootElement.Clone());
|
||||
}
|
||||
|
||||
public override ValueTask<IEnumerable<CheckpointInfo>> RetrieveIndexAsync(string sessionId, CheckpointInfo? withParent = null)
|
||||
{
|
||||
if (!this._store.TryGetValue(sessionId, out Dictionary<string, JsonElement>? sessionStore))
|
||||
{
|
||||
return new ValueTask<IEnumerable<CheckpointInfo>>(Array.Empty<CheckpointInfo>());
|
||||
}
|
||||
|
||||
IEnumerable<CheckpointInfo> infos = sessionStore.Keys.Select(id => new CheckpointInfo(sessionId, id));
|
||||
return new ValueTask<IEnumerable<CheckpointInfo>>(infos);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,230 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
using FluentAssertions;
|
||||
using Microsoft.Agents.AI.Workflows.Checkpointing;
|
||||
using Microsoft.Extensions.AI;
|
||||
|
||||
namespace Microsoft.Agents.AI.Workflows.UnitTests;
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that <see cref="TypeId.IsMatch(Type)"/> and <see cref="TypeId.Equals(object?)"/>
|
||||
/// compare on the type full name and the simple assembly name, ignoring version, culture,
|
||||
/// and public key token both in the outer assembly name and in any assembly-qualified generic
|
||||
/// arguments embedded in the type full name.
|
||||
/// </summary>
|
||||
public class TypeIdVersionToleranceTests
|
||||
{
|
||||
[SuppressMessage("Performance", "CA1812", Justification = "Used via typeof() only; never instantiated.")]
|
||||
private sealed class Probe
|
||||
{
|
||||
}
|
||||
|
||||
private static string ProbeSimpleAssemblyName => typeof(Probe).Assembly.GetName().Name!;
|
||||
private static string ProbeTypeFullName => typeof(Probe).FullName!;
|
||||
|
||||
[Fact]
|
||||
public void Test_IsMatch_RoundTripsRealType()
|
||||
{
|
||||
TypeId id = new(typeof(Probe));
|
||||
|
||||
id.IsMatch(typeof(Probe)).Should().BeTrue();
|
||||
id.IsMatch<Probe>().Should().BeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Test_IsMatch_IgnoresAssemblyVersion()
|
||||
{
|
||||
string assemblyName = $"{ProbeSimpleAssemblyName}, Version=99.0.0.0, Culture=neutral, PublicKeyToken=null";
|
||||
TypeId id = new(assemblyName, ProbeTypeFullName);
|
||||
|
||||
id.IsMatch(typeof(Probe)).Should().BeTrue("version differences in AssemblyName must not affect matching");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Test_IsMatch_IgnoresCultureAndPublicKeyToken()
|
||||
{
|
||||
string assemblyName = $"{ProbeSimpleAssemblyName}, Version=99.0.0.0, Culture=en-US, PublicKeyToken=abcdef0123456789";
|
||||
TypeId id = new(assemblyName, ProbeTypeFullName);
|
||||
|
||||
id.IsMatch(typeof(Probe)).Should().BeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Test_IsMatch_AcceptsSimpleAssemblyNameOnly()
|
||||
{
|
||||
TypeId id = new(ProbeSimpleAssemblyName, ProbeTypeFullName);
|
||||
|
||||
id.IsMatch(typeof(Probe)).Should().BeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Test_IsMatch_RejectsDifferentSimpleAssemblyName()
|
||||
{
|
||||
TypeId id = new(
|
||||
assemblyName: "Some.Completely.Different.Assembly, Version=1.0.0.0",
|
||||
typeName: ProbeTypeFullName);
|
||||
|
||||
id.IsMatch(typeof(Probe)).Should().BeFalse("different simple assembly names must not match");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Test_IsMatch_RejectsDifferentTypeName()
|
||||
{
|
||||
TypeId id = new(
|
||||
assemblyName: $"{ProbeSimpleAssemblyName}, Version=99.0.0.0",
|
||||
typeName: "Some.Other.Namespace.Probe");
|
||||
|
||||
id.IsMatch(typeof(Probe)).Should().BeFalse("different type names must not match");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Test_IsMatch_ToleratesMalformedAssemblyName()
|
||||
{
|
||||
TypeId id = new(
|
||||
assemblyName: $"{ProbeSimpleAssemblyName}, Version=not-a-version, Culture=??, PublicKeyToken=???",
|
||||
typeName: ProbeTypeFullName);
|
||||
|
||||
id.IsMatch(typeof(Probe)).Should().BeTrue("the substring fallback recovers the simple name when AssemblyName parsing fails");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Test_IsMatchPolymorphic_IgnoresAssemblyVersion()
|
||||
{
|
||||
TypeId id = new(
|
||||
assemblyName: $"{typeof(object).Assembly.GetName().Name}, Version=99.0.0.0",
|
||||
typeName: typeof(object).FullName!);
|
||||
|
||||
id.IsMatchPolymorphic(typeof(Probe)).Should().BeTrue("IsMatchPolymorphic uses the same comparison rules as IsMatch");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Test_Equals_IgnoresAssemblyVersion()
|
||||
{
|
||||
TypeId v1 = new($"{ProbeSimpleAssemblyName}, Version=1.0.0.0", ProbeTypeFullName);
|
||||
TypeId v2 = new($"{ProbeSimpleAssemblyName}, Version=2.0.0.0", ProbeTypeFullName);
|
||||
|
||||
v1.Equals(v2).Should().BeTrue();
|
||||
(v1 == v2).Should().BeTrue();
|
||||
v1.GetHashCode().Should().Be(v2.GetHashCode());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Test_Equals_RejectsDifferentSimpleAssemblyName()
|
||||
{
|
||||
TypeId a = new($"{ProbeSimpleAssemblyName}, Version=1.0.0.0", ProbeTypeFullName);
|
||||
TypeId b = new("Some.Other.Assembly, Version=1.0.0.0", ProbeTypeFullName);
|
||||
|
||||
a.Equals(b).Should().BeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Test_Equals_RejectsDifferentTypeName()
|
||||
{
|
||||
TypeId a = new($"{ProbeSimpleAssemblyName}, Version=1.0.0.0", ProbeTypeFullName);
|
||||
TypeId b = new($"{ProbeSimpleAssemblyName}, Version=1.0.0.0", "Some.Other.Type");
|
||||
|
||||
a.Equals(b).Should().BeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Test_Dictionary_LookupAcrossVersions()
|
||||
{
|
||||
TypeId live = new(typeof(Probe));
|
||||
TypeId mutated = new(
|
||||
assemblyName: $"{ProbeSimpleAssemblyName}, Version=99.0.0.0, Culture=neutral, PublicKeyToken=null",
|
||||
typeName: ProbeTypeFullName);
|
||||
|
||||
Dictionary<TypeId, string> map = new() { [live] = "value" };
|
||||
map.TryGetValue(mutated, out string? value).Should().BeTrue();
|
||||
value.Should().Be("value");
|
||||
|
||||
HashSet<TypeId> set = new() { live };
|
||||
set.Contains(mutated).Should().BeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Test_Equals_TreatsIdenticalStringsAsEqual()
|
||||
{
|
||||
TypeId a = new(typeof(Probe));
|
||||
TypeId b = new(typeof(Probe));
|
||||
|
||||
a.Equals(b).Should().BeTrue();
|
||||
a.GetHashCode().Should().Be(b.GetHashCode());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Test_NormalizeTypeName_ReturnsInputWhenNoAssemblyQualifier()
|
||||
{
|
||||
const string TypeName = "Microsoft.Agents.AI.Workflows.Checkpointing.TypeId";
|
||||
|
||||
TypeId.NormalizeTypeName(TypeName).Should().BeSameAs(TypeName);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Test_NormalizeTypeName_StripsVersionCultureAndPublicKeyTokenTriplets()
|
||||
{
|
||||
const string TypeName = "System.Collections.Generic.List`1[[Some.Type, Some.Asm, Version=1.2.3.4, Culture=neutral, PublicKeyToken=abcdef0123456789]]";
|
||||
const string Expected = "System.Collections.Generic.List`1[[Some.Type, Some.Asm]]";
|
||||
|
||||
TypeId.NormalizeTypeName(TypeName).Should().Be(Expected);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Test_NormalizeTypeName_StripsTripletsFromNestedGenericArguments()
|
||||
{
|
||||
const string TypeName = "System.Collections.Generic.Dictionary`2[[System.String, mscorlib, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089],[System.Collections.Generic.List`1[[Some.Type, Some.Asm, Version=1.0.0.0, Culture=neutral, PublicKeyToken=null]], mscorlib, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089]]";
|
||||
const string Expected = "System.Collections.Generic.Dictionary`2[[System.String, mscorlib],[System.Collections.Generic.List`1[[Some.Type, Some.Asm]], mscorlib]]";
|
||||
|
||||
TypeId.NormalizeTypeName(TypeName).Should().Be(Expected);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Test_IsMatch_IgnoresVersionInGenericArguments()
|
||||
{
|
||||
Type live = typeof(List<ChatMessage>);
|
||||
string simpleAssemblyName = live.Assembly.GetName().Name!;
|
||||
|
||||
// Hand-craft a TypeName as if persisted under a different version of the generic
|
||||
// argument assembly (Microsoft.Extensions.AI.Abstractions).
|
||||
string innerArgSimpleName = typeof(ChatMessage).Assembly.GetName().Name!;
|
||||
string mutatedTypeName = $"System.Collections.Generic.List`1[[Microsoft.Extensions.AI.ChatMessage, {innerArgSimpleName}, Version=99.0.0.0, Culture=neutral, PublicKeyToken=null]]";
|
||||
|
||||
TypeId id = new(simpleAssemblyName, mutatedTypeName);
|
||||
|
||||
id.IsMatch(live).Should().BeTrue("version differences inside generic argument names must not affect matching");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Test_Equals_IgnoresVersionInGenericArguments()
|
||||
{
|
||||
Type live = typeof(List<ChatMessage>);
|
||||
TypeId fromLive = new(live);
|
||||
|
||||
string simpleAssemblyName = live.Assembly.GetName().Name!;
|
||||
string innerArgSimpleName = typeof(ChatMessage).Assembly.GetName().Name!;
|
||||
string mutatedTypeName = $"System.Collections.Generic.List`1[[Microsoft.Extensions.AI.ChatMessage, {innerArgSimpleName}, Version=99.0.0.0, Culture=neutral, PublicKeyToken=null]]";
|
||||
TypeId fromMutated = new(simpleAssemblyName, mutatedTypeName);
|
||||
|
||||
fromLive.Equals(fromMutated).Should().BeTrue();
|
||||
fromLive.GetHashCode().Should().Be(fromMutated.GetHashCode());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Test_Dictionary_LookupAcrossGenericArgumentVersions()
|
||||
{
|
||||
Type live = typeof(List<ChatMessage>);
|
||||
TypeId fromLive = new(live);
|
||||
|
||||
string simpleAssemblyName = live.Assembly.GetName().Name!;
|
||||
string innerArgSimpleName = typeof(ChatMessage).Assembly.GetName().Name!;
|
||||
string mutatedTypeName = $"System.Collections.Generic.List`1[[Microsoft.Extensions.AI.ChatMessage, {innerArgSimpleName}, Version=99.0.0.0, Culture=neutral, PublicKeyToken=null]]";
|
||||
TypeId fromMutated = new(simpleAssemblyName, mutatedTypeName);
|
||||
|
||||
Dictionary<TypeId, string> map = new() { [fromLive] = "value" };
|
||||
map.TryGetValue(fromMutated, out string? value).Should().BeTrue();
|
||||
value.Should().Be("value");
|
||||
}
|
||||
}
|
||||
+67
@@ -0,0 +1,67 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
using FluentAssertions;
|
||||
using Microsoft.Agents.AI.Workflows.Checkpointing;
|
||||
using Microsoft.Extensions.AI;
|
||||
|
||||
namespace Microsoft.Agents.AI.Workflows.UnitTests;
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that <see cref="WorkflowSession.ResolveTypeLenient(TypeId)"/> resolves a
|
||||
/// <see cref="TypeId"/> to a loaded <see cref="Type"/> even when the stored assembly
|
||||
/// name carries a different <c>Version=</c> than the loaded assembly.
|
||||
/// </summary>
|
||||
public class WorkflowSessionResolveTypeLenientTests
|
||||
{
|
||||
[SuppressMessage("Performance", "CA1812", Justification = "Instantiated via Type.GetType in the production code path under test.")]
|
||||
private sealed class TestEnvelope : IExternalRequestEnvelope
|
||||
{
|
||||
AIContent? IExternalRequestEnvelope.GetInnerRequestContent() => null;
|
||||
|
||||
object IExternalRequestEnvelope.CreateResponse(IList<ChatMessage> messages) => messages;
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Test_ResolveTypeLenient_ResolvesWhenAssemblyNameMatchesLoadedVersion()
|
||||
{
|
||||
Type live = typeof(TestEnvelope);
|
||||
TypeId id = new(live);
|
||||
|
||||
WorkflowSession.ResolveTypeLenient(id).Should().Be(live);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Test_ResolveTypeLenient_ResolvesAcrossAssemblyVersionMutation()
|
||||
{
|
||||
Type live = typeof(TestEnvelope);
|
||||
string simpleAssemblyName = live.Assembly.GetName().Name!;
|
||||
string mutatedAssemblyName = $"{simpleAssemblyName}, Version=99.0.0.0, Culture=neutral, PublicKeyToken=null";
|
||||
TypeId mutated = new(mutatedAssemblyName, live.FullName!);
|
||||
|
||||
WorkflowSession.ResolveTypeLenient(mutated).Should().Be(live);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Test_ResolveTypeLenient_ReturnsNullForUnknownType()
|
||||
{
|
||||
TypeId id = new("Some.Unloaded.Assembly", "Some.Unknown.Type");
|
||||
|
||||
WorkflowSession.ResolveTypeLenient(id).Should().BeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Test_ResolveTypeLenient_ResolvesAcrossGenericArgumentVersionMutation()
|
||||
{
|
||||
Type live = typeof(List<ChatMessage>);
|
||||
string outerSimpleName = live.Assembly.GetName().Name!;
|
||||
string innerSimpleName = typeof(ChatMessage).Assembly.GetName().Name!;
|
||||
string mutatedTypeName = $"System.Collections.Generic.List`1[[Microsoft.Extensions.AI.ChatMessage, {innerSimpleName}, Version=99.0.0.0, Culture=neutral, PublicKeyToken=null]]";
|
||||
|
||||
TypeId mutated = new(outerSimpleName, mutatedTypeName);
|
||||
|
||||
WorkflowSession.ResolveTypeLenient(mutated).Should().Be(live);
|
||||
}
|
||||
}
|
||||
Vendored
-1
@@ -1 +0,0 @@
|
||||
../../../.github/skills/pull-requests
|
||||
+116
@@ -0,0 +1,116 @@
|
||||
---
|
||||
name: pull-requests
|
||||
description: >
|
||||
Guidance for creating pull requests and handling PR review comments in the
|
||||
Agent Framework repository. Use this when writing a PR description (filling out
|
||||
the PR template) or when responding to and resolving review comments on an
|
||||
existing PR.
|
||||
---
|
||||
|
||||
# Pull Request Workflow
|
||||
|
||||
This skill covers two tasks: (1) writing a high-quality PR description, and
|
||||
(2) handling review comments on an existing PR.
|
||||
|
||||
## 1. Writing the PR description
|
||||
|
||||
Always follow the repository PR template at
|
||||
[`.github/pull_request_template.md`](../../../../.github/pull_request_template.md). Keep its
|
||||
exact structure and headings. Fill every section:
|
||||
|
||||
### `### Motivation & Context`
|
||||
Explain *why* the change is needed: the problem it solves and the scenario it
|
||||
contributes to. Describe the net change relative to `main` — this is implied, so
|
||||
do **not** spell out "vs main" explicitly.
|
||||
|
||||
### `### Description & Review Guide`
|
||||
Describe the changes, the overall approach, and the design. Answer the three
|
||||
prompts:
|
||||
- **What are the major changes?**
|
||||
- **What is the impact of these changes?**
|
||||
- **What do you want reviewers to focus on?** — This item is for **human
|
||||
reviewers only**. Automated/AI reviewers must ignore it and review the entire
|
||||
change rather than narrowing scope to it.
|
||||
|
||||
### `### Related Issue`
|
||||
Link the issue the PR fixes using a GitHub closing keyword (`Fixes #123` /
|
||||
`Closes #123`) so it closes automatically on merge. A PR with no linked issue may
|
||||
be closed regardless of how valid the change is. Before opening, confirm there is
|
||||
no other open PR for the same issue; if there is, explain how this PR differs.
|
||||
|
||||
### `### Contribution Checklist`
|
||||
Check every item that applies. For the breaking-change item:
|
||||
- Leave **"This is not a breaking change."** checked for the common case.
|
||||
- If the change **is** breaking, add the `breaking change` label **or** put
|
||||
`[BREAKING]` in the title prefix, before or after a language prefix such as
|
||||
`Python:` or `.NET:` — workflows keep the label and the title prefix in sync
|
||||
automatically (see `.github/workflows/label-title-prefix.yml` and
|
||||
`.github/workflows/label-pr.yml`).
|
||||
|
||||
### Do not
|
||||
- Do **not** add ad-hoc sections such as "Validation" or "Tests run"; CI/CD and
|
||||
the checklist already cover validation status.
|
||||
- Do **not** remove or reorder the template's headings.
|
||||
|
||||
### Creating the PR
|
||||
Open new PRs as **drafts** until they are ready for review. Example:
|
||||
|
||||
```bash
|
||||
gh pr create --repo microsoft/agent-framework --base main \
|
||||
--head <your-fork-owner>:<branch> --draft \
|
||||
--title "<concise title>" --body "<body following the template>"
|
||||
```
|
||||
|
||||
## 2. Handling review comments
|
||||
|
||||
When a PR receives review comments, follow this sequence — **do not start editing
|
||||
code before the user has reviewed the plan**:
|
||||
|
||||
1. **Review the comments.** Read every review comment and thread on the PR,
|
||||
including inline code comments and general review summaries.
|
||||
2. **Make a plan.** Produce a concrete plan describing how each comment will be
|
||||
addressed (or why it should not be, with reasoning).
|
||||
3. **Let the user review the plan.** Present the plan and wait for the user's
|
||||
approval or adjustments before implementing anything.
|
||||
4. **Implement.** Make the agreed changes.
|
||||
5. **Reply to every comment.** Add a reply to **all** comments explaining how it
|
||||
was addressed (or the agreed outcome) — leave none unanswered.
|
||||
6. **Resolve resolved threads.** Mark a review thread as resolved only when the
|
||||
comment has actually been addressed.
|
||||
|
||||
### Useful commands
|
||||
|
||||
List review comments and threads:
|
||||
|
||||
```bash
|
||||
# Inline review comments
|
||||
gh api repos/{owner}/{repo}/pulls/{pr}/comments
|
||||
|
||||
# Review threads with resolution state (GraphQL)
|
||||
gh api graphql -f query='
|
||||
query($owner:String!,$repo:String!,$pr:Int!){
|
||||
repository(owner:$owner,name:$repo){
|
||||
pullRequest(number:$pr){
|
||||
reviewThreads(first:100){
|
||||
nodes{ id isResolved comments(first:50){ nodes{ id body author{login} } } }
|
||||
}
|
||||
}
|
||||
}
|
||||
}' -F owner={owner} -F repo={repo} -F pr={pr}
|
||||
```
|
||||
|
||||
Reply to an inline review comment:
|
||||
|
||||
```bash
|
||||
gh api repos/{owner}/{repo}/pulls/{pr}/comments/{comment_id}/replies \
|
||||
-f body="Addressed in <commit>: <explanation>"
|
||||
```
|
||||
|
||||
Resolve a review thread (needs the thread node id from the GraphQL query above):
|
||||
|
||||
```bash
|
||||
gh api graphql -f query='
|
||||
mutation($threadId:ID!){
|
||||
resolveReviewThread(input:{threadId:$threadId}){ thread{ isResolved } }
|
||||
}' -F threadId={thread_id}
|
||||
```
|
||||
@@ -34,6 +34,8 @@ Status is grouped into these buckets:
|
||||
| `agent-framework-foundry-local` | `python/packages/foundry_local` | `beta` |
|
||||
| `agent-framework-gemini` | `python/packages/gemini` | `alpha` |
|
||||
| `agent-framework-github-copilot` | `python/packages/github_copilot` | `rc` |
|
||||
| `agent-framework-hosting` | `python/packages/hosting` | `alpha` |
|
||||
| `agent-framework-hosting-responses` | `python/packages/hosting-responses` | `alpha` |
|
||||
| `agent-framework-hyperlight` | `python/packages/hyperlight` | `beta` |
|
||||
| `agent-framework-lab` | `python/packages/lab` | `beta` |
|
||||
| `agent-framework-mem0` | `python/packages/mem0` | `beta` |
|
||||
|
||||
@@ -80,8 +80,9 @@ agent_framework/
|
||||
|
||||
- **`MCPTool`** - Base wrapper that owns the MCP `ClientSession` and exposes the remote server's tools as `FunctionTool`s.
|
||||
- **`MCPStdioTool`** / **`MCPStreamableHTTPTool`** / **`MCPWebsocketTool`** - Transport-specific subclasses.
|
||||
- **Argument allowlist (`_prepare_call_kwargs`)** - Before each `tools/call`, kwargs are filtered to an **allowlist** built from the tool's declared parameters (`inputSchema.properties`) plus any user-configured extras. Framework runtime kwargs injected through the function-invocation pipeline (e.g. `thread`, `conversation_id`, `chat_options`, `options`, `response_format`) are stripped by default rather than forwarded. A tool that declares no usable `properties` (including schemas with `additionalProperties: true`) forwards only the configured extras. The `_MCP_FRAMEWORK_DENYLIST` is a safety net for framework-named params a server *declares* in its schema (those are dropped); names explicitly opted in via `additional_tool_argument_names` always win. The reserved `_meta` key is extracted as MCP request metadata, never forwarded as an argument.
|
||||
- **`additional_tool_argument_names`** (constructor arg on all `MCPTool` subclasses) - Opt extra argument names back into the allowlist. Accepts a `Sequence[str]` (applied to every tool) or a `Mapping[str, Sequence[str]]` keyed by **remote tool name**, where the reserved key `"*"` denotes global extras. It is configured only in user code at construction; there is **no per-call/runtime override**, so a model-issued tool call cannot change which names pass through. To use a server that accepts `additionalProperties: true`, list the extra names here and then either (1) manually extend that tool's `inputSchema` (via the `.functions` list after connecting) so the model is prompted to supply them, or (2) supply the values yourself via `function_invocation_kwargs`. If a name is supplied by both the model and `function_invocation_kwargs`, the model-supplied value wins.
|
||||
- **Argument allowlist (`_prepare_call_kwargs`)** - Before each `tools/call`, kwargs are filtered to an **allowlist** built from the tool's declared parameters (`inputSchema.properties`) plus any user-configured extras. Framework runtime kwargs injected through the function-invocation pipeline (e.g. `thread`, `conversation_id`, `chat_options`, `options`, `response_format`) are stripped by default rather than forwarded. A tool that declares no usable `properties` (including schemas with `additionalProperties: true`) forwards only the configured extras. The `_MCP_FRAMEWORK_DENYLIST` is a safety net for framework-named params a server *declares* in its schema (those are dropped); names explicitly opted in via `additional_tool_argument_names` always win. The reserved `_meta` key is never forwarded as an argument; trusted caller/runtime `_meta` is validated as MCP request metadata, model-supplied `_meta` is discarded in generated MCP functions, and metadata precedence is caller/runtime < OpenTelemetry < tools/list metadata.
|
||||
- **`allowed_tools`** (constructor arg on all `MCPTool` subclasses) - Restricts exposed MCP tools by raw remote MCP tool identity. Prefixed local names remain accepted only when the raw remote name already matches its normalized form; normalized/local aliases do not authorize a different raw remote name. If multiple raw remote tool names map to the same local function name, tool loading raises `ToolExecutionException` instead of first-one-wins shadowing.
|
||||
- **`additional_tool_argument_names`** (constructor arg on all `MCPTool` subclasses) - Opt extra argument names back into the allowlist. Accepts a `Sequence[str]` (applied to every tool) or a `Mapping[str, Sequence[str]]` keyed by **remote tool name**, where the reserved key `"*"` denotes global extras. It is configured only in user code at construction; there is **no per-call/runtime override**, so a model-issued tool call cannot change which names pass through. To use a server that accepts `additionalProperties: true`, list the extra names here and then either (1) manually extend that tool's `inputSchema` (via the `.functions` list after connecting) so the model is prompted to supply them, or (2) supply the values yourself via `function_invocation_kwargs`. If a normal forwarded argument name is supplied by both the model and `function_invocation_kwargs`, the model-supplied value wins; `_meta` is the exception and only trusted runtime/caller metadata is used.
|
||||
- **Sampling guardrails** (`sampling_callback`) - Passing `client=` advertises `SamplingCapability` so the server can send `sampling/createMessage`. Because remote servers are untrusted (confused-deputy risk), the default `sampling_callback` is **deny-by-default** and applies, in order: a per-session rate limit (`sampling_max_requests`, default `_DEFAULT_SAMPLING_MAX_REQUESTS`), an approval gate (`sampling_approval_callback`), and a `maxTokens` cap (`sampling_max_tokens`, default `_DEFAULT_SAMPLING_MAX_TOKENS`). The approval callback (constructor arg on all subclasses; exported type alias `SamplingApprovalCallback`) receives the raw `CreateMessageRequestParams`, may be sync or async, and must return truthy to approve. When it is `None` (the default) every sampling request is denied; pass `lambda params: True` to restore legacy auto-approve as an explicit opt-in. Requests and denials are logged at WARNING (content is not logged). The per-session counter resets in `_reset_session_state`.
|
||||
- **`MCPTaskOptions`** (experimental, `MCP_LONG_RUNNING_TASKS` feature, **frozen**) - Per-tool-instance options controlling the SEP-2663 long-running task lifecycle. When the server advertises a tool with `execution.taskSupport == "required"`, `MCPTool.call_tool` transparently routes through `call_tool_as_task`, which sends an augmented `tools/call`, polls `tasks/get` until terminal, and reinterprets `tasks/result` as a normal `CallToolResult`. Instances are immutable; replace via `MCPTool.task_options = MCPTaskOptions(...)`. Fields:
|
||||
- `default_ttl: timedelta | None` — forwarded to the server as `params.task.ttl` (milliseconds). When `None`, the server's default applies.
|
||||
@@ -98,7 +99,7 @@ agent_framework/
|
||||
- **`InMemoryAgentFileStore`** - Dict-backed store suitable for tests and lightweight scenarios.
|
||||
- **`FileSystemAgentFileStore`** - Disk-backed store rooted under a configurable directory. Enforces relative-path normalization, root containment, and rejects symlink/reparse-point segments to prevent escape.
|
||||
- **`FileSearchResult`** / **`FileSearchMatch`** - `SerializationMixin` DTOs returned by `search_files`, carrying the matching file name, a context snippet, and the matching lines with 1-based line numbers.
|
||||
- **`FileAccessProvider`** - `ContextProvider` that adds shared file-access tools (`file_access_save_file`, `file_access_read_file`, `file_access_delete_file`, `file_access_list_files`, `file_access_list_subdirectories`, `file_access_search_files`) plus default usage instructions to each invocation. `file_access_list_files`/`file_access_list_subdirectories` enumerate direct children (files / subdirectories) so the agent can walk the tree level by level; `file_access_search_files` searches recursively from the store root and returns store-root-relative `file_name` paths, scoped via an `fnmatch` glob (where `*` crosses `/`, e.g. `*.md`, `reports/*`). Unlike `MemoryContextProvider`, the store is intentionally shared across sessions and agents.
|
||||
- **`FileAccessProvider`** - `ContextProvider` that adds shared file-access tools (`file_access_save_file`, `file_access_read_file`, `file_access_delete_file`, `file_access_list_files`, `file_access_list_subdirectories`, `file_access_search_files`) plus default usage instructions to each invocation. `file_access_list_files`/`file_access_list_subdirectories` enumerate direct children (files / subdirectories) so the agent can walk the tree level by level; `file_access_search_files` searches recursively from the store root and returns store-root-relative `file_name` paths, scoped via an `fnmatch` glob (where `*` crosses `/`, e.g. `*.md`, `reports/*`). All six tools are registered with `approval_mode="always_require"`, so every file operation needs host approval. To run unattended, pass one of the static auto-approval rules to `ToolApprovalMiddleware` (via `auto_approval_rules`): `FileAccessProvider.read_only_tools_auto_approval_rule` approves only the read-only tools (read, list files, list subdirectories, search), while `FileAccessProvider.all_tools_auto_approval_rule` approves every file-access tool including save and delete. Both rules reject any call carrying a `server_label` so they stay scoped to this provider's local tools and never auto-approve a same-named hosted tool. The tool names are also exposed as class constants (`SAVE_FILE_TOOL_NAME`, `READ_FILE_TOOL_NAME`, `DELETE_FILE_TOOL_NAME`, `LIST_FILES_TOOL_NAME`, `LIST_SUBDIRECTORIES_TOOL_NAME`, `SEARCH_FILES_TOOL_NAME`). Unlike `MemoryContextProvider`, the store is intentionally shared across sessions and agents.
|
||||
|
||||
### File Memory Harness (`_harness/_file_memory.py`)
|
||||
|
||||
|
||||
@@ -9,7 +9,7 @@ integrations, many of which are lazy-loaded from optional packages.
|
||||
"""
|
||||
|
||||
import importlib.metadata
|
||||
from typing import Final
|
||||
from typing import TYPE_CHECKING, Any, Final
|
||||
|
||||
try:
|
||||
_version = importlib.metadata.version(__name__)
|
||||
@@ -264,6 +264,7 @@ from ._workflows._agent_executor import (
|
||||
)
|
||||
from ._workflows._agent_utils import resolve_agent_id
|
||||
from ._workflows._checkpoint import (
|
||||
CheckpointID,
|
||||
CheckpointStorage,
|
||||
FileCheckpointStorage,
|
||||
InMemoryCheckpointStorage,
|
||||
@@ -307,7 +308,6 @@ from ._workflows._functional import (
|
||||
workflow,
|
||||
)
|
||||
from ._workflows._request_info_mixin import response_handler
|
||||
from ._workflows._runner import Runner
|
||||
from ._workflows._runner_context import (
|
||||
InProcRunnerContext,
|
||||
RunnerContext,
|
||||
@@ -405,6 +405,7 @@ __all__ = [
|
||||
"ChatResponse",
|
||||
"ChatResponseUpdate",
|
||||
"CheckResult",
|
||||
"CheckpointID",
|
||||
"CheckpointStorage",
|
||||
"ClassSkill",
|
||||
"CompactionProvider",
|
||||
@@ -618,3 +619,20 @@ __all__ = [
|
||||
"validate_workflow_graph",
|
||||
"workflow",
|
||||
]
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from ._workflows._runner import Runner
|
||||
|
||||
|
||||
def __getattr__(name: str) -> Any:
|
||||
"""Lazily resolve deprecated public names, emitting a ``DeprecationWarning``.
|
||||
|
||||
``Runner`` remains importable from ``agent_framework`` for backward
|
||||
compatibility but is deprecated and slated for removal from the public API.
|
||||
"""
|
||||
if name == "Runner":
|
||||
from ._workflows._runner import Runner, warn_runner_deprecated
|
||||
|
||||
warn_runner_deprecated()
|
||||
return Runner
|
||||
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
|
||||
|
||||
@@ -37,7 +37,8 @@ from pydantic import BaseModel, Field
|
||||
from .._feature_stage import ExperimentalFeature, experimental
|
||||
from .._serialization import SerializationMixin
|
||||
from .._sessions import AgentSession, ContextProvider, SessionContext
|
||||
from .._tools import ApprovalMode, tool
|
||||
from .._tools import tool
|
||||
from .._types import Content
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -1075,15 +1076,72 @@ class FileAccessProvider(ContextProvider):
|
||||
contents are visible across sessions and agents. The store is passed in by
|
||||
the caller and should already be scoped to the desired folder or storage
|
||||
location.
|
||||
|
||||
All six tools always require approval: each is registered with
|
||||
``approval_mode="always_require"`` so the host must approve every file
|
||||
operation the model proposes. In the auto-invocation flow this means the
|
||||
model's calls to these tools are converted into
|
||||
``function_approval_request`` items and the tool does **not** execute until
|
||||
the host supplies a matching ``function_approval_response``. Consumers that
|
||||
use the base agent directly must install
|
||||
:class:`~agent_framework.ToolApprovalMiddleware` (or use
|
||||
:func:`~agent_framework.create_harness_agent`, which wires it in by default)
|
||||
to drive that handshake; otherwise these tools never run. To run unattended,
|
||||
supply one of the static auto-approval rules to
|
||||
:class:`~agent_framework.ToolApprovalMiddleware` via its
|
||||
``auto_approval_rules``:
|
||||
|
||||
- :meth:`read_only_tools_auto_approval_rule` — auto-approves only the
|
||||
read-only tools (read, list files, list subdirectories, search), while
|
||||
still prompting for the tools that modify the store (save and delete).
|
||||
- :meth:`all_tools_auto_approval_rule` — auto-approves every file-access
|
||||
tool, including save and delete.
|
||||
|
||||
For example, to auto-approve only the read-only tools::
|
||||
|
||||
create_harness_agent(
|
||||
chat_client,
|
||||
auto_approval_rules=[FileAccessProvider.read_only_tools_auto_approval_rule],
|
||||
)
|
||||
"""
|
||||
|
||||
#: Name of the tool that saves a file.
|
||||
SAVE_FILE_TOOL_NAME = "file_access_save_file"
|
||||
#: Name of the tool that reads a file.
|
||||
READ_FILE_TOOL_NAME = "file_access_read_file"
|
||||
#: Name of the tool that deletes a file.
|
||||
DELETE_FILE_TOOL_NAME = "file_access_delete_file"
|
||||
#: Name of the tool that lists the files in a directory.
|
||||
LIST_FILES_TOOL_NAME = "file_access_list_files"
|
||||
#: Name of the tool that lists the subdirectories of a directory.
|
||||
LIST_SUBDIRECTORIES_TOOL_NAME = "file_access_list_subdirectories"
|
||||
#: Name of the tool that searches file contents.
|
||||
SEARCH_FILES_TOOL_NAME = "file_access_search_files"
|
||||
|
||||
#: Names of the tools that only read from (never modify) the file store.
|
||||
_READ_ONLY_TOOL_NAMES: frozenset[str] = frozenset({
|
||||
READ_FILE_TOOL_NAME,
|
||||
LIST_FILES_TOOL_NAME,
|
||||
LIST_SUBDIRECTORIES_TOOL_NAME,
|
||||
SEARCH_FILES_TOOL_NAME,
|
||||
})
|
||||
|
||||
#: Names of all tools exposed by this provider.
|
||||
_ALL_TOOL_NAMES: frozenset[str] = frozenset({
|
||||
SAVE_FILE_TOOL_NAME,
|
||||
READ_FILE_TOOL_NAME,
|
||||
DELETE_FILE_TOOL_NAME,
|
||||
LIST_FILES_TOOL_NAME,
|
||||
LIST_SUBDIRECTORIES_TOOL_NAME,
|
||||
SEARCH_FILES_TOOL_NAME,
|
||||
})
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
store: AgentFileStore,
|
||||
*,
|
||||
source_id: str = DEFAULT_FILE_ACCESS_SOURCE_ID,
|
||||
instructions: str | None = None,
|
||||
require_delete_approval: bool = True,
|
||||
) -> None:
|
||||
"""Initialize the file access provider.
|
||||
|
||||
@@ -1096,17 +1154,78 @@ class FileAccessProvider(ContextProvider):
|
||||
source_id: Unique source ID for the provider.
|
||||
instructions: Optional instruction override. When ``None`` the
|
||||
default file-access instructions are used.
|
||||
require_delete_approval: When ``True`` (the default) the
|
||||
``file_access_delete_file`` tool is registered with
|
||||
``approval_mode="always_require"`` so the host must approve every
|
||||
delete the model proposes. Set to ``False`` to opt out and allow
|
||||
the agent to delete files autonomously (matching the .NET
|
||||
``FileAccessProvider``, which has no approval mechanism).
|
||||
"""
|
||||
super().__init__(source_id)
|
||||
self.store = store
|
||||
self.instructions = instructions or DEFAULT_FILE_ACCESS_INSTRUCTIONS
|
||||
self.require_delete_approval = require_delete_approval
|
||||
|
||||
@staticmethod
|
||||
def _is_local_tool_call(function_call: Content) -> bool:
|
||||
"""Return whether a function call targets this provider's local tools.
|
||||
|
||||
Hosted-tool calls carry a ``server_label`` in their
|
||||
``additional_properties`` and are a separate server-scoped approval
|
||||
boundary that must be passed through untouched (see
|
||||
:func:`agent_framework._tools._is_hosted_tool_approval`). These rules
|
||||
only ever auto-approve the provider's own local tools, so any call that
|
||||
carries a ``server_label`` is rejected even if its name collides with a
|
||||
file-access tool name.
|
||||
"""
|
||||
return not function_call.additional_properties.get("server_label")
|
||||
|
||||
@staticmethod
|
||||
def read_only_tools_auto_approval_rule(function_call: Content) -> bool:
|
||||
"""Auto-approval rule that approves only the read-only file-access tools.
|
||||
|
||||
The tools exposed by :class:`FileAccessProvider` always require approval.
|
||||
Pass this rule to :class:`~agent_framework.ToolApprovalMiddleware` (via
|
||||
``auto_approval_rules``) to automatically approve the tools that read
|
||||
from the store (``file_access_read_file``, ``file_access_list_files``,
|
||||
``file_access_list_subdirectories``, and ``file_access_search_files``),
|
||||
while still prompting for the tools that modify it
|
||||
(``file_access_save_file`` and ``file_access_delete_file``).
|
||||
|
||||
Hosted-tool calls (those carrying a ``server_label``) are never
|
||||
auto-approved, even when their name matches a file-access tool, so the
|
||||
rule stays scoped to this provider's local tools.
|
||||
|
||||
Args:
|
||||
function_call: The pending ``function_call`` content.
|
||||
|
||||
Returns:
|
||||
``True`` for read-only file-access tools, ``False`` otherwise so that
|
||||
subsequent rules continue to be evaluated.
|
||||
"""
|
||||
return (
|
||||
FileAccessProvider._is_local_tool_call(function_call)
|
||||
and function_call.name in FileAccessProvider._READ_ONLY_TOOL_NAMES
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def all_tools_auto_approval_rule(function_call: Content) -> bool:
|
||||
"""Auto-approval rule that approves every file-access tool.
|
||||
|
||||
The tools exposed by :class:`FileAccessProvider` always require approval.
|
||||
Pass this rule to :class:`~agent_framework.ToolApprovalMiddleware` (via
|
||||
``auto_approval_rules``) to automatically approve every file-access tool,
|
||||
including the tools that modify the store (``file_access_save_file`` and
|
||||
``file_access_delete_file``).
|
||||
|
||||
Hosted-tool calls (those carrying a ``server_label``) are never
|
||||
auto-approved, even when their name matches a file-access tool, so the
|
||||
rule stays scoped to this provider's local tools.
|
||||
|
||||
Args:
|
||||
function_call: The pending ``function_call`` content.
|
||||
|
||||
Returns:
|
||||
``True`` for any file-access tool, ``False`` otherwise so that
|
||||
subsequent rules continue to be evaluated.
|
||||
"""
|
||||
return (
|
||||
FileAccessProvider._is_local_tool_call(function_call)
|
||||
and function_call.name in FileAccessProvider._ALL_TOOL_NAMES
|
||||
)
|
||||
|
||||
async def before_run(
|
||||
self,
|
||||
@@ -1118,7 +1237,7 @@ class FileAccessProvider(ContextProvider):
|
||||
) -> None:
|
||||
"""Inject file-access tools and instructions before the model runs."""
|
||||
|
||||
@tool(name="file_access_save_file", schema=_SaveFileInput, approval_mode="never_require")
|
||||
@tool(name=FileAccessProvider.SAVE_FILE_TOOL_NAME, schema=_SaveFileInput, approval_mode="always_require")
|
||||
async def file_access_save_file(file_name: str, content: str, overwrite: bool = False) -> str:
|
||||
"""Save a file with the given name and content. By default, does not overwrite an existing file unless overwrite is set to true.""" # noqa: E501
|
||||
try:
|
||||
@@ -1132,7 +1251,7 @@ class FileAccessProvider(ContextProvider):
|
||||
return f"Could not save file '{file_name}': {exc.strerror or exc}"
|
||||
return f"File '{file_name}' saved."
|
||||
|
||||
@tool(name="file_access_read_file", schema=_ReadFileInput, approval_mode="never_require")
|
||||
@tool(name=FileAccessProvider.READ_FILE_TOOL_NAME, schema=_ReadFileInput, approval_mode="always_require")
|
||||
async def file_access_read_file(file_name: str) -> str:
|
||||
"""Read the content of a file by name. Returns the file content or a message indicating the file could not be read.""" # noqa: E501
|
||||
try:
|
||||
@@ -1144,9 +1263,7 @@ class FileAccessProvider(ContextProvider):
|
||||
return f"Could not read file '{file_name}': {exc.strerror or exc}"
|
||||
return content if content is not None else f"File '{file_name}' not found."
|
||||
|
||||
delete_approval_mode: ApprovalMode = "always_require" if self.require_delete_approval else "never_require"
|
||||
|
||||
@tool(name="file_access_delete_file", schema=_DeleteFileInput, approval_mode=delete_approval_mode)
|
||||
@tool(name=FileAccessProvider.DELETE_FILE_TOOL_NAME, schema=_DeleteFileInput, approval_mode="always_require")
|
||||
async def file_access_delete_file(file_name: str) -> str:
|
||||
"""Delete a file by name."""
|
||||
try:
|
||||
@@ -1158,7 +1275,7 @@ class FileAccessProvider(ContextProvider):
|
||||
return f"Could not delete file '{file_name}': {exc.strerror or exc}"
|
||||
return f"File '{file_name}' deleted." if deleted else f"File '{file_name}' not found."
|
||||
|
||||
@tool(name="file_access_list_files", schema=_ListFilesInput, approval_mode="never_require")
|
||||
@tool(name=FileAccessProvider.LIST_FILES_TOOL_NAME, schema=_ListFilesInput, approval_mode="always_require")
|
||||
async def file_access_list_files(directory: str | None = None) -> list[str] | str:
|
||||
"""List the direct child file names of a directory. Omit ``directory`` (or pass an empty string) to list the root. To enumerate files in a subdirectory, pass its relative path, for example ``"reports"`` or ``"reports/2024"``.""" # noqa: E501
|
||||
target = directory if directory and directory.strip() else ""
|
||||
@@ -1169,7 +1286,11 @@ class FileAccessProvider(ContextProvider):
|
||||
except OSError as exc:
|
||||
return f"Could not list directory '{directory or ''}': {exc.strerror or exc}"
|
||||
|
||||
@tool(name="file_access_list_subdirectories", schema=_ListSubdirectoriesInput, approval_mode="never_require")
|
||||
@tool(
|
||||
name=FileAccessProvider.LIST_SUBDIRECTORIES_TOOL_NAME,
|
||||
schema=_ListSubdirectoriesInput,
|
||||
approval_mode="always_require",
|
||||
)
|
||||
async def file_access_list_subdirectories(directory: str | None = None) -> list[str] | str:
|
||||
"""List the direct child subdirectory names of a directory.
|
||||
|
||||
@@ -1186,7 +1307,7 @@ class FileAccessProvider(ContextProvider):
|
||||
except OSError as exc:
|
||||
return f"Could not list directory '{directory or ''}': {exc.strerror or exc}"
|
||||
|
||||
@tool(name="file_access_search_files", schema=_SearchFilesInput, approval_mode="never_require")
|
||||
@tool(name=FileAccessProvider.SEARCH_FILES_TOOL_NAME, schema=_SearchFilesInput, approval_mode="always_require")
|
||||
async def file_access_search_files(
|
||||
regex_pattern: str,
|
||||
file_pattern: str | None = None,
|
||||
|
||||
@@ -15,10 +15,7 @@ DEFAULT_MODE_SOURCE_ID = "agent_mode"
|
||||
DEFAULT_MODE_INSTRUCTIONS = (
|
||||
"## Agent Mode\n\n"
|
||||
"- You can operate in different modes. Depending on the mode you are in, "
|
||||
"you will be required to follow different processes.\n"
|
||||
"- You must check the current mode after any user input, since the user may have changed the mode themselves, "
|
||||
"e.g. the user may have switched to 'plan' mode after a previous research task finished in 'execute' mode, "
|
||||
"meaning they want to review a plan first before execution.\n\n"
|
||||
"you will be required to follow different processes.\n\n"
|
||||
"Use the mode_get tool to check your current operating mode.\n"
|
||||
"Use the mode_set tool to switch between modes as your work progresses. "
|
||||
"Only use mode_set if the user explicitly instructs/allows you to change modes.\n\n"
|
||||
@@ -56,9 +53,12 @@ DEFAULT_MODE_DESCRIPTIONS: dict[str, str] = {
|
||||
"and follow the steps for *Execute mode*."
|
||||
),
|
||||
"execute": (
|
||||
"Use this mode when carrying out approved plans. Work autonomously using your best judgment — do not ask "
|
||||
"the user questions or wait for feedback.\n\n"
|
||||
"Process to follow when in execute mode:\n"
|
||||
"Determine the type of ask:\n"
|
||||
"1. Simple question that doesn't require any further work to answer.\n"
|
||||
"2. Any other work, including complex user request that requires a multi-step process to satisfy.\n\n"
|
||||
"If 1. just answer the question directly.\n"
|
||||
"If 2. Work autonomously using your best judgment — do not ask the user questions or wait for feedback "
|
||||
"and follow the following process:\n"
|
||||
"1. If you don't have a plan or tasks yet, analyze the user request and create tasks and a plan. "
|
||||
"(**Skip this step if you came from plan mode**)\n"
|
||||
"2. Work autonomously — use your best judgment to make decisions and keep progressing without asking "
|
||||
|
||||
@@ -24,15 +24,19 @@ DEFAULT_TODO_SOURCE_ID = "todo"
|
||||
DEFAULT_TODO_INSTRUCTIONS = (
|
||||
"## Todo Items\n\n"
|
||||
"You have access to a todo list for tracking work items.\n"
|
||||
"While planning, make sure that you break down complex tasks into manageable todo items "
|
||||
"and add them to the list.\n"
|
||||
"When a user asks you to perform a task, follow these steps to manage your work:\n"
|
||||
"1. Determine whether the ask requires multiple steps to complete (complex) or can be completed "
|
||||
"using a single step (simple).\n"
|
||||
"2. If complex, turn the task into manageable todo items and add them to the list.\n"
|
||||
"3. If simple, don't add a todo item, but rather just complete the task directly.\n\n"
|
||||
"### General TODO Guidelines\n"
|
||||
"Ask questions from the user where clarification is needed to create effective todos.\n"
|
||||
"If the user provides feedback on your plan, adjust your todos accordingly by adding new items "
|
||||
"or removing irrelevant ones.\n"
|
||||
"During execution, use the todo list to keep track of what needs to be done, "
|
||||
"mark items as complete when finished, and remove any items that are no longer needed.\n"
|
||||
"When a user changes the topic or changes their mind, ensure that you update the todo list accordingly "
|
||||
"by removing irrelevant items or adding new ones as needed.\n\n"
|
||||
"When a user changes the topic, changes their mind or switches to a new request, ensure that you update "
|
||||
"the todo list accordingly by removing irrelevant/old items, clearing the list, or adding new ones as needed.\n\n"
|
||||
"Use these tools to manage your tasks:\n"
|
||||
"- Use todos_add to break down complex work into trackable items (supports adding one or many at once).\n"
|
||||
"- Use todos_complete to mark items as done when finished (supports one or many at once). "
|
||||
|
||||
@@ -74,6 +74,11 @@ _MCP_NORMALIZED_NAME_KEY = "_mcp_normalized_name"
|
||||
# Reserved key in an ``additional_tool_argument_names`` mapping that applies its
|
||||
# values to every tool on the server rather than a single named tool.
|
||||
_MCP_GLOBAL_EXTRA_ARGS_KEY = "*"
|
||||
_MCP_META_LABEL_PATTERN = r"[A-Za-z](?:[A-Za-z0-9-]*[A-Za-z0-9])?"
|
||||
_MCP_META_KEY_PATTERN = re.compile(
|
||||
rf"^(?:(?:{_MCP_META_LABEL_PATTERN})(?:\.{_MCP_META_LABEL_PATTERN})*/)?"
|
||||
r"[A-Za-z0-9](?:[A-Za-z0-9_.-]*[A-Za-z0-9])?$"
|
||||
)
|
||||
# Framework kwargs that flow through the function-invocation pipeline (via
|
||||
# ``FunctionInvocationContext.kwargs``) but must never be forwarded to an MCP
|
||||
# server: they are internal objects that the MCP SDK cannot serialize. They are
|
||||
@@ -205,7 +210,42 @@ def _normalize_additional_tool_argument_names(
|
||||
return set(additional_tool_argument_names), {}
|
||||
|
||||
|
||||
def _inject_otel_into_mcp_meta(meta: dict[str, Any] | None = None) -> dict[str, Any] | None:
|
||||
def _mcp_config_candidate_names(*, local_name: str, normalized_name: str, remote_name: str) -> tuple[str, ...]:
|
||||
"""Return safe configuration names for MCP allow/approval matching."""
|
||||
names = [remote_name]
|
||||
if normalized_name == remote_name and local_name != remote_name:
|
||||
names.append(local_name)
|
||||
return tuple(names)
|
||||
|
||||
|
||||
def _validate_mcp_meta_key(key: str) -> None:
|
||||
"""Validate an MCP ``_meta`` key against the 2025-06-18 key-name format."""
|
||||
if not _MCP_META_KEY_PATTERN.fullmatch(key):
|
||||
raise ToolExecutionException(f"Invalid MCP _meta key name: {key!r}.")
|
||||
|
||||
|
||||
def _validate_mcp_meta(raw_meta: object | None) -> dict[str, Any] | None:
|
||||
"""Validate and copy MCP request metadata."""
|
||||
if raw_meta is None:
|
||||
return None
|
||||
if not isinstance(raw_meta, dict):
|
||||
raise ToolExecutionException("MCP tool metadata provided via _meta must be a dict.")
|
||||
|
||||
raw_meta_dict = cast(Mapping[object, Any], raw_meta)
|
||||
meta: dict[str, Any] = {}
|
||||
for key, value in raw_meta_dict.items():
|
||||
if not isinstance(key, str):
|
||||
raise ToolExecutionException("MCP tool metadata provided via _meta must use string keys.")
|
||||
_validate_mcp_meta_key(key)
|
||||
meta[key] = value
|
||||
return meta
|
||||
|
||||
|
||||
def _inject_otel_into_mcp_meta(
|
||||
meta: dict[str, Any] | None = None,
|
||||
*,
|
||||
overwrite: bool = False,
|
||||
) -> dict[str, Any] | None:
|
||||
"""Inject OpenTelemetry trace context into MCP request _meta via the global propagator(s)."""
|
||||
carrier: dict[str, str] = {}
|
||||
propagate.inject(carrier)
|
||||
@@ -215,7 +255,8 @@ def _inject_otel_into_mcp_meta(meta: dict[str, Any] | None = None) -> dict[str,
|
||||
if meta is None:
|
||||
meta = {}
|
||||
for key, value in carrier.items():
|
||||
if key not in meta:
|
||||
_validate_mcp_meta_key(key)
|
||||
if overwrite or key not in meta:
|
||||
meta[key] = value
|
||||
|
||||
return meta
|
||||
@@ -381,7 +422,9 @@ class MCPTool:
|
||||
approval_mode: Whether approval is required to run tools.
|
||||
allowed_tools: Optional allow-list of MCP tool names to expose as functions.
|
||||
``None`` (the default) exposes every tool advertised by the MCP server.
|
||||
A non-empty collection exposes only the tools whose names appear in it.
|
||||
A non-empty collection exposes only the raw remote tools whose names appear in it. For
|
||||
compatibility, the prefixed local function name is also accepted when the raw remote name already
|
||||
matches its normalized form; normalized aliases do not authorize a different raw remote tool.
|
||||
An empty collection (``[]``) exposes no tools — if you simply want to
|
||||
disable tool execution, prefer ``load_tools=False`` instead. ``[]`` is
|
||||
useful as a runtime guard or when you want to load tool metadata for
|
||||
@@ -753,11 +796,14 @@ class MCPTool:
|
||||
additional_properties = func.additional_properties or {}
|
||||
normalized_name = additional_properties.get(_MCP_NORMALIZED_NAME_KEY)
|
||||
remote_name = additional_properties.get(_MCP_REMOTE_NAME_KEY)
|
||||
if (
|
||||
func.name in allowed_names
|
||||
or (isinstance(normalized_name, str) and normalized_name in allowed_names)
|
||||
or (isinstance(remote_name, str) and remote_name in allowed_names)
|
||||
):
|
||||
if not isinstance(normalized_name, str) or not isinstance(remote_name, str):
|
||||
continue
|
||||
candidate_names = _mcp_config_candidate_names(
|
||||
local_name=func.name,
|
||||
normalized_name=normalized_name,
|
||||
remote_name=remote_name,
|
||||
)
|
||||
if any(name in allowed_names for name in candidate_names):
|
||||
filtered_functions.append(func)
|
||||
return filtered_functions
|
||||
|
||||
@@ -1381,7 +1427,13 @@ class MCPTool:
|
||||
continue
|
||||
|
||||
input_model = _get_input_model_from_mcp_prompt(prompt)
|
||||
approval_mode = self._determine_approval_mode(local_name, normalized_name, prompt.name)
|
||||
approval_mode = self._determine_approval_mode(
|
||||
*_mcp_config_candidate_names(
|
||||
local_name=local_name,
|
||||
normalized_name=normalized_name,
|
||||
remote_name=prompt.name,
|
||||
)
|
||||
)
|
||||
func: FunctionTool = FunctionTool(
|
||||
func=partial(self.get_prompt, prompt.name),
|
||||
name=local_name,
|
||||
@@ -1422,7 +1474,11 @@ class MCPTool:
|
||||
return
|
||||
|
||||
# Track existing function names to prevent duplicates
|
||||
existing_names = {func.name for func in self._functions}
|
||||
existing_remote_by_local: dict[str, str] = {}
|
||||
for func in self._functions:
|
||||
remote_name = (func.additional_properties or {}).get(_MCP_REMOTE_NAME_KEY)
|
||||
if isinstance(remote_name, str):
|
||||
existing_remote_by_local[func.name] = remote_name
|
||||
tool_call_meta_by_name: dict[str, dict[str, Any]] = {}
|
||||
tool_task_support_by_name: dict[str, str] = {}
|
||||
tool_param_names_by_name: dict[str, set[str]] = {}
|
||||
@@ -1462,7 +1518,7 @@ class MCPTool:
|
||||
|
||||
for tool in tool_list.tools:
|
||||
if tool.meta is not None:
|
||||
tool_call_meta_by_name[tool.name] = dict(tool.meta)
|
||||
tool_call_meta_by_name[tool.name] = _validate_mcp_meta(tool.meta) or {}
|
||||
|
||||
task_support = getattr(getattr(tool, "execution", None), "taskSupport", None)
|
||||
if task_support is not None:
|
||||
@@ -1490,10 +1546,24 @@ class MCPTool:
|
||||
local_name = _build_prefixed_mcp_name(normalized_name, self.tool_name_prefix)
|
||||
|
||||
# Skip if already loaded
|
||||
if local_name in existing_names:
|
||||
if local_name in existing_remote_by_local:
|
||||
if existing_remote_by_local.get(local_name) != tool.name:
|
||||
raise ToolExecutionException(
|
||||
"MCP server advertised multiple tools that map to the same local function name: "
|
||||
f"{existing_remote_by_local[local_name]!r} and {tool.name!r} both map to "
|
||||
f"{local_name!r}."
|
||||
)
|
||||
continue
|
||||
|
||||
approval_mode = self._determine_approval_mode(local_name, normalized_name, tool.name)
|
||||
existing_remote_by_local[local_name] = tool.name
|
||||
|
||||
approval_mode = self._determine_approval_mode(
|
||||
*_mcp_config_candidate_names(
|
||||
local_name=local_name,
|
||||
normalized_name=normalized_name,
|
||||
remote_name=tool.name,
|
||||
)
|
||||
)
|
||||
|
||||
async def _call_tool_with_runtime_kwargs(
|
||||
ctx: FunctionInvocationContext,
|
||||
@@ -1501,8 +1571,13 @@ class MCPTool:
|
||||
_remote_tool_name: str = tool.name,
|
||||
**kwargs: Any,
|
||||
) -> str | list[Content]:
|
||||
trusted_meta = ctx.kwargs.get("_meta")
|
||||
call_kwargs = dict(ctx.kwargs)
|
||||
call_kwargs.update(kwargs)
|
||||
if trusted_meta is not None:
|
||||
call_kwargs["_meta"] = trusted_meta
|
||||
else:
|
||||
call_kwargs.pop("_meta", None)
|
||||
return await self.call_tool(_remote_tool_name, **call_kwargs)
|
||||
|
||||
# Create FunctionTools out of each tool
|
||||
@@ -1518,7 +1593,6 @@ class MCPTool:
|
||||
},
|
||||
)
|
||||
self._functions.append(func)
|
||||
existing_names.add(local_name)
|
||||
|
||||
# Check if there are more pages
|
||||
if not tool_list.nextCursor:
|
||||
@@ -1636,8 +1710,8 @@ class MCPTool:
|
||||
Keyword Args:
|
||||
_meta: Optional ``dict[str, Any]`` of MCP request metadata. This reserved key is passed as the
|
||||
``meta`` parameter of the underlying ``session.call_tool`` call rather than as a tool argument.
|
||||
User-supplied keys override metadata from ``tools/list``; OpenTelemetry propagation fills in
|
||||
non-conflicting keys.
|
||||
OpenTelemetry propagation overrides caller-supplied keys, and metadata from ``tools/list``
|
||||
overrides both.
|
||||
kwargs: Remaining arguments to pass to the tool.
|
||||
|
||||
Returns:
|
||||
@@ -1746,17 +1820,7 @@ class MCPTool:
|
||||
self, tool_name: str, kwargs: dict[str, Any]
|
||||
) -> tuple[dict[str, Any], dict[str, Any] | None]:
|
||||
"""Filter kwargs down to the tool's arguments and build the merged MCP request metadata."""
|
||||
raw_user_meta: object | None = kwargs.get("_meta")
|
||||
user_meta: dict[str, Any] | None = None
|
||||
if raw_user_meta is not None and not isinstance(raw_user_meta, dict):
|
||||
raise ToolExecutionException("MCP tool metadata provided via _meta must be a dict.")
|
||||
if isinstance(raw_user_meta, dict):
|
||||
raw_user_meta_dict = cast(Mapping[object, object], raw_user_meta)
|
||||
user_meta = {}
|
||||
for key, value in raw_user_meta_dict.items():
|
||||
if not isinstance(key, str):
|
||||
raise ToolExecutionException("MCP tool metadata provided via _meta must use string keys.")
|
||||
user_meta[key] = value
|
||||
user_meta = _validate_mcp_meta(kwargs.get("_meta"))
|
||||
|
||||
# Allowlist: forward only the tool's declared parameters (from inputSchema.properties)
|
||||
# plus any user-configured extra argument names. Everything else - notably the
|
||||
@@ -1783,12 +1847,12 @@ class MCPTool:
|
||||
}
|
||||
|
||||
# Some MCP proxies require their tools/list metadata to be echoed on tools/call.
|
||||
tool_meta = self._tool_call_meta_by_name.get(tool_name)
|
||||
request_meta = dict(tool_meta) if tool_meta is not None else None
|
||||
if user_meta is not None:
|
||||
request_meta = {**(request_meta or {}), **user_meta}
|
||||
meta = _inject_otel_into_mcp_meta(request_meta)
|
||||
return filtered_kwargs, meta
|
||||
request_meta = dict(user_meta) if user_meta is not None else None
|
||||
request_meta = _inject_otel_into_mcp_meta(request_meta, overwrite=True)
|
||||
tool_meta = _validate_mcp_meta(self._tool_call_meta_by_name.get(tool_name))
|
||||
if tool_meta is not None:
|
||||
request_meta = {**(request_meta or {}), **tool_meta}
|
||||
return filtered_kwargs, request_meta
|
||||
|
||||
async def call_tool_as_task(self, tool_name: str, **kwargs: Any) -> str | list[Content]:
|
||||
"""Call an MCP tool via the long-running task lifecycle (SEP-2663).
|
||||
@@ -2898,7 +2962,7 @@ class MCPWebsocketTool(MCPTool):
|
||||
An async context manager for the WebSocket client transport.
|
||||
"""
|
||||
try:
|
||||
from mcp.client.websocket import websocket_client
|
||||
from mcp.client.websocket import websocket_client # pyright: ignore[reportDeprecated]
|
||||
except ModuleNotFoundError as ex:
|
||||
missing_name = ex.name or "mcp/websocket dependencies"
|
||||
if missing_name == "mcp" or missing_name.startswith("mcp."):
|
||||
@@ -2917,4 +2981,4 @@ class MCPWebsocketTool(MCPTool):
|
||||
}
|
||||
if self._client_kwargs:
|
||||
args.update(self._client_kwargs)
|
||||
return websocket_client(**args)
|
||||
return websocket_client(**args) # pyright: ignore[reportDeprecated]
|
||||
|
||||
@@ -1004,39 +1004,6 @@ def normalize_tools(
|
||||
return normalized
|
||||
|
||||
|
||||
def _tools_to_dict( # pyright: ignore[reportUnusedFunction]
|
||||
tools: ToolTypes | Callable[..., Any] | Sequence[ToolTypes | Callable[..., Any]] | None,
|
||||
) -> list[str | dict[str, Any]] | None:
|
||||
"""Parse the tools to a dict.
|
||||
|
||||
Args:
|
||||
tools: The tools to parse. Can be a single tool or a sequence of tools.
|
||||
|
||||
Returns:
|
||||
A list of tool specifications as dictionaries, or None if no tools provided.
|
||||
"""
|
||||
normalized_tools = normalize_tools(tools)
|
||||
if not normalized_tools:
|
||||
return None
|
||||
|
||||
results: list[str | dict[str, Any]] = []
|
||||
for tool_item in normalized_tools:
|
||||
if isinstance(tool_item, FunctionTool):
|
||||
results.append(tool_item.to_json_schema_spec())
|
||||
continue
|
||||
if isinstance(tool_item, BaseModel):
|
||||
results.append(tool_item.model_dump(exclude_none=True))
|
||||
continue
|
||||
if isinstance(tool_item, SerializationMixin):
|
||||
results.append(tool_item.to_dict())
|
||||
continue
|
||||
if isinstance(tool_item, dict):
|
||||
results.append(tool_item) # type: ignore[reportUnknownArgumentType]
|
||||
continue
|
||||
logger.warning("Can't parse tool.")
|
||||
return results
|
||||
|
||||
|
||||
# region AI Function Decorator
|
||||
|
||||
|
||||
|
||||
@@ -2983,6 +2983,8 @@ class ResponseStream(AsyncIterable[UpdateT], Generic[UpdateT, FinalT]):
|
||||
self._inner_stream_source: ResponseStream[Any, Any] | Awaitable[ResponseStream[Any, Any]] | None = None
|
||||
self._wrap_inner: bool = False
|
||||
self._map_update: Callable[[Any], UpdateT | Awaitable[UpdateT]] | None = None
|
||||
self._flat_map_update: Callable[[Any], Iterable[UpdateT] | Awaitable[Iterable[UpdateT]]] | None = None
|
||||
self._pending_mapped_updates: list[UpdateT] = []
|
||||
self._pull_context_manager_factories: list[Callable[[], contextlib.AbstractContextManager[Any]]] = []
|
||||
|
||||
def map(
|
||||
@@ -3029,6 +3031,23 @@ class ResponseStream(AsyncIterable[UpdateT], Generic[UpdateT, FinalT]):
|
||||
stream._map_update = transform
|
||||
return stream
|
||||
|
||||
def flat_map(
|
||||
self,
|
||||
transform: Callable[[UpdateT], Iterable[OuterUpdateT] | Awaitable[Iterable[OuterUpdateT]]],
|
||||
finalizer: Callable[[Sequence[OuterUpdateT]], OuterFinalT | Awaitable[OuterFinalT]],
|
||||
) -> ResponseStream[OuterUpdateT, OuterFinalT]:
|
||||
"""Create a new stream that transforms each update into zero or more updates.
|
||||
|
||||
Like :meth:`map`, the returned stream delegates iteration to this stream,
|
||||
preserving single consumption and inner finalization/result hooks. Use this
|
||||
when one upstream update naturally expands into multiple wire-protocol events.
|
||||
"""
|
||||
stream: ResponseStream[OuterUpdateT, OuterFinalT] = ResponseStream(self, finalizer=finalizer)
|
||||
stream._inner_stream_source = self
|
||||
stream._wrap_inner = True
|
||||
stream._flat_map_update = transform
|
||||
return stream
|
||||
|
||||
def with_finalizer(
|
||||
self,
|
||||
finalizer: Callable[[Sequence[UpdateT]], OuterFinalT | Awaitable[OuterFinalT]],
|
||||
@@ -3101,35 +3120,7 @@ class ResponseStream(AsyncIterable[UpdateT], Generic[UpdateT, FinalT]):
|
||||
def __aiter__(self) -> ResponseStream[UpdateT, FinalT]:
|
||||
return self
|
||||
|
||||
async def __anext__(self) -> UpdateT:
|
||||
try:
|
||||
with contextlib.ExitStack() as stack:
|
||||
for factory in self._pull_context_manager_factories:
|
||||
stack.enter_context(factory())
|
||||
# Resolve the underlying stream inside the pull contexts so that any
|
||||
# spans/contexts created during stream resolution (e.g. inner chat
|
||||
# completion spans created on the first pull of a wrapped agent stream)
|
||||
# inherit the active context (e.g. an outer agent invoke span).
|
||||
if self._iterator is None:
|
||||
stream = await self._get_stream()
|
||||
self._iterator = stream.__aiter__()
|
||||
update: UpdateT = await self._iterator.__anext__()
|
||||
except StopAsyncIteration:
|
||||
self._consumed = True
|
||||
await self._run_cleanup_hooks()
|
||||
await self.get_final_response()
|
||||
raise
|
||||
except Exception as exc:
|
||||
self._stream_error = exc
|
||||
try:
|
||||
await self._run_cleanup_hooks()
|
||||
finally:
|
||||
self._stream_error = None
|
||||
raise
|
||||
if self._map_update is not None:
|
||||
update = self._map_update(update) # type: ignore[assignment]
|
||||
if isawaitable(update):
|
||||
update = await update
|
||||
async def _record_update(self, update: UpdateT) -> UpdateT:
|
||||
self._updates.append(update)
|
||||
for hook in self._transform_hooks:
|
||||
hooked = hook(update)
|
||||
@@ -3139,6 +3130,47 @@ class ResponseStream(AsyncIterable[UpdateT], Generic[UpdateT, FinalT]):
|
||||
update = cast(UpdateT, hooked)
|
||||
return update
|
||||
|
||||
async def __anext__(self) -> UpdateT:
|
||||
while True:
|
||||
if self._pending_mapped_updates:
|
||||
return await self._record_update(self._pending_mapped_updates.pop(0))
|
||||
|
||||
try:
|
||||
with contextlib.ExitStack() as stack:
|
||||
for factory in self._pull_context_manager_factories:
|
||||
stack.enter_context(factory())
|
||||
# Resolve the underlying stream inside the pull contexts so that any
|
||||
# spans/contexts created during stream resolution (e.g. inner chat
|
||||
# completion spans created on the first pull of a wrapped agent stream)
|
||||
# inherit the active context (e.g. an outer agent invoke span).
|
||||
if self._iterator is None:
|
||||
stream = await self._get_stream()
|
||||
self._iterator = stream.__aiter__()
|
||||
update: UpdateT = await self._iterator.__anext__()
|
||||
except StopAsyncIteration:
|
||||
self._consumed = True
|
||||
await self._run_cleanup_hooks()
|
||||
await self.get_final_response()
|
||||
raise
|
||||
except Exception as exc:
|
||||
self._stream_error = exc
|
||||
try:
|
||||
await self._run_cleanup_hooks()
|
||||
finally:
|
||||
self._stream_error = None
|
||||
raise
|
||||
if self._flat_map_update is not None:
|
||||
mapped_updates = self._flat_map_update(update)
|
||||
if isawaitable(mapped_updates):
|
||||
mapped_updates = await mapped_updates
|
||||
self._pending_mapped_updates.extend(mapped_updates)
|
||||
continue
|
||||
if self._map_update is not None:
|
||||
update = self._map_update(update) # type: ignore[assignment]
|
||||
if isawaitable(update):
|
||||
update = await update
|
||||
return await self._record_update(update)
|
||||
|
||||
async def _resolve_stream_with_pull_contexts(self) -> AsyncIterable[UpdateT]:
|
||||
"""Resolve the underlying stream while activating any registered pull context managers.
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import asyncio
|
||||
import contextlib
|
||||
import logging
|
||||
import warnings
|
||||
from collections import defaultdict
|
||||
from collections.abc import AsyncGenerator, Sequence
|
||||
from typing import Any
|
||||
@@ -10,7 +11,6 @@ from typing import Any
|
||||
from ..exceptions import (
|
||||
WorkflowCheckpointException,
|
||||
WorkflowConvergenceException,
|
||||
WorkflowRunnerException,
|
||||
)
|
||||
from ._checkpoint import CheckpointID, CheckpointStorage, WorkflowCheckpoint
|
||||
from ._const import EXECUTOR_STATE_KEY
|
||||
@@ -27,6 +27,21 @@ from ._state import State
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def warn_runner_deprecated() -> None:
|
||||
"""Emit a deprecation warning when ``Runner`` is accessed from the public API.
|
||||
|
||||
``Runner`` remains importable from ``agent_framework`` for backward
|
||||
compatibility, but it is intended for internal use only and will be removed
|
||||
from the public API in a future version.
|
||||
"""
|
||||
warnings.warn(
|
||||
"`Runner` is deprecated and will be removed from the public API in a future version. "
|
||||
"It is intended for internal use only.",
|
||||
DeprecationWarning,
|
||||
stacklevel=3,
|
||||
)
|
||||
|
||||
|
||||
class Runner:
|
||||
"""A class to run a workflow in Pregel supersteps."""
|
||||
|
||||
@@ -63,25 +78,34 @@ class Runner:
|
||||
self._iteration = 0
|
||||
self._max_iterations = max_iterations
|
||||
self._state = state
|
||||
self._running = False
|
||||
self._resumed_from_checkpoint = False # Track whether we resumed
|
||||
|
||||
# Checkpointing related attributes
|
||||
self._resumed_from_checkpoint = False
|
||||
self._previous_checkpoint_id: CheckpointID | None = None
|
||||
|
||||
@property
|
||||
def context(self) -> RunnerContext:
|
||||
"""Get the workflow context."""
|
||||
"""Get the runner context for message, event, and checkpoint handling."""
|
||||
return self._ctx
|
||||
|
||||
@property
|
||||
def state(self) -> State:
|
||||
"""Get the shared state for the workflow."""
|
||||
return self._state
|
||||
|
||||
def reset_iteration_count(self) -> None:
|
||||
"""Reset the iteration count to zero."""
|
||||
"""Reset the iteration count to zero.
|
||||
|
||||
This is useful when the workflow resumes from a new set of messages.
|
||||
|
||||
Note:
|
||||
When a workflow is resumed from a response (for a request_info_event)
|
||||
or a checkpoint, the iteration count is normally NOT reset.
|
||||
"""
|
||||
self._iteration = 0
|
||||
|
||||
async def run_until_convergence(self) -> AsyncGenerator[WorkflowEvent, None]:
|
||||
"""Run the workflow until no more messages are sent."""
|
||||
if self._running:
|
||||
raise WorkflowRunnerException("Runner is already running.")
|
||||
|
||||
self._running = True
|
||||
previous_checkpoint_id: CheckpointID | None = None
|
||||
try:
|
||||
# Emit any events already produced prior to entering loop
|
||||
if await self._ctx.has_events():
|
||||
@@ -89,12 +113,12 @@ class Runner:
|
||||
for event in await self._ctx.drain_events():
|
||||
yield event
|
||||
|
||||
# Create the first checkpoint. Checkpoints are usually considered to be created at the end of an iteration,
|
||||
# we can think of the first checkpoint as being created at the end of a "superstep 0" which captures the
|
||||
# states after which the start executor has run. Note that we execute the start executor outside of the
|
||||
# main iteration loop.
|
||||
if await self._ctx.has_messages() and not self._resumed_from_checkpoint:
|
||||
previous_checkpoint_id = await self._create_checkpoint_if_enabled(previous_checkpoint_id)
|
||||
# Create a checkpoint before a run starts. Checkpoints are usually considered to be created at the
|
||||
# end of an iteration, we can think of this checkpoint as being created at the end of "superstep 0"
|
||||
# which captures the states after which the start executor has run. Note that we execute the start
|
||||
# executor outside of the main iteration loop.
|
||||
if await self._ctx.has_messages() and self._iteration == 0 and not self._resumed_from_checkpoint:
|
||||
await self.create_checkpoint_if_enabled()
|
||||
|
||||
while self._iteration < self._max_iterations:
|
||||
logger.info(f"Starting superstep {self._iteration + 1}")
|
||||
@@ -141,7 +165,7 @@ class Runner:
|
||||
self._state.commit()
|
||||
|
||||
# Create checkpoint after each superstep iteration
|
||||
previous_checkpoint_id = await self._create_checkpoint_if_enabled(previous_checkpoint_id)
|
||||
await self.create_checkpoint_if_enabled()
|
||||
|
||||
yield WorkflowEvent.superstep_completed(iteration=self._iteration)
|
||||
|
||||
@@ -149,13 +173,15 @@ class Runner:
|
||||
if not await self._ctx.has_messages():
|
||||
break
|
||||
|
||||
logger.info(f"Workflow completed after {self._iteration} supersteps")
|
||||
|
||||
if self._iteration >= self._max_iterations and await self._ctx.has_messages():
|
||||
raise WorkflowConvergenceException(f"Runner did not converge after {self._max_iterations} iterations.")
|
||||
|
||||
logger.info(f"Workflow completed after {self._iteration} supersteps")
|
||||
self._resumed_from_checkpoint = False # Reset resume flag for next run
|
||||
finally:
|
||||
self._running = False
|
||||
# Reset the resume flag so stale resume state never leaks into the next run on this
|
||||
# instance - even if convergence raised before completing (e.g. an executor failure
|
||||
# during a resumed run).
|
||||
self._resumed_from_checkpoint = False
|
||||
|
||||
async def _run_iteration(self) -> None:
|
||||
"""Run a single iteration of the workflow.
|
||||
@@ -209,40 +235,55 @@ class Runner:
|
||||
]
|
||||
await asyncio.gather(*tasks)
|
||||
|
||||
async def _create_checkpoint_if_enabled(self, previous_checkpoint_id: CheckpointID | None) -> CheckpointID | None:
|
||||
async def _prepare_checkpoint_state(self) -> None:
|
||||
"""Persist executor snapshots into committed shared state.
|
||||
|
||||
This is used by checkpoint capture paths that need a complete, restorable
|
||||
state payload without necessarily writing to a checkpoint storage backend.
|
||||
"""
|
||||
await self._save_executor_states()
|
||||
self._state.commit()
|
||||
|
||||
async def create_checkpoint_if_enabled(self) -> None:
|
||||
"""Create a checkpoint if checkpointing is enabled and attach a label and metadata."""
|
||||
if not self._ctx.has_checkpointing():
|
||||
return None
|
||||
return
|
||||
|
||||
try:
|
||||
# Save executor states into the shared state before creating the checkpoint,
|
||||
# so that they are included in the checkpoint payload.
|
||||
await self._save_executor_states()
|
||||
# `on_checkpoint_save()` writes via State.set(), which stages values in the
|
||||
# pending buffer. Checkpoints serialize committed state only, so commit here
|
||||
# to ensure executor snapshots are captured in this checkpoint.
|
||||
self._state.commit()
|
||||
# Save executor states into committed state before creating the checkpoint.
|
||||
await self._prepare_checkpoint_state()
|
||||
|
||||
checkpoint_id = await self._ctx.create_checkpoint(
|
||||
self._workflow_name,
|
||||
self._graph_signature_hash,
|
||||
self._state,
|
||||
previous_checkpoint_id,
|
||||
self._previous_checkpoint_id,
|
||||
self._iteration,
|
||||
)
|
||||
|
||||
logger.info(f"Created checkpoint: {checkpoint_id}")
|
||||
return checkpoint_id
|
||||
logger.info(
|
||||
"Created checkpoint: %s with parent checkpoint at iteration %d: %s",
|
||||
checkpoint_id,
|
||||
self._iteration,
|
||||
self._previous_checkpoint_id,
|
||||
)
|
||||
self._previous_checkpoint_id = checkpoint_id
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to create checkpoint: {e}")
|
||||
return None
|
||||
logger.warning(
|
||||
"Failed to create checkpoint at iteration %d: %s. "
|
||||
"Note that this does not fail the workflow run. "
|
||||
"The next successfully-created checkpoint will be parented to the last successful checkpoint: %s",
|
||||
self._iteration,
|
||||
e,
|
||||
self._previous_checkpoint_id,
|
||||
)
|
||||
|
||||
async def restore_from_checkpoint(
|
||||
self,
|
||||
checkpoint_id: CheckpointID,
|
||||
checkpoint_storage: CheckpointStorage | None = None,
|
||||
) -> None:
|
||||
"""Restore workflow state from a checkpoint.
|
||||
"""Restore the runner from a checkpoint.
|
||||
|
||||
Args:
|
||||
checkpoint_id: The ID of the checkpoint to restore from
|
||||
@@ -290,7 +331,7 @@ class Runner:
|
||||
# Apply the checkpoint to the context
|
||||
await self._ctx.apply_checkpoint(checkpoint)
|
||||
# Mark the runner as resumed
|
||||
self._mark_resumed(checkpoint.iteration_count)
|
||||
self._mark_resumed(checkpoint)
|
||||
|
||||
logger.info(f"Successfully restored workflow from checkpoint: {checkpoint_id}")
|
||||
except WorkflowCheckpointException:
|
||||
@@ -356,13 +397,14 @@ class Runner:
|
||||
|
||||
return parsed
|
||||
|
||||
def _mark_resumed(self, iteration: int) -> None:
|
||||
def _mark_resumed(self, checkpoint: WorkflowCheckpoint) -> None:
|
||||
"""Mark the runner as having resumed from a checkpoint.
|
||||
|
||||
Optionally set the current iteration and max iterations.
|
||||
"""
|
||||
self._resumed_from_checkpoint = True
|
||||
self._iteration = iteration
|
||||
self._iteration = checkpoint.iteration_count
|
||||
self._previous_checkpoint_id = checkpoint.checkpoint_id
|
||||
|
||||
async def _set_executor_state(self, executor_id: str, state: dict[str, Any]) -> None:
|
||||
"""Store executor state in state under a reserved key.
|
||||
|
||||
@@ -11,12 +11,14 @@ import logging
|
||||
import types
|
||||
import uuid
|
||||
import warnings
|
||||
import weakref
|
||||
from collections.abc import AsyncIterable, Awaitable, Callable, Mapping, Sequence
|
||||
from dataclasses import dataclass, field
|
||||
from typing import TYPE_CHECKING, Any, Literal, overload
|
||||
|
||||
from .._sessions import ContextProvider
|
||||
from .._types import ResponseStream
|
||||
from ..exceptions import WorkflowException
|
||||
from ..observability import OtelAttr, capture_exception, create_workflow_span
|
||||
from ._checkpoint import CheckpointStorage
|
||||
from ._const import DEFAULT_MAX_ITERATIONS, GLOBAL_KWARGS_KEY, WORKFLOW_RUN_KWARGS_KEY
|
||||
@@ -346,25 +348,29 @@ class Workflow(DictConvertible):
|
||||
# Store non-serializable runtime objects as private attributes
|
||||
self._runner_context = runner_context
|
||||
self._runner_context.set_yield_output_classifier(self._output_designation.classify)
|
||||
self._state = State()
|
||||
self._runner: Runner = Runner(
|
||||
self.edge_groups,
|
||||
self.executors,
|
||||
self._state,
|
||||
State(),
|
||||
runner_context,
|
||||
self.name,
|
||||
self.graph_signature_hash,
|
||||
max_iterations=max_iterations,
|
||||
)
|
||||
|
||||
# Flag to prevent concurrent workflow executions
|
||||
self._is_running = False
|
||||
|
||||
# Current run-level status of this workflow instance. Updated in lockstep with
|
||||
# the status events emitted from `_run_workflow_with_tracing`. Defaults to IDLE
|
||||
# for a freshly built workflow that has not yet been run.
|
||||
self._status: WorkflowRunState = WorkflowRunState.IDLE
|
||||
|
||||
# Weak reference to the in-flight run's ``ResponseStream``. Used as the single
|
||||
# concurrency lock: if the previous stream is still alive, ``run()`` rejects a
|
||||
# new run synchronously (before any await). When the stream is fully consumed
|
||||
# ``_run_core``'s finally clears this; if the caller drops the stream without
|
||||
# ever iterating, the weakref dereferences to ``None`` once Python collects it,
|
||||
# so a subsequent ``run()`` is allowed.
|
||||
self._active_run: weakref.ref[ResponseStream[WorkflowEvent, WorkflowRunResult]] | None = None
|
||||
|
||||
@property
|
||||
def status(self) -> WorkflowRunState:
|
||||
"""Return the current run-level status of this workflow instance.
|
||||
@@ -376,16 +382,6 @@ class Workflow(DictConvertible):
|
||||
"""
|
||||
return self._status
|
||||
|
||||
def _ensure_not_running(self) -> None:
|
||||
"""Ensure the workflow is not already running."""
|
||||
if self._is_running:
|
||||
raise RuntimeError("Workflow is already running. Concurrent executions are not allowed.")
|
||||
self._is_running = True
|
||||
|
||||
def _reset_running_flag(self) -> None:
|
||||
"""Reset the running flag."""
|
||||
self._is_running = False
|
||||
|
||||
def to_dict(self) -> dict[str, Any]:
|
||||
"""Serialize the workflow definition into a JSON-ready dictionary."""
|
||||
data: dict[str, Any] = {
|
||||
@@ -535,13 +531,12 @@ class Workflow(DictConvertible):
|
||||
yield in_progress # noqa: RUF070
|
||||
|
||||
# Per-run reset for fresh-message runs only. We deliberately
|
||||
# do NOT clear shared workflow state (`_state.clear()`) or the
|
||||
# runner context's in-flight messages (`reset_for_new_run()`)
|
||||
# here - state and pending work persist across `run()` calls
|
||||
# so that a `WorkflowAgent` can deliver multi-turn input on
|
||||
# the same instance and have prior turns' context survive.
|
||||
# Iteration counting and per-run kwargs ARE per-run though,
|
||||
# so they're reset here.
|
||||
# do NOT clear shared workflow state or the runner context's
|
||||
# in-flight messages here - state and pending work persist
|
||||
# across `run()` calls so that a `WorkflowAgent` can deliver
|
||||
# multi-turn input on the same instance and have prior turns'
|
||||
# context survive. Iteration counting and per-run kwargs ARE
|
||||
# per-run though, so they're reset here.
|
||||
if not is_continuation:
|
||||
self._runner.reset_iteration_count()
|
||||
|
||||
@@ -564,14 +559,13 @@ class Workflow(DictConvertible):
|
||||
combined_kwargs["client_kwargs"] = self._resolve_invocation_kwargs(
|
||||
client_kwargs, "client_kwargs"
|
||||
)
|
||||
self._state.set(WORKFLOW_RUN_KWARGS_KEY, combined_kwargs)
|
||||
self._runner.state.set(WORKFLOW_RUN_KWARGS_KEY, combined_kwargs)
|
||||
elif not is_continuation:
|
||||
self._state.set(WORKFLOW_RUN_KWARGS_KEY, {})
|
||||
self._state.commit() # Commit immediately so kwargs are available
|
||||
self._runner.state.set(WORKFLOW_RUN_KWARGS_KEY, {})
|
||||
self._runner.state.commit() # Commit immediately so kwargs are available
|
||||
|
||||
# Set streaming mode (always set explicitly per run since
|
||||
# reset_for_new_run() no longer runs to clear it).
|
||||
self._runner_context.set_streaming(streaming)
|
||||
# Explicitly set streaming mode per run
|
||||
self._runner.context.set_streaming(streaming)
|
||||
|
||||
# Execute initial setup if provided
|
||||
if initial_executor_fn:
|
||||
@@ -665,7 +659,7 @@ class Workflow(DictConvertible):
|
||||
await executor.execute(
|
||||
message,
|
||||
[self.__class__.__name__],
|
||||
self._state,
|
||||
self._runner.state,
|
||||
self._runner.context,
|
||||
trace_contexts=None,
|
||||
source_span_ids=None,
|
||||
@@ -745,9 +739,28 @@ class Workflow(DictConvertible):
|
||||
Raises:
|
||||
ValueError: If parameter combination is invalid.
|
||||
"""
|
||||
# Validate parameters and set running flag eagerly (before any async work)
|
||||
# Validate parameters first so misuse fails before we touch any run state.
|
||||
self._validate_run_params(message, responses, checkpoint_id)
|
||||
self._ensure_not_running()
|
||||
|
||||
# Concurrency check: reject a second run synchronously - before constructing
|
||||
# the ResponseStream or yielding control to the event loop - so a concurrent
|
||||
# ``run`` call can't slip past the guard while the first call is suspended
|
||||
# inside its async generator. The ``ResponseStream`` returned below is the
|
||||
# lock: as long as the caller holds a reference to it, ``self._active_run()``
|
||||
# resolves to a live object and a new ``run`` is rejected. When the stream is
|
||||
# fully consumed, ``_run_core``'s finally clears the attribute. When the
|
||||
# caller drops the stream without iterating, garbage collection invalidates
|
||||
# the weakref, so a subsequent ``run`` is permitted.
|
||||
if self._is_run_active():
|
||||
raise WorkflowException(
|
||||
"Workflow is already running; concurrent runs are not allowed on the same instance."
|
||||
)
|
||||
|
||||
# No run is active, so any runtime checkpoint storage override still set on the
|
||||
# context is stale - left over from a prior run whose stream was dropped before
|
||||
# its async-generator finalizer ran. Clear it so this run starts clean and does
|
||||
# not silently inherit the prior run's runtime checkpoint storage.
|
||||
self._runner.context.clear_runtime_checkpoint_storage()
|
||||
|
||||
response_stream = ResponseStream[WorkflowEvent, WorkflowRunResult](
|
||||
self._run_core(
|
||||
@@ -760,10 +773,8 @@ class Workflow(DictConvertible):
|
||||
client_kwargs=client_kwargs,
|
||||
),
|
||||
finalizer=functools.partial(self._finalize_events, include_status_events=include_status_events),
|
||||
cleanup_hooks=[
|
||||
functools.partial(self._run_cleanup, checkpoint_storage),
|
||||
],
|
||||
)
|
||||
self._active_run = weakref.ref(response_stream)
|
||||
|
||||
if stream:
|
||||
return response_stream
|
||||
@@ -785,55 +796,79 @@ class Workflow(DictConvertible):
|
||||
Yields:
|
||||
WorkflowEvent: The events generated during the workflow execution.
|
||||
"""
|
||||
# Enable runtime checkpointing if storage provided
|
||||
# Capture the weakref instance ``run()`` installed for *this* run. We
|
||||
# compare by object identity in the finally so a stale finalizer (e.g.
|
||||
# the caller dropped this stream after partial iteration, then started
|
||||
# a new run before async-gen finalization throws ``GeneratorExit`` into
|
||||
# us) does not clobber a successor run's freshly installed weakref.
|
||||
# ``run()`` runs synchronously and assigns ``self._active_run`` before
|
||||
# this generator's body is first iterated, so by the time we read it
|
||||
# here it already points at our own ``ResponseStream``.
|
||||
my_active_run = self._active_run
|
||||
|
||||
# Enable runtime checkpointing if storage provided.
|
||||
if checkpoint_storage is not None:
|
||||
self._runner.context.set_runtime_checkpoint_storage(checkpoint_storage)
|
||||
|
||||
# Async validation: a fresh-message run is only allowed when the
|
||||
# runner context has fully drained from any prior run. If it still
|
||||
# has in-flight executor messages, the prior run didn't complete -
|
||||
# the caller must either resume from a checkpoint or wait for the
|
||||
# prior run to drain. (Pending request_info events are intentionally
|
||||
# NOT blocked here: a follow-up run with message=... is the normal
|
||||
# way to deliver a response to those pending requests, e.g. via
|
||||
# WorkflowAgent._process_pending_requests.)
|
||||
# NOTE: _validate_run_params already enforces that ``message`` is
|
||||
# mutually exclusive with both ``checkpoint_id`` and ``responses``,
|
||||
# so we don't need to re-check those here.
|
||||
if message is not None and await self._runner.context.has_messages():
|
||||
raise RuntimeError(
|
||||
"Cannot start a new run with 'message' while in-flight executor "
|
||||
"messages remain from a prior run. Resume from a checkpoint "
|
||||
"(checkpoint_id=...) or wait for the prior run to complete. "
|
||||
"Workflows that need to recover from a mid-run failure must use "
|
||||
"checkpointing; there is no in-process recovery path."
|
||||
)
|
||||
try:
|
||||
# Async validation: a fresh-message run is only allowed when the
|
||||
# runner context has fully drained from any prior run. If it still
|
||||
# has in-flight executor messages, the prior run didn't complete -
|
||||
# the caller must either resume from a checkpoint or wait for the
|
||||
# prior run to drain. (Pending request_info events are intentionally
|
||||
# NOT blocked here: a follow-up run with message=... is the normal
|
||||
# way to deliver a response to those pending requests, e.g. via
|
||||
# WorkflowAgent._process_pending_requests.)
|
||||
# NOTE: _validate_run_params already enforces that ``message`` is
|
||||
# mutually exclusive with both ``checkpoint_id`` and ``responses``,
|
||||
# so we don't need to re-check those here.
|
||||
if message is not None and await self._runner.context.has_messages():
|
||||
raise RuntimeError(
|
||||
"Cannot start a new run with 'message' while in-flight executor "
|
||||
"messages remain from a prior run. Resume from a checkpoint "
|
||||
"(checkpoint_id=...) or wait for the prior run to complete. "
|
||||
"Workflows that need to recover from a mid-run failure must use "
|
||||
"checkpointing; there is no in-process recovery path."
|
||||
)
|
||||
|
||||
initial_executor_fn = self._resolve_execution_mode(message, responses, checkpoint_id, checkpoint_storage)
|
||||
initial_executor_fn = self._resolve_execution_mode(message, responses, checkpoint_id, checkpoint_storage)
|
||||
|
||||
async for event in self._run_workflow_with_tracing(
|
||||
initial_executor_fn=initial_executor_fn,
|
||||
is_continuation=(message is None),
|
||||
streaming=streaming,
|
||||
function_invocation_kwargs=function_invocation_kwargs,
|
||||
client_kwargs=client_kwargs,
|
||||
):
|
||||
if event.type == "request_info" and event.request_id in (responses or {}):
|
||||
# Don't yield request_info events for which we have responses to send -
|
||||
# these are considered "handled". This prevents the caller from seeing
|
||||
# events for requests they are already responding to.
|
||||
# This usually happens when responses are provided with a checkpoint
|
||||
# (restore then send), because the request_info events are stored in the
|
||||
# checkpoint and would be emitted on restoration by the runner regardless
|
||||
# of if a response is provided or not.
|
||||
continue
|
||||
yield event
|
||||
|
||||
async def _run_cleanup(self, checkpoint_storage: CheckpointStorage | None) -> None:
|
||||
"""Cleanup hook called after stream consumption."""
|
||||
if checkpoint_storage is not None:
|
||||
self._runner.context.clear_runtime_checkpoint_storage()
|
||||
self._reset_running_flag()
|
||||
async for event in self._run_workflow_with_tracing(
|
||||
initial_executor_fn=initial_executor_fn,
|
||||
is_continuation=(message is None),
|
||||
streaming=streaming,
|
||||
function_invocation_kwargs=function_invocation_kwargs,
|
||||
client_kwargs=client_kwargs,
|
||||
):
|
||||
if event.type == "request_info" and event.request_id in (responses or {}):
|
||||
# Don't yield request_info events for which we have responses to send -
|
||||
# these are considered "handled". This prevents the caller from seeing
|
||||
# events for requests they are already responding to.
|
||||
# This usually happens when responses are provided with a checkpoint
|
||||
# (restore then send), because the request_info events are stored in the
|
||||
# checkpoint and would be emitted on restoration by the runner regardless
|
||||
# of if a response is provided or not.
|
||||
continue
|
||||
yield event
|
||||
finally:
|
||||
# Whether this run is still the active one (no successor ``run()`` has
|
||||
# installed a new weakref since we started). Captured once because the
|
||||
# active-run clear below mutates ``self._active_run``. Used to scope both
|
||||
# the run-lock release and the runtime-storage clear so a dropped run's
|
||||
# deferred finalizer cannot clobber a successor run's state.
|
||||
owns_run = self._active_run is my_active_run
|
||||
if owns_run:
|
||||
# Clear the active-run weakref so a subsequent ``run()`` is allowed.
|
||||
# If the caller dropped this stream after partial iteration and a new
|
||||
# ``run()`` already installed its own weakref before our async-gen
|
||||
# finalizer ran, ``self._active_run`` points at the successor and we
|
||||
# leave it untouched to preserve the successor's concurrency guard.
|
||||
self._active_run = None
|
||||
# Same ownership scoping applies to the runtime checkpoint storage:
|
||||
# only clear it when this run still owns it, so a dropped run's
|
||||
# deferred finalizer can't clear a successor's storage.
|
||||
if checkpoint_storage is not None:
|
||||
self._runner.context.clear_runtime_checkpoint_storage()
|
||||
|
||||
@staticmethod
|
||||
def _finalize_events(
|
||||
@@ -935,7 +970,7 @@ class Workflow(DictConvertible):
|
||||
|
||||
async def _send_responses_internal(self, responses: Mapping[str, Any]) -> None:
|
||||
"""Internal method to validate and send responses to the executors."""
|
||||
pending_requests = await self._runner_context.get_pending_request_info_events()
|
||||
pending_requests = await self._runner.context.get_pending_request_info_events()
|
||||
if not pending_requests:
|
||||
raise RuntimeError("No pending requests found in workflow context.")
|
||||
|
||||
@@ -955,7 +990,7 @@ class Workflow(DictConvertible):
|
||||
coerced_responses[request_id] = response
|
||||
|
||||
await asyncio.gather(*[
|
||||
self._runner_context.send_request_info_response(request_id, response)
|
||||
self._runner.context.send_request_info_response(request_id, response)
|
||||
for request_id, response in coerced_responses.items()
|
||||
])
|
||||
|
||||
@@ -1151,3 +1186,12 @@ class Workflow(DictConvertible):
|
||||
context_providers=context_providers,
|
||||
**kwargs,
|
||||
)
|
||||
|
||||
def _is_run_active(self) -> bool:
|
||||
"""Check if a workflow run is currently active.
|
||||
|
||||
Returns:
|
||||
True if a run is active, False otherwise.
|
||||
"""
|
||||
existing_stream = self._active_run() if self._active_run is not None else None
|
||||
return existing_stream is not None
|
||||
|
||||
@@ -2211,6 +2211,181 @@ def _get_instructions_from_options(options: Any) -> str | list[str] | None:
|
||||
return None
|
||||
|
||||
|
||||
# region OTel tool definitions
|
||||
|
||||
# Per-item in-memory cache of computed OTel tool definitions, keyed by the tool
|
||||
# object's identity. Tool objects (e.g. ``FunctionTool``, ``MCPTool``) are often
|
||||
# reused across runs, so caching their converted definitions avoids repeating the
|
||||
# isinstance checks, schema generation, and dict construction on every invocation.
|
||||
# A ``WeakKeyDictionary`` lets entries be garbage collected with their tools.
|
||||
# Unhashable / non-weak-referenceable specs (e.g. plain dicts) bypass the cache.
|
||||
_TOOL_OTEL_DEFINITION_CACHE: weakref.WeakKeyDictionary[Any, dict[str, Any] | None] = weakref.WeakKeyDictionary()
|
||||
# Sentinel distinguishing "not cached" from a cached ``None`` (unparseable tool).
|
||||
_CACHE_MISS: Final = object()
|
||||
|
||||
|
||||
def _tools_to_dict(
|
||||
tools: Any,
|
||||
) -> list[dict[str, Any]] | None:
|
||||
"""Convert tools into OpenTelemetry GenAI tool definitions.
|
||||
|
||||
The output conforms to the OTel GenAI tool-definitions schema, where each
|
||||
entry is either a ``FunctionToolDefinition`` (``type="function"`` with
|
||||
``name`` and optional ``description``/``parameters``) or a
|
||||
``GenericToolDefinition`` (any ``type`` plus a ``name``). See
|
||||
https://github.com/open-telemetry/semantic-conventions-genai/blob/main/docs/gen-ai/gen-ai-tool-definitions.json.
|
||||
|
||||
Args:
|
||||
tools: The tools to parse. Can be a single tool or a sequence of tools.
|
||||
|
||||
Returns:
|
||||
A list of OTel-conformant tool-definition dicts, or ``None`` when
|
||||
``tools`` is empty or no tool can be represented.
|
||||
"""
|
||||
from ._tools import normalize_tools
|
||||
|
||||
normalized_tools = normalize_tools(tools)
|
||||
if not normalized_tools:
|
||||
return None
|
||||
results: list[dict[str, Any]] = []
|
||||
for tool_item in normalized_tools:
|
||||
otel_def = _tool_to_otel_definition(tool_item)
|
||||
if otel_def is not None:
|
||||
results.append(otel_def)
|
||||
return results or None
|
||||
|
||||
|
||||
def _tool_to_otel_definition(tool_item: Any) -> dict[str, Any] | None:
|
||||
"""Convert a single tool spec into an OTel GenAI tool-definition dict.
|
||||
|
||||
Results are cached per tool object (keyed by identity) so repeated runs that
|
||||
reuse the same tool instances skip the conversion work. Specs that cannot be
|
||||
weakly referenced (e.g. plain dicts) are converted without caching.
|
||||
|
||||
Returns ``None`` and emits a warning when the input cannot be represented
|
||||
as either a ``FunctionToolDefinition`` or a ``GenericToolDefinition``.
|
||||
"""
|
||||
try:
|
||||
cached = _TOOL_OTEL_DEFINITION_CACHE.get(tool_item, _CACHE_MISS)
|
||||
except TypeError:
|
||||
# Unhashable spec (e.g. a plain dict); convert without caching.
|
||||
return _build_tool_otel_definition(tool_item)
|
||||
if cached is not _CACHE_MISS:
|
||||
return cast("dict[str, Any] | None", cached)
|
||||
|
||||
definition = _build_tool_otel_definition(tool_item)
|
||||
with contextlib.suppress(TypeError):
|
||||
# Object may not support weak references; skip caching when that is the case.
|
||||
_TOOL_OTEL_DEFINITION_CACHE[tool_item] = definition
|
||||
return definition
|
||||
|
||||
|
||||
def _build_tool_otel_definition(tool_item: Any) -> dict[str, Any] | None:
|
||||
"""Convert a single tool spec into an OTel GenAI tool-definition dict (uncached)."""
|
||||
from pydantic import BaseModel
|
||||
|
||||
from ._mcp import MCPTool
|
||||
from ._serialization import SerializationMixin
|
||||
from ._tools import FunctionTool
|
||||
|
||||
if isinstance(tool_item, FunctionTool):
|
||||
definition: dict[str, Any] = {"type": "function", "name": tool_item.name}
|
||||
if tool_item.description:
|
||||
definition["description"] = tool_item.description
|
||||
parameters = tool_item.parameters()
|
||||
if parameters:
|
||||
definition["parameters"] = parameters
|
||||
return definition
|
||||
|
||||
if isinstance(tool_item, MCPTool):
|
||||
definition = {"type": "mcp", "name": tool_item.name}
|
||||
if tool_item.description:
|
||||
definition["description"] = tool_item.description
|
||||
return definition
|
||||
|
||||
raw: Mapping[str, Any] | None = None
|
||||
if isinstance(tool_item, BaseModel):
|
||||
raw = tool_item.model_dump(exclude_none=True)
|
||||
elif isinstance(tool_item, SerializationMixin):
|
||||
raw = tool_item.to_dict()
|
||||
elif isinstance(tool_item, Mapping):
|
||||
raw = cast("Mapping[str, Any]", tool_item)
|
||||
|
||||
if raw is None:
|
||||
logger.warning(
|
||||
"Can't parse tool to OpenTelemetry tool definition: %s",
|
||||
type(tool_item).__name__, # type: ignore[reportUnknownArgumentType]
|
||||
)
|
||||
return None
|
||||
return _otel_definition_from_mapping(raw)
|
||||
|
||||
|
||||
def _otel_definition_from_mapping(raw: Mapping[str, Any]) -> dict[str, Any] | None:
|
||||
"""Reshape a tool spec mapping into an OTel GenAI tool-definition dict.
|
||||
|
||||
Handles the nested OpenAI Chat Completions function shape
|
||||
(``{"type": "function", "function": {...}}``) by flattening it into the
|
||||
OTel shape.
|
||||
"""
|
||||
# OpenAI Chat Completions nests the function spec one level deeper; flatten it.
|
||||
nested_function = raw.get("function") if raw.get("type") == "function" else None
|
||||
if isinstance(nested_function, Mapping):
|
||||
nested = cast("Mapping[str, Any]", nested_function)
|
||||
name = nested.get("name")
|
||||
if not isinstance(name, str) or not name:
|
||||
logger.warning("Can't parse tool to OpenTelemetry tool definition: missing 'name'.")
|
||||
return None
|
||||
definition: dict[str, Any] = {"type": "function", "name": name}
|
||||
description = nested.get("description")
|
||||
if description:
|
||||
definition["description"] = description
|
||||
parameters = nested.get("parameters")
|
||||
if parameters:
|
||||
definition["parameters"] = parameters
|
||||
# Forward extra properties from both layers, preferring the inner spec.
|
||||
for source in (nested, raw):
|
||||
for key, value in source.items():
|
||||
if key in {"type", "function", "name", "description", "parameters"}:
|
||||
continue
|
||||
definition.setdefault(key, value)
|
||||
return definition
|
||||
|
||||
type_value = raw.get("type")
|
||||
if not isinstance(type_value, str) or not type_value:
|
||||
logger.warning("Can't parse tool to OpenTelemetry tool definition: missing 'type'.")
|
||||
return None
|
||||
|
||||
name_value = raw.get("name")
|
||||
if not isinstance(name_value, str) or not name_value:
|
||||
# Hosted tools sometimes omit ``name`` (e.g. ``{"type": "code_interpreter"}``);
|
||||
# fall back to the type so the OTel definition stays valid.
|
||||
name_value = type_value
|
||||
|
||||
if type_value == "function":
|
||||
definition = {"type": "function", "name": name_value}
|
||||
description = raw.get("description")
|
||||
if description:
|
||||
definition["description"] = description
|
||||
parameters = raw.get("parameters")
|
||||
if parameters:
|
||||
definition["parameters"] = parameters
|
||||
for key, value in raw.items():
|
||||
if key in {"type", "name", "description", "parameters"}:
|
||||
continue
|
||||
definition.setdefault(key, value)
|
||||
return definition
|
||||
|
||||
definition = {"type": type_value, "name": name_value}
|
||||
for key, value in raw.items():
|
||||
if key in {"type", "name"}:
|
||||
continue
|
||||
definition[key] = value
|
||||
return definition
|
||||
|
||||
|
||||
# endregion
|
||||
|
||||
|
||||
# Mapping configuration for extracting span attributes
|
||||
# Each entry: source_keys -> (otel_attribute_key, transform_func, check_options_first, default_value)
|
||||
# - source_keys: single key or list of keys to check (first non-None value wins)
|
||||
@@ -2246,11 +2421,7 @@ OTEL_ATTR_MAP: dict[str | tuple[str, ...], tuple[str, Callable[[Any], Any] | Non
|
||||
# Tools with validation - returns None if no valid tools
|
||||
"tools": (
|
||||
OtelAttr.TOOL_DEFINITIONS,
|
||||
lambda tools: (
|
||||
json.dumps(tools_dict, ensure_ascii=False)
|
||||
if (tools_dict := __import__("agent_framework._tools", fromlist=["_tools_to_dict"])._tools_to_dict(tools))
|
||||
else None
|
||||
),
|
||||
lambda tools: json.dumps(tools_dict, ensure_ascii=False) if (tools_dict := _tools_to_dict(tools)) else None,
|
||||
True,
|
||||
None,
|
||||
),
|
||||
|
||||
@@ -497,10 +497,10 @@ async def test_file_access_provider_registers_tools_and_instructions(
|
||||
assert any(DEFAULT_FILE_ACCESS_INSTRUCTIONS in chunk for chunk in (instructions or []))
|
||||
|
||||
|
||||
async def test_file_access_provider_delete_approval_defaults_to_always_require(
|
||||
async def test_file_access_provider_all_tools_require_approval(
|
||||
chat_client_base: SupportsChatGetResponse,
|
||||
) -> None:
|
||||
"""By default ``file_access_delete_file`` should require host approval."""
|
||||
"""Every file-access tool should require host approval."""
|
||||
session = AgentSession(session_id="session-1")
|
||||
provider = FileAccessProvider(store=InMemoryAgentFileStore())
|
||||
agent = Agent(client=chat_client_base, context_providers=[provider])
|
||||
@@ -512,36 +512,72 @@ async def test_file_access_provider_delete_approval_defaults_to_always_require(
|
||||
|
||||
tools = options["tools"]
|
||||
assert isinstance(tools, list)
|
||||
delete_file = _tool_by_name(tools, "file_access_delete_file")
|
||||
assert delete_file.approval_mode == "always_require"
|
||||
# The non-destructive tools should remain autonomous.
|
||||
for name in (
|
||||
"file_access_save_file",
|
||||
"file_access_read_file",
|
||||
"file_access_list_files",
|
||||
"file_access_list_subdirectories",
|
||||
"file_access_search_files",
|
||||
FileAccessProvider.SAVE_FILE_TOOL_NAME,
|
||||
FileAccessProvider.READ_FILE_TOOL_NAME,
|
||||
FileAccessProvider.DELETE_FILE_TOOL_NAME,
|
||||
FileAccessProvider.LIST_FILES_TOOL_NAME,
|
||||
FileAccessProvider.LIST_SUBDIRECTORIES_TOOL_NAME,
|
||||
FileAccessProvider.SEARCH_FILES_TOOL_NAME,
|
||||
):
|
||||
assert _tool_by_name(tools, name).approval_mode == "never_require"
|
||||
assert _tool_by_name(tools, name).approval_mode == "always_require"
|
||||
|
||||
|
||||
async def test_file_access_provider_delete_approval_opt_out(
|
||||
chat_client_base: SupportsChatGetResponse,
|
||||
) -> None:
|
||||
"""``require_delete_approval=False`` should drop delete to ``never_require``."""
|
||||
session = AgentSession(session_id="session-1")
|
||||
provider = FileAccessProvider(store=InMemoryAgentFileStore(), require_delete_approval=False)
|
||||
agent = Agent(client=chat_client_base, context_providers=[provider])
|
||||
def test_read_only_tools_auto_approval_rule() -> None:
|
||||
"""The read-only rule approves only the non-mutating tools."""
|
||||
approved = {
|
||||
FileAccessProvider.READ_FILE_TOOL_NAME,
|
||||
FileAccessProvider.LIST_FILES_TOOL_NAME,
|
||||
FileAccessProvider.LIST_SUBDIRECTORIES_TOOL_NAME,
|
||||
FileAccessProvider.SEARCH_FILES_TOOL_NAME,
|
||||
}
|
||||
rejected = {
|
||||
FileAccessProvider.SAVE_FILE_TOOL_NAME,
|
||||
FileAccessProvider.DELETE_FILE_TOOL_NAME,
|
||||
"some_other_tool",
|
||||
}
|
||||
for name in approved:
|
||||
call = Content("function_call", call_id="c1", name=name, arguments="{}")
|
||||
assert FileAccessProvider.read_only_tools_auto_approval_rule(call) is True
|
||||
for name in rejected:
|
||||
call = Content("function_call", call_id="c1", name=name, arguments="{}")
|
||||
assert FileAccessProvider.read_only_tools_auto_approval_rule(call) is False
|
||||
# A hosted tool with the same name (carrying a server_label) is NOT auto-approved.
|
||||
for name in approved:
|
||||
hosted = Content(
|
||||
"function_call",
|
||||
call_id="c1",
|
||||
name=name,
|
||||
arguments="{}",
|
||||
additional_properties={"server_label": "remote"},
|
||||
)
|
||||
assert FileAccessProvider.read_only_tools_auto_approval_rule(hosted) is False
|
||||
|
||||
_, options = await agent._prepare_session_and_messages( # pyright: ignore[reportPrivateUsage]
|
||||
session=session,
|
||||
input_messages=[Message(role="user", contents=["work with files"])],
|
||||
)
|
||||
|
||||
tools = options["tools"]
|
||||
assert isinstance(tools, list)
|
||||
delete_file = _tool_by_name(tools, "file_access_delete_file")
|
||||
assert delete_file.approval_mode == "never_require"
|
||||
def test_all_tools_auto_approval_rule() -> None:
|
||||
"""The all-tools rule approves every file-access tool but nothing else."""
|
||||
for name in (
|
||||
FileAccessProvider.SAVE_FILE_TOOL_NAME,
|
||||
FileAccessProvider.READ_FILE_TOOL_NAME,
|
||||
FileAccessProvider.DELETE_FILE_TOOL_NAME,
|
||||
FileAccessProvider.LIST_FILES_TOOL_NAME,
|
||||
FileAccessProvider.LIST_SUBDIRECTORIES_TOOL_NAME,
|
||||
FileAccessProvider.SEARCH_FILES_TOOL_NAME,
|
||||
):
|
||||
call = Content("function_call", call_id="c1", name=name, arguments="{}")
|
||||
assert FileAccessProvider.all_tools_auto_approval_rule(call) is True
|
||||
# A hosted tool with the same name (carrying a server_label) is NOT auto-approved.
|
||||
hosted = Content(
|
||||
"function_call",
|
||||
call_id="c1",
|
||||
name=name,
|
||||
arguments="{}",
|
||||
additional_properties={"server_label": "remote"},
|
||||
)
|
||||
assert FileAccessProvider.all_tools_auto_approval_rule(hosted) is False
|
||||
|
||||
unrelated = Content("function_call", call_id="c1", name="some_other_tool", arguments="{}")
|
||||
assert FileAccessProvider.all_tools_auto_approval_rule(unrelated) is False
|
||||
|
||||
|
||||
async def test_file_access_provider_tools_round_trip_files(
|
||||
|
||||
@@ -122,6 +122,113 @@ async def test_load_tools_with_tool_name_prefix_preserves_matching_configuration
|
||||
assert tool.functions[0].approval_mode == "always_require"
|
||||
|
||||
|
||||
async def test_allowed_tools_does_not_authorize_normalized_remote_name_collision() -> None:
|
||||
"""A normalized/local allowlist match must not authorize a different raw remote tool."""
|
||||
tool = MCPTool(name="test_server", allowed_tools=["delete-file"]) # type: ignore[abstract]
|
||||
|
||||
mock_session = AsyncMock()
|
||||
tool.session = mock_session
|
||||
tool.load_tools_flag = True
|
||||
|
||||
page = Mock()
|
||||
page.tools = [
|
||||
types.Tool(
|
||||
name="delete/file",
|
||||
description="Delete a file",
|
||||
inputSchema={"type": "object", "properties": {}},
|
||||
),
|
||||
]
|
||||
page.nextCursor = None
|
||||
mock_session.list_tools = AsyncMock(return_value=page)
|
||||
|
||||
await tool.load_tools()
|
||||
|
||||
assert [function.name for function in tool._functions] == ["delete-file"]
|
||||
assert tool.functions == []
|
||||
|
||||
|
||||
async def test_load_tools_rejects_colliding_normalized_tool_names() -> None:
|
||||
"""A remote MCP server must not choose which raw tool backs a colliding local name."""
|
||||
tool = MCPTool(name="test_server", allowed_tools=["delete-file"]) # type: ignore[abstract]
|
||||
|
||||
mock_session = AsyncMock()
|
||||
tool.session = mock_session
|
||||
tool.load_tools_flag = True
|
||||
|
||||
page = Mock()
|
||||
page.tools = [
|
||||
types.Tool(
|
||||
name="delete/file",
|
||||
description="Unauthorized tool",
|
||||
inputSchema={"type": "object", "properties": {}},
|
||||
),
|
||||
types.Tool(
|
||||
name="delete-file",
|
||||
description="Authorized tool",
|
||||
inputSchema={"type": "object", "properties": {}},
|
||||
),
|
||||
]
|
||||
page.nextCursor = None
|
||||
mock_session.list_tools = AsyncMock(return_value=page)
|
||||
|
||||
with pytest.raises(ToolExecutionException, match="map to the same local function name"):
|
||||
await tool.load_tools()
|
||||
|
||||
|
||||
async def test_allowed_tools_exact_raw_name_allows_normalized_function_name() -> None:
|
||||
"""An exact raw remote allowlist entry still exposes that raw tool, regardless of local normalization."""
|
||||
tool = MCPTool(name="test_server", allowed_tools=["delete/file"]) # type: ignore[abstract]
|
||||
|
||||
mock_session = AsyncMock()
|
||||
tool.session = mock_session
|
||||
tool.load_tools_flag = True
|
||||
|
||||
page = Mock()
|
||||
page.tools = [
|
||||
types.Tool(
|
||||
name="delete/file",
|
||||
description="Delete a file",
|
||||
inputSchema={"type": "object", "properties": {}},
|
||||
),
|
||||
]
|
||||
page.nextCursor = None
|
||||
mock_session.list_tools = AsyncMock(return_value=page)
|
||||
|
||||
await tool.load_tools()
|
||||
|
||||
assert [function.name for function in tool.functions] == ["delete-file"]
|
||||
assert tool.functions[0].additional_properties is not None
|
||||
assert tool.functions[0].additional_properties["_mcp_remote_name"] == "delete/file"
|
||||
|
||||
|
||||
async def test_approval_mode_does_not_match_normalized_colliding_name() -> None:
|
||||
"""Approval rules should not apply to a different raw remote tool through normalization."""
|
||||
tool = MCPTool( # type: ignore[abstract]
|
||||
name="test_server",
|
||||
approval_mode={"always_require_approval": ["delete-file"]},
|
||||
)
|
||||
|
||||
mock_session = AsyncMock()
|
||||
tool.session = mock_session
|
||||
tool.load_tools_flag = True
|
||||
|
||||
page = Mock()
|
||||
page.tools = [
|
||||
types.Tool(
|
||||
name="delete/file",
|
||||
description="Delete a file",
|
||||
inputSchema={"type": "object", "properties": {}},
|
||||
),
|
||||
]
|
||||
page.nextCursor = None
|
||||
mock_session.list_tools = AsyncMock(return_value=page)
|
||||
|
||||
await tool.load_tools()
|
||||
|
||||
assert tool._functions[0].name == "delete-file"
|
||||
assert tool._functions[0].approval_mode == "never_require"
|
||||
|
||||
|
||||
async def test_load_prompts_with_tool_name_prefix() -> None:
|
||||
"""Prefixed MCP prompt names should be exposed with the configured prefix."""
|
||||
tool = MCPTool(name="docs", tool_name_prefix="docs") # type: ignore[abstract]
|
||||
@@ -3339,6 +3446,7 @@ async def test_load_tools_adds_properties_to_zero_arg_tool_schema():
|
||||
none_schema_tool.name = "none_schema_tool"
|
||||
none_schema_tool.description = "A tool with None inputSchema"
|
||||
none_schema_tool.inputSchema = None
|
||||
none_schema_tool.meta = None
|
||||
page.tools.append(none_schema_tool)
|
||||
page.nextCursor = None
|
||||
|
||||
@@ -4777,7 +4885,7 @@ async def test_mcp_tool_call_tool_forwards_tool_list_meta():
|
||||
|
||||
|
||||
async def test_mcp_tool_call_tool_user_meta_merges_with_tool_list_meta():
|
||||
"""User-provided _meta should be sent as MCP request metadata, not tool arguments."""
|
||||
"""Tools/list _meta should win over caller-provided _meta on conflicts."""
|
||||
from opentelemetry import trace
|
||||
|
||||
tool_meta = {"from_tool": "tool-value", "shared": "tool-value"}
|
||||
@@ -4817,11 +4925,153 @@ async def test_mcp_tool_call_tool_user_meta_merges_with_tool_list_meta():
|
||||
assert call_kwargs["meta"] == {
|
||||
"from_tool": "tool-value",
|
||||
"from_user": "user-value",
|
||||
"shared": "user-value",
|
||||
"shared": "tool-value",
|
||||
}
|
||||
assert user_meta == {"from_user": "user-value", "shared": "user-value"}
|
||||
|
||||
|
||||
async def test_mcp_tool_function_invocation_strips_model_supplied_meta() -> None:
|
||||
"""Model-supplied _meta should not become MCP request metadata."""
|
||||
from opentelemetry import trace
|
||||
|
||||
class TestServer(MCPTool):
|
||||
async def connect(self) -> None: # type: ignore[override] # pyrefly: ignore[bad-override] # ty: ignore[invalid-method-override]
|
||||
self.session = Mock(spec=ClientSession)
|
||||
self.session.list_tools = AsyncMock(
|
||||
return_value=types.ListToolsResult(
|
||||
tools=[
|
||||
types.Tool(
|
||||
name="test_tool",
|
||||
description="Test tool",
|
||||
inputSchema={"type": "object", "properties": {"param": {"type": "string"}}},
|
||||
)
|
||||
]
|
||||
)
|
||||
)
|
||||
self.session.call_tool = AsyncMock(
|
||||
return_value=types.CallToolResult(content=[types.TextContent(type="text", text="result")])
|
||||
)
|
||||
|
||||
def get_mcp_client(self) -> _AsyncGeneratorContextManager[Any, None]:
|
||||
return None # type: ignore[return-value] # pyrefly: ignore[bad-return] # ty: ignore[invalid-return-type]
|
||||
|
||||
server = TestServer(name="test_server")
|
||||
async with server:
|
||||
await server.load_tools()
|
||||
|
||||
with (
|
||||
trace.use_span(trace.NonRecordingSpan(trace.INVALID_SPAN_CONTEXT)),
|
||||
patch("agent_framework._mcp.propagate.inject", side_effect=lambda carrier: None),
|
||||
):
|
||||
await server.functions[0].invoke(
|
||||
arguments={"param": "test_value", "_meta": {"attacker.example/route": "evil"}}
|
||||
)
|
||||
|
||||
call_kwargs = server.session.call_tool.call_args.kwargs # type: ignore[union-attr] # ty: ignore[unresolved-attribute]
|
||||
assert call_kwargs["arguments"] == {"param": "test_value"}
|
||||
assert call_kwargs["meta"] is None
|
||||
|
||||
|
||||
async def test_mcp_tool_function_invocation_preserves_trusted_meta_over_model_meta() -> None:
|
||||
"""Trusted function-invocation _meta should be restored after model arguments are merged."""
|
||||
from opentelemetry import trace
|
||||
|
||||
trusted_meta = {"trusted.example/route": "trusted"}
|
||||
|
||||
class TestServer(MCPTool):
|
||||
async def connect(self) -> None: # type: ignore[override] # pyrefly: ignore[bad-override] # ty: ignore[invalid-method-override]
|
||||
self.session = Mock(spec=ClientSession)
|
||||
self.session.list_tools = AsyncMock(
|
||||
return_value=types.ListToolsResult(
|
||||
tools=[
|
||||
types.Tool(
|
||||
name="test_tool",
|
||||
description="Test tool",
|
||||
inputSchema={"type": "object", "properties": {"param": {"type": "string"}}},
|
||||
)
|
||||
]
|
||||
)
|
||||
)
|
||||
self.session.call_tool = AsyncMock(
|
||||
return_value=types.CallToolResult(content=[types.TextContent(type="text", text="result")])
|
||||
)
|
||||
|
||||
def get_mcp_client(self) -> _AsyncGeneratorContextManager[Any, None]:
|
||||
return None # type: ignore[return-value] # pyrefly: ignore[bad-return] # ty: ignore[invalid-return-type]
|
||||
|
||||
server = TestServer(name="test_server")
|
||||
async with server:
|
||||
await server.load_tools()
|
||||
|
||||
context = FunctionInvocationContext(
|
||||
function=server.functions[0],
|
||||
arguments={},
|
||||
kwargs={"_meta": trusted_meta},
|
||||
)
|
||||
with (
|
||||
trace.use_span(trace.NonRecordingSpan(trace.INVALID_SPAN_CONTEXT)),
|
||||
patch("agent_framework._mcp.propagate.inject", side_effect=lambda carrier: None),
|
||||
):
|
||||
await server.functions[0].invoke(
|
||||
arguments={"param": "test_value", "_meta": {"attacker.example/route": "evil"}},
|
||||
context=context,
|
||||
)
|
||||
|
||||
call_kwargs = server.session.call_tool.call_args.kwargs # type: ignore[union-attr] # ty: ignore[unresolved-attribute]
|
||||
assert call_kwargs["arguments"] == {"param": "test_value"}
|
||||
assert call_kwargs["meta"] == trusted_meta
|
||||
|
||||
|
||||
async def test_mcp_tool_call_tool_otel_meta_overrides_user_meta_but_not_tool_list_meta() -> None:
|
||||
"""OpenTelemetry should override caller metadata while tools/list metadata remains most trusted."""
|
||||
from opentelemetry import trace
|
||||
|
||||
tool_meta = {"traceparent": "tool-traceparent", "from_tool": "tool-value"}
|
||||
user_meta = {"traceparent": "user-traceparent", "from_user": "user-value"}
|
||||
|
||||
class TestServer(MCPTool):
|
||||
async def connect(self) -> None: # type: ignore[override] # pyrefly: ignore[bad-override] # ty: ignore[invalid-method-override]
|
||||
self.session = Mock(spec=ClientSession)
|
||||
self.session.list_tools = AsyncMock(
|
||||
return_value=types.ListToolsResult(
|
||||
tools=[
|
||||
types.Tool(
|
||||
name="test_tool",
|
||||
description="Test tool",
|
||||
inputSchema={"type": "object", "properties": {"param": {"type": "string"}}},
|
||||
_meta=tool_meta,
|
||||
)
|
||||
]
|
||||
)
|
||||
)
|
||||
self.session.call_tool = AsyncMock(
|
||||
return_value=types.CallToolResult(content=[types.TextContent(type="text", text="result")])
|
||||
)
|
||||
|
||||
def get_mcp_client(self) -> _AsyncGeneratorContextManager[Any, None]:
|
||||
return None # type: ignore[return-value] # pyrefly: ignore[bad-return] # ty: ignore[invalid-return-type]
|
||||
|
||||
server = TestServer(name="test_server")
|
||||
async with server:
|
||||
await server.load_tools()
|
||||
|
||||
with (
|
||||
trace.use_span(trace.NonRecordingSpan(trace.INVALID_SPAN_CONTEXT)),
|
||||
patch(
|
||||
"agent_framework._mcp.propagate.inject",
|
||||
side_effect=lambda carrier: carrier.update({"traceparent": "otel-traceparent"}),
|
||||
),
|
||||
):
|
||||
await server.call_tool("test_tool", param="test_value", _meta=user_meta)
|
||||
|
||||
call_kwargs = server.session.call_tool.call_args.kwargs # type: ignore[union-attr] # ty: ignore[unresolved-attribute]
|
||||
assert call_kwargs["meta"] == {
|
||||
"traceparent": "tool-traceparent",
|
||||
"from_tool": "tool-value",
|
||||
"from_user": "user-value",
|
||||
}
|
||||
|
||||
|
||||
async def test_mcp_streamable_http_tool_hook_not_duplicated_on_repeated_get_mcp_client():
|
||||
"""Test that calling get_mcp_client multiple times does not accumulate duplicate hooks."""
|
||||
tool = MCPStreamableHTTPTool(
|
||||
@@ -6475,6 +6725,30 @@ def test_prepare_call_kwargs_extracts_meta() -> None:
|
||||
assert meta.get("trace") == "abc"
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"key",
|
||||
[
|
||||
"",
|
||||
"_leading-underscore",
|
||||
"trailing-underscore_",
|
||||
"abc/",
|
||||
"1bad.example/name",
|
||||
"bad..example/name",
|
||||
"bad.example/_name",
|
||||
"bad.example/name_",
|
||||
],
|
||||
)
|
||||
def test_prepare_call_kwargs_rejects_invalid_meta_key_names(key: str) -> None:
|
||||
server = MCPTool(name="test_server") # type: ignore[abstract]
|
||||
server._tool_param_names_by_name = {"test_tool": {"param"}}
|
||||
|
||||
with pytest.raises(ToolExecutionException, match="Invalid MCP _meta key name"):
|
||||
server._prepare_call_kwargs(
|
||||
"test_tool",
|
||||
{"param": "v", "_meta": {key: "value"}},
|
||||
)
|
||||
|
||||
|
||||
async def test_call_tool_forwards_only_declared_arguments() -> None:
|
||||
"""End-to-end: framework runtime kwargs are stripped before reaching the server."""
|
||||
|
||||
|
||||
@@ -3132,6 +3132,223 @@ def test_get_span_attributes_with_agent_info():
|
||||
assert attrs[OtelAttr.AGENT_DESCRIPTION] == "A test agent"
|
||||
|
||||
|
||||
def test_get_span_attributes_emits_otel_tool_definitions() -> None:
|
||||
"""``tools`` are serialized to OTel GenAI tool definitions on the span."""
|
||||
import json as _json
|
||||
|
||||
from agent_framework import tool
|
||||
from agent_framework.observability import OtelAttr, _get_span_attributes
|
||||
|
||||
@tool(name="echo", description="Echo input")
|
||||
def echo(value: str) -> str:
|
||||
return value
|
||||
|
||||
attrs = _get_span_attributes(
|
||||
operation_name="chat",
|
||||
provider_name="openai",
|
||||
model="gpt-4",
|
||||
service_url="https://api.openai.com",
|
||||
tools=[
|
||||
echo,
|
||||
{
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "lookup",
|
||||
"description": "Lookup by id",
|
||||
"parameters": {"type": "object", "properties": {"id": {"type": "string"}}},
|
||||
},
|
||||
},
|
||||
{"type": "web_search", "name": "web_search"},
|
||||
],
|
||||
)
|
||||
|
||||
assert OtelAttr.TOOL_DEFINITIONS in attrs
|
||||
definitions = _json.loads(attrs[OtelAttr.TOOL_DEFINITIONS])
|
||||
assert definitions == [
|
||||
{
|
||||
"type": "function",
|
||||
"name": "echo",
|
||||
"description": "Echo input",
|
||||
"parameters": echo.parameters(),
|
||||
},
|
||||
{
|
||||
"type": "function",
|
||||
"name": "lookup",
|
||||
"description": "Lookup by id",
|
||||
"parameters": {"type": "object", "properties": {"id": {"type": "string"}}},
|
||||
},
|
||||
{"type": "web_search", "name": "web_search"},
|
||||
]
|
||||
|
||||
|
||||
def test_get_span_attributes_omits_tool_definitions_when_unparseable() -> None:
|
||||
"""When no tool can be converted, the tool definitions attribute is omitted."""
|
||||
from agent_framework.observability import OtelAttr, _get_span_attributes
|
||||
|
||||
attrs = _get_span_attributes(
|
||||
operation_name="chat",
|
||||
provider_name="openai",
|
||||
model="gpt-4",
|
||||
service_url="https://api.openai.com",
|
||||
tools=[{"kind": "not_an_otel_tool"}],
|
||||
)
|
||||
|
||||
assert OtelAttr.TOOL_DEFINITIONS not in attrs
|
||||
|
||||
|
||||
def test_tools_to_dict_supports_pydantic_tool_models() -> None:
|
||||
"""Pydantic-based tool specs are reshaped into the OTel GenAI tool-definition shape."""
|
||||
from pydantic import BaseModel
|
||||
|
||||
from agent_framework.observability import _tools_to_dict
|
||||
|
||||
class ProviderTool(BaseModel):
|
||||
type: str
|
||||
name: str
|
||||
enabled: bool = True
|
||||
note: str | None = None
|
||||
|
||||
result = _tools_to_dict([ProviderTool(type="web_search", name="web_search")])
|
||||
|
||||
assert result == [{"type": "web_search", "name": "web_search", "enabled": True}]
|
||||
|
||||
|
||||
def test_tools_to_dict_returns_none_for_empty_input() -> None:
|
||||
"""``_tools_to_dict`` returns None when no tools are supplied."""
|
||||
from agent_framework.observability import _tools_to_dict
|
||||
|
||||
assert _tools_to_dict(None) is None
|
||||
assert _tools_to_dict([]) is None
|
||||
|
||||
|
||||
def test_tools_to_dict_function_tool_uses_otel_function_definition() -> None:
|
||||
"""``FunctionTool`` instances are emitted as flat OTel FunctionToolDefinition dicts."""
|
||||
from agent_framework import tool
|
||||
from agent_framework.observability import _tools_to_dict
|
||||
|
||||
@tool(name="add", description="Add two numbers")
|
||||
def add(x: int, y: int) -> int:
|
||||
return x + y
|
||||
|
||||
result = _tools_to_dict([add])
|
||||
|
||||
assert result is not None
|
||||
assert len(result) == 1
|
||||
definition = result[0]
|
||||
assert definition["type"] == "function"
|
||||
assert definition["name"] == "add"
|
||||
assert definition["description"] == "Add two numbers"
|
||||
assert definition["parameters"]["type"] == "object"
|
||||
assert set(definition["parameters"]["required"]) == {"x", "y"}
|
||||
# The legacy OpenAI Chat Completions ``function`` wrapper is not part of the OTel shape.
|
||||
assert "function" not in definition
|
||||
|
||||
|
||||
def test_tools_to_dict_flattens_openai_chat_completions_function_spec() -> None:
|
||||
"""OpenAI Chat Completions nested ``function`` spec is flattened to the OTel shape."""
|
||||
from agent_framework.observability import _tools_to_dict
|
||||
|
||||
openai_spec = {
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "lookup_user",
|
||||
"description": "Look up a user by id",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {"user_id": {"type": "string"}},
|
||||
"required": ["user_id"],
|
||||
},
|
||||
"strict": True,
|
||||
},
|
||||
}
|
||||
|
||||
result = _tools_to_dict([openai_spec])
|
||||
|
||||
assert result == [
|
||||
{
|
||||
"type": "function",
|
||||
"name": "lookup_user",
|
||||
"description": "Look up a user by id",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {"user_id": {"type": "string"}},
|
||||
"required": ["user_id"],
|
||||
},
|
||||
"strict": True,
|
||||
}
|
||||
]
|
||||
|
||||
|
||||
def test_tools_to_dict_passes_through_hosted_tool_dicts() -> None:
|
||||
"""Hosted-tool dicts pass through with the OTel required keys preserved."""
|
||||
from agent_framework.observability import _tools_to_dict
|
||||
|
||||
result = _tools_to_dict([{"type": "web_search", "name": "web_search", "max_results": 5}])
|
||||
|
||||
assert result == [{"type": "web_search", "name": "web_search", "max_results": 5}]
|
||||
|
||||
|
||||
def test_tools_to_dict_falls_back_to_type_when_name_missing() -> None:
|
||||
"""Hosted-tool dicts without ``name`` fall back to the ``type`` value."""
|
||||
from agent_framework.observability import _tools_to_dict
|
||||
|
||||
result = _tools_to_dict([{"type": "code_interpreter"}])
|
||||
|
||||
assert result == [{"type": "code_interpreter", "name": "code_interpreter"}]
|
||||
|
||||
|
||||
def test_tools_to_dict_warns_when_type_missing(caplog: pytest.LogCaptureFixture) -> None:
|
||||
"""Tools without an extractable ``type`` are skipped with a warning."""
|
||||
from agent_framework.observability import _tools_to_dict
|
||||
|
||||
with caplog.at_level("WARNING", logger="agent_framework"):
|
||||
result = _tools_to_dict([{"kind": "not_an_otel_tool"}])
|
||||
|
||||
assert result is None
|
||||
assert any("missing 'type'" in rec.message for rec in caplog.records)
|
||||
|
||||
|
||||
def test_tools_to_dict_warns_for_unknown_tool_object(caplog: pytest.LogCaptureFixture) -> None:
|
||||
"""Tools that are neither callable, mapping, BaseModel, nor known type are skipped."""
|
||||
from agent_framework.observability import _tools_to_dict
|
||||
|
||||
class _Opaque:
|
||||
pass
|
||||
|
||||
with caplog.at_level("WARNING", logger="agent_framework"):
|
||||
result = _tools_to_dict([_Opaque()])
|
||||
|
||||
assert result is None
|
||||
assert any("OpenTelemetry tool definition" in rec.message for rec in caplog.records)
|
||||
|
||||
|
||||
def test_tool_to_otel_definition_caches_per_tool_object() -> None:
|
||||
"""Converting the same tool object twice reuses the cached OTel definition."""
|
||||
from agent_framework import tool
|
||||
from agent_framework.observability import _build_tool_otel_definition, _tool_to_otel_definition
|
||||
|
||||
@tool(name="add", description="Add two numbers")
|
||||
def add(x: int, y: int) -> int:
|
||||
return x + y
|
||||
|
||||
first = _tool_to_otel_definition(add)
|
||||
second = _tool_to_otel_definition(add)
|
||||
|
||||
# The cached result is returned as the same object on subsequent conversions.
|
||||
assert first is second
|
||||
# A fresh (uncached) build produces an equal but distinct object.
|
||||
assert _build_tool_otel_definition(add) == first
|
||||
|
||||
|
||||
def test_tool_to_otel_definition_skips_cache_for_unhashable_specs() -> None:
|
||||
"""Plain-dict tool specs are converted without raising despite being uncacheable."""
|
||||
from agent_framework.observability import _tool_to_otel_definition
|
||||
|
||||
spec = {"type": "web_search", "name": "web_search"}
|
||||
|
||||
assert _tool_to_otel_definition(spec) == {"type": "web_search", "name": "web_search"}
|
||||
|
||||
|
||||
# region Test _capture_response
|
||||
|
||||
|
||||
|
||||
@@ -19,26 +19,12 @@ from agent_framework._middleware import FunctionInvocationContext
|
||||
from agent_framework._tools import (
|
||||
_parse_annotation,
|
||||
_parse_inputs,
|
||||
_tools_to_dict,
|
||||
)
|
||||
from agent_framework.observability import OtelAttr
|
||||
|
||||
# region FunctionTool and tool decorator tests
|
||||
|
||||
|
||||
def test_tools_to_dict_supports_pydantic_tool_models() -> None:
|
||||
"""Pydantic-based tool specs are serialized without logging parse warnings."""
|
||||
|
||||
class ProviderTool(BaseModel):
|
||||
kind: str
|
||||
enabled: bool = True
|
||||
note: str | None = None
|
||||
|
||||
result = _tools_to_dict([ProviderTool(kind="google_search")])
|
||||
|
||||
assert result == [{"kind": "google_search", "enabled": True}]
|
||||
|
||||
|
||||
def test_tool_decorator():
|
||||
"""Test the tool decorator."""
|
||||
|
||||
|
||||
@@ -3860,6 +3860,61 @@ class TestResponseStreamMapAndWithFinalizer:
|
||||
final = await outer.get_final_response()
|
||||
assert final.text == "mapped_update_0mapped_update_1"
|
||||
|
||||
async def test_flat_map_expands_updates(self) -> None:
|
||||
"""flat_map() can transform one update into many updates."""
|
||||
inner = ResponseStream(_generate_updates(2), finalizer=_combine_updates)
|
||||
|
||||
def expand(update: ChatResponseUpdate) -> list[ChatResponseUpdate]:
|
||||
return [
|
||||
ChatResponseUpdate(contents=[Content.from_text(update.text)], role=cast(Any, update.role)),
|
||||
ChatResponseUpdate(contents=[Content.from_text(f"{update.text}_extra")], role=cast(Any, update.role)),
|
||||
]
|
||||
|
||||
outer = inner.flat_map(expand, _combine_updates)
|
||||
|
||||
collected: list[str] = []
|
||||
async for update in outer:
|
||||
collected.append(update.text or "")
|
||||
|
||||
assert collected == ["update_0", "update_0_extra", "update_1", "update_1_extra"]
|
||||
|
||||
final = await outer.get_final_response()
|
||||
assert final.text == "update_0update_0_extraupdate_1update_1_extra"
|
||||
|
||||
async def test_flat_map_skips_empty_mappings(self) -> None:
|
||||
"""flat_map() supports zero-output transforms."""
|
||||
inner = ResponseStream(_generate_updates(3), finalizer=_combine_updates)
|
||||
|
||||
def keep_odd(update: ChatResponseUpdate) -> list[ChatResponseUpdate]:
|
||||
return [update] if update.text == "update_1" else []
|
||||
|
||||
outer = inner.flat_map(keep_odd, _combine_updates)
|
||||
|
||||
collected = [update.text async for update in outer]
|
||||
assert collected == ["update_1"]
|
||||
|
||||
final = await outer.get_final_response()
|
||||
assert final.text == "update_1"
|
||||
|
||||
async def test_flat_map_calls_inner_result_hooks(self) -> None:
|
||||
"""flat_map() preserves inner result hooks."""
|
||||
inner_result_hook_called = {"value": False}
|
||||
|
||||
def inner_result_hook(response: ChatResponse) -> ChatResponse:
|
||||
inner_result_hook_called["value"] = True
|
||||
return response
|
||||
|
||||
inner = ResponseStream(
|
||||
_generate_updates(2),
|
||||
finalizer=_combine_updates,
|
||||
result_hooks=[inner_result_hook], # ty: ignore[invalid-argument-type]
|
||||
)
|
||||
outer = inner.flat_map(lambda u: [u], _combine_updates)
|
||||
|
||||
await outer.get_final_response()
|
||||
|
||||
assert inner_result_hook_called["value"] is True
|
||||
|
||||
async def test_outer_transform_hooks_independent(self) -> None:
|
||||
"""Outer stream has its own independent transform hooks."""
|
||||
inner_hook_calls = {"value": 0}
|
||||
|
||||
@@ -336,6 +336,97 @@ async def test_workflow_checkpoint_chaining_via_previous_checkpoint_id():
|
||||
)
|
||||
|
||||
|
||||
async def test_workflow_checkpoint_ancestry_preserved_after_resume():
|
||||
"""Resuming from a checkpoint must preserve ancestry: future checkpoints chain back to the resumed one."""
|
||||
from typing_extensions import Never
|
||||
|
||||
from agent_framework import WorkflowBuilder, WorkflowContext, handler
|
||||
from agent_framework._workflows._executor import Executor
|
||||
|
||||
class StartExecutor(Executor):
|
||||
@handler
|
||||
async def run(self, message: str, ctx: WorkflowContext[str]) -> None:
|
||||
await ctx.send_message(message, target_id="middle")
|
||||
|
||||
class MiddleExecutor(Executor):
|
||||
@handler
|
||||
async def process(self, message: str, ctx: WorkflowContext[str]) -> None:
|
||||
await ctx.send_message(message + "-processed", target_id="finish")
|
||||
|
||||
class FinishExecutor(Executor):
|
||||
@handler
|
||||
async def finish(self, message: str, ctx: WorkflowContext[Never, str]) -> None: # type: ignore[valid-type]
|
||||
await ctx.yield_output(message + "-done")
|
||||
|
||||
storage = InMemoryCheckpointStorage()
|
||||
|
||||
def _build_workflow() -> Any:
|
||||
start = StartExecutor(id="start")
|
||||
middle = MiddleExecutor(id="middle")
|
||||
finish = FinishExecutor(id="finish")
|
||||
return (
|
||||
WorkflowBuilder(
|
||||
name="resume-ancestry-test",
|
||||
max_iterations=10,
|
||||
start_executor=start,
|
||||
checkpoint_storage=storage,
|
||||
)
|
||||
.add_edge(start, middle)
|
||||
.add_edge(middle, finish)
|
||||
.build()
|
||||
)
|
||||
|
||||
# First run: produce an initial chain of checkpoints
|
||||
workflow = _build_workflow()
|
||||
workflow_name = workflow.name
|
||||
_ = [event async for event in workflow.run("hello", stream=True)]
|
||||
|
||||
initial_checkpoints = sorted(await storage.list_checkpoints(workflow_name=workflow_name), key=lambda c: c.timestamp)
|
||||
assert len(initial_checkpoints) >= 3, (
|
||||
f"Need at least 3 initial checkpoints to pick a middle one, got {len(initial_checkpoints)}"
|
||||
)
|
||||
initial_ids = {cp.checkpoint_id for cp in initial_checkpoints}
|
||||
|
||||
# Pick an intermediate checkpoint to resume from (not the first, not the last)
|
||||
resume_from = initial_checkpoints[len(initial_checkpoints) // 2]
|
||||
|
||||
# Resume on a fresh workflow instance (same graph signature) and run to completion
|
||||
resumed_workflow = _build_workflow()
|
||||
assert resumed_workflow.name == workflow_name
|
||||
_ = [event async for event in resumed_workflow.run(checkpoint_id=resume_from.checkpoint_id, stream=True)]
|
||||
|
||||
# Inspect new checkpoints created after resuming
|
||||
all_checkpoints = sorted(await storage.list_checkpoints(workflow_name=workflow_name), key=lambda c: c.timestamp)
|
||||
new_checkpoints = [cp for cp in all_checkpoints if cp.checkpoint_id not in initial_ids]
|
||||
assert new_checkpoints, "Resuming from an intermediate checkpoint should produce new checkpoints"
|
||||
|
||||
# The very first checkpoint created after resuming must chain back to the resumed checkpoint
|
||||
assert new_checkpoints[0].previous_checkpoint_id == resume_from.checkpoint_id, (
|
||||
"First post-resume checkpoint must chain to the checkpoint that was resumed from; "
|
||||
f"got previous_checkpoint_id={new_checkpoints[0].previous_checkpoint_id!r}, "
|
||||
f"expected {resume_from.checkpoint_id!r}"
|
||||
)
|
||||
|
||||
# Subsequent post-resume checkpoints must continue chaining
|
||||
for i in range(1, len(new_checkpoints)):
|
||||
assert new_checkpoints[i].previous_checkpoint_id == new_checkpoints[i - 1].checkpoint_id, (
|
||||
f"Post-resume checkpoint {i} should chain to checkpoint {i - 1}"
|
||||
)
|
||||
|
||||
# Walking the chain backwards from the most recent checkpoint must reach the original root
|
||||
# without breaks (i.e. the full ancestry across the resume boundary is intact).
|
||||
checkpoints_by_id = {cp.checkpoint_id: cp for cp in all_checkpoints}
|
||||
chain: list[str] = []
|
||||
cursor: str | None = new_checkpoints[-1].checkpoint_id
|
||||
while cursor is not None:
|
||||
chain.append(cursor)
|
||||
cursor = checkpoints_by_id[cursor].previous_checkpoint_id
|
||||
# Chain must include the resumed-from checkpoint and terminate at the original root
|
||||
assert resume_from.checkpoint_id in chain
|
||||
assert chain[-1] == initial_checkpoints[0].checkpoint_id
|
||||
assert checkpoints_by_id[chain[-1]].previous_checkpoint_id is None
|
||||
|
||||
|
||||
async def test_memory_checkpoint_storage_roundtrip_json_native_types():
|
||||
"""Test that JSON-native types (str, int, float, bool, None) roundtrip correctly."""
|
||||
storage = InMemoryCheckpointStorage()
|
||||
|
||||
@@ -23,7 +23,7 @@ class StartExecutor(Executor):
|
||||
|
||||
class FinishExecutor(Executor):
|
||||
@handler
|
||||
async def finish(self, message: str, ctx: WorkflowContext[Never, str]) -> None: # type: ignore[valid-type]
|
||||
async def finish(self, message: str, ctx: WorkflowContext[Never, str]) -> None: # zuban: ignore
|
||||
await ctx.yield_output(message)
|
||||
|
||||
|
||||
@@ -95,7 +95,7 @@ class SubStartExecutor(Executor):
|
||||
|
||||
class SubFinishExecutor(Executor):
|
||||
@handler
|
||||
async def finish(self, message: str, ctx: WorkflowContext[Never, str]) -> None: # type: ignore[valid-type]
|
||||
async def finish(self, message: str, ctx: WorkflowContext[Never, str]) -> None: # zuban: ignore
|
||||
await ctx.yield_output(message)
|
||||
|
||||
|
||||
|
||||
@@ -17,7 +17,6 @@ from agent_framework import (
|
||||
WorkflowContext,
|
||||
WorkflowConvergenceException,
|
||||
WorkflowEvent,
|
||||
WorkflowRunnerException,
|
||||
WorkflowRunState,
|
||||
handler,
|
||||
)
|
||||
@@ -305,40 +304,62 @@ async def test_fanout_edge_runner_delivers_to_multiple_targets_concurrently() ->
|
||||
assert probe_target.call_count == 1
|
||||
|
||||
|
||||
async def test_runner_already_running():
|
||||
"""Test that running the runner while it is already running raises an error."""
|
||||
async def test_runner_run_until_convergence_runs_sequentially():
|
||||
"""run_until_convergence can be invoked back-to-back on the same Runner.
|
||||
|
||||
The Runner itself does not enforce concurrency; that responsibility lives on
|
||||
:class:`Workflow`. This test simply confirms the Runner is reusable across
|
||||
sequential runs.
|
||||
"""
|
||||
runner = _make_runner()
|
||||
async for _ in runner.run_until_convergence():
|
||||
pass
|
||||
async for _ in runner.run_until_convergence():
|
||||
pass
|
||||
|
||||
|
||||
def _make_runner() -> Runner:
|
||||
"""Build a minimal runner for runner-level tests."""
|
||||
return Runner(
|
||||
[],
|
||||
{},
|
||||
State(),
|
||||
InProcRunnerContext(),
|
||||
"test_name",
|
||||
graph_signature_hash="test_hash",
|
||||
)
|
||||
|
||||
|
||||
async def test_runner_accepts_new_run_after_previous_failure():
|
||||
"""A failed run must not leave the Runner unable to start a new run.
|
||||
|
||||
After the first run raises, ``run_until_convergence()`` must be callable
|
||||
again and not surface any lifecycle-related rejection.
|
||||
"""
|
||||
executor_a = MockExecutor(id="executor_a")
|
||||
executor_b = MockExecutor(id="executor_b")
|
||||
|
||||
# Create a loop
|
||||
edges = [
|
||||
SingleEdgeGroup(executor_a.id, executor_b.id),
|
||||
SingleEdgeGroup(executor_b.id, executor_a.id),
|
||||
]
|
||||
|
||||
executors: dict[str, Executor] = {
|
||||
executor_a.id: executor_a,
|
||||
executor_b.id: executor_b,
|
||||
}
|
||||
executors: dict[str, Executor] = {executor_a.id: executor_a, executor_b.id: executor_b}
|
||||
state = State()
|
||||
ctx = InProcRunnerContext()
|
||||
runner = Runner(edges, executors, state, ctx, "test_name", graph_signature_hash="test_hash", max_iterations=2)
|
||||
|
||||
runner = Runner(edges, executors, state, ctx, "test_name", graph_signature_hash="test_hash")
|
||||
await executor_a.execute(MockMessage(data=0), ["START"], state, ctx)
|
||||
|
||||
await executor_a.execute(
|
||||
MockMessage(data=0),
|
||||
["START"], # source_executor_ids
|
||||
state, # state
|
||||
ctx, # runner_context
|
||||
)
|
||||
with pytest.raises(WorkflowConvergenceException):
|
||||
async for _ in runner.run_until_convergence():
|
||||
pass
|
||||
|
||||
with pytest.raises(WorkflowRunnerException, match="Runner is already running."):
|
||||
|
||||
async def _run():
|
||||
async for _ in runner.run_until_convergence():
|
||||
pass
|
||||
|
||||
await asyncio.gather(_run(), _run())
|
||||
# A second run on the same Runner must not be blocked by stale lifecycle
|
||||
# state from the failed run.
|
||||
try:
|
||||
async for _ in runner.run_until_convergence():
|
||||
pass
|
||||
except Exception as exc:
|
||||
assert "Runner is already running" not in str(exc), "Runner stayed locked after a failed run"
|
||||
|
||||
|
||||
async def test_runner_emits_runner_completion_for_agent_response_without_targets():
|
||||
@@ -862,7 +883,13 @@ async def test_runner_checkpoint_with_resumed_flag():
|
||||
state = State()
|
||||
|
||||
runner = Runner(edges, executors, state, ctx, "test_name", graph_signature_hash="test_hash")
|
||||
runner._mark_resumed(5) # pyright: ignore[reportPrivateUsage]
|
||||
resumed_checkpoint = WorkflowCheckpoint(
|
||||
checkpoint_id="resumed-cp",
|
||||
workflow_name="test_name",
|
||||
graph_signature_hash="test_hash",
|
||||
iteration_count=5,
|
||||
)
|
||||
runner._mark_resumed(resumed_checkpoint) # pyright: ignore[reportPrivateUsage]
|
||||
|
||||
# Add a message to trigger the checkpoint creation path
|
||||
await ctx.send_message(WorkflowMessage(data=MockMessage(data=8), source_id="START"))
|
||||
@@ -882,6 +909,86 @@ async def test_runner_checkpoint_with_resumed_flag():
|
||||
assert runner._resumed_from_checkpoint is False # pyright: ignore[reportPrivateUsage]
|
||||
|
||||
|
||||
async def test_runner_mark_resumed_sets_previous_checkpoint_id():
|
||||
"""_mark_resumed must populate _previous_checkpoint_id so future checkpoints chain back to the resume point."""
|
||||
runner = Runner(
|
||||
[],
|
||||
{},
|
||||
State(),
|
||||
InProcRunnerContext(),
|
||||
"test_name",
|
||||
graph_signature_hash="test_hash",
|
||||
)
|
||||
|
||||
# Pre-condition: nothing to chain back to
|
||||
assert runner._previous_checkpoint_id is None # pyright: ignore[reportPrivateUsage]
|
||||
|
||||
resumed_checkpoint = WorkflowCheckpoint(
|
||||
checkpoint_id="resumed-cp-id",
|
||||
workflow_name="test_name",
|
||||
graph_signature_hash="test_hash",
|
||||
iteration_count=3,
|
||||
)
|
||||
runner._mark_resumed(resumed_checkpoint) # pyright: ignore[reportPrivateUsage]
|
||||
|
||||
assert runner._resumed_from_checkpoint is True # pyright: ignore[reportPrivateUsage]
|
||||
assert runner._iteration == 3 # pyright: ignore[reportPrivateUsage]
|
||||
assert runner._previous_checkpoint_id == "resumed-cp-id" # pyright: ignore[reportPrivateUsage]
|
||||
|
||||
|
||||
async def test_runner_post_resume_checkpoint_chains_to_resumed_checkpoint():
|
||||
"""After resuming, the next checkpoint created must reference the resumed checkpoint as its parent."""
|
||||
storage = InMemoryCheckpointStorage()
|
||||
ctx = CheckpointingContext(storage)
|
||||
executor_a = MockExecutor(id="executor_a")
|
||||
executor_b = MockExecutor(id="executor_b")
|
||||
|
||||
edges = [
|
||||
SingleEdgeGroup(executor_a.id, executor_b.id),
|
||||
SingleEdgeGroup(executor_b.id, executor_a.id),
|
||||
]
|
||||
|
||||
executors: dict[str, Executor] = {
|
||||
executor_a.id: executor_a,
|
||||
executor_b.id: executor_b,
|
||||
}
|
||||
state = State()
|
||||
|
||||
runner = Runner(edges, executors, state, ctx, "test_name", graph_signature_hash="test_hash")
|
||||
|
||||
# Simulate having resumed from a prior checkpoint
|
||||
resumed_checkpoint = WorkflowCheckpoint(
|
||||
checkpoint_id="parent-checkpoint-id",
|
||||
workflow_name="test_name",
|
||||
graph_signature_hash="test_hash",
|
||||
iteration_count=1,
|
||||
)
|
||||
runner._mark_resumed(resumed_checkpoint) # pyright: ignore[reportPrivateUsage]
|
||||
|
||||
# Seed a message so the runner has work to do (and creates checkpoints at superstep boundaries)
|
||||
await ctx.send_message(WorkflowMessage(data=MockMessage(data=8), source_id=executor_a.id))
|
||||
|
||||
async for _ in runner.run_until_convergence():
|
||||
pass
|
||||
|
||||
# Find the first checkpoint created after the resume point (across all workflows tracked by storage)
|
||||
new_checkpoints = sorted(
|
||||
await storage.list_checkpoints(workflow_name="test_name"),
|
||||
key=lambda c: c.timestamp,
|
||||
)
|
||||
assert new_checkpoints, "Resuming and running should produce at least one new checkpoint"
|
||||
|
||||
# The first new checkpoint must chain to the resumed-from checkpoint, not to None
|
||||
assert new_checkpoints[0].previous_checkpoint_id == "parent-checkpoint-id", (
|
||||
"First post-resume checkpoint must chain to the resumed checkpoint id; "
|
||||
f"got {new_checkpoints[0].previous_checkpoint_id!r}"
|
||||
)
|
||||
|
||||
# Subsequent post-resume checkpoints continue the chain
|
||||
for i in range(1, len(new_checkpoints)):
|
||||
assert new_checkpoints[i].previous_checkpoint_id == new_checkpoints[i - 1].checkpoint_id
|
||||
|
||||
|
||||
class ExecutorThatFailsWithEvents(Executor):
|
||||
"""An executor that emits events and then raises an exception after receiving messages."""
|
||||
|
||||
@@ -951,6 +1058,172 @@ async def test_runner_drains_events_on_iteration_exception():
|
||||
assert len(output_events) >= 1
|
||||
|
||||
|
||||
async def test_runner_resumed_flag_reset_after_failed_resumed_run():
|
||||
"""A failed *resumed* run must not leak the resume flag into the next run.
|
||||
|
||||
The resume flag suppresses the initial "superstep 0" (entry) checkpoint when resuming from an
|
||||
iteration-0 checkpoint (which already exists and must not be recreated). It used to be cleared
|
||||
only on the success path, so an executor failure during a resumed run left it ``True`` and the
|
||||
next fresh run wrongly skipped its entry checkpoint. The flag is now cleared in a ``finally`` so
|
||||
this holds even when convergence raises.
|
||||
|
||||
This also verifies checkpoint creation on the re-run: the resumed (failed) run creates no entry
|
||||
checkpoint, while the subsequent fresh run does.
|
||||
"""
|
||||
storage = InMemoryCheckpointStorage()
|
||||
ctx = CheckpointingContext(storage)
|
||||
executor_a = PassthroughExecutor(id="executor_a")
|
||||
executor_b = ExecutorThatFailsWithEvents(id="executor_b", runner_ctx=ctx, fail_on_iteration=1)
|
||||
|
||||
edges = [SingleEdgeGroup(executor_a.id, executor_b.id)]
|
||||
executors: dict[str, Executor] = {executor_a.id: executor_a, executor_b.id: executor_b}
|
||||
state = State()
|
||||
|
||||
runner = Runner(edges, executors, state, ctx, "test_name", graph_signature_hash="test_hash")
|
||||
|
||||
# Simulate a resumed run; this marks the runner as resumed so the next run skips
|
||||
# the superstep-0 checkpoint.
|
||||
resumed_checkpoint = WorkflowCheckpoint(
|
||||
checkpoint_id="resumed-cp",
|
||||
workflow_name="test_name",
|
||||
graph_signature_hash="test_hash",
|
||||
iteration_count=0,
|
||||
)
|
||||
runner._mark_resumed(resumed_checkpoint) # pyright: ignore[reportPrivateUsage]
|
||||
assert runner._resumed_from_checkpoint is True # pyright: ignore[reportPrivateUsage]
|
||||
|
||||
# Run the resumed turn; executor_b fails mid-iteration before any superstep
|
||||
# checkpoint is created.
|
||||
await executor_a.execute(MockMessage(data=0), ["START"], state, ctx)
|
||||
with pytest.raises(RuntimeError, match="Executor failed with pending events"):
|
||||
async for _ in runner.run_until_convergence():
|
||||
pass
|
||||
|
||||
# The fix: the resume flag is cleared even though the run raised.
|
||||
assert runner._resumed_from_checkpoint is False # pyright: ignore[reportPrivateUsage]
|
||||
# The resumed (failed) run created no superstep-0 checkpoint (it was skipped).
|
||||
assert await storage.list_checkpoints(workflow_name="test_name") == []
|
||||
|
||||
# Re-run as a fresh turn: with the flag correctly reset, the runner now creates
|
||||
# the initial superstep-0 checkpoint (iteration_count == 0) before failing again.
|
||||
runner.reset_iteration_count()
|
||||
await executor_a.execute(MockMessage(data=0), ["START"], state, ctx)
|
||||
with pytest.raises(RuntimeError, match="Executor failed with pending events"):
|
||||
async for _ in runner.run_until_convergence():
|
||||
pass
|
||||
|
||||
checkpoints = await storage.list_checkpoints(workflow_name="test_name")
|
||||
assert any(cp.iteration_count == 0 for cp in checkpoints), (
|
||||
"Fresh run after a failed resumed run must create the superstep-0 checkpoint; "
|
||||
"a leaked resume flag would have skipped it"
|
||||
)
|
||||
|
||||
|
||||
async def test_runner_creates_entry_checkpoint_at_iteration_zero():
|
||||
"""A fresh run creates the entry (superstep-0) checkpoint at iteration 0 with no parent.
|
||||
|
||||
This is the baseline the lineage-consistency guard must preserve: when starting from iteration 0
|
||||
with messages queued and not resumed, the entry checkpoint is created and begins a new lineage
|
||||
(``previous_checkpoint_id is None``).
|
||||
"""
|
||||
storage = InMemoryCheckpointStorage()
|
||||
ctx = CheckpointingContext(storage)
|
||||
# Terminal executor with no outgoing edges: the runner runs one superstep and converges.
|
||||
source = MockExecutor(id="source")
|
||||
state = State()
|
||||
runner = Runner([], {source.id: source}, state, ctx, "test_name", graph_signature_hash="test_hash")
|
||||
|
||||
assert runner._iteration == 0 # pyright: ignore[reportPrivateUsage]
|
||||
assert runner._resumed_from_checkpoint is False # pyright: ignore[reportPrivateUsage]
|
||||
|
||||
await ctx.send_message(WorkflowMessage(data=MockMessage(data=8), source_id=source.id))
|
||||
|
||||
async for _ in runner.run_until_convergence():
|
||||
pass
|
||||
|
||||
checkpoints = await storage.list_checkpoints(workflow_name="test_name")
|
||||
entry_checkpoints = [cp for cp in checkpoints if cp.iteration_count == 0]
|
||||
assert len(entry_checkpoints) == 1, "A fresh run must create exactly one entry checkpoint at iteration 0"
|
||||
assert entry_checkpoints[0].previous_checkpoint_id is None, (
|
||||
"The entry checkpoint of a fresh run must begin a new lineage with no parent"
|
||||
)
|
||||
|
||||
|
||||
async def test_runner_skips_entry_checkpoint_when_iteration_nonzero():
|
||||
"""The entry (superstep-0) checkpoint must only be created at iteration 0 to keep lineage consistent.
|
||||
|
||||
A re-run that did not reset the iteration count (and is not marked as resumed) must not write an
|
||||
entry checkpoint carrying a non-zero ``iteration_count`` - doing so would place two checkpoints at
|
||||
the same iteration in the lineage. The ``_iteration == 0`` guard suppresses the entry checkpoint in
|
||||
this case while still allowing the normal per-superstep checkpoints to be created.
|
||||
"""
|
||||
storage = InMemoryCheckpointStorage()
|
||||
ctx = CheckpointingContext(storage)
|
||||
# Terminal executor with no outgoing edges: the runner runs one superstep and converges.
|
||||
source = MockExecutor(id="source")
|
||||
state = State()
|
||||
runner = Runner([], {source.id: source}, state, ctx, "test_name", graph_signature_hash="test_hash")
|
||||
|
||||
# Simulate a re-run that kept its iteration count and is not marked as resumed.
|
||||
runner._iteration = 5 # pyright: ignore[reportPrivateUsage]
|
||||
assert runner._resumed_from_checkpoint is False # pyright: ignore[reportPrivateUsage]
|
||||
|
||||
await ctx.send_message(WorkflowMessage(data=MockMessage(data=8), source_id=source.id))
|
||||
|
||||
async for _ in runner.run_until_convergence():
|
||||
pass
|
||||
|
||||
checkpoints = await storage.list_checkpoints(workflow_name="test_name")
|
||||
# No entry checkpoint at the pre-existing iteration count may be created.
|
||||
assert all(cp.iteration_count != 5 for cp in checkpoints), (
|
||||
"Entry checkpoint must not be created at a non-zero iteration; lineage would have a duplicate iteration"
|
||||
)
|
||||
# The normal post-superstep checkpoint is still created (iteration advanced to 6).
|
||||
assert any(cp.iteration_count == 6 for cp in checkpoints)
|
||||
|
||||
|
||||
async def test_runner_resumed_from_iteration_zero_skips_entry_checkpoint():
|
||||
"""Resuming from an iteration-0 checkpoint must not recreate the entry checkpoint.
|
||||
|
||||
Here ``_iteration == 0`` is true, so the iteration guard alone would not suppress the entry
|
||||
checkpoint; the resume flag is what prevents recreating the checkpoint that already exists at
|
||||
iteration 0.
|
||||
"""
|
||||
storage = InMemoryCheckpointStorage()
|
||||
ctx = CheckpointingContext(storage)
|
||||
source = MockExecutor(id="source")
|
||||
state = State()
|
||||
runner = Runner([], {source.id: source}, state, ctx, "test_name", graph_signature_hash="test_hash")
|
||||
|
||||
# Resume from an iteration-0 checkpoint: iteration stays 0 but the run is marked as resumed.
|
||||
resumed_checkpoint = WorkflowCheckpoint(
|
||||
checkpoint_id="entry-cp",
|
||||
workflow_name="test_name",
|
||||
graph_signature_hash="test_hash",
|
||||
iteration_count=0,
|
||||
)
|
||||
runner._mark_resumed(resumed_checkpoint) # pyright: ignore[reportPrivateUsage]
|
||||
assert runner._iteration == 0 # pyright: ignore[reportPrivateUsage]
|
||||
assert runner._resumed_from_checkpoint is True # pyright: ignore[reportPrivateUsage]
|
||||
|
||||
await ctx.send_message(WorkflowMessage(data=MockMessage(data=8), source_id=source.id))
|
||||
|
||||
async for _ in runner.run_until_convergence():
|
||||
pass
|
||||
|
||||
# The pre-loop entry checkpoint is skipped; only the post-superstep checkpoint (iteration 1) is created,
|
||||
# and it chains back to the resumed entry checkpoint.
|
||||
checkpoints = sorted(
|
||||
await storage.list_checkpoints(workflow_name="test_name"),
|
||||
key=lambda c: c.timestamp,
|
||||
)
|
||||
assert all(cp.checkpoint_id != "entry-cp" for cp in checkpoints), "Resumed entry checkpoint must not be recreated"
|
||||
assert checkpoints, "The resumed run must still create its post-superstep checkpoint"
|
||||
assert checkpoints[0].previous_checkpoint_id == "entry-cp", (
|
||||
"The first post-resume checkpoint must chain back to the resumed entry checkpoint"
|
||||
)
|
||||
|
||||
|
||||
class SlowEventEmittingExecutor(Executor):
|
||||
"""An executor that emits events with delays to test straggler event draining."""
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
import asyncio
|
||||
import gc
|
||||
import tempfile
|
||||
from collections.abc import AsyncIterable, Awaitable, Sequence
|
||||
from dataclasses import dataclass, field
|
||||
@@ -19,6 +20,7 @@ from agent_framework import (
|
||||
Content,
|
||||
Executor,
|
||||
FileCheckpointStorage,
|
||||
InProcRunnerContext,
|
||||
Message,
|
||||
ResponseStream,
|
||||
WorkflowBuilder,
|
||||
@@ -26,6 +28,7 @@ from agent_framework import (
|
||||
WorkflowContext,
|
||||
WorkflowConvergenceException,
|
||||
WorkflowEvent,
|
||||
WorkflowException,
|
||||
WorkflowMessage,
|
||||
WorkflowRunState,
|
||||
handler,
|
||||
@@ -759,8 +762,7 @@ async def test_workflow_concurrent_execution_prevention():
|
||||
|
||||
# Try to start a second concurrent execution - this should fail
|
||||
with pytest.raises(
|
||||
RuntimeError,
|
||||
match="Workflow is already running. Concurrent executions are not allowed.",
|
||||
WorkflowException, match="Workflow is already running; concurrent runs are not allowed on the same instance."
|
||||
):
|
||||
await workflow.run(NumberMessage(data=0))
|
||||
|
||||
@@ -795,8 +797,7 @@ async def test_workflow_concurrent_execution_prevention_streaming():
|
||||
|
||||
# Try to start a second concurrent execution - this should fail
|
||||
with pytest.raises(
|
||||
RuntimeError,
|
||||
match="Workflow is already running. Concurrent executions are not allowed.",
|
||||
WorkflowException, match="Workflow is already running; concurrent runs are not allowed on the same instance."
|
||||
):
|
||||
await workflow.run(NumberMessage(data=0))
|
||||
|
||||
@@ -828,14 +829,12 @@ async def test_workflow_concurrent_execution_prevention_mixed_methods():
|
||||
|
||||
# Try different execution methods - all should fail
|
||||
with pytest.raises(
|
||||
RuntimeError,
|
||||
match="Workflow is already running. Concurrent executions are not allowed.",
|
||||
WorkflowException, match="Workflow is already running; concurrent runs are not allowed on the same instance."
|
||||
):
|
||||
await workflow.run(NumberMessage(data=0))
|
||||
|
||||
with pytest.raises(
|
||||
RuntimeError,
|
||||
match="Workflow is already running. Concurrent executions are not allowed.",
|
||||
WorkflowException, match="Workflow is already running; concurrent runs are not allowed on the same instance."
|
||||
):
|
||||
async for _ in workflow.run(NumberMessage(data=0), stream=True):
|
||||
break
|
||||
@@ -848,6 +847,238 @@ async def test_workflow_concurrent_execution_prevention_mixed_methods():
|
||||
assert result.get_final_state() == WorkflowRunState.IDLE
|
||||
|
||||
|
||||
async def test_workflow_sequential_runs_after_completion() -> None:
|
||||
"""A completed run must release the runner so the next ``run`` succeeds.
|
||||
|
||||
This is the happy-path counterpart to the concurrent-run guard tests:
|
||||
those tests verify that a *concurrent* run is rejected, but they do not
|
||||
verify that the lock is actually released afterwards. This test
|
||||
exercises that release path explicitly across the three call shapes
|
||||
(non-streaming, streaming-iterated, streaming-via-get_final_response)
|
||||
and across multiple consecutive turns to catch lock leaks.
|
||||
"""
|
||||
executor = IncrementExecutor(id="seq_executor", limit=3, increment=1)
|
||||
workflow = WorkflowBuilder(start_executor=executor).build()
|
||||
|
||||
# Non-streaming -> non-streaming
|
||||
r1 = await workflow.run(NumberMessage(data=0))
|
||||
assert r1.get_final_state() == WorkflowRunState.IDLE
|
||||
|
||||
r2 = await workflow.run(NumberMessage(data=0))
|
||||
assert r2.get_final_state() == WorkflowRunState.IDLE
|
||||
|
||||
# Non-streaming -> streaming-iterated
|
||||
stream_events: list[WorkflowEvent] = []
|
||||
async for event in workflow.run(NumberMessage(data=0), stream=True):
|
||||
stream_events.append(event)
|
||||
assert any(e.type == "status" and e.state == WorkflowRunState.IDLE for e in stream_events)
|
||||
|
||||
# Streaming -> streaming via get_final_response (no manual iteration)
|
||||
r3 = await workflow.run(NumberMessage(data=0), stream=True).get_final_response()
|
||||
assert r3.get_final_state() == WorkflowRunState.IDLE
|
||||
|
||||
# Streaming -> non-streaming (back to the start)
|
||||
r4 = await workflow.run(NumberMessage(data=0))
|
||||
assert r4.get_final_state() == WorkflowRunState.IDLE
|
||||
|
||||
|
||||
async def test_workflow_unconsumed_stream_releases_run_lock() -> None:
|
||||
"""An unconsumed stream must not leak the run lock.
|
||||
|
||||
``Workflow.run`` reserves the runner *synchronously* so that concurrent
|
||||
callers are rejected immediately. The reservation is normally released
|
||||
by ``_run_core``'s ``finally`` once the stream is iterated. If the
|
||||
caller never iterates the stream, a GC-time finalizer must release the
|
||||
reservation instead - otherwise every subsequent ``Workflow.run`` call
|
||||
on this instance would fail with the concurrent-run error.
|
||||
"""
|
||||
executor = IncrementExecutor(id="unconsumed_stream_exec", limit=3, increment=1)
|
||||
workflow = WorkflowBuilder(start_executor=executor).build()
|
||||
|
||||
# Build a stream and immediately drop it without iterating.
|
||||
stream = workflow.run(NumberMessage(data=0), stream=True)
|
||||
assert stream is not None # silence unused-variable warnings; stream is GC'd below
|
||||
del stream
|
||||
gc.collect()
|
||||
# Yield to the event loop so any scheduled finalizer work can run.
|
||||
await asyncio.sleep(0)
|
||||
|
||||
# The runner should be back to IDLE; a fresh run must succeed.
|
||||
result = await workflow.run(NumberMessage(data=0))
|
||||
assert result.get_final_state() == WorkflowRunState.IDLE
|
||||
|
||||
|
||||
async def test_workflow_unawaited_run_coroutine_releases_run_lock() -> None:
|
||||
"""An un-awaited non-streaming ``run()`` coroutine must also not leak the lock.
|
||||
|
||||
``Workflow.run`` (non-streaming) returns a coroutine produced by
|
||||
``ResponseStream.get_final_response``. The underlying ResponseStream is
|
||||
held alive by that coroutine, so dropping the coroutine without
|
||||
awaiting it must still release the reservation via the same GC-time
|
||||
fallback used for unconsumed streams.
|
||||
"""
|
||||
executor = IncrementExecutor(id="unawaited_run_exec", limit=3, increment=1)
|
||||
workflow = WorkflowBuilder(start_executor=executor).build()
|
||||
|
||||
coro = workflow.run(NumberMessage(data=0))
|
||||
# Closing suppresses the "coroutine was never awaited" warning. We cast to
|
||||
# ``Any`` because the typed return is ``Awaitable[...]``; in practice it is
|
||||
# a coroutine that exposes ``close``.
|
||||
cast(Any, coro).close()
|
||||
del coro
|
||||
gc.collect()
|
||||
await asyncio.sleep(0)
|
||||
|
||||
result = await workflow.run(NumberMessage(data=0))
|
||||
assert result.get_final_state() == WorkflowRunState.IDLE
|
||||
|
||||
|
||||
async def test_workflow_partial_stream_does_not_clobber_successor_active_run() -> None:
|
||||
"""A stale ``_run_core`` finalizer must not clear a successor's run lock.
|
||||
|
||||
Repro for the GC-finalizer race the user reported:
|
||||
|
||||
1. Start stream A and consume one event so its body is suspended at a
|
||||
``yield``. Its ``finally`` is now armed and will run when the
|
||||
generator is closed.
|
||||
2. Drop stream A and ``gc.collect``. The ``_active_run`` weakref's
|
||||
referent is gone, so a subsequent ``run()`` will pass the
|
||||
concurrency guard - but stream A's async-gen finalizer hasn't
|
||||
actually executed yet (``aclose`` is scheduled on the loop).
|
||||
3. Synchronously start stream B; ``run()`` installs a fresh weakref
|
||||
in ``_active_run``.
|
||||
4. Yield to the loop so stream A's stale ``finally`` runs. Without
|
||||
the identity check it unconditionally writes
|
||||
``self._active_run = None``, silently disabling the concurrency
|
||||
guard for stream B.
|
||||
"""
|
||||
executor = IncrementExecutor(id="stale_finalizer_exec", limit=100, increment=1)
|
||||
workflow = WorkflowBuilder(start_executor=executor).build()
|
||||
|
||||
# Step 1: drive stream A's body until it's suspended at its first yield.
|
||||
stream_a = workflow.run(NumberMessage(data=0), stream=True)
|
||||
aiter_a = stream_a.__aiter__()
|
||||
await aiter_a.__anext__()
|
||||
|
||||
# Step 2: drop stream A; GC invalidates the weakref and schedules
|
||||
# async-gen close, but does not run the close inline.
|
||||
del stream_a
|
||||
del aiter_a
|
||||
gc.collect()
|
||||
|
||||
# Step 3: synchronously start stream B *before* yielding to the loop,
|
||||
# so the stale ``aclose`` for stream A hasn't fired yet.
|
||||
stream_b = workflow.run(NumberMessage(data=0), stream=True)
|
||||
ref_b = workflow._active_run # type: ignore[attr-defined]
|
||||
assert ref_b is not None and ref_b() is stream_b
|
||||
|
||||
# Step 4: yield enough times for stream A's scheduled aclose to drive
|
||||
# its body through ``GeneratorExit`` and into its ``finally``.
|
||||
for _ in range(5):
|
||||
await asyncio.sleep(0)
|
||||
|
||||
# With the fix, stream B's reservation is still in place. Without it,
|
||||
# ``_active_run`` was clobbered to ``None`` and a concurrent run would
|
||||
# be (incorrectly) accepted.
|
||||
assert workflow._active_run is ref_b # type: ignore[attr-defined]
|
||||
with pytest.raises(
|
||||
WorkflowException,
|
||||
match="Workflow is already running; concurrent runs are not allowed on the same instance.",
|
||||
):
|
||||
await workflow.run(NumberMessage(data=0))
|
||||
|
||||
# Tear down stream B without iterating it (its body never started, so
|
||||
# closing it is a no-op for workflow state).
|
||||
del stream_b
|
||||
del ref_b
|
||||
gc.collect()
|
||||
await asyncio.sleep(0)
|
||||
|
||||
|
||||
async def test_workflow_stale_runtime_checkpoint_storage_not_inherited() -> None:
|
||||
"""A new run must not inherit a prior run's leftover runtime checkpoint storage.
|
||||
|
||||
If a run that set a runtime ``checkpoint_storage`` override is dropped before
|
||||
its async-generator finalizer clears it, the override can linger on the
|
||||
``RunnerContext`` while ``_is_run_active()`` already reports False. ``run()``
|
||||
defensively clears that stale override so a subsequent run that does not pass
|
||||
its own ``checkpoint_storage`` does not silently checkpoint into it.
|
||||
"""
|
||||
with tempfile.TemporaryDirectory() as temp_dir:
|
||||
leftover_storage = FileCheckpointStorage(temp_dir)
|
||||
executor = IncrementExecutor(id="stale_storage_exec", limit=3, increment=1)
|
||||
workflow = WorkflowBuilder(start_executor=executor).build()
|
||||
|
||||
assert isinstance(workflow._runner.context, InProcRunnerContext) # pyright: ignore[reportPrivateUsage]
|
||||
|
||||
# Simulate a leftover runtime override from a dropped prior run.
|
||||
workflow._runner.context.set_runtime_checkpoint_storage(leftover_storage) # pyright: ignore[reportPrivateUsage]
|
||||
|
||||
# A fresh run without its own checkpoint_storage must not use the leftover.
|
||||
result = await workflow.run(NumberMessage(data=0))
|
||||
assert result.get_final_state() == WorkflowRunState.IDLE
|
||||
|
||||
checkpoints = await leftover_storage.list_checkpoints(workflow_name=workflow.name)
|
||||
assert checkpoints == [], "Stale runtime checkpoint storage must not be inherited by a new run"
|
||||
assert workflow._runner.context._runtime_checkpoint_storage is None # pyright: ignore[reportPrivateUsage]
|
||||
|
||||
|
||||
async def test_workflow_partial_stream_does_not_clobber_successor_runtime_storage() -> None:
|
||||
"""A stale ``_run_core`` finalizer must not clear a successor's runtime storage.
|
||||
|
||||
Same GC-finalizer race as
|
||||
``test_workflow_partial_stream_does_not_clobber_successor_active_run`` but for the
|
||||
runtime checkpoint storage override: the dropped run's deferred ``finally`` must
|
||||
only clear the override if it still owns it, otherwise it wipes the successor
|
||||
run's storage.
|
||||
"""
|
||||
with (
|
||||
tempfile.TemporaryDirectory() as temp_dir_a,
|
||||
tempfile.TemporaryDirectory() as temp_dir_b,
|
||||
):
|
||||
storage_a = FileCheckpointStorage(temp_dir_a)
|
||||
storage_b = FileCheckpointStorage(temp_dir_b)
|
||||
executor = IncrementExecutor(id="storage_finalizer_exec", limit=100, increment=1)
|
||||
workflow = WorkflowBuilder(start_executor=executor).build()
|
||||
context = workflow._runner.context # pyright: ignore[reportPrivateUsage]
|
||||
|
||||
assert isinstance(context, InProcRunnerContext)
|
||||
|
||||
# Step 1: drive stream A's body to its first yield so it set storage_a.
|
||||
stream_a = workflow.run(NumberMessage(data=0), checkpoint_storage=storage_a, stream=True)
|
||||
aiter_a = stream_a.__aiter__()
|
||||
await aiter_a.__anext__()
|
||||
assert context._runtime_checkpoint_storage is storage_a # pyright: ignore[reportPrivateUsage]
|
||||
|
||||
# Step 2: drop stream A; the weakref dies and async-gen close is scheduled
|
||||
# but not run inline.
|
||||
del stream_a
|
||||
del aiter_a
|
||||
gc.collect()
|
||||
|
||||
# Step 3: synchronously start stream B with its own storage and drive it to
|
||||
# its first yield so it set storage_b and took ownership of the override.
|
||||
stream_b = workflow.run(NumberMessage(data=0), checkpoint_storage=storage_b, stream=True)
|
||||
aiter_b = stream_b.__aiter__()
|
||||
await aiter_b.__anext__()
|
||||
assert context._runtime_checkpoint_storage is storage_b # pyright: ignore[reportPrivateUsage]
|
||||
|
||||
# Step 4: yield enough for stream A's scheduled aclose to drive its body
|
||||
# through ``GeneratorExit`` and into its ``finally``.
|
||||
for _ in range(5):
|
||||
await asyncio.sleep(0)
|
||||
|
||||
# With the ownership guard, stream B's override survives. Without it, A's
|
||||
# stale finalizer would have cleared it.
|
||||
assert context._runtime_checkpoint_storage is storage_b # pyright: ignore[reportPrivateUsage]
|
||||
|
||||
# Tear down stream B.
|
||||
del stream_b
|
||||
del aiter_b
|
||||
gc.collect()
|
||||
await asyncio.sleep(0)
|
||||
|
||||
|
||||
class _StreamingTestAgent(BaseAgent):
|
||||
"""Test agent that supports both streaming and non-streaming modes."""
|
||||
|
||||
|
||||
@@ -90,7 +90,7 @@ async def _run(yaml_def: dict[str, Any], handler: HttpRequestHandler) -> Any:
|
||||
|
||||
def _state(workflow: Any, events: Any) -> dict[str, Any]:
|
||||
"""Read declarative state out of the workflow after run completes."""
|
||||
return workflow._state.get(DECLARATIVE_STATE_KEY) or {}
|
||||
return workflow._runner.state.get(DECLARATIVE_STATE_KEY) or {}
|
||||
|
||||
|
||||
# Helper used by parametrised path tests
|
||||
@@ -151,7 +151,7 @@ class TestSuccessPath:
|
||||
workflow = factory.create_workflow_from_definition(_yaml(_action(method="GET", response="Local.Result")))
|
||||
await workflow.run({})
|
||||
|
||||
decl = workflow._state.get(DECLARATIVE_STATE_KEY)
|
||||
decl = workflow._runner.state.get(DECLARATIVE_STATE_KEY)
|
||||
assert decl["Local"]["Result"] == {"key": "value", "number": 42}
|
||||
assert handler.last_info is not None
|
||||
assert handler.last_info.method == "GET"
|
||||
@@ -164,7 +164,7 @@ class TestSuccessPath:
|
||||
workflow = factory.create_workflow_from_definition(_yaml(_action(response="Local.Result")))
|
||||
await workflow.run({})
|
||||
|
||||
decl = workflow._state.get(DECLARATIVE_STATE_KEY)
|
||||
decl = workflow._runner.state.get(DECLARATIVE_STATE_KEY)
|
||||
assert decl["Local"]["Result"] == "not-json content"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@@ -174,7 +174,7 @@ class TestSuccessPath:
|
||||
workflow = factory.create_workflow_from_definition(_yaml(_action(response="Local.Result")))
|
||||
await workflow.run({})
|
||||
|
||||
decl = workflow._state.get(DECLARATIVE_STATE_KEY)
|
||||
decl = workflow._runner.state.get(DECLARATIVE_STATE_KEY)
|
||||
assert decl["Local"]["Result"] is None
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@@ -184,7 +184,7 @@ class TestSuccessPath:
|
||||
workflow = factory.create_workflow_from_definition(_yaml(_action(response={"path": "Local.Result"})))
|
||||
await workflow.run({})
|
||||
|
||||
decl = workflow._state.get(DECLARATIVE_STATE_KEY)
|
||||
decl = workflow._runner.state.get(DECLARATIVE_STATE_KEY)
|
||||
assert decl["Local"]["Result"] == {"x": 1}
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@@ -517,7 +517,7 @@ class TestResponseHeaders:
|
||||
factory = WorkflowFactory(http_request_handler=handler)
|
||||
workflow = factory.create_workflow_from_definition(_yaml(_action(response_headers="Local.H")))
|
||||
await workflow.run({})
|
||||
decl = workflow._state.get(DECLARATIVE_STATE_KEY)
|
||||
decl = workflow._runner.state.get(DECLARATIVE_STATE_KEY)
|
||||
h = decl["Local"]["H"]
|
||||
assert h["Content-Type"] == "application/json"
|
||||
assert h["Set-Cookie"] == "a=1,b=2"
|
||||
@@ -528,7 +528,7 @@ class TestResponseHeaders:
|
||||
factory = WorkflowFactory(http_request_handler=handler)
|
||||
workflow = factory.create_workflow_from_definition(_yaml(_action(response_headers="Local.H")))
|
||||
await workflow.run({})
|
||||
decl = workflow._state.get(DECLARATIVE_STATE_KEY)
|
||||
decl = workflow._runner.state.get(DECLARATIVE_STATE_KEY)
|
||||
assert decl["Local"]["H"] is None
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@@ -538,7 +538,7 @@ class TestResponseHeaders:
|
||||
workflow = factory.create_workflow_from_definition(_yaml(_action(response_headers="Local.H")))
|
||||
with pytest.raises(DeclarativeActionError):
|
||||
await workflow.run({})
|
||||
decl = workflow._state.get(DECLARATIVE_STATE_KEY)
|
||||
decl = workflow._runner.state.get(DECLARATIVE_STATE_KEY)
|
||||
assert decl["Local"]["H"] == {"X-Trace": "abc"}
|
||||
|
||||
|
||||
@@ -559,7 +559,7 @@ class TestConversationAppend:
|
||||
)
|
||||
)
|
||||
await workflow.run({})
|
||||
decl = workflow._state.get(DECLARATIVE_STATE_KEY)
|
||||
decl = workflow._runner.state.get(DECLARATIVE_STATE_KEY)
|
||||
conv = decl["System"]["conversations"].get("conv-test-1")
|
||||
assert conv is not None
|
||||
assert len(conv["messages"]) == 1
|
||||
@@ -570,7 +570,7 @@ class TestConversationAppend:
|
||||
factory = WorkflowFactory(http_request_handler=handler)
|
||||
workflow = factory.create_workflow_from_definition(_yaml(_action(response="Local.Result", conversation_id="")))
|
||||
await workflow.run({})
|
||||
decl = workflow._state.get(DECLARATIVE_STATE_KEY)
|
||||
decl = workflow._runner.state.get(DECLARATIVE_STATE_KEY)
|
||||
# Auto-init creates an entry for the System.ConversationId conversation,
|
||||
# but it should NOT have HTTP-appended messages from us.
|
||||
for _cid, conv in decl["System"]["conversations"].items():
|
||||
@@ -582,7 +582,7 @@ class TestConversationAppend:
|
||||
factory = WorkflowFactory(http_request_handler=handler)
|
||||
workflow = factory.create_workflow_from_definition(_yaml(_action(conversation_id="conv-test-1")))
|
||||
await workflow.run({})
|
||||
decl = workflow._state.get(DECLARATIVE_STATE_KEY)
|
||||
decl = workflow._runner.state.get(DECLARATIVE_STATE_KEY)
|
||||
# No conversation entry should have been created either.
|
||||
assert "conv-test-1" not in decl["System"]["conversations"]
|
||||
|
||||
|
||||
@@ -73,7 +73,7 @@ async def test_http_request_yaml_roundtrip() -> None:
|
||||
workflow = factory.create_workflow_from_yaml_path(FIXTURE_PATH)
|
||||
await workflow.run({})
|
||||
|
||||
decl: dict[str, Any] = workflow._state.get(DECLARATIVE_STATE_KEY) or {}
|
||||
decl: dict[str, Any] = workflow._runner.state.get(DECLARATIVE_STATE_KEY) or {}
|
||||
local: dict[str, Any] = decl.get("Local") or {}
|
||||
|
||||
assert local.get("RepoOwner") == "dotnet"
|
||||
|
||||
@@ -244,7 +244,7 @@ class TestOutput:
|
||||
factory = WorkflowFactory(mcp_tool_handler=handler)
|
||||
workflow = factory.create_workflow_from_definition(_yaml(_action(output={"result": "Local.Result"})))
|
||||
await workflow.run({})
|
||||
decl = workflow._state.get(DECLARATIVE_STATE_KEY)
|
||||
decl = workflow._runner.state.get(DECLARATIVE_STATE_KEY)
|
||||
assert decl["Local"]["Result"] == [{"k": "v", "n": 1}]
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@@ -253,7 +253,7 @@ class TestOutput:
|
||||
factory = WorkflowFactory(mcp_tool_handler=handler)
|
||||
workflow = factory.create_workflow_from_definition(_yaml(_action(output={"result": "Local.Result"})))
|
||||
await workflow.run({})
|
||||
decl = workflow._state.get(DECLARATIVE_STATE_KEY)
|
||||
decl = workflow._runner.state.get(DECLARATIVE_STATE_KEY)
|
||||
assert decl["Local"]["Result"] == ["plain text not json"]
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@@ -262,7 +262,7 @@ class TestOutput:
|
||||
factory = WorkflowFactory(mcp_tool_handler=handler)
|
||||
workflow = factory.create_workflow_from_definition(_yaml(_action(output={"messages": "Local.Messages"})))
|
||||
await workflow.run({})
|
||||
decl = workflow._state.get(DECLARATIVE_STATE_KEY)
|
||||
decl = workflow._runner.state.get(DECLARATIVE_STATE_KEY)
|
||||
msg = decl["Local"]["Messages"]
|
||||
# Single Tool-role message containing both contents (parity with .NET).
|
||||
assert isinstance(msg, Message)
|
||||
@@ -276,7 +276,7 @@ class TestOutput:
|
||||
factory = WorkflowFactory(mcp_tool_handler=handler)
|
||||
workflow = factory.create_workflow_from_definition(_yaml(_action(output={"result": "Local.Result"})))
|
||||
await workflow.run({})
|
||||
decl = workflow._state.get(DECLARATIVE_STATE_KEY)
|
||||
decl = workflow._runner.state.get(DECLARATIVE_STATE_KEY)
|
||||
assert decl["Local"]["Result"] == ["https://example.com/file.txt"]
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@@ -285,7 +285,7 @@ class TestOutput:
|
||||
factory = WorkflowFactory(mcp_tool_handler=handler)
|
||||
workflow = factory.create_workflow_from_definition(_yaml(_action(output={"result": {"path": "Local.Result"}})))
|
||||
await workflow.run({})
|
||||
decl = workflow._state.get(DECLARATIVE_STATE_KEY)
|
||||
decl = workflow._runner.state.get(DECLARATIVE_STATE_KEY)
|
||||
assert decl["Local"]["Result"] == ["ok"]
|
||||
|
||||
|
||||
@@ -306,7 +306,7 @@ class TestConversation:
|
||||
)
|
||||
)
|
||||
await workflow.run({})
|
||||
decl = workflow._state.get(DECLARATIVE_STATE_KEY)
|
||||
decl = workflow._runner.state.get(DECLARATIVE_STATE_KEY)
|
||||
conv = decl["System"]["conversations"]["conv-42"]
|
||||
msgs = conv["messages"] if isinstance(conv, dict) else conv.messages
|
||||
assert len(msgs) == 1
|
||||
@@ -328,7 +328,7 @@ class TestConversation:
|
||||
)
|
||||
)
|
||||
await workflow.run({})
|
||||
decl = workflow._state.get(DECLARATIVE_STATE_KEY)
|
||||
decl = workflow._runner.state.get(DECLARATIVE_STATE_KEY)
|
||||
# Empty conversation id must not produce a `""` entry under System.conversations.
|
||||
conversations = decl.get("System", {}).get("conversations", {})
|
||||
assert "" not in conversations
|
||||
@@ -529,7 +529,7 @@ class TestErrorHandling:
|
||||
factory = WorkflowFactory(mcp_tool_handler=handler)
|
||||
workflow = factory.create_workflow_from_definition(_yaml(_action(output={"result": "Local.Result"})))
|
||||
await workflow.run({})
|
||||
decl = workflow._state.get(DECLARATIVE_STATE_KEY)
|
||||
decl = workflow._runner.state.get(DECLARATIVE_STATE_KEY)
|
||||
assert decl["Local"]["Result"] == "Error: server down"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@@ -538,7 +538,7 @@ class TestErrorHandling:
|
||||
factory = WorkflowFactory(mcp_tool_handler=handler)
|
||||
workflow = factory.create_workflow_from_definition(_yaml(_action(output={"result": "Local.Result"})))
|
||||
await workflow.run({})
|
||||
decl = workflow._state.get(DECLARATIVE_STATE_KEY)
|
||||
decl = workflow._runner.state.get(DECLARATIVE_STATE_KEY)
|
||||
assert decl["Local"]["Result"] == "Error: invalid arguments"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@@ -547,7 +547,7 @@ class TestErrorHandling:
|
||||
factory = WorkflowFactory(mcp_tool_handler=handler)
|
||||
workflow = factory.create_workflow_from_definition(_yaml(_action(output={"result": "Local.Result"})))
|
||||
await workflow.run({})
|
||||
decl = workflow._state.get(DECLARATIVE_STATE_KEY)
|
||||
decl = workflow._runner.state.get(DECLARATIVE_STATE_KEY)
|
||||
result = decl["Local"]["Result"]
|
||||
assert isinstance(result, str)
|
||||
assert result.startswith("Error:")
|
||||
|
||||
@@ -291,11 +291,11 @@ actions:
|
||||
# Stamp a marker into the declarative state between turns. The
|
||||
# continuation branch must preserve it; a state-clearing run would
|
||||
# wipe ``DECLARATIVE_STATE_KEY`` and force re-initialization.
|
||||
state_data = workflow._state.get(DECLARATIVE_STATE_KEY)
|
||||
state_data = workflow._runner.state.get(DECLARATIVE_STATE_KEY)
|
||||
assert isinstance(state_data, dict), "Expected declarative state to be initialized after turn 1"
|
||||
state_data["Local"] = {"persisted_marker": "kept-from-turn-1"}
|
||||
workflow._state.set(DECLARATIVE_STATE_KEY, state_data)
|
||||
workflow._state.commit()
|
||||
workflow._runner.state.set(DECLARATIVE_STATE_KEY, state_data)
|
||||
workflow._runner.state.commit()
|
||||
|
||||
second = await agent.run("turn-2-msg")
|
||||
assert second.text == "turn-2-msg", (
|
||||
@@ -305,7 +305,7 @@ actions:
|
||||
# The continuation branch in ``_ensure_state_initialized`` must:
|
||||
# 1. preserve the cross-turn marker we stamped above
|
||||
# 2. refresh Inputs.input and System.LastMessage* to the new turn
|
||||
post_state = workflow._state.get(DECLARATIVE_STATE_KEY)
|
||||
post_state = workflow._runner.state.get(DECLARATIVE_STATE_KEY)
|
||||
assert isinstance(post_state, dict), "declarative state vanished between turns"
|
||||
local = post_state.get("Local", {})
|
||||
assert local.get("persisted_marker") == "kept-from-turn-1", (
|
||||
|
||||
@@ -59,7 +59,7 @@ class DurableAgentTask(CompositeTask[AgentResponse], CompletableTask[AgentRespon
|
||||
"""
|
||||
self._response_format = response_format
|
||||
self._correlation_id = correlation_id
|
||||
super().__init__([entity_task]) # type: ignore
|
||||
super().__init__([entity_task])
|
||||
|
||||
def on_child_completed(self, task: Task[Any]) -> None:
|
||||
"""Handle completion of the underlying entity task.
|
||||
|
||||
@@ -129,7 +129,7 @@ class DurableAIAgentWorker:
|
||||
|
||||
# Register the entity class with the worker
|
||||
# The worker.add_entity method takes a class
|
||||
entity_registered: str = self._worker.add_entity(entity_class) # pyright: ignore[reportUnknownMemberType]
|
||||
entity_registered: str = self._worker.add_entity(entity_class)
|
||||
|
||||
logger.debug(
|
||||
"[DurableAIAgentWorker] Successfully registered entity class %s for agent: %s",
|
||||
@@ -225,7 +225,7 @@ class DurableAIAgentWorker:
|
||||
executor_activity.__name__ = activity_name
|
||||
executor_activity.__qualname__ = activity_name
|
||||
|
||||
self._worker.add_activity(executor_activity) # type: ignore[arg-type]
|
||||
self._worker.add_activity(executor_activity)
|
||||
logger.debug("[DurableAIAgentWorker] Registered activity: %s", activity_name)
|
||||
|
||||
def _register_workflow_orchestrator(self) -> None:
|
||||
|
||||
@@ -16,7 +16,7 @@ from durabletask.task import (
|
||||
OrchestrationContext,
|
||||
Task,
|
||||
when_all,
|
||||
when_any, # pyright: ignore[reportUnknownVariableType]
|
||||
when_any,
|
||||
)
|
||||
|
||||
from .._executors import OrchestrationAgentExecutor
|
||||
|
||||
@@ -27,6 +27,7 @@ dependencies = [
|
||||
"azure-ai-agentserver-core>=2.0.0b3,<3",
|
||||
"azure-ai-agentserver-responses>=1.0.0b7,<2",
|
||||
"azure-ai-agentserver-invocations>=1.0.0b3,<2",
|
||||
"mcp>=1.24.0,<2",
|
||||
]
|
||||
|
||||
[tool.uv]
|
||||
|
||||
@@ -24,7 +24,7 @@ classifiers = [
|
||||
]
|
||||
dependencies = [
|
||||
"agent-framework-core>=1.8.0,<2",
|
||||
"github-copilot-sdk>=1.0.0,<2; python_version >= '3.11'",
|
||||
"github-copilot-sdk==1.0.2; python_version >= '3.11'",
|
||||
]
|
||||
|
||||
[tool.uv]
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) Microsoft Corporation.
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE
|
||||
@@ -0,0 +1,21 @@
|
||||
# agent-framework-hosting-responses
|
||||
|
||||
OpenAI Responses-shaped channel for `agent-framework-hosting`.
|
||||
|
||||
Exposes a single `POST /responses` endpoint that accepts the OpenAI
|
||||
Responses API request body and returns either a Responses-shaped JSON
|
||||
body or a Server-Sent-Events stream when `stream=True`.
|
||||
|
||||
```python
|
||||
from agent_framework.openai import OpenAIChatClient
|
||||
from agent_framework_hosting import AgentFrameworkHost
|
||||
from agent_framework_hosting_responses import ResponsesChannel
|
||||
|
||||
agent = OpenAIChatClient().as_agent(name="Assistant")
|
||||
|
||||
host = AgentFrameworkHost(target=agent, channels=[ResponsesChannel()])
|
||||
host.serve(port=8000)
|
||||
```
|
||||
|
||||
The base host plumbing lives in
|
||||
[`agent-framework-hosting`](https://pypi.org/project/agent-framework-hosting/).
|
||||
@@ -0,0 +1,25 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
"""OpenAI Responses-shaped channel for ``agent-framework-hosting``."""
|
||||
|
||||
import importlib.metadata
|
||||
|
||||
from ._channel import ResponsesChannel
|
||||
from ._parsing import (
|
||||
messages_from_responses_input,
|
||||
parse_responses_identity,
|
||||
parse_responses_request,
|
||||
)
|
||||
|
||||
try:
|
||||
__version__ = importlib.metadata.version(__name__)
|
||||
except importlib.metadata.PackageNotFoundError:
|
||||
__version__ = "0.0.0"
|
||||
|
||||
__all__ = [
|
||||
"ResponsesChannel",
|
||||
"__version__",
|
||||
"messages_from_responses_input",
|
||||
"parse_responses_identity",
|
||||
"parse_responses_request",
|
||||
]
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,156 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
"""Parsing helpers for the OpenAI Responses-API request body.
|
||||
|
||||
The Responses API accepts ``input`` as either a string or a list of "input
|
||||
items". An item is either a content part (``input_text`` / ``input_image``
|
||||
/ ``input_file``) or a message envelope ``{type: "message", role,
|
||||
content: [...]}``. We translate that into an Agent Framework ``Message``
|
||||
list and remap the generation-control fields the API also carries into
|
||||
``ChatOptions``-shaped keys. The result is available to the channel's
|
||||
``run_hook``; a default hook strips them before they reach the agent so
|
||||
unknown fields from untrusted callers are not forwarded unless the host
|
||||
developer explicitly opts in.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Mapping
|
||||
from typing import Any, cast
|
||||
|
||||
from agent_framework import Content, Message
|
||||
from agent_framework_hosting import ChannelIdentity, ChannelSession
|
||||
|
||||
# OpenAI Responses field name → Agent Framework ChatOptions field name.
|
||||
_RESPONSES_OPTION_REMAP = {
|
||||
"max_output_tokens": "max_tokens",
|
||||
"parallel_tool_calls": "allow_multiple_tool_calls",
|
||||
}
|
||||
# Fields the Responses transport owns; they are consumed separately and must
|
||||
# not also appear in options.
|
||||
_RESPONSES_TRANSPORT_KEYS = frozenset({"input", "stream", "previous_response_id"})
|
||||
|
||||
|
||||
def parse_responses_identity(body: Mapping[str, Any], channel_name: str) -> ChannelIdentity | None:
|
||||
"""Surface the caller as a :class:`ChannelIdentity` so the host can record it.
|
||||
|
||||
OpenAI Responses replaced ``user`` with ``safety_identifier`` — we use
|
||||
that as the native id, falling back to the legacy ``user`` field.
|
||||
"""
|
||||
native = body.get("safety_identifier") or body.get("user")
|
||||
if not isinstance(native, str) or not native:
|
||||
return None
|
||||
return ChannelIdentity(channel=channel_name, native_id=native)
|
||||
|
||||
|
||||
def _content_from_input_item(item: Mapping[str, Any]) -> Content:
|
||||
"""Convert a single OpenAI Responses ``input`` item into a :class:`Content` part.
|
||||
|
||||
Handles the ``input_text``/``output_text``/``text`` text variants,
|
||||
``input_image`` URL references, and ``input_file`` references via either
|
||||
a public URL or a hosted ``file_id``. Raises ``ValueError`` for any
|
||||
unsupported item type so the surrounding parser can return a 422.
|
||||
"""
|
||||
item_type = item.get("type")
|
||||
if item_type in ("input_text", "output_text", "text"):
|
||||
return Content.from_text(text=str(item.get("text", "")))
|
||||
if item_type == "input_image":
|
||||
image_url: Any = item.get("image_url")
|
||||
if isinstance(image_url, Mapping):
|
||||
image_url = cast("Mapping[str, Any]", image_url).get("url")
|
||||
if not isinstance(image_url, str):
|
||||
raise ValueError("input_image requires `image_url`")
|
||||
return Content.from_uri(uri=image_url, media_type="image/*")
|
||||
if item_type == "input_file":
|
||||
if (uri := item.get("file_url")) and isinstance(uri, str):
|
||||
return Content.from_uri(uri=uri, media_type=item.get("mime_type"))
|
||||
if file_id := item.get("file_id"):
|
||||
return Content(type="hosted_file", file_id=str(file_id))
|
||||
raise ValueError("input_file requires `file_url` or `file_id`")
|
||||
raise ValueError(f"Unsupported Responses input content type: {item_type!r}")
|
||||
|
||||
|
||||
def messages_from_responses_input(value: Any) -> list[Message]:
|
||||
"""Translate ``input`` (string or list of items) into :class:`Message` objects."""
|
||||
if isinstance(value, str):
|
||||
return [Message("user", [Content.from_text(text=value)])]
|
||||
if not isinstance(value, list) or not value:
|
||||
raise ValueError("`input` must be a non-empty string or list")
|
||||
|
||||
messages: list[Message] = []
|
||||
pending_user_parts: list[Content] = []
|
||||
|
||||
def flush() -> None:
|
||||
"""Emit any buffered loose user content as a single user message."""
|
||||
if pending_user_parts:
|
||||
messages.append(Message("user", list(pending_user_parts)))
|
||||
pending_user_parts.clear()
|
||||
|
||||
for item in cast("list[Any]", value):
|
||||
if not isinstance(item, Mapping):
|
||||
raise ValueError("each `input` item must be an object")
|
||||
item_map = cast("Mapping[str, Any]", item)
|
||||
if item_map.get("type") == "message":
|
||||
flush()
|
||||
role = str(item_map.get("role") or "user")
|
||||
content: Any = item_map.get("content") or []
|
||||
parts: list[Content]
|
||||
if isinstance(content, str):
|
||||
parts = [Content.from_text(text=content)]
|
||||
elif isinstance(content, list):
|
||||
parts = []
|
||||
for content_item in cast("list[Any]", content):
|
||||
if not isinstance(content_item, Mapping):
|
||||
raise ValueError("each message `content` item must be an object")
|
||||
parts.append(_content_from_input_item(cast("Mapping[str, Any]", content_item)))
|
||||
else:
|
||||
raise ValueError("message `content` must be a string or list")
|
||||
messages.append(Message(role, parts))
|
||||
else:
|
||||
pending_user_parts.append(_content_from_input_item(item_map))
|
||||
|
||||
flush()
|
||||
if not messages:
|
||||
raise ValueError("`input` produced no messages")
|
||||
return messages
|
||||
|
||||
|
||||
def parse_responses_request(
|
||||
body: Mapping[str, Any],
|
||||
) -> tuple[list[Message], dict[str, Any], ChannelSession | None]:
|
||||
"""Translate a Responses-API request body into Agent Framework constructs.
|
||||
|
||||
Returns a triple ``(messages, options, session)`` where:
|
||||
|
||||
- ``messages`` is the parsed conversation.
|
||||
- ``options`` is a ``ChatOptions``-shaped dict with the remapped
|
||||
generation-control fields. Known Responses→ChatOptions renames are
|
||||
applied (e.g. ``max_output_tokens`` → ``max_tokens``); transport/
|
||||
session keys are excluded; ``None``-valued fields are dropped.
|
||||
Unknown fields are forwarded as-is so the channel's ``run_hook``
|
||||
can inspect and filter them. The default ``ResponsesChannel`` strips
|
||||
all options before the agent runs; supply a custom ``run_hook`` to
|
||||
selectively keep fields.
|
||||
- ``session`` is a :class:`ChannelSession` keyed by
|
||||
``previous_response_id`` when one was supplied, else ``None``.
|
||||
"""
|
||||
messages = messages_from_responses_input(body.get("input"))
|
||||
|
||||
options: dict[str, Any] = {}
|
||||
for key, value in body.items():
|
||||
if key in _RESPONSES_TRANSPORT_KEYS or value is None:
|
||||
continue
|
||||
options[_RESPONSES_OPTION_REMAP.get(key, key)] = value
|
||||
|
||||
session: ChannelSession | None = None
|
||||
if (prev := body.get("previous_response_id")) and isinstance(prev, str):
|
||||
session = ChannelSession(isolation_key=prev)
|
||||
|
||||
return messages, options, session
|
||||
|
||||
|
||||
__all__ = [
|
||||
"messages_from_responses_input",
|
||||
"parse_responses_identity",
|
||||
"parse_responses_request",
|
||||
]
|
||||
@@ -0,0 +1,80 @@
|
||||
[project]
|
||||
name = "agent-framework-hosting-responses"
|
||||
description = "OpenAI Responses-shaped channel for agent-framework-hosting."
|
||||
authors = [{ name = "Microsoft", email = "af-support@microsoft.com"}]
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.10"
|
||||
version = "1.0.0a260424"
|
||||
license-files = ["LICENSE"]
|
||||
urls.homepage = "https://aka.ms/agent-framework"
|
||||
urls.source = "https://github.com/microsoft/agent-framework/tree/main/python"
|
||||
urls.release_notes = "https://github.com/microsoft/agent-framework/releases?q=tag%3Apython-1&expanded=true"
|
||||
urls.issues = "https://github.com/microsoft/agent-framework/issues"
|
||||
classifiers = [
|
||||
"License :: OSI Approved :: MIT License",
|
||||
"Development Status :: 3 - Alpha",
|
||||
"Intended Audience :: Developers",
|
||||
"Programming Language :: Python :: 3",
|
||||
"Programming Language :: Python :: 3.10",
|
||||
"Programming Language :: Python :: 3.11",
|
||||
"Programming Language :: Python :: 3.12",
|
||||
"Programming Language :: Python :: 3.13",
|
||||
"Programming Language :: Python :: 3.14",
|
||||
"Typing :: Typed",
|
||||
]
|
||||
dependencies = [
|
||||
"agent-framework-core>=1.2.0,<2",
|
||||
"agent-framework-hosting==1.0.0a260424",
|
||||
"openai>=1.99.0,<3",
|
||||
]
|
||||
|
||||
[tool.uv]
|
||||
prerelease = "if-necessary-or-explicit"
|
||||
environments = [
|
||||
"sys_platform == 'darwin'",
|
||||
"sys_platform == 'linux'",
|
||||
"sys_platform == 'win32'"
|
||||
]
|
||||
|
||||
[tool.uv-dynamic-versioning]
|
||||
fallback-version = "0.0.0"
|
||||
|
||||
[tool.pytest.ini_options]
|
||||
testpaths = 'tests'
|
||||
addopts = "-ra -q -r fEX"
|
||||
asyncio_mode = "auto"
|
||||
asyncio_default_fixture_loop_scope = "function"
|
||||
filterwarnings = []
|
||||
timeout = 120
|
||||
markers = [
|
||||
"integration: marks tests as integration tests that require external services",
|
||||
]
|
||||
|
||||
[tool.ruff]
|
||||
extend = "../../pyproject.toml"
|
||||
|
||||
[tool.coverage.run]
|
||||
omit = [
|
||||
"**/__init__.py"
|
||||
]
|
||||
|
||||
[tool.pyright]
|
||||
extends = "../../pyproject.toml"
|
||||
include = ["agent_framework_hosting_responses"]
|
||||
exclude = ['tests']
|
||||
|
||||
[tool.bandit]
|
||||
targets = ["agent_framework_hosting_responses"]
|
||||
exclude_dirs = ["tests"]
|
||||
|
||||
[tool.poe]
|
||||
executor.type = "uv"
|
||||
include = "../../shared_tasks.toml"
|
||||
|
||||
[tool.poe.tasks.test]
|
||||
help = "Run the default unit test suite for this package."
|
||||
cmd = 'pytest -m "not integration" --cov=agent_framework_hosting_responses --cov-report=term-missing:skip-covered tests'
|
||||
|
||||
[build-system]
|
||||
requires = ["flit-core >= 3.11,<4.0"]
|
||||
build-backend = "flit_core.buildapi"
|
||||
@@ -0,0 +1,651 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
"""End-to-end tests for :class:`ResponsesChannel` via Starlette's ``TestClient``."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from collections.abc import AsyncIterator
|
||||
from dataclasses import dataclass
|
||||
from typing import Any
|
||||
|
||||
from agent_framework import AgentResponse, AgentResponseUpdate, Content, Message
|
||||
from agent_framework_hosting import (
|
||||
AgentFrameworkHost,
|
||||
HostedRunResult,
|
||||
)
|
||||
from starlette.testclient import TestClient
|
||||
|
||||
from agent_framework_hosting_responses import ResponsesChannel
|
||||
from agent_framework_hosting_responses._channel import ( # pyright: ignore[reportPrivateUsage]
|
||||
_result_to_output_items,
|
||||
_result_to_text,
|
||||
)
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Fakes #
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
|
||||
@dataclass
|
||||
class _FakeAgentResponse:
|
||||
text: str
|
||||
|
||||
|
||||
class _FakeStream:
|
||||
"""Minimal stand-in for AF's ``ResponseStream`` returned by ``run(stream=True)``."""
|
||||
|
||||
def __init__(self, chunks: list[str]) -> None:
|
||||
self._chunks = chunks
|
||||
self._final = _FakeAgentResponse(text="".join(chunks))
|
||||
|
||||
def __aiter__(self) -> AsyncIterator[AgentResponseUpdate]:
|
||||
async def _gen() -> AsyncIterator[AgentResponseUpdate]:
|
||||
for c in self._chunks:
|
||||
yield AgentResponseUpdate(contents=[Content.from_text(c)], role="assistant")
|
||||
|
||||
return _gen()
|
||||
|
||||
async def get_final_response(self) -> _FakeAgentResponse:
|
||||
return self._final
|
||||
|
||||
|
||||
class _FakeAgent:
|
||||
def __init__(self, reply: Any = "hello", chunks: list[str] | None = None) -> None:
|
||||
self.id = "fake-agent"
|
||||
self.name: str | None = "Fake Agent"
|
||||
self.description: str | None = "Test fake agent"
|
||||
self._reply = reply
|
||||
self._chunks = chunks or [reply]
|
||||
self.calls: list[dict[str, Any]] = []
|
||||
|
||||
def create_session(self, *, session_id: str | None = None) -> Any:
|
||||
return {"session_id": session_id}
|
||||
|
||||
def get_session(self, service_session_id: str, *, session_id: str | None = None) -> Any:
|
||||
return {"service_session_id": service_session_id, "session_id": session_id}
|
||||
|
||||
def run(self, messages: Any = None, *, stream: bool = False, **kwargs: Any) -> Any:
|
||||
self.calls.append({"messages": messages, "stream": stream, "kwargs": kwargs})
|
||||
if stream:
|
||||
return _FakeStream(self._chunks)
|
||||
|
||||
async def _coro() -> Any:
|
||||
if not isinstance(self._reply, str):
|
||||
return self._reply
|
||||
return _FakeAgentResponse(text=self._reply)
|
||||
|
||||
return _coro()
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Tests #
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
|
||||
def _make_client(
|
||||
agent: _FakeAgent | None = None,
|
||||
*,
|
||||
path: str = "/responses",
|
||||
response_id_factory: Any | None = None,
|
||||
) -> tuple[TestClient, AgentFrameworkHost, _FakeAgent]:
|
||||
agent = agent or _FakeAgent()
|
||||
host = AgentFrameworkHost(
|
||||
target=agent,
|
||||
channels=[ResponsesChannel(path=path, response_id_factory=response_id_factory)],
|
||||
)
|
||||
return TestClient(host.app), host, agent
|
||||
|
||||
|
||||
def _sse_payload(body: str, event_type: str) -> dict[str, Any]:
|
||||
current_event: str | None = None
|
||||
for line in body.splitlines():
|
||||
if line.startswith("event: "):
|
||||
current_event = line[len("event: ") :]
|
||||
continue
|
||||
if current_event == event_type and line.startswith("data: "):
|
||||
return json.loads(line[len("data: ") :])
|
||||
raise AssertionError(f"Missing SSE event: {event_type}")
|
||||
|
||||
|
||||
class TestResponsesChannelNonStreaming:
|
||||
def test_post_responses_returns_completed_envelope(self) -> None:
|
||||
client, _host, agent = _make_client(_FakeAgent(reply="hi back"))
|
||||
with client:
|
||||
r = client.post("/responses", json={"input": "hi"})
|
||||
assert r.status_code == 200
|
||||
body = r.json()
|
||||
assert body["status"] == "completed"
|
||||
assert body["object"] == "response"
|
||||
assert body["id"].startswith("resp_")
|
||||
assert isinstance(body["created_at"], int)
|
||||
assert body["output"][0]["content"][0]["text"] == "hi back"
|
||||
assert len(agent.calls) == 1
|
||||
|
||||
def test_non_string_model_falls_back_to_agent(self) -> None:
|
||||
client, _host, _agent = _make_client(_FakeAgent(reply="hi"))
|
||||
with client:
|
||||
r = client.post("/responses", json={"input": "hi", "model": None})
|
||||
assert r.status_code == 200
|
||||
assert r.json()["model"] == "agent"
|
||||
|
||||
def test_empty_path_mounts_at_app_root(self) -> None:
|
||||
client, _host, _agent = _make_client(_FakeAgent(reply="hi back"), path="")
|
||||
with client:
|
||||
r = client.post("/", json={"input": "hi"})
|
||||
assert r.status_code == 200
|
||||
assert r.json()["output"][0]["content"][0]["text"] == "hi back"
|
||||
|
||||
def test_custom_path_mounts_route_under_host_path(self) -> None:
|
||||
client, _host, _agent = _make_client(_FakeAgent(reply="custom"), path="/api/responses")
|
||||
with client:
|
||||
r = client.post("/api/responses", json={"input": "hi"})
|
||||
missing = client.post("/api/responses/responses", json={"input": "hi"})
|
||||
assert r.status_code == 200
|
||||
assert r.json()["output"][0]["content"][0]["text"] == "custom"
|
||||
assert missing.status_code == 404
|
||||
|
||||
def test_invalid_json_returns_400(self) -> None:
|
||||
client, *_ = _make_client()
|
||||
with client:
|
||||
r = client.post("/responses", content=b"{not json", headers={"content-type": "application/json"})
|
||||
assert r.status_code == 400
|
||||
|
||||
def test_non_object_json_returns_422(self) -> None:
|
||||
client, *_ = _make_client()
|
||||
with client:
|
||||
r = client.post("/responses", json=["not", "an", "object"])
|
||||
assert r.status_code == 422
|
||||
assert r.json()["error"] == "request body must be a JSON object"
|
||||
|
||||
def test_invalid_input_returns_422(self) -> None:
|
||||
client, *_ = _make_client()
|
||||
with client:
|
||||
r = client.post("/responses", json={"input": 42})
|
||||
assert r.status_code == 422
|
||||
|
||||
def test_request_options_are_not_forwarded_by_default(self) -> None:
|
||||
client, _host, agent = _make_client()
|
||||
with client:
|
||||
r = client.post(
|
||||
"/responses",
|
||||
json={"input": "x", "temperature": 0.5, "max_output_tokens": 64, "truncation": "auto"},
|
||||
)
|
||||
assert r.status_code == 200
|
||||
assert "options" not in agent.calls[0]["kwargs"]
|
||||
|
||||
def test_custom_run_hook_can_forward_options(self) -> None:
|
||||
import dataclasses
|
||||
|
||||
def keep_temperature(request: Any, **_: Any) -> Any:
|
||||
opts = dict(request.options or {})
|
||||
return dataclasses.replace(request, options={"temperature": opts.get("temperature")})
|
||||
|
||||
agent = _FakeAgent()
|
||||
host = AgentFrameworkHost(
|
||||
target=agent,
|
||||
channels=[ResponsesChannel(run_hook=keep_temperature)],
|
||||
)
|
||||
with TestClient(host.app) as client:
|
||||
r = client.post("/responses", json={"input": "x", "temperature": 0.7, "truncation": "auto"})
|
||||
assert r.status_code == 200
|
||||
opts = agent.calls[0]["kwargs"]["options"]
|
||||
assert opts == {"temperature": 0.7}
|
||||
assert "truncation" not in opts
|
||||
|
||||
def test_multimodal_agent_response_outputs_are_preserved(self) -> None:
|
||||
response = AgentResponse(
|
||||
messages=[
|
||||
Message(
|
||||
"assistant",
|
||||
[
|
||||
Content.from_text_reasoning(id="rs_1", text="checking"),
|
||||
Content.from_function_call("call_1", "collect_media", arguments={"city": "Seattle"}),
|
||||
Content.from_function_result(
|
||||
"call_1",
|
||||
result=[
|
||||
Content.from_text("caption"),
|
||||
Content.from_uri("https://example.com/cat.png", media_type="image/png"),
|
||||
Content.from_hosted_file("file_pdf", media_type="application/pdf"),
|
||||
],
|
||||
),
|
||||
Content.from_text("done"),
|
||||
],
|
||||
)
|
||||
],
|
||||
)
|
||||
client, _host, _agent = _make_client(_FakeAgent(reply=response))
|
||||
|
||||
with client:
|
||||
r = client.post("/responses", json={"input": "hi"})
|
||||
|
||||
assert r.status_code == 200
|
||||
output = r.json()["output"]
|
||||
assert [item["type"] for item in output] == [
|
||||
"reasoning",
|
||||
"function_call",
|
||||
"function_call_output",
|
||||
"message",
|
||||
]
|
||||
assert output[0]["content"][0]["text"] == "checking"
|
||||
assert output[1]["name"] == "collect_media"
|
||||
assert output[1]["arguments"] == '{"city": "Seattle"}'
|
||||
assert output[2]["output"] == [
|
||||
{"text": "caption", "type": "input_text"},
|
||||
{"detail": "auto", "type": "input_image", "image_url": "https://example.com/cat.png"},
|
||||
{"type": "input_file", "file_id": "file_pdf"},
|
||||
]
|
||||
assert output[3]["content"][0]["text"] == "done"
|
||||
|
||||
def test_raw_responses_output_items_are_preserved(self) -> None:
|
||||
raw_item = {
|
||||
"id": "ig_1",
|
||||
"type": "image_generation_call",
|
||||
"result": "base64-image",
|
||||
"status": "completed",
|
||||
}
|
||||
response = AgentResponse(
|
||||
messages=[
|
||||
Message(
|
||||
"assistant",
|
||||
[
|
||||
Content.from_image_generation_tool_call(image_id="ig_1", raw_representation=raw_item),
|
||||
Content.from_image_generation_tool_result(
|
||||
image_id="ig_1",
|
||||
outputs=Content.from_uri("data:image/png;base64,base64-image", media_type="image/png"),
|
||||
raw_representation=raw_item,
|
||||
),
|
||||
],
|
||||
)
|
||||
],
|
||||
)
|
||||
client, _host, _agent = _make_client(_FakeAgent(reply=response))
|
||||
|
||||
with client:
|
||||
r = client.post("/responses", json={"input": "hi"})
|
||||
|
||||
assert r.status_code == 200
|
||||
assert r.json()["output"] == [raw_item]
|
||||
|
||||
def test_later_raw_responses_output_item_replaces_earlier_partial_item(self) -> None:
|
||||
partial = {
|
||||
"id": "mcp_1",
|
||||
"type": "mcp_call",
|
||||
"server_label": "weather",
|
||||
"name": "lookup",
|
||||
"arguments": "{}",
|
||||
"status": "in_progress",
|
||||
}
|
||||
completed = {**partial, "status": "completed", "output": "sunny"}
|
||||
response = AgentResponse(
|
||||
messages=[
|
||||
Message(
|
||||
"assistant",
|
||||
[
|
||||
Content.from_mcp_server_tool_call(
|
||||
"mcp_1",
|
||||
"lookup",
|
||||
server_name="weather",
|
||||
raw_representation=partial,
|
||||
),
|
||||
Content.from_mcp_server_tool_result("mcp_1", output="sunny", raw_representation=completed),
|
||||
],
|
||||
)
|
||||
],
|
||||
)
|
||||
client, _host, _agent = _make_client(_FakeAgent(reply=response))
|
||||
|
||||
with client:
|
||||
r = client.post("/responses", json={"input": "hi"})
|
||||
|
||||
assert r.status_code == 200
|
||||
assert r.json()["output"] == [completed]
|
||||
|
||||
def test_previous_response_id_creates_session(self) -> None:
|
||||
client, _host, agent = _make_client()
|
||||
with client:
|
||||
client.post("/responses", json={"input": "x", "previous_response_id": "resp_42"})
|
||||
# AgentFrameworkHost converts the channel session into an AgentSession.
|
||||
sess = agent.calls[0]["kwargs"].get("session")
|
||||
assert sess is not None
|
||||
# _FakeAgent.create_session stashes the session_id on the dict it returns.
|
||||
assert sess["session_id"] == "resp_42"
|
||||
|
||||
def test_first_turn_response_id_creates_session(self) -> None:
|
||||
client, _host, agent = _make_client(response_id_factory=lambda *_: "resp_first")
|
||||
with client:
|
||||
client.post("/responses", json={"input": "x"})
|
||||
sess = agent.calls[0]["kwargs"].get("session")
|
||||
assert sess is not None
|
||||
assert sess["session_id"] == "resp_first"
|
||||
|
||||
def test_chat_isolation_header_ignored_outside_foundry(self) -> None:
|
||||
client, _host, agent = _make_client(response_id_factory=lambda *_: "resp_local")
|
||||
with client:
|
||||
client.post(
|
||||
"/responses",
|
||||
json={"input": "x"},
|
||||
headers={"x-agent-chat-isolation-key": "chat-abc"},
|
||||
)
|
||||
sess = agent.calls[0]["kwargs"].get("session")
|
||||
assert sess is not None
|
||||
assert sess["session_id"] == "resp_local"
|
||||
|
||||
def test_chat_isolation_header_creates_session_in_foundry(self, monkeypatch: Any) -> None:
|
||||
"""Foundry-style ``x-agent-chat-isolation-key`` falls back to a session anchor.
|
||||
|
||||
First-turn requests have no ``previous_response_id`` (the client
|
||||
doesn't have one yet), but Foundry Hosted Agents always inject
|
||||
the isolation headers. The channel must derive a session from the
|
||||
chat key so the host can build a stable per-conversation session
|
||||
that history providers persist under.
|
||||
"""
|
||||
monkeypatch.setenv("FOUNDRY_HOSTING_ENVIRONMENT", "1")
|
||||
client, _host, agent = _make_client()
|
||||
with client:
|
||||
client.post(
|
||||
"/responses",
|
||||
json={"input": "x"},
|
||||
headers={"x-agent-chat-isolation-key": "chat-abc"},
|
||||
)
|
||||
sess = agent.calls[0]["kwargs"].get("session")
|
||||
assert sess is not None
|
||||
assert sess["session_id"] == "chat-abc"
|
||||
|
||||
def test_prev_response_id_wins_over_chat_isolation_header(self, monkeypatch: Any) -> None:
|
||||
"""When both anchors are present, ``previous_response_id`` wins.
|
||||
|
||||
``previous_response_id`` is the protocol-native chain anchor; the
|
||||
header fallback is only meant to bootstrap when no protocol
|
||||
anchor exists.
|
||||
"""
|
||||
monkeypatch.setenv("FOUNDRY_HOSTING_ENVIRONMENT", "1")
|
||||
client, _host, agent = _make_client()
|
||||
with client:
|
||||
client.post(
|
||||
"/responses",
|
||||
json={"input": "x", "previous_response_id": "resp_99"},
|
||||
headers={"x-agent-chat-isolation-key": "chat-abc"},
|
||||
)
|
||||
sess = agent.calls[0]["kwargs"].get("session")
|
||||
assert sess is not None
|
||||
assert sess["session_id"] == "resp_99"
|
||||
|
||||
def test_response_hook_can_rewrite_originating_reply(self) -> None:
|
||||
seen_kwargs: list[dict[str, Any]] = []
|
||||
|
||||
def hook(result: HostedRunResult, **kwargs: Any) -> HostedRunResult:
|
||||
seen_kwargs.append(dict(kwargs))
|
||||
return HostedRunResult(_FakeAgentResponse(text=result.result.text.upper()), session=result.session)
|
||||
|
||||
agent = _FakeAgent(reply="hooked")
|
||||
host = AgentFrameworkHost(target=agent, channels=[ResponsesChannel(response_hook=hook)])
|
||||
|
||||
with TestClient(host.app) as client:
|
||||
r = client.post("/responses", json={"input": "hi"})
|
||||
|
||||
assert r.status_code == 200
|
||||
body = r.json()
|
||||
assert body["output"][0]["content"][0]["text"] == "HOOKED"
|
||||
assert seen_kwargs
|
||||
assert seen_kwargs[0]["channel_name"] == "responses"
|
||||
|
||||
|
||||
class TestResultTextRendering:
|
||||
def test_result_text_prefers_text_property(self) -> None:
|
||||
assert _result_to_text(_FakeAgentResponse(text="plain")) == "plain"
|
||||
|
||||
def test_result_text_projects_workflow_outputs(self) -> None:
|
||||
class _WorkflowResult:
|
||||
def get_outputs(self) -> list[Any]:
|
||||
return [_FakeAgentResponse(text="one"), " two"]
|
||||
|
||||
assert _result_to_text(_WorkflowResult()) == "one two"
|
||||
|
||||
def test_result_output_items_project_workflow_message_and_content_outputs(self) -> None:
|
||||
class _WorkflowResult:
|
||||
def get_outputs(self) -> list[Any]:
|
||||
return [
|
||||
Message("assistant", [Content.from_text("one")]),
|
||||
Content.from_function_result(
|
||||
"call_1",
|
||||
result=[Content.from_uri("https://example.com/cat.png", media_type="image/png")],
|
||||
),
|
||||
]
|
||||
|
||||
output = [
|
||||
item.model_dump(mode="json", exclude_none=True)
|
||||
for item in _result_to_output_items(_WorkflowResult(), status="completed")
|
||||
]
|
||||
assert output[0]["type"] == "message"
|
||||
assert output[0]["content"][0]["text"] == "one"
|
||||
assert output[1]["type"] == "function_call_output"
|
||||
assert output[1]["output"] == [
|
||||
{"detail": "auto", "type": "input_image", "image_url": "https://example.com/cat.png"}
|
||||
]
|
||||
|
||||
def test_function_result_exception_is_preserved(self) -> None:
|
||||
output = [
|
||||
item.model_dump(mode="json", exclude_none=True)
|
||||
for item in _result_to_output_items(
|
||||
Content.from_function_result("call_1", exception="tool failed"),
|
||||
status="completed",
|
||||
)
|
||||
]
|
||||
assert output[0]["output"] == "tool failed"
|
||||
|
||||
def test_stateful_call_and_result_content_coalesce_to_one_output_item(self) -> None:
|
||||
output = [
|
||||
item.model_dump(mode="json", exclude_none=True)
|
||||
for item in _result_to_output_items(
|
||||
Message(
|
||||
"assistant",
|
||||
[
|
||||
Content.from_image_generation_tool_call(image_id="ig_1"),
|
||||
Content.from_image_generation_tool_result(
|
||||
image_id="ig_1",
|
||||
outputs=Content.from_uri("data:image/png;base64,base64-image", media_type="image/png"),
|
||||
),
|
||||
Content.from_mcp_server_tool_call(
|
||||
"mcp_1",
|
||||
"lookup",
|
||||
server_name="weather",
|
||||
arguments={"city": "Seattle"},
|
||||
),
|
||||
Content.from_mcp_server_tool_result("mcp_1", output=[Content.from_text("sunny")]),
|
||||
],
|
||||
),
|
||||
status="completed",
|
||||
)
|
||||
]
|
||||
assert output == [
|
||||
{
|
||||
"id": "ig_1",
|
||||
"result": "base64-image",
|
||||
"status": "completed",
|
||||
"type": "image_generation_call",
|
||||
},
|
||||
{
|
||||
"id": "mcp_1",
|
||||
"arguments": '{"city": "Seattle"}',
|
||||
"name": "lookup",
|
||||
"output": "sunny",
|
||||
"server_label": "weather",
|
||||
"status": "completed",
|
||||
"type": "mcp_call",
|
||||
},
|
||||
]
|
||||
|
||||
def test_stateful_call_and_result_content_coalesce_across_messages(self) -> None:
|
||||
output = [
|
||||
item.model_dump(mode="json", exclude_none=True)
|
||||
for item in _result_to_output_items(
|
||||
AgentResponse(
|
||||
messages=[
|
||||
Message(
|
||||
"assistant",
|
||||
[
|
||||
Content.from_mcp_server_tool_call(
|
||||
"mcp_1",
|
||||
"lookup",
|
||||
server_name="weather",
|
||||
arguments={"city": "Seattle"},
|
||||
)
|
||||
],
|
||||
),
|
||||
Message(
|
||||
"tool",
|
||||
[Content.from_mcp_server_tool_result("mcp_1", output=[Content.from_text("sunny")])],
|
||||
),
|
||||
]
|
||||
),
|
||||
status="completed",
|
||||
)
|
||||
]
|
||||
assert output == [
|
||||
{
|
||||
"id": "mcp_1",
|
||||
"arguments": '{"city": "Seattle"}',
|
||||
"name": "lookup",
|
||||
"output": "sunny",
|
||||
"server_label": "weather",
|
||||
"status": "completed",
|
||||
"type": "mcp_call",
|
||||
}
|
||||
]
|
||||
|
||||
|
||||
class TestResponsesChannelStreaming:
|
||||
def test_sse_emits_created_delta_completed(self) -> None:
|
||||
agent = _FakeAgent(reply="hello world", chunks=["hello", " ", "world"])
|
||||
host = AgentFrameworkHost(target=agent, channels=[ResponsesChannel()])
|
||||
with TestClient(host.app) as client:
|
||||
r = client.post("/responses", json={"input": "hi", "stream": True})
|
||||
assert r.status_code == 200
|
||||
body = r.text
|
||||
|
||||
# SSE event lines look like "event: <type>\ndata: <json>\n\n".
|
||||
events = [line[len("event: ") :] for line in body.splitlines() if line.startswith("event: ")]
|
||||
assert events[0] == "response.created"
|
||||
assert events[-1] == "response.completed"
|
||||
assert events.count("response.output_text.delta") == 3
|
||||
|
||||
def test_sse_transform_hook_can_rewrite_chunks(self) -> None:
|
||||
agent = _FakeAgent(reply="hello", chunks=["he", "llo"])
|
||||
|
||||
def transform(update: AgentResponseUpdate) -> AgentResponseUpdate:
|
||||
return AgentResponseUpdate(contents=[Content.from_text(update.text.upper())], role="assistant")
|
||||
|
||||
host = AgentFrameworkHost(target=agent, channels=[ResponsesChannel(stream_update_hook=transform)])
|
||||
with TestClient(host.app) as client:
|
||||
r = client.post("/responses", json={"input": "hi", "stream": True})
|
||||
|
||||
assert r.status_code == 200
|
||||
assert '"delta":"HE"' in r.text
|
||||
assert '"delta":"LLO"' in r.text
|
||||
# Stream update hooks are update-only; they do not rewrite get_final_response().
|
||||
assert '"text":"hello"' in r.text
|
||||
|
||||
def test_sse_completed_preserves_streamed_multimodal_updates_when_finalize_fails(self) -> None:
|
||||
class _MultimodalStream:
|
||||
def __aiter__(self) -> AsyncIterator[AgentResponseUpdate]:
|
||||
async def _gen() -> AsyncIterator[AgentResponseUpdate]:
|
||||
yield AgentResponseUpdate(
|
||||
contents=[
|
||||
Content.from_text("caption"),
|
||||
Content.from_text_reasoning(id="rs_1", text="thinking"),
|
||||
Content.from_function_call("call_1", "lookup", arguments={"city": "Seattle"}),
|
||||
Content.from_uri("https://example.com/cat.png", media_type="image/png"),
|
||||
],
|
||||
role="assistant",
|
||||
)
|
||||
|
||||
return _gen()
|
||||
|
||||
async def get_final_response(self) -> _FakeAgentResponse:
|
||||
raise RuntimeError("finalize unavailable")
|
||||
|
||||
class _MultimodalAgent(_FakeAgent):
|
||||
def run(self, messages: Any = None, *, stream: bool = False, **kwargs: Any) -> Any:
|
||||
self.calls.append({"messages": messages, "stream": stream, "kwargs": kwargs})
|
||||
if stream:
|
||||
return _MultimodalStream()
|
||||
raise AssertionError("non-streaming path not exercised here")
|
||||
|
||||
host = AgentFrameworkHost(target=_MultimodalAgent(), channels=[ResponsesChannel()])
|
||||
with TestClient(host.app) as client:
|
||||
r = client.post("/responses", json={"input": "hi", "stream": True})
|
||||
|
||||
assert r.status_code == 200
|
||||
assert "event: response.output_item.added" in r.text
|
||||
assert "event: response.output_item.done" in r.text
|
||||
events = [line[len("event: ") :] for line in r.text.splitlines() if line.startswith("event: ")]
|
||||
assert "response.content_part.added" in events
|
||||
assert "response.output_text.done" in events
|
||||
assert "response.reasoning_text.delta" in events
|
||||
assert "response.reasoning_text.done" in events
|
||||
assert "response.function_call_arguments.delta" in events
|
||||
assert "response.function_call_arguments.done" in events
|
||||
content_part_added = _sse_payload(r.text, "response.content_part.added")
|
||||
assert content_part_added["part"] == {"annotations": [], "text": "", "type": "output_text"}
|
||||
added_items = [
|
||||
json.loads(line[len("data: ") :])["item"]
|
||||
for line in r.text.splitlines()
|
||||
if line.startswith("data: ") and '"type":"response.output_item.added"' in line
|
||||
]
|
||||
assert [item["type"] for item in added_items] == [
|
||||
"message",
|
||||
"reasoning",
|
||||
"function_call",
|
||||
"function_call_output",
|
||||
]
|
||||
assert added_items[0]["content"] == []
|
||||
assert added_items[1]["content"] == []
|
||||
assert added_items[2]["name"] == "lookup"
|
||||
assert added_items[2]["arguments"] == ""
|
||||
assert added_items[3]["output"] == [
|
||||
{"detail": "auto", "type": "input_image", "image_url": "https://example.com/cat.png"}
|
||||
]
|
||||
completed = _sse_payload(r.text, "response.completed")
|
||||
assert completed["response"]["output"][0]["content"][0]["text"] == "caption"
|
||||
assert completed["response"]["output"][1]["content"][0]["text"] == "thinking"
|
||||
assert completed["response"]["output"][2]["name"] == "lookup"
|
||||
assert completed["response"]["output"][3]["output"] == [
|
||||
{"detail": "auto", "type": "input_image", "image_url": "https://example.com/cat.png"}
|
||||
]
|
||||
|
||||
def test_sse_emits_failed_when_stream_raises(self) -> None:
|
||||
# Regression: ResponseOutputMessage.status only accepts in_progress/
|
||||
# completed/incomplete, so building an OpenAIResponse with status="failed"
|
||||
# used to crash with a pydantic ValidationError. The channel must map the
|
||||
# nested message status to "incomplete" while keeping the top-level
|
||||
# Response.status="failed".
|
||||
class _BoomStream:
|
||||
def __aiter__(self) -> AsyncIterator[AgentResponseUpdate]:
|
||||
async def _gen() -> AsyncIterator[AgentResponseUpdate]:
|
||||
yield AgentResponseUpdate(contents=[Content.from_text("partial")], role="assistant")
|
||||
raise RuntimeError("upstream blew up")
|
||||
|
||||
return _gen()
|
||||
|
||||
async def get_final_response(self) -> _FakeAgentResponse: # pragma: no cover
|
||||
return _FakeAgentResponse(text="")
|
||||
|
||||
class _BoomAgent(_FakeAgent):
|
||||
def run(self, messages: Any = None, *, stream: bool = False, **kwargs: Any) -> Any:
|
||||
self.calls.append({"messages": messages, "stream": stream, "kwargs": kwargs})
|
||||
if stream:
|
||||
return _BoomStream()
|
||||
raise AssertionError("non-streaming path not exercised here")
|
||||
|
||||
host = AgentFrameworkHost(target=_BoomAgent(), channels=[ResponsesChannel()])
|
||||
with TestClient(host.app) as client:
|
||||
r = client.post("/responses", json={"input": "hi", "stream": True})
|
||||
assert r.status_code == 200
|
||||
body = r.text
|
||||
|
||||
events = [line[len("event: ") :] for line in body.splitlines() if line.startswith("event: ")]
|
||||
assert events[0] == "response.created"
|
||||
assert events[-1] == "response.failed"
|
||||
# The failed envelope must serialize cleanly — i.e. no ValidationError raised.
|
||||
assert "upstream blew up" in body
|
||||
@@ -0,0 +1,169 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
"""Tests for the OpenAI Responses request-body parser."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
from agent_framework_hosting_responses import (
|
||||
messages_from_responses_input,
|
||||
parse_responses_identity,
|
||||
parse_responses_request,
|
||||
)
|
||||
|
||||
|
||||
class TestMessagesFromResponsesInput:
|
||||
def test_string_input_becomes_single_user_message(self) -> None:
|
||||
msgs = messages_from_responses_input("hello")
|
||||
assert len(msgs) == 1
|
||||
assert msgs[0].role == "user"
|
||||
assert msgs[0].text == "hello"
|
||||
|
||||
def test_input_text_items_collapse_into_one_user_message(self) -> None:
|
||||
msgs = messages_from_responses_input([{"type": "input_text", "text": "a"}, {"type": "input_text", "text": "b"}])
|
||||
assert len(msgs) == 1
|
||||
assert msgs[0].role == "user"
|
||||
assert msgs[0].text == "a b"
|
||||
|
||||
def test_message_envelope_with_string_content(self) -> None:
|
||||
msgs = messages_from_responses_input([
|
||||
{"type": "message", "role": "system", "content": "be brief"},
|
||||
{"type": "message", "role": "user", "content": "hi"},
|
||||
])
|
||||
assert [m.role for m in msgs] == ["system", "user"]
|
||||
assert msgs[0].text == "be brief"
|
||||
|
||||
def test_message_envelope_with_content_parts(self) -> None:
|
||||
msgs = messages_from_responses_input([
|
||||
{
|
||||
"type": "message",
|
||||
"role": "user",
|
||||
"content": [{"type": "input_text", "text": "describe this"}],
|
||||
}
|
||||
])
|
||||
assert msgs[0].text == "describe this"
|
||||
|
||||
def test_message_envelope_rejects_non_object_content_item(self) -> None:
|
||||
with pytest.raises(ValueError, match="content.*object"):
|
||||
messages_from_responses_input([{"type": "message", "role": "user", "content": ["bad"]}])
|
||||
|
||||
def test_message_envelope_rejects_invalid_content_shape(self) -> None:
|
||||
with pytest.raises(ValueError, match="content.*string or list"):
|
||||
messages_from_responses_input([{"type": "message", "role": "user", "content": 42}])
|
||||
|
||||
def test_input_file_via_url(self) -> None:
|
||||
msgs = messages_from_responses_input([
|
||||
{"type": "input_file", "file_url": "https://example.com/report.pdf", "mime_type": "application/pdf"}
|
||||
])
|
||||
assert msgs[0].contents[0].uri == "https://example.com/report.pdf"
|
||||
|
||||
def test_input_file_via_file_id(self) -> None:
|
||||
msgs = messages_from_responses_input([{"type": "input_file", "file_id": "file_123"}])
|
||||
assert msgs[0].contents[0].file_id == "file_123"
|
||||
|
||||
def test_input_file_missing_anchor_raises(self) -> None:
|
||||
with pytest.raises(ValueError, match="input_file"):
|
||||
messages_from_responses_input([{"type": "input_file"}])
|
||||
|
||||
def test_pending_text_flushes_before_message_envelope(self) -> None:
|
||||
msgs = messages_from_responses_input([
|
||||
{"type": "input_text", "text": "first"},
|
||||
{"type": "message", "role": "user", "content": "second"},
|
||||
])
|
||||
assert len(msgs) == 2
|
||||
assert msgs[0].text == "first"
|
||||
assert msgs[1].text == "second"
|
||||
|
||||
def test_image_url_via_string(self) -> None:
|
||||
msgs = messages_from_responses_input([{"type": "input_image", "image_url": "https://example.com/cat.png"}])
|
||||
assert len(msgs) == 1
|
||||
# Image content present.
|
||||
assert any(getattr(c, "uri", None) == "https://example.com/cat.png" for c in msgs[0].contents)
|
||||
|
||||
def test_image_url_via_object(self) -> None:
|
||||
msgs = messages_from_responses_input([
|
||||
{"type": "input_image", "image_url": {"url": "https://example.com/cat.png"}}
|
||||
])
|
||||
assert any(getattr(c, "uri", None) == "https://example.com/cat.png" for c in msgs[0].contents)
|
||||
|
||||
def test_unknown_input_type_raises(self) -> None:
|
||||
with pytest.raises(ValueError, match="Unsupported"):
|
||||
messages_from_responses_input([{"type": "weird"}])
|
||||
|
||||
def test_empty_list_raises(self) -> None:
|
||||
with pytest.raises(ValueError, match="non-empty"):
|
||||
messages_from_responses_input([])
|
||||
|
||||
def test_non_string_non_list_raises(self) -> None:
|
||||
with pytest.raises(ValueError):
|
||||
messages_from_responses_input(42) # type: ignore[arg-type]
|
||||
|
||||
def test_image_url_missing_raises(self) -> None:
|
||||
with pytest.raises(ValueError, match="image_url"):
|
||||
messages_from_responses_input([{"type": "input_image"}])
|
||||
|
||||
|
||||
class TestParseResponsesRequest:
|
||||
def test_known_fields_remapped_and_unknown_forwarded(self) -> None:
|
||||
_, opts, _ = parse_responses_request({
|
||||
"input": "hi",
|
||||
"instructions": "be brief",
|
||||
"temperature": 0.4,
|
||||
"top_p": 0.9,
|
||||
"tool_choice": "auto",
|
||||
"max_output_tokens": 256,
|
||||
"parallel_tool_calls": False,
|
||||
"truncation": "auto",
|
||||
"reasoning": {"effort": "low"},
|
||||
})
|
||||
# Known remaps applied.
|
||||
assert opts["max_tokens"] == 256
|
||||
assert opts["allow_multiple_tool_calls"] is False
|
||||
# Straight-through fields present.
|
||||
assert opts["temperature"] == 0.4
|
||||
assert opts["instructions"] == "be brief"
|
||||
assert opts["truncation"] == "auto"
|
||||
# Transport/session keys excluded.
|
||||
for key in ("input", "stream", "previous_response_id"):
|
||||
assert key not in opts
|
||||
|
||||
def test_model_passes_through_transport_keys_excluded(self) -> None:
|
||||
_, opts, _ = parse_responses_request({
|
||||
"input": "x",
|
||||
"model": "gpt-x",
|
||||
"stream": True,
|
||||
"previous_response_id": "r",
|
||||
})
|
||||
for key in ("input", "stream", "previous_response_id"):
|
||||
assert key not in opts
|
||||
# model passes through — not a transport key; run_hook decides what to do with it.
|
||||
assert opts["model"] == "gpt-x"
|
||||
|
||||
def test_none_values_dropped(self) -> None:
|
||||
_, opts, _ = parse_responses_request({"input": "x", "temperature": None})
|
||||
assert "temperature" not in opts
|
||||
|
||||
def test_previous_response_id_becomes_session(self) -> None:
|
||||
_, _, sess = parse_responses_request({"input": "x", "previous_response_id": "resp_42"})
|
||||
assert sess is not None
|
||||
assert sess.isolation_key == "resp_42"
|
||||
|
||||
|
||||
class TestParseResponsesIdentity:
|
||||
def test_safety_identifier_preferred(self) -> None:
|
||||
ident = parse_responses_identity({"safety_identifier": "abc", "user": "legacy"}, "responses")
|
||||
assert ident is not None
|
||||
assert ident.native_id == "abc"
|
||||
assert ident.channel == "responses"
|
||||
|
||||
def test_fallback_to_user(self) -> None:
|
||||
ident = parse_responses_identity({"user": "legacy"}, "responses")
|
||||
assert ident is not None
|
||||
assert ident.native_id == "legacy"
|
||||
|
||||
def test_returns_none_when_absent(self) -> None:
|
||||
assert parse_responses_identity({}, "responses") is None
|
||||
|
||||
def test_returns_none_for_non_string(self) -> None:
|
||||
assert parse_responses_identity({"safety_identifier": 42}, "responses") is None
|
||||
@@ -0,0 +1,21 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) Microsoft Corporation.
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE
|
||||
@@ -0,0 +1,122 @@
|
||||
# agent-framework-hosting
|
||||
|
||||
Multi-channel hosting for Microsoft Agent Framework agents.
|
||||
|
||||
`agent-framework-hosting` lets you serve a single agent or workflow target
|
||||
through one or more **channels**. The host owns one Starlette ASGI app,
|
||||
route/lifecycle composition, and per-`isolation_key` session resolution.
|
||||
Each channel owns its protocol parsing and response rendering.
|
||||
|
||||
The base package contains only channel-neutral plumbing:
|
||||
|
||||
- `AgentFrameworkHost` — the Starlette host.
|
||||
- `Channel` — the channel protocol.
|
||||
- `ChannelRequest` / `ChannelSession` / `ChannelIdentity` — the request
|
||||
envelope and optional channel metadata.
|
||||
- `ChannelContext` / `ChannelContribution` / `ChannelCommand` — channel-side
|
||||
hooks for invoking the target and contributing routes, commands, and
|
||||
lifecycle callbacks.
|
||||
- `ChannelRunHook` / `ChannelResponseHook` / `ChannelStreamUpdateHook` —
|
||||
host-invoked customization seams.
|
||||
|
||||
`ChannelStreamUpdateHook` applies to streamed updates only. It is not a
|
||||
substitute for final-response redaction.
|
||||
|
||||
Concrete channels live in their own packages so you only install what you use:
|
||||
|
||||
| Package | Transport |
|
||||
|---|---|
|
||||
| `agent-framework-hosting-responses` | OpenAI Responses API |
|
||||
|
||||
Additional channel packages can build on the same host contract without adding
|
||||
their protocol dependencies to the base package.
|
||||
|
||||
## Install
|
||||
|
||||
```bash
|
||||
pip install agent-framework-hosting agent-framework-hosting-responses
|
||||
# or with Hypercorn pre-installed for the demo `host.serve(...)` helper
|
||||
pip install "agent-framework-hosting[serve]" agent-framework-hosting-responses
|
||||
# add the [disk] extra to persist reset-session aliases
|
||||
pip install "agent-framework-hosting[disk]"
|
||||
```
|
||||
|
||||
## Quickstart
|
||||
|
||||
```python
|
||||
from agent_framework.openai import OpenAIChatClient
|
||||
from agent_framework_hosting import AgentFrameworkHost, Channel
|
||||
|
||||
agent = OpenAIChatClient().as_agent(name="Assistant")
|
||||
|
||||
# Add channels from sibling packages, e.g. `agent-framework-hosting-responses`
|
||||
# exposes a `ResponsesChannel` that serves the OpenAI Responses API.
|
||||
channels: list[Channel] = []
|
||||
|
||||
host = AgentFrameworkHost(target=agent, channels=channels)
|
||||
host.serve(port=8000)
|
||||
```
|
||||
|
||||
## Session state and workflow checkpoints
|
||||
|
||||
By default the host keeps live `AgentSession` objects and reset-session aliases
|
||||
in memory. Channels opt into continuity by setting
|
||||
`ChannelRequest.session = ChannelSession(isolation_key=...)`; requests with the
|
||||
same isolation key reuse the same host-created session.
|
||||
|
||||
The host treats `isolation_key` as an opaque partition key. Each channel or
|
||||
hosting environment decides where that key comes from:
|
||||
|
||||
- protocol headers supplied by a trusted platform,
|
||||
- request body fields such as a previous response or conversation ID,
|
||||
- route/path parameters,
|
||||
- channel-native metadata such as chat/user IDs, or
|
||||
- environment-provided context in an ephemeral host.
|
||||
|
||||
The host should be able to carry any of those sources as long as the channel or
|
||||
platform has already authenticated and authorized the caller before passing the
|
||||
key to `ChannelSession`.
|
||||
|
||||
The built-in request-context helper recognizes the `x-agent-user-isolation-key`
|
||||
and `x-agent-chat-isolation-key` header names because some hosting
|
||||
environments, including Foundry Hosted Agents, already use them. Reusing those
|
||||
header names does **not** mean `agent-framework-hosting` is the supported way to
|
||||
run on Foundry Hosted Agents; use `agent-framework-foundry-hosting` for that
|
||||
hosting surface.
|
||||
|
||||
For long-running deployments that need `reset_session(...)` aliases to survive
|
||||
restart, pass `state_dir`:
|
||||
|
||||
```python
|
||||
host = AgentFrameworkHost(
|
||||
target=agent,
|
||||
channels=channels,
|
||||
state_dir="./.host-state",
|
||||
)
|
||||
```
|
||||
|
||||
This creates `./.host-state/sessions/` and stores only lightweight alias
|
||||
bookkeeping. Live `AgentSession` objects are still rehydrated lazily by the
|
||||
configured history provider on the next turn.
|
||||
|
||||
For workflow targets, `checkpoint_location=...` is the clearest way to enable
|
||||
checkpoint persistence. As a convenience, `state_dir="./.host-state"` also
|
||||
derives `./.host-state/checkpoints/` for workflow targets. Use the mapping form
|
||||
when you want only one component:
|
||||
|
||||
```python
|
||||
from agent_framework_hosting import HostStatePaths
|
||||
|
||||
host = AgentFrameworkHost(
|
||||
target=workflow,
|
||||
channels=channels,
|
||||
state_dir=HostStatePaths(
|
||||
sessions="/var/lib/myapp/sessions",
|
||||
checkpoints="/var/lib/myapp/checkpoints",
|
||||
),
|
||||
)
|
||||
```
|
||||
|
||||
Cross-channel identity linking, multicast delivery, background runs,
|
||||
continuation tokens, and durable delivery runners are follow-up enhancements,
|
||||
not part of this v1 host contract.
|
||||
@@ -0,0 +1,66 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
"""Multi-channel hosting for Microsoft Agent Framework agents.
|
||||
|
||||
Serve a single agent target through one or more **channels** — pluggable
|
||||
adapters that expose the target over different transports. The base
|
||||
package contains only the channel-neutral plumbing; concrete channels
|
||||
ship in their own packages, such as ``agent-framework-hosting-responses``,
|
||||
so users install only what they need.
|
||||
"""
|
||||
|
||||
import importlib.metadata
|
||||
|
||||
from ._host import AgentFrameworkHost, ChannelContext, logger
|
||||
from ._isolation import (
|
||||
ISOLATION_HEADER_CHAT,
|
||||
ISOLATION_HEADER_USER,
|
||||
IsolationKeys,
|
||||
get_current_isolation_keys,
|
||||
reset_current_isolation_keys,
|
||||
set_current_isolation_keys,
|
||||
)
|
||||
from ._types import (
|
||||
Channel,
|
||||
ChannelCommand,
|
||||
ChannelCommandContext,
|
||||
ChannelContribution,
|
||||
ChannelIdentity,
|
||||
ChannelRequest,
|
||||
ChannelResponseHook,
|
||||
ChannelRunHook,
|
||||
ChannelSession,
|
||||
ChannelStreamUpdateHook,
|
||||
HostedRunResult,
|
||||
HostStatePaths,
|
||||
)
|
||||
|
||||
try:
|
||||
__version__ = importlib.metadata.version(__name__)
|
||||
except importlib.metadata.PackageNotFoundError:
|
||||
__version__ = "0.0.0"
|
||||
|
||||
__all__ = [
|
||||
"ISOLATION_HEADER_CHAT",
|
||||
"ISOLATION_HEADER_USER",
|
||||
"AgentFrameworkHost",
|
||||
"Channel",
|
||||
"ChannelCommand",
|
||||
"ChannelCommandContext",
|
||||
"ChannelContext",
|
||||
"ChannelContribution",
|
||||
"ChannelIdentity",
|
||||
"ChannelRequest",
|
||||
"ChannelResponseHook",
|
||||
"ChannelRunHook",
|
||||
"ChannelSession",
|
||||
"ChannelStreamUpdateHook",
|
||||
"HostStatePaths",
|
||||
"HostedRunResult",
|
||||
"IsolationKeys",
|
||||
"__version__",
|
||||
"get_current_isolation_keys",
|
||||
"logger",
|
||||
"reset_current_isolation_keys",
|
||||
"set_current_isolation_keys",
|
||||
]
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,82 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
"""Per-request isolation keys for host/platform-provided request context.
|
||||
|
||||
``ChannelSession.isolation_key`` is the host's generic session partition key,
|
||||
but different channels and platforms discover that key from different places:
|
||||
protocol headers, request bodies, URL/path segments, webhook metadata, or
|
||||
environment-provided context for ephemeral hosts.
|
||||
|
||||
This module covers the request-context case where a platform provides
|
||||
isolation outside the channel payload. The Foundry Hosted Agents runtime, for
|
||||
example, injects two well-known headers on requests it forwards to the user's
|
||||
container:
|
||||
|
||||
* ``x-agent-user-isolation-key`` — opaque per-user partition key
|
||||
* ``x-agent-chat-isolation-key`` — opaque per-conversation partition key
|
||||
|
||||
The generic host intentionally reuses those header names so the same isolation
|
||||
context can be consumed by supporting providers. Reusing the names does **not**
|
||||
mean ``agent-framework-hosting`` is a supported way to run on Foundry Hosted
|
||||
Agents; use ``agent-framework-foundry-hosting`` for that hosting surface.
|
||||
|
||||
When those headers are present the host-installed ASGI middleware pushes them
|
||||
into :data:`current_isolation_keys` for the duration of the request, then
|
||||
resets it. Channels may still choose a different session key source and pass it
|
||||
directly via ``ChannelSession(isolation_key=...)``.
|
||||
|
||||
The contextvar holds a plain :class:`IsolationKeys` mapping; conversion to
|
||||
provider-specific types happens at the consuming provider so this module has no
|
||||
provider dependencies.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from contextvars import ContextVar, Token
|
||||
|
||||
__all__ = [
|
||||
"ISOLATION_HEADER_CHAT",
|
||||
"ISOLATION_HEADER_USER",
|
||||
"IsolationKeys",
|
||||
"current_isolation_keys",
|
||||
"get_current_isolation_keys",
|
||||
"reset_current_isolation_keys",
|
||||
"set_current_isolation_keys",
|
||||
]
|
||||
|
||||
|
||||
ISOLATION_HEADER_USER = "x-agent-user-isolation-key"
|
||||
ISOLATION_HEADER_CHAT = "x-agent-chat-isolation-key"
|
||||
|
||||
|
||||
class IsolationKeys:
|
||||
"""Per-request isolation keys lifted from host/platform context."""
|
||||
|
||||
def __init__(self, user_key: str | None = None, chat_key: str | None = None) -> None:
|
||||
self.user_key = user_key
|
||||
self.chat_key = chat_key
|
||||
|
||||
@property
|
||||
def is_empty(self) -> bool:
|
||||
return self.user_key is None and self.chat_key is None
|
||||
|
||||
|
||||
current_isolation_keys: ContextVar[IsolationKeys | None] = ContextVar(
|
||||
"agent_framework_hosting_isolation_keys",
|
||||
default=None,
|
||||
)
|
||||
|
||||
|
||||
def get_current_isolation_keys() -> IsolationKeys | None:
|
||||
"""Return the isolation keys bound to the current request, if any."""
|
||||
return current_isolation_keys.get()
|
||||
|
||||
|
||||
def set_current_isolation_keys(keys: IsolationKeys | None) -> Token[IsolationKeys | None]:
|
||||
"""Bind ``keys`` to the current async context and return a reset token."""
|
||||
return current_isolation_keys.set(keys)
|
||||
|
||||
|
||||
def reset_current_isolation_keys(token: Token[IsolationKeys | None]) -> None:
|
||||
"""Restore the isolation contextvar to its prior value."""
|
||||
current_isolation_keys.reset(token)
|
||||
@@ -0,0 +1,128 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
"""Shared persistence primitives for the hosting package.
|
||||
|
||||
The simplified hosting core keeps disk persistence only for session aliases
|
||||
created by :meth:`AgentFrameworkHost.reset_session` and for workflow
|
||||
checkpoint path derivation. The on-disk session-alias store uses the optional
|
||||
``diskcache`` package installed via the ``[disk]`` extra.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import contextlib
|
||||
import importlib
|
||||
import os
|
||||
import sys
|
||||
from collections.abc import Mapping
|
||||
from pathlib import Path
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from ._types import HostStatePaths
|
||||
|
||||
_KNOWN_COMPONENTS: tuple[str, ...] = ("sessions", "checkpoints")
|
||||
|
||||
|
||||
def load_diskcache() -> Any:
|
||||
"""Lazy-import :mod:`diskcache` with a helpful error when missing."""
|
||||
try:
|
||||
return importlib.import_module("diskcache")
|
||||
except ImportError as exc: # pragma: no cover - exercised via tests by monkeypatching
|
||||
raise ImportError(
|
||||
"agent-framework-hosting was asked to persist session aliases to disk "
|
||||
"(state_dir['sessions'] is set) but the optional `diskcache` dependency "
|
||||
"is not installed. Install the disk extra: "
|
||||
"`pip install 'agent-framework-hosting[disk]`."
|
||||
) from exc
|
||||
|
||||
|
||||
def acquire_state_dir_lock(component_dir: Path) -> Any:
|
||||
"""Acquire an exclusive single-owner lock on a component's state dir.
|
||||
|
||||
Raises:
|
||||
RuntimeError: If another process already holds the lock.
|
||||
"""
|
||||
component_dir.mkdir(parents=True, exist_ok=True)
|
||||
lock_path = component_dir / ".lock"
|
||||
fh = open(lock_path, "a+", encoding="utf-8") # noqa: SIM115 - kept open for lifetime
|
||||
try:
|
||||
if sys.platform == "win32":
|
||||
import msvcrt
|
||||
|
||||
try:
|
||||
msvcrt.locking(fh.fileno(), msvcrt.LK_NBLCK, 1)
|
||||
except OSError as exc:
|
||||
fh.close()
|
||||
raise RuntimeError(
|
||||
f"Another process already holds the hosting state lock at {lock_path}. "
|
||||
"Point each host at its own state_dir."
|
||||
) from exc
|
||||
else:
|
||||
import fcntl
|
||||
|
||||
try:
|
||||
fcntl.flock(fh.fileno(), fcntl.LOCK_EX | fcntl.LOCK_NB)
|
||||
except OSError as exc:
|
||||
fh.close()
|
||||
raise RuntimeError(
|
||||
f"Another process already holds the hosting state lock at {lock_path}. "
|
||||
"Point each host at its own state_dir."
|
||||
) from exc
|
||||
except RuntimeError:
|
||||
raise
|
||||
except Exception:
|
||||
fh.close()
|
||||
raise
|
||||
return fh
|
||||
|
||||
|
||||
def release_state_dir_lock(handle: Any) -> None:
|
||||
"""Release a lock previously acquired by :func:`acquire_state_dir_lock`."""
|
||||
if handle is None:
|
||||
return
|
||||
with contextlib.suppress(Exception):
|
||||
handle.close()
|
||||
|
||||
|
||||
def normalize_state_dir(
|
||||
state_dir: str | os.PathLike[str] | HostStatePaths | Mapping[str, str | os.PathLike[str]] | None,
|
||||
) -> dict[str, Path | None]:
|
||||
"""Resolve the host-level ``state_dir`` parameter into a per-component map.
|
||||
|
||||
Accepts ``None``, a single root path, or a mapping with ``sessions`` and
|
||||
``checkpoints`` keys. Unknown keys raise ``ValueError`` so obsolete
|
||||
``runner`` / ``links`` configuration is rejected instead of silently
|
||||
doing nothing.
|
||||
"""
|
||||
result: dict[str, Path | None] = {name: None for name in _KNOWN_COMPONENTS}
|
||||
if state_dir is None:
|
||||
return result
|
||||
|
||||
if isinstance(state_dir, (str, os.PathLike)):
|
||||
root = Path(os.fspath(state_dir))
|
||||
for name in _KNOWN_COMPONENTS:
|
||||
result[name] = root / name
|
||||
return result
|
||||
|
||||
if isinstance(state_dir, Mapping):
|
||||
unknown = [k for k in state_dir if k not in _KNOWN_COMPONENTS]
|
||||
if unknown:
|
||||
raise ValueError(
|
||||
f"state_dir mapping contains unknown component key(s): {unknown!r}. "
|
||||
f"Known components are: {list(_KNOWN_COMPONENTS)!r}."
|
||||
)
|
||||
for name in _KNOWN_COMPONENTS:
|
||||
raw_value: Any = state_dir.get(name)
|
||||
if raw_value is None:
|
||||
result[name] = None
|
||||
continue
|
||||
if isinstance(raw_value, (str, os.PathLike)):
|
||||
result[name] = Path(os.fspath(raw_value))
|
||||
else:
|
||||
raise TypeError(f"state_dir[{name!r}] must be a str or PathLike — got {type(raw_value).__name__}")
|
||||
return result
|
||||
|
||||
raise TypeError(
|
||||
f"state_dir must be a str, PathLike, HostStatePaths mapping, or None — got {type(state_dir).__name__}"
|
||||
)
|
||||
@@ -0,0 +1,146 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
"""Disk-backed wrapper for the host's session-alias map.
|
||||
|
||||
``AgentFrameworkHost.reset_session(isolation_key)`` rotates future requests for
|
||||
that isolation key onto a new session id. Persisting the alias map lets that
|
||||
rotation survive a host restart without introducing cross-channel identity or
|
||||
delivery state into the core host.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import os
|
||||
from collections.abc import Mapping
|
||||
from pathlib import Path
|
||||
from typing import Any, TypeVar
|
||||
|
||||
from ._persistence import (
|
||||
acquire_state_dir_lock,
|
||||
load_diskcache,
|
||||
release_state_dir_lock,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_V = TypeVar("_V")
|
||||
_ALIASES_PREFIX = "aliases:"
|
||||
|
||||
|
||||
class SessionsStateStore:
|
||||
"""One disk cache + lock for host-side session aliases."""
|
||||
|
||||
def __init__(self, sessions_dir: str | os.PathLike[str]) -> None:
|
||||
self._sessions_dir: Path = Path(os.fspath(sessions_dir))
|
||||
diskcache = load_diskcache()
|
||||
self._lock_handle: Any = acquire_state_dir_lock(self._sessions_dir)
|
||||
try:
|
||||
self._cache: Any = diskcache.Cache(str(self._sessions_dir))
|
||||
except Exception:
|
||||
release_state_dir_lock(self._lock_handle)
|
||||
self._lock_handle = None
|
||||
raise
|
||||
|
||||
@property
|
||||
def cache(self) -> Any:
|
||||
"""Return the underlying :mod:`diskcache` Cache."""
|
||||
return self._cache
|
||||
|
||||
def close(self) -> None:
|
||||
"""Close the cache and release the directory lock."""
|
||||
if self._cache is not None:
|
||||
try:
|
||||
self._cache.close()
|
||||
except Exception: # pragma: no cover - close errors aren't actionable
|
||||
logger.exception("SessionsStateStore: failed to close cache cleanly")
|
||||
self._cache = None
|
||||
if self._lock_handle is not None:
|
||||
release_state_dir_lock(self._lock_handle)
|
||||
self._lock_handle = None
|
||||
|
||||
|
||||
class _PersistedDict(dict[str, _V]):
|
||||
"""Drop-in :class:`dict` whose mutations mirror to a diskcache prefix."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
store: SessionsStateStore,
|
||||
key_prefix: str,
|
||||
initial: Mapping[str, _V] | None = None,
|
||||
) -> None:
|
||||
super().__init__()
|
||||
self._store = store
|
||||
self._prefix = key_prefix
|
||||
cache: Any = store.cache
|
||||
for raw_key in cache.iterkeys():
|
||||
if not isinstance(raw_key, str) or not raw_key.startswith(key_prefix):
|
||||
continue
|
||||
try:
|
||||
value: Any = cache.get(raw_key)
|
||||
except Exception:
|
||||
logger.exception("SessionsStateStore: failed to rehydrate %s; skipping", raw_key)
|
||||
continue
|
||||
logical_key = raw_key[len(key_prefix) :]
|
||||
super().__setitem__(logical_key, value)
|
||||
if initial:
|
||||
for key, value in initial.items():
|
||||
self[key] = value
|
||||
|
||||
def __setitem__(self, key: str, value: _V) -> None:
|
||||
super().__setitem__(key, value)
|
||||
try:
|
||||
self._store.cache.set(self._prefix + key, value)
|
||||
except Exception: # pragma: no cover - cache write failures aren't actionable
|
||||
logger.exception("SessionsStateStore: failed to persist %s%s", self._prefix, key)
|
||||
|
||||
def __delitem__(self, key: str) -> None:
|
||||
super().__delitem__(key)
|
||||
try:
|
||||
del self._store.cache[self._prefix + key]
|
||||
except KeyError:
|
||||
pass
|
||||
except Exception: # pragma: no cover - cache write failures aren't actionable
|
||||
logger.exception("SessionsStateStore: failed to evict %s%s", self._prefix, key)
|
||||
|
||||
def pop(self, key: str, *args: Any) -> _V:
|
||||
"""Mirror ``dict.pop`` to disk."""
|
||||
value: _V = super().pop(key, *args)
|
||||
try:
|
||||
del self._store.cache[self._prefix + key]
|
||||
except KeyError:
|
||||
pass
|
||||
except Exception: # pragma: no cover
|
||||
logger.exception("SessionsStateStore: failed to evict %s%s", self._prefix, key)
|
||||
return value
|
||||
|
||||
def clear(self) -> None:
|
||||
"""Mirror ``dict.clear`` to disk."""
|
||||
keys = list(self.keys())
|
||||
super().clear()
|
||||
cache = self._store.cache
|
||||
for key in keys:
|
||||
try:
|
||||
del cache[self._prefix + key]
|
||||
except KeyError:
|
||||
pass
|
||||
except Exception: # pragma: no cover
|
||||
logger.exception("SessionsStateStore: failed to evict %s%s during clear", self._prefix, key)
|
||||
|
||||
def update( # type: ignore[override]
|
||||
self,
|
||||
other: Mapping[str, _V] | None = None,
|
||||
/,
|
||||
**kwargs: _V,
|
||||
) -> None:
|
||||
"""Mirror ``dict.update`` to disk one item at a time."""
|
||||
if other is not None:
|
||||
for key in other:
|
||||
self[key] = other[key]
|
||||
for key, value in kwargs.items():
|
||||
self[key] = value
|
||||
|
||||
|
||||
def build_session_aliases(store: SessionsStateStore) -> dict[str, str]:
|
||||
"""Return the disk-backed session-alias map for ``store``."""
|
||||
return _PersistedDict[str](store, _ALIASES_PREFIX)
|
||||
@@ -0,0 +1,212 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
# ``ChannelRequest`` is the only intentional dataclass here (callers use
|
||||
# ``dataclasses.replace`` on it in run hooks). The other types are plain
|
||||
# Python classes by preference, so the "could be a dataclass" lint is muted
|
||||
# at the file level.
|
||||
# ruff: noqa: B903
|
||||
|
||||
"""Channel-neutral request envelope and channel protocol types.
|
||||
|
||||
These types form the boundary between the host and individual channels.
|
||||
A channel parses its native payload, builds a :class:`ChannelRequest`, and
|
||||
hands it to :class:`ChannelContext.run` (or ``run_stream``) on the host.
|
||||
The channel owns rendering the result back onto its originating protocol.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from collections.abc import Awaitable, Callable, Mapping, Sequence
|
||||
from dataclasses import dataclass, field
|
||||
from typing import TYPE_CHECKING, Any, Generic, Protocol, TypedDict, TypeVar, runtime_checkable
|
||||
|
||||
from agent_framework import (
|
||||
AgentResponseUpdate,
|
||||
AgentRunInputs,
|
||||
)
|
||||
from starlette.routing import BaseRoute
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from ._host import ChannelContext
|
||||
|
||||
|
||||
class ChannelSession:
|
||||
"""Channel-supplied session hint.
|
||||
|
||||
The host turns this into an ``AgentSession`` keyed by ``isolation_key`` so
|
||||
every distinct end user gets their own context-provider state (e.g. one
|
||||
``FileHistoryProvider`` JSONL file per user).
|
||||
"""
|
||||
|
||||
def __init__(self, isolation_key: str | None = None) -> None:
|
||||
self.isolation_key = isolation_key
|
||||
|
||||
|
||||
class ChannelIdentity:
|
||||
"""Channel-native identity metadata observed on a request.
|
||||
|
||||
The simplified hosting core records this only on the persisted input
|
||||
message's ``additional_properties["hosting"]`` block and forwards it
|
||||
through run/response hooks. Cross-channel linking and recipient lookup are
|
||||
follow-up concerns, not part of the v1 host contract.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
channel: str,
|
||||
native_id: str,
|
||||
attributes: Mapping[str, Any] | None = None,
|
||||
) -> None:
|
||||
self.channel = channel
|
||||
self.native_id = native_id
|
||||
self.attributes: Mapping[str, Any] = attributes if attributes is not None else dict()
|
||||
|
||||
|
||||
@dataclass
|
||||
class ChannelRequest:
|
||||
"""Uniform invocation envelope every channel produces from its native payload.
|
||||
|
||||
Kept as a dataclass so app authors can use ``dataclasses.replace(...)`` in
|
||||
run hooks to produce a modified envelope without re-listing every field.
|
||||
"""
|
||||
|
||||
channel: str
|
||||
operation: str
|
||||
input: AgentRunInputs
|
||||
session: ChannelSession | None = None
|
||||
options: Mapping[str, Any] | None = None
|
||||
session_mode: str = "auto"
|
||||
metadata: Mapping[str, Any] = field(default_factory=lambda: {})
|
||||
attributes: Mapping[str, Any] = field(default_factory=lambda: {})
|
||||
stream: bool = False
|
||||
identity: ChannelIdentity | None = None
|
||||
|
||||
|
||||
class ChannelCommand:
|
||||
"""A discoverable command a channel exposes to its users (e.g. ``/reset``)."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
name: str,
|
||||
description: str,
|
||||
handle: Callable[[ChannelCommandContext], Awaitable[None]],
|
||||
) -> None:
|
||||
self.name = name
|
||||
self.description = description
|
||||
self.handle = handle
|
||||
|
||||
|
||||
class ChannelCommandContext:
|
||||
"""Context passed to a :class:`ChannelCommand` handler."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
request: ChannelRequest,
|
||||
reply: Callable[[str], Awaitable[None]],
|
||||
) -> None:
|
||||
self.request = request
|
||||
self.reply = reply
|
||||
|
||||
|
||||
_EMPTY_ROUTES: tuple[BaseRoute, ...] = ()
|
||||
_EMPTY_COMMANDS: tuple[ChannelCommand, ...] = ()
|
||||
_EMPTY_LIFECYCLE: tuple[Callable[[], Awaitable[None]], ...] = ()
|
||||
|
||||
|
||||
class ChannelContribution:
|
||||
"""Routes, commands, and lifecycle hooks a channel contributes to the host."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
routes: Sequence[BaseRoute] = _EMPTY_ROUTES,
|
||||
commands: Sequence[ChannelCommand] = _EMPTY_COMMANDS,
|
||||
on_startup: Sequence[Callable[[], Awaitable[None]]] = _EMPTY_LIFECYCLE,
|
||||
on_shutdown: Sequence[Callable[[], Awaitable[None]]] = _EMPTY_LIFECYCLE,
|
||||
) -> None:
|
||||
self.routes = routes
|
||||
self.commands = commands
|
||||
self.on_startup = on_startup
|
||||
self.on_shutdown = on_shutdown
|
||||
|
||||
|
||||
class _Unset:
|
||||
"""Sentinel for ``HostedRunResult.replace`` overrides.
|
||||
|
||||
Distinguishes "caller did not pass this kwarg" from "caller passed
|
||||
``None`` explicitly" — needed because ``session`` is ``None`` in
|
||||
many envelopes and we want the no-arg call to preserve it.
|
||||
"""
|
||||
|
||||
|
||||
_UNSET = _Unset()
|
||||
|
||||
|
||||
TResult = TypeVar("TResult")
|
||||
|
||||
|
||||
class HostedRunResult(Generic[TResult]):
|
||||
"""Channel-neutral envelope around the target's full-fidelity result.
|
||||
|
||||
The host does not flatten or pre-shape the target output. Channels and
|
||||
response hooks read the underlying result type directly and serialize the
|
||||
subset their wire format can carry.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
result: TResult,
|
||||
*,
|
||||
session: Any | None = None,
|
||||
) -> None:
|
||||
self.result = result
|
||||
self.session = session
|
||||
|
||||
def replace(
|
||||
self,
|
||||
*,
|
||||
result: TResult | _Unset = _UNSET,
|
||||
session: Any | _Unset | None = _UNSET,
|
||||
) -> HostedRunResult[TResult]:
|
||||
"""Return a shallow copy with the supplied fields overridden."""
|
||||
new: HostedRunResult[TResult] = HostedRunResult.__new__(HostedRunResult) # pyright: ignore[reportUnknownVariableType]
|
||||
new.result = self.result if isinstance(result, _Unset) else result
|
||||
new.session = self.session if isinstance(session, _Unset) else session
|
||||
return new
|
||||
|
||||
|
||||
class HostStatePaths(TypedDict, total=False):
|
||||
"""Per-component disk paths for host-managed state.
|
||||
|
||||
Only session aliases and workflow checkpoints remain in the simplified
|
||||
host. Linking stores, active-channel maps, identity registries, and runner
|
||||
queues are follow-up concerns.
|
||||
"""
|
||||
|
||||
sessions: str | os.PathLike[str]
|
||||
"""Where the host persists session aliases created by ``reset_session``."""
|
||||
|
||||
checkpoints: str | os.PathLike[str]
|
||||
"""Where the host persists workflow checkpoints for ``Workflow`` targets."""
|
||||
|
||||
|
||||
ChannelStreamUpdateHook = Callable[
|
||||
[AgentResponseUpdate],
|
||||
"AgentResponseUpdate | Awaitable[AgentResponseUpdate | None] | None",
|
||||
]
|
||||
|
||||
|
||||
ChannelRunHook = Callable[..., "Awaitable[ChannelRequest] | ChannelRequest"]
|
||||
|
||||
|
||||
ChannelResponseHook = Callable[..., "Awaitable[HostedRunResult[Any]] | HostedRunResult[Any]"]
|
||||
|
||||
|
||||
@runtime_checkable
|
||||
class Channel(Protocol):
|
||||
"""A pluggable adapter that exposes one transport on the host."""
|
||||
|
||||
name: str
|
||||
path: str
|
||||
|
||||
def contribute(self, context: ChannelContext) -> ChannelContribution: ...
|
||||
@@ -0,0 +1,92 @@
|
||||
[project]
|
||||
name = "agent-framework-hosting"
|
||||
description = "Multi-channel hosting for Microsoft Agent Framework agents."
|
||||
authors = [{ name = "Microsoft", email = "af-support@microsoft.com"}]
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.10"
|
||||
version = "1.0.0a260424"
|
||||
license-files = ["LICENSE"]
|
||||
urls.homepage = "https://aka.ms/agent-framework"
|
||||
urls.source = "https://github.com/microsoft/agent-framework/tree/main/python"
|
||||
urls.release_notes = "https://github.com/microsoft/agent-framework/releases?q=tag%3Apython-1&expanded=true"
|
||||
urls.issues = "https://github.com/microsoft/agent-framework/issues"
|
||||
classifiers = [
|
||||
"License :: OSI Approved :: MIT License",
|
||||
"Development Status :: 3 - Alpha",
|
||||
"Intended Audience :: Developers",
|
||||
"Programming Language :: Python :: 3",
|
||||
"Programming Language :: Python :: 3.10",
|
||||
"Programming Language :: Python :: 3.11",
|
||||
"Programming Language :: Python :: 3.12",
|
||||
"Programming Language :: Python :: 3.13",
|
||||
"Programming Language :: Python :: 3.14",
|
||||
"Typing :: Typed",
|
||||
]
|
||||
dependencies = [
|
||||
"agent-framework-core>=1.2.0,<2",
|
||||
"starlette>=0.37",
|
||||
]
|
||||
|
||||
[project.optional-dependencies]
|
||||
serve = [
|
||||
"hypercorn>=0.17",
|
||||
]
|
||||
disk = [
|
||||
"diskcache>=5.6",
|
||||
]
|
||||
|
||||
[tool.uv]
|
||||
prerelease = "if-necessary-or-explicit"
|
||||
environments = [
|
||||
"sys_platform == 'darwin'",
|
||||
"sys_platform == 'linux'",
|
||||
"sys_platform == 'win32'"
|
||||
]
|
||||
|
||||
[tool.uv-dynamic-versioning]
|
||||
fallback-version = "0.0.0"
|
||||
|
||||
[tool.pytest.ini_options]
|
||||
testpaths = 'tests'
|
||||
addopts = "-ra -q -r fEX"
|
||||
asyncio_mode = "auto"
|
||||
asyncio_default_fixture_loop_scope = "function"
|
||||
filterwarnings = []
|
||||
timeout = 120
|
||||
markers = [
|
||||
"integration: marks tests as integration tests that require external services",
|
||||
]
|
||||
|
||||
[tool.ruff]
|
||||
extend = "../../pyproject.toml"
|
||||
|
||||
[tool.coverage.run]
|
||||
omit = [
|
||||
"**/__init__.py"
|
||||
]
|
||||
|
||||
[tool.pyright]
|
||||
extends = "../../pyproject.toml"
|
||||
include = ["agent_framework_hosting"]
|
||||
exclude = ['tests']
|
||||
|
||||
[tool.bandit]
|
||||
targets = ["agent_framework_hosting"]
|
||||
exclude_dirs = ["tests"]
|
||||
|
||||
[tool.poe]
|
||||
executor.type = "uv"
|
||||
include = "../../shared_tasks.toml"
|
||||
|
||||
[tool.poe.tasks.test]
|
||||
help = "Run the default unit test suite for this package."
|
||||
cmd = 'pytest -m "not integration" --cov=agent_framework_hosting --cov-report=term-missing:skip-covered tests'
|
||||
|
||||
[build-system]
|
||||
requires = ["flit-core >= 3.11,<4.0"]
|
||||
build-backend = "flit_core.buildapi"
|
||||
|
||||
[dependency-groups]
|
||||
dev = [
|
||||
"httpx>=0.28.1",
|
||||
]
|
||||
@@ -0,0 +1,45 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
"""Workflow fixtures for hosting tests.
|
||||
|
||||
Defined in a module that does not use ``from __future__ import annotations``
|
||||
because the workflow handler validation reflects on real annotation objects
|
||||
rather than stringified forms.
|
||||
"""
|
||||
|
||||
from typing import Any
|
||||
|
||||
from agent_framework import Executor, Workflow, WorkflowBuilder, WorkflowContext, handler
|
||||
|
||||
|
||||
class _UpperExecutor(Executor):
|
||||
@handler
|
||||
async def handle(self, text: str, ctx: WorkflowContext[Any, str]) -> None:
|
||||
await ctx.yield_output(text.upper())
|
||||
|
||||
|
||||
class _EchoExecutor(Executor):
|
||||
@handler
|
||||
async def handle(self, text: str, ctx: WorkflowContext[Any, str]) -> None:
|
||||
await ctx.yield_output(text)
|
||||
|
||||
|
||||
def build_upper_workflow() -> Workflow:
|
||||
return WorkflowBuilder(start_executor=_UpperExecutor(id="upper")).build()
|
||||
|
||||
|
||||
def build_echo_workflow() -> Workflow:
|
||||
return WorkflowBuilder(start_executor=_EchoExecutor(id="echo")).build()
|
||||
|
||||
|
||||
class _MultiChunkExecutor(Executor):
|
||||
"""Yields three separate ``output`` events so streaming has something to chew on."""
|
||||
|
||||
@handler
|
||||
async def handle(self, text: str, ctx: WorkflowContext[Any, str]) -> None:
|
||||
for chunk in (f"{text}-1", f"{text}-2", f"{text}-3"):
|
||||
await ctx.yield_output(chunk)
|
||||
|
||||
|
||||
def build_multi_chunk_workflow() -> Workflow:
|
||||
return WorkflowBuilder(start_executor=_MultiChunkExecutor(id="multi")).build()
|
||||
@@ -0,0 +1,25 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
"""Pytest configuration for hosting tests."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import importlib.util
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def pytest_configure() -> None:
|
||||
"""Make workflow fixtures importable in package-local and aggregate test modes."""
|
||||
module_name = "hosting_workflow_fixtures"
|
||||
if module_name in sys.modules:
|
||||
return
|
||||
|
||||
fixture_path = Path(__file__).with_name("_workflow_fixtures.py")
|
||||
spec = importlib.util.spec_from_file_location(module_name, fixture_path)
|
||||
if spec is None or spec.loader is None:
|
||||
raise ImportError(f"Unable to load workflow fixtures from {fixture_path}")
|
||||
|
||||
module = importlib.util.module_from_spec(spec)
|
||||
sys.modules[module_name] = module
|
||||
spec.loader.exec_module(module)
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,239 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
"""Tests for narrowed ``state_dir`` support in :class:`AgentFrameworkHost`."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import importlib
|
||||
from pathlib import Path
|
||||
from typing import Any, cast
|
||||
|
||||
import pytest
|
||||
from agent_framework import AgentSession
|
||||
|
||||
from agent_framework_hosting import AgentFrameworkHost, ChannelContext, ChannelContribution
|
||||
|
||||
pytest.importorskip("diskcache")
|
||||
|
||||
|
||||
class _AgentStub:
|
||||
"""Bare-minimum SupportsAgentRun stub for host construction."""
|
||||
|
||||
id = "agent-stub"
|
||||
name: str | None = "Agent Stub"
|
||||
description: str | None = "Test agent stub"
|
||||
|
||||
def create_session(self, *, session_id: str | None = None) -> AgentSession:
|
||||
return AgentSession(session_id=session_id)
|
||||
|
||||
def get_session(self, service_session_id: str, *, session_id: str | None = None) -> AgentSession:
|
||||
return AgentSession(service_session_id=service_session_id, session_id=session_id)
|
||||
|
||||
def run(self, *_args: Any, **_kwargs: Any) -> Any: # pragma: no cover - unused
|
||||
raise RuntimeError("not invoked")
|
||||
|
||||
|
||||
class _ChannelStub:
|
||||
name = "stub"
|
||||
path = "/stub"
|
||||
|
||||
def contribute(self, context: ChannelContext) -> ChannelContribution:
|
||||
del context
|
||||
return ChannelContribution()
|
||||
|
||||
|
||||
def _close_host_disk(host: AgentFrameworkHost) -> None:
|
||||
"""Release any session-alias store held by ``host``."""
|
||||
if host._sessions_store is not None:
|
||||
host._sessions_store.close()
|
||||
|
||||
|
||||
def test_state_dir_none_keeps_plain_alias_dict(tmp_path: Path) -> None:
|
||||
"""No store, no alias persistence, no files written."""
|
||||
host = AgentFrameworkHost(target=_AgentStub(), channels=[_ChannelStub()])
|
||||
assert host._sessions_store is None
|
||||
assert isinstance(host._session_aliases, dict)
|
||||
assert list(tmp_path.iterdir()) == []
|
||||
|
||||
|
||||
def test_string_state_dir_creates_sessions_subfolder_only(tmp_path: Path) -> None:
|
||||
"""Passing a single path expands to ``sessions/`` plus lazy checkpoint path."""
|
||||
host = AgentFrameworkHost(
|
||||
target=_AgentStub(),
|
||||
channels=[_ChannelStub()],
|
||||
state_dir=tmp_path,
|
||||
)
|
||||
try:
|
||||
assert host._sessions_store is not None
|
||||
assert (tmp_path / "sessions").is_dir()
|
||||
assert not (tmp_path / "runner").exists()
|
||||
assert not (tmp_path / "links").exists()
|
||||
# Checkpoint path is derived but not created for agent targets.
|
||||
assert not (tmp_path / "checkpoints").exists()
|
||||
finally:
|
||||
_close_host_disk(host)
|
||||
|
||||
|
||||
def test_per_component_session_path(tmp_path: Path) -> None:
|
||||
"""Dict form lets callers route session aliases to a specific root."""
|
||||
sessions_dir = tmp_path / "state"
|
||||
host = AgentFrameworkHost(
|
||||
target=_AgentStub(),
|
||||
channels=[_ChannelStub()],
|
||||
state_dir={"sessions": sessions_dir},
|
||||
)
|
||||
try:
|
||||
assert sessions_dir.is_dir()
|
||||
assert host._sessions_store is not None
|
||||
assert host._checkpoint_location is None
|
||||
finally:
|
||||
_close_host_disk(host)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("key", ["runner", "links", "active", "identities"])
|
||||
def test_removed_state_dir_component_keys_raise(tmp_path: Path, key: str) -> None:
|
||||
"""Obsolete follow-up components should fail loudly instead of becoming no-ops."""
|
||||
with pytest.raises(ValueError, match="unknown"):
|
||||
AgentFrameworkHost(
|
||||
target=_AgentStub(),
|
||||
channels=[_ChannelStub()],
|
||||
state_dir=cast(Any, {key: tmp_path / key}),
|
||||
)
|
||||
|
||||
|
||||
def test_session_aliases_survive_restart(tmp_path: Path) -> None:
|
||||
"""Aliases written on host #1 must be visible to host #2."""
|
||||
state_dir = tmp_path / "state"
|
||||
|
||||
host1 = AgentFrameworkHost(target=_AgentStub(), channels=[_ChannelStub()], state_dir=state_dir)
|
||||
host1._session_aliases["user-1"] = "sess-abc"
|
||||
host1._session_aliases["user-2"] = "sess-def"
|
||||
_close_host_disk(host1)
|
||||
|
||||
host2 = AgentFrameworkHost(target=_AgentStub(), channels=[_ChannelStub()], state_dir=state_dir)
|
||||
try:
|
||||
assert host2._session_aliases["user-1"] == "sess-abc"
|
||||
assert host2._session_aliases["user-2"] == "sess-def"
|
||||
finally:
|
||||
_close_host_disk(host2)
|
||||
|
||||
|
||||
def _build_simple_workflow() -> Any:
|
||||
"""Build a no-op workflow for checkpoint-wiring tests."""
|
||||
build_upper_workflow = importlib.import_module("hosting_workflow_fixtures").build_upper_workflow
|
||||
|
||||
return build_upper_workflow()
|
||||
|
||||
|
||||
def test_single_path_state_dir_wires_workflow_checkpoints(tmp_path: Path) -> None:
|
||||
"""``state_dir="/foo"`` + workflow target → ``/foo/checkpoints/`` is used."""
|
||||
workflow = _build_simple_workflow()
|
||||
host = AgentFrameworkHost(
|
||||
target=workflow,
|
||||
channels=[_ChannelStub()],
|
||||
state_dir=tmp_path,
|
||||
)
|
||||
try:
|
||||
assert host._checkpoint_location == tmp_path / "checkpoints"
|
||||
finally:
|
||||
_close_host_disk(host)
|
||||
|
||||
|
||||
def test_mapping_state_dir_checkpoints_key_wires_workflow_checkpoints(tmp_path: Path) -> None:
|
||||
"""``state_dir={"checkpoints": ...}`` + workflow target → that path is used."""
|
||||
workflow = _build_simple_workflow()
|
||||
ckpt_dir = tmp_path / "ck"
|
||||
host = AgentFrameworkHost(
|
||||
target=workflow,
|
||||
channels=[_ChannelStub()],
|
||||
state_dir={"checkpoints": ckpt_dir},
|
||||
)
|
||||
try:
|
||||
assert host._checkpoint_location == ckpt_dir
|
||||
assert host._sessions_store is None
|
||||
finally:
|
||||
_close_host_disk(host)
|
||||
|
||||
|
||||
def test_mapping_state_dir_omits_checkpoints_for_workflow(tmp_path: Path) -> None:
|
||||
"""Mapping form lets workflow callers opt out of checkpoint persistence."""
|
||||
workflow = _build_simple_workflow()
|
||||
host = AgentFrameworkHost(
|
||||
target=workflow,
|
||||
channels=[_ChannelStub()],
|
||||
state_dir={"sessions": tmp_path / "s"},
|
||||
)
|
||||
try:
|
||||
assert host._checkpoint_location is None
|
||||
finally:
|
||||
_close_host_disk(host)
|
||||
|
||||
|
||||
def test_explicit_checkpoint_location_wins_over_state_dir(tmp_path: Path, caplog: pytest.LogCaptureFixture) -> None:
|
||||
"""``checkpoint_location`` + ``state_dir`` → explicit param wins + warn."""
|
||||
workflow = _build_simple_workflow()
|
||||
explicit = tmp_path / "explicit-ck"
|
||||
with caplog.at_level("WARNING", logger="agent_framework.hosting"):
|
||||
host = AgentFrameworkHost(
|
||||
target=workflow,
|
||||
channels=[_ChannelStub()],
|
||||
checkpoint_location=explicit,
|
||||
state_dir=tmp_path,
|
||||
)
|
||||
try:
|
||||
assert host._checkpoint_location == explicit
|
||||
assert any(
|
||||
"state_dir['checkpoints']" in rec.message and "checkpoint_location" in rec.message for rec in caplog.records
|
||||
)
|
||||
finally:
|
||||
_close_host_disk(host)
|
||||
|
||||
|
||||
def test_state_dir_checkpoints_for_agent_target_silent_for_single_path(tmp_path: Path) -> None:
|
||||
"""Single-path state_dir + agent target → no checkpoint, no warning."""
|
||||
host = AgentFrameworkHost(
|
||||
target=_AgentStub(),
|
||||
channels=[_ChannelStub()],
|
||||
state_dir=tmp_path,
|
||||
)
|
||||
try:
|
||||
assert host._checkpoint_location is None
|
||||
assert not (tmp_path / "checkpoints").exists()
|
||||
finally:
|
||||
_close_host_disk(host)
|
||||
|
||||
|
||||
def test_state_dir_checkpoints_for_agent_target_warns_when_explicit(
|
||||
tmp_path: Path, caplog: pytest.LogCaptureFixture
|
||||
) -> None:
|
||||
"""Mapping form with ``checkpoints`` + agent target → warn."""
|
||||
with caplog.at_level("WARNING", logger="agent_framework.hosting"):
|
||||
host = AgentFrameworkHost(
|
||||
target=_AgentStub(),
|
||||
channels=[_ChannelStub()],
|
||||
state_dir={"checkpoints": tmp_path / "ck"},
|
||||
)
|
||||
try:
|
||||
assert host._checkpoint_location is None
|
||||
assert any(
|
||||
"state_dir['checkpoints']" in rec.message and "not a Workflow" in rec.message for rec in caplog.records
|
||||
)
|
||||
finally:
|
||||
_close_host_disk(host)
|
||||
|
||||
|
||||
def test_state_dir_checkpoints_conflicts_with_workflow_own_storage(tmp_path: Path) -> None:
|
||||
"""Derived checkpoint path triggers the same conflict guard as explicit."""
|
||||
from agent_framework import InMemoryCheckpointStorage, WorkflowBuilder
|
||||
|
||||
_UpperExecutor = importlib.import_module("hosting_workflow_fixtures")._UpperExecutor
|
||||
workflow = WorkflowBuilder(
|
||||
start_executor=_UpperExecutor(id="upper"),
|
||||
checkpoint_storage=InMemoryCheckpointStorage(),
|
||||
).build()
|
||||
with pytest.raises(RuntimeError, match="already has checkpoint storage"):
|
||||
AgentFrameworkHost(
|
||||
target=workflow,
|
||||
channels=[_ChannelStub()],
|
||||
state_dir=tmp_path,
|
||||
)
|
||||
@@ -0,0 +1,316 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
"""Tests for the per-request isolation contextvar surface in
|
||||
:mod:`agent_framework_hosting._isolation`.
|
||||
|
||||
The isolation keys are the ONLY seam Foundry-aware providers use to
|
||||
find partition keys, and the host's ASGI middleware lifts them off the
|
||||
two well-known headers on every inbound HTTP request. A regression
|
||||
that drops the lookup, mistypes a header name, or fails to reset the
|
||||
contextvar would silently misroute writes / leak per-request state
|
||||
across requests, with zero unit-test signal — so cover the surface
|
||||
fully here.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from typing import Any
|
||||
|
||||
import pytest
|
||||
from agent_framework import AgentSession
|
||||
from starlette.requests import Request
|
||||
from starlette.responses import JSONResponse
|
||||
from starlette.routing import BaseRoute, Route
|
||||
from starlette.testclient import TestClient
|
||||
|
||||
from agent_framework_hosting import (
|
||||
AgentFrameworkHost,
|
||||
Channel,
|
||||
ChannelContext,
|
||||
ChannelContribution,
|
||||
IsolationKeys,
|
||||
get_current_isolation_keys,
|
||||
reset_current_isolation_keys,
|
||||
set_current_isolation_keys,
|
||||
)
|
||||
from agent_framework_hosting._isolation import ( # pyright: ignore[reportPrivateUsage]
|
||||
ISOLATION_HEADER_CHAT,
|
||||
ISOLATION_HEADER_USER,
|
||||
current_isolation_keys,
|
||||
)
|
||||
|
||||
|
||||
class TestIsolationKeys:
|
||||
def test_defaults_to_none_pair(self) -> None:
|
||||
keys = IsolationKeys()
|
||||
assert keys.user_key is None
|
||||
assert keys.chat_key is None
|
||||
assert keys.is_empty is True
|
||||
|
||||
def test_partial_with_only_user_is_not_empty(self) -> None:
|
||||
keys = IsolationKeys(user_key="alice")
|
||||
assert keys.user_key == "alice"
|
||||
assert keys.chat_key is None
|
||||
assert keys.is_empty is False
|
||||
|
||||
def test_partial_with_only_chat_is_not_empty(self) -> None:
|
||||
keys = IsolationKeys(chat_key="general")
|
||||
assert keys.is_empty is False
|
||||
|
||||
def test_full_pair_is_not_empty(self) -> None:
|
||||
keys = IsolationKeys(user_key="alice", chat_key="general")
|
||||
assert keys.is_empty is False
|
||||
|
||||
|
||||
class TestContextVarHelpers:
|
||||
def test_default_is_none(self) -> None:
|
||||
# Each test gets a fresh contextvar value because pytest runs
|
||||
# tests in fresh contexts. ``get`` returns the default.
|
||||
assert get_current_isolation_keys() is None
|
||||
|
||||
def test_set_and_get_round_trip(self) -> None:
|
||||
token = set_current_isolation_keys(IsolationKeys(user_key="alice", chat_key="general"))
|
||||
try:
|
||||
current = get_current_isolation_keys()
|
||||
assert current is not None
|
||||
assert current.user_key == "alice"
|
||||
assert current.chat_key == "general"
|
||||
finally:
|
||||
reset_current_isolation_keys(token)
|
||||
# Reset restores prior value (None in the default context).
|
||||
assert get_current_isolation_keys() is None
|
||||
|
||||
def test_set_with_none_clears(self) -> None:
|
||||
outer = set_current_isolation_keys(IsolationKeys(user_key="alice"))
|
||||
try:
|
||||
inner = set_current_isolation_keys(None)
|
||||
try:
|
||||
assert get_current_isolation_keys() is None
|
||||
finally:
|
||||
reset_current_isolation_keys(inner)
|
||||
# Reset surfaces the outer value again.
|
||||
current = get_current_isolation_keys()
|
||||
assert current is not None
|
||||
assert current.user_key == "alice"
|
||||
finally:
|
||||
reset_current_isolation_keys(outer)
|
||||
|
||||
def test_module_level_contextvar_is_the_same_instance(self) -> None:
|
||||
"""Direct contextvar access (used by the ASGI middleware) and the
|
||||
public `get_current_isolation_keys()` helper read from the SAME
|
||||
underlying contextvar. A regression that introduced a second
|
||||
contextvar would silently break the middleware → provider hop."""
|
||||
token = current_isolation_keys.set(IsolationKeys(user_key="bob"))
|
||||
try:
|
||||
via_helper = get_current_isolation_keys()
|
||||
assert via_helper is not None
|
||||
assert via_helper.user_key == "bob"
|
||||
finally:
|
||||
current_isolation_keys.reset(token)
|
||||
|
||||
|
||||
class TestHeaderConstants:
|
||||
"""The two header names are part of the public contract — they
|
||||
match the ones the Foundry Hosted Agents runtime stamps on every
|
||||
inbound request. A typo here would silently misroute partition
|
||||
writes."""
|
||||
|
||||
def test_user_header_value(self) -> None:
|
||||
assert ISOLATION_HEADER_USER == "x-agent-user-isolation-key"
|
||||
|
||||
def test_chat_header_value(self) -> None:
|
||||
assert ISOLATION_HEADER_CHAT == "x-agent-chat-isolation-key"
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# End-to-end: ASGI middleware lifts the headers into the contextvar.
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
|
||||
class _IsolationProbeChannel:
|
||||
"""A minimal Channel that exposes a single GET route which captures
|
||||
the contextvar value INSIDE the request and returns it as JSON.
|
||||
|
||||
Tests use this to exercise the full middleware → contextvar →
|
||||
handler hop end-to-end.
|
||||
"""
|
||||
|
||||
name = "probe"
|
||||
path = ""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self.captured: list[IsolationKeys | None] = []
|
||||
|
||||
async def _handler(_request: Request) -> JSONResponse:
|
||||
keys = get_current_isolation_keys()
|
||||
self.captured.append(keys)
|
||||
payload: dict[str, str | bool | None]
|
||||
payload = (
|
||||
{"user": keys.user_key, "chat": keys.chat_key}
|
||||
if keys is not None
|
||||
else {"user": None, "chat": None, "_present": False}
|
||||
)
|
||||
return JSONResponse(payload)
|
||||
|
||||
self._routes: list[BaseRoute] = [Route("/probe", _handler)]
|
||||
|
||||
def contribute(self, context: ChannelContext) -> ChannelContribution:
|
||||
del context
|
||||
return ChannelContribution(routes=self._routes)
|
||||
|
||||
|
||||
def _make_host_with_probe() -> tuple[AgentFrameworkHost, _IsolationProbeChannel]:
|
||||
class _NoopAgent:
|
||||
id = "noop-agent"
|
||||
name: str | None = "Noop Agent"
|
||||
description: str | None = "Test noop agent"
|
||||
|
||||
def create_session(self, *, session_id: str | None = None) -> AgentSession:
|
||||
return AgentSession(session_id=session_id)
|
||||
|
||||
def get_session(self, service_session_id: str, *, session_id: str | None = None) -> AgentSession:
|
||||
return AgentSession(service_session_id=service_session_id, session_id=session_id)
|
||||
|
||||
def run(self, *_args: object, **_kwargs: object) -> Any: # pragma: no cover - never called
|
||||
raise RuntimeError("not invoked")
|
||||
|
||||
probe = _IsolationProbeChannel()
|
||||
assert isinstance(probe, Channel)
|
||||
host = AgentFrameworkHost(target=_NoopAgent(), channels=[probe])
|
||||
return host, probe
|
||||
|
||||
|
||||
class TestIsolationMiddlewareEndToEnd:
|
||||
def test_headers_ignored_outside_foundry_environment(self) -> None:
|
||||
host, probe = _make_host_with_probe()
|
||||
with TestClient(host.app) as client: # type: ignore[attr-defined]
|
||||
r = client.get(
|
||||
"/probe",
|
||||
headers={
|
||||
ISOLATION_HEADER_USER: "alice-uid",
|
||||
ISOLATION_HEADER_CHAT: "general-cid",
|
||||
},
|
||||
)
|
||||
assert r.status_code == 200
|
||||
assert r.json() == {"user": None, "chat": None, "_present": False}
|
||||
assert probe.captured == [None]
|
||||
|
||||
def test_both_headers_lifted_into_contextvar(self, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
monkeypatch.setenv("FOUNDRY_HOSTING_ENVIRONMENT", "1")
|
||||
host, probe = _make_host_with_probe()
|
||||
with TestClient(host.app) as client: # type: ignore[attr-defined]
|
||||
r = client.get(
|
||||
"/probe",
|
||||
headers={
|
||||
ISOLATION_HEADER_USER: "alice-uid",
|
||||
ISOLATION_HEADER_CHAT: "general-cid",
|
||||
},
|
||||
)
|
||||
assert r.status_code == 200
|
||||
assert r.json() == {"user": "alice-uid", "chat": "general-cid"}
|
||||
assert len(probe.captured) == 1
|
||||
captured = probe.captured[0]
|
||||
assert captured is not None
|
||||
assert captured.user_key == "alice-uid"
|
||||
assert captured.chat_key == "general-cid"
|
||||
|
||||
def test_only_user_header_lifted(self, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""One-header-only branch: the middleware still binds (chat=None)."""
|
||||
monkeypatch.setenv("FOUNDRY_HOSTING_ENVIRONMENT", "1")
|
||||
host, probe = _make_host_with_probe()
|
||||
with TestClient(host.app) as client: # type: ignore[attr-defined]
|
||||
r = client.get("/probe", headers={ISOLATION_HEADER_USER: "alice-uid"})
|
||||
assert r.status_code == 200
|
||||
assert r.json() == {"user": "alice-uid", "chat": None}
|
||||
|
||||
def test_only_chat_header_lifted(self, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
monkeypatch.setenv("FOUNDRY_HOSTING_ENVIRONMENT", "1")
|
||||
host, probe = _make_host_with_probe()
|
||||
with TestClient(host.app) as client: # type: ignore[attr-defined]
|
||||
r = client.get("/probe", headers={ISOLATION_HEADER_CHAT: "general-cid"})
|
||||
assert r.status_code == 200
|
||||
assert r.json() == {"user": None, "chat": "general-cid"}
|
||||
|
||||
def test_no_headers_keeps_contextvar_none(self) -> None:
|
||||
"""Local-dev path: with neither header present the middleware is
|
||||
a no-op and the contextvar stays at its default ``None`` —
|
||||
providers see "no isolation" and route to the in-memory
|
||||
fallback rather than picking up stale per-request state."""
|
||||
host, probe = _make_host_with_probe()
|
||||
with TestClient(host.app) as client: # type: ignore[attr-defined]
|
||||
r = client.get("/probe")
|
||||
assert r.status_code == 200
|
||||
assert r.json() == {"user": None, "chat": None, "_present": False}
|
||||
assert probe.captured == [None]
|
||||
|
||||
def test_empty_header_value_treated_as_absent(self, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""A header that's present but empty must not bind an empty key —
|
||||
``IsolationContext`` rejects empty strings on the read side."""
|
||||
monkeypatch.setenv("FOUNDRY_HOSTING_ENVIRONMENT", "1")
|
||||
host, probe = _make_host_with_probe()
|
||||
with TestClient(host.app) as client: # type: ignore[attr-defined]
|
||||
r = client.get(
|
||||
"/probe",
|
||||
headers={
|
||||
ISOLATION_HEADER_USER: "",
|
||||
ISOLATION_HEADER_CHAT: "general-cid",
|
||||
},
|
||||
)
|
||||
assert r.status_code == 200
|
||||
# Empty user header decodes to None; chat key stays bound.
|
||||
assert r.json() == {"user": None, "chat": "general-cid"}
|
||||
|
||||
def test_contextvar_resets_after_request(self, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""The middleware must call ``reset_current_isolation_keys`` in
|
||||
a ``finally`` so per-request state never leaks across requests
|
||||
or back into the calling thread's context."""
|
||||
monkeypatch.setenv("FOUNDRY_HOSTING_ENVIRONMENT", "1")
|
||||
host, probe = _make_host_with_probe()
|
||||
with TestClient(host.app) as client: # type: ignore[attr-defined]
|
||||
r1 = client.get("/probe", headers={ISOLATION_HEADER_USER: "alice-uid"})
|
||||
assert r1.status_code == 200
|
||||
# Reading the contextvar OUTSIDE the request scope must see
|
||||
# the default — not the value the prior request bound.
|
||||
assert get_current_isolation_keys() is None
|
||||
# And a follow-up request without headers gets a clean
|
||||
# ``None`` rather than inheriting alice-uid.
|
||||
r2 = client.get("/probe")
|
||||
assert r2.json() == {"user": None, "chat": None, "_present": False}
|
||||
|
||||
def test_concurrent_requests_get_isolated_contextvars(self, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""Different requests run in different async contexts; binding
|
||||
from request A must NOT leak into a concurrent request B."""
|
||||
monkeypatch.setenv("FOUNDRY_HOSTING_ENVIRONMENT", "1")
|
||||
host, probe = _make_host_with_probe()
|
||||
|
||||
async def _drive() -> None:
|
||||
# Run two requests in parallel asyncio tasks against the
|
||||
# same TestClient and assert their captures don't bleed
|
||||
# into each other.
|
||||
async def _hit(user_key: str) -> dict[str, str | None]:
|
||||
with TestClient(host.app) as client: # type: ignore[attr-defined]
|
||||
r = client.get("/probe", headers={ISOLATION_HEADER_USER: user_key})
|
||||
return r.json() # type: ignore[no-any-return]
|
||||
|
||||
r_alice, r_bob = await asyncio.gather(_hit("alice-uid"), _hit("bob-uid"))
|
||||
assert r_alice == {"user": "alice-uid", "chat": None}
|
||||
assert r_bob == {"user": "bob-uid", "chat": None}
|
||||
|
||||
asyncio.run(_drive())
|
||||
|
||||
|
||||
class TestNonHttpScopesPassThrough:
|
||||
"""The middleware intentionally only inspects ``http`` scopes;
|
||||
lifespan / websocket scopes are forwarded untouched. A regression
|
||||
that touched lifespan scopes here would crash boot."""
|
||||
|
||||
async def test_lifespan_scope_does_not_consult_headers(self) -> None:
|
||||
# The TestClient context manager exercises the lifespan scope
|
||||
# implicitly; if the middleware tried to decode headers on a
|
||||
# non-http scope this would raise. Exercise it without binding
|
||||
# any contextvar work.
|
||||
host, _probe = _make_host_with_probe()
|
||||
with TestClient(host.app): # type: ignore[attr-defined]
|
||||
# Just enter / exit; no requests.
|
||||
pass
|
||||
@@ -0,0 +1,50 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
"""Tests for the channel-neutral envelope types in :mod:`agent_framework_hosting._types`."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from agent_framework_hosting import (
|
||||
ChannelIdentity,
|
||||
ChannelRequest,
|
||||
ChannelSession,
|
||||
)
|
||||
|
||||
|
||||
class TestChannelRequest:
|
||||
def test_required_fields_only(self) -> None:
|
||||
req = ChannelRequest(channel="responses", operation="message.create", input="hi")
|
||||
assert req.channel == "responses"
|
||||
assert req.operation == "message.create"
|
||||
assert req.input == "hi"
|
||||
assert req.session is None
|
||||
assert req.options is None
|
||||
assert req.session_mode == "auto"
|
||||
assert req.metadata == {}
|
||||
assert req.attributes == {}
|
||||
assert req.stream is False
|
||||
assert req.identity is None
|
||||
|
||||
def test_with_session_and_identity(self) -> None:
|
||||
req = ChannelRequest(
|
||||
channel="telegram",
|
||||
operation="message.create",
|
||||
input="hi",
|
||||
session=ChannelSession(isolation_key="user:42"),
|
||||
identity=ChannelIdentity(channel="telegram", native_id="42"),
|
||||
)
|
||||
assert req.session is not None
|
||||
assert req.session.isolation_key == "user:42"
|
||||
assert req.identity is not None
|
||||
assert req.identity.channel == "telegram"
|
||||
assert req.identity.native_id == "42"
|
||||
|
||||
|
||||
class TestChannelIdentity:
|
||||
def test_attributes_default_empty_mapping(self) -> None:
|
||||
ident = ChannelIdentity(channel="teams", native_id="abc")
|
||||
assert dict(ident.attributes) == {}
|
||||
|
||||
def test_attributes_passthrough(self) -> None:
|
||||
ident = ChannelIdentity(channel="teams", native_id="abc", attributes={"role": "user"})
|
||||
assert dict(ident.attributes) == {"role": "user"}
|
||||
@@ -10,6 +10,7 @@ available in CI / dev sandboxes).
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import subprocess
|
||||
import sys
|
||||
|
||||
import pytest
|
||||
@@ -24,11 +25,27 @@ from agent_framework_tools.shell._docker import (
|
||||
build_run_argv,
|
||||
)
|
||||
|
||||
|
||||
def _docker_image_available(image: str) -> bool:
|
||||
if not is_docker_available():
|
||||
return False
|
||||
try:
|
||||
result = subprocess.run(
|
||||
["docker", "image", "inspect", image],
|
||||
capture_output=True,
|
||||
check=False,
|
||||
timeout=5.0,
|
||||
)
|
||||
except (OSError, subprocess.TimeoutExpired):
|
||||
return False
|
||||
return result.returncode == 0
|
||||
|
||||
|
||||
# Integration tests use Linux container images (alpine) that don't run
|
||||
# under Docker Desktop's default Windows-container mode.
|
||||
_skip_if_no_linux_docker = pytest.mark.skipif(
|
||||
not is_docker_available() or sys.platform == "win32",
|
||||
reason="docker daemon unavailable or running Windows containers",
|
||||
not _docker_image_available("alpine:3") or sys.platform == "win32",
|
||||
reason="docker daemon unavailable, alpine:3 image missing, or running Windows containers",
|
||||
)
|
||||
|
||||
# --------------------------------------------------------------------- argv builders
|
||||
|
||||
@@ -91,6 +91,8 @@ agent-framework-foundry-hosting = { workspace = true }
|
||||
agent-framework-foundry-local = { workspace = true }
|
||||
agent-framework-gemini = { workspace = true }
|
||||
agent-framework-github-copilot = { workspace = true }
|
||||
agent-framework-hosting = { workspace = true }
|
||||
agent-framework-hosting-responses = { workspace = true }
|
||||
agent-framework-hyperlight = { workspace = true }
|
||||
agent-framework-lab = { workspace = true }
|
||||
agent-framework-mem0 = { workspace = true }
|
||||
|
||||
@@ -12,6 +12,10 @@ import asyncio
|
||||
from agent_framework import Agent, workflow
|
||||
from agent_framework.foundry import FoundryChatClient
|
||||
from azure.identity import AzureCliCredential
|
||||
from dotenv import load_dotenv
|
||||
|
||||
# Load environment variables from .env file (e.g., FOUNDRY_PROJECT_ENDPOINT, FOUNDRY_MODEL)
|
||||
load_dotenv()
|
||||
|
||||
# <create_agents>
|
||||
client = FoundryChatClient(credential=AzureCliCredential())
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user