Compare commits
5 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 9e4e4a8fab | |||
| 7b5ef68abc | |||
| bd32e3142c | |||
| 1a698f92ba | |||
| f70c58fa7c |
@@ -1,641 +0,0 @@
|
||||
---
|
||||
status: proposed
|
||||
contact: sergeymenshykh
|
||||
date: 2026-06-23
|
||||
deciders: sergeymenshykh
|
||||
---
|
||||
|
||||
# Skills Over MCP: Implementation Design Options
|
||||
|
||||
This document explores design options for two SEP-2640 features. The decisions are not yet finalized.
|
||||
|
||||
- **Part 1: MCP Resource Template Skills** - skills described by a URI template with variables that must be resolved before loading.
|
||||
- **Part 2: Direct Skill References** - reading `skill://` URIs referenced directly (e.g., in server instructions) without being listed in the index.
|
||||
|
||||
## Part 1: MCP Resource Template Skills
|
||||
|
||||
### Context and Problem Statement
|
||||
|
||||
The `AgentMcpSkillsSource` currently only supports `skill-md` type entries from `skill://index.json` (support for `archive` type is planned). The SEP-2640 specification also defines `mcp-resource-template` entries: **parameterized skill namespaces** described by a URI template with variables (e.g., `{product}`) that resolve to concrete `SKILL.md` URIs. Rather than materializing every skill in the index, the template's variables must be resolved before a skill can be loaded.
|
||||
|
||||
### Index Entry Format
|
||||
|
||||
```json
|
||||
{
|
||||
"$schema": "https://schemas.agentskills.io/discovery/0.2.0/schema.json",
|
||||
"skills": [
|
||||
{
|
||||
"name": "git-workflow",
|
||||
"type": "skill-md",
|
||||
"description": "Follow this team's Git conventions for branching and commits",
|
||||
"url": "skill://git-workflow/SKILL.md"
|
||||
},
|
||||
{
|
||||
"type": "mcp-resource-template",
|
||||
"description": "Per-product documentation skill",
|
||||
"url": "skill://docs/{product}/SKILL.md"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
Key differences from `skill-md`:
|
||||
|
||||
| Field | `skill-md` | `mcp-resource-template` |
|
||||
|-------|------------|-------------------------|
|
||||
| `name` | Required (the skill name) | **Omitted** (represents many skills) |
|
||||
| `type` | `"skill-md"` | `"mcp-resource-template"` |
|
||||
| `url` | Concrete URI to `SKILL.md` | URI template with variables |
|
||||
| `description` | Describes the skill | Describes the addressable skill space |
|
||||
|
||||
### Use Cases
|
||||
|
||||
Template skills address two scenarios where listing concrete skills is impractical:
|
||||
|
||||
- **Large skill catalogs** - too many skills to enumerate every entry in the index.
|
||||
- **Dynamically generated skills** - skill content generated on the fly from parameters, so the set of valid skills is not known at index-creation time.
|
||||
|
||||
### How Template Skills Are Consumed
|
||||
|
||||
Per SEP-2640, the consumption flow relies on the MCP `completion/complete` method:
|
||||
|
||||
1. **Server registers a resource template** - The MCP server registers the same `url` value (e.g., `skill://docs/{product}/SKILL.md`) as an MCP [resource template](https://modelcontextprotocol.io/specification/2025-11-25/server/resources#resource-templates), wiring template variables to the [completion API](https://modelcontextprotocol.io/specification/2025-11-25/server/utilities/completion).
|
||||
|
||||
2. **Host reads `skill://index.json`** - Discovers the template entry with `type: "mcp-resource-template"`.
|
||||
|
||||
3. **Host surfaces template in UI** - Presents the template as an interactive discovery point where the user fills in variables.
|
||||
|
||||
4. **Host calls `completion/complete`** - For each template variable (e.g., `{product}`), the host calls the MCP completion API to get possible values from the server:
|
||||
```json
|
||||
{
|
||||
"method": "completion/complete",
|
||||
"params": {
|
||||
"ref": {
|
||||
"type": "ref/resource",
|
||||
"uri": "skill://docs/{product}/SKILL.md"
|
||||
},
|
||||
"argument": {
|
||||
"name": "product",
|
||||
"value": ""
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
The server responds with possible completions:
|
||||
```json
|
||||
{
|
||||
"completion": {
|
||||
"values": ["widgets", "billing", "auth", "payments"],
|
||||
"hasMore": false,
|
||||
"total": 4
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
5. **User selects a value** - The user picks a value (e.g., `"billing"`) from the list.
|
||||
|
||||
6. **Host resolves the URI** - The template `skill://docs/{product}/SKILL.md` becomes the concrete URI `skill://docs/billing/SKILL.md`.
|
||||
|
||||
7. **Host reads the resolved skill** - Calls `resources/read` with the concrete URI and proceeds as with any `skill-md` skill.
|
||||
|
||||
### Potential Implementation Options
|
||||
|
||||
### Option 1: Callback on `AgentMcpSkillsSource` for Variable Value Selection
|
||||
|
||||
Add a callback to `AgentMcpSkillsSource` (or its options) that is invoked for each `mcp-resource-template` entry to let the caller select variable values.
|
||||
|
||||
**Flow:**
|
||||
|
||||
1. `AgentMcpSkillsSource.GetSkillsAsync()` reads `skill://index.json`
|
||||
2. For each entry with `type: "mcp-resource-template"`:
|
||||
- Parse the URI template to extract variable names (e.g., `{product}`)
|
||||
- Call the MCP `completion/complete` API to get possible values for each variable
|
||||
- Invoke the caller-provided callback with the variable name, description, and possible values
|
||||
- The callback returns a selected value and a `bool` indicating whether to include the skill
|
||||
3. Resolve the URI template with the selected values
|
||||
4. Create an `AgentMcpSkill` from the resolved URI and add it to the skills list
|
||||
|
||||
**API sketch:**
|
||||
|
||||
```csharp
|
||||
public delegate Task<(string? SelectedValue, bool IncludeSkill)> McpTemplateVariableSelector(
|
||||
string templateDescription,
|
||||
string variableName,
|
||||
IReadOnlyList<string> possibleValues,
|
||||
CancellationToken cancellationToken);
|
||||
|
||||
// Usage via builder:
|
||||
var provider = new AgentSkillsProviderBuilder()
|
||||
.UseMcpSkills(mcpClient, options => {
|
||||
options.TemplateVariableSelector = async (description, variable, values, ct) =>
|
||||
{
|
||||
// Present to user, return selection
|
||||
var selected = PromptUser(variable, values);
|
||||
return (selected, IncludeSkill: selected is not null);
|
||||
};
|
||||
})
|
||||
.Build();
|
||||
```
|
||||
|
||||
**Pros:**
|
||||
- Simple implementation
|
||||
- Easy to understand and use
|
||||
|
||||
**Cons:**
|
||||
- Cannot be used in server-side scenarios where there is no interactive user at skill-discovery time
|
||||
- Does not integrate with the agent's conversational flow
|
||||
|
||||
---
|
||||
|
||||
### Option 2: Integrate into Agent Conversation via `ChatClientAgent` Decorator
|
||||
|
||||
Model the template variable resolution as a request/response interaction within the agent's conversational loop.
|
||||
|
||||
**Flow:**
|
||||
|
||||
1. A `DelegatingAIAgent` decorator (e.g., `McpTemplateSkillResolutionAgent`) intercepts `RunAsync`/`RunStreamingAsync` calls and checks whether the inner agent has an `AgentSkillsProvider` with an `AgentMcpSkillsSource` containing unresolved template entries. The check is performed via `GetService<AgentMcpSkillsSource>()` on the `AgentSkillsProvider`, which delegates to a `GetService` method on the `AgentSkillsSource` base class.
|
||||
|
||||
2. The decorator calls an internal member on `AgentMcpSkillsSource` to get the list of `mcp-resource-template` entries from the index. The `AgentMcpSkillsSource` needs to be extended with an internal member that exposes unresolved template entries separately from concrete skills.
|
||||
|
||||
3. For each template entry, the decorator calls an internal member on `AgentMcpSkillsSource` to retrieve possible values for the template's variables via the MCP `completion/complete` API.
|
||||
|
||||
4. For each variable needing resolution, the decorator returns an `McpResourceTemplateValueRequestContent` (inherits from MEAI's `InputRequestContent`) in the agent response - bypassing the call to the inner agent. The content carries the template description, variable name, and possible values.
|
||||
|
||||
5. The user app receives the response, identifies the `McpResourceTemplateValueRequestContent` content type, and displays UI to the user showing the variable name and possible values, or forwards it further downstream if the user app is a service.
|
||||
|
||||
6. The user selects a value, and the user app calls the agent again with a corresponding `McpResourceTemplateValueResponseContent` (inherits from MEAI's `InputResponseContent`) containing the selected value. The `RequestId` property (inherited from the base classes) correlates the response with the original request.
|
||||
|
||||
7. The decorator identifies the response content and provides the resolved values to `AgentMcpSkillsSource` so it can use them when constructing concrete skills.
|
||||
|
||||
8. Having resolved all template variables, the decorator calls `RunAsync`/`RunStreamingAsync` on the inner agent.
|
||||
|
||||
9. The inner agent invokes the `AgentSkillsProvider`, which calls `AgentMcpSkillsSource.GetSkillsAsync()`. The source now has all resolved variable values and constructs concrete `AgentMcpSkill` instances from the resolved URIs, so it can provide the skill content if requested by the model.
|
||||
|
||||
**API sketch:**
|
||||
|
||||
```csharp
|
||||
// New content types inheriting from MEAI's InputRequestContent/InputResponseContent:
|
||||
public sealed class McpResourceTemplateValueRequestContent : InputRequestContent
|
||||
{
|
||||
public string TemplateDescription { get; }
|
||||
public string VariableName { get; }
|
||||
public IReadOnlyList<string> PossibleValues { get; }
|
||||
public string TemplateUrl { get; }
|
||||
}
|
||||
|
||||
public sealed class McpResourceTemplateValueResponseContent : InputResponseContent
|
||||
{
|
||||
public string SelectedValue { get; }
|
||||
public string TemplateUrl { get; }
|
||||
}
|
||||
|
||||
// Decorator usage:
|
||||
var provider = new AgentSkillsProviderBuilder()
|
||||
.UseMcpSkills(mcpClient)
|
||||
.Build();
|
||||
|
||||
AIAgent agent = new ChatClientAgent(chatClient, new ChatClientAgentOptions
|
||||
{
|
||||
AIContextProviders = [provider],
|
||||
});
|
||||
agent = new McpTemplateSkillResolutionAgent(agent);
|
||||
```
|
||||
|
||||
**Pros:**
|
||||
- Works in server-side scenarios
|
||||
- Fits the existing `DelegatingAIAgent` decorator pattern
|
||||
- Can be composed with other decorators (tool approval, etc.)
|
||||
|
||||
**Cons:**
|
||||
- Complex implementation
|
||||
- Requires user app awareness of the new content types
|
||||
- Users need to know that an additional decorator is required for handling MCP template skills, in addition to registering the MCP skills source
|
||||
- Resolved template variable values must be persisted across conversation turns so the decorator does not re-prompt on subsequent agent runs within the same session
|
||||
|
||||
**Note:** This writeup is high-level and may miss details that could change the design. A POC would be needed to validate the approach.
|
||||
|
||||
### Open Questions
|
||||
|
||||
1. **Completion API limit** - The MCP completion API returns at most 100 values per request and provides no offset/cursor mechanism for enumeration. If a variable has more than 100 possible values, it's unclear how to retrieve the rest - the API only supports prefix-based filtering (typeahead), not bulk pagination.
|
||||
|
||||
2. **Multi-variable templates** - A template like `skill://{org}/{product}/SKILL.md` has multiple variables. Should they be resolved sequentially (org first, then product - since product values may depend on org) or presented together?
|
||||
|
||||
3. **Caching** - Should resolved template values be saved in the `AgentSession` so the user isn't re-prompted on every agent run? How should they be persisted between sessions?
|
||||
|
||||
---
|
||||
|
||||
## Part 2: Direct Skill References
|
||||
|
||||
This part covers how to let the model read `skill://` URIs referenced directly (e.g., in an MCP server's `instructions`, in a resource, or in another skill's content) without being listed in `skill://index.json`.
|
||||
|
||||
### How MCP Skills and Relative Links Work Today
|
||||
|
||||
The `AgentMcpSkillsSource` discovers skills by reading the well-known `skill://index.json` resource from the MCP server:
|
||||
|
||||
```json
|
||||
{
|
||||
"$schema": "https://schemas.agentskills.io/discovery/0.2.0/schema.json",
|
||||
"skills": [
|
||||
{
|
||||
"name": "unit-converter",
|
||||
"type": "skill-md",
|
||||
"description": "Convert between common units.",
|
||||
"url": "skill://unit-converter/SKILL.md"
|
||||
},
|
||||
{
|
||||
"name": "currency-converter",
|
||||
"type": "skill-md",
|
||||
"description": "Convert between world currencies using live rates.",
|
||||
"url": "skill://currency-converter/SKILL.md"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
For each `skill-md` entry it creates an `AgentMcpSkill` instance - frontmatter (name/description) comes straight from the entry. The `AgentSkillsProvider` lists the discovered skills in the model's context (name + description):
|
||||
|
||||
```xml
|
||||
<available_skills>
|
||||
<skill>
|
||||
<name>unit-converter</name>
|
||||
<description>Convert between common units.</description>
|
||||
</skill>
|
||||
<skill>
|
||||
<name>currency-converter</name>
|
||||
<description>Convert between world currencies using live rates.</description>
|
||||
</skill>
|
||||
</available_skills>
|
||||
```
|
||||
|
||||
It also provides functions to the model so it can load a skill and access its resources:
|
||||
|
||||
```csharp
|
||||
// Loads the full content of a specific skill.
|
||||
load_skill(string skillName)
|
||||
|
||||
// Reads a resource associated with a skill (references, assets, dynamic data).
|
||||
read_skill_resource(string skillName, string resourceName)
|
||||
```
|
||||
|
||||
The model calls `load_skill("unit-converter")` and receives the skill content:
|
||||
|
||||
```markdown
|
||||
---
|
||||
name: unit-converter
|
||||
description: Convert between common units.
|
||||
---
|
||||
## Usage
|
||||
|
||||
For the full conversion table, see references/units-table.md.
|
||||
```
|
||||
|
||||
The skill body references `references/units-table.md` by relative path. The model calls `read_skill_resource("unit-converter", "references/units-table.md")` and receives the resource content:
|
||||
|
||||
```markdown
|
||||
# Unit Conversion Table
|
||||
|
||||
| From | To | Factor |
|
||||
| miles | km | 1.60934 |
|
||||
| kg | lbs | 2.20462 |
|
||||
```
|
||||
|
||||
### Direct Reference Examples
|
||||
|
||||
A `skill://` URI can appear in any of these locations:
|
||||
|
||||
**Server instructions** - the MCP server advertises a skill the model should load:
|
||||
|
||||
```text
|
||||
Follow our coding standards. Load skill://code-standards/SKILL.md for details.
|
||||
```
|
||||
|
||||
**A skill body** - a skill's `SKILL.md` links to a sibling resource:
|
||||
|
||||
```markdown
|
||||
---
|
||||
name: code-standards
|
||||
description: Coding standards and conventions.
|
||||
---
|
||||
## Naming
|
||||
|
||||
Follow the naming rules in skill://code-standards/references/naming.md.
|
||||
```
|
||||
|
||||
**A resource** - the linked resource holds the actual content:
|
||||
|
||||
```markdown
|
||||
# Naming Rules
|
||||
|
||||
- Use PascalCase for public members and type names.
|
||||
- Use camelCase for locals and parameters.
|
||||
- Prefix interfaces with `I` (e.g. `ISkillReader`).
|
||||
- Suffix async methods with `Async`.
|
||||
|
||||
For examples, see skill://code-standards/references/naming-examples.md.
|
||||
```
|
||||
|
||||
How can the model access content by direct reference?
|
||||
|
||||
### Function for Reading Direct Skill References
|
||||
|
||||
### Option 1: Extend existing `load_skill` and `read_skill_resource` functions
|
||||
|
||||
```csharp
|
||||
// Added optional 'origin' and a direct skill:// URI is passed in 'skillName'.
|
||||
load_skill(string skillName, string? origin = null)
|
||||
|
||||
// Added optional 'origin', made 'skillName' optional, and a direct skill:// URI is passed in 'resourceName'.
|
||||
read_skill_resource(string resourceName, string? skillName = null, string? origin = null)
|
||||
```
|
||||
|
||||
The optional `origin` identifies the source/MCP server that should handle the direct URI.
|
||||
|
||||
| Case | Call |
|
||||
|------|------|
|
||||
| Load skill | `load_skill("commit-guidelines")` |
|
||||
| Relative resource | `read_skill_resource("commit-guidelines", "examples/COMMIT_EXAMPLES.md")` |
|
||||
| `skill://` link (skill) | `load_skill(skillName: "skill://commit-guidelines/SKILL.md", origin: "DirectRefServer")` |
|
||||
| `skill://` link (resource) | `read_skill_resource(resourceName: "skill://commit-guidelines/examples/COMMIT_EXAMPLES.md", origin: "DirectRefServer")` |
|
||||
|
||||
**Pros:**
|
||||
|
||||
- No new functions added: existing tool surface stays at two functions.
|
||||
|
||||
**Cons:**
|
||||
|
||||
- Unreliable on some models (gpt-4o, gpt-4.1-mini): it often omits `origin` when it should not or calls the wrong function.
|
||||
- Optional parameters create silent ambiguity - the model can pass `origin` for non-MCP skills or omit it for `skill://` URIs.
|
||||
|
||||
### Option 2 (Proposed): Add a dedicated `read_skill_uri` function alongside existing ones
|
||||
|
||||
```csharp
|
||||
// Existing functions stay unchanged.
|
||||
load_skill(string skillName)
|
||||
read_skill_resource(string skillName, string resourceName)
|
||||
|
||||
// New function added alongside: reads content by direct skill:// URI.
|
||||
read_skill_uri(string uri, string origin)
|
||||
```
|
||||
|
||||
| Case | Call |
|
||||
|------|------|
|
||||
| Load skill | `load_skill("commit-guidelines")` |
|
||||
| Relative resource | `read_skill_resource("commit-guidelines", "examples/COMMIT_EXAMPLES.md")` |
|
||||
| `skill://` link (skill) | `read_skill_uri(uri: "skill://commit-guidelines/SKILL.md", origin:"DirectRefServer")` |
|
||||
| `skill://` link (resource) | `read_skill_uri(uri: "skill://commit-guidelines/examples/COMMIT_EXAMPLES.md", origin: "DirectRefServer")` |
|
||||
|
||||
**Pros:**
|
||||
|
||||
- Purely additive - no changes to existing functions needed; `read_skill_uri` can be deferred and added later when direct `skill://` reference support is needed.
|
||||
- Granular approval: each function can have its own approval gate (like the existing `ScriptApproval` for `run_skill_script`), making per-operation approval for skill loading, resource reading, and direct URI access straightforward to add.
|
||||
- Both `uri` and `origin` are required - no silent misuse through optional parameters.
|
||||
- Clean split: `load_skill`/`read_skill_resource` for named skills, `read_skill_uri` for `skill://` links - no parameter ambiguity.
|
||||
|
||||
**Cons:**
|
||||
|
||||
- Three read functions (`load_skill`, `read_skill_resource`, `read_skill_uri`), not counting `run_skill_script`: larger tool surface than a single-function design.
|
||||
|
||||
### Option 3: Collapse `load_skill` and `read_skill_resource` into a single `read_resource` function
|
||||
|
||||
```csharp
|
||||
// Single entrypoint for all skill content. 'uri' is required; 'origin' is optional.
|
||||
read_resource(string uri, string? origin = null)
|
||||
```
|
||||
|
||||
- `uri` - what to read: a skill name, a relative resource path, or a `skill://` link.
|
||||
- `origin` - determines how `uri` is interpreted:
|
||||
- **omitted** → load skill by name (`uri` is the skill name).
|
||||
- **skill name** → read a relative resource (`uri` is the path within that skill).
|
||||
- **server name** → read content by the `skill://` link (`uri` is handled by the source identified by the `[Origin: X]` marker).
|
||||
|
||||
Dispatch is ordered: null `origin` routes to Case 1; if `origin` names a known skill, routes to Case 2; otherwise tries to find an `ISkillUriReader` whose `CanRead` returns true for `origin` (Case 3).
|
||||
|
||||
| Case | Call |
|
||||
|------|------|
|
||||
| Load skill | `read_resource(uri: "commit-guidelines")` |
|
||||
| Relative resource | `read_resource(uri: "examples/COMMIT_EXAMPLES.md", origin: "commit-guidelines")` |
|
||||
| `skill://` link (skill) | `read_resource(uri: "skill://commit-guidelines/SKILL.md", origin: "DirectRefServer")` |
|
||||
| `skill://` link (resource) | `read_resource(uri: "skill://commit-guidelines/examples/COMMIT_EXAMPLES.md", origin: "DirectRefServer")` |
|
||||
|
||||
**Pros:**
|
||||
|
||||
- Minimal tool surface: one read function instead of two or three (not counting `run_skill_script`) reduces token usage and gives the model fewer choices.
|
||||
|
||||
**Cons:**
|
||||
|
||||
- No per-operation approval: all cases (skill loading, resource reading, direct URI access) share one function, so approval cannot be scoped to individual operations.
|
||||
- Unreliable on gpt-4.1-mini: omits `origin` when reading `skill://` links, passes skill name as `origin` when loading a plain skill (should be omitted), and hallucinates resource names (e.g. `API_SPECIFICATION.md`) that do not exist.
|
||||
|
||||
|
||||
---
|
||||
|
||||
### Origin Marker
|
||||
|
||||
A `skill://` URI does not carry an origin, but the model needs to provide one when reading it. The `origin` is what routes the read call to the source that can handle the URI - the provider uses it to pick the matching source. Since the URI itself carries no such hint, the MCP source injects an `[Origin: ...]` marker wherever a `skill://` URI appears, so the model can read it back and pass it as the `origin` argument.
|
||||
|
||||
The marker is only added when the content actually contains `skill://` references. If a piece of content (server instructions, a skill body, or a resource) has no `skill://` URIs, there is nothing for the model to read back, so no marker is injected.
|
||||
|
||||
Into **server instructions**, which may mention `skill://` URIs directly:
|
||||
|
||||
```
|
||||
[Origin: code-standards-server]
|
||||
Follow our coding standards. Load skill://code-standards/SKILL.md for details.
|
||||
```
|
||||
|
||||
Into **skill bodies**, since a `SKILL.md` may reference other `skill://` URIs (a resource file or a related skill):
|
||||
|
||||
```
|
||||
[Origin: code-standards-server]
|
||||
# Code Standards
|
||||
|
||||
For naming conventions, load skill://code-standards/references/naming.md.
|
||||
```
|
||||
|
||||
Into **skill resources**, since a resource may itself reference further `skill://` URIs:
|
||||
|
||||
```
|
||||
[Origin: code-standards-server]
|
||||
# Naming Rules
|
||||
|
||||
- Use PascalCase for public members and type names.
|
||||
- Use camelCase for locals and parameters.
|
||||
|
||||
For examples, see skill://code-standards/references/naming-examples.md.
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Read-by-URI Capability: Interface vs Base Class Virtual Methods
|
||||
|
||||
Now let's look at how an `AgentSkillsSource` can opt in to reading `skill://` URIs and signal that capability to the provider.
|
||||
|
||||
### Option 1: New `ISkillUriReader` interface
|
||||
|
||||
```csharp
|
||||
public interface ISkillUriReader
|
||||
{
|
||||
// Returns true if this reader can handle the given skill:// URI from the given origin.
|
||||
bool CanRead(string uri, string origin);
|
||||
|
||||
// Reads and returns the content for the given skill:// URI.
|
||||
Task<object?> ReadByUriAsync(string uri, string origin, CancellationToken cancellationToken = default);
|
||||
}
|
||||
```
|
||||
|
||||
Sources that support direct `skill://` URI reads - such as `AgentMcpSkillsSource` - implement this interface to opt in.
|
||||
|
||||
The provider discovers readers via a service locator and dispatches to the first that can handle the URI:
|
||||
|
||||
```csharp
|
||||
// Discover all registered readers.
|
||||
var readers = source.GetService<IEnumerable<ISkillUriReader>>();
|
||||
|
||||
// Pick the first reader that can handle the URI.
|
||||
var reader = readers.FirstOrDefault(r => r.CanRead(uri, origin))
|
||||
?? throw new InvalidOperationException($"No reader can handle URI '{uri}' from origin '{origin}'.");
|
||||
|
||||
// Delegate the read to it.
|
||||
return await reader.ReadByUriAsync(uri, origin, cancellationToken);
|
||||
```
|
||||
|
||||
The provider may treat a source implementing `ISkillUriReader` as the signal to advertise `read_skill_uri`: if at least one registered source implements the interface, the function is exposed to the model; otherwise it is not.
|
||||
|
||||
### Option 2 (Proposed): Virtual methods on `AgentSkillsSource` base class
|
||||
|
||||
```csharp
|
||||
public abstract class AgentSkillsSource
|
||||
{
|
||||
// New members for reading by URI.
|
||||
|
||||
// Whether this source can read by URI; drives whether read_skill_uri is advertised. Off by default.
|
||||
public virtual bool SupportsReadByUri => false;
|
||||
|
||||
// Returns true if this source can handle the given skill:// URI from the given origin.
|
||||
public virtual bool CanReadByUri(string uri, string origin) => false;
|
||||
|
||||
// Reads and returns the content for the given skill:// URI.
|
||||
public virtual Task<object?> ReadByUriAsync(string uri, string origin, CancellationToken cancellationToken = default)
|
||||
=> Task.FromResult<object?>(null);
|
||||
|
||||
// Existing member.
|
||||
public abstract Task<IList<AgentSkills>> GetSkillsAsync(CancellationToken cancellationToken = default);
|
||||
}
|
||||
```
|
||||
|
||||
Sources opt in by overriding, and the provider calls them directly:
|
||||
|
||||
```csharp
|
||||
// AgentMcpSkillsSource opts in by overriding the virtuals.
|
||||
public override bool SupportsReadByUri => true;
|
||||
|
||||
// Handles the URI when its origin matches this source's MCP server.
|
||||
public override bool CanReadByUri(string uri, string origin)
|
||||
=> string.Equals(origin, this.Origin, StringComparison.OrdinalIgnoreCase);
|
||||
|
||||
// Reads content by skill:// URI from the MCP server.
|
||||
public override Task<string?> ReadByUriAsync(string uri, string origin, CancellationToken cancellationToken)
|
||||
=> /* resolve uri via the MCP server identified by origin */;
|
||||
```
|
||||
|
||||
All sources inherit the methods, so there is no type signal - `SupportsReadByUri` fills that role. The function is advertised when any registered source returns `true`.
|
||||
|
||||
### Comparison
|
||||
|
||||
| Aspect | Option 1: Interface | Option 2: Base class virtual methods |
|
||||
|--------|---------------------|--------------------------------------|
|
||||
| Discovery | Service locator | Direct call on source |
|
||||
| Advertising signal | Interface implementation | `SupportsReadByUri` flag |
|
||||
| Adding new members | Breaking change | Non-breaking |
|
||||
| Complexity | Higher | Lower |
|
||||
|
||||
---
|
||||
|
||||
### Include MCP Server Instructions Into Agent Instructions
|
||||
|
||||
MCP server instructions may contain the `skill://` references the model needs, so we want to surface them in the agent's instructions. But they can also carry system prompts or behavioral directives irrelevant to the agent, polluting context - so inclusion is **opt-in** via the `IncludeServerInstructions` option:
|
||||
|
||||
```csharp
|
||||
public sealed class AgentMcpSkillsSourceOptions
|
||||
{
|
||||
// When true, the MCP server's instructions are injected into the agent instructions. Off by default.
|
||||
public bool IncludeServerInstructions { get; set; }
|
||||
}
|
||||
|
||||
builder.UseMcpSkills(mcpClient, options => options.IncludeServerInstructions = true);
|
||||
```
|
||||
|
||||
When enabled, the instructions travel alongside the discovered skills on `AgentSkillsResult`:
|
||||
|
||||
```csharp
|
||||
public class AgentSkillsResult
|
||||
{
|
||||
// The skills discovered from the source.
|
||||
public IList<AgentSkill> Skills { get; }
|
||||
|
||||
// The MCP server instructions, when IncludeServerInstructions is enabled; otherwise null.
|
||||
public string? Instructions { get; }
|
||||
}
|
||||
```
|
||||
|
||||
The `AgentSkillsProvider` then appends them to its own skill-usage guidance when building the agent's instructions:
|
||||
|
||||
```csharp
|
||||
var result = await source.GetSkillsAsync(cancellationToken);
|
||||
|
||||
var instructions = DefaultSkillsInstructionPrompt;
|
||||
if (!string.IsNullOrWhiteSpace(result.Instructions))
|
||||
{
|
||||
// Combine the provider's skill-usage guidance with the server instructions.
|
||||
instructions += Environment.NewLine + result.Instructions;
|
||||
}
|
||||
```
|
||||
|
||||
### Enabling Direct Skill References
|
||||
|
||||
Following direct `skill://` references is **disabled by default** and activated via an option. When enabled, the provider advertises the read function to the model, and the source injects the `[Origin: ...]` marker into all content provided by the MCP server that contains `skill://` references. When disabled, no function is advertised and no marker is injected.
|
||||
|
||||
```csharp
|
||||
public sealed class AgentMcpSkillsSourceOptions
|
||||
{
|
||||
public bool EnableDirectReferences { get; set; }
|
||||
}
|
||||
|
||||
builder.UseMcpSkills(mcpClient, options => options.EnableDirectReferences = true);
|
||||
```
|
||||
|
||||
## Decision Outcome
|
||||
|
||||
### Template Variable Resolution: Callback vs Decorator (Part 1)
|
||||
|
||||
**Postponed.** Deferring this decision until:
|
||||
|
||||
- We have a concrete list of scenarios that require template variable resolution.
|
||||
- The skills-over-MCP spec is released (it is still a draft, so the design may change).
|
||||
- There is a strong signal of demand from users or the ecosystem.
|
||||
|
||||
### Function for Reading Direct Skill References (Part 2)
|
||||
|
||||
**Postponed.** Leaning toward **Option 2 - dedicated `read_skill_uri` function alongside existing ones** (purely additive, and each function can have its own approval gate for granular per-operation approval), but deferring the decision until:
|
||||
|
||||
- The skills-over-MCP spec is released (it is still a draft, so the design may change).
|
||||
- There is a strong signal of demand from users or the ecosystem.
|
||||
|
||||
### Read-by-URI Capability: Interface vs Base Class (Part 2)
|
||||
|
||||
**Postponed.** Leaning toward **Option 2 - virtual methods on `AgentSkillsSource`** (non-breaking, lower complexity, and a natural fit with the existing base class hierarchy), but deferring the decision until:
|
||||
|
||||
- The skills-over-MCP spec is released (it is still a draft, so the design may change).
|
||||
- There is a strong signal of demand from users or the ecosystem.
|
||||
|
||||
The method naming (`SupportsReadByUri`, `CanReadByUri`, `ReadByUriAsync`) should also be abstracted a little more before adoption, so the same members can be reused when a similar direct-reference concept is needed for other skill types (e.g. file skills).
|
||||
|
||||
## References
|
||||
|
||||
- [SEP-2640: Skills Extension](https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2640) - Draft proposal
|
||||
- [SEP-2640 Implementation Guidelines: Model-Driven Resource Loading](https://github.com/modelcontextprotocol/experimental-ext-skills/blob/main/docs/sep-draft-skills-extension.md#hosts-model-driven-resource-loading)
|
||||
- [MCP Completion API](https://modelcontextprotocol.io/specification/2025-11-25/server/utilities/completion) - Used for template variable resolution
|
||||
- [MCP Resource Templates](https://modelcontextprotocol.io/specification/2025-11-25/server/resources#resource-templates)
|
||||
- [Skills Over MCP Working Group](https://github.com/modelcontextprotocol/experimental-ext-skills)
|
||||
- [Open Question #4: Multi-server skill dependencies](https://github.com/modelcontextprotocol/experimental-ext-skills/issues/39)
|
||||
- [Anthropic Agent Skills - Overview](https://platform.claude.com/docs/en/agents-and-tools/agent-skills/overview) - Prior art: single skill entrypoint + generic file reads
|
||||
- [Anthropic Agent Skills in the SDK](https://code.claude.com/docs/en/agent-sdk/skills) - The `Skill` tool exposed to the model
|
||||
+1
@@ -0,0 +1 @@
|
||||
../../../.github/skills/pull-requests
|
||||
-116
@@ -1,116 +0,0 @@
|
||||
---
|
||||
name: pull-requests
|
||||
description: >
|
||||
Guidance for creating pull requests and handling PR review comments in the
|
||||
Agent Framework repository. Use this when writing a PR description (filling out
|
||||
the PR template) or when responding to and resolving review comments on an
|
||||
existing PR.
|
||||
---
|
||||
|
||||
# Pull Request Workflow
|
||||
|
||||
This skill covers two tasks: (1) writing a high-quality PR description, and
|
||||
(2) handling review comments on an existing PR.
|
||||
|
||||
## 1. Writing the PR description
|
||||
|
||||
Always follow the repository PR template at
|
||||
[`.github/pull_request_template.md`](../../../../.github/pull_request_template.md). Keep its
|
||||
exact structure and headings. Fill every section:
|
||||
|
||||
### `### Motivation & Context`
|
||||
Explain *why* the change is needed: the problem it solves and the scenario it
|
||||
contributes to. Describe the net change relative to `main` — this is implied, so
|
||||
do **not** spell out "vs main" explicitly.
|
||||
|
||||
### `### Description & Review Guide`
|
||||
Describe the changes, the overall approach, and the design. Answer the three
|
||||
prompts:
|
||||
- **What are the major changes?**
|
||||
- **What is the impact of these changes?**
|
||||
- **What do you want reviewers to focus on?** — This item is for **human
|
||||
reviewers only**. Automated/AI reviewers must ignore it and review the entire
|
||||
change rather than narrowing scope to it.
|
||||
|
||||
### `### Related Issue`
|
||||
Link the issue the PR fixes using a GitHub closing keyword (`Fixes #123` /
|
||||
`Closes #123`) so it closes automatically on merge. A PR with no linked issue may
|
||||
be closed regardless of how valid the change is. Before opening, confirm there is
|
||||
no other open PR for the same issue; if there is, explain how this PR differs.
|
||||
|
||||
### `### Contribution Checklist`
|
||||
Check every item that applies. For the breaking-change item:
|
||||
- Leave **"This is not a breaking change."** checked for the common case.
|
||||
- If the change **is** breaking, add the `breaking change` label **or** put
|
||||
`[BREAKING]` in the title prefix, before or after a language prefix such as
|
||||
`Python:` or `.NET:` — workflows keep the label and the title prefix in sync
|
||||
automatically (see `.github/workflows/label-title-prefix.yml` and
|
||||
`.github/workflows/label-pr.yml`).
|
||||
|
||||
### Do not
|
||||
- Do **not** add ad-hoc sections such as "Validation" or "Tests run"; CI/CD and
|
||||
the checklist already cover validation status.
|
||||
- Do **not** remove or reorder the template's headings.
|
||||
|
||||
### Creating the PR
|
||||
Open new PRs as **drafts** until they are ready for review. Example:
|
||||
|
||||
```bash
|
||||
gh pr create --repo microsoft/agent-framework --base main \
|
||||
--head <your-fork-owner>:<branch> --draft \
|
||||
--title "<concise title>" --body "<body following the template>"
|
||||
```
|
||||
|
||||
## 2. Handling review comments
|
||||
|
||||
When a PR receives review comments, follow this sequence — **do not start editing
|
||||
code before the user has reviewed the plan**:
|
||||
|
||||
1. **Review the comments.** Read every review comment and thread on the PR,
|
||||
including inline code comments and general review summaries.
|
||||
2. **Make a plan.** Produce a concrete plan describing how each comment will be
|
||||
addressed (or why it should not be, with reasoning).
|
||||
3. **Let the user review the plan.** Present the plan and wait for the user's
|
||||
approval or adjustments before implementing anything.
|
||||
4. **Implement.** Make the agreed changes.
|
||||
5. **Reply to every comment.** Add a reply to **all** comments explaining how it
|
||||
was addressed (or the agreed outcome) — leave none unanswered.
|
||||
6. **Resolve resolved threads.** Mark a review thread as resolved only when the
|
||||
comment has actually been addressed.
|
||||
|
||||
### Useful commands
|
||||
|
||||
List review comments and threads:
|
||||
|
||||
```bash
|
||||
# Inline review comments
|
||||
gh api repos/{owner}/{repo}/pulls/{pr}/comments
|
||||
|
||||
# Review threads with resolution state (GraphQL)
|
||||
gh api graphql -f query='
|
||||
query($owner:String!,$repo:String!,$pr:Int!){
|
||||
repository(owner:$owner,name:$repo){
|
||||
pullRequest(number:$pr){
|
||||
reviewThreads(first:100){
|
||||
nodes{ id isResolved comments(first:50){ nodes{ id body author{login} } } }
|
||||
}
|
||||
}
|
||||
}
|
||||
}' -F owner={owner} -F repo={repo} -F pr={pr}
|
||||
```
|
||||
|
||||
Reply to an inline review comment:
|
||||
|
||||
```bash
|
||||
gh api repos/{owner}/{repo}/pulls/{pr}/comments/{comment_id}/replies \
|
||||
-f body="Addressed in <commit>: <explanation>"
|
||||
```
|
||||
|
||||
Resolve a review thread (needs the thread node id from the GraphQL query above):
|
||||
|
||||
```bash
|
||||
gh api graphql -f query='
|
||||
mutation($threadId:ID!){
|
||||
resolveReviewThread(input:{threadId:$threadId}){ thread{ isResolved } }
|
||||
}' -F threadId={thread_id}
|
||||
```
|
||||
@@ -120,7 +120,6 @@
|
||||
</Folder>
|
||||
<Folder Name="/Samples/02-agents/Harness/">
|
||||
<File Path="samples/02-agents/Harness/README.md" />
|
||||
<Project Path="samples/02-agents/Harness/BuildYourOwnClaw/Claw_Step01_MeetYourClaw/Claw_Step01_MeetYourClaw.csproj" />
|
||||
<Project Path="samples/02-agents/Harness/ConsoleReactiveComponents/ConsoleReactiveComponents.csproj" />
|
||||
<Project Path="samples/02-agents/Harness/ConsoleReactiveFramework/ConsoleReactiveFramework.csproj" />
|
||||
<Project Path="samples/02-agents/Harness/Harness_Shared_Console/Harness_Shared_Console.csproj" />
|
||||
@@ -218,7 +217,6 @@
|
||||
<Folder Name="/Samples/02-agents/ModelContextProtocol/">
|
||||
<File Path="samples/02-agents/ModelContextProtocol/README.md" />
|
||||
<Project Path="samples/02-agents/ModelContextProtocol/Agent_MCP_LongRunningTask_Client/Agent_MCP_LongRunningTask_Client.csproj" />
|
||||
<Project Path="samples/02-agents/ModelContextProtocol/Agent_MCP_PerRun_AuthHeaders/Agent_MCP_PerRun_AuthHeaders.csproj" />
|
||||
<Project Path="samples/02-agents/ModelContextProtocol/Agent_MCP_Server/Agent_MCP_Server.csproj" />
|
||||
<Project Path="samples/02-agents/ModelContextProtocol/Agent_MCP_Server_Auth/Agent_MCP_Server_Auth.csproj" />
|
||||
<Project Path="samples/02-agents/ModelContextProtocol/FoundryAgent_Hosted_MCP/FoundryAgent_Hosted_MCP.csproj" />
|
||||
|
||||
@@ -1,14 +1,14 @@
|
||||
<Project>
|
||||
<PropertyGroup>
|
||||
<!-- Central version prefix - applies to all nuget packages. -->
|
||||
<VersionPrefix>1.11.0</VersionPrefix>
|
||||
<VersionPrefix>1.10.0</VersionPrefix>
|
||||
<RCNumber>1</RCNumber>
|
||||
<DateSuffix>260622</DateSuffix>
|
||||
<DateSuffix>260610</DateSuffix>
|
||||
<PackageVersion Condition="'$(IsReleaseCandidate)' == 'true'">$(VersionPrefix)-rc$(RCNumber)</PackageVersion>
|
||||
<PackageVersion Condition="'$(IsReleaseCandidate)' != 'true' AND '$(VersionSuffix)' != ''">$(VersionPrefix)-$(VersionSuffix).$(DateSuffix).1</PackageVersion>
|
||||
<PackageVersion Condition="'$(IsReleaseCandidate)' != 'true' AND '$(VersionSuffix)' == ''">$(VersionPrefix)-preview.$(DateSuffix).1</PackageVersion>
|
||||
<PackageVersion Condition="'$(IsReleased)' == 'true'">$(VersionPrefix)</PackageVersion>
|
||||
<GitTag>1.11.0</GitTag>
|
||||
<GitTag>1.10.0</GitTag>
|
||||
|
||||
<Configurations>Debug;Release;Publish</Configurations>
|
||||
<IsPackable>true</IsPackable>
|
||||
|
||||
-22
@@ -1,22 +0,0 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<OutputType>Exe</OutputType>
|
||||
<TargetFrameworks>net10.0</TargetFrameworks>
|
||||
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Azure.Identity" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\..\..\..\src\Microsoft.Agents.AI.Foundry\Microsoft.Agents.AI.Foundry.csproj" />
|
||||
<ProjectReference Include="..\..\..\..\..\src\Microsoft.Agents.AI.Harness\Microsoft.Agents.AI.Harness.csproj" />
|
||||
<ProjectReference Include="..\..\Harness_Shared_Console\Harness_Shared_Console.csproj" />
|
||||
<ProjectReference Include="..\..\Harness_Shared_Console_OpenAI\Harness_Shared_Console_OpenAI.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
-111
@@ -1,111 +0,0 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
// "Meet your agent harness and claw" — Post 1 of the "Build your own claw with Microsoft Agent Framework" series.
|
||||
// See: https://devblogs.microsoft.com/agent-framework/meet-your-agent-harness-and-claw.
|
||||
//
|
||||
// This sample builds the foundation of a personal finance / investing assistant on top of a
|
||||
// HarnessAgent. The harness comes pre-configured with function invocation, per-service-call
|
||||
// history persistence, and planning (TodoProvider + AgentModeProvider), plus web search — so
|
||||
// all we add here is:
|
||||
// 1. Finance-focused instructions.
|
||||
// 2. A custom get_stock_price function tool.
|
||||
//
|
||||
// The agent can plan a multi-step request ("Review my watchlist and recommend some stocks to add"), create a todo list, switch
|
||||
// between plan and execute modes, search the web for market news, and call our stock-price tool.
|
||||
//
|
||||
// Special commands (handled by the shared HarnessConsole):
|
||||
// /todos — Display the current todo list without invoking the agent.
|
||||
// /mode — Get or set the current agent mode.
|
||||
// /exit — End the session.
|
||||
|
||||
#pragma warning disable OPENAI001 // Suppress experimental API warnings for Responses API usage.
|
||||
#pragma warning disable MAAI001 // Suppress experimental API warnings for Agents AI experiments.
|
||||
|
||||
using System.ClientModel.Primitives;
|
||||
using Azure.AI.Projects;
|
||||
using Azure.Identity;
|
||||
using ClawSample;
|
||||
using Harness.Shared.Console;
|
||||
using Harness.Shared.Console.OpenAI;
|
||||
using Harness.Shared.Console.ToolFormatters;
|
||||
using Microsoft.Agents.AI;
|
||||
using Microsoft.Extensions.AI;
|
||||
|
||||
var endpoint = Environment.GetEnvironmentVariable("FOUNDRY_PROJECT_ENDPOINT") ?? throw new InvalidOperationException("FOUNDRY_PROJECT_ENDPOINT is not set.");
|
||||
var deploymentName = Environment.GetEnvironmentVariable("FOUNDRY_MODEL") ?? "gpt-5.4";
|
||||
|
||||
// <instructions>
|
||||
var instructions =
|
||||
"""
|
||||
## Personal Finance Assistant Instructions
|
||||
|
||||
You are a personal finance and investing assistant. You help the user understand their
|
||||
watchlist and the markets. When asked about a stock, look up its current price with the
|
||||
get_stock_price tool, and use web search for recent news, earnings, or analyst commentary.
|
||||
|
||||
### Working style
|
||||
|
||||
- Always verify numbers with a tool rather than relying on memory. Stock prices change.
|
||||
- Cite web sources inline when you use them.
|
||||
- Keep the user's watchlist in a memory file called watchlist.md: read it when reviewing the
|
||||
watchlist, and update it whenever the user adds or removes a ticker.
|
||||
|
||||
### Important
|
||||
|
||||
You provide information and analysis only — you are not a licensed financial advisor and you
|
||||
must not present your output as personalized investment advice. Remind the user to do their
|
||||
own research before making decisions.
|
||||
""";
|
||||
// </instructions>
|
||||
|
||||
// <create_client>
|
||||
// Construct an IChatClient. Here we use a Microsoft Foundry project: the endpoint points at the
|
||||
// project, DefaultAzureCredential handles auth, and the deployment name selects the model.
|
||||
// The harness works with ANY IChatClient — see the AgentProviders samples for OpenAI, Azure
|
||||
// OpenAI, Anthropic, Google Gemini, Ollama, ONNX, and more.
|
||||
IChatClient chatClient =
|
||||
new AIProjectClient(
|
||||
new Uri(endpoint),
|
||||
// WARNING: DefaultAzureCredential is convenient for development but requires careful consideration in production.
|
||||
// In production, consider using a specific credential (e.g., ManagedIdentityCredential) to avoid
|
||||
// latency issues, unintended credential probing, and potential security risks from fallback mechanisms.
|
||||
new DefaultAzureCredential(),
|
||||
new AIProjectClientOptions { RetryPolicy = new ClientRetryPolicy(3) }) // Enable retries to improve resiliency.
|
||||
.GetProjectOpenAIClient()
|
||||
.GetResponsesClient()
|
||||
.AsIChatClient(deploymentName);
|
||||
// </create_client>
|
||||
|
||||
// <create_agent>
|
||||
// Turn the chat client into a HarnessAgent. AsHarnessAgent pre-configures function invocation,
|
||||
// per-service-call chat history persistence, TodoProvider, AgentModeProvider, and web search.
|
||||
// We add finance instructions and our get_stock_price tool.
|
||||
AIAgent agent = chatClient.AsHarnessAgent(new HarnessAgentOptions
|
||||
{
|
||||
ChatOptions = new ChatOptions
|
||||
{
|
||||
Instructions = instructions,
|
||||
Tools = [StockTools.CreateGetStockPriceTool()],
|
||||
Reasoning = new() { Effort = ReasoningEffort.Medium },
|
||||
},
|
||||
});
|
||||
// </create_agent>
|
||||
|
||||
// <run>
|
||||
// Run the interactive console session using the shared HarnessConsole helper.
|
||||
await HarnessConsole.RunAgentAsync(
|
||||
agent,
|
||||
userPrompt: "Ask about a stock or say 'Review my watchlist and recommend some stocks to add' to get started.",
|
||||
new HarnessConsoleOptions
|
||||
{
|
||||
Observers = [
|
||||
new OpenAIResponsesWebSearchDisplayObserver(),
|
||||
new OpenAIResponsesErrorObserver(),
|
||||
.. HarnessConsoleOptions.BuildObserversWithPlanning(
|
||||
agent,
|
||||
planModeName: "plan",
|
||||
executionModeName: "execute",
|
||||
toolFormatters: ToolCallFormatter.BuildDefaultToolFormatters())],
|
||||
CommandHandlers = HarnessConsoleOptions.BuildDefaultCommandHandlers(agent),
|
||||
});
|
||||
// </run>
|
||||
@@ -1,52 +0,0 @@
|
||||
# Meet your claw (Post 1) — .NET
|
||||
|
||||
The first runnable sample from the [**"Build your own agent harness and claw with Microsoft Agent Framework"** blog](https://devblogs.microsoft.com/agent-framework/build-your-own-claw-and-agent-harness-with-microsoft-agent-framework)
|
||||
series. It builds the foundation of a personal finance / investing assistant on top of a
|
||||
`HarnessAgent`.
|
||||
|
||||
## What this sample demonstrates
|
||||
|
||||
- **`AsHarnessAgent`** — turns an `IChatClient` into a batteries-included agent: function
|
||||
invocation, per-service-call history persistence, planning
|
||||
(`TodoProvider` + `AgentModeProvider`), and web search.
|
||||
- **A custom function tool** — `get_stock_price` (see `StockTools.cs`), exposing local data to the
|
||||
agent. Prices are illustrative mock data, not real quotes.
|
||||
- **Web search** — provided automatically by the harness for market news and commentary.
|
||||
- **Planning & modes** — the agent breaks a multi-step request ("Review my watchlist and recommend some stocks to add") into a todo
|
||||
list and switches between *plan* and *execute* modes.
|
||||
- **Shared harness console** — interactive streaming UI with `/todos`, `/mode`, and `/exit`
|
||||
commands.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
1. A Microsoft Foundry project with a deployed model (e.g. `gpt-5.4`).
|
||||
2. Azure CLI installed and authenticated (`az login`).
|
||||
|
||||
## Environment variables
|
||||
|
||||
```bash
|
||||
export FOUNDRY_PROJECT_ENDPOINT="https://your-project.services.ai.azure.com/api/projects/your-project"
|
||||
# Optional (defaults to gpt-5.4)
|
||||
export FOUNDRY_MODEL="gpt-5.4"
|
||||
```
|
||||
|
||||
## Running
|
||||
|
||||
```bash
|
||||
cd dotnet
|
||||
dotnet run --project samples/02-agents/Harness/BuildYourOwnClaw/Claw_Step01_MeetYourClaw
|
||||
```
|
||||
|
||||
## What to expect
|
||||
|
||||
The sample starts an interactive loop. Try these in order:
|
||||
|
||||
1. `/mode execute` — switch out of the default plan mode; quick lookups don't need a plan.
|
||||
2. `What's the price of MSFT?` — the agent calls the `get_stock_price` tool.
|
||||
3. `Any recent news on NVDA?` — the agent uses web search.
|
||||
4. `Add MSFT, NVDA and SPY to my watch list` — saved to `watchlist.md` in the session's memory.
|
||||
5. `/mode plan` — switch back to plan mode for a bigger, multi-step task.
|
||||
6. `Review my watchlist and recommend some stocks to add` — the agent plans, then executes. Type
|
||||
`/todos` to see the list and `/mode` to inspect the current mode.
|
||||
|
||||
Output is colored by mode: **cyan** during planning, **green** during execution.
|
||||
-61
@@ -1,61 +0,0 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System.ComponentModel;
|
||||
using Microsoft.Extensions.AI;
|
||||
|
||||
namespace ClawSample;
|
||||
|
||||
/// <summary>
|
||||
/// A custom function tool that gives our "claw" access to (illustrative) stock prices.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// The prices returned here are mock data for demonstration purposes only and are not real
|
||||
/// market quotes. In a real assistant you would call a market-data API instead.
|
||||
/// </remarks>
|
||||
internal static class StockTools
|
||||
{
|
||||
// <stock_quote>
|
||||
/// <summary>A delayed, illustrative stock quote.</summary>
|
||||
public sealed record StockQuote(string Symbol, decimal Price, string Currency, DateTimeOffset AsOf);
|
||||
// </stock_quote>
|
||||
|
||||
// A tiny in-memory price book so the sample runs without any external dependency.
|
||||
private static readonly Dictionary<string, decimal> s_priceBook = new(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
["MSFT"] = 462.97m,
|
||||
["AAPL"] = 229.35m,
|
||||
["GOOGL"] = 178.12m,
|
||||
["AMZN"] = 201.45m,
|
||||
["NVDA"] = 134.81m,
|
||||
};
|
||||
|
||||
// <get_stock_price>
|
||||
/// <summary>
|
||||
/// Gets the latest (delayed, illustrative) stock price for a ticker symbol.
|
||||
/// </summary>
|
||||
/// <param name="symbol">The stock ticker symbol, e.g. <c>MSFT</c> or <c>AAPL</c>.</param>
|
||||
[Description("Gets the latest (delayed, illustrative) stock price for a ticker symbol.")]
|
||||
public static StockQuote GetStockPrice(
|
||||
[Description("The stock ticker symbol, e.g. MSFT or AAPL.")] string symbol)
|
||||
{
|
||||
if (!s_priceBook.TryGetValue(symbol, out var price))
|
||||
{
|
||||
// Deterministic pseudo-price for unknown symbols so the sample stays self-contained.
|
||||
// Derive a stable seed from the characters — string.GetHashCode() is randomized per
|
||||
// process and Math.Abs(int.MinValue) throws, so neither is safe for repeatable output.
|
||||
var seed = 0;
|
||||
foreach (var ch in symbol.ToUpperInvariant())
|
||||
{
|
||||
seed = (seed * 31 + ch) % 1_000_000;
|
||||
}
|
||||
|
||||
price = 50m + seed % 45000 / 100m;
|
||||
}
|
||||
|
||||
return new StockQuote(symbol.ToUpperInvariant(), price, "USD", DateTimeOffset.UtcNow);
|
||||
}
|
||||
// </get_stock_price>
|
||||
|
||||
/// <summary>Creates the <see cref="AIFunction"/> wrapper used to expose the tool to the agent.</summary>
|
||||
public static AIFunction CreateGetStockPriceTool() => AIFunctionFactory.Create(GetStockPrice, "get_stock_price");
|
||||
}
|
||||
@@ -64,15 +64,21 @@ public sealed class HarnessAgentRunner : IDisposable
|
||||
|
||||
/// <summary>
|
||||
/// Replaces the current session with the specified session. Used by the UX driver
|
||||
/// when importing a serialized session. This method is always called from within
|
||||
/// a command handler (which already holds the input gate), so no additional
|
||||
/// synchronization is needed.
|
||||
/// when importing a serialized session. Acquires the input gate to ensure no
|
||||
/// concurrent agent turn is reading the session.
|
||||
/// </summary>
|
||||
/// <param name="newSession">The new session to use.</param>
|
||||
internal Task ReplaceSessionAsync(AgentSession newSession)
|
||||
internal async Task ReplaceSessionAsync(AgentSession newSession)
|
||||
{
|
||||
this._session = newSession;
|
||||
return Task.CompletedTask;
|
||||
await this._inputGate.WaitAsync().ConfigureAwait(false);
|
||||
try
|
||||
{
|
||||
this._session = newSession;
|
||||
}
|
||||
finally
|
||||
{
|
||||
this._inputGate.Release();
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
|
||||
@@ -10,11 +10,3 @@ Samples demonstrating the [Harness AIContextProviders](../../../src/Microsoft.Ag
|
||||
| [Harness_Step02_Research_WithBackgroundAgents](./Harness_Step02_Research_WithBackgroundAgents/README.md) | Using BackgroundAgentsProvider to delegate stock price lookups to a web-search background agent concurrently |
|
||||
| [Harness_Step03_DataProcessing](./Harness_Step03_DataProcessing/README.md) | Using FileAccessProvider to give an agent access to CSV data files for reading, analysis, and output generation |
|
||||
| [Harness_Step05_Loop](./Harness_Step05_Loop/README.md) | Wrapping a HarnessAgent with the LoopAgent decorator to re-invoke it until a configured LoopEvaluator (completion marker, predicate, AI judge, or approval-aware loop) decides to stop |
|
||||
|
||||
## Build your own claw blog series
|
||||
|
||||
Samples accompanying the [*Build your own agent harness or claw with Microsoft Agent Framework*](https://devblogs.microsoft.com/agent-framework/build-your-own-claw-and-agent-harness-with-microsoft-agent-framework) blog series, which builds a personal finance assistant step by step.
|
||||
|
||||
| Sample | Description |
|
||||
| --- | --- |
|
||||
| [Claw_Step01_MeetYourClaw](./BuildYourOwnClaw/Claw_Step01_MeetYourClaw/README.md) | Post 1 — a minimal HarnessAgent with a custom `get_stock_price` tool, web search, and planning |
|
||||
|
||||
-21
@@ -1,21 +0,0 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<OutputType>Exe</OutputType>
|
||||
<TargetFrameworks>net10.0</TargetFrameworks>
|
||||
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Azure.Identity" />
|
||||
<PackageReference Include="ModelContextProtocol" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\..\..\src\Microsoft.Agents.AI\Microsoft.Agents.AI.csproj" />
|
||||
<ProjectReference Include="..\..\..\..\src\Microsoft.Agents.AI.Foundry\Microsoft.Agents.AI.Foundry.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
-136
@@ -1,136 +0,0 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
// This sample shows how to attach per-run (refreshable) authentication headers to MCP requests.
|
||||
//
|
||||
// The agent connects to an MCP server with a custom HttpClient. A DelegatingHandler reads a token
|
||||
// for the current run from an AsyncLocal scope and stamps it on each outbound MCP request, so a
|
||||
// short-lived token (for example an OBO or cloud identity token that expires) can be refreshed on
|
||||
// every run without rebuilding the agent or the MCP connection.
|
||||
//
|
||||
// The agent backend is Microsoft Foundry via the Responses API (RAPI). The MCP server is the public
|
||||
// Microsoft Learn MCP server, which ignores the demonstration token; in production you point the
|
||||
// handler at your own protected MCP server and mint a real token per run.
|
||||
|
||||
using System.Net.Http.Headers;
|
||||
using Azure.AI.Projects;
|
||||
using Azure.Identity;
|
||||
using Microsoft.Agents.AI;
|
||||
using Microsoft.Extensions.AI;
|
||||
using ModelContextProtocol.Client;
|
||||
|
||||
var projectEndpoint = new Uri(Environment.GetEnvironmentVariable("FOUNDRY_PROJECT_ENDPOINT")
|
||||
?? throw new InvalidOperationException("FOUNDRY_PROJECT_ENDPOINT is not set."));
|
||||
var deploymentName = Environment.GetEnvironmentVariable("FOUNDRY_MODEL") ?? "gpt-5.4-mini";
|
||||
|
||||
var serverEndpoint = new Uri("https://learn.microsoft.com/api/mcp");
|
||||
|
||||
// Custom HttpClient for the MCP transport. The per-run handler attaches the bearer; the inner
|
||||
// handler disables cookies (no cross-context state), disables auto-redirect (so a redirect cannot
|
||||
// carry the bearer past the origin re-check), and checks certificate revocation.
|
||||
using var httpClient = new HttpClient(new PerRunAuthHeaderHandler(serverEndpoint)
|
||||
{
|
||||
InnerHandler = new HttpClientHandler
|
||||
{
|
||||
UseCookies = false,
|
||||
AllowAutoRedirect = false,
|
||||
CheckCertificateRevocationList = true,
|
||||
},
|
||||
});
|
||||
|
||||
Console.WriteLine($"Connecting to MCP server at {serverEndpoint} ...");
|
||||
|
||||
await using var mcpClient = await McpClient.CreateAsync(new HttpClientTransport(new()
|
||||
{
|
||||
Endpoint = serverEndpoint,
|
||||
Name = "Microsoft Learn MCP",
|
||||
TransportMode = HttpTransportMode.StreamableHttp,
|
||||
}, httpClient));
|
||||
|
||||
IList<McpClientTool> mcpTools = await mcpClient.ListToolsAsync();
|
||||
Console.WriteLine($"MCP tools available: {string.Join(", ", mcpTools.Select(t => t.Name))}");
|
||||
|
||||
// Build the agent from Microsoft Foundry using the Responses API (RAPI).
|
||||
// WARNING: DefaultAzureCredential is convenient for development but requires careful consideration in production.
|
||||
// In production, consider using a specific credential (e.g., ManagedIdentityCredential) to avoid
|
||||
// latency issues, unintended credential probing, and potential security risks from fallback mechanisms.
|
||||
AIAgent agent = new AIProjectClient(projectEndpoint, new DefaultAzureCredential())
|
||||
.AsAIAgent(
|
||||
model: deploymentName,
|
||||
instructions: "You answer Microsoft documentation questions using the available tools.",
|
||||
name: "DocsAgent",
|
||||
tools: [.. mcpTools.Cast<AITool>()]);
|
||||
|
||||
// Run the same agent twice under two different contexts. Each run gets a freshly minted token,
|
||||
// proving the auth header is per-run rather than bound when the agent or MCP connection was created.
|
||||
await RunForContextAsync(agent, "tenant-a", "How do I create an Azure storage account with az cli?");
|
||||
await RunForContextAsync(agent, "tenant-b", "What is Azure Functions?");
|
||||
|
||||
static async Task RunForContextAsync(AIAgent agent, string label, string prompt)
|
||||
{
|
||||
// Stand-in for a real per-run token (for example an OBO or cloud identity token).
|
||||
// It carries no PII and is regenerated on every run. The label is non-secret and used for logging.
|
||||
McpRunContext? previous = McpRunScope.Current;
|
||||
McpRunScope.Current = new McpRunContext(label, $"{label}.{Guid.NewGuid():N}");
|
||||
try
|
||||
{
|
||||
Console.WriteLine($"\n=== Run for '{label}' (fresh per-run token) ===");
|
||||
Console.WriteLine(await agent.RunAsync(prompt));
|
||||
}
|
||||
finally
|
||||
{
|
||||
// Restore the prior scope (stack-like) so this is safe to call from within an outer scope.
|
||||
McpRunScope.Current = previous;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Carries the context for the current run. <see cref="Label"/> is a non-secret identifier safe to
|
||||
/// log; <see cref="Token"/> is the secret that must never be logged or persisted.
|
||||
/// </summary>
|
||||
internal sealed record McpRunContext(string Label, string Token);
|
||||
|
||||
/// <summary>
|
||||
/// Flows the current <see cref="McpRunContext"/> to the MCP <see cref="DelegatingHandler"/> without
|
||||
/// threading it through every call. Set it before a run and reset it afterwards.
|
||||
/// </summary>
|
||||
internal static class McpRunScope
|
||||
{
|
||||
private static readonly AsyncLocal<McpRunContext?> s_current = new();
|
||||
|
||||
public static McpRunContext? Current
|
||||
{
|
||||
get => s_current.Value;
|
||||
set => s_current.Value = value;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Attaches the current run's bearer token to outbound MCP requests. The token is read fresh on
|
||||
/// every request, so refreshing it between runs needs no agent or connection rebuild.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Security: the bearer is attached only over HTTPS and only when the request targets the configured
|
||||
/// MCP server origin, which prevents the credential from leaking over plaintext or to a redirect
|
||||
/// target on another origin. Only the non-secret label is logged, never the token.
|
||||
/// </remarks>
|
||||
internal sealed class PerRunAuthHeaderHandler(Uri serverEndpoint) : DelegatingHandler
|
||||
{
|
||||
private readonly string _serverOrigin = serverEndpoint.GetLeftPart(UriPartial.Authority);
|
||||
|
||||
protected override async Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
|
||||
{
|
||||
McpRunContext? context = McpRunScope.Current;
|
||||
Uri? requestUri = request.RequestUri;
|
||||
|
||||
if (context is not null
|
||||
&& requestUri is not null
|
||||
&& requestUri.Scheme == Uri.UriSchemeHttps
|
||||
&& string.Equals(requestUri.GetLeftPart(UriPartial.Authority), this._serverOrigin, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", context.Token);
|
||||
Console.WriteLine($"[mcp-auth] attached bearer for '{context.Label}' -> {request.Method} {requestUri.AbsolutePath}");
|
||||
}
|
||||
|
||||
return await base.SendAsync(request, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
@@ -1,89 +0,0 @@
|
||||
# Per-Run MCP Authentication Headers
|
||||
|
||||
This sample shows how to attach per-run (refreshable) authentication headers to Model Context
|
||||
Protocol (MCP) requests using existing Agent Framework primitives. It addresses scenarios where the
|
||||
header value changes from one run to the next, for example a short-lived On-Behalf-Of (OBO) or cloud
|
||||
identity token that expires and must be refreshed.
|
||||
|
||||
The agent backend is Microsoft Foundry accessed through the Responses API (RAPI). The MCP server is
|
||||
the public Microsoft Learn MCP server.
|
||||
|
||||
## What this sample demonstrates
|
||||
|
||||
- A custom `HttpClient` on the MCP transport whose `DelegatingHandler` stamps an `Authorization`
|
||||
header on every outbound MCP request.
|
||||
- An `AsyncLocal` scope (`McpRunScope`) that carries the current run's context to the handler, set
|
||||
immediately before each run and cleared in a `finally` block.
|
||||
- Running the same agent twice under two different contexts, each with a freshly minted token, so the
|
||||
header is per-run rather than fixed when the agent or the MCP connection was created.
|
||||
|
||||
Because the handler reads the token fresh on every request, an expiring token is refreshed simply by
|
||||
placing a new value in scope before the next run. No agent or connection rebuild is required.
|
||||
|
||||
## How it works
|
||||
|
||||
```text
|
||||
RunForContextAsync sets McpRunScope.Current
|
||||
-> agent.RunAsync invokes an MCP tool
|
||||
-> PerRunAuthHeaderHandler reads McpRunScope.Current
|
||||
-> stamps Authorization: Bearer <token> on the MCP request
|
||||
RunForContextAsync clears McpRunScope.Current in finally
|
||||
```
|
||||
|
||||
The public Microsoft Learn MCP server is anonymous and ignores the demonstration token. In production
|
||||
you point the handler at your own protected MCP server and mint a real token per run.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- .NET 10 SDK or later
|
||||
- A Microsoft Foundry project endpoint and a model deployment
|
||||
- An authenticated Azure identity (for example, sign in with `az login`)
|
||||
|
||||
Set the following environment variables:
|
||||
|
||||
```powershell
|
||||
$env:FOUNDRY_PROJECT_ENDPOINT="https://your-foundry-service.services.ai.azure.com/api/projects/your-foundry-project"
|
||||
$env:FOUNDRY_MODEL="gpt-5.4-mini"
|
||||
```
|
||||
|
||||
## Run the sample
|
||||
|
||||
```powershell
|
||||
dotnet run
|
||||
```
|
||||
|
||||
## Security considerations
|
||||
|
||||
This sample is written to demonstrate the pattern safely. When you adapt it, keep these in place:
|
||||
|
||||
- **Never log the token.** Only the non-secret label is printed. Avoid printing the token even in a
|
||||
masked form.
|
||||
- **Attach the header over HTTPS only.** The handler skips the header when the request is not HTTPS,
|
||||
so a credential is never sent over plaintext.
|
||||
- **Scope the header to the MCP server origin.** The handler attaches the header only when the
|
||||
request targets the configured server origin (scheme, host, and port). Auto-redirect is also
|
||||
disabled (`AllowAutoRedirect = false`) so a redirect cannot carry the token to another origin
|
||||
below the handler before the origin check runs.
|
||||
- **Reset the scope after each run.** `McpRunScope.Current` is restored to its prior value in a
|
||||
`finally` block so a token does not bleed into later, unrelated work and nesting stays safe.
|
||||
- **Disable cookies on the shared handler.** `UseCookies = false` avoids cross-context state on a
|
||||
shared client, and `CheckCertificateRevocationList = true` validates the server certificate.
|
||||
- **Use non-identifying labels and tokens.** The labels and tokens here carry no personal data and are
|
||||
regenerated per run.
|
||||
- **Do not persist secrets in serialized session state.** Agent session state is serializable, so keep
|
||||
raw tokens in memory or mint them per run rather than storing them there.
|
||||
|
||||
## Production notes
|
||||
|
||||
- Replace the demonstration token with a real per-request exchange inside the handler, for example an
|
||||
Azure `TokenCredential`, MSAL OBO flow, or a cloud identity token. Performing the exchange per
|
||||
request lets expiry self-heal because each request obtains a current token.
|
||||
- The `AsyncLocal` scope isolates concurrent runs from each other, so parallel runs with different
|
||||
tokens do not interfere.
|
||||
- As an alternative carrier, the token can be read from `AgentSession` state by an `AIContextProvider`
|
||||
that copies it into the scope at the start of each invocation. Remember the serialized-state warning
|
||||
above and avoid persisting the raw secret.
|
||||
- For MCP servers that implement standard OAuth, `HttpClientTransportOptions.OAuth` already handles the
|
||||
authorization and refresh flow, so a custom handler is unnecessary.
|
||||
- This sample attaches the same header for every tool call in a run. Selecting different headers based
|
||||
on the specific tool or its arguments is intentionally out of scope here.
|
||||
@@ -21,7 +21,6 @@ Before you begin, ensure you have the following prerequisites:
|
||||
|---|---|
|
||||
|[Agent with MCP server tools](./Agent_MCP_Server/)|This sample demonstrates how to use MCP server tools with a simple agent|
|
||||
|[Agent with MCP server tools and authorization](./Agent_MCP_Server_Auth/)|This sample demonstrates how to use MCP Server tools from a protected MCP server with a simple agent|
|
||||
|[Agent with per-run MCP authentication headers](./Agent_MCP_PerRun_AuthHeaders/)|This sample demonstrates how to attach per-run, refreshable authentication headers to MCP requests using a custom HttpClient handler and an AsyncLocal scope. Uses Microsoft Foundry (`FOUNDRY_PROJECT_ENDPOINT` / `FOUNDRY_MODEL`) rather than the Azure OpenAI variables in the prerequisites above.|
|
||||
|[Responses Agent with Hosted MCP tool](./ResponseAgent_Hosted_MCP/)|This sample demonstrates how to use the Hosted MCP tool with the Responses Service, where the service invokes any MCP tools directly|
|
||||
|[Agent with long-running MCP task (transparent polling)](./Agent_MCP_LongRunningTask_Client/)|This sample demonstrates how an agent transparently drives a long-running MCP task (SEP-2663) to completion. The wrapper polls the task internally on both `RunAsync` and `RunStreamingAsync` invocations.|
|
||||
|
||||
|
||||
@@ -101,15 +101,10 @@ else
|
||||
throw new ArgumentException("Either A2AServer:ApiKey or A2AServer:ConnectionString & agentName must be provided");
|
||||
}
|
||||
|
||||
// IMPORTANT: In production, register a SessionIsolationKeyProvider to isolate sessions by authenticated caller.
|
||||
// Without this, contextId alone is the session key — any caller who knows a contextId can access that session.
|
||||
// Example using claims-based identity:
|
||||
// When running in production, make sure to use an SessionIsolationKeyProvider, e.g. ClaimsIdentity-based
|
||||
// if using Claims-based Identity for Authentication/Authorization
|
||||
// builder.Services.UseClaimsBasedSessionIsolation(new() { ClaimType = ClaimTypes.NameIdentifier });
|
||||
|
||||
// By default, NoopAgentSessionStore is used — sessions are not persisted across requests.
|
||||
// To enable multi-turn conversations, register a session store explicitly, e.g.:
|
||||
// builder.Services.AddKeyedSingleton<AgentSessionStore>(hostA2AAgent.Name, new InMemoryAgentSessionStore());
|
||||
|
||||
builder.AddA2AServer(hostA2AAgent);
|
||||
|
||||
var app = builder.Build();
|
||||
|
||||
@@ -28,15 +28,10 @@ builder.AddDevUI();
|
||||
builder.AddOpenAIChatCompletions();
|
||||
builder.AddOpenAIResponses();
|
||||
|
||||
// IMPORTANT: In production, register a SessionIsolationKeyProvider to isolate sessions by authenticated caller.
|
||||
// Without this, contextId alone is the session key — any caller who knows a contextId can access that session.
|
||||
// Example using claims-based identity:
|
||||
// When running in production, make sure to use an SessionIsolationKeyProvider, e.g. ClaimsIdentity-based
|
||||
// if using Claims-based Identity for Authentication/Authorization
|
||||
// builder.Services.UseClaimsBasedSessionIsolation(new() { ClaimType = ClaimTypes.NameIdentifier });
|
||||
|
||||
// By default, NoopAgentSessionStore is used — sessions are not persisted across requests.
|
||||
// To enable multi-turn conversations, register a session store explicitly, e.g.:
|
||||
// agentBuilder.WithInMemorySessionStore();
|
||||
|
||||
var pirateAgentBuilder = builder.AddAIAgent(
|
||||
"pirate",
|
||||
instructions: "You are a pirate. Speak like a pirate",
|
||||
@@ -157,9 +152,8 @@ builder.Services.AddKeyedSingleton<AIAgent>("my-di-matchingname-agent", (sp, nam
|
||||
pirateAgentBuilder.AddA2AServer();
|
||||
knightsKnavesAgentBuilder.AddA2AServer();
|
||||
|
||||
// IMPORTANT: In production, register a SessionIsolationKeyProvider to isolate sessions by authenticated caller.
|
||||
// Without this, contextId alone is the session key — any caller who knows a contextId can access that session.
|
||||
// Example using claims-based identity:
|
||||
// When running in production, make sure to use an SessionIsolationKeyProvider, e.g. ClaimsIdentity-based
|
||||
// if using Claims-based Identity for Authentication/Authorization
|
||||
// builder.Services.UseClaimsBasedSessionIsolation(new() { ClaimType = ClaimTypes.NameIdentifier });
|
||||
|
||||
var app = builder.Build();
|
||||
|
||||
@@ -6,7 +6,6 @@ using System.IO;
|
||||
using System.Linq;
|
||||
using System.Runtime.CompilerServices;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization.Metadata;
|
||||
using System.Threading;
|
||||
using System.Threading.Channels;
|
||||
using System.Threading.Tasks;
|
||||
@@ -30,7 +29,6 @@ public sealed class GitHubCopilotAgent : AIAgent, IAsyncDisposable
|
||||
private readonly string _description;
|
||||
private readonly SessionConfig? _sessionConfig;
|
||||
private readonly bool _ownsClient;
|
||||
private readonly JsonSerializerOptions _jsonSerializerOptions;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="GitHubCopilotAgent"/> class.
|
||||
@@ -41,15 +39,13 @@ public sealed class GitHubCopilotAgent : AIAgent, IAsyncDisposable
|
||||
/// <param name="id">The unique identifier for the agent.</param>
|
||||
/// <param name="name">The name of the agent.</param>
|
||||
/// <param name="description">The description of the agent.</param>
|
||||
/// <param name="jsonSerializerOptions">Optional JSON serializer options. Defaults to <see cref="GitHubCopilotJsonUtilities.DefaultOptions"/>.</param>
|
||||
public GitHubCopilotAgent(
|
||||
CopilotClient copilotClient,
|
||||
SessionConfig? sessionConfig = null,
|
||||
bool ownsClient = false,
|
||||
string? id = null,
|
||||
string? name = null,
|
||||
string? description = null,
|
||||
JsonSerializerOptions? jsonSerializerOptions = null)
|
||||
string? description = null)
|
||||
{
|
||||
_ = Throw.IfNull(copilotClient);
|
||||
|
||||
@@ -59,7 +55,6 @@ public sealed class GitHubCopilotAgent : AIAgent, IAsyncDisposable
|
||||
this._id = id;
|
||||
this._name = name ?? DefaultName;
|
||||
this._description = description ?? DefaultDescription;
|
||||
this._jsonSerializerOptions = jsonSerializerOptions ?? GitHubCopilotJsonUtilities.DefaultOptions;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -72,7 +67,6 @@ public sealed class GitHubCopilotAgent : AIAgent, IAsyncDisposable
|
||||
/// <param name="description">The description of the agent.</param>
|
||||
/// <param name="tools">The tools to make available to the agent.</param>
|
||||
/// <param name="instructions">Optional instructions to append as a system message.</param>
|
||||
/// <param name="jsonSerializerOptions">Optional JSON serializer options. Defaults to <see cref="GitHubCopilotJsonUtilities.DefaultOptions"/>.</param>
|
||||
public GitHubCopilotAgent(
|
||||
CopilotClient copilotClient,
|
||||
bool ownsClient = false,
|
||||
@@ -80,16 +74,14 @@ public sealed class GitHubCopilotAgent : AIAgent, IAsyncDisposable
|
||||
string? name = null,
|
||||
string? description = null,
|
||||
IList<AITool>? tools = null,
|
||||
string? instructions = null,
|
||||
JsonSerializerOptions? jsonSerializerOptions = null)
|
||||
string? instructions = null)
|
||||
: this(
|
||||
copilotClient,
|
||||
GetSessionConfig(tools, instructions),
|
||||
ownsClient,
|
||||
id,
|
||||
name,
|
||||
description,
|
||||
jsonSerializerOptions)
|
||||
description)
|
||||
{
|
||||
}
|
||||
|
||||
@@ -190,14 +182,6 @@ public sealed class GitHubCopilotAgent : AIAgent, IAsyncDisposable
|
||||
channel.Writer.TryWrite(this.ConvertToAgentResponseUpdate(assistantMessage, isStreaming));
|
||||
break;
|
||||
|
||||
case ToolExecutionStartEvent toolStart:
|
||||
channel.Writer.TryWrite(this.ConvertToAgentResponseUpdate(toolStart));
|
||||
break;
|
||||
|
||||
case ToolExecutionCompleteEvent toolComplete:
|
||||
channel.Writer.TryWrite(this.ConvertToAgentResponseUpdate(toolComplete));
|
||||
break;
|
||||
|
||||
case AssistantUsageEvent usageEvent:
|
||||
channel.Writer.TryWrite(this.ConvertToAgentResponseUpdate(usageEvent));
|
||||
break;
|
||||
@@ -365,79 +349,6 @@ public sealed class GitHubCopilotAgent : AIAgent, IAsyncDisposable
|
||||
};
|
||||
}
|
||||
|
||||
internal AgentResponseUpdate ConvertToAgentResponseUpdate(ToolExecutionStartEvent toolStart)
|
||||
{
|
||||
IDictionary<string, object?>? arguments = this.ParseArguments(toolStart.Data?.Arguments);
|
||||
|
||||
FunctionCallContent content = new(
|
||||
toolStart.Data?.ToolCallId ?? string.Empty,
|
||||
toolStart.Data?.ToolName ?? string.Empty,
|
||||
arguments)
|
||||
{
|
||||
RawRepresentation = toolStart
|
||||
};
|
||||
|
||||
return new AgentResponseUpdate(ChatRole.Assistant, [content])
|
||||
{
|
||||
AgentId = this.Id,
|
||||
CreatedAt = toolStart.Timestamp
|
||||
};
|
||||
}
|
||||
|
||||
internal AgentResponseUpdate ConvertToAgentResponseUpdate(ToolExecutionCompleteEvent toolComplete)
|
||||
{
|
||||
object? result = toolComplete.Data?.Success == true
|
||||
? toolComplete.Data?.Result?.Content
|
||||
: toolComplete.Data?.Error?.Message ?? "Tool execution failed";
|
||||
|
||||
FunctionResultContent content = new(
|
||||
toolComplete.Data?.ToolCallId ?? string.Empty,
|
||||
result)
|
||||
{
|
||||
RawRepresentation = toolComplete
|
||||
};
|
||||
|
||||
return new AgentResponseUpdate(ChatRole.Tool, [content])
|
||||
{
|
||||
AgentId = this.Id,
|
||||
CreatedAt = toolComplete.Timestamp
|
||||
};
|
||||
}
|
||||
|
||||
private IDictionary<string, object?>? ParseArguments(object? arguments)
|
||||
{
|
||||
if (arguments is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
if (arguments is JsonElement jsonElement)
|
||||
{
|
||||
if (jsonElement.ValueKind == JsonValueKind.Null || jsonElement.ValueKind == JsonValueKind.Undefined)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var typeInfo = (JsonTypeInfo<Dictionary<string, object?>>)this._jsonSerializerOptions.GetTypeInfo(typeof(Dictionary<string, object?>));
|
||||
|
||||
try
|
||||
{
|
||||
return JsonSerializer.Deserialize(jsonElement.GetRawText(), typeInfo);
|
||||
}
|
||||
catch (JsonException)
|
||||
{
|
||||
return new Dictionary<string, object?> { ["value"] = jsonElement.ToString() };
|
||||
}
|
||||
}
|
||||
|
||||
if (arguments is IDictionary<string, object?> dict)
|
||||
{
|
||||
return dict;
|
||||
}
|
||||
|
||||
return new Dictionary<string, object?> { ["value"] = arguments.ToString() };
|
||||
}
|
||||
|
||||
private AgentResponseUpdate ConvertToAgentResponseUpdate(AssistantUsageEvent usageEvent)
|
||||
{
|
||||
UsageDetails usageDetails = new()
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System.Collections.Generic;
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
using System.Text.Encodings.Web;
|
||||
using System.Text.Json;
|
||||
@@ -44,7 +43,6 @@ internal static partial class GitHubCopilotJsonUtilities
|
||||
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
|
||||
NumberHandling = JsonNumberHandling.AllowReadingFromString)]
|
||||
[JsonSerializable(typeof(GitHubCopilotAgentSession))]
|
||||
[JsonSerializable(typeof(Dictionary<string, object?>))]
|
||||
[ExcludeFromCodeCoverage]
|
||||
private sealed partial class JsonContext : JsonSerializerContext;
|
||||
}
|
||||
|
||||
@@ -189,7 +189,7 @@ public static class A2AServerServiceCollectionExtensions
|
||||
var isolationKeyProvider = serviceProvider.GetService<SessionIsolationKeyProvider>();
|
||||
if (agentSessionStore?.GetService<IsolationKeyScopedAgentSessionStore>() is null)
|
||||
{
|
||||
agentSessionStore ??= new NoopAgentSessionStore();
|
||||
agentSessionStore ??= new InMemoryAgentSessionStore();
|
||||
agentSessionStore = new IsolationKeyScopedAgentSessionStore(agentSessionStore, isolationKeyProvider, new() { Strict = isolationKeyProvider != null });
|
||||
}
|
||||
|
||||
|
||||
+7
@@ -14,6 +14,7 @@ using Microsoft.Agents.AI.Workflows.Declarative.Kit;
|
||||
using Microsoft.Agents.AI.Workflows.Declarative.PowerFx;
|
||||
using Microsoft.Agents.ObjectModel;
|
||||
using Microsoft.Extensions.AI;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Shared.Diagnostics;
|
||||
|
||||
namespace Microsoft.Agents.AI.Workflows.Declarative.ObjectModel;
|
||||
@@ -167,6 +168,9 @@ internal sealed class InvokeFunctionToolExecutor(
|
||||
{
|
||||
if (!this._approvalSnapshots.ContainsKey(approval.RequestId))
|
||||
{
|
||||
this.Logger.LogWarning(
|
||||
"Approval response '{RequestId}' did not match any pending invocation on '{ActionId}'.",
|
||||
approval.RequestId, this.Id);
|
||||
await this.AssignErrorAsync(context, "No pending approval matched the response.").ConfigureAwait(false);
|
||||
}
|
||||
else if (!approval.Approved)
|
||||
@@ -180,6 +184,9 @@ internal sealed class InvokeFunctionToolExecutor(
|
||||
}
|
||||
else
|
||||
{
|
||||
this.Logger.LogWarning(
|
||||
"Approval response '{RequestId}' had no remaining pending snapshot on '{ActionId}'.",
|
||||
approval.RequestId, this.Id);
|
||||
await this.AssignErrorAsync(context, "No pending approval matched the response.").ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,10 +1,7 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System;
|
||||
using System.IO;
|
||||
using System.Reflection;
|
||||
using System.Text.Json.Serialization;
|
||||
using System.Text.RegularExpressions;
|
||||
using Microsoft.Shared.Diagnostics;
|
||||
|
||||
namespace Microsoft.Agents.AI.Workflows.Checkpointing;
|
||||
@@ -14,7 +11,7 @@ namespace Microsoft.Agents.AI.Workflows.Checkpointing;
|
||||
/// </summary>
|
||||
public sealed class TypeId : IEquatable<TypeId>
|
||||
{
|
||||
/// <inheritdoc cref="Assembly.FullName"/>
|
||||
/// <inheritdoc cref="System.Reflection.Assembly.FullName"/>
|
||||
public string AssemblyName { get; }
|
||||
|
||||
/// <inheritdoc cref="Type.FullName"/>
|
||||
@@ -49,11 +46,6 @@ public sealed class TypeId : IEquatable<TypeId>
|
||||
=> this.Equals(obj as TypeId);
|
||||
|
||||
/// <inheritdoc />
|
||||
/// <remarks>
|
||||
/// Compares the type full name and the simple assembly name. Version, culture, and public key
|
||||
/// token are ignored both in <see cref="AssemblyName"/> and in any assembly-qualified generic
|
||||
/// arguments embedded in <see cref="TypeName"/>.
|
||||
/// </remarks>
|
||||
public bool Equals(TypeId? other)
|
||||
{
|
||||
if (other is null)
|
||||
@@ -66,27 +58,11 @@ public sealed class TypeId : IEquatable<TypeId>
|
||||
return true;
|
||||
}
|
||||
|
||||
if (this.NormalizedTypeName != other.NormalizedTypeName)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (string.Equals(this.AssemblyName, other.AssemblyName, StringComparison.Ordinal))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
string? thisSimpleName = this.SimpleAssemblyName;
|
||||
string? otherSimpleName = other.SimpleAssemblyName;
|
||||
|
||||
return thisSimpleName is not null
|
||||
&& string.Equals(thisSimpleName, otherSimpleName, StringComparison.Ordinal);
|
||||
return this.AssemblyName == other.AssemblyName && this.TypeName == other.TypeName;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
/// <remarks>Hashes the normalized type name and the simple assembly name.</remarks>
|
||||
public override int GetHashCode()
|
||||
=> HashCode.Combine(this.SimpleAssemblyName, this.NormalizedTypeName);
|
||||
public override int GetHashCode() => HashCode.Combine(this.AssemblyName, this.TypeName);
|
||||
|
||||
/// <inheritdoc />
|
||||
public static bool operator ==(TypeId? left, TypeId? right) => left is null ? right is null : left.Equals(right);
|
||||
@@ -97,27 +73,13 @@ public sealed class TypeId : IEquatable<TypeId>
|
||||
/// <summary>
|
||||
/// Determines whether the specified type matches both the assembly name and type name represented by this instance.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Compares the type full name and the simple assembly name. Version, culture, and public key
|
||||
/// token are ignored both in <see cref="AssemblyName"/> and in any assembly-qualified generic
|
||||
/// arguments embedded in <see cref="TypeName"/>.
|
||||
/// </remarks>
|
||||
/// <param name="type">The type to compare against the stored assembly and type names. Cannot be null.</param>
|
||||
/// <returns>true if the specified type's assembly simple name and normalized type full name are equal to those stored
|
||||
/// in this instance; otherwise, false.</returns>
|
||||
/// <returns>true if the specified type's assembly and type names are equal to those stored in this instance; otherwise,
|
||||
/// false.</returns>
|
||||
public bool IsMatch(Type type)
|
||||
{
|
||||
string? runtimeNormalizedTypeName = type.FullName is null ? null : NormalizeTypeName(type.FullName);
|
||||
if (this.NormalizedTypeName != runtimeNormalizedTypeName)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
string? storedSimpleName = this.SimpleAssemblyName;
|
||||
string? runtimeSimpleName = type.Assembly.GetName().Name;
|
||||
|
||||
return storedSimpleName is not null
|
||||
&& string.Equals(storedSimpleName, runtimeSimpleName, StringComparison.Ordinal);
|
||||
return this.AssemblyName == type.Assembly.FullName
|
||||
&& this.TypeName == type.FullName;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -151,64 +113,4 @@ public sealed class TypeId : IEquatable<TypeId>
|
||||
|
||||
/// <inheritdoc/>
|
||||
public override string ToString() => $"{this.TypeName}, {this.AssemblyName}";
|
||||
|
||||
/// <summary>
|
||||
/// The simple assembly name parsed from <see cref="AssemblyName"/>, lazily computed and cached.
|
||||
/// </summary>
|
||||
internal string? SimpleAssemblyName
|
||||
=> field ??= GetSimpleAssemblyName(this.AssemblyName);
|
||||
|
||||
/// <summary>
|
||||
/// The type full name with embedded assembly-qualified generic arguments stripped of
|
||||
/// version, culture, and public key token. Lazily computed and cached.
|
||||
/// </summary>
|
||||
internal string NormalizedTypeName
|
||||
=> field ??= NormalizeTypeName(this.TypeName);
|
||||
|
||||
private static readonly Regex s_assemblyQualifierPattern = new(
|
||||
@", Version=[^,\]]+, Culture=[^,\]]+, PublicKeyToken=[^,\]]+",
|
||||
RegexOptions.Compiled | RegexOptions.CultureInvariant);
|
||||
|
||||
/// <summary>
|
||||
/// Removes <c>, Version=...</c>, <c>, Culture=...</c>, and <c>, PublicKeyToken=...</c> triplets
|
||||
/// from <paramref name="typeName"/>. Returns the input unchanged when no triplet is present.
|
||||
/// </summary>
|
||||
internal static string NormalizeTypeName(string typeName)
|
||||
{
|
||||
if (typeName.IndexOf("Version=", StringComparison.Ordinal) < 0)
|
||||
{
|
||||
return typeName;
|
||||
}
|
||||
|
||||
return s_assemblyQualifierPattern.Replace(typeName, string.Empty);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns the simple assembly name parsed from an <see cref="Assembly.FullName"/>-style string,
|
||||
/// or <see langword="null"/> when both parsing and the substring fallback fail.
|
||||
/// </summary>
|
||||
internal static string? GetSimpleAssemblyName(string assemblyFullName)
|
||||
{
|
||||
if (string.IsNullOrEmpty(assemblyFullName))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
string? parsed = new AssemblyName(assemblyFullName).Name;
|
||||
if (!string.IsNullOrEmpty(parsed))
|
||||
{
|
||||
return parsed;
|
||||
}
|
||||
}
|
||||
catch (Exception ex) when (ex is FileLoadException or ArgumentException)
|
||||
{
|
||||
// Fall through to substring fallback.
|
||||
}
|
||||
|
||||
int comma = assemblyFullName.IndexOf(',');
|
||||
string fallback = (comma < 0 ? assemblyFullName : assemblyFullName.Substring(0, comma)).Trim();
|
||||
return fallback.Length == 0 ? null : fallback;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System;
|
||||
using System.Collections.Concurrent;
|
||||
using System.Collections.Generic;
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
using System.Linq;
|
||||
@@ -298,14 +297,12 @@ internal sealed class WorkflowSession : AgentSession
|
||||
/// interface assignment succeeds.
|
||||
/// </summary>
|
||||
[UnconditionalSuppressMessage("Trimming", "IL2057:Unrecognized value passed to the parameter of method", Justification = "Higher-layer envelope types are explicitly preserved by the package that defines them.")]
|
||||
[UnconditionalSuppressMessage("Trimming", "IL2026:Members annotated with 'RequiresUnreferencedCodeAttribute' require dynamic access", Justification = "Higher-layer envelope types are explicitly preserved by the package that defines them.")]
|
||||
[UnconditionalSuppressMessage("Trimming", "IL2073:Members annotated with 'DynamicallyAccessedMembersAttribute' require dynamic access", Justification = "Higher-layer envelope types are explicitly preserved by the package that defines them.")]
|
||||
private static bool TryGetRequestEnvelope(ExternalRequest request, [NotNullWhen(true)] out IExternalRequestEnvelope? envelope)
|
||||
{
|
||||
envelope = null;
|
||||
|
||||
TypeId requestType = request.PortInfo.RequestType;
|
||||
Type? concreteType = ResolveTypeLenient(requestType);
|
||||
Type? concreteType = Type.GetType($"{requestType.TypeName}, {requestType.AssemblyName}", throwOnError: false);
|
||||
if (concreteType is null || !typeof(IExternalRequestEnvelope).IsAssignableFrom(concreteType))
|
||||
{
|
||||
return false;
|
||||
@@ -320,20 +317,6 @@ internal sealed class WorkflowSession : AgentSession
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <summary>Caches <see cref="ResolveTypeLenient"/> results keyed by <see cref="TypeId"/>.</summary>
|
||||
private static readonly ConcurrentDictionary<TypeId, Type?> s_envelopeTypeCache = new();
|
||||
|
||||
/// <summary>
|
||||
/// Resolves a <see cref="TypeId"/> to a loaded <see cref="Type"/> using partial-name binding,
|
||||
/// which matches any loaded assembly with the same simple name regardless of version. Results
|
||||
/// are cached.
|
||||
/// </summary>
|
||||
[UnconditionalSuppressMessage("Trimming", "IL2026:Members annotated with 'RequiresUnreferencedCodeAttribute' require dynamic access", Justification = "Higher-layer envelope types are explicitly preserved by the package that defines them.")]
|
||||
[UnconditionalSuppressMessage("Trimming", "IL2057:Unrecognized value passed to the parameter of method", Justification = "Higher-layer envelope types are explicitly preserved by the package that defines them.")]
|
||||
internal static Type? ResolveTypeLenient(TypeId typeId)
|
||||
=> s_envelopeTypeCache.GetOrAdd(typeId, static id =>
|
||||
Type.GetType($"{id.NormalizedTypeName}, {id.SimpleAssemblyName}", throwOnError: false));
|
||||
|
||||
/// <summary>
|
||||
/// Creates the workflow-facing request content surfaced in response updates.
|
||||
/// </summary>
|
||||
|
||||
@@ -94,12 +94,6 @@ public static class ChatClientExtensions
|
||||
chatBuilder.Use(innerClient => new PerServiceCallChatHistoryPersistingChatClient(innerClient));
|
||||
}
|
||||
|
||||
// DeferredOpenTelemetryChatClient is registered last so it sits as the innermost decorator, directly
|
||||
// above the leaf client and below FunctionInvokingChatClient. It is inert until an OpenTelemetryAgent
|
||||
// activates it. Placing OpenTelemetry below FICC ensures the chat span closes before tools are invoked,
|
||||
// so FICC emits execute_tool spans on the agent source.
|
||||
chatBuilder.Use(innerClient => new DeferredOpenTelemetryChatClient(innerClient));
|
||||
|
||||
var agentChatClient = chatBuilder.Build(services);
|
||||
|
||||
if (options?.ChatOptions?.Tools is { Count: > 0 })
|
||||
|
||||
@@ -1,133 +0,0 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Extensions.AI;
|
||||
using Microsoft.Shared.Diagnostics;
|
||||
|
||||
namespace Microsoft.Agents.AI;
|
||||
|
||||
/// <summary>
|
||||
/// A delegating chat client that reserves a position for OpenTelemetry instrumentation directly above
|
||||
/// the leaf <see cref="IChatClient"/> and below the <see cref="FunctionInvokingChatClient"/> in a
|
||||
/// <see cref="ChatClientAgent"/> pipeline.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// The slot is inert until <see cref="Activate"/> is called: it simply forwards to its inner client.
|
||||
/// When the agent is wrapped by an <see cref="OpenTelemetryAgent"/>, that agent activates the slot with
|
||||
/// the resolved source name, at which point the slot routes calls through an
|
||||
/// <see cref="OpenTelemetryChatClient"/> so chat spans are emitted below the
|
||||
/// <see cref="FunctionInvokingChatClient"/>.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Positioning OpenTelemetry below FICC is required for tool telemetry: the chat span then closes before
|
||||
/// FICC invokes tools, so <see cref="System.Diagnostics.Activity.Current"/> is the invoke_agent span and
|
||||
/// FICC emits execute_tool spans on the agent source.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
internal sealed class DeferredOpenTelemetryChatClient : DelegatingChatClient
|
||||
{
|
||||
private readonly object _activationLock = new();
|
||||
private volatile IChatClient _target;
|
||||
private OpenTelemetryChatClient? _activatedClient;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="DeferredOpenTelemetryChatClient"/> class in its inert state.
|
||||
/// </summary>
|
||||
/// <param name="innerClient">The underlying chat client to forward to until the slot is activated.</param>
|
||||
public DeferredOpenTelemetryChatClient(IChatClient innerClient)
|
||||
: base(innerClient)
|
||||
{
|
||||
this._target = innerClient;
|
||||
}
|
||||
|
||||
/// <summary>Gets a value indicating whether the slot has been activated.</summary>
|
||||
public bool IsActive => !ReferenceEquals(this._target, this.InnerClient);
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets a value indicating whether the activated <see cref="OpenTelemetryChatClient"/> should
|
||||
/// include potentially sensitive information (such as message content) in telemetry. Reading or writing
|
||||
/// this property is a no-op while the slot is inert; the owning <see cref="OpenTelemetryAgent"/> applies
|
||||
/// and propagates the value once the slot is activated.
|
||||
/// </summary>
|
||||
public bool EnableSensitiveData
|
||||
{
|
||||
get => this._activatedClient?.EnableSensitiveData ?? false;
|
||||
set
|
||||
{
|
||||
if (this._activatedClient is { } activatedClient)
|
||||
{
|
||||
activatedClient.EnableSensitiveData = value;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Activates the slot so that calls are routed through an <see cref="OpenTelemetryChatClient"/> wrapping
|
||||
/// the inner client under the specified <paramref name="sourceName"/>. Idempotent and thread-safe; a
|
||||
/// second call (or a call after another thread activated the slot) is a no-op.
|
||||
/// </summary>
|
||||
/// <param name="sourceName">The telemetry source name to emit chat spans under.</param>
|
||||
public void Activate(string sourceName)
|
||||
{
|
||||
if (this.IsActive)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
lock (this._activationLock)
|
||||
{
|
||||
if (this.IsActive)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var activatedTarget = this.InnerClient.AsBuilder().UseOpenTelemetry(sourceName: sourceName).Build();
|
||||
|
||||
// Capture the OpenTelemetryChatClient so the owning agent can propagate EnableSensitiveData to it
|
||||
// (the agent's value may be set after construction, e.g. via the UseOpenTelemetry configure callback).
|
||||
this._activatedClient = activatedTarget.GetService(typeof(OpenTelemetryChatClient)) as OpenTelemetryChatClient;
|
||||
this._target = activatedTarget;
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public override Task<ChatResponse> GetResponseAsync(
|
||||
IEnumerable<ChatMessage> messages, ChatOptions? options = null, CancellationToken cancellationToken = default) =>
|
||||
this._target.GetResponseAsync(messages, options, cancellationToken);
|
||||
|
||||
/// <inheritdoc/>
|
||||
public override IAsyncEnumerable<ChatResponseUpdate> GetStreamingResponseAsync(
|
||||
IEnumerable<ChatMessage> messages, ChatOptions? options = null, CancellationToken cancellationToken = default) =>
|
||||
this._target.GetStreamingResponseAsync(messages, options, cancellationToken);
|
||||
|
||||
/// <inheritdoc/>
|
||||
public override object? GetService(Type serviceType, object? serviceKey = null)
|
||||
{
|
||||
_ = Throw.IfNull(serviceType);
|
||||
|
||||
// Return this slot for its own type and base contracts; otherwise forward to the current target so
|
||||
// that, once activated, queries such as OpenTelemetryChatClient and ActivitySource resolve to the
|
||||
// activated instrumentation rather than the bare leaf.
|
||||
return serviceKey is null && serviceType.IsInstanceOfType(this)
|
||||
? this
|
||||
: this._target.GetService(serviceType, serviceKey);
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
protected override void Dispose(bool disposing)
|
||||
{
|
||||
if (disposing && !ReferenceEquals(this._target, this.InnerClient))
|
||||
{
|
||||
// When activated, _target is an OpenTelemetryChatClient wrapping the inner client; dispose it so its
|
||||
// own telemetry resources are released. It also disposes the inner client, which is idempotent with the
|
||||
// base.Dispose call below.
|
||||
this._target.Dispose();
|
||||
}
|
||||
|
||||
base.Dispose(disposing);
|
||||
}
|
||||
}
|
||||
@@ -46,6 +46,8 @@ public sealed class AgentModeProvider : AIContextProvider
|
||||
## Agent Mode
|
||||
|
||||
- You can operate in different modes. Depending on the mode you are in, you will be required to follow different processes.
|
||||
- You must check the current mode after any user input, since the user may have changed the mode themselves,
|
||||
e.g. the user may have switched to 'plan' mode after a previous research task finished in 'execute' mode, meaning they want to review a plan first before execution.
|
||||
|
||||
Use the mode_get tool to check your current operating mode.
|
||||
Use the mode_set tool to switch between modes as your work progresses. Only use mode_set if the user explicitly instructs/allows you to change modes.
|
||||
@@ -82,12 +84,9 @@ public sealed class AgentModeProvider : AIContextProvider
|
||||
new(
|
||||
"execute",
|
||||
"""
|
||||
Determine the type of ask:
|
||||
1. Simple question that doesn't require any further work to answer.
|
||||
2. Any other work, including complex user request that requires a multi-step process to satisfy.
|
||||
|
||||
If 1. just answer the question directly.
|
||||
If 2. Work autonomously using your best judgment — do not ask the user questions or wait for feedback and follow the following process:
|
||||
Use this mode when carrying out approved plans. Work autonomously using your best judgment — do not ask the user questions or wait for feedback.
|
||||
|
||||
Process to follow when in execute mode:
|
||||
1. If you don't have a plan or tasks yet, analyze the user request and create tasks and a plan. (**Skip this step if you came from plan mode**)
|
||||
2. Work autonomously — use your best judgment to make decisions and keep progressing without asking the user questions. The goal is to have a complete, useful result ready when the user returns.
|
||||
3. If you encounter ambiguity or an unexpected situation during execution, choose the most reasonable option, note your choice, and keep going.
|
||||
|
||||
@@ -46,16 +46,11 @@ public sealed class TodoProvider : AIContextProvider, IDisposable
|
||||
## Todo Items
|
||||
|
||||
You have access to a todo list for tracking work items.
|
||||
When a user asks you to perform a task, follow these steps to manage your work:
|
||||
1. Determine whether the ask requires multiple steps to complete (complex) or can be completed using a single step (simple).
|
||||
2. If complex, turn the task into manageable todo items and add them to the list.
|
||||
3. If simple, don't add a todo item, but rather just complete the task directly.
|
||||
|
||||
### General TODO Guidelines
|
||||
While planning, make sure that you break down complex tasks into manageable todo items and add them to the list.
|
||||
Ask questions from the user where clarification is needed to create effective todos.
|
||||
If the user provides feedback on your plan, adjust your todos accordingly by adding new items or removing irrelevant/old ones.
|
||||
During execution, use the todo list to keep track of what needs to be done, mark items as complete when finished, and remove any items that are no longer needed.
|
||||
When a user changes the topic, changes their mind or switches to a new request, ensure that you update the todo list accordingly by removing irrelevant/old items, clearing the list, or adding new ones as needed.
|
||||
When a user changes the topic or changes their mind, ensure that you update the todo list accordingly by removing irrelevant/old items or adding new ones as needed.
|
||||
|
||||
Use these tools to manage your tasks:
|
||||
- Use todos_add to break down complex work into trackable items (supports adding one or many at once).
|
||||
|
||||
@@ -42,12 +42,6 @@ public sealed class OpenTelemetryAgent : DelegatingAIAgent, IDisposable
|
||||
/// </summary>
|
||||
private readonly bool _autoWireChatClient;
|
||||
|
||||
/// <summary>
|
||||
/// The auto-wired below-FICC telemetry slot, when one was activated. Cached so that updates to
|
||||
/// <see cref="EnableSensitiveData"/> made after construction can be propagated to it.
|
||||
/// </summary>
|
||||
private DeferredOpenTelemetryChatClient? _innerTelemetrySlot;
|
||||
|
||||
/// <summary>Initializes a new instance of the <see cref="OpenTelemetryAgent"/> class.</summary>
|
||||
/// <param name="innerAgent">The underlying <see cref="AIAgent"/> to be augmented with telemetry capabilities.</param>
|
||||
/// <param name="sourceName">
|
||||
@@ -97,8 +91,6 @@ public sealed class OpenTelemetryAgent : DelegatingAIAgent, IDisposable
|
||||
this._otelClient = new OpenTelemetryChatClient(
|
||||
new ForwardingChatClient(this),
|
||||
sourceName: this._sourceName);
|
||||
|
||||
this.TryActivateInnerChatClientTelemetry();
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
@@ -128,16 +120,7 @@ public sealed class OpenTelemetryAgent : DelegatingAIAgent, IDisposable
|
||||
public bool EnableSensitiveData
|
||||
{
|
||||
get => this._otelClient.EnableSensitiveData;
|
||||
set
|
||||
{
|
||||
this._otelClient.EnableSensitiveData = value;
|
||||
|
||||
// Keep the auto-wired below-FICC slot in sync so its chat span captures message content too.
|
||||
if (this._innerTelemetrySlot is { } slot)
|
||||
{
|
||||
slot.EnableSensitiveData = value;
|
||||
}
|
||||
}
|
||||
set => this._otelClient.EnableSensitiveData = value;
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
@@ -221,53 +204,85 @@ public sealed class OpenTelemetryAgent : DelegatingAIAgent, IDisposable
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// When auto-wiring is enabled and the inner agent is a <see cref="ChatClientAgent"/> whose underlying
|
||||
/// <see cref="IChatClient"/> is not already instrumented, activates the in-place
|
||||
/// <see cref="DeferredOpenTelemetryChatClient"/> slot so that chat spans are emitted below the
|
||||
/// <see cref="FunctionInvokingChatClient"/> under this agent's source name. Positioning OpenTelemetry below FICC
|
||||
/// is what allows FICC to emit execute_tool spans on the agent source. Respects
|
||||
/// <see cref="ChatClientAgentOptions.UseProvidedChatClientAsIs"/> and is a no-op when no slot is reachable.
|
||||
/// If auto-wiring is enabled and the inner agent is a <see cref="ChatClientAgent"/> whose underlying
|
||||
/// <see cref="IChatClient"/> is not already instrumented with <see cref="OpenTelemetryChatClient"/>, returns a
|
||||
/// new <see cref="ChatClientAgentRunOptions"/> with a <see cref="ChatClientAgentRunOptions.ChatClientFactory"/>
|
||||
/// that wraps the chat client with <see cref="OpenTelemetryChatClient"/>. When <paramref name="options"/> is a
|
||||
/// plain <see cref="AgentRunOptions"/> (the base type, not <see cref="ChatClientAgentRunOptions"/>), the base
|
||||
/// properties are copied onto the new <see cref="ChatClientAgentRunOptions"/> so high-level callers that pass
|
||||
/// the abstract <see cref="AgentRunOptions"/> still benefit from auto-wiring and propagate their settings to
|
||||
/// the inner agent. Otherwise, returns <paramref name="options"/> unchanged.
|
||||
/// </summary>
|
||||
private void TryActivateInnerChatClientTelemetry()
|
||||
private AgentRunOptions? GetRunOptionsWithChatClientWiring(AgentRunOptions? options)
|
||||
{
|
||||
if (!this._autoWireChatClient)
|
||||
{
|
||||
return;
|
||||
return options;
|
||||
}
|
||||
|
||||
// Auto-wiring only applies when a ChatClientAgent is reachable from the inner agent. Otherwise, no-op.
|
||||
// The auto-wiring only applies when a ChatClientAgent is reachable from the inner agent. Otherwise, no-op.
|
||||
// Use GetService rather than a type check so wrapping agents that expose a nested ChatClientAgent are supported.
|
||||
var chatClientAgent = this.InnerAgent.GetService<ChatClientAgent>();
|
||||
if (chatClientAgent is null)
|
||||
{
|
||||
return;
|
||||
return options;
|
||||
}
|
||||
|
||||
// Respect ChatClientAgentOptions.UseProvidedChatClientAsIs: don't decorate the chat client when the user opted out.
|
||||
if (chatClientAgent.GetService<ChatClientAgentOptions>()?.UseProvidedChatClientAsIs is true)
|
||||
{
|
||||
return;
|
||||
return options;
|
||||
}
|
||||
|
||||
// Don't activate when the chat client is already instrumented (e.g. the caller added their own
|
||||
// OpenTelemetryChatClient), to avoid emitting duplicate chat spans.
|
||||
// Capture the underlying IChatClient and check whether it is already instrumented.
|
||||
var chatClient = chatClientAgent.GetService<IChatClient>();
|
||||
if (chatClient is null || chatClient.GetService(typeof(OpenTelemetryChatClient)) is not null)
|
||||
{
|
||||
return;
|
||||
return options;
|
||||
}
|
||||
|
||||
// Activate the pre-placed slot in-place (below FICC) rather than wrapping a new OpenTelemetryChatClient
|
||||
// around the whole pipeline on each run. Cache it and seed its EnableSensitiveData from the current
|
||||
// value so a later change to this agent's EnableSensitiveData can be propagated to the inner chat span.
|
||||
if (chatClient.GetService(typeof(DeferredOpenTelemetryChatClient)) is DeferredOpenTelemetryChatClient slot)
|
||||
string sourceName = this._sourceName;
|
||||
bool enableSensitiveData = this.EnableSensitiveData;
|
||||
static IChatClient WrapIfNeeded(IChatClient cc, string sourceName, bool enableSensitiveData) =>
|
||||
cc.GetService(typeof(OpenTelemetryChatClient)) is not null
|
||||
? cc
|
||||
: cc.AsBuilder().UseOpenTelemetry(sourceName: sourceName, configure: o => o.EnableSensitiveData = enableSensitiveData).Build();
|
||||
|
||||
if (options is ChatClientAgentRunOptions ccOptions)
|
||||
{
|
||||
slot.Activate(this._sourceName);
|
||||
slot.EnableSensitiveData = this.EnableSensitiveData;
|
||||
this._innerTelemetrySlot = slot;
|
||||
// Don't mutate the caller's options; clone and chain any caller-provided factory.
|
||||
// If the user factory already returns an OpenTelemetry-instrumented client, don't double-wrap.
|
||||
var clone = (ChatClientAgentRunOptions)ccOptions.Clone();
|
||||
var userFactory = clone.ChatClientFactory;
|
||||
clone.ChatClientFactory = cc => WrapIfNeeded(userFactory is null ? cc : userFactory(cc), sourceName, enableSensitiveData);
|
||||
return clone;
|
||||
}
|
||||
|
||||
// For a plain AgentRunOptions (or null), create a ChatClientAgentRunOptions and preserve
|
||||
// any base AgentRunOptions properties from the caller so they reach the inner agent.
|
||||
var newOptions = new ChatClientAgentRunOptions
|
||||
{
|
||||
ChatClientFactory = cc => WrapIfNeeded(cc, sourceName, enableSensitiveData),
|
||||
};
|
||||
|
||||
if (options is not null)
|
||||
{
|
||||
CopyBaseAgentRunOptions(options, newOptions);
|
||||
}
|
||||
|
||||
return newOptions;
|
||||
}
|
||||
|
||||
#pragma warning disable MEAI001 // ContinuationToken is experimental; copy it through to preserve caller-provided value.
|
||||
private static void CopyBaseAgentRunOptions(AgentRunOptions source, AgentRunOptions target)
|
||||
{
|
||||
target.ContinuationToken = source.ContinuationToken;
|
||||
target.AllowBackgroundResponses = source.AllowBackgroundResponses;
|
||||
target.AdditionalProperties = source.AdditionalProperties?.Clone();
|
||||
target.ResponseFormat = source.ResponseFormat;
|
||||
}
|
||||
#pragma warning restore MEAI001
|
||||
|
||||
/// <summary>The stub <see cref="IChatClient"/> used to delegate from the <see cref="OpenTelemetryChatClient"/> into the inner <see cref="AIAgent"/>.</summary>
|
||||
/// <param name="parentAgent"></param>
|
||||
private sealed class ForwardingChatClient(OpenTelemetryAgent parentAgent) : IChatClient
|
||||
@@ -280,9 +295,11 @@ public sealed class OpenTelemetryAgent : DelegatingAIAgent, IDisposable
|
||||
// Update the current activity to reflect the agent invocation.
|
||||
parentAgent.UpdateCurrentActivity(fo?.CurrentActivity);
|
||||
|
||||
// Invoke the inner agent. Chat-level telemetry is emitted by the in-place DeferredOpenTelemetryChatClient
|
||||
// slot (below FICC), activated once at construction; no per-run chat-client wiring is needed here.
|
||||
var response = await parentAgent.InnerAgent.RunAsync(messages, fo?.Session, fo?.Options, cancellationToken).ConfigureAwait(false);
|
||||
// If enabled, wire the underlying chat client with OpenTelemetryChatClient via ChatClientFactory.
|
||||
var runOptions = parentAgent.GetRunOptionsWithChatClientWiring(fo?.Options);
|
||||
|
||||
// Invoke the inner agent.
|
||||
var response = await parentAgent.InnerAgent.RunAsync(messages, fo?.Session, runOptions, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
// Wrap the response in a ChatResponse so we can pass it back through OpenTelemetryChatClient.
|
||||
return response.AsChatResponse();
|
||||
@@ -296,9 +313,11 @@ public sealed class OpenTelemetryAgent : DelegatingAIAgent, IDisposable
|
||||
// Update the current activity to reflect the agent invocation.
|
||||
parentAgent.UpdateCurrentActivity(fo?.CurrentActivity);
|
||||
|
||||
// Invoke the inner agent. Chat-level telemetry is emitted by the in-place DeferredOpenTelemetryChatClient
|
||||
// slot (below FICC), activated once at construction; no per-run chat-client wiring is needed here.
|
||||
await foreach (var update in parentAgent.InnerAgent.RunStreamingAsync(messages, fo?.Session, fo?.Options, cancellationToken).ConfigureAwait(false))
|
||||
// If enabled, wire the underlying chat client with OpenTelemetryChatClient via ChatClientFactory.
|
||||
var runOptions = parentAgent.GetRunOptionsWithChatClientWiring(fo?.Options);
|
||||
|
||||
// Invoke the inner agent.
|
||||
await foreach (var update in parentAgent.InnerAgent.RunStreamingAsync(messages, fo?.Session, runOptions, cancellationToken).ConfigureAwait(false))
|
||||
{
|
||||
// Wrap the response updates in ChatResponseUpdates so we can pass them back through OpenTelemetryChatClient.
|
||||
yield return update.AsChatResponseUpdate();
|
||||
|
||||
@@ -48,19 +48,16 @@ public sealed class AgentFileSkill : AgentSkill
|
||||
|
||||
/// <inheritdoc/>
|
||||
/// <remarks>
|
||||
/// Returns the raw SKILL.md content with an <c><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.
|
||||
/// 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.
|
||||
/// </remarks>
|
||||
public override ValueTask<string> GetContentAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
this._content ??=
|
||||
this._originalContent
|
||||
+ "\n" + AgentInlineSkillContentBuilder.BuildAvailableResourcesBlock(this._resources)
|
||||
+ "\n" + AgentInlineSkillContentBuilder.BuildAvailableScriptsBlock(this._scripts);
|
||||
return new(this._content);
|
||||
var content = this._content ??= this._scripts is { Count: > 0 }
|
||||
? this._originalContent + AgentInlineSkillContentBuilder.BuildScriptSchemasBlock(this._scripts)
|
||||
: this._originalContent;
|
||||
return new(content);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
||||
@@ -120,7 +120,6 @@ public abstract class AgentClassSkill<
|
||||
this.Frontmatter.Name,
|
||||
this.Frontmatter.Description,
|
||||
this.Instructions,
|
||||
this.Resources,
|
||||
this.Scripts));
|
||||
}
|
||||
|
||||
@@ -161,9 +160,8 @@ public abstract class AgentClassSkill<
|
||||
/// Override this property in derived classes to provide skill-specific resources.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Resources are listed in the <c><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.
|
||||
/// Resources are not automatically included in the skill body.
|
||||
/// To enable discovery, reference resources by name in the skill's instructions or in other resources.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public virtual IReadOnlyList<AgentSkillResource>? Resources => this._resources.Value;
|
||||
@@ -180,9 +178,8 @@ public abstract class AgentClassSkill<
|
||||
/// Override this property in derived classes to provide skill-specific scripts.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Scripts are listed in the <c><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.
|
||||
/// 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.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public virtual IReadOnlyList<AgentSkillScript>? Scripts => this._scripts.Value;
|
||||
@@ -205,9 +202,8 @@ public abstract class AgentClassSkill<
|
||||
/// Creates a skill resource backed by a static value.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// The resource is listed in the <c><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.
|
||||
/// Resources are not automatically included in the skill body.
|
||||
/// To enable discovery, reference the resource by name in the skill's instructions or in another resource.
|
||||
/// </remarks>
|
||||
/// <param name="name">The resource name.</param>
|
||||
/// <param name="value">The static resource value.</param>
|
||||
@@ -220,9 +216,8 @@ public abstract class AgentClassSkill<
|
||||
/// Creates a skill resource backed by a delegate that produces a dynamic value.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// The resource is listed in the <c><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.
|
||||
/// Resources are not automatically included in the skill body.
|
||||
/// To enable discovery, reference the resource by name in the skill's instructions or in another resource.
|
||||
/// </remarks>
|
||||
/// <param name="name">The resource name.</param>
|
||||
/// <param name="method">A method that produces the resource value when requested.</param>
|
||||
@@ -239,9 +234,8 @@ public abstract class AgentClassSkill<
|
||||
/// Creates a skill script backed by a delegate.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// The script is listed in the <c><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.
|
||||
/// 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.
|
||||
/// </remarks>
|
||||
/// <param name="name">The script name.</param>
|
||||
/// <param name="method">A method to execute when the script is invoked.</param>
|
||||
|
||||
@@ -107,7 +107,7 @@ public sealed class AgentInlineSkill : AgentSkill
|
||||
/// <inheritdoc/>
|
||||
public override ValueTask<string> GetContentAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
return new(this._cachedContent ??= AgentInlineSkillContentBuilder.Build(this.Frontmatter.Name, this.Frontmatter.Description, this._instructions, this._resources, this._scripts));
|
||||
return new(this._cachedContent ??= AgentInlineSkillContentBuilder.Build(this.Frontmatter.Name, this.Frontmatter.Description, this._instructions, this._scripts));
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
@@ -128,9 +128,8 @@ public sealed class AgentInlineSkill : AgentSkill
|
||||
/// Registers a static resource with this skill.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// The resource is listed in the <c><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.
|
||||
/// Resources are not automatically included in the skill body.
|
||||
/// To enable discovery, reference the resource by name in the skill's instructions or in another resource.
|
||||
/// </remarks>
|
||||
/// <param name="name">The resource name.</param>
|
||||
/// <param name="value">The static resource value.</param>
|
||||
@@ -147,9 +146,8 @@ public sealed class AgentInlineSkill : AgentSkill
|
||||
/// The delegate's parameters and return type are automatically marshaled via <c>AIFunctionFactory</c>.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// The resource is listed in the <c><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.
|
||||
/// Resources are not automatically included in the skill body.
|
||||
/// To enable discovery, reference the resource by name in the skill's instructions or in another resource.
|
||||
/// </remarks>
|
||||
/// <param name="name">The resource name.</param>
|
||||
/// <param name="method">A method that produces the resource value when requested.</param>
|
||||
@@ -170,9 +168,8 @@ public sealed class AgentInlineSkill : AgentSkill
|
||||
/// The delegate's parameters and return type are automatically marshaled via <c>AIFunctionFactory</c>.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// The script is listed in the <c><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.
|
||||
/// 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.
|
||||
/// </remarks>
|
||||
/// <param name="name">The script name.</param>
|
||||
/// <param name="method">A method to execute when the script is invoked.</param>
|
||||
|
||||
+17
-58
@@ -12,19 +12,17 @@ namespace Microsoft.Agents.AI;
|
||||
internal static class AgentInlineSkillContentBuilder
|
||||
{
|
||||
/// <summary>
|
||||
/// Builds the complete skill content containing name, description, instructions, resources, and script parameter schemas.
|
||||
/// Builds the complete skill content containing name, description, instructions, and script parameter schemas.
|
||||
/// </summary>
|
||||
/// <param name="name">The skill name.</param>
|
||||
/// <param name="description">The skill description.</param>
|
||||
/// <param name="instructions">The raw instructions text.</param>
|
||||
/// <param name="resources">Optional resources associated with the skill.</param>
|
||||
/// <param name="scripts">Optional scripts associated with the skill.</param>
|
||||
/// <returns>An XML-structured content string.</returns>
|
||||
public static string Build(
|
||||
string name,
|
||||
string description,
|
||||
string instructions,
|
||||
IReadOnlyList<AgentSkillResource>? resources,
|
||||
IReadOnlyList<AgentSkillScript>? scripts)
|
||||
{
|
||||
_ = Throw.IfNullOrWhitespace(name);
|
||||
@@ -39,71 +37,34 @@ internal static class AgentInlineSkillContentBuilder
|
||||
.Append(EscapeXmlString(instructions))
|
||||
.Append("\n</instructions>");
|
||||
|
||||
sb.Append('\n');
|
||||
sb.Append(BuildAvailableResourcesBlock(resources ?? []));
|
||||
sb.Append('\n');
|
||||
sb.Append(BuildAvailableScriptsBlock(scripts ?? []));
|
||||
if (scripts is { Count: > 0 })
|
||||
{
|
||||
sb.Append('\n');
|
||||
sb.Append(BuildScriptSchemasBlock(scripts));
|
||||
}
|
||||
|
||||
return sb.ToString();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Builds an <c><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.
|
||||
/// 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.
|
||||
/// </summary>
|
||||
/// <param name="scripts">The scripts to include in the block.</param>
|
||||
/// <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)
|
||||
/// <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)
|
||||
{
|
||||
_ = Throw.IfNull(scripts);
|
||||
|
||||
if (scripts.Count == 0)
|
||||
{
|
||||
// Emit an empty element so the model knows no scripts are available and does not hallucinate script names.
|
||||
return "\n<available_scripts />";
|
||||
return string.Empty;
|
||||
}
|
||||
|
||||
var sb = new StringBuilder();
|
||||
sb.Append("\n<available_scripts>\n");
|
||||
sb.Append("\n<script_schemas>\n");
|
||||
|
||||
foreach (var script in scripts)
|
||||
{
|
||||
@@ -111,17 +72,15 @@ internal static class AgentInlineSkillContentBuilder
|
||||
|
||||
if (parametersSchema is null)
|
||||
{
|
||||
sb.Append($" <script name=\"{EscapeXmlString(script.Name)}\"/>\n");
|
||||
sb.Append($" <schema script=\"{EscapeXmlString(script.Name)}\"/>\n");
|
||||
}
|
||||
else
|
||||
{
|
||||
sb.Append($" <script name=\"{EscapeXmlString(script.Name)}\">\n");
|
||||
sb.Append($" <parameters_schema>{EscapeXmlString(parametersSchema.Value.GetRawText(), preserveQuotes: true)}</parameters_schema>\n");
|
||||
sb.Append(" </script>\n");
|
||||
sb.Append($" <schema script=\"{EscapeXmlString(script.Name)}\">{EscapeXmlString(parametersSchema.Value.GetRawText(), preserveQuotes: true)}</schema>\n");
|
||||
}
|
||||
}
|
||||
|
||||
sb.Append("</available_scripts>");
|
||||
sb.Append("</script_schemas>");
|
||||
|
||||
return sb.ToString();
|
||||
}
|
||||
|
||||
-324
@@ -1,324 +0,0 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System.Text.Json;
|
||||
using GitHub.Copilot;
|
||||
using Microsoft.Extensions.AI;
|
||||
|
||||
namespace Microsoft.Agents.AI.GitHub.Copilot.UnitTests;
|
||||
|
||||
/// <summary>
|
||||
/// Unit tests for tool execution event projection in <see cref="GitHubCopilotAgent"/>.
|
||||
/// </summary>
|
||||
public sealed class ToolExecutionEventProjectionTests
|
||||
{
|
||||
private static JsonElement ParseJson(string json) => JsonDocument.Parse(json).RootElement;
|
||||
|
||||
[Fact]
|
||||
public void ConvertToAgentResponseUpdate_ToolExecutionStartEvent_ProducesFunctionCallContent()
|
||||
{
|
||||
// Arrange
|
||||
var copilotClient = new CopilotClient(new CopilotClientOptions());
|
||||
var agent = new GitHubCopilotAgent(copilotClient, ownsClient: false, id: "agent-1", tools: null);
|
||||
|
||||
var startEvent = new ToolExecutionStartEvent
|
||||
{
|
||||
Data = new ToolExecutionStartData
|
||||
{
|
||||
ToolCallId = "call-123",
|
||||
ToolName = "readFile",
|
||||
Arguments = ParseJson("{\"path\":\"/tmp/test.txt\"}")
|
||||
}
|
||||
};
|
||||
|
||||
// Act
|
||||
AgentResponseUpdate result = agent.ConvertToAgentResponseUpdate(startEvent);
|
||||
|
||||
// Assert
|
||||
Assert.Equal(ChatRole.Assistant, result.Role);
|
||||
Assert.Equal("agent-1", result.AgentId);
|
||||
|
||||
var content = Assert.Single(result.Contents);
|
||||
var functionCall = Assert.IsType<FunctionCallContent>(content);
|
||||
Assert.Equal("call-123", functionCall.CallId);
|
||||
Assert.Equal("readFile", functionCall.Name);
|
||||
Assert.NotNull(functionCall.Arguments);
|
||||
Assert.Equal("/tmp/test.txt", functionCall.Arguments!["path"]?.ToString());
|
||||
Assert.Same(startEvent, functionCall.RawRepresentation);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ConvertToAgentResponseUpdate_ToolExecutionStartEvent_WithNullArguments_ProducesNullArguments()
|
||||
{
|
||||
// Arrange
|
||||
var copilotClient = new CopilotClient(new CopilotClientOptions());
|
||||
var agent = new GitHubCopilotAgent(copilotClient, ownsClient: false, tools: null);
|
||||
|
||||
var startEvent = new ToolExecutionStartEvent
|
||||
{
|
||||
Data = new ToolExecutionStartData
|
||||
{
|
||||
ToolCallId = "call-456",
|
||||
ToolName = "listTools",
|
||||
Arguments = null
|
||||
}
|
||||
};
|
||||
|
||||
// Act
|
||||
AgentResponseUpdate result = agent.ConvertToAgentResponseUpdate(startEvent);
|
||||
|
||||
// Assert
|
||||
var content = Assert.Single(result.Contents);
|
||||
var functionCall = Assert.IsType<FunctionCallContent>(content);
|
||||
Assert.Equal("call-456", functionCall.CallId);
|
||||
Assert.Equal("listTools", functionCall.Name);
|
||||
Assert.Null(functionCall.Arguments);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ConvertToAgentResponseUpdate_ToolExecutionStartEvent_WithNullData_ProducesEmptyFunctionCall()
|
||||
{
|
||||
// Arrange
|
||||
var copilotClient = new CopilotClient(new CopilotClientOptions());
|
||||
var agent = new GitHubCopilotAgent(copilotClient, ownsClient: false, tools: null);
|
||||
|
||||
var startEvent = new ToolExecutionStartEvent { Data = null! };
|
||||
|
||||
// Act
|
||||
AgentResponseUpdate result = agent.ConvertToAgentResponseUpdate(startEvent);
|
||||
|
||||
// Assert
|
||||
var content = Assert.Single(result.Contents);
|
||||
var functionCall = Assert.IsType<FunctionCallContent>(content);
|
||||
Assert.Equal(string.Empty, functionCall.CallId);
|
||||
Assert.Equal(string.Empty, functionCall.Name);
|
||||
Assert.Null(functionCall.Arguments);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ConvertToAgentResponseUpdate_ToolExecutionCompleteEvent_WithSuccess_ProducesFunctionResultContent()
|
||||
{
|
||||
// Arrange
|
||||
var copilotClient = new CopilotClient(new CopilotClientOptions());
|
||||
var agent = new GitHubCopilotAgent(copilotClient, ownsClient: false, id: "agent-2", tools: null);
|
||||
|
||||
var completeEvent = new ToolExecutionCompleteEvent
|
||||
{
|
||||
Data = new ToolExecutionCompleteData
|
||||
{
|
||||
ToolCallId = "call-123",
|
||||
Success = true,
|
||||
Result = new ToolExecutionCompleteResult
|
||||
{
|
||||
Content = "{\"users\":[{\"name\":\"Alice\"}]}"
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Act
|
||||
AgentResponseUpdate result = agent.ConvertToAgentResponseUpdate(completeEvent);
|
||||
|
||||
// Assert
|
||||
Assert.Equal(ChatRole.Tool, result.Role);
|
||||
Assert.Equal("agent-2", result.AgentId);
|
||||
|
||||
var content = Assert.Single(result.Contents);
|
||||
var functionResult = Assert.IsType<FunctionResultContent>(content);
|
||||
Assert.Equal("call-123", functionResult.CallId);
|
||||
Assert.Equal("{\"users\":[{\"name\":\"Alice\"}]}", functionResult.Result);
|
||||
Assert.Same(completeEvent, functionResult.RawRepresentation);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ConvertToAgentResponseUpdate_ToolExecutionCompleteEvent_WithError_ProducesErrorResult()
|
||||
{
|
||||
// Arrange
|
||||
var copilotClient = new CopilotClient(new CopilotClientOptions());
|
||||
var agent = new GitHubCopilotAgent(copilotClient, ownsClient: false, tools: null);
|
||||
|
||||
var completeEvent = new ToolExecutionCompleteEvent
|
||||
{
|
||||
Data = new ToolExecutionCompleteData
|
||||
{
|
||||
ToolCallId = "call-789",
|
||||
Success = false,
|
||||
Error = new ToolExecutionCompleteError
|
||||
{
|
||||
Code = "PERMISSION_DENIED",
|
||||
Message = "Access denied to resource"
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Act
|
||||
AgentResponseUpdate result = agent.ConvertToAgentResponseUpdate(completeEvent);
|
||||
|
||||
// Assert
|
||||
Assert.Equal(ChatRole.Tool, result.Role);
|
||||
|
||||
var content = Assert.Single(result.Contents);
|
||||
var functionResult = Assert.IsType<FunctionResultContent>(content);
|
||||
Assert.Equal("call-789", functionResult.CallId);
|
||||
Assert.Equal("Access denied to resource", functionResult.Result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ConvertToAgentResponseUpdate_ToolExecutionCompleteEvent_WithFailureNoError_ProducesDefaultErrorMessage()
|
||||
{
|
||||
// Arrange
|
||||
var copilotClient = new CopilotClient(new CopilotClientOptions());
|
||||
var agent = new GitHubCopilotAgent(copilotClient, ownsClient: false, tools: null);
|
||||
|
||||
var completeEvent = new ToolExecutionCompleteEvent
|
||||
{
|
||||
Data = new ToolExecutionCompleteData
|
||||
{
|
||||
ToolCallId = "call-000",
|
||||
Success = false,
|
||||
Error = null
|
||||
}
|
||||
};
|
||||
|
||||
// Act
|
||||
AgentResponseUpdate result = agent.ConvertToAgentResponseUpdate(completeEvent);
|
||||
|
||||
// Assert
|
||||
var content = Assert.Single(result.Contents);
|
||||
var functionResult = Assert.IsType<FunctionResultContent>(content);
|
||||
Assert.Equal("call-000", functionResult.CallId);
|
||||
Assert.Equal("Tool execution failed", functionResult.Result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ConvertToAgentResponseUpdate_ToolExecutionCompleteEvent_WithNullData_ProducesEmptyResult()
|
||||
{
|
||||
// Arrange
|
||||
var copilotClient = new CopilotClient(new CopilotClientOptions());
|
||||
var agent = new GitHubCopilotAgent(copilotClient, ownsClient: false, tools: null);
|
||||
|
||||
var completeEvent = new ToolExecutionCompleteEvent { Data = null! };
|
||||
|
||||
// Act
|
||||
AgentResponseUpdate result = agent.ConvertToAgentResponseUpdate(completeEvent);
|
||||
|
||||
// Assert
|
||||
var content = Assert.Single(result.Contents);
|
||||
var functionResult = Assert.IsType<FunctionResultContent>(content);
|
||||
Assert.Equal(string.Empty, functionResult.CallId);
|
||||
Assert.Equal("Tool execution failed", functionResult.Result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ConvertToAgentResponseUpdate_ToolExecutionStartEvent_WithMultipleArguments_ParsesAll()
|
||||
{
|
||||
// Arrange
|
||||
var copilotClient = new CopilotClient(new CopilotClientOptions());
|
||||
var agent = new GitHubCopilotAgent(copilotClient, ownsClient: false, tools: null);
|
||||
|
||||
var startEvent = new ToolExecutionStartEvent
|
||||
{
|
||||
Data = new ToolExecutionStartData
|
||||
{
|
||||
ToolCallId = "call-multi",
|
||||
ToolName = "queryTable",
|
||||
Arguments = ParseJson("{\"table\":\"incidents\",\"limit\":10,\"filter\":\"active=true\"}")
|
||||
}
|
||||
};
|
||||
|
||||
// Act
|
||||
AgentResponseUpdate result = agent.ConvertToAgentResponseUpdate(startEvent);
|
||||
|
||||
// Assert
|
||||
var content = Assert.Single(result.Contents);
|
||||
var functionCall = Assert.IsType<FunctionCallContent>(content);
|
||||
Assert.Equal("call-multi", functionCall.CallId);
|
||||
Assert.Equal("queryTable", functionCall.Name);
|
||||
Assert.NotNull(functionCall.Arguments);
|
||||
Assert.Equal("incidents", functionCall.Arguments!["table"]?.ToString());
|
||||
Assert.Equal("10", functionCall.Arguments!["limit"]?.ToString());
|
||||
Assert.Equal("active=true", functionCall.Arguments!["filter"]?.ToString());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ConvertToAgentResponseUpdate_ToolExecutionCompleteEvent_WithSuccessButNullResult_ProducesNullResult()
|
||||
{
|
||||
// Arrange
|
||||
var copilotClient = new CopilotClient(new CopilotClientOptions());
|
||||
var agent = new GitHubCopilotAgent(copilotClient, ownsClient: false, tools: null);
|
||||
|
||||
var completeEvent = new ToolExecutionCompleteEvent
|
||||
{
|
||||
Data = new ToolExecutionCompleteData
|
||||
{
|
||||
ToolCallId = "call-null-result",
|
||||
Success = true,
|
||||
Result = null
|
||||
}
|
||||
};
|
||||
|
||||
// Act
|
||||
AgentResponseUpdate result = agent.ConvertToAgentResponseUpdate(completeEvent);
|
||||
|
||||
// Assert
|
||||
var content = Assert.Single(result.Contents);
|
||||
var functionResult = Assert.IsType<FunctionResultContent>(content);
|
||||
Assert.Equal("call-null-result", functionResult.CallId);
|
||||
Assert.Null(functionResult.Result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ConvertToAgentResponseUpdate_ToolExecutionStartEvent_WithEmptyObjectArguments_ProducesEmptyDictionary()
|
||||
{
|
||||
// Arrange
|
||||
var copilotClient = new CopilotClient(new CopilotClientOptions());
|
||||
var agent = new GitHubCopilotAgent(copilotClient, ownsClient: false, tools: null);
|
||||
|
||||
var startEvent = new ToolExecutionStartEvent
|
||||
{
|
||||
Data = new ToolExecutionStartData
|
||||
{
|
||||
ToolCallId = "call-empty",
|
||||
ToolName = "noArgsTool",
|
||||
Arguments = ParseJson("{}")
|
||||
}
|
||||
};
|
||||
|
||||
// Act
|
||||
AgentResponseUpdate result = agent.ConvertToAgentResponseUpdate(startEvent);
|
||||
|
||||
// Assert
|
||||
var content = Assert.Single(result.Contents);
|
||||
var functionCall = Assert.IsType<FunctionCallContent>(content);
|
||||
Assert.Equal("call-empty", functionCall.CallId);
|
||||
Assert.NotNull(functionCall.Arguments);
|
||||
Assert.Empty(functionCall.Arguments!);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ConvertToAgentResponseUpdate_ToolExecutionStartEvent_WithNestedJsonArguments_ParsesTopLevel()
|
||||
{
|
||||
// Arrange
|
||||
var copilotClient = new CopilotClient(new CopilotClientOptions());
|
||||
var agent = new GitHubCopilotAgent(copilotClient, ownsClient: false, tools: null);
|
||||
|
||||
var startEvent = new ToolExecutionStartEvent
|
||||
{
|
||||
Data = new ToolExecutionStartData
|
||||
{
|
||||
ToolCallId = "call-nested",
|
||||
ToolName = "complexTool",
|
||||
Arguments = ParseJson("{\"config\":{\"timeout\":30},\"name\":\"test\"}")
|
||||
}
|
||||
};
|
||||
|
||||
// Act
|
||||
AgentResponseUpdate result = agent.ConvertToAgentResponseUpdate(startEvent);
|
||||
|
||||
// Assert
|
||||
var content = Assert.Single(result.Contents);
|
||||
var functionCall = Assert.IsType<FunctionCallContent>(content);
|
||||
Assert.Equal("call-nested", functionCall.CallId);
|
||||
Assert.NotNull(functionCall.Arguments);
|
||||
Assert.Equal("test", functionCall.Arguments!["name"]?.ToString());
|
||||
Assert.NotNull(functionCall.Arguments!["config"]);
|
||||
}
|
||||
}
|
||||
+4
-4
@@ -62,10 +62,10 @@ public sealed class A2AServerServiceCollectionExtensionsTests
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that when no ITaskStore or AgentSessionStore are registered,
|
||||
/// AddA2AServer falls back to noop session store default and resolves successfully.
|
||||
/// AddA2AServer falls back to in-memory defaults and resolves successfully.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task AddA2AServer_WithNoCustomStores_FallsBackToNoopSessionStoreDefaultAsync()
|
||||
public async Task AddA2AServer_WithNoCustomStores_FallsBackToInMemoryDefaultsAsync()
|
||||
{
|
||||
// Arrange
|
||||
const string AgentName = "default-stores-agent";
|
||||
@@ -382,7 +382,7 @@ public sealed class A2AServerServiceCollectionExtensionsTests
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that when no custom stores or handlers are registered, the server uses
|
||||
/// the default noop session store and processes requests successfully end-to-end.
|
||||
/// the default in-memory stores and processes requests successfully end-to-end.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task AddA2AServer_WithNoCustomStores_DefaultStoresProcessRequestSuccessfullyAsync()
|
||||
@@ -400,7 +400,7 @@ public sealed class A2AServerServiceCollectionExtensionsTests
|
||||
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(10));
|
||||
var response = await server.SendMessageAsync(CreateTestSendMessageRequest(), cts.Token);
|
||||
|
||||
// Assert - request was processed successfully with default noop session store
|
||||
// Assert - request was processed successfully with default in-memory stores
|
||||
Assert.NotNull(response);
|
||||
Assert.Equal(SendMessageResponseCase.Message, response.PayloadCase);
|
||||
Assert.NotNull(response.Message);
|
||||
|
||||
@@ -382,10 +382,9 @@ public sealed class AgentClassSkillTests
|
||||
// Arrange
|
||||
var skill = new AttributedFullSkill();
|
||||
|
||||
// Act & Assert — Content includes resources in body; scripts are in available_scripts
|
||||
Assert.Contains("<available_resources>", await skill.GetContentAsync());
|
||||
Assert.Contains("conversion-table", await skill.GetContentAsync());
|
||||
Assert.Contains("<available_scripts>", await skill.GetContentAsync());
|
||||
// Act & Assert — Content no longer includes resources in body; scripts are in script_schemas
|
||||
Assert.DoesNotContain("<resources>", await skill.GetContentAsync());
|
||||
Assert.Contains("<script_schemas>", await skill.GetContentAsync());
|
||||
Assert.Contains("convert", await skill.GetContentAsync());
|
||||
|
||||
// Act & Assert — discovered members are cached
|
||||
@@ -503,7 +502,7 @@ public sealed class AgentClassSkillTests
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Content_RendersResources_InBodyAsync()
|
||||
public async Task Content_DoesNotRenderResources_InBodyAsync()
|
||||
{
|
||||
// Arrange
|
||||
var skill = new AttributedResourcePropertiesSkill();
|
||||
@@ -511,10 +510,8 @@ public sealed class AgentClassSkillTests
|
||||
// Act
|
||||
var content = await skill.GetContentAsync();
|
||||
|
||||
// Assert — resources are rendered in body content by name; descriptions are not emitted
|
||||
Assert.Contains("<available_resources>", content);
|
||||
Assert.Contains("ref-data", content);
|
||||
Assert.DoesNotContain("Some important data.", content);
|
||||
// Assert — resources are no longer rendered in body content
|
||||
Assert.DoesNotContain("<resources>", content);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
|
||||
+7
-58
@@ -122,17 +122,14 @@ public sealed class AgentFileSkillScriptTests
|
||||
|
||||
// Assert — content starts with original and appends per-script entries
|
||||
Assert.StartsWith("Original content", content);
|
||||
Assert.Contains("<available_scripts>", content);
|
||||
Assert.Contains("<script name=\"build\">", content);
|
||||
Assert.Contains("<script name=\"deploy\">", content);
|
||||
Assert.Contains("</available_scripts>", content);
|
||||
|
||||
// A scripts-only skill still emits an empty resources peer so the model knows none are available
|
||||
Assert.Contains("<available_resources />", content);
|
||||
Assert.Contains("<script_schemas>", content);
|
||||
Assert.Contains("<schema script=\"build\">", content);
|
||||
Assert.Contains("<schema script=\"deploy\">", content);
|
||||
Assert.Contains("</script_schemas>", content);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Content_WithoutResourcesOrScripts_EmitsSelfClosingPeersAsync()
|
||||
public async Task Content_WithoutScripts_ReturnsOriginalContentAsync()
|
||||
{
|
||||
// Arrange
|
||||
var fileSkill = new AgentFileSkill(
|
||||
@@ -143,56 +140,8 @@ public sealed class AgentFileSkillScriptTests
|
||||
// Act
|
||||
var content = await fileSkill.GetContentAsync();
|
||||
|
||||
// Assert — both blocks are always emitted as self-closing elements so the model knows none are available
|
||||
Assert.StartsWith("Original content only", content);
|
||||
Assert.Contains("<available_resources />", content);
|
||||
Assert.Contains("<available_scripts />", content);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Content_WithResources_AppendsResourceEntriesAsync()
|
||||
{
|
||||
// Arrange
|
||||
var fileSkill = new AgentFileSkill(
|
||||
new AgentSkillFrontmatter("my-skill", "A skill"),
|
||||
"Original content",
|
||||
"/skills/my-skill",
|
||||
resources: [new AgentInlineSkillResource("reference", "value"), new AgentInlineSkillResource("table", "value")]);
|
||||
|
||||
// Act
|
||||
var content = await fileSkill.GetContentAsync();
|
||||
|
||||
// Assert — content starts with original and appends per-resource entries so the model knows what is callable
|
||||
Assert.StartsWith("Original content", content);
|
||||
Assert.Contains("<available_resources>", content);
|
||||
Assert.Contains("<resource name=\"reference\"/>", content);
|
||||
Assert.Contains("<resource name=\"table\"/>", content);
|
||||
Assert.Contains("</available_resources>", content);
|
||||
|
||||
// A resources-only skill still emits an empty scripts peer so the model knows none are available
|
||||
Assert.Contains("<available_scripts />", content);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Content_WithResourcesAndScripts_AppendsResourcesBeforeScriptsAsync()
|
||||
{
|
||||
// Arrange
|
||||
static Task<object?> RunnerAsync(AgentFileSkill s, AgentFileSkillScript sc, JsonElement? a, IServiceProvider? sp, CancellationToken ct) => Task.FromResult<object?>(null);
|
||||
var fileSkill = new AgentFileSkill(
|
||||
new AgentSkillFrontmatter("my-skill", "A skill"),
|
||||
"Original content",
|
||||
"/skills/my-skill",
|
||||
resources: [new AgentInlineSkillResource("reference", "value")],
|
||||
scripts: [CreateScript("build", "/scripts/build.sh", RunnerAsync)]);
|
||||
|
||||
// Act
|
||||
var content = await fileSkill.GetContentAsync();
|
||||
|
||||
// Assert — resources block precedes scripts block
|
||||
var resourcesIndex = content.IndexOf("<available_resources>", StringComparison.Ordinal);
|
||||
var scriptsIndex = content.IndexOf("<available_scripts>", StringComparison.Ordinal);
|
||||
Assert.True(resourcesIndex >= 0 && scriptsIndex >= 0);
|
||||
Assert.True(resourcesIndex < scriptsIndex);
|
||||
// Assert
|
||||
Assert.Equal("Original content only", content);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
|
||||
-241
@@ -1,241 +0,0 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System;
|
||||
using System.Text.Json;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace Microsoft.Agents.AI.UnitTests.AgentSkills;
|
||||
|
||||
/// <summary>
|
||||
/// Unit tests for <see cref="AgentInlineSkillContentBuilder"/>, focusing on the structure of the
|
||||
/// emitted <c><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_IncludesResourcesInBodyAsync()
|
||||
public async Task Content_DoesNotIncludeResourcesInBodyAsync()
|
||||
{
|
||||
// Arrange
|
||||
var skill = new AgentInlineSkill("my-skill", "A valid skill.", "Instructions.");
|
||||
@@ -158,14 +158,12 @@ public sealed class AgentInlineSkillTests
|
||||
// Act
|
||||
var content = await skill.GetContentAsync();
|
||||
|
||||
// Assert — resources are rendered in the body so the model can discover them
|
||||
Assert.Contains("<available_resources>", content);
|
||||
Assert.Contains("<resource name=\"config\"/>", content);
|
||||
Assert.DoesNotContain("A config resource.", content);
|
||||
// Assert — resources are no longer rendered in the body; they're accessed via GetResourceAsync
|
||||
Assert.DoesNotContain("<resources>", content);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Content_IncludesDelegateResourcesInBodyAsync()
|
||||
public async Task Content_DoesNotIncludeDelegateResourcesInBodyAsync()
|
||||
{
|
||||
// Arrange
|
||||
var skill = new AgentInlineSkill("my-skill", "A valid skill.", "Instructions.");
|
||||
@@ -174,9 +172,8 @@ public sealed class AgentInlineSkillTests
|
||||
// Act
|
||||
var content = await skill.GetContentAsync();
|
||||
|
||||
// Assert — resources are rendered in the body
|
||||
Assert.Contains("<available_resources>", content);
|
||||
Assert.Contains("<resource name=\"dynamic\"/>", content);
|
||||
// Assert — resources are no longer rendered in the body
|
||||
Assert.DoesNotContain("<resources>", content);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -190,8 +187,8 @@ public sealed class AgentInlineSkillTests
|
||||
var content = await skill.GetContentAsync();
|
||||
|
||||
// Assert
|
||||
Assert.Contains("<available_scripts>", content);
|
||||
Assert.Contains("<script name=\"run\"", content);
|
||||
Assert.Contains("<script_schemas>", content);
|
||||
Assert.Contains("run", content);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -221,9 +218,8 @@ public sealed class AgentInlineSkillTests
|
||||
var content = await skill.GetContentAsync();
|
||||
|
||||
// Assert
|
||||
Assert.Contains("<available_resources>", content);
|
||||
Assert.Contains("r1", content);
|
||||
Assert.Contains("<available_scripts>", content);
|
||||
Assert.DoesNotContain("<resources>", content);
|
||||
Assert.Contains("<script_schemas>", content);
|
||||
Assert.Contains("s1", content);
|
||||
}
|
||||
|
||||
@@ -237,9 +233,8 @@ public sealed class AgentInlineSkillTests
|
||||
// Act
|
||||
var content = await skill.GetContentAsync();
|
||||
|
||||
// Assert — JSON schema should be present inside <parameters_schema> element with preserved quotes
|
||||
Assert.Contains("<script name=\"search\">", content);
|
||||
Assert.Contains("<parameters_schema>", content);
|
||||
// Assert — JSON schema should be present inside <schema> element (no extra wrapper) with preserved quotes
|
||||
Assert.Contains("<schema script=\"search\">", content);
|
||||
Assert.Contains("\"query\"", content);
|
||||
Assert.DoesNotContain("<![CDATA[", content);
|
||||
}
|
||||
@@ -422,7 +417,7 @@ public sealed class AgentInlineSkillTests
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Content_NoResourcesOrScripts_EmitsSelfClosingTagsAsync()
|
||||
public async Task Content_NoResourcesOrScripts_DoesNotContainResourcesOrScriptsTagsAsync()
|
||||
{
|
||||
// Arrange
|
||||
var skill = new AgentInlineSkill("my-skill", "A valid skill.", "Instructions.");
|
||||
@@ -430,9 +425,9 @@ public sealed class AgentInlineSkillTests
|
||||
// Act
|
||||
var content = await skill.GetContentAsync();
|
||||
|
||||
// Assert — empty self-closing elements are emitted when no resources or scripts exist
|
||||
Assert.Contains("<available_resources />", content);
|
||||
Assert.Contains("<available_scripts />", content);
|
||||
// Assert
|
||||
Assert.DoesNotContain("<resources>", content);
|
||||
Assert.DoesNotContain("<script_schemas>", content);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -475,8 +470,9 @@ public sealed class AgentInlineSkillTests
|
||||
// Act
|
||||
var content = await skill.GetContentAsync();
|
||||
|
||||
// Assert — only the script name is emitted; the description is not rendered as an attribute
|
||||
Assert.Contains("<script name=\"my-script\"", content);
|
||||
// Assert — description is no longer emitted in the script_schemas block;
|
||||
// the block only contains parameter schemas for calling scripts.
|
||||
Assert.Contains("<schema script=\"my-script\"", content);
|
||||
Assert.DoesNotContain("description=\"Runs something.\"", content);
|
||||
}
|
||||
|
||||
@@ -496,7 +492,7 @@ public sealed class AgentInlineSkillTests
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Content_ResourceWithDescription_RenderedInBodyWithoutDescriptionAsync()
|
||||
public async Task Content_ResourceWithDescription_NotRenderedInBodyAsync()
|
||||
{
|
||||
// Arrange
|
||||
var skill = new AgentInlineSkill("my-skill", "A valid skill.", "Instructions.");
|
||||
@@ -506,11 +502,10 @@ public sealed class AgentInlineSkillTests
|
||||
// Act
|
||||
var content = await skill.GetContentAsync();
|
||||
|
||||
// Assert — resources are rendered by name in the body; descriptions are not emitted
|
||||
Assert.Contains("<available_resources>", content);
|
||||
Assert.Contains("<resource name=\"with-desc\"/>", content);
|
||||
Assert.Contains("<resource name=\"no-desc\"/>", content);
|
||||
Assert.DoesNotContain("A described resource.", content);
|
||||
// Assert — resources are no longer rendered in the body
|
||||
Assert.DoesNotContain("<resources>", content);
|
||||
Assert.DoesNotContain("with-desc", content);
|
||||
Assert.DoesNotContain("no-desc", content);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
|
||||
@@ -795,9 +795,9 @@ public class OpenTelemetryAgentTests
|
||||
[Fact]
|
||||
public async Task AutoWireChatClient_PlainAgentRunOptions_PreservesBaseProperties_Async()
|
||||
{
|
||||
// The auto-wire no longer rewrites the caller's options (the slot below FICC is activated once at
|
||||
// construction), so a plain AgentRunOptions reaches the inner agent unchanged with all base
|
||||
// properties (AllowBackgroundResponses, AdditionalProperties, ResponseFormat) intact.
|
||||
// Auto-wiring converts a plain AgentRunOptions into a ChatClientAgentRunOptions. The base
|
||||
// properties (ContinuationToken, AllowBackgroundResponses, AdditionalProperties, ResponseFormat)
|
||||
// must be preserved so they reach the inner agent.
|
||||
AgentRunOptions? observedOptions = null;
|
||||
var fakeChatClient = new AutoWireTestChatClient();
|
||||
var innerChatClientAgent = new ChatClientAgent(fakeChatClient);
|
||||
@@ -827,27 +827,17 @@ public class OpenTelemetryAgentTests
|
||||
|
||||
_ = await agent.RunAsync("hi", options: inputOptions);
|
||||
|
||||
// Options flow through unchanged (same instance, no conversion to ChatClientAgentRunOptions).
|
||||
Assert.NotNull(observedOptions);
|
||||
Assert.Same(inputOptions, observedOptions);
|
||||
Assert.Equal(true, observedOptions.AllowBackgroundResponses);
|
||||
Assert.IsType<ChatClientAgentRunOptions>(observedOptions);
|
||||
Assert.Equal(true, observedOptions!.AllowBackgroundResponses);
|
||||
Assert.Same(ChatResponseFormat.Json, observedOptions.ResponseFormat);
|
||||
Assert.NotNull(observedOptions.AdditionalProperties);
|
||||
Assert.Equal("customValue", observedOptions.AdditionalProperties!["customKey"]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AutoWireChatClient_UserFactoryAddsOwnOTel_CoexistsWithBelowFiccSlot_Async()
|
||||
public async Task AutoWireChatClient_UserFactoryReturnsInstrumentedClient_DoesNotDoubleWrap_Async()
|
||||
{
|
||||
// This is NOT a single model call counted twice. One model call is observed by two independent
|
||||
// OpenTelemetry layers, so each layer emits its own "chat" span:
|
||||
// - the framework's slot, always activated below FICC by OpenTelemetryAgent. This below-FICC layer
|
||||
// is what lets FICC emit execute_tool spans, so it must remain even when the caller adds their own
|
||||
// instrumentation. Dropping it to avoid the second span would reintroduce the missing-tool-span bug.
|
||||
// - the caller's per-run ChatClientFactory, which wraps the pipeline above FICC with its own
|
||||
// OpenTelemetryChatClient.
|
||||
// The two chat spans nest and measure different scopes (the above-FICC span covers the whole tool loop,
|
||||
// the below-FICC span covers each individual model call), so both coexisting is the intended result.
|
||||
var sourceName = Guid.NewGuid().ToString();
|
||||
var activities = new List<Activity>();
|
||||
using var tracerProvider = OpenTelemetry.Sdk.CreateTracerProviderBuilder()
|
||||
@@ -859,7 +849,7 @@ public class OpenTelemetryAgentTests
|
||||
var inner = new ChatClientAgent(fakeChatClient);
|
||||
using var agent = new OpenTelemetryAgent(inner, sourceName);
|
||||
|
||||
// User factory wraps the chat client with OpenTelemetryChatClient itself (above FICC).
|
||||
// User factory wraps the chat client with OpenTelemetryChatClient itself.
|
||||
var runOptions = new ChatClientAgentRunOptions
|
||||
{
|
||||
ChatClientFactory = cc => cc.AsBuilder().UseOpenTelemetry(sourceName: sourceName).Build(),
|
||||
@@ -867,10 +857,8 @@ public class OpenTelemetryAgentTests
|
||||
|
||||
_ = await agent.RunAsync("hi", options: runOptions);
|
||||
|
||||
// invoke_agent + two chat spans: one from the caller's above-FICC OTel and one from the slot below FICC.
|
||||
Assert.Equal(3, activities.Count);
|
||||
Assert.Contains(activities, a => a.DisplayName.StartsWith("invoke_agent", StringComparison.Ordinal));
|
||||
Assert.Equal(2, activities.Count(a => string.Equals(a.GetTagItem("gen_ai.operation.name") as string, "chat", StringComparison.Ordinal)));
|
||||
// Expect 2 activities (invoke_agent + a single chat span). If we double-wrapped, we would see 3.
|
||||
Assert.Equal(2, activities.Count);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
@@ -905,8 +893,8 @@ public class OpenTelemetryAgentTests
|
||||
[Fact]
|
||||
public async Task AutoWireChatClient_PlainAgentRunOptions_PreservesContinuationToken_Async()
|
||||
{
|
||||
// ContinuationToken on a plain AgentRunOptions must reach the inner agent unchanged now that the
|
||||
// auto-wire passes the caller's options straight through (no conversion to ChatClientAgentRunOptions).
|
||||
// ContinuationToken is the fourth base AgentRunOptions property copied by CopyBaseAgentRunOptions
|
||||
// and is not exercised by AutoWireChatClient_PlainAgentRunOptions_PreservesBaseProperties_Async.
|
||||
AgentRunOptions? observedOptions = null;
|
||||
var fakeChatClient = new AutoWireTestChatClient();
|
||||
var innerChatClientAgent = new ChatClientAgent(fakeChatClient);
|
||||
@@ -933,8 +921,8 @@ public class OpenTelemetryAgentTests
|
||||
_ = await agent.RunAsync("hi", options: inputOptions);
|
||||
|
||||
Assert.NotNull(observedOptions);
|
||||
Assert.Same(inputOptions, observedOptions);
|
||||
Assert.Same(token, observedOptions.ContinuationToken);
|
||||
Assert.IsType<ChatClientAgentRunOptions>(observedOptions);
|
||||
Assert.Same(token, observedOptions!.ContinuationToken);
|
||||
}
|
||||
#pragma warning restore MEAI001
|
||||
|
||||
@@ -1073,10 +1061,11 @@ public class OpenTelemetryAgentTests
|
||||
[InlineData(true, true)]
|
||||
public async Task AutoWireChatClient_EnableSensitiveData_PropagatedToInnerChatClient_Async(bool enableSensitiveData, bool streaming)
|
||||
{
|
||||
// Regression test (issue #5873): when EnableSensitiveData is set on OpenTelemetryAgent, the auto-wired
|
||||
// inner OpenTelemetryChatClient (the below-FICC slot) must also have EnableSensitiveData propagated to it,
|
||||
// so the inner chat span captures gen_ai.input.messages / gen_ai.output.messages. The agent sets the value
|
||||
// on the slot after construction, since EnableSensitiveData is typically set via the UseOpenTelemetry callback.
|
||||
// Regression test for: when EnableSensitiveData is set on OpenTelemetryAgent, the auto-wired
|
||||
// inner OpenTelemetryChatClient must also have EnableSensitiveData propagated to it. Previously,
|
||||
// GetRunOptionsWithChatClientWiring created the inner client without passing EnableSensitiveData,
|
||||
// so the inner chat span would never emit gen_ai.input.messages / gen_ai.output.messages even
|
||||
// when the caller explicitly set EnableSensitiveData = true.
|
||||
var sourceName = Guid.NewGuid().ToString();
|
||||
var activities = new List<Activity>();
|
||||
using var tracerProvider = OpenTelemetry.Sdk.CreateTracerProviderBuilder()
|
||||
@@ -1120,71 +1109,6 @@ public class OpenTelemetryAgentTests
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AutoWireChatClient_EmitsExecuteToolSpans_Async()
|
||||
{
|
||||
// The core of the OTel-below-FICC fix: with the slot active below FICC, the inner chat span closes
|
||||
// before FICC invokes tools, so Activity.Current is the invoke_agent span and FICC emits an
|
||||
// execute_tool span on the agent source, parented under invoke_agent.
|
||||
var sourceName = Guid.NewGuid().ToString();
|
||||
var activities = new List<Activity>();
|
||||
using var tracerProvider = OpenTelemetry.Sdk.CreateTracerProviderBuilder()
|
||||
.AddSource(sourceName)
|
||||
.AddInMemoryExporter(activities)
|
||||
.Build();
|
||||
|
||||
var tool = AIFunctionFactory.Create(() => "sunny", "get_weather");
|
||||
var fakeChatClient = new ToolCallingTestChatClient();
|
||||
var inner = new ChatClientAgent(fakeChatClient, new ChatClientAgentOptions
|
||||
{
|
||||
ChatOptions = new ChatOptions { Tools = [tool] },
|
||||
});
|
||||
using var agent = new OpenTelemetryAgent(inner, sourceName);
|
||||
|
||||
_ = await agent.RunAsync("weather?");
|
||||
|
||||
var invokeAgent = Assert.Single(activities, a => a.DisplayName.StartsWith("invoke_agent", StringComparison.Ordinal));
|
||||
var executeTool = Assert.Single(activities, a => a.DisplayName.StartsWith("execute_tool", StringComparison.Ordinal));
|
||||
Assert.Equal(sourceName, executeTool.Source.Name);
|
||||
Assert.Equal(invokeAgent.SpanId, executeTool.ParentSpanId);
|
||||
Assert.Contains(activities, a => string.Equals(a.GetTagItem("gen_ai.operation.name") as string, "chat", StringComparison.Ordinal));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DeferredOpenTelemetryChatClient_InertUntilActivated_Async()
|
||||
{
|
||||
var sourceName = Guid.NewGuid().ToString();
|
||||
var activities = new List<Activity>();
|
||||
using var tracerProvider = OpenTelemetry.Sdk.CreateTracerProviderBuilder()
|
||||
.AddSource(sourceName)
|
||||
.AddInMemoryExporter(activities)
|
||||
.Build();
|
||||
|
||||
var leaf = new AutoWireTestChatClient();
|
||||
using var slot = new DeferredOpenTelemetryChatClient(leaf);
|
||||
|
||||
// Inert: resolves itself for its own type and forwards other lookups to the inner client. The bare
|
||||
// leaf is not instrumented, so the OpenTelemetryChatClient lookup is null and no span is emitted.
|
||||
Assert.False(slot.IsActive);
|
||||
Assert.Same(slot, slot.GetService(typeof(DeferredOpenTelemetryChatClient)));
|
||||
Assert.Null(slot.GetService(typeof(OpenTelemetryChatClient)));
|
||||
_ = await slot.GetResponseAsync("hi");
|
||||
Assert.Empty(activities);
|
||||
|
||||
// Active: routes through an OpenTelemetryChatClient that emits a chat span on the source.
|
||||
slot.Activate(sourceName);
|
||||
Assert.True(slot.IsActive);
|
||||
Assert.NotNull(slot.GetService(typeof(OpenTelemetryChatClient)));
|
||||
_ = await slot.GetResponseAsync("hi");
|
||||
var chat = Assert.Single(activities);
|
||||
Assert.Equal("chat", chat.GetTagItem("gen_ai.operation.name") as string);
|
||||
|
||||
// Idempotent: a second activation does not replace the existing wrapper.
|
||||
var target = slot.GetService(typeof(OpenTelemetryChatClient));
|
||||
slot.Activate(sourceName);
|
||||
Assert.Same(target, slot.GetService(typeof(OpenTelemetryChatClient)));
|
||||
}
|
||||
|
||||
private sealed class AutoWireTestChatClient : IChatClient
|
||||
{
|
||||
public Action<IEnumerable<ChatMessage>, ChatOptions?>? OnGetResponseAsync { get; set; }
|
||||
@@ -1208,40 +1132,5 @@ public class OpenTelemetryAgentTests
|
||||
public void Dispose() { }
|
||||
}
|
||||
|
||||
private sealed class ToolCallingTestChatClient : IChatClient
|
||||
{
|
||||
private int _callCount;
|
||||
|
||||
public Task<ChatResponse> GetResponseAsync(IEnumerable<ChatMessage> messages, ChatOptions? options = null, CancellationToken cancellationToken = default)
|
||||
{
|
||||
// First call returns a tool call so FICC invokes the tool; the second call returns the final text.
|
||||
if (Interlocked.Increment(ref this._callCount) == 1)
|
||||
{
|
||||
var call = new FunctionCallContent("call_1", "get_weather", new Dictionary<string, object?>());
|
||||
return Task.FromResult(new ChatResponse(new ChatMessage(ChatRole.Assistant, [call])));
|
||||
}
|
||||
|
||||
return Task.FromResult(new ChatResponse(new ChatMessage(ChatRole.Assistant, "done")));
|
||||
}
|
||||
|
||||
public async IAsyncEnumerable<ChatResponseUpdate> GetStreamingResponseAsync(IEnumerable<ChatMessage> messages, ChatOptions? options = null, [EnumeratorCancellation] CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (Interlocked.Increment(ref this._callCount) == 1)
|
||||
{
|
||||
yield return new ChatResponseUpdate(ChatRole.Assistant, [new FunctionCallContent("call_1", "get_weather", new Dictionary<string, object?>())]);
|
||||
await Task.Yield();
|
||||
yield break;
|
||||
}
|
||||
|
||||
await Task.Yield();
|
||||
yield return new ChatResponseUpdate(ChatRole.Assistant, "done");
|
||||
}
|
||||
|
||||
public object? GetService(Type serviceType, object? serviceKey = null) =>
|
||||
serviceType?.IsInstanceOfType(this) == true ? this : null;
|
||||
|
||||
public void Dispose() { }
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
|
||||
-135
@@ -1,135 +0,0 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Text.Json;
|
||||
using System.Text.RegularExpressions;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using FluentAssertions;
|
||||
using Microsoft.Agents.AI.Workflows.Checkpointing;
|
||||
using Microsoft.Agents.AI.Workflows.InProc;
|
||||
|
||||
namespace Microsoft.Agents.AI.Workflows.UnitTests;
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that a checkpoint serialized through <see cref="JsonCheckpointStore"/> can be restored
|
||||
/// after every <c>Version=X.Y.Z.W</c> substring in the persisted JSON is rewritten to a different value.
|
||||
/// </summary>
|
||||
public class CheckpointVersionToleranceTests
|
||||
{
|
||||
private sealed class EchoExecutor() : Executor("Echo")
|
||||
{
|
||||
protected override ProtocolBuilder ConfigureProtocol(ProtocolBuilder protocolBuilder)
|
||||
=> protocolBuilder.ConfigureRoutes(routeBuilder =>
|
||||
routeBuilder.AddHandler<string>((msg, ctx) => ctx.SendMessageAsync(msg)));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(ExecutionEnvironment.InProcess_OffThread)]
|
||||
[InlineData(ExecutionEnvironment.InProcess_Lockstep)]
|
||||
internal async Task Test_Checkpoint_Resumes_AfterAssemblyVersionRewriteAsync(ExecutionEnvironment environment)
|
||||
{
|
||||
// Arrange
|
||||
RequestPort<string, string> requestPort = RequestPort.Create<string, string>("TestPort");
|
||||
EchoExecutor echo = new();
|
||||
|
||||
Workflow workflow = new WorkflowBuilder(requestPort)
|
||||
.AddEdge(requestPort, echo)
|
||||
.Build();
|
||||
|
||||
VersionMutatingJsonStore store = new();
|
||||
CheckpointManager checkpointManager = CheckpointManager.CreateJson(store);
|
||||
InProcessExecutionEnvironment env = environment.ToWorkflowExecutionEnvironment();
|
||||
|
||||
// Run the workflow and capture a checkpoint.
|
||||
CheckpointInfo? checkpoint = null;
|
||||
await using (StreamingRun firstRun = await env.WithCheckpointing(checkpointManager)
|
||||
.RunStreamingAsync(workflow, "Hello"))
|
||||
{
|
||||
await foreach (WorkflowEvent evt in firstRun.WatchStreamAsync(blockOnPendingRequest: false))
|
||||
{
|
||||
if (evt is SuperStepCompletedEvent step && step.CompletionInfo?.Checkpoint is { } cp)
|
||||
{
|
||||
checkpoint = cp;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
checkpoint.Should().NotBeNull();
|
||||
store.MutationApplied.Should().BeFalse();
|
||||
|
||||
// Resume against the mutated store, which rewrites every Version=X.Y.Z.W in the persisted JSON.
|
||||
Func<Task> resume = async () =>
|
||||
{
|
||||
await using StreamingRun resumed = await env.WithCheckpointing(checkpointManager)
|
||||
.ResumeStreamingAsync(workflow, checkpoint!);
|
||||
using CancellationTokenSource cts = new(TimeSpan.FromSeconds(10));
|
||||
await foreach (WorkflowEvent _ in resumed.WatchStreamAsync(blockOnPendingRequest: false, cts.Token))
|
||||
{
|
||||
}
|
||||
};
|
||||
|
||||
await resume.Should().NotThrowAsync("resume must succeed when persisted assembly versions differ from loaded ones");
|
||||
store.MutationApplied.Should().BeTrue();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// JSON checkpoint store that rewrites every <c>Version=N.N.N.N</c> token in the persisted
|
||||
/// payload at retrieval time.
|
||||
/// </summary>
|
||||
private sealed class VersionMutatingJsonStore : JsonCheckpointStore
|
||||
{
|
||||
private static readonly Regex s_versionPattern = new(@"Version=\d+\.\d+\.\d+\.\d+", RegexOptions.Compiled);
|
||||
|
||||
private readonly Dictionary<string, Dictionary<string, JsonElement>> _store = [];
|
||||
|
||||
public string ReplacementVersion { get; init; } = "99.0.0.0";
|
||||
|
||||
public bool MutationApplied { get; private set; }
|
||||
|
||||
public override ValueTask<CheckpointInfo> CreateCheckpointAsync(string sessionId, JsonElement value, CheckpointInfo? parent = null)
|
||||
{
|
||||
if (!this._store.TryGetValue(sessionId, out Dictionary<string, JsonElement>? sessionStore))
|
||||
{
|
||||
sessionStore = this._store[sessionId] = [];
|
||||
}
|
||||
|
||||
CheckpointInfo info = new(sessionId);
|
||||
sessionStore[info.CheckpointId] = value.Clone();
|
||||
return new ValueTask<CheckpointInfo>(info);
|
||||
}
|
||||
|
||||
public override ValueTask<JsonElement> RetrieveCheckpointAsync(string sessionId, CheckpointInfo key)
|
||||
{
|
||||
if (!this._store.TryGetValue(sessionId, out Dictionary<string, JsonElement>? sessionStore)
|
||||
|| !sessionStore.TryGetValue(key.CheckpointId, out JsonElement raw))
|
||||
{
|
||||
throw new KeyNotFoundException($"Could not retrieve checkpoint with id {key.CheckpointId} for session {sessionId}");
|
||||
}
|
||||
|
||||
string rawText = raw.GetRawText();
|
||||
string mutatedText = s_versionPattern.Replace(rawText, $"Version={this.ReplacementVersion}");
|
||||
|
||||
if (!ReferenceEquals(rawText, mutatedText) && rawText != mutatedText)
|
||||
{
|
||||
this.MutationApplied = true;
|
||||
}
|
||||
|
||||
using JsonDocument doc = JsonDocument.Parse(mutatedText);
|
||||
return new ValueTask<JsonElement>(doc.RootElement.Clone());
|
||||
}
|
||||
|
||||
public override ValueTask<IEnumerable<CheckpointInfo>> RetrieveIndexAsync(string sessionId, CheckpointInfo? withParent = null)
|
||||
{
|
||||
if (!this._store.TryGetValue(sessionId, out Dictionary<string, JsonElement>? sessionStore))
|
||||
{
|
||||
return new ValueTask<IEnumerable<CheckpointInfo>>(Array.Empty<CheckpointInfo>());
|
||||
}
|
||||
|
||||
IEnumerable<CheckpointInfo> infos = sessionStore.Keys.Select(id => new CheckpointInfo(sessionId, id));
|
||||
return new ValueTask<IEnumerable<CheckpointInfo>>(infos);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,230 +0,0 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
using FluentAssertions;
|
||||
using Microsoft.Agents.AI.Workflows.Checkpointing;
|
||||
using Microsoft.Extensions.AI;
|
||||
|
||||
namespace Microsoft.Agents.AI.Workflows.UnitTests;
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that <see cref="TypeId.IsMatch(Type)"/> and <see cref="TypeId.Equals(object?)"/>
|
||||
/// compare on the type full name and the simple assembly name, ignoring version, culture,
|
||||
/// and public key token both in the outer assembly name and in any assembly-qualified generic
|
||||
/// arguments embedded in the type full name.
|
||||
/// </summary>
|
||||
public class TypeIdVersionToleranceTests
|
||||
{
|
||||
[SuppressMessage("Performance", "CA1812", Justification = "Used via typeof() only; never instantiated.")]
|
||||
private sealed class Probe
|
||||
{
|
||||
}
|
||||
|
||||
private static string ProbeSimpleAssemblyName => typeof(Probe).Assembly.GetName().Name!;
|
||||
private static string ProbeTypeFullName => typeof(Probe).FullName!;
|
||||
|
||||
[Fact]
|
||||
public void Test_IsMatch_RoundTripsRealType()
|
||||
{
|
||||
TypeId id = new(typeof(Probe));
|
||||
|
||||
id.IsMatch(typeof(Probe)).Should().BeTrue();
|
||||
id.IsMatch<Probe>().Should().BeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Test_IsMatch_IgnoresAssemblyVersion()
|
||||
{
|
||||
string assemblyName = $"{ProbeSimpleAssemblyName}, Version=99.0.0.0, Culture=neutral, PublicKeyToken=null";
|
||||
TypeId id = new(assemblyName, ProbeTypeFullName);
|
||||
|
||||
id.IsMatch(typeof(Probe)).Should().BeTrue("version differences in AssemblyName must not affect matching");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Test_IsMatch_IgnoresCultureAndPublicKeyToken()
|
||||
{
|
||||
string assemblyName = $"{ProbeSimpleAssemblyName}, Version=99.0.0.0, Culture=en-US, PublicKeyToken=abcdef0123456789";
|
||||
TypeId id = new(assemblyName, ProbeTypeFullName);
|
||||
|
||||
id.IsMatch(typeof(Probe)).Should().BeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Test_IsMatch_AcceptsSimpleAssemblyNameOnly()
|
||||
{
|
||||
TypeId id = new(ProbeSimpleAssemblyName, ProbeTypeFullName);
|
||||
|
||||
id.IsMatch(typeof(Probe)).Should().BeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Test_IsMatch_RejectsDifferentSimpleAssemblyName()
|
||||
{
|
||||
TypeId id = new(
|
||||
assemblyName: "Some.Completely.Different.Assembly, Version=1.0.0.0",
|
||||
typeName: ProbeTypeFullName);
|
||||
|
||||
id.IsMatch(typeof(Probe)).Should().BeFalse("different simple assembly names must not match");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Test_IsMatch_RejectsDifferentTypeName()
|
||||
{
|
||||
TypeId id = new(
|
||||
assemblyName: $"{ProbeSimpleAssemblyName}, Version=99.0.0.0",
|
||||
typeName: "Some.Other.Namespace.Probe");
|
||||
|
||||
id.IsMatch(typeof(Probe)).Should().BeFalse("different type names must not match");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Test_IsMatch_ToleratesMalformedAssemblyName()
|
||||
{
|
||||
TypeId id = new(
|
||||
assemblyName: $"{ProbeSimpleAssemblyName}, Version=not-a-version, Culture=??, PublicKeyToken=???",
|
||||
typeName: ProbeTypeFullName);
|
||||
|
||||
id.IsMatch(typeof(Probe)).Should().BeTrue("the substring fallback recovers the simple name when AssemblyName parsing fails");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Test_IsMatchPolymorphic_IgnoresAssemblyVersion()
|
||||
{
|
||||
TypeId id = new(
|
||||
assemblyName: $"{typeof(object).Assembly.GetName().Name}, Version=99.0.0.0",
|
||||
typeName: typeof(object).FullName!);
|
||||
|
||||
id.IsMatchPolymorphic(typeof(Probe)).Should().BeTrue("IsMatchPolymorphic uses the same comparison rules as IsMatch");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Test_Equals_IgnoresAssemblyVersion()
|
||||
{
|
||||
TypeId v1 = new($"{ProbeSimpleAssemblyName}, Version=1.0.0.0", ProbeTypeFullName);
|
||||
TypeId v2 = new($"{ProbeSimpleAssemblyName}, Version=2.0.0.0", ProbeTypeFullName);
|
||||
|
||||
v1.Equals(v2).Should().BeTrue();
|
||||
(v1 == v2).Should().BeTrue();
|
||||
v1.GetHashCode().Should().Be(v2.GetHashCode());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Test_Equals_RejectsDifferentSimpleAssemblyName()
|
||||
{
|
||||
TypeId a = new($"{ProbeSimpleAssemblyName}, Version=1.0.0.0", ProbeTypeFullName);
|
||||
TypeId b = new("Some.Other.Assembly, Version=1.0.0.0", ProbeTypeFullName);
|
||||
|
||||
a.Equals(b).Should().BeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Test_Equals_RejectsDifferentTypeName()
|
||||
{
|
||||
TypeId a = new($"{ProbeSimpleAssemblyName}, Version=1.0.0.0", ProbeTypeFullName);
|
||||
TypeId b = new($"{ProbeSimpleAssemblyName}, Version=1.0.0.0", "Some.Other.Type");
|
||||
|
||||
a.Equals(b).Should().BeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Test_Dictionary_LookupAcrossVersions()
|
||||
{
|
||||
TypeId live = new(typeof(Probe));
|
||||
TypeId mutated = new(
|
||||
assemblyName: $"{ProbeSimpleAssemblyName}, Version=99.0.0.0, Culture=neutral, PublicKeyToken=null",
|
||||
typeName: ProbeTypeFullName);
|
||||
|
||||
Dictionary<TypeId, string> map = new() { [live] = "value" };
|
||||
map.TryGetValue(mutated, out string? value).Should().BeTrue();
|
||||
value.Should().Be("value");
|
||||
|
||||
HashSet<TypeId> set = new() { live };
|
||||
set.Contains(mutated).Should().BeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Test_Equals_TreatsIdenticalStringsAsEqual()
|
||||
{
|
||||
TypeId a = new(typeof(Probe));
|
||||
TypeId b = new(typeof(Probe));
|
||||
|
||||
a.Equals(b).Should().BeTrue();
|
||||
a.GetHashCode().Should().Be(b.GetHashCode());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Test_NormalizeTypeName_ReturnsInputWhenNoAssemblyQualifier()
|
||||
{
|
||||
const string TypeName = "Microsoft.Agents.AI.Workflows.Checkpointing.TypeId";
|
||||
|
||||
TypeId.NormalizeTypeName(TypeName).Should().BeSameAs(TypeName);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Test_NormalizeTypeName_StripsVersionCultureAndPublicKeyTokenTriplets()
|
||||
{
|
||||
const string TypeName = "System.Collections.Generic.List`1[[Some.Type, Some.Asm, Version=1.2.3.4, Culture=neutral, PublicKeyToken=abcdef0123456789]]";
|
||||
const string Expected = "System.Collections.Generic.List`1[[Some.Type, Some.Asm]]";
|
||||
|
||||
TypeId.NormalizeTypeName(TypeName).Should().Be(Expected);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Test_NormalizeTypeName_StripsTripletsFromNestedGenericArguments()
|
||||
{
|
||||
const string TypeName = "System.Collections.Generic.Dictionary`2[[System.String, mscorlib, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089],[System.Collections.Generic.List`1[[Some.Type, Some.Asm, Version=1.0.0.0, Culture=neutral, PublicKeyToken=null]], mscorlib, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089]]";
|
||||
const string Expected = "System.Collections.Generic.Dictionary`2[[System.String, mscorlib],[System.Collections.Generic.List`1[[Some.Type, Some.Asm]], mscorlib]]";
|
||||
|
||||
TypeId.NormalizeTypeName(TypeName).Should().Be(Expected);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Test_IsMatch_IgnoresVersionInGenericArguments()
|
||||
{
|
||||
Type live = typeof(List<ChatMessage>);
|
||||
string simpleAssemblyName = live.Assembly.GetName().Name!;
|
||||
|
||||
// Hand-craft a TypeName as if persisted under a different version of the generic
|
||||
// argument assembly (Microsoft.Extensions.AI.Abstractions).
|
||||
string innerArgSimpleName = typeof(ChatMessage).Assembly.GetName().Name!;
|
||||
string mutatedTypeName = $"System.Collections.Generic.List`1[[Microsoft.Extensions.AI.ChatMessage, {innerArgSimpleName}, Version=99.0.0.0, Culture=neutral, PublicKeyToken=null]]";
|
||||
|
||||
TypeId id = new(simpleAssemblyName, mutatedTypeName);
|
||||
|
||||
id.IsMatch(live).Should().BeTrue("version differences inside generic argument names must not affect matching");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Test_Equals_IgnoresVersionInGenericArguments()
|
||||
{
|
||||
Type live = typeof(List<ChatMessage>);
|
||||
TypeId fromLive = new(live);
|
||||
|
||||
string simpleAssemblyName = live.Assembly.GetName().Name!;
|
||||
string innerArgSimpleName = typeof(ChatMessage).Assembly.GetName().Name!;
|
||||
string mutatedTypeName = $"System.Collections.Generic.List`1[[Microsoft.Extensions.AI.ChatMessage, {innerArgSimpleName}, Version=99.0.0.0, Culture=neutral, PublicKeyToken=null]]";
|
||||
TypeId fromMutated = new(simpleAssemblyName, mutatedTypeName);
|
||||
|
||||
fromLive.Equals(fromMutated).Should().BeTrue();
|
||||
fromLive.GetHashCode().Should().Be(fromMutated.GetHashCode());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Test_Dictionary_LookupAcrossGenericArgumentVersions()
|
||||
{
|
||||
Type live = typeof(List<ChatMessage>);
|
||||
TypeId fromLive = new(live);
|
||||
|
||||
string simpleAssemblyName = live.Assembly.GetName().Name!;
|
||||
string innerArgSimpleName = typeof(ChatMessage).Assembly.GetName().Name!;
|
||||
string mutatedTypeName = $"System.Collections.Generic.List`1[[Microsoft.Extensions.AI.ChatMessage, {innerArgSimpleName}, Version=99.0.0.0, Culture=neutral, PublicKeyToken=null]]";
|
||||
TypeId fromMutated = new(simpleAssemblyName, mutatedTypeName);
|
||||
|
||||
Dictionary<TypeId, string> map = new() { [fromLive] = "value" };
|
||||
map.TryGetValue(fromMutated, out string? value).Should().BeTrue();
|
||||
value.Should().Be("value");
|
||||
}
|
||||
}
|
||||
-67
@@ -1,67 +0,0 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
using FluentAssertions;
|
||||
using Microsoft.Agents.AI.Workflows.Checkpointing;
|
||||
using Microsoft.Extensions.AI;
|
||||
|
||||
namespace Microsoft.Agents.AI.Workflows.UnitTests;
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that <see cref="WorkflowSession.ResolveTypeLenient(TypeId)"/> resolves a
|
||||
/// <see cref="TypeId"/> to a loaded <see cref="Type"/> even when the stored assembly
|
||||
/// name carries a different <c>Version=</c> than the loaded assembly.
|
||||
/// </summary>
|
||||
public class WorkflowSessionResolveTypeLenientTests
|
||||
{
|
||||
[SuppressMessage("Performance", "CA1812", Justification = "Instantiated via Type.GetType in the production code path under test.")]
|
||||
private sealed class TestEnvelope : IExternalRequestEnvelope
|
||||
{
|
||||
AIContent? IExternalRequestEnvelope.GetInnerRequestContent() => null;
|
||||
|
||||
object IExternalRequestEnvelope.CreateResponse(IList<ChatMessage> messages) => messages;
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Test_ResolveTypeLenient_ResolvesWhenAssemblyNameMatchesLoadedVersion()
|
||||
{
|
||||
Type live = typeof(TestEnvelope);
|
||||
TypeId id = new(live);
|
||||
|
||||
WorkflowSession.ResolveTypeLenient(id).Should().Be(live);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Test_ResolveTypeLenient_ResolvesAcrossAssemblyVersionMutation()
|
||||
{
|
||||
Type live = typeof(TestEnvelope);
|
||||
string simpleAssemblyName = live.Assembly.GetName().Name!;
|
||||
string mutatedAssemblyName = $"{simpleAssemblyName}, Version=99.0.0.0, Culture=neutral, PublicKeyToken=null";
|
||||
TypeId mutated = new(mutatedAssemblyName, live.FullName!);
|
||||
|
||||
WorkflowSession.ResolveTypeLenient(mutated).Should().Be(live);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Test_ResolveTypeLenient_ReturnsNullForUnknownType()
|
||||
{
|
||||
TypeId id = new("Some.Unloaded.Assembly", "Some.Unknown.Type");
|
||||
|
||||
WorkflowSession.ResolveTypeLenient(id).Should().BeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Test_ResolveTypeLenient_ResolvesAcrossGenericArgumentVersionMutation()
|
||||
{
|
||||
Type live = typeof(List<ChatMessage>);
|
||||
string outerSimpleName = live.Assembly.GetName().Name!;
|
||||
string innerSimpleName = typeof(ChatMessage).Assembly.GetName().Name!;
|
||||
string mutatedTypeName = $"System.Collections.Generic.List`1[[Microsoft.Extensions.AI.ChatMessage, {innerSimpleName}, Version=99.0.0.0, Culture=neutral, PublicKeyToken=null]]";
|
||||
|
||||
TypeId mutated = new(outerSimpleName, mutatedTypeName);
|
||||
|
||||
WorkflowSession.ResolveTypeLenient(mutated).Should().Be(live);
|
||||
}
|
||||
}
|
||||
+1
@@ -0,0 +1 @@
|
||||
../../../.github/skills/pull-requests
|
||||
-116
@@ -1,116 +0,0 @@
|
||||
---
|
||||
name: pull-requests
|
||||
description: >
|
||||
Guidance for creating pull requests and handling PR review comments in the
|
||||
Agent Framework repository. Use this when writing a PR description (filling out
|
||||
the PR template) or when responding to and resolving review comments on an
|
||||
existing PR.
|
||||
---
|
||||
|
||||
# Pull Request Workflow
|
||||
|
||||
This skill covers two tasks: (1) writing a high-quality PR description, and
|
||||
(2) handling review comments on an existing PR.
|
||||
|
||||
## 1. Writing the PR description
|
||||
|
||||
Always follow the repository PR template at
|
||||
[`.github/pull_request_template.md`](../../../../.github/pull_request_template.md). Keep its
|
||||
exact structure and headings. Fill every section:
|
||||
|
||||
### `### Motivation & Context`
|
||||
Explain *why* the change is needed: the problem it solves and the scenario it
|
||||
contributes to. Describe the net change relative to `main` — this is implied, so
|
||||
do **not** spell out "vs main" explicitly.
|
||||
|
||||
### `### Description & Review Guide`
|
||||
Describe the changes, the overall approach, and the design. Answer the three
|
||||
prompts:
|
||||
- **What are the major changes?**
|
||||
- **What is the impact of these changes?**
|
||||
- **What do you want reviewers to focus on?** — This item is for **human
|
||||
reviewers only**. Automated/AI reviewers must ignore it and review the entire
|
||||
change rather than narrowing scope to it.
|
||||
|
||||
### `### Related Issue`
|
||||
Link the issue the PR fixes using a GitHub closing keyword (`Fixes #123` /
|
||||
`Closes #123`) so it closes automatically on merge. A PR with no linked issue may
|
||||
be closed regardless of how valid the change is. Before opening, confirm there is
|
||||
no other open PR for the same issue; if there is, explain how this PR differs.
|
||||
|
||||
### `### Contribution Checklist`
|
||||
Check every item that applies. For the breaking-change item:
|
||||
- Leave **"This is not a breaking change."** checked for the common case.
|
||||
- If the change **is** breaking, add the `breaking change` label **or** put
|
||||
`[BREAKING]` in the title prefix, before or after a language prefix such as
|
||||
`Python:` or `.NET:` — workflows keep the label and the title prefix in sync
|
||||
automatically (see `.github/workflows/label-title-prefix.yml` and
|
||||
`.github/workflows/label-pr.yml`).
|
||||
|
||||
### Do not
|
||||
- Do **not** add ad-hoc sections such as "Validation" or "Tests run"; CI/CD and
|
||||
the checklist already cover validation status.
|
||||
- Do **not** remove or reorder the template's headings.
|
||||
|
||||
### Creating the PR
|
||||
Open new PRs as **drafts** until they are ready for review. Example:
|
||||
|
||||
```bash
|
||||
gh pr create --repo microsoft/agent-framework --base main \
|
||||
--head <your-fork-owner>:<branch> --draft \
|
||||
--title "<concise title>" --body "<body following the template>"
|
||||
```
|
||||
|
||||
## 2. Handling review comments
|
||||
|
||||
When a PR receives review comments, follow this sequence — **do not start editing
|
||||
code before the user has reviewed the plan**:
|
||||
|
||||
1. **Review the comments.** Read every review comment and thread on the PR,
|
||||
including inline code comments and general review summaries.
|
||||
2. **Make a plan.** Produce a concrete plan describing how each comment will be
|
||||
addressed (or why it should not be, with reasoning).
|
||||
3. **Let the user review the plan.** Present the plan and wait for the user's
|
||||
approval or adjustments before implementing anything.
|
||||
4. **Implement.** Make the agreed changes.
|
||||
5. **Reply to every comment.** Add a reply to **all** comments explaining how it
|
||||
was addressed (or the agreed outcome) — leave none unanswered.
|
||||
6. **Resolve resolved threads.** Mark a review thread as resolved only when the
|
||||
comment has actually been addressed.
|
||||
|
||||
### Useful commands
|
||||
|
||||
List review comments and threads:
|
||||
|
||||
```bash
|
||||
# Inline review comments
|
||||
gh api repos/{owner}/{repo}/pulls/{pr}/comments
|
||||
|
||||
# Review threads with resolution state (GraphQL)
|
||||
gh api graphql -f query='
|
||||
query($owner:String!,$repo:String!,$pr:Int!){
|
||||
repository(owner:$owner,name:$repo){
|
||||
pullRequest(number:$pr){
|
||||
reviewThreads(first:100){
|
||||
nodes{ id isResolved comments(first:50){ nodes{ id body author{login} } } }
|
||||
}
|
||||
}
|
||||
}
|
||||
}' -F owner={owner} -F repo={repo} -F pr={pr}
|
||||
```
|
||||
|
||||
Reply to an inline review comment:
|
||||
|
||||
```bash
|
||||
gh api repos/{owner}/{repo}/pulls/{pr}/comments/{comment_id}/replies \
|
||||
-f body="Addressed in <commit>: <explanation>"
|
||||
```
|
||||
|
||||
Resolve a review thread (needs the thread node id from the GraphQL query above):
|
||||
|
||||
```bash
|
||||
gh api graphql -f query='
|
||||
mutation($threadId:ID!){
|
||||
resolveReviewThread(input:{threadId:$threadId}){ thread{ isResolved } }
|
||||
}' -F threadId={thread_id}
|
||||
```
|
||||
@@ -98,7 +98,7 @@ agent_framework/
|
||||
- **`InMemoryAgentFileStore`** - Dict-backed store suitable for tests and lightweight scenarios.
|
||||
- **`FileSystemAgentFileStore`** - Disk-backed store rooted under a configurable directory. Enforces relative-path normalization, root containment, and rejects symlink/reparse-point segments to prevent escape.
|
||||
- **`FileSearchResult`** / **`FileSearchMatch`** - `SerializationMixin` DTOs returned by `search_files`, carrying the matching file name, a context snippet, and the matching lines with 1-based line numbers.
|
||||
- **`FileAccessProvider`** - `ContextProvider` that adds shared file-access tools (`file_access_save_file`, `file_access_read_file`, `file_access_delete_file`, `file_access_list_files`, `file_access_list_subdirectories`, `file_access_search_files`) plus default usage instructions to each invocation. `file_access_list_files`/`file_access_list_subdirectories` enumerate direct children (files / subdirectories) so the agent can walk the tree level by level; `file_access_search_files` searches recursively from the store root and returns store-root-relative `file_name` paths, scoped via an `fnmatch` glob (where `*` crosses `/`, e.g. `*.md`, `reports/*`). All six tools are registered with `approval_mode="always_require"`, so every file operation needs host approval. To run unattended, pass one of the static auto-approval rules to `ToolApprovalMiddleware` (via `auto_approval_rules`): `FileAccessProvider.read_only_tools_auto_approval_rule` approves only the read-only tools (read, list files, list subdirectories, search), while `FileAccessProvider.all_tools_auto_approval_rule` approves every file-access tool including save and delete. Both rules reject any call carrying a `server_label` so they stay scoped to this provider's local tools and never auto-approve a same-named hosted tool. The tool names are also exposed as class constants (`SAVE_FILE_TOOL_NAME`, `READ_FILE_TOOL_NAME`, `DELETE_FILE_TOOL_NAME`, `LIST_FILES_TOOL_NAME`, `LIST_SUBDIRECTORIES_TOOL_NAME`, `SEARCH_FILES_TOOL_NAME`). Unlike `MemoryContextProvider`, the store is intentionally shared across sessions and agents.
|
||||
- **`FileAccessProvider`** - `ContextProvider` that adds shared file-access tools (`file_access_save_file`, `file_access_read_file`, `file_access_delete_file`, `file_access_list_files`, `file_access_list_subdirectories`, `file_access_search_files`) plus default usage instructions to each invocation. `file_access_list_files`/`file_access_list_subdirectories` enumerate direct children (files / subdirectories) so the agent can walk the tree level by level; `file_access_search_files` searches recursively from the store root and returns store-root-relative `file_name` paths, scoped via an `fnmatch` glob (where `*` crosses `/`, e.g. `*.md`, `reports/*`). Unlike `MemoryContextProvider`, the store is intentionally shared across sessions and agents.
|
||||
|
||||
### File Memory Harness (`_harness/_file_memory.py`)
|
||||
|
||||
|
||||
@@ -9,7 +9,7 @@ integrations, many of which are lazy-loaded from optional packages.
|
||||
"""
|
||||
|
||||
import importlib.metadata
|
||||
from typing import TYPE_CHECKING, Any, Final
|
||||
from typing import Final
|
||||
|
||||
try:
|
||||
_version = importlib.metadata.version(__name__)
|
||||
@@ -264,7 +264,6 @@ from ._workflows._agent_executor import (
|
||||
)
|
||||
from ._workflows._agent_utils import resolve_agent_id
|
||||
from ._workflows._checkpoint import (
|
||||
CheckpointID,
|
||||
CheckpointStorage,
|
||||
FileCheckpointStorage,
|
||||
InMemoryCheckpointStorage,
|
||||
@@ -308,6 +307,7 @@ from ._workflows._functional import (
|
||||
workflow,
|
||||
)
|
||||
from ._workflows._request_info_mixin import response_handler
|
||||
from ._workflows._runner import Runner
|
||||
from ._workflows._runner_context import (
|
||||
InProcRunnerContext,
|
||||
RunnerContext,
|
||||
@@ -405,7 +405,6 @@ __all__ = [
|
||||
"ChatResponse",
|
||||
"ChatResponseUpdate",
|
||||
"CheckResult",
|
||||
"CheckpointID",
|
||||
"CheckpointStorage",
|
||||
"ClassSkill",
|
||||
"CompactionProvider",
|
||||
@@ -619,20 +618,3 @@ __all__ = [
|
||||
"validate_workflow_graph",
|
||||
"workflow",
|
||||
]
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from ._workflows._runner import Runner
|
||||
|
||||
|
||||
def __getattr__(name: str) -> Any:
|
||||
"""Lazily resolve deprecated public names, emitting a ``DeprecationWarning``.
|
||||
|
||||
``Runner`` remains importable from ``agent_framework`` for backward
|
||||
compatibility but is deprecated and slated for removal from the public API.
|
||||
"""
|
||||
if name == "Runner":
|
||||
from ._workflows._runner import Runner, warn_runner_deprecated
|
||||
|
||||
warn_runner_deprecated()
|
||||
return Runner
|
||||
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
|
||||
|
||||
@@ -37,8 +37,7 @@ from pydantic import BaseModel, Field
|
||||
from .._feature_stage import ExperimentalFeature, experimental
|
||||
from .._serialization import SerializationMixin
|
||||
from .._sessions import AgentSession, ContextProvider, SessionContext
|
||||
from .._tools import tool
|
||||
from .._types import Content
|
||||
from .._tools import ApprovalMode, tool
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -1076,72 +1075,15 @@ class FileAccessProvider(ContextProvider):
|
||||
contents are visible across sessions and agents. The store is passed in by
|
||||
the caller and should already be scoped to the desired folder or storage
|
||||
location.
|
||||
|
||||
All six tools always require approval: each is registered with
|
||||
``approval_mode="always_require"`` so the host must approve every file
|
||||
operation the model proposes. In the auto-invocation flow this means the
|
||||
model's calls to these tools are converted into
|
||||
``function_approval_request`` items and the tool does **not** execute until
|
||||
the host supplies a matching ``function_approval_response``. Consumers that
|
||||
use the base agent directly must install
|
||||
:class:`~agent_framework.ToolApprovalMiddleware` (or use
|
||||
:func:`~agent_framework.create_harness_agent`, which wires it in by default)
|
||||
to drive that handshake; otherwise these tools never run. To run unattended,
|
||||
supply one of the static auto-approval rules to
|
||||
:class:`~agent_framework.ToolApprovalMiddleware` via its
|
||||
``auto_approval_rules``:
|
||||
|
||||
- :meth:`read_only_tools_auto_approval_rule` — auto-approves only the
|
||||
read-only tools (read, list files, list subdirectories, search), while
|
||||
still prompting for the tools that modify the store (save and delete).
|
||||
- :meth:`all_tools_auto_approval_rule` — auto-approves every file-access
|
||||
tool, including save and delete.
|
||||
|
||||
For example, to auto-approve only the read-only tools::
|
||||
|
||||
create_harness_agent(
|
||||
chat_client,
|
||||
auto_approval_rules=[FileAccessProvider.read_only_tools_auto_approval_rule],
|
||||
)
|
||||
"""
|
||||
|
||||
#: Name of the tool that saves a file.
|
||||
SAVE_FILE_TOOL_NAME = "file_access_save_file"
|
||||
#: Name of the tool that reads a file.
|
||||
READ_FILE_TOOL_NAME = "file_access_read_file"
|
||||
#: Name of the tool that deletes a file.
|
||||
DELETE_FILE_TOOL_NAME = "file_access_delete_file"
|
||||
#: Name of the tool that lists the files in a directory.
|
||||
LIST_FILES_TOOL_NAME = "file_access_list_files"
|
||||
#: Name of the tool that lists the subdirectories of a directory.
|
||||
LIST_SUBDIRECTORIES_TOOL_NAME = "file_access_list_subdirectories"
|
||||
#: Name of the tool that searches file contents.
|
||||
SEARCH_FILES_TOOL_NAME = "file_access_search_files"
|
||||
|
||||
#: Names of the tools that only read from (never modify) the file store.
|
||||
_READ_ONLY_TOOL_NAMES: frozenset[str] = frozenset({
|
||||
READ_FILE_TOOL_NAME,
|
||||
LIST_FILES_TOOL_NAME,
|
||||
LIST_SUBDIRECTORIES_TOOL_NAME,
|
||||
SEARCH_FILES_TOOL_NAME,
|
||||
})
|
||||
|
||||
#: Names of all tools exposed by this provider.
|
||||
_ALL_TOOL_NAMES: frozenset[str] = frozenset({
|
||||
SAVE_FILE_TOOL_NAME,
|
||||
READ_FILE_TOOL_NAME,
|
||||
DELETE_FILE_TOOL_NAME,
|
||||
LIST_FILES_TOOL_NAME,
|
||||
LIST_SUBDIRECTORIES_TOOL_NAME,
|
||||
SEARCH_FILES_TOOL_NAME,
|
||||
})
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
store: AgentFileStore,
|
||||
*,
|
||||
source_id: str = DEFAULT_FILE_ACCESS_SOURCE_ID,
|
||||
instructions: str | None = None,
|
||||
require_delete_approval: bool = True,
|
||||
) -> None:
|
||||
"""Initialize the file access provider.
|
||||
|
||||
@@ -1154,78 +1096,17 @@ class FileAccessProvider(ContextProvider):
|
||||
source_id: Unique source ID for the provider.
|
||||
instructions: Optional instruction override. When ``None`` the
|
||||
default file-access instructions are used.
|
||||
require_delete_approval: When ``True`` (the default) the
|
||||
``file_access_delete_file`` tool is registered with
|
||||
``approval_mode="always_require"`` so the host must approve every
|
||||
delete the model proposes. Set to ``False`` to opt out and allow
|
||||
the agent to delete files autonomously (matching the .NET
|
||||
``FileAccessProvider``, which has no approval mechanism).
|
||||
"""
|
||||
super().__init__(source_id)
|
||||
self.store = store
|
||||
self.instructions = instructions or DEFAULT_FILE_ACCESS_INSTRUCTIONS
|
||||
|
||||
@staticmethod
|
||||
def _is_local_tool_call(function_call: Content) -> bool:
|
||||
"""Return whether a function call targets this provider's local tools.
|
||||
|
||||
Hosted-tool calls carry a ``server_label`` in their
|
||||
``additional_properties`` and are a separate server-scoped approval
|
||||
boundary that must be passed through untouched (see
|
||||
:func:`agent_framework._tools._is_hosted_tool_approval`). These rules
|
||||
only ever auto-approve the provider's own local tools, so any call that
|
||||
carries a ``server_label`` is rejected even if its name collides with a
|
||||
file-access tool name.
|
||||
"""
|
||||
return not function_call.additional_properties.get("server_label")
|
||||
|
||||
@staticmethod
|
||||
def read_only_tools_auto_approval_rule(function_call: Content) -> bool:
|
||||
"""Auto-approval rule that approves only the read-only file-access tools.
|
||||
|
||||
The tools exposed by :class:`FileAccessProvider` always require approval.
|
||||
Pass this rule to :class:`~agent_framework.ToolApprovalMiddleware` (via
|
||||
``auto_approval_rules``) to automatically approve the tools that read
|
||||
from the store (``file_access_read_file``, ``file_access_list_files``,
|
||||
``file_access_list_subdirectories``, and ``file_access_search_files``),
|
||||
while still prompting for the tools that modify it
|
||||
(``file_access_save_file`` and ``file_access_delete_file``).
|
||||
|
||||
Hosted-tool calls (those carrying a ``server_label``) are never
|
||||
auto-approved, even when their name matches a file-access tool, so the
|
||||
rule stays scoped to this provider's local tools.
|
||||
|
||||
Args:
|
||||
function_call: The pending ``function_call`` content.
|
||||
|
||||
Returns:
|
||||
``True`` for read-only file-access tools, ``False`` otherwise so that
|
||||
subsequent rules continue to be evaluated.
|
||||
"""
|
||||
return (
|
||||
FileAccessProvider._is_local_tool_call(function_call)
|
||||
and function_call.name in FileAccessProvider._READ_ONLY_TOOL_NAMES
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def all_tools_auto_approval_rule(function_call: Content) -> bool:
|
||||
"""Auto-approval rule that approves every file-access tool.
|
||||
|
||||
The tools exposed by :class:`FileAccessProvider` always require approval.
|
||||
Pass this rule to :class:`~agent_framework.ToolApprovalMiddleware` (via
|
||||
``auto_approval_rules``) to automatically approve every file-access tool,
|
||||
including the tools that modify the store (``file_access_save_file`` and
|
||||
``file_access_delete_file``).
|
||||
|
||||
Hosted-tool calls (those carrying a ``server_label``) are never
|
||||
auto-approved, even when their name matches a file-access tool, so the
|
||||
rule stays scoped to this provider's local tools.
|
||||
|
||||
Args:
|
||||
function_call: The pending ``function_call`` content.
|
||||
|
||||
Returns:
|
||||
``True`` for any file-access tool, ``False`` otherwise so that
|
||||
subsequent rules continue to be evaluated.
|
||||
"""
|
||||
return (
|
||||
FileAccessProvider._is_local_tool_call(function_call)
|
||||
and function_call.name in FileAccessProvider._ALL_TOOL_NAMES
|
||||
)
|
||||
self.require_delete_approval = require_delete_approval
|
||||
|
||||
async def before_run(
|
||||
self,
|
||||
@@ -1237,7 +1118,7 @@ class FileAccessProvider(ContextProvider):
|
||||
) -> None:
|
||||
"""Inject file-access tools and instructions before the model runs."""
|
||||
|
||||
@tool(name=FileAccessProvider.SAVE_FILE_TOOL_NAME, schema=_SaveFileInput, approval_mode="always_require")
|
||||
@tool(name="file_access_save_file", schema=_SaveFileInput, approval_mode="never_require")
|
||||
async def file_access_save_file(file_name: str, content: str, overwrite: bool = False) -> str:
|
||||
"""Save a file with the given name and content. By default, does not overwrite an existing file unless overwrite is set to true.""" # noqa: E501
|
||||
try:
|
||||
@@ -1251,7 +1132,7 @@ class FileAccessProvider(ContextProvider):
|
||||
return f"Could not save file '{file_name}': {exc.strerror or exc}"
|
||||
return f"File '{file_name}' saved."
|
||||
|
||||
@tool(name=FileAccessProvider.READ_FILE_TOOL_NAME, schema=_ReadFileInput, approval_mode="always_require")
|
||||
@tool(name="file_access_read_file", schema=_ReadFileInput, approval_mode="never_require")
|
||||
async def file_access_read_file(file_name: str) -> str:
|
||||
"""Read the content of a file by name. Returns the file content or a message indicating the file could not be read.""" # noqa: E501
|
||||
try:
|
||||
@@ -1263,7 +1144,9 @@ class FileAccessProvider(ContextProvider):
|
||||
return f"Could not read file '{file_name}': {exc.strerror or exc}"
|
||||
return content if content is not None else f"File '{file_name}' not found."
|
||||
|
||||
@tool(name=FileAccessProvider.DELETE_FILE_TOOL_NAME, schema=_DeleteFileInput, approval_mode="always_require")
|
||||
delete_approval_mode: ApprovalMode = "always_require" if self.require_delete_approval else "never_require"
|
||||
|
||||
@tool(name="file_access_delete_file", schema=_DeleteFileInput, approval_mode=delete_approval_mode)
|
||||
async def file_access_delete_file(file_name: str) -> str:
|
||||
"""Delete a file by name."""
|
||||
try:
|
||||
@@ -1275,7 +1158,7 @@ class FileAccessProvider(ContextProvider):
|
||||
return f"Could not delete file '{file_name}': {exc.strerror or exc}"
|
||||
return f"File '{file_name}' deleted." if deleted else f"File '{file_name}' not found."
|
||||
|
||||
@tool(name=FileAccessProvider.LIST_FILES_TOOL_NAME, schema=_ListFilesInput, approval_mode="always_require")
|
||||
@tool(name="file_access_list_files", schema=_ListFilesInput, approval_mode="never_require")
|
||||
async def file_access_list_files(directory: str | None = None) -> list[str] | str:
|
||||
"""List the direct child file names of a directory. Omit ``directory`` (or pass an empty string) to list the root. To enumerate files in a subdirectory, pass its relative path, for example ``"reports"`` or ``"reports/2024"``.""" # noqa: E501
|
||||
target = directory if directory and directory.strip() else ""
|
||||
@@ -1286,11 +1169,7 @@ class FileAccessProvider(ContextProvider):
|
||||
except OSError as exc:
|
||||
return f"Could not list directory '{directory or ''}': {exc.strerror or exc}"
|
||||
|
||||
@tool(
|
||||
name=FileAccessProvider.LIST_SUBDIRECTORIES_TOOL_NAME,
|
||||
schema=_ListSubdirectoriesInput,
|
||||
approval_mode="always_require",
|
||||
)
|
||||
@tool(name="file_access_list_subdirectories", schema=_ListSubdirectoriesInput, approval_mode="never_require")
|
||||
async def file_access_list_subdirectories(directory: str | None = None) -> list[str] | str:
|
||||
"""List the direct child subdirectory names of a directory.
|
||||
|
||||
@@ -1307,7 +1186,7 @@ class FileAccessProvider(ContextProvider):
|
||||
except OSError as exc:
|
||||
return f"Could not list directory '{directory or ''}': {exc.strerror or exc}"
|
||||
|
||||
@tool(name=FileAccessProvider.SEARCH_FILES_TOOL_NAME, schema=_SearchFilesInput, approval_mode="always_require")
|
||||
@tool(name="file_access_search_files", schema=_SearchFilesInput, approval_mode="never_require")
|
||||
async def file_access_search_files(
|
||||
regex_pattern: str,
|
||||
file_pattern: str | None = None,
|
||||
|
||||
@@ -15,7 +15,10 @@ DEFAULT_MODE_SOURCE_ID = "agent_mode"
|
||||
DEFAULT_MODE_INSTRUCTIONS = (
|
||||
"## Agent Mode\n\n"
|
||||
"- You can operate in different modes. Depending on the mode you are in, "
|
||||
"you will be required to follow different processes.\n\n"
|
||||
"you will be required to follow different processes.\n"
|
||||
"- You must check the current mode after any user input, since the user may have changed the mode themselves, "
|
||||
"e.g. the user may have switched to 'plan' mode after a previous research task finished in 'execute' mode, "
|
||||
"meaning they want to review a plan first before execution.\n\n"
|
||||
"Use the mode_get tool to check your current operating mode.\n"
|
||||
"Use the mode_set tool to switch between modes as your work progresses. "
|
||||
"Only use mode_set if the user explicitly instructs/allows you to change modes.\n\n"
|
||||
@@ -53,12 +56,9 @@ DEFAULT_MODE_DESCRIPTIONS: dict[str, str] = {
|
||||
"and follow the steps for *Execute mode*."
|
||||
),
|
||||
"execute": (
|
||||
"Determine the type of ask:\n"
|
||||
"1. Simple question that doesn't require any further work to answer.\n"
|
||||
"2. Any other work, including complex user request that requires a multi-step process to satisfy.\n\n"
|
||||
"If 1. just answer the question directly.\n"
|
||||
"If 2. Work autonomously using your best judgment — do not ask the user questions or wait for feedback "
|
||||
"and follow the following process:\n"
|
||||
"Use this mode when carrying out approved plans. Work autonomously using your best judgment — do not ask "
|
||||
"the user questions or wait for feedback.\n\n"
|
||||
"Process to follow when in execute mode:\n"
|
||||
"1. If you don't have a plan or tasks yet, analyze the user request and create tasks and a plan. "
|
||||
"(**Skip this step if you came from plan mode**)\n"
|
||||
"2. Work autonomously — use your best judgment to make decisions and keep progressing without asking "
|
||||
|
||||
@@ -24,19 +24,15 @@ DEFAULT_TODO_SOURCE_ID = "todo"
|
||||
DEFAULT_TODO_INSTRUCTIONS = (
|
||||
"## Todo Items\n\n"
|
||||
"You have access to a todo list for tracking work items.\n"
|
||||
"When a user asks you to perform a task, follow these steps to manage your work:\n"
|
||||
"1. Determine whether the ask requires multiple steps to complete (complex) or can be completed "
|
||||
"using a single step (simple).\n"
|
||||
"2. If complex, turn the task into manageable todo items and add them to the list.\n"
|
||||
"3. If simple, don't add a todo item, but rather just complete the task directly.\n\n"
|
||||
"### General TODO Guidelines\n"
|
||||
"While planning, make sure that you break down complex tasks into manageable todo items "
|
||||
"and add them to the list.\n"
|
||||
"Ask questions from the user where clarification is needed to create effective todos.\n"
|
||||
"If the user provides feedback on your plan, adjust your todos accordingly by adding new items "
|
||||
"or removing irrelevant ones.\n"
|
||||
"During execution, use the todo list to keep track of what needs to be done, "
|
||||
"mark items as complete when finished, and remove any items that are no longer needed.\n"
|
||||
"When a user changes the topic, changes their mind or switches to a new request, ensure that you update "
|
||||
"the todo list accordingly by removing irrelevant/old items, clearing the list, or adding new ones as needed.\n\n"
|
||||
"When a user changes the topic or changes their mind, ensure that you update the todo list accordingly "
|
||||
"by removing irrelevant items or adding new ones as needed.\n\n"
|
||||
"Use these tools to manage your tasks:\n"
|
||||
"- Use todos_add to break down complex work into trackable items (supports adding one or many at once).\n"
|
||||
"- Use todos_complete to mark items as done when finished (supports one or many at once). "
|
||||
|
||||
@@ -2898,7 +2898,7 @@ class MCPWebsocketTool(MCPTool):
|
||||
An async context manager for the WebSocket client transport.
|
||||
"""
|
||||
try:
|
||||
from mcp.client.websocket import websocket_client # pyright: ignore[reportDeprecated]
|
||||
from mcp.client.websocket import websocket_client
|
||||
except ModuleNotFoundError as ex:
|
||||
missing_name = ex.name or "mcp/websocket dependencies"
|
||||
if missing_name == "mcp" or missing_name.startswith("mcp."):
|
||||
@@ -2917,4 +2917,4 @@ class MCPWebsocketTool(MCPTool):
|
||||
}
|
||||
if self._client_kwargs:
|
||||
args.update(self._client_kwargs)
|
||||
return websocket_client(**args) # pyright: ignore[reportDeprecated]
|
||||
return websocket_client(**args)
|
||||
|
||||
@@ -3,7 +3,6 @@
|
||||
import asyncio
|
||||
import contextlib
|
||||
import logging
|
||||
import warnings
|
||||
from collections import defaultdict
|
||||
from collections.abc import AsyncGenerator, Sequence
|
||||
from typing import Any
|
||||
@@ -11,7 +10,7 @@ from typing import Any
|
||||
from ..exceptions import (
|
||||
WorkflowCheckpointException,
|
||||
WorkflowConvergenceException,
|
||||
WorkflowException,
|
||||
WorkflowRunnerException,
|
||||
)
|
||||
from ._checkpoint import CheckpointID, CheckpointStorage, WorkflowCheckpoint
|
||||
from ._const import EXECUTOR_STATE_KEY
|
||||
@@ -28,21 +27,6 @@ from ._state import State
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def warn_runner_deprecated() -> None:
|
||||
"""Emit a deprecation warning when ``Runner`` is accessed from the public API.
|
||||
|
||||
``Runner`` remains importable from ``agent_framework`` for backward
|
||||
compatibility, but it is intended for internal use only and will be removed
|
||||
from the public API in a future version.
|
||||
"""
|
||||
warnings.warn(
|
||||
"`Runner` is deprecated and will be removed from the public API in a future version. "
|
||||
"It is intended for internal use only.",
|
||||
DeprecationWarning,
|
||||
stacklevel=3,
|
||||
)
|
||||
|
||||
|
||||
class Runner:
|
||||
"""A class to run a workflow in Pregel supersteps."""
|
||||
|
||||
@@ -79,123 +63,99 @@ class Runner:
|
||||
self._iteration = 0
|
||||
self._max_iterations = max_iterations
|
||||
self._state = state
|
||||
|
||||
# Checkpointing related attributes
|
||||
self._resumed_from_checkpoint = False
|
||||
self._previous_checkpoint_id: CheckpointID | None = None
|
||||
self._running = False
|
||||
self._resumed_from_checkpoint = False # Track whether we resumed
|
||||
|
||||
@property
|
||||
def context(self) -> RunnerContext:
|
||||
"""Get the runner context for message, event, and checkpoint handling."""
|
||||
"""Get the workflow context."""
|
||||
return self._ctx
|
||||
|
||||
@property
|
||||
def state(self) -> State:
|
||||
"""Get the shared state for the workflow."""
|
||||
return self._state
|
||||
|
||||
def reset_iteration_count(self) -> None:
|
||||
"""Reset the iteration count to zero.
|
||||
|
||||
This is useful when the workflow resumes from a new set of messages.
|
||||
|
||||
Note:
|
||||
When a workflow is resumed from a response (for a request_info_event)
|
||||
or a checkpoint, the iteration count is normally NOT reset.
|
||||
"""
|
||||
"""Reset the iteration count to zero."""
|
||||
self._iteration = 0
|
||||
|
||||
def reset_runtime_state(
|
||||
self,
|
||||
*,
|
||||
iteration: int = 0,
|
||||
previous_checkpoint_id: CheckpointID | None = None,
|
||||
resumed_from_checkpoint: bool = False,
|
||||
) -> None:
|
||||
"""Reset runner runtime bookkeeping to a known baseline.
|
||||
|
||||
Args:
|
||||
iteration: Iteration value to restore.
|
||||
previous_checkpoint_id: Checkpoint parent pointer for subsequent saves.
|
||||
resumed_from_checkpoint: Whether to treat next run as resumed.
|
||||
"""
|
||||
self._iteration = iteration
|
||||
self._previous_checkpoint_id = previous_checkpoint_id
|
||||
self._resumed_from_checkpoint = resumed_from_checkpoint
|
||||
|
||||
async def run_until_convergence(self) -> AsyncGenerator[WorkflowEvent, None]:
|
||||
"""Run the workflow until no more messages are sent."""
|
||||
# Emit any events already produced prior to entering loop
|
||||
if await self._ctx.has_events():
|
||||
logger.info("Yielding pre-loop events")
|
||||
for event in await self._ctx.drain_events():
|
||||
yield event
|
||||
if self._running:
|
||||
raise WorkflowRunnerException("Runner is already running.")
|
||||
|
||||
# Create a checkpoint before a run starts. Checkpoints are usually considered to be created at the
|
||||
# end of an iteration, we can think of this checkpoint as being created at the end of "superstep 0"
|
||||
# which captures the states after which the start executor has run. Note that we execute the start
|
||||
# executor outside of the main iteration loop.
|
||||
if await self._ctx.has_messages() and not self._resumed_from_checkpoint:
|
||||
await self.create_checkpoint_if_enabled()
|
||||
|
||||
while self._iteration < self._max_iterations:
|
||||
logger.info(f"Starting superstep {self._iteration + 1}")
|
||||
yield WorkflowEvent.superstep_started(iteration=self._iteration + 1)
|
||||
|
||||
# Run iteration concurrently with live event streaming: we poll
|
||||
# for new events while the iteration coroutine progresses.
|
||||
iteration_task = asyncio.create_task(self._run_iteration())
|
||||
try:
|
||||
while not iteration_task.done():
|
||||
try:
|
||||
# Wait briefly for any new event; timeout allows progress checks
|
||||
event = await asyncio.wait_for(self._ctx.next_event(), timeout=0.05)
|
||||
yield event
|
||||
except asyncio.TimeoutError:
|
||||
# Periodically continue to let iteration advance
|
||||
continue
|
||||
except asyncio.CancelledError:
|
||||
# Propagate cancellation to the iteration task to avoid orphaned work
|
||||
iteration_task.cancel()
|
||||
with contextlib.suppress(asyncio.CancelledError):
|
||||
await iteration_task
|
||||
raise
|
||||
|
||||
# Propagate errors from iteration, but first surface any pending events
|
||||
try:
|
||||
await iteration_task
|
||||
except Exception:
|
||||
# Make sure failure-related events (like ExecutorFailedEvent) are surfaced
|
||||
if await self._ctx.has_events():
|
||||
for event in await self._ctx.drain_events():
|
||||
yield event
|
||||
raise
|
||||
self._iteration += 1
|
||||
|
||||
# Drain any straggler events emitted at tail end
|
||||
self._running = True
|
||||
previous_checkpoint_id: CheckpointID | None = None
|
||||
try:
|
||||
# Emit any events already produced prior to entering loop
|
||||
if await self._ctx.has_events():
|
||||
logger.info("Yielding pre-loop events")
|
||||
for event in await self._ctx.drain_events():
|
||||
yield event
|
||||
|
||||
logger.info(f"Completed superstep {self._iteration}")
|
||||
# Create the first checkpoint. Checkpoints are usually considered to be created at the end of an iteration,
|
||||
# we can think of the first checkpoint as being created at the end of a "superstep 0" which captures the
|
||||
# states after which the start executor has run. Note that we execute the start executor outside of the
|
||||
# main iteration loop.
|
||||
if await self._ctx.has_messages() and not self._resumed_from_checkpoint:
|
||||
previous_checkpoint_id = await self._create_checkpoint_if_enabled(previous_checkpoint_id)
|
||||
|
||||
# Commit pending state changes at superstep boundary
|
||||
self._state.commit()
|
||||
while self._iteration < self._max_iterations:
|
||||
logger.info(f"Starting superstep {self._iteration + 1}")
|
||||
yield WorkflowEvent.superstep_started(iteration=self._iteration + 1)
|
||||
|
||||
# Create checkpoint after each superstep iteration
|
||||
await self.create_checkpoint_if_enabled()
|
||||
# Run iteration concurrently with live event streaming: we poll
|
||||
# for new events while the iteration coroutine progresses.
|
||||
iteration_task = asyncio.create_task(self._run_iteration())
|
||||
try:
|
||||
while not iteration_task.done():
|
||||
try:
|
||||
# Wait briefly for any new event; timeout allows progress checks
|
||||
event = await asyncio.wait_for(self._ctx.next_event(), timeout=0.05)
|
||||
yield event
|
||||
except asyncio.TimeoutError:
|
||||
# Periodically continue to let iteration advance
|
||||
continue
|
||||
except asyncio.CancelledError:
|
||||
# Propagate cancellation to the iteration task to avoid orphaned work
|
||||
iteration_task.cancel()
|
||||
with contextlib.suppress(asyncio.CancelledError):
|
||||
await iteration_task
|
||||
raise
|
||||
|
||||
yield WorkflowEvent.superstep_completed(iteration=self._iteration)
|
||||
# Propagate errors from iteration, but first surface any pending events
|
||||
try:
|
||||
await iteration_task
|
||||
except Exception:
|
||||
# Make sure failure-related events (like ExecutorFailedEvent) are surfaced
|
||||
if await self._ctx.has_events():
|
||||
for event in await self._ctx.drain_events():
|
||||
yield event
|
||||
raise
|
||||
self._iteration += 1
|
||||
|
||||
# Check for convergence: no more messages to process
|
||||
if not await self._ctx.has_messages():
|
||||
break
|
||||
# Drain any straggler events emitted at tail end
|
||||
if await self._ctx.has_events():
|
||||
for event in await self._ctx.drain_events():
|
||||
yield event
|
||||
|
||||
logger.info(f"Workflow completed after {self._iteration} supersteps")
|
||||
self._resumed_from_checkpoint = False # Reset resume flag for next run
|
||||
logger.info(f"Completed superstep {self._iteration}")
|
||||
|
||||
if self._iteration >= self._max_iterations and await self._ctx.has_messages():
|
||||
raise WorkflowConvergenceException(f"Runner did not converge after {self._max_iterations} iterations.")
|
||||
# Commit pending state changes at superstep boundary
|
||||
self._state.commit()
|
||||
|
||||
# Create checkpoint after each superstep iteration
|
||||
previous_checkpoint_id = await self._create_checkpoint_if_enabled(previous_checkpoint_id)
|
||||
|
||||
yield WorkflowEvent.superstep_completed(iteration=self._iteration)
|
||||
|
||||
# Check for convergence: no more messages to process
|
||||
if not await self._ctx.has_messages():
|
||||
break
|
||||
|
||||
if self._iteration >= self._max_iterations and await self._ctx.has_messages():
|
||||
raise WorkflowConvergenceException(f"Runner did not converge after {self._max_iterations} iterations.")
|
||||
|
||||
logger.info(f"Workflow completed after {self._iteration} supersteps")
|
||||
self._resumed_from_checkpoint = False # Reset resume flag for next run
|
||||
finally:
|
||||
self._running = False
|
||||
|
||||
async def _run_iteration(self) -> None:
|
||||
"""Run a single iteration of the workflow.
|
||||
@@ -249,121 +209,40 @@ class Runner:
|
||||
]
|
||||
await asyncio.gather(*tasks)
|
||||
|
||||
async def _prepare_checkpoint_state(self) -> None:
|
||||
"""Persist executor snapshots into committed shared state.
|
||||
|
||||
This is used by checkpoint capture paths that need a complete, restorable
|
||||
state payload without necessarily writing to a checkpoint storage backend.
|
||||
"""
|
||||
await self._save_executor_states()
|
||||
self._state.commit()
|
||||
|
||||
async def capture_checkpoint_object(self, *, metadata: dict[str, Any] | None = None) -> WorkflowCheckpoint:
|
||||
"""Capture the current runner state as an in-memory checkpoint object.
|
||||
|
||||
Persists executor snapshots into committed state and builds a
|
||||
``WorkflowCheckpoint`` from the current committed state. The checkpoint is
|
||||
not written to any storage backend; the caller owns its lifetime (for
|
||||
example, the workflow's captured initial checkpoint used by reset).
|
||||
|
||||
This is only valid when the runner is quiescent: it rejects capture when
|
||||
in-flight executor messages or pending request_info events are present,
|
||||
since those represent mid-run state that would not form a clean baseline.
|
||||
|
||||
Args:
|
||||
metadata: Optional metadata to attach to the checkpoint.
|
||||
|
||||
Returns:
|
||||
A ``WorkflowCheckpoint`` snapshot of the current runner state.
|
||||
|
||||
Raises:
|
||||
WorkflowException: If in-flight messages or pending requests are present.
|
||||
"""
|
||||
if await self._ctx.has_messages():
|
||||
raise WorkflowException("Cannot capture checkpoint while in-flight messages are present.")
|
||||
|
||||
pending_requests = await self._ctx.get_pending_request_info_events()
|
||||
if pending_requests:
|
||||
raise WorkflowException("Cannot capture checkpoint while pending requests are present.")
|
||||
|
||||
await self._prepare_checkpoint_state()
|
||||
return WorkflowCheckpoint(
|
||||
workflow_name=self._workflow_name,
|
||||
graph_signature_hash=self._graph_signature_hash,
|
||||
previous_checkpoint_id=None,
|
||||
messages={},
|
||||
state=self._state.export_state(),
|
||||
pending_request_info_events={},
|
||||
iteration_count=0,
|
||||
metadata=metadata or {},
|
||||
)
|
||||
|
||||
async def create_checkpoint_if_enabled(self) -> None:
|
||||
async def _create_checkpoint_if_enabled(self, previous_checkpoint_id: CheckpointID | None) -> CheckpointID | None:
|
||||
"""Create a checkpoint if checkpointing is enabled and attach a label and metadata."""
|
||||
if not self._ctx.has_checkpointing():
|
||||
return
|
||||
return None
|
||||
|
||||
try:
|
||||
# Save executor states into committed state before creating the checkpoint.
|
||||
await self._prepare_checkpoint_state()
|
||||
# Save executor states into the shared state before creating the checkpoint,
|
||||
# so that they are included in the checkpoint payload.
|
||||
await self._save_executor_states()
|
||||
# `on_checkpoint_save()` writes via State.set(), which stages values in the
|
||||
# pending buffer. Checkpoints serialize committed state only, so commit here
|
||||
# to ensure executor snapshots are captured in this checkpoint.
|
||||
self._state.commit()
|
||||
|
||||
checkpoint_id = await self._ctx.create_checkpoint(
|
||||
self._workflow_name,
|
||||
self._graph_signature_hash,
|
||||
self._state,
|
||||
self._previous_checkpoint_id,
|
||||
previous_checkpoint_id,
|
||||
self._iteration,
|
||||
)
|
||||
|
||||
logger.info(
|
||||
"Created checkpoint: %s with parent checkpoint at iteration %d: %s",
|
||||
checkpoint_id,
|
||||
self._iteration,
|
||||
self._previous_checkpoint_id,
|
||||
)
|
||||
self._previous_checkpoint_id = checkpoint_id
|
||||
logger.info(f"Created checkpoint: {checkpoint_id}")
|
||||
return checkpoint_id
|
||||
except Exception as e:
|
||||
logger.warning(
|
||||
"Failed to create checkpoint at iteration %d: %s. "
|
||||
"Note that this does not fail the workflow run. "
|
||||
"The next successfully-created checkpoint will be parented to the last successful checkpoint: %s",
|
||||
self._iteration,
|
||||
e,
|
||||
self._previous_checkpoint_id,
|
||||
)
|
||||
|
||||
async def restore_from_checkpoint_object(self, checkpoint: WorkflowCheckpoint) -> None:
|
||||
"""Restore runner state from an in-memory checkpoint object.
|
||||
|
||||
Unlike :meth:`restore_from_checkpoint`, this does not load from storage or
|
||||
validate the graph signature; it applies a checkpoint that the caller already
|
||||
holds (for example, the workflow's captured initial checkpoint used by reset).
|
||||
|
||||
This clears any runtime checkpoint storage override and resets the context for a
|
||||
fresh run, then restores shared state, executor snapshots, and runtime bookkeeping
|
||||
from the checkpoint.
|
||||
|
||||
Args:
|
||||
checkpoint: The checkpoint whose state should be restored.
|
||||
"""
|
||||
self._ctx.clear_runtime_checkpoint_storage()
|
||||
self._ctx.reset_for_new_run()
|
||||
|
||||
self._state.clear()
|
||||
self._state.import_state(checkpoint.state)
|
||||
await self._restore_executor_states()
|
||||
self.reset_runtime_state(
|
||||
iteration=checkpoint.iteration_count,
|
||||
previous_checkpoint_id=checkpoint.previous_checkpoint_id,
|
||||
resumed_from_checkpoint=False,
|
||||
)
|
||||
logger.warning(f"Failed to create checkpoint: {e}")
|
||||
return None
|
||||
|
||||
async def restore_from_checkpoint(
|
||||
self,
|
||||
checkpoint_id: CheckpointID,
|
||||
checkpoint_storage: CheckpointStorage | None = None,
|
||||
) -> None:
|
||||
"""Restore the runner from a checkpoint.
|
||||
"""Restore workflow state from a checkpoint.
|
||||
|
||||
Args:
|
||||
checkpoint_id: The ID of the checkpoint to restore from
|
||||
@@ -411,7 +290,7 @@ class Runner:
|
||||
# Apply the checkpoint to the context
|
||||
await self._ctx.apply_checkpoint(checkpoint)
|
||||
# Mark the runner as resumed
|
||||
self._mark_resumed(checkpoint)
|
||||
self._mark_resumed(checkpoint.iteration_count)
|
||||
|
||||
logger.info(f"Successfully restored workflow from checkpoint: {checkpoint_id}")
|
||||
except WorkflowCheckpointException:
|
||||
@@ -477,14 +356,13 @@ class Runner:
|
||||
|
||||
return parsed
|
||||
|
||||
def _mark_resumed(self, checkpoint: WorkflowCheckpoint) -> None:
|
||||
def _mark_resumed(self, iteration: int) -> None:
|
||||
"""Mark the runner as having resumed from a checkpoint.
|
||||
|
||||
Optionally set the current iteration and max iterations.
|
||||
"""
|
||||
self._resumed_from_checkpoint = True
|
||||
self._iteration = checkpoint.iteration_count
|
||||
self._previous_checkpoint_id = checkpoint.checkpoint_id
|
||||
self._iteration = iteration
|
||||
|
||||
async def _set_executor_state(self, executor_id: str, state: dict[str, Any]) -> None:
|
||||
"""Store executor state in state under a reserved key.
|
||||
|
||||
@@ -403,14 +403,12 @@ class InProcRunnerContext:
|
||||
def reset_for_new_run(self) -> None:
|
||||
"""Reset the context for a new workflow run.
|
||||
|
||||
Clears messages, the pending event queue, the pending request_info
|
||||
correlation map, and the streaming flag. Runtime checkpoint storage is
|
||||
NOT cleared here as it's managed at the workflow level.
|
||||
This clears messages, events, and resets streaming flag.
|
||||
Runtime checkpoint storage is NOT cleared here as it's managed at the workflow level.
|
||||
"""
|
||||
self._messages.clear()
|
||||
# Clear any pending events (best-effort) by recreating the queue
|
||||
self._event_queue = asyncio.Queue()
|
||||
self._pending_request_info_events.clear()
|
||||
self._streaming = False # Reset streaming flag
|
||||
|
||||
async def apply_checkpoint(self, checkpoint: WorkflowCheckpoint) -> None:
|
||||
|
||||
@@ -11,16 +11,14 @@ import logging
|
||||
import types
|
||||
import uuid
|
||||
import warnings
|
||||
import weakref
|
||||
from collections.abc import AsyncIterable, Awaitable, Callable, Mapping, Sequence
|
||||
from dataclasses import dataclass, field
|
||||
from typing import TYPE_CHECKING, Any, Literal, overload
|
||||
|
||||
from .._sessions import ContextProvider
|
||||
from .._types import ResponseStream
|
||||
from ..exceptions import WorkflowException
|
||||
from ..observability import OtelAttr, capture_exception, create_workflow_span
|
||||
from ._checkpoint import CheckpointStorage, WorkflowCheckpoint
|
||||
from ._checkpoint import CheckpointStorage
|
||||
from ._const import DEFAULT_MAX_ITERATIONS, GLOBAL_KWARGS_KEY, WORKFLOW_RUN_KWARGS_KEY
|
||||
from ._edge import (
|
||||
EdgeGroup,
|
||||
@@ -348,33 +346,25 @@ class Workflow(DictConvertible):
|
||||
# Store non-serializable runtime objects as private attributes
|
||||
self._runner_context = runner_context
|
||||
self._runner_context.set_yield_output_classifier(self._output_designation.classify)
|
||||
self._state = State()
|
||||
self._runner: Runner = Runner(
|
||||
self.edge_groups,
|
||||
self.executors,
|
||||
State(),
|
||||
self._state,
|
||||
runner_context,
|
||||
self.name,
|
||||
self.graph_signature_hash,
|
||||
max_iterations=max_iterations,
|
||||
)
|
||||
|
||||
# Flag to prevent concurrent workflow executions
|
||||
self._is_running = False
|
||||
|
||||
# Current run-level status of this workflow instance. Updated in lockstep with
|
||||
# the status events emitted from `_run_workflow_with_tracing`. Defaults to IDLE
|
||||
# for a freshly built workflow that has not yet been run.
|
||||
self._status: WorkflowRunState = WorkflowRunState.IDLE
|
||||
|
||||
# Weak reference to the in-flight run's ``ResponseStream``. Used as the single
|
||||
# concurrency lock: if the previous stream is still alive, ``run()`` rejects a
|
||||
# new run synchronously (before any await). When the stream is fully consumed
|
||||
# ``_run_core``'s finally clears this; if the caller drops the stream without
|
||||
# ever iterating, the weakref dereferences to ``None`` once Python collects it,
|
||||
# so a subsequent ``run()`` is allowed.
|
||||
self._active_run: weakref.ref[ResponseStream[WorkflowEvent, WorkflowRunResult]] | None = None
|
||||
|
||||
# In-memory initial checkpoint captured from the just-built workflow state.
|
||||
# This is internal-only and used by ``reset()``.
|
||||
self._initial_checkpoint: WorkflowCheckpoint | None = None
|
||||
|
||||
@property
|
||||
def status(self) -> WorkflowRunState:
|
||||
"""Return the current run-level status of this workflow instance.
|
||||
@@ -386,6 +376,16 @@ class Workflow(DictConvertible):
|
||||
"""
|
||||
return self._status
|
||||
|
||||
def _ensure_not_running(self) -> None:
|
||||
"""Ensure the workflow is not already running."""
|
||||
if self._is_running:
|
||||
raise RuntimeError("Workflow is already running. Concurrent executions are not allowed.")
|
||||
self._is_running = True
|
||||
|
||||
def _reset_running_flag(self) -> None:
|
||||
"""Reset the running flag."""
|
||||
self._is_running = False
|
||||
|
||||
def to_dict(self) -> dict[str, Any]:
|
||||
"""Serialize the workflow definition into a JSON-ready dictionary."""
|
||||
data: dict[str, Any] = {
|
||||
@@ -478,44 +478,6 @@ class Workflow(DictConvertible):
|
||||
"""Get the list of executors in the workflow."""
|
||||
return list(self.executors.values())
|
||||
|
||||
async def _ensure_initial_checkpoint(self) -> None:
|
||||
"""Capture the in-memory initial checkpoint once for this workflow instance."""
|
||||
if self._initial_checkpoint is not None:
|
||||
return
|
||||
|
||||
self._initial_checkpoint = await self._runner.capture_checkpoint_object(
|
||||
metadata={"kind": "initial_in_memory"},
|
||||
)
|
||||
|
||||
async def reset(self) -> None:
|
||||
"""Reset the workflow instance to its captured initial checkpoint state.
|
||||
|
||||
The initial checkpoint is captured in memory once per workflow instance and
|
||||
is not persisted to external checkpoint storage.
|
||||
|
||||
Raises:
|
||||
WorkflowException: If called while a workflow run is active.
|
||||
"""
|
||||
if self._is_run_active():
|
||||
raise WorkflowException(
|
||||
"Cannot reset workflow while a run is active. "
|
||||
"Reset is only allowed between runs when the workflow is idle."
|
||||
)
|
||||
|
||||
# Capture the baseline if it doesn't exist yet. This is idempotent: on a
|
||||
# normal reset after one or more runs it's a no-op (the snapshot was taken
|
||||
# before the first run); when reset is the first operation it captures the
|
||||
# pristine just-built state so the workflow stays runnable.
|
||||
await self._ensure_initial_checkpoint()
|
||||
if self._initial_checkpoint is None:
|
||||
raise WorkflowException("Workflow initial checkpoint is unavailable.")
|
||||
|
||||
# Restore runner state, executor snapshots, and runtime bookkeeping from the
|
||||
# in-memory initial checkpoint.
|
||||
await self._runner.restore_from_checkpoint_object(self._initial_checkpoint)
|
||||
|
||||
self._status = WorkflowRunState.IDLE
|
||||
|
||||
async def _run_workflow_with_tracing(
|
||||
self,
|
||||
initial_executor_fn: Callable[[], Awaitable[None]] | None = None,
|
||||
@@ -573,12 +535,13 @@ class Workflow(DictConvertible):
|
||||
yield in_progress # noqa: RUF070
|
||||
|
||||
# Per-run reset for fresh-message runs only. We deliberately
|
||||
# do NOT clear shared workflow state or the runner context's
|
||||
# in-flight messages here - state and pending work persist
|
||||
# across `run()` calls so that a `WorkflowAgent` can deliver
|
||||
# multi-turn input on the same instance and have prior turns'
|
||||
# context survive. Iteration counting and per-run kwargs ARE
|
||||
# per-run though, so they're reset here.
|
||||
# do NOT clear shared workflow state (`_state.clear()`) or the
|
||||
# runner context's in-flight messages (`reset_for_new_run()`)
|
||||
# here - state and pending work persist across `run()` calls
|
||||
# so that a `WorkflowAgent` can deliver multi-turn input on
|
||||
# the same instance and have prior turns' context survive.
|
||||
# Iteration counting and per-run kwargs ARE per-run though,
|
||||
# so they're reset here.
|
||||
if not is_continuation:
|
||||
self._runner.reset_iteration_count()
|
||||
|
||||
@@ -601,13 +564,14 @@ class Workflow(DictConvertible):
|
||||
combined_kwargs["client_kwargs"] = self._resolve_invocation_kwargs(
|
||||
client_kwargs, "client_kwargs"
|
||||
)
|
||||
self._runner.state.set(WORKFLOW_RUN_KWARGS_KEY, combined_kwargs)
|
||||
self._state.set(WORKFLOW_RUN_KWARGS_KEY, combined_kwargs)
|
||||
elif not is_continuation:
|
||||
self._runner.state.set(WORKFLOW_RUN_KWARGS_KEY, {})
|
||||
self._runner.state.commit() # Commit immediately so kwargs are available
|
||||
self._state.set(WORKFLOW_RUN_KWARGS_KEY, {})
|
||||
self._state.commit() # Commit immediately so kwargs are available
|
||||
|
||||
# Explicitly set streaming mode per run
|
||||
self._runner.context.set_streaming(streaming)
|
||||
# Set streaming mode (always set explicitly per run since
|
||||
# reset_for_new_run() no longer runs to clear it).
|
||||
self._runner_context.set_streaming(streaming)
|
||||
|
||||
# Execute initial setup if provided
|
||||
if initial_executor_fn:
|
||||
@@ -701,7 +665,7 @@ class Workflow(DictConvertible):
|
||||
await executor.execute(
|
||||
message,
|
||||
[self.__class__.__name__],
|
||||
self._runner.state,
|
||||
self._state,
|
||||
self._runner.context,
|
||||
trace_contexts=None,
|
||||
source_span_ids=None,
|
||||
@@ -781,22 +745,9 @@ class Workflow(DictConvertible):
|
||||
Raises:
|
||||
ValueError: If parameter combination is invalid.
|
||||
"""
|
||||
# Validate parameters first so misuse fails before we touch any run state.
|
||||
# Validate parameters and set running flag eagerly (before any async work)
|
||||
self._validate_run_params(message, responses, checkpoint_id)
|
||||
|
||||
# Concurrency check: reject a second run synchronously - before constructing
|
||||
# the ResponseStream or yielding control to the event loop - so a concurrent
|
||||
# ``run`` call can't slip past the guard while the first call is suspended
|
||||
# inside its async generator. The ``ResponseStream`` returned below is the
|
||||
# lock: as long as the caller holds a reference to it, ``self._active_run()``
|
||||
# resolves to a live object and a new ``run`` is rejected. When the stream is
|
||||
# fully consumed, ``_run_core``'s finally clears the attribute. When the
|
||||
# caller drops the stream without iterating, garbage collection invalidates
|
||||
# the weakref, so a subsequent ``run`` is permitted.
|
||||
if self._is_run_active():
|
||||
raise WorkflowException(
|
||||
"Workflow is already running; concurrent runs are not allowed on the same instance."
|
||||
)
|
||||
self._ensure_not_running()
|
||||
|
||||
response_stream = ResponseStream[WorkflowEvent, WorkflowRunResult](
|
||||
self._run_core(
|
||||
@@ -809,8 +760,10 @@ class Workflow(DictConvertible):
|
||||
client_kwargs=client_kwargs,
|
||||
),
|
||||
finalizer=functools.partial(self._finalize_events, include_status_events=include_status_events),
|
||||
cleanup_hooks=[
|
||||
functools.partial(self._run_cleanup, checkpoint_storage),
|
||||
],
|
||||
)
|
||||
self._active_run = weakref.ref(response_stream)
|
||||
|
||||
if stream:
|
||||
return response_stream
|
||||
@@ -836,69 +789,51 @@ class Workflow(DictConvertible):
|
||||
if checkpoint_storage is not None:
|
||||
self._runner.context.set_runtime_checkpoint_storage(checkpoint_storage)
|
||||
|
||||
# Capture the weakref instance ``run()`` installed for *this* run. We
|
||||
# compare by object identity in the finally so a stale finalizer (e.g.
|
||||
# the caller dropped this stream after partial iteration, then started
|
||||
# a new run before async-gen finalization throws ``GeneratorExit`` into
|
||||
# us) does not clobber a successor run's freshly installed weakref.
|
||||
# ``run()`` runs synchronously and assigns ``self._active_run`` before
|
||||
# this generator's body is first iterated, so by the time we read it
|
||||
# here it already points at our own ``ResponseStream``.
|
||||
my_active_run = self._active_run
|
||||
# Async validation: a fresh-message run is only allowed when the
|
||||
# runner context has fully drained from any prior run. If it still
|
||||
# has in-flight executor messages, the prior run didn't complete -
|
||||
# the caller must either resume from a checkpoint or wait for the
|
||||
# prior run to drain. (Pending request_info events are intentionally
|
||||
# NOT blocked here: a follow-up run with message=... is the normal
|
||||
# way to deliver a response to those pending requests, e.g. via
|
||||
# WorkflowAgent._process_pending_requests.)
|
||||
# NOTE: _validate_run_params already enforces that ``message`` is
|
||||
# mutually exclusive with both ``checkpoint_id`` and ``responses``,
|
||||
# so we don't need to re-check those here.
|
||||
if message is not None and await self._runner.context.has_messages():
|
||||
raise RuntimeError(
|
||||
"Cannot start a new run with 'message' while in-flight executor "
|
||||
"messages remain from a prior run. Resume from a checkpoint "
|
||||
"(checkpoint_id=...) or wait for the prior run to complete. "
|
||||
"Workflows that need to recover from a mid-run failure must use "
|
||||
"checkpointing; there is no in-process recovery path."
|
||||
)
|
||||
|
||||
try:
|
||||
# Async validation: a fresh-message run is only allowed when the
|
||||
# runner context has fully drained from any prior run. If it still
|
||||
# has in-flight executor messages, the prior run didn't complete -
|
||||
# the caller must either resume from a checkpoint or wait for the
|
||||
# prior run to drain. (Pending request_info events are intentionally
|
||||
# NOT blocked here: a follow-up run with message=... is the normal
|
||||
# way to deliver a response to those pending requests, e.g. via
|
||||
# WorkflowAgent._process_pending_requests.)
|
||||
# NOTE: _validate_run_params already enforces that ``message`` is
|
||||
# mutually exclusive with both ``checkpoint_id`` and ``responses``,
|
||||
# so we don't need to re-check those here.
|
||||
if message is not None and await self._runner.context.has_messages():
|
||||
raise RuntimeError(
|
||||
"Cannot start a new run with 'message' while in-flight executor "
|
||||
"messages remain from a prior run. Resume from a checkpoint "
|
||||
"(checkpoint_id=...) or wait for the prior run to complete. "
|
||||
"Workflows that need to recover from a mid-run failure must use "
|
||||
"checkpointing; there is no in-process recovery path."
|
||||
)
|
||||
initial_executor_fn = self._resolve_execution_mode(message, responses, checkpoint_id, checkpoint_storage)
|
||||
|
||||
await self._ensure_initial_checkpoint()
|
||||
async for event in self._run_workflow_with_tracing(
|
||||
initial_executor_fn=initial_executor_fn,
|
||||
is_continuation=(message is None),
|
||||
streaming=streaming,
|
||||
function_invocation_kwargs=function_invocation_kwargs,
|
||||
client_kwargs=client_kwargs,
|
||||
):
|
||||
if event.type == "request_info" and event.request_id in (responses or {}):
|
||||
# Don't yield request_info events for which we have responses to send -
|
||||
# these are considered "handled". This prevents the caller from seeing
|
||||
# events for requests they are already responding to.
|
||||
# This usually happens when responses are provided with a checkpoint
|
||||
# (restore then send), because the request_info events are stored in the
|
||||
# checkpoint and would be emitted on restoration by the runner regardless
|
||||
# of if a response is provided or not.
|
||||
continue
|
||||
yield event
|
||||
|
||||
initial_executor_fn = self._resolve_execution_mode(message, responses, checkpoint_id, checkpoint_storage)
|
||||
|
||||
async for event in self._run_workflow_with_tracing(
|
||||
initial_executor_fn=initial_executor_fn,
|
||||
is_continuation=(message is None),
|
||||
streaming=streaming,
|
||||
function_invocation_kwargs=function_invocation_kwargs,
|
||||
client_kwargs=client_kwargs,
|
||||
):
|
||||
if event.type == "request_info" and event.request_id in (responses or {}):
|
||||
# Don't yield request_info events for which we have responses to send -
|
||||
# these are considered "handled". This prevents the caller from seeing
|
||||
# events for requests they are already responding to.
|
||||
# This usually happens when responses are provided with a checkpoint
|
||||
# (restore then send), because the request_info events are stored in the
|
||||
# checkpoint and would be emitted on restoration by the runner regardless
|
||||
# of if a response is provided or not.
|
||||
continue
|
||||
yield event
|
||||
finally:
|
||||
# Clear the active-run weakref so a subsequent ``run()`` is allowed,
|
||||
# but only if the slot still holds *our* weakref. If the caller
|
||||
# dropped this stream after partial iteration and a new ``run()``
|
||||
# already installed its own weakref before our async-gen finalizer
|
||||
# ran, ``self._active_run`` now points at the successor; clearing
|
||||
# it would silently break the successor's concurrency guard.
|
||||
if self._active_run is my_active_run:
|
||||
self._active_run = None
|
||||
if checkpoint_storage is not None:
|
||||
self._runner.context.clear_runtime_checkpoint_storage()
|
||||
async def _run_cleanup(self, checkpoint_storage: CheckpointStorage | None) -> None:
|
||||
"""Cleanup hook called after stream consumption."""
|
||||
if checkpoint_storage is not None:
|
||||
self._runner.context.clear_runtime_checkpoint_storage()
|
||||
self._reset_running_flag()
|
||||
|
||||
@staticmethod
|
||||
def _finalize_events(
|
||||
@@ -1000,7 +935,7 @@ class Workflow(DictConvertible):
|
||||
|
||||
async def _send_responses_internal(self, responses: Mapping[str, Any]) -> None:
|
||||
"""Internal method to validate and send responses to the executors."""
|
||||
pending_requests = await self._runner.context.get_pending_request_info_events()
|
||||
pending_requests = await self._runner_context.get_pending_request_info_events()
|
||||
if not pending_requests:
|
||||
raise RuntimeError("No pending requests found in workflow context.")
|
||||
|
||||
@@ -1020,7 +955,7 @@ class Workflow(DictConvertible):
|
||||
coerced_responses[request_id] = response
|
||||
|
||||
await asyncio.gather(*[
|
||||
self._runner.context.send_request_info_response(request_id, response)
|
||||
self._runner_context.send_request_info_response(request_id, response)
|
||||
for request_id, response in coerced_responses.items()
|
||||
])
|
||||
|
||||
@@ -1216,12 +1151,3 @@ class Workflow(DictConvertible):
|
||||
context_providers=context_providers,
|
||||
**kwargs,
|
||||
)
|
||||
|
||||
def _is_run_active(self) -> bool:
|
||||
"""Check if a workflow run is currently active.
|
||||
|
||||
Returns:
|
||||
True if a run is active, False otherwise.
|
||||
"""
|
||||
existing_stream = self._active_run() if self._active_run is not None else None
|
||||
return existing_stream is not None
|
||||
|
||||
@@ -517,10 +517,6 @@ class WorkflowExecutor(Executor):
|
||||
self._execution_contexts = execution_contexts
|
||||
self._request_to_execution = request_to_execution
|
||||
|
||||
# Reset the sub workflow to its initial state. This must be done before pumping
|
||||
# the request info events back into the sub workflow.
|
||||
await self.workflow.reset()
|
||||
|
||||
# Add the `request_info_event`s back to the sub workflow.
|
||||
# This is only a temporary solution to rehydrate the sub workflow with the requests.
|
||||
# The proper way would be to rehydrate the workflow from a checkpoint on a Workflow
|
||||
|
||||
@@ -497,10 +497,10 @@ async def test_file_access_provider_registers_tools_and_instructions(
|
||||
assert any(DEFAULT_FILE_ACCESS_INSTRUCTIONS in chunk for chunk in (instructions or []))
|
||||
|
||||
|
||||
async def test_file_access_provider_all_tools_require_approval(
|
||||
async def test_file_access_provider_delete_approval_defaults_to_always_require(
|
||||
chat_client_base: SupportsChatGetResponse,
|
||||
) -> None:
|
||||
"""Every file-access tool should require host approval."""
|
||||
"""By default ``file_access_delete_file`` should require host approval."""
|
||||
session = AgentSession(session_id="session-1")
|
||||
provider = FileAccessProvider(store=InMemoryAgentFileStore())
|
||||
agent = Agent(client=chat_client_base, context_providers=[provider])
|
||||
@@ -512,72 +512,36 @@ async def test_file_access_provider_all_tools_require_approval(
|
||||
|
||||
tools = options["tools"]
|
||||
assert isinstance(tools, list)
|
||||
delete_file = _tool_by_name(tools, "file_access_delete_file")
|
||||
assert delete_file.approval_mode == "always_require"
|
||||
# The non-destructive tools should remain autonomous.
|
||||
for name in (
|
||||
FileAccessProvider.SAVE_FILE_TOOL_NAME,
|
||||
FileAccessProvider.READ_FILE_TOOL_NAME,
|
||||
FileAccessProvider.DELETE_FILE_TOOL_NAME,
|
||||
FileAccessProvider.LIST_FILES_TOOL_NAME,
|
||||
FileAccessProvider.LIST_SUBDIRECTORIES_TOOL_NAME,
|
||||
FileAccessProvider.SEARCH_FILES_TOOL_NAME,
|
||||
"file_access_save_file",
|
||||
"file_access_read_file",
|
||||
"file_access_list_files",
|
||||
"file_access_list_subdirectories",
|
||||
"file_access_search_files",
|
||||
):
|
||||
assert _tool_by_name(tools, name).approval_mode == "always_require"
|
||||
assert _tool_by_name(tools, name).approval_mode == "never_require"
|
||||
|
||||
|
||||
def test_read_only_tools_auto_approval_rule() -> None:
|
||||
"""The read-only rule approves only the non-mutating tools."""
|
||||
approved = {
|
||||
FileAccessProvider.READ_FILE_TOOL_NAME,
|
||||
FileAccessProvider.LIST_FILES_TOOL_NAME,
|
||||
FileAccessProvider.LIST_SUBDIRECTORIES_TOOL_NAME,
|
||||
FileAccessProvider.SEARCH_FILES_TOOL_NAME,
|
||||
}
|
||||
rejected = {
|
||||
FileAccessProvider.SAVE_FILE_TOOL_NAME,
|
||||
FileAccessProvider.DELETE_FILE_TOOL_NAME,
|
||||
"some_other_tool",
|
||||
}
|
||||
for name in approved:
|
||||
call = Content("function_call", call_id="c1", name=name, arguments="{}")
|
||||
assert FileAccessProvider.read_only_tools_auto_approval_rule(call) is True
|
||||
for name in rejected:
|
||||
call = Content("function_call", call_id="c1", name=name, arguments="{}")
|
||||
assert FileAccessProvider.read_only_tools_auto_approval_rule(call) is False
|
||||
# A hosted tool with the same name (carrying a server_label) is NOT auto-approved.
|
||||
for name in approved:
|
||||
hosted = Content(
|
||||
"function_call",
|
||||
call_id="c1",
|
||||
name=name,
|
||||
arguments="{}",
|
||||
additional_properties={"server_label": "remote"},
|
||||
)
|
||||
assert FileAccessProvider.read_only_tools_auto_approval_rule(hosted) is False
|
||||
async def test_file_access_provider_delete_approval_opt_out(
|
||||
chat_client_base: SupportsChatGetResponse,
|
||||
) -> None:
|
||||
"""``require_delete_approval=False`` should drop delete to ``never_require``."""
|
||||
session = AgentSession(session_id="session-1")
|
||||
provider = FileAccessProvider(store=InMemoryAgentFileStore(), require_delete_approval=False)
|
||||
agent = Agent(client=chat_client_base, context_providers=[provider])
|
||||
|
||||
_, options = await agent._prepare_session_and_messages( # pyright: ignore[reportPrivateUsage]
|
||||
session=session,
|
||||
input_messages=[Message(role="user", contents=["work with files"])],
|
||||
)
|
||||
|
||||
def test_all_tools_auto_approval_rule() -> None:
|
||||
"""The all-tools rule approves every file-access tool but nothing else."""
|
||||
for name in (
|
||||
FileAccessProvider.SAVE_FILE_TOOL_NAME,
|
||||
FileAccessProvider.READ_FILE_TOOL_NAME,
|
||||
FileAccessProvider.DELETE_FILE_TOOL_NAME,
|
||||
FileAccessProvider.LIST_FILES_TOOL_NAME,
|
||||
FileAccessProvider.LIST_SUBDIRECTORIES_TOOL_NAME,
|
||||
FileAccessProvider.SEARCH_FILES_TOOL_NAME,
|
||||
):
|
||||
call = Content("function_call", call_id="c1", name=name, arguments="{}")
|
||||
assert FileAccessProvider.all_tools_auto_approval_rule(call) is True
|
||||
# A hosted tool with the same name (carrying a server_label) is NOT auto-approved.
|
||||
hosted = Content(
|
||||
"function_call",
|
||||
call_id="c1",
|
||||
name=name,
|
||||
arguments="{}",
|
||||
additional_properties={"server_label": "remote"},
|
||||
)
|
||||
assert FileAccessProvider.all_tools_auto_approval_rule(hosted) is False
|
||||
|
||||
unrelated = Content("function_call", call_id="c1", name="some_other_tool", arguments="{}")
|
||||
assert FileAccessProvider.all_tools_auto_approval_rule(unrelated) is False
|
||||
tools = options["tools"]
|
||||
assert isinstance(tools, list)
|
||||
delete_file = _tool_by_name(tools, "file_access_delete_file")
|
||||
assert delete_file.approval_mode == "never_require"
|
||||
|
||||
|
||||
async def test_file_access_provider_tools_round_trip_files(
|
||||
|
||||
@@ -336,97 +336,6 @@ async def test_workflow_checkpoint_chaining_via_previous_checkpoint_id():
|
||||
)
|
||||
|
||||
|
||||
async def test_workflow_checkpoint_ancestry_preserved_after_resume():
|
||||
"""Resuming from a checkpoint must preserve ancestry: future checkpoints chain back to the resumed one."""
|
||||
from typing_extensions import Never
|
||||
|
||||
from agent_framework import WorkflowBuilder, WorkflowContext, handler
|
||||
from agent_framework._workflows._executor import Executor
|
||||
|
||||
class StartExecutor(Executor):
|
||||
@handler
|
||||
async def run(self, message: str, ctx: WorkflowContext[str]) -> None:
|
||||
await ctx.send_message(message, target_id="middle")
|
||||
|
||||
class MiddleExecutor(Executor):
|
||||
@handler
|
||||
async def process(self, message: str, ctx: WorkflowContext[str]) -> None:
|
||||
await ctx.send_message(message + "-processed", target_id="finish")
|
||||
|
||||
class FinishExecutor(Executor):
|
||||
@handler
|
||||
async def finish(self, message: str, ctx: WorkflowContext[Never, str]) -> None:
|
||||
await ctx.yield_output(message + "-done")
|
||||
|
||||
storage = InMemoryCheckpointStorage()
|
||||
|
||||
def _build_workflow() -> Any:
|
||||
start = StartExecutor(id="start")
|
||||
middle = MiddleExecutor(id="middle")
|
||||
finish = FinishExecutor(id="finish")
|
||||
return (
|
||||
WorkflowBuilder(
|
||||
name="resume-ancestry-test",
|
||||
max_iterations=10,
|
||||
start_executor=start,
|
||||
checkpoint_storage=storage,
|
||||
)
|
||||
.add_edge(start, middle)
|
||||
.add_edge(middle, finish)
|
||||
.build()
|
||||
)
|
||||
|
||||
# First run: produce an initial chain of checkpoints
|
||||
workflow = _build_workflow()
|
||||
workflow_name = workflow.name
|
||||
_ = [event async for event in workflow.run("hello", stream=True)]
|
||||
|
||||
initial_checkpoints = sorted(await storage.list_checkpoints(workflow_name=workflow_name), key=lambda c: c.timestamp)
|
||||
assert len(initial_checkpoints) >= 3, (
|
||||
f"Need at least 3 initial checkpoints to pick a middle one, got {len(initial_checkpoints)}"
|
||||
)
|
||||
initial_ids = {cp.checkpoint_id for cp in initial_checkpoints}
|
||||
|
||||
# Pick an intermediate checkpoint to resume from (not the first, not the last)
|
||||
resume_from = initial_checkpoints[len(initial_checkpoints) // 2]
|
||||
|
||||
# Resume on a fresh workflow instance (same graph signature) and run to completion
|
||||
resumed_workflow = _build_workflow()
|
||||
assert resumed_workflow.name == workflow_name
|
||||
_ = [event async for event in resumed_workflow.run(checkpoint_id=resume_from.checkpoint_id, stream=True)]
|
||||
|
||||
# Inspect new checkpoints created after resuming
|
||||
all_checkpoints = sorted(await storage.list_checkpoints(workflow_name=workflow_name), key=lambda c: c.timestamp)
|
||||
new_checkpoints = [cp for cp in all_checkpoints if cp.checkpoint_id not in initial_ids]
|
||||
assert new_checkpoints, "Resuming from an intermediate checkpoint should produce new checkpoints"
|
||||
|
||||
# The very first checkpoint created after resuming must chain back to the resumed checkpoint
|
||||
assert new_checkpoints[0].previous_checkpoint_id == resume_from.checkpoint_id, (
|
||||
"First post-resume checkpoint must chain to the checkpoint that was resumed from; "
|
||||
f"got previous_checkpoint_id={new_checkpoints[0].previous_checkpoint_id!r}, "
|
||||
f"expected {resume_from.checkpoint_id!r}"
|
||||
)
|
||||
|
||||
# Subsequent post-resume checkpoints must continue chaining
|
||||
for i in range(1, len(new_checkpoints)):
|
||||
assert new_checkpoints[i].previous_checkpoint_id == new_checkpoints[i - 1].checkpoint_id, (
|
||||
f"Post-resume checkpoint {i} should chain to checkpoint {i - 1}"
|
||||
)
|
||||
|
||||
# Walking the chain backwards from the most recent checkpoint must reach the original root
|
||||
# without breaks (i.e. the full ancestry across the resume boundary is intact).
|
||||
checkpoints_by_id = {cp.checkpoint_id: cp for cp in all_checkpoints}
|
||||
chain: list[str] = []
|
||||
cursor: str | None = new_checkpoints[-1].checkpoint_id
|
||||
while cursor is not None:
|
||||
chain.append(cursor)
|
||||
cursor = checkpoints_by_id[cursor].previous_checkpoint_id
|
||||
# Chain must include the resumed-from checkpoint and terminate at the original root
|
||||
assert resume_from.checkpoint_id in chain
|
||||
assert chain[-1] == initial_checkpoints[0].checkpoint_id
|
||||
assert checkpoints_by_id[chain[-1]].previous_checkpoint_id is None
|
||||
|
||||
|
||||
async def test_memory_checkpoint_storage_roundtrip_json_native_types():
|
||||
"""Test that JSON-native types (str, int, float, bool, None) roundtrip correctly."""
|
||||
storage = InMemoryCheckpointStorage()
|
||||
|
||||
@@ -17,6 +17,7 @@ from agent_framework import (
|
||||
WorkflowContext,
|
||||
WorkflowConvergenceException,
|
||||
WorkflowEvent,
|
||||
WorkflowRunnerException,
|
||||
WorkflowRunState,
|
||||
handler,
|
||||
)
|
||||
@@ -304,62 +305,40 @@ async def test_fanout_edge_runner_delivers_to_multiple_targets_concurrently() ->
|
||||
assert probe_target.call_count == 1
|
||||
|
||||
|
||||
async def test_runner_run_until_convergence_runs_sequentially():
|
||||
"""run_until_convergence can be invoked back-to-back on the same Runner.
|
||||
|
||||
The Runner itself does not enforce concurrency; that responsibility lives on
|
||||
:class:`Workflow`. This test simply confirms the Runner is reusable across
|
||||
sequential runs.
|
||||
"""
|
||||
runner = _make_runner()
|
||||
async for _ in runner.run_until_convergence():
|
||||
pass
|
||||
async for _ in runner.run_until_convergence():
|
||||
pass
|
||||
|
||||
|
||||
def _make_runner() -> Runner:
|
||||
"""Build a minimal runner for runner-level tests."""
|
||||
return Runner(
|
||||
[],
|
||||
{},
|
||||
State(),
|
||||
InProcRunnerContext(),
|
||||
"test_name",
|
||||
graph_signature_hash="test_hash",
|
||||
)
|
||||
|
||||
|
||||
async def test_runner_accepts_new_run_after_previous_failure():
|
||||
"""A failed run must not leave the Runner unable to start a new run.
|
||||
|
||||
After the first run raises, ``run_until_convergence()`` must be callable
|
||||
again and not surface any lifecycle-related rejection.
|
||||
"""
|
||||
async def test_runner_already_running():
|
||||
"""Test that running the runner while it is already running raises an error."""
|
||||
executor_a = MockExecutor(id="executor_a")
|
||||
executor_b = MockExecutor(id="executor_b")
|
||||
|
||||
# Create a loop
|
||||
edges = [
|
||||
SingleEdgeGroup(executor_a.id, executor_b.id),
|
||||
SingleEdgeGroup(executor_b.id, executor_a.id),
|
||||
]
|
||||
executors: dict[str, Executor] = {executor_a.id: executor_a, executor_b.id: executor_b}
|
||||
|
||||
executors: dict[str, Executor] = {
|
||||
executor_a.id: executor_a,
|
||||
executor_b.id: executor_b,
|
||||
}
|
||||
state = State()
|
||||
ctx = InProcRunnerContext()
|
||||
runner = Runner(edges, executors, state, ctx, "test_name", graph_signature_hash="test_hash", max_iterations=2)
|
||||
|
||||
await executor_a.execute(MockMessage(data=0), ["START"], state, ctx)
|
||||
runner = Runner(edges, executors, state, ctx, "test_name", graph_signature_hash="test_hash")
|
||||
|
||||
with pytest.raises(WorkflowConvergenceException):
|
||||
async for _ in runner.run_until_convergence():
|
||||
pass
|
||||
await executor_a.execute(
|
||||
MockMessage(data=0),
|
||||
["START"], # source_executor_ids
|
||||
state, # state
|
||||
ctx, # runner_context
|
||||
)
|
||||
|
||||
# A second run on the same Runner must not be blocked by stale lifecycle
|
||||
# state from the failed run.
|
||||
try:
|
||||
async for _ in runner.run_until_convergence():
|
||||
pass
|
||||
except Exception as exc:
|
||||
assert "Runner is already running" not in str(exc), "Runner stayed locked after a failed run"
|
||||
with pytest.raises(WorkflowRunnerException, match="Runner is already running."):
|
||||
|
||||
async def _run():
|
||||
async for _ in runner.run_until_convergence():
|
||||
pass
|
||||
|
||||
await asyncio.gather(_run(), _run())
|
||||
|
||||
|
||||
async def test_runner_emits_runner_completion_for_agent_response_without_targets():
|
||||
@@ -883,13 +862,7 @@ async def test_runner_checkpoint_with_resumed_flag():
|
||||
state = State()
|
||||
|
||||
runner = Runner(edges, executors, state, ctx, "test_name", graph_signature_hash="test_hash")
|
||||
resumed_checkpoint = WorkflowCheckpoint(
|
||||
checkpoint_id="resumed-cp",
|
||||
workflow_name="test_name",
|
||||
graph_signature_hash="test_hash",
|
||||
iteration_count=5,
|
||||
)
|
||||
runner._mark_resumed(resumed_checkpoint) # pyright: ignore[reportPrivateUsage]
|
||||
runner._mark_resumed(5) # pyright: ignore[reportPrivateUsage]
|
||||
|
||||
# Add a message to trigger the checkpoint creation path
|
||||
await ctx.send_message(WorkflowMessage(data=MockMessage(data=8), source_id="START"))
|
||||
@@ -909,86 +882,6 @@ async def test_runner_checkpoint_with_resumed_flag():
|
||||
assert runner._resumed_from_checkpoint is False # pyright: ignore[reportPrivateUsage]
|
||||
|
||||
|
||||
async def test_runner_mark_resumed_sets_previous_checkpoint_id():
|
||||
"""_mark_resumed must populate _previous_checkpoint_id so future checkpoints chain back to the resume point."""
|
||||
runner = Runner(
|
||||
[],
|
||||
{},
|
||||
State(),
|
||||
InProcRunnerContext(),
|
||||
"test_name",
|
||||
graph_signature_hash="test_hash",
|
||||
)
|
||||
|
||||
# Pre-condition: nothing to chain back to
|
||||
assert runner._previous_checkpoint_id is None # pyright: ignore[reportPrivateUsage]
|
||||
|
||||
resumed_checkpoint = WorkflowCheckpoint(
|
||||
checkpoint_id="resumed-cp-id",
|
||||
workflow_name="test_name",
|
||||
graph_signature_hash="test_hash",
|
||||
iteration_count=3,
|
||||
)
|
||||
runner._mark_resumed(resumed_checkpoint) # pyright: ignore[reportPrivateUsage]
|
||||
|
||||
assert runner._resumed_from_checkpoint is True # pyright: ignore[reportPrivateUsage]
|
||||
assert runner._iteration == 3 # pyright: ignore[reportPrivateUsage]
|
||||
assert runner._previous_checkpoint_id == "resumed-cp-id" # pyright: ignore[reportPrivateUsage]
|
||||
|
||||
|
||||
async def test_runner_post_resume_checkpoint_chains_to_resumed_checkpoint():
|
||||
"""After resuming, the next checkpoint created must reference the resumed checkpoint as its parent."""
|
||||
storage = InMemoryCheckpointStorage()
|
||||
ctx = CheckpointingContext(storage)
|
||||
executor_a = MockExecutor(id="executor_a")
|
||||
executor_b = MockExecutor(id="executor_b")
|
||||
|
||||
edges = [
|
||||
SingleEdgeGroup(executor_a.id, executor_b.id),
|
||||
SingleEdgeGroup(executor_b.id, executor_a.id),
|
||||
]
|
||||
|
||||
executors: dict[str, Executor] = {
|
||||
executor_a.id: executor_a,
|
||||
executor_b.id: executor_b,
|
||||
}
|
||||
state = State()
|
||||
|
||||
runner = Runner(edges, executors, state, ctx, "test_name", graph_signature_hash="test_hash")
|
||||
|
||||
# Simulate having resumed from a prior checkpoint
|
||||
resumed_checkpoint = WorkflowCheckpoint(
|
||||
checkpoint_id="parent-checkpoint-id",
|
||||
workflow_name="test_name",
|
||||
graph_signature_hash="test_hash",
|
||||
iteration_count=1,
|
||||
)
|
||||
runner._mark_resumed(resumed_checkpoint) # pyright: ignore[reportPrivateUsage]
|
||||
|
||||
# Seed a message so the runner has work to do (and creates checkpoints at superstep boundaries)
|
||||
await ctx.send_message(WorkflowMessage(data=MockMessage(data=8), source_id=executor_a.id))
|
||||
|
||||
async for _ in runner.run_until_convergence():
|
||||
pass
|
||||
|
||||
# Find the first checkpoint created after the resume point (across all workflows tracked by storage)
|
||||
new_checkpoints = sorted(
|
||||
await storage.list_checkpoints(workflow_name="test_name"),
|
||||
key=lambda c: c.timestamp,
|
||||
)
|
||||
assert new_checkpoints, "Resuming and running should produce at least one new checkpoint"
|
||||
|
||||
# The first new checkpoint must chain to the resumed-from checkpoint, not to None
|
||||
assert new_checkpoints[0].previous_checkpoint_id == "parent-checkpoint-id", (
|
||||
"First post-resume checkpoint must chain to the resumed checkpoint id; "
|
||||
f"got {new_checkpoints[0].previous_checkpoint_id!r}"
|
||||
)
|
||||
|
||||
# Subsequent post-resume checkpoints continue the chain
|
||||
for i in range(1, len(new_checkpoints)):
|
||||
assert new_checkpoints[i].previous_checkpoint_id == new_checkpoints[i - 1].checkpoint_id
|
||||
|
||||
|
||||
class ExecutorThatFailsWithEvents(Executor):
|
||||
"""An executor that emits events and then raises an exception after receiving messages."""
|
||||
|
||||
|
||||
@@ -1,72 +0,0 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
"""Tests for `InProcRunnerContext`."""
|
||||
|
||||
import pytest
|
||||
|
||||
from agent_framework import (
|
||||
InProcRunnerContext,
|
||||
WorkflowEvent,
|
||||
WorkflowMessage,
|
||||
)
|
||||
|
||||
|
||||
def _make_request_info_event(request_id: str, source_executor_id: str = "executor") -> WorkflowEvent[str]:
|
||||
return WorkflowEvent.request_info(
|
||||
request_id=request_id,
|
||||
source_executor_id=source_executor_id,
|
||||
request_data="please respond",
|
||||
response_type=str,
|
||||
)
|
||||
|
||||
|
||||
class TestInProcRunnerContextResetForNewRun:
|
||||
"""Verify `reset_for_new_run` clears per-run state, including pending request_info events."""
|
||||
|
||||
async def test_reset_clears_pending_request_info_events(self) -> None:
|
||||
ctx = InProcRunnerContext()
|
||||
|
||||
await ctx.add_request_info_event(_make_request_info_event("req-1"))
|
||||
await ctx.add_request_info_event(_make_request_info_event("req-2"))
|
||||
|
||||
assert set((await ctx.get_pending_request_info_events()).keys()) == {"req-1", "req-2"}
|
||||
|
||||
ctx.reset_for_new_run()
|
||||
|
||||
assert await ctx.get_pending_request_info_events() == {}
|
||||
|
||||
async def test_reset_clears_pending_request_info_events_when_already_empty(self) -> None:
|
||||
ctx = InProcRunnerContext()
|
||||
|
||||
assert await ctx.get_pending_request_info_events() == {}
|
||||
|
||||
ctx.reset_for_new_run()
|
||||
|
||||
assert await ctx.get_pending_request_info_events() == {}
|
||||
|
||||
async def test_reset_after_pending_event_blocks_response_correlation(self) -> None:
|
||||
"""After `reset_for_new_run`, prior request ids must no longer correlate to a response."""
|
||||
ctx = InProcRunnerContext()
|
||||
await ctx.add_request_info_event(_make_request_info_event("req-1"))
|
||||
|
||||
ctx.reset_for_new_run()
|
||||
|
||||
with pytest.raises(ValueError, match="No pending request found for request_id: req-1"):
|
||||
await ctx.send_request_info_response("req-1", "answer")
|
||||
|
||||
async def test_reset_clears_messages_events_and_streaming_flag(self) -> None:
|
||||
"""Sanity-check the other state `reset_for_new_run` is documented to clear."""
|
||||
ctx = InProcRunnerContext()
|
||||
await ctx.send_message(WorkflowMessage(data="hello", source_id="executor"))
|
||||
await ctx.add_event(WorkflowEvent("status", data="running"))
|
||||
ctx.set_streaming(True)
|
||||
|
||||
assert await ctx.has_messages() is True
|
||||
assert await ctx.has_events() is True
|
||||
assert ctx.is_streaming() is True
|
||||
|
||||
ctx.reset_for_new_run()
|
||||
|
||||
assert await ctx.has_messages() is False
|
||||
assert await ctx.has_events() is False
|
||||
assert ctx.is_streaming() is False
|
||||
@@ -1,7 +1,6 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
import asyncio
|
||||
import gc
|
||||
import tempfile
|
||||
from collections.abc import AsyncIterable, Awaitable, Sequence
|
||||
from dataclasses import dataclass, field
|
||||
@@ -27,7 +26,6 @@ from agent_framework import (
|
||||
WorkflowContext,
|
||||
WorkflowConvergenceException,
|
||||
WorkflowEvent,
|
||||
WorkflowException,
|
||||
WorkflowMessage,
|
||||
WorkflowRunState,
|
||||
handler,
|
||||
@@ -761,7 +759,8 @@ async def test_workflow_concurrent_execution_prevention():
|
||||
|
||||
# Try to start a second concurrent execution - this should fail
|
||||
with pytest.raises(
|
||||
WorkflowException, match="Workflow is already running; concurrent runs are not allowed on the same instance."
|
||||
RuntimeError,
|
||||
match="Workflow is already running. Concurrent executions are not allowed.",
|
||||
):
|
||||
await workflow.run(NumberMessage(data=0))
|
||||
|
||||
@@ -796,7 +795,8 @@ async def test_workflow_concurrent_execution_prevention_streaming():
|
||||
|
||||
# Try to start a second concurrent execution - this should fail
|
||||
with pytest.raises(
|
||||
WorkflowException, match="Workflow is already running; concurrent runs are not allowed on the same instance."
|
||||
RuntimeError,
|
||||
match="Workflow is already running. Concurrent executions are not allowed.",
|
||||
):
|
||||
await workflow.run(NumberMessage(data=0))
|
||||
|
||||
@@ -828,12 +828,14 @@ async def test_workflow_concurrent_execution_prevention_mixed_methods():
|
||||
|
||||
# Try different execution methods - all should fail
|
||||
with pytest.raises(
|
||||
WorkflowException, match="Workflow is already running; concurrent runs are not allowed on the same instance."
|
||||
RuntimeError,
|
||||
match="Workflow is already running. Concurrent executions are not allowed.",
|
||||
):
|
||||
await workflow.run(NumberMessage(data=0))
|
||||
|
||||
with pytest.raises(
|
||||
WorkflowException, match="Workflow is already running; concurrent runs are not allowed on the same instance."
|
||||
RuntimeError,
|
||||
match="Workflow is already running. Concurrent executions are not allowed.",
|
||||
):
|
||||
async for _ in workflow.run(NumberMessage(data=0), stream=True):
|
||||
break
|
||||
@@ -846,154 +848,6 @@ async def test_workflow_concurrent_execution_prevention_mixed_methods():
|
||||
assert result.get_final_state() == WorkflowRunState.IDLE
|
||||
|
||||
|
||||
async def test_workflow_sequential_runs_after_completion() -> None:
|
||||
"""A completed run must release the runner so the next ``run`` succeeds.
|
||||
|
||||
This is the happy-path counterpart to the concurrent-run guard tests:
|
||||
those tests verify that a *concurrent* run is rejected, but they do not
|
||||
verify that the lock is actually released afterwards. This test
|
||||
exercises that release path explicitly across the three call shapes
|
||||
(non-streaming, streaming-iterated, streaming-via-get_final_response)
|
||||
and across multiple consecutive turns to catch lock leaks.
|
||||
"""
|
||||
executor = IncrementExecutor(id="seq_executor", limit=3, increment=1)
|
||||
workflow = WorkflowBuilder(start_executor=executor).build()
|
||||
|
||||
# Non-streaming -> non-streaming
|
||||
r1 = await workflow.run(NumberMessage(data=0))
|
||||
assert r1.get_final_state() == WorkflowRunState.IDLE
|
||||
|
||||
r2 = await workflow.run(NumberMessage(data=0))
|
||||
assert r2.get_final_state() == WorkflowRunState.IDLE
|
||||
|
||||
# Non-streaming -> streaming-iterated
|
||||
stream_events: list[WorkflowEvent] = []
|
||||
async for event in workflow.run(NumberMessage(data=0), stream=True):
|
||||
stream_events.append(event)
|
||||
assert any(e.type == "status" and e.state == WorkflowRunState.IDLE for e in stream_events)
|
||||
|
||||
# Streaming -> streaming via get_final_response (no manual iteration)
|
||||
r3 = await workflow.run(NumberMessage(data=0), stream=True).get_final_response()
|
||||
assert r3.get_final_state() == WorkflowRunState.IDLE
|
||||
|
||||
# Streaming -> non-streaming (back to the start)
|
||||
r4 = await workflow.run(NumberMessage(data=0))
|
||||
assert r4.get_final_state() == WorkflowRunState.IDLE
|
||||
|
||||
|
||||
async def test_workflow_unconsumed_stream_releases_run_lock() -> None:
|
||||
"""An unconsumed stream must not leak the run lock.
|
||||
|
||||
``Workflow.run`` reserves the runner *synchronously* so that concurrent
|
||||
callers are rejected immediately. The reservation is normally released
|
||||
by ``_run_core``'s ``finally`` once the stream is iterated. If the
|
||||
caller never iterates the stream, a GC-time finalizer must release the
|
||||
reservation instead - otherwise every subsequent ``Workflow.run`` call
|
||||
on this instance would fail with the concurrent-run error.
|
||||
"""
|
||||
executor = IncrementExecutor(id="unconsumed_stream_exec", limit=3, increment=1)
|
||||
workflow = WorkflowBuilder(start_executor=executor).build()
|
||||
|
||||
# Build a stream and immediately drop it without iterating.
|
||||
stream = workflow.run(NumberMessage(data=0), stream=True)
|
||||
assert stream is not None # silence unused-variable warnings; stream is GC'd below
|
||||
del stream
|
||||
gc.collect()
|
||||
# Yield to the event loop so any scheduled finalizer work can run.
|
||||
await asyncio.sleep(0)
|
||||
|
||||
# The runner should be back to IDLE; a fresh run must succeed.
|
||||
result = await workflow.run(NumberMessage(data=0))
|
||||
assert result.get_final_state() == WorkflowRunState.IDLE
|
||||
|
||||
|
||||
async def test_workflow_unawaited_run_coroutine_releases_run_lock() -> None:
|
||||
"""An un-awaited non-streaming ``run()`` coroutine must also not leak the lock.
|
||||
|
||||
``Workflow.run`` (non-streaming) returns a coroutine produced by
|
||||
``ResponseStream.get_final_response``. The underlying ResponseStream is
|
||||
held alive by that coroutine, so dropping the coroutine without
|
||||
awaiting it must still release the reservation via the same GC-time
|
||||
fallback used for unconsumed streams.
|
||||
"""
|
||||
executor = IncrementExecutor(id="unawaited_run_exec", limit=3, increment=1)
|
||||
workflow = WorkflowBuilder(start_executor=executor).build()
|
||||
|
||||
coro = workflow.run(NumberMessage(data=0))
|
||||
# Closing suppresses the "coroutine was never awaited" warning. We cast to
|
||||
# ``Any`` because the typed return is ``Awaitable[...]``; in practice it is
|
||||
# a coroutine that exposes ``close``.
|
||||
cast(Any, coro).close()
|
||||
del coro
|
||||
gc.collect()
|
||||
await asyncio.sleep(0)
|
||||
|
||||
result = await workflow.run(NumberMessage(data=0))
|
||||
assert result.get_final_state() == WorkflowRunState.IDLE
|
||||
|
||||
|
||||
async def test_workflow_partial_stream_does_not_clobber_successor_active_run() -> None:
|
||||
"""A stale ``_run_core`` finalizer must not clear a successor's run lock.
|
||||
|
||||
Repro for the GC-finalizer race the user reported:
|
||||
|
||||
1. Start stream A and consume one event so its body is suspended at a
|
||||
``yield``. Its ``finally`` is now armed and will run when the
|
||||
generator is closed.
|
||||
2. Drop stream A and ``gc.collect``. The ``_active_run`` weakref's
|
||||
referent is gone, so a subsequent ``run()`` will pass the
|
||||
concurrency guard - but stream A's async-gen finalizer hasn't
|
||||
actually executed yet (``aclose`` is scheduled on the loop).
|
||||
3. Synchronously start stream B; ``run()`` installs a fresh weakref
|
||||
in ``_active_run``.
|
||||
4. Yield to the loop so stream A's stale ``finally`` runs. Without
|
||||
the identity check it unconditionally writes
|
||||
``self._active_run = None``, silently disabling the concurrency
|
||||
guard for stream B.
|
||||
"""
|
||||
executor = IncrementExecutor(id="stale_finalizer_exec", limit=100, increment=1)
|
||||
workflow = WorkflowBuilder(start_executor=executor).build()
|
||||
|
||||
# Step 1: drive stream A's body until it's suspended at its first yield.
|
||||
stream_a = workflow.run(NumberMessage(data=0), stream=True)
|
||||
aiter_a = stream_a.__aiter__()
|
||||
await aiter_a.__anext__()
|
||||
|
||||
# Step 2: drop stream A; GC invalidates the weakref and schedules
|
||||
# async-gen close, but does not run the close inline.
|
||||
del stream_a
|
||||
del aiter_a
|
||||
gc.collect()
|
||||
|
||||
# Step 3: synchronously start stream B *before* yielding to the loop,
|
||||
# so the stale ``aclose`` for stream A hasn't fired yet.
|
||||
stream_b = workflow.run(NumberMessage(data=0), stream=True)
|
||||
ref_b = workflow._active_run # type: ignore[attr-defined]
|
||||
assert ref_b is not None and ref_b() is stream_b
|
||||
|
||||
# Step 4: yield enough times for stream A's scheduled aclose to drive
|
||||
# its body through ``GeneratorExit`` and into its ``finally``.
|
||||
for _ in range(5):
|
||||
await asyncio.sleep(0)
|
||||
|
||||
# With the fix, stream B's reservation is still in place. Without it,
|
||||
# ``_active_run`` was clobbered to ``None`` and a concurrent run would
|
||||
# be (incorrectly) accepted.
|
||||
assert workflow._active_run is ref_b # type: ignore[attr-defined]
|
||||
with pytest.raises(
|
||||
WorkflowException,
|
||||
match="Workflow is already running; concurrent runs are not allowed on the same instance.",
|
||||
):
|
||||
await workflow.run(NumberMessage(data=0))
|
||||
|
||||
# Tear down stream B without iterating it (its body never started, so
|
||||
# closing it is a no-op for workflow state).
|
||||
del stream_b
|
||||
del ref_b
|
||||
gc.collect()
|
||||
await asyncio.sleep(0)
|
||||
|
||||
|
||||
class _StreamingTestAgent(BaseAgent):
|
||||
"""Test agent that supports both streaming and non-streaming modes."""
|
||||
|
||||
@@ -1415,85 +1269,3 @@ async def test_output_executors_filtering_with_run_responses_streaming() -> None
|
||||
|
||||
|
||||
# endregion
|
||||
|
||||
|
||||
# region Workflow.reset
|
||||
|
||||
|
||||
class CounterStateExecutor(Executor):
|
||||
"""Executor with local mutable state used to verify checkpoint-based reset."""
|
||||
|
||||
def __init__(self, id: str) -> None:
|
||||
super().__init__(id=id)
|
||||
self.counter = 0
|
||||
|
||||
@handler
|
||||
async def handle(self, message: str, ctx: WorkflowContext[str, int]) -> None:
|
||||
self.counter += 1
|
||||
await ctx.yield_output(self.counter)
|
||||
|
||||
async def on_checkpoint_save(self) -> dict[str, Any]:
|
||||
return {"counter": self.counter}
|
||||
|
||||
async def on_checkpoint_restore(self, state: dict[str, Any]) -> None:
|
||||
self.counter = int(state.get("counter", 0))
|
||||
|
||||
|
||||
class TestWorkflowReset:
|
||||
"""Tests for :meth:`Workflow.reset`."""
|
||||
|
||||
async def test_reset_restores_initial_shared_state(self) -> None:
|
||||
"""Reset clears accumulated workflow state back to the initial baseline."""
|
||||
executor = StateTrackingExecutor(id="state_executor")
|
||||
workflow = WorkflowBuilder(start_executor=executor).add_edge(executor, executor).build()
|
||||
|
||||
result1 = await workflow.run(StateTrackingMessage(data="message1", run_id="run1"))
|
||||
assert result1.get_outputs()[0] == ["run1:message1"]
|
||||
|
||||
result2 = await workflow.run(StateTrackingMessage(data="message2", run_id="run2"))
|
||||
assert result2.get_outputs()[0] == ["run1:message1", "run2:message2"]
|
||||
|
||||
await workflow.reset()
|
||||
|
||||
result3 = await workflow.run(StateTrackingMessage(data="message3", run_id="run3"))
|
||||
assert result3.get_outputs()[0] == ["run3:message3"]
|
||||
|
||||
async def test_reset_restores_executor_checkpoint_state(self) -> None:
|
||||
"""Reset restores per-executor local state captured in the initial checkpoint."""
|
||||
executor = CounterStateExecutor(id="counter_executor")
|
||||
workflow = WorkflowBuilder(start_executor=executor).add_edge(executor, executor).build()
|
||||
|
||||
result1 = await workflow.run("one")
|
||||
assert result1.get_outputs() == [1]
|
||||
|
||||
result2 = await workflow.run("two")
|
||||
assert result2.get_outputs() == [2]
|
||||
|
||||
await workflow.reset()
|
||||
|
||||
result3 = await workflow.run("three")
|
||||
assert result3.get_outputs() == [1]
|
||||
|
||||
async def test_reset_before_first_run_is_allowed(self, simple_executor: Executor) -> None:
|
||||
"""Reset can be called before the first run and leaves workflow runnable."""
|
||||
workflow = WorkflowBuilder(start_executor=simple_executor).add_edge(simple_executor, simple_executor).build()
|
||||
|
||||
await workflow.reset()
|
||||
|
||||
result = await workflow.run("hello")
|
||||
assert result.get_final_state() == WorkflowRunState.IDLE
|
||||
|
||||
async def test_reset_raises_while_run_active(self, simple_executor: Executor) -> None:
|
||||
"""Reset must reject while a workflow run is active."""
|
||||
workflow = WorkflowBuilder(start_executor=simple_executor).add_edge(simple_executor, simple_executor).build()
|
||||
|
||||
active_stream = workflow.run(WorkflowMessage(data="hi", source_id="test"), stream=True)
|
||||
try:
|
||||
with pytest.raises(WorkflowException, match="Cannot reset workflow while a run is active"):
|
||||
await workflow.reset()
|
||||
finally:
|
||||
async for _ in active_stream:
|
||||
pass
|
||||
|
||||
|
||||
# endregion
|
||||
|
||||
@@ -90,7 +90,7 @@ async def _run(yaml_def: dict[str, Any], handler: HttpRequestHandler) -> Any:
|
||||
|
||||
def _state(workflow: Any, events: Any) -> dict[str, Any]:
|
||||
"""Read declarative state out of the workflow after run completes."""
|
||||
return workflow._runner.state.get(DECLARATIVE_STATE_KEY) or {}
|
||||
return workflow._state.get(DECLARATIVE_STATE_KEY) or {}
|
||||
|
||||
|
||||
# Helper used by parametrised path tests
|
||||
@@ -151,7 +151,7 @@ class TestSuccessPath:
|
||||
workflow = factory.create_workflow_from_definition(_yaml(_action(method="GET", response="Local.Result")))
|
||||
await workflow.run({})
|
||||
|
||||
decl = workflow._runner.state.get(DECLARATIVE_STATE_KEY)
|
||||
decl = workflow._state.get(DECLARATIVE_STATE_KEY)
|
||||
assert decl["Local"]["Result"] == {"key": "value", "number": 42}
|
||||
assert handler.last_info is not None
|
||||
assert handler.last_info.method == "GET"
|
||||
@@ -164,7 +164,7 @@ class TestSuccessPath:
|
||||
workflow = factory.create_workflow_from_definition(_yaml(_action(response="Local.Result")))
|
||||
await workflow.run({})
|
||||
|
||||
decl = workflow._runner.state.get(DECLARATIVE_STATE_KEY)
|
||||
decl = workflow._state.get(DECLARATIVE_STATE_KEY)
|
||||
assert decl["Local"]["Result"] == "not-json content"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@@ -174,7 +174,7 @@ class TestSuccessPath:
|
||||
workflow = factory.create_workflow_from_definition(_yaml(_action(response="Local.Result")))
|
||||
await workflow.run({})
|
||||
|
||||
decl = workflow._runner.state.get(DECLARATIVE_STATE_KEY)
|
||||
decl = workflow._state.get(DECLARATIVE_STATE_KEY)
|
||||
assert decl["Local"]["Result"] is None
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@@ -184,7 +184,7 @@ class TestSuccessPath:
|
||||
workflow = factory.create_workflow_from_definition(_yaml(_action(response={"path": "Local.Result"})))
|
||||
await workflow.run({})
|
||||
|
||||
decl = workflow._runner.state.get(DECLARATIVE_STATE_KEY)
|
||||
decl = workflow._state.get(DECLARATIVE_STATE_KEY)
|
||||
assert decl["Local"]["Result"] == {"x": 1}
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@@ -517,7 +517,7 @@ class TestResponseHeaders:
|
||||
factory = WorkflowFactory(http_request_handler=handler)
|
||||
workflow = factory.create_workflow_from_definition(_yaml(_action(response_headers="Local.H")))
|
||||
await workflow.run({})
|
||||
decl = workflow._runner.state.get(DECLARATIVE_STATE_KEY)
|
||||
decl = workflow._state.get(DECLARATIVE_STATE_KEY)
|
||||
h = decl["Local"]["H"]
|
||||
assert h["Content-Type"] == "application/json"
|
||||
assert h["Set-Cookie"] == "a=1,b=2"
|
||||
@@ -528,7 +528,7 @@ class TestResponseHeaders:
|
||||
factory = WorkflowFactory(http_request_handler=handler)
|
||||
workflow = factory.create_workflow_from_definition(_yaml(_action(response_headers="Local.H")))
|
||||
await workflow.run({})
|
||||
decl = workflow._runner.state.get(DECLARATIVE_STATE_KEY)
|
||||
decl = workflow._state.get(DECLARATIVE_STATE_KEY)
|
||||
assert decl["Local"]["H"] is None
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@@ -538,7 +538,7 @@ class TestResponseHeaders:
|
||||
workflow = factory.create_workflow_from_definition(_yaml(_action(response_headers="Local.H")))
|
||||
with pytest.raises(DeclarativeActionError):
|
||||
await workflow.run({})
|
||||
decl = workflow._runner.state.get(DECLARATIVE_STATE_KEY)
|
||||
decl = workflow._state.get(DECLARATIVE_STATE_KEY)
|
||||
assert decl["Local"]["H"] == {"X-Trace": "abc"}
|
||||
|
||||
|
||||
@@ -559,7 +559,7 @@ class TestConversationAppend:
|
||||
)
|
||||
)
|
||||
await workflow.run({})
|
||||
decl = workflow._runner.state.get(DECLARATIVE_STATE_KEY)
|
||||
decl = workflow._state.get(DECLARATIVE_STATE_KEY)
|
||||
conv = decl["System"]["conversations"].get("conv-test-1")
|
||||
assert conv is not None
|
||||
assert len(conv["messages"]) == 1
|
||||
@@ -570,7 +570,7 @@ class TestConversationAppend:
|
||||
factory = WorkflowFactory(http_request_handler=handler)
|
||||
workflow = factory.create_workflow_from_definition(_yaml(_action(response="Local.Result", conversation_id="")))
|
||||
await workflow.run({})
|
||||
decl = workflow._runner.state.get(DECLARATIVE_STATE_KEY)
|
||||
decl = workflow._state.get(DECLARATIVE_STATE_KEY)
|
||||
# Auto-init creates an entry for the System.ConversationId conversation,
|
||||
# but it should NOT have HTTP-appended messages from us.
|
||||
for _cid, conv in decl["System"]["conversations"].items():
|
||||
@@ -582,7 +582,7 @@ class TestConversationAppend:
|
||||
factory = WorkflowFactory(http_request_handler=handler)
|
||||
workflow = factory.create_workflow_from_definition(_yaml(_action(conversation_id="conv-test-1")))
|
||||
await workflow.run({})
|
||||
decl = workflow._runner.state.get(DECLARATIVE_STATE_KEY)
|
||||
decl = workflow._state.get(DECLARATIVE_STATE_KEY)
|
||||
# No conversation entry should have been created either.
|
||||
assert "conv-test-1" not in decl["System"]["conversations"]
|
||||
|
||||
|
||||
@@ -73,8 +73,8 @@ async def test_http_request_yaml_roundtrip() -> None:
|
||||
workflow = factory.create_workflow_from_yaml_path(FIXTURE_PATH)
|
||||
await workflow.run({})
|
||||
|
||||
decl: dict[str, Any] = workflow._runner.state.get(DECLARATIVE_STATE_KEY) or {}
|
||||
local = decl.get("Local") or {}
|
||||
decl: dict[str, Any] = workflow._state.get(DECLARATIVE_STATE_KEY) or {}
|
||||
local: dict[str, Any] = decl.get("Local") or {}
|
||||
|
||||
assert local.get("RepoOwner") == "dotnet"
|
||||
repo_info = local.get("RepoInfo")
|
||||
|
||||
@@ -244,7 +244,7 @@ class TestOutput:
|
||||
factory = WorkflowFactory(mcp_tool_handler=handler)
|
||||
workflow = factory.create_workflow_from_definition(_yaml(_action(output={"result": "Local.Result"})))
|
||||
await workflow.run({})
|
||||
decl = workflow._runner.state.get(DECLARATIVE_STATE_KEY)
|
||||
decl = workflow._state.get(DECLARATIVE_STATE_KEY)
|
||||
assert decl["Local"]["Result"] == [{"k": "v", "n": 1}]
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@@ -253,7 +253,7 @@ class TestOutput:
|
||||
factory = WorkflowFactory(mcp_tool_handler=handler)
|
||||
workflow = factory.create_workflow_from_definition(_yaml(_action(output={"result": "Local.Result"})))
|
||||
await workflow.run({})
|
||||
decl = workflow._runner.state.get(DECLARATIVE_STATE_KEY)
|
||||
decl = workflow._state.get(DECLARATIVE_STATE_KEY)
|
||||
assert decl["Local"]["Result"] == ["plain text not json"]
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@@ -262,7 +262,7 @@ class TestOutput:
|
||||
factory = WorkflowFactory(mcp_tool_handler=handler)
|
||||
workflow = factory.create_workflow_from_definition(_yaml(_action(output={"messages": "Local.Messages"})))
|
||||
await workflow.run({})
|
||||
decl = workflow._runner.state.get(DECLARATIVE_STATE_KEY)
|
||||
decl = workflow._state.get(DECLARATIVE_STATE_KEY)
|
||||
msg = decl["Local"]["Messages"]
|
||||
# Single Tool-role message containing both contents (parity with .NET).
|
||||
assert isinstance(msg, Message)
|
||||
@@ -276,7 +276,7 @@ class TestOutput:
|
||||
factory = WorkflowFactory(mcp_tool_handler=handler)
|
||||
workflow = factory.create_workflow_from_definition(_yaml(_action(output={"result": "Local.Result"})))
|
||||
await workflow.run({})
|
||||
decl = workflow._runner.state.get(DECLARATIVE_STATE_KEY)
|
||||
decl = workflow._state.get(DECLARATIVE_STATE_KEY)
|
||||
assert decl["Local"]["Result"] == ["https://example.com/file.txt"]
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@@ -285,7 +285,7 @@ class TestOutput:
|
||||
factory = WorkflowFactory(mcp_tool_handler=handler)
|
||||
workflow = factory.create_workflow_from_definition(_yaml(_action(output={"result": {"path": "Local.Result"}})))
|
||||
await workflow.run({})
|
||||
decl = workflow._runner.state.get(DECLARATIVE_STATE_KEY)
|
||||
decl = workflow._state.get(DECLARATIVE_STATE_KEY)
|
||||
assert decl["Local"]["Result"] == ["ok"]
|
||||
|
||||
|
||||
@@ -306,7 +306,7 @@ class TestConversation:
|
||||
)
|
||||
)
|
||||
await workflow.run({})
|
||||
decl = workflow._runner.state.get(DECLARATIVE_STATE_KEY)
|
||||
decl = workflow._state.get(DECLARATIVE_STATE_KEY)
|
||||
conv = decl["System"]["conversations"]["conv-42"]
|
||||
msgs = conv["messages"] if isinstance(conv, dict) else conv.messages
|
||||
assert len(msgs) == 1
|
||||
@@ -328,7 +328,7 @@ class TestConversation:
|
||||
)
|
||||
)
|
||||
await workflow.run({})
|
||||
decl = workflow._runner.state.get(DECLARATIVE_STATE_KEY)
|
||||
decl = workflow._state.get(DECLARATIVE_STATE_KEY)
|
||||
# Empty conversation id must not produce a `""` entry under System.conversations.
|
||||
conversations = decl.get("System", {}).get("conversations", {})
|
||||
assert "" not in conversations
|
||||
@@ -529,7 +529,7 @@ class TestErrorHandling:
|
||||
factory = WorkflowFactory(mcp_tool_handler=handler)
|
||||
workflow = factory.create_workflow_from_definition(_yaml(_action(output={"result": "Local.Result"})))
|
||||
await workflow.run({})
|
||||
decl = workflow._runner.state.get(DECLARATIVE_STATE_KEY)
|
||||
decl = workflow._state.get(DECLARATIVE_STATE_KEY)
|
||||
assert decl["Local"]["Result"] == "Error: server down"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@@ -538,7 +538,7 @@ class TestErrorHandling:
|
||||
factory = WorkflowFactory(mcp_tool_handler=handler)
|
||||
workflow = factory.create_workflow_from_definition(_yaml(_action(output={"result": "Local.Result"})))
|
||||
await workflow.run({})
|
||||
decl = workflow._runner.state.get(DECLARATIVE_STATE_KEY)
|
||||
decl = workflow._state.get(DECLARATIVE_STATE_KEY)
|
||||
assert decl["Local"]["Result"] == "Error: invalid arguments"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@@ -547,7 +547,7 @@ class TestErrorHandling:
|
||||
factory = WorkflowFactory(mcp_tool_handler=handler)
|
||||
workflow = factory.create_workflow_from_definition(_yaml(_action(output={"result": "Local.Result"})))
|
||||
await workflow.run({})
|
||||
decl = workflow._runner.state.get(DECLARATIVE_STATE_KEY)
|
||||
decl = workflow._state.get(DECLARATIVE_STATE_KEY)
|
||||
result = decl["Local"]["Result"]
|
||||
assert isinstance(result, str)
|
||||
assert result.startswith("Error:")
|
||||
|
||||
@@ -291,11 +291,11 @@ actions:
|
||||
# Stamp a marker into the declarative state between turns. The
|
||||
# continuation branch must preserve it; a state-clearing run would
|
||||
# wipe ``DECLARATIVE_STATE_KEY`` and force re-initialization.
|
||||
state_data = workflow._runner.state.get(DECLARATIVE_STATE_KEY)
|
||||
state_data = workflow._state.get(DECLARATIVE_STATE_KEY)
|
||||
assert isinstance(state_data, dict), "Expected declarative state to be initialized after turn 1"
|
||||
state_data["Local"] = {"persisted_marker": "kept-from-turn-1"}
|
||||
workflow._runner.state.set(DECLARATIVE_STATE_KEY, state_data)
|
||||
workflow._runner.state.commit()
|
||||
workflow._state.set(DECLARATIVE_STATE_KEY, state_data)
|
||||
workflow._state.commit()
|
||||
|
||||
second = await agent.run("turn-2-msg")
|
||||
assert second.text == "turn-2-msg", (
|
||||
@@ -305,7 +305,7 @@ actions:
|
||||
# The continuation branch in ``_ensure_state_initialized`` must:
|
||||
# 1. preserve the cross-turn marker we stamped above
|
||||
# 2. refresh Inputs.input and System.LastMessage* to the new turn
|
||||
post_state = workflow._runner.state.get(DECLARATIVE_STATE_KEY)
|
||||
post_state = workflow._state.get(DECLARATIVE_STATE_KEY)
|
||||
assert isinstance(post_state, dict), "declarative state vanished between turns"
|
||||
local = post_state.get("Local", {})
|
||||
assert local.get("persisted_marker") == "kept-from-turn-1", (
|
||||
|
||||
@@ -59,7 +59,7 @@ class DurableAgentTask(CompositeTask[AgentResponse], CompletableTask[AgentRespon
|
||||
"""
|
||||
self._response_format = response_format
|
||||
self._correlation_id = correlation_id
|
||||
super().__init__([entity_task])
|
||||
super().__init__([entity_task]) # type: ignore
|
||||
|
||||
def on_child_completed(self, task: Task[Any]) -> None:
|
||||
"""Handle completion of the underlying entity task.
|
||||
|
||||
@@ -129,7 +129,7 @@ class DurableAIAgentWorker:
|
||||
|
||||
# Register the entity class with the worker
|
||||
# The worker.add_entity method takes a class
|
||||
entity_registered: str = self._worker.add_entity(entity_class)
|
||||
entity_registered: str = self._worker.add_entity(entity_class) # pyright: ignore[reportUnknownMemberType]
|
||||
|
||||
logger.debug(
|
||||
"[DurableAIAgentWorker] Successfully registered entity class %s for agent: %s",
|
||||
@@ -225,7 +225,7 @@ class DurableAIAgentWorker:
|
||||
executor_activity.__name__ = activity_name
|
||||
executor_activity.__qualname__ = activity_name
|
||||
|
||||
self._worker.add_activity(executor_activity)
|
||||
self._worker.add_activity(executor_activity) # type: ignore[arg-type]
|
||||
logger.debug("[DurableAIAgentWorker] Registered activity: %s", activity_name)
|
||||
|
||||
def _register_workflow_orchestrator(self) -> None:
|
||||
|
||||
@@ -16,7 +16,7 @@ from durabletask.task import (
|
||||
OrchestrationContext,
|
||||
Task,
|
||||
when_all,
|
||||
when_any,
|
||||
when_any, # pyright: ignore[reportUnknownVariableType]
|
||||
)
|
||||
|
||||
from .._executors import OrchestrationAgentExecutor
|
||||
|
||||
@@ -386,6 +386,7 @@ class ResponsesHostServer(ResponsesAgentServerHost):
|
||||
)
|
||||
|
||||
self._is_workflow_agent = False
|
||||
self._checkpoint_storage_path = None
|
||||
if isinstance(agent, WorkflowAgent):
|
||||
if agent.workflow._runner_context.has_checkpointing(): # pyright: ignore[reportPrivateUsage]
|
||||
raise RuntimeError(
|
||||
@@ -579,6 +580,8 @@ class ResponsesHostServer(ResponsesAgentServerHost):
|
||||
|
||||
# The following should never happen due to the checks above.
|
||||
# This is for type safety and defensive programming.
|
||||
if self._checkpoint_storage_path is None:
|
||||
raise RuntimeError("Checkpoint storage path is not configured for workflow agent.")
|
||||
if not isinstance(self._agent, WorkflowAgent):
|
||||
raise RuntimeError("Agent is not a workflow agent.")
|
||||
|
||||
@@ -596,27 +599,43 @@ class ResponsesHostServer(ResponsesAgentServerHost):
|
||||
# the only place that state lives is the workflow checkpoint, so
|
||||
# on every turn we restore the latest checkpoint and feed the new
|
||||
# input back into the start executor as a continuation rather than
|
||||
# a fresh run. If no conversation_id or previous_response_id is
|
||||
# supplied (or no checkpoint exists for that context), reset the
|
||||
# workflow to its in-memory initial baseline to avoid context bleed
|
||||
# between requests.
|
||||
# a fresh run.
|
||||
latest_checkpoint_id: str | None = None
|
||||
restore_storage: FileCheckpointStorage | None = None
|
||||
if context_id is not None:
|
||||
context_storage = _checkpoint_storage_for_context(self._checkpoint_storage_path, context_id)
|
||||
latest_checkpoint = await context_storage.get_latest(workflow_name=self._agent.workflow.name)
|
||||
restore_storage = _checkpoint_storage_for_context(self._checkpoint_storage_path, context_id)
|
||||
latest_checkpoint = await restore_storage.get_latest(workflow_name=self._agent.workflow.name)
|
||||
if latest_checkpoint is not None:
|
||||
latest_checkpoint_id = latest_checkpoint.checkpoint_id
|
||||
restore_storage = context_storage
|
||||
|
||||
# Restore the workflow to the latest checkpoint and run it with the
|
||||
# new input. Events (including request info events) will not be emitted
|
||||
# during restoration (in streaming) or after restoration (in non-streaming)
|
||||
# since we assume the client had already seen those events and we don't want
|
||||
# to emit duplicates.
|
||||
if latest_checkpoint_id is None or restore_storage is None:
|
||||
await self._agent.workflow.reset()
|
||||
else:
|
||||
# Storage that will receive checkpoints written during this turn.
|
||||
# When the caller chains with previous_response_id, the next turn
|
||||
# will reference the current response_id as its previous_response_id,
|
||||
# so new checkpoints must land under the current response_id (or the
|
||||
# conversation_id when set). When conversation_id is set, this
|
||||
# matches restore_storage; when only previous_response_id was
|
||||
# supplied, restore_storage points at the *prior* response's
|
||||
# directory and write_storage points at the *current* response's.
|
||||
write_context_id = context.conversation_id or context.response_id
|
||||
write_storage = _checkpoint_storage_for_context(self._checkpoint_storage_path, write_context_id)
|
||||
|
||||
# Multi-turn pattern: when we have a prior checkpoint, restore it
|
||||
# first (drive the workflow back to idle with prior state intact),
|
||||
# then make a separate call that delivers the new user input. This
|
||||
# depends on Workflow.run preserving shared state across calls. The
|
||||
# restore-only call may yield events from any pending in-flight
|
||||
# work in the checkpoint; we consume those internally here so they
|
||||
# don't surface to the response stream as duplicates.
|
||||
#
|
||||
# If the restored checkpoint had pending request_info events, the
|
||||
# restore-only call replays them through
|
||||
# ``WorkflowAgent._convert_workflow_event_to_agent_response_updates``
|
||||
# and populates ``self._agent.pending_requests``. That is the correct
|
||||
# state: those requests are genuinely outstanding, and the next
|
||||
# ``run(input_messages, ...)`` call may contain ``function_call_output``
|
||||
# items (carried as FunctionResult/FunctionApprovalResponse content)
|
||||
# that fulfill them via :meth:`WorkflowAgent._process_pending_requests`.
|
||||
if latest_checkpoint_id is not None:
|
||||
if is_streaming_request:
|
||||
async for _ in self._agent.run(
|
||||
stream=True,
|
||||
@@ -631,17 +650,6 @@ class ResponsesHostServer(ResponsesAgentServerHost):
|
||||
checkpoint_storage=restore_storage,
|
||||
)
|
||||
|
||||
# Storage that will receive checkpoints written during this turn.
|
||||
# When the caller chains with previous_response_id, the next turn
|
||||
# will reference the current response_id as its previous_response_id,
|
||||
# so new checkpoints must land under the current response_id (or the
|
||||
# conversation_id when set). When conversation_id is set, this
|
||||
# matches restore_storage; when only previous_response_id was
|
||||
# supplied, restore_storage points at the *prior* response's
|
||||
# directory and write_storage points at the *current* response's.
|
||||
write_context_id = context.conversation_id or context.response_id
|
||||
write_storage = _checkpoint_storage_for_context(self._checkpoint_storage_path, write_context_id)
|
||||
|
||||
if not is_streaming_request:
|
||||
# Run the agent in non-streaming mode with the new user input.
|
||||
response = await self._agent.run(
|
||||
|
||||
@@ -27,7 +27,6 @@ dependencies = [
|
||||
"azure-ai-agentserver-core>=2.0.0b3,<3",
|
||||
"azure-ai-agentserver-responses>=1.0.0b7,<2",
|
||||
"azure-ai-agentserver-invocations>=1.0.0b3,<2",
|
||||
"mcp>=1.24.0,<2",
|
||||
]
|
||||
|
||||
[tool.uv]
|
||||
|
||||
@@ -3062,7 +3062,6 @@ class TestCheckpointContextPathValidation:
|
||||
agent.workflow = MagicMock()
|
||||
agent.workflow.name = "wf"
|
||||
agent.workflow._runner_context.has_checkpointing = MagicMock(return_value=False)
|
||||
agent.workflow.reset = AsyncMock()
|
||||
agent.run = AsyncMock(
|
||||
side_effect=[
|
||||
AgentResponse(messages=[]),
|
||||
@@ -3093,136 +3092,6 @@ class TestCheckpointContextPathValidation:
|
||||
assert new_turn_messages[0].text == "next turn"
|
||||
assert new_turn_call.kwargs["checkpoint_storage"].storage_path == (root / response_id).resolve()
|
||||
|
||||
async def test_handle_inner_workflow_resets_when_no_context_id(self, tmp_path: Any) -> None:
|
||||
"""When no context id is supplied, the workflow resets to its initial in-memory state."""
|
||||
from agent_framework import WorkflowAgent
|
||||
from azure.ai.agentserver.responses import ResponseContext
|
||||
from azure.ai.agentserver.responses.models import CreateResponse, ItemMessage
|
||||
|
||||
response_id = "resp_current"
|
||||
root = tmp_path / "root"
|
||||
root.mkdir()
|
||||
|
||||
agent = MagicMock(spec=WorkflowAgent)
|
||||
agent.id = "wf-agent"
|
||||
agent.name = "wf"
|
||||
agent.description = ""
|
||||
agent.context_providers = []
|
||||
agent.workflow = MagicMock()
|
||||
agent.workflow.name = "wf"
|
||||
agent.workflow._runner_context.has_checkpointing = MagicMock(return_value=False)
|
||||
agent.workflow.reset = AsyncMock()
|
||||
agent.run = AsyncMock(
|
||||
return_value=AgentResponse(messages=[Message(role="assistant", contents=[Content.from_text("ok")])])
|
||||
)
|
||||
server = ResponsesHostServer(agent, store=InMemoryResponseProvider())
|
||||
server._checkpoint_storage_path = str(root) # pyright: ignore[reportPrivateUsage]
|
||||
|
||||
# No previous_response_id and no conversation_id.
|
||||
request = CreateResponse(model="m", input="hi")
|
||||
context = ResponseContext(response_id=response_id, mode_flags=MagicMock())
|
||||
input_item = ItemMessage({"type": "message", "role": "user", "content": "fresh turn"})
|
||||
|
||||
with patch.object(ResponseContext, "get_input_items", new=AsyncMock(return_value=[input_item])):
|
||||
async for _ in server._handle_inner_workflow(request, context): # pyright: ignore[reportPrivateUsage]
|
||||
pass
|
||||
|
||||
# No checkpoint restore is attempted; workflow resets in memory.
|
||||
assert agent.workflow.reset.await_count == 1
|
||||
assert agent.run.call_count == 1
|
||||
|
||||
# The single run() call delivers the new input; checkpoints land under response_id
|
||||
# (the write-sink directory keyed by the current response id).
|
||||
new_turn_call = agent.run.call_args_list[0]
|
||||
new_turn_messages = new_turn_call.args[0]
|
||||
assert len(new_turn_messages) == 1
|
||||
assert new_turn_messages[0].text == "fresh turn"
|
||||
assert new_turn_call.kwargs["checkpoint_storage"].storage_path == (root / response_id).resolve()
|
||||
|
||||
async def test_handle_inner_workflow_resets_each_request_without_context_id(self, tmp_path: Any) -> None:
|
||||
"""Requests without context ids reset workflow state per request."""
|
||||
from agent_framework import WorkflowAgent
|
||||
from azure.ai.agentserver.responses import ResponseContext
|
||||
from azure.ai.agentserver.responses.models import CreateResponse, ItemMessage
|
||||
|
||||
root = tmp_path / "root"
|
||||
root.mkdir()
|
||||
|
||||
agent = MagicMock(spec=WorkflowAgent)
|
||||
agent.id = "wf-agent"
|
||||
agent.name = "wf"
|
||||
agent.description = ""
|
||||
agent.context_providers = []
|
||||
agent.workflow = MagicMock()
|
||||
agent.workflow.name = "wf"
|
||||
agent.workflow._runner_context.has_checkpointing = MagicMock(return_value=False)
|
||||
agent.workflow.reset = AsyncMock()
|
||||
# Two run() calls total: one new turn per request.
|
||||
agent.run = AsyncMock(return_value=AgentResponse(messages=[]))
|
||||
|
||||
server = ResponsesHostServer(agent, store=InMemoryResponseProvider())
|
||||
server._checkpoint_storage_path = str(root) # pyright: ignore[reportPrivateUsage]
|
||||
|
||||
request1 = CreateResponse(model="m", input="hi")
|
||||
context1 = ResponseContext(response_id="resp_first", mode_flags=MagicMock())
|
||||
request2 = CreateResponse(model="m", input="hi again")
|
||||
context2 = ResponseContext(response_id="resp_second", mode_flags=MagicMock())
|
||||
input_item = ItemMessage({"type": "message", "role": "user", "content": "turn"})
|
||||
|
||||
with patch.object(ResponseContext, "get_input_items", new=AsyncMock(return_value=[input_item])):
|
||||
async for _ in server._handle_inner_workflow(request1, context1): # pyright: ignore[reportPrivateUsage]
|
||||
pass
|
||||
async for _ in server._handle_inner_workflow(request2, context2): # pyright: ignore[reportPrivateUsage]
|
||||
pass
|
||||
|
||||
assert agent.workflow.reset.await_count == 2
|
||||
assert agent.run.call_count == 2
|
||||
|
||||
async def test_handle_inner_workflow_resets_when_context_dir_is_empty(self, tmp_path: Any) -> None:
|
||||
"""When previous_response_id has no checkpoint, workflow resets instead of restoring."""
|
||||
from agent_framework import WorkflowAgent
|
||||
from azure.ai.agentserver.responses import ResponseContext
|
||||
from azure.ai.agentserver.responses.models import CreateResponse, ItemMessage
|
||||
|
||||
previous_response_id = "resp_previous"
|
||||
response_id = "resp_current"
|
||||
root = tmp_path / "root"
|
||||
root.mkdir()
|
||||
# The per-context storage exists but contains no checkpoints.
|
||||
(root / previous_response_id).mkdir()
|
||||
|
||||
agent = MagicMock(spec=WorkflowAgent)
|
||||
agent.id = "wf-agent"
|
||||
agent.name = "wf"
|
||||
agent.description = ""
|
||||
agent.context_providers = []
|
||||
agent.workflow = MagicMock()
|
||||
agent.workflow.name = "wf"
|
||||
agent.workflow._runner_context.has_checkpointing = MagicMock(return_value=False)
|
||||
agent.workflow.reset = AsyncMock()
|
||||
agent.run = AsyncMock(
|
||||
return_value=AgentResponse(messages=[Message(role="assistant", contents=[Content.from_text("ok")])])
|
||||
)
|
||||
server = ResponsesHostServer(agent, store=InMemoryResponseProvider())
|
||||
server._checkpoint_storage_path = str(root) # pyright: ignore[reportPrivateUsage]
|
||||
|
||||
request = CreateResponse(model="m", input="hi", previous_response_id=previous_response_id)
|
||||
context = ResponseContext(
|
||||
response_id=response_id, previous_response_id=previous_response_id, mode_flags=MagicMock()
|
||||
)
|
||||
input_item = ItemMessage({"type": "message", "role": "user", "content": "next turn"})
|
||||
|
||||
with patch.object(ResponseContext, "get_input_items", new=AsyncMock(return_value=[input_item])):
|
||||
async for _ in server._handle_inner_workflow(request, context): # pyright: ignore[reportPrivateUsage]
|
||||
pass
|
||||
|
||||
assert agent.workflow.reset.await_count == 1
|
||||
assert agent.run.call_count == 1
|
||||
|
||||
# The new turn writes checkpoints under the current response id.
|
||||
new_turn_call = agent.run.call_args_list[0]
|
||||
assert new_turn_call.kwargs["checkpoint_storage"].storage_path == (root / response_id).resolve()
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"bad_id",
|
||||
[
|
||||
@@ -3316,8 +3185,6 @@ class TestCheckpointContextPathValidation:
|
||||
agent.workflow = MagicMock()
|
||||
agent.workflow.name = "wf"
|
||||
agent.workflow._runner_context.has_checkpointing = MagicMock(return_value=False)
|
||||
agent.workflow.reset = AsyncMock()
|
||||
agent.run = AsyncMock(return_value=AgentResponse(messages=[]))
|
||||
|
||||
# Constructor inspects WorkflowAgent.workflow internals; bypass setup
|
||||
# by feeding a configured mock through a normal init.
|
||||
|
||||
@@ -24,7 +24,7 @@ classifiers = [
|
||||
]
|
||||
dependencies = [
|
||||
"agent-framework-core>=1.8.0,<2",
|
||||
"github-copilot-sdk==1.0.2; python_version >= '3.11'",
|
||||
"github-copilot-sdk>=1.0.0,<2; python_version >= '3.11'",
|
||||
]
|
||||
|
||||
[tool.uv]
|
||||
|
||||
+2
-14
@@ -7,12 +7,6 @@ This sample demonstrates how to attach :class:`FileAccessProvider` (backed by
|
||||
data, perform analysis, and write summary output back to the same folder via
|
||||
the ``file_access_*`` tools.
|
||||
|
||||
The file-access tools all require approval (``approval_mode="always_require"``),
|
||||
so a base ``Agent`` installs :class:`ToolApprovalMiddleware` to drive the
|
||||
approval handshake. Because this sample is non-interactive, it auto-approves
|
||||
every file-access tool via
|
||||
:meth:`FileAccessProvider.all_tools_auto_approval_rule`.
|
||||
|
||||
The sibling ``working/`` folder contains ``sales.csv`` — ~50 rows of sales
|
||||
transactions (date, product, category, quantity, unit_price, region,
|
||||
salesperson). The agent is asked, in a single session, to: list available
|
||||
@@ -28,7 +22,7 @@ import asyncio
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
from agent_framework import Agent, FileAccessProvider, FileSystemAgentFileStore, ToolApprovalMiddleware
|
||||
from agent_framework import Agent, FileAccessProvider, FileSystemAgentFileStore
|
||||
from agent_framework.foundry import FoundryChatClient
|
||||
from azure.identity import AzureCliCredential
|
||||
from dotenv import load_dotenv
|
||||
@@ -98,19 +92,13 @@ async def main() -> None:
|
||||
# agent for the duration of each run.
|
||||
file_access = FileAccessProvider(store=FileSystemAgentFileStore(working_dir))
|
||||
|
||||
# 4. Create the agent and attach the provider. The file-access tools all
|
||||
# require approval (approval_mode="always_require"). Developers can
|
||||
# present these to the user for approval, or like in this case, auto-approve
|
||||
# them via FileAccessProvider.all_tools_auto_approval_rule. Note that
|
||||
# to use tool approval rules, the agent must have ToolApprovalMiddleware
|
||||
# in its middleware stack.
|
||||
# 4. Create the agent and attach the provider.
|
||||
async with Agent(
|
||||
client=client,
|
||||
name="DataAnalyst",
|
||||
description="A data analyst assistant that reads, analyzes, and processes data files.",
|
||||
instructions=INSTRUCTIONS,
|
||||
context_providers=[file_access],
|
||||
middleware=[ToolApprovalMiddleware(auto_approval_rules=[FileAccessProvider.all_tools_auto_approval_rule])],
|
||||
) as agent:
|
||||
# 5. Run all prompts inside one session so the conversation remains
|
||||
# coherent across turns.
|
||||
|
||||
@@ -29,7 +29,6 @@ Each feature can be disabled or customized via keyword arguments.
|
||||
| File | Description |
|
||||
|------|-------------|
|
||||
| `harness_research.py` | Interactive research assistant with web search, a plan/execute workflow, and an execute-mode loop that re-invokes the agent until every todo is complete |
|
||||
| `harness_data_processing.py` | Data-processing assistant over a folder of CSV files, demonstrating file-access tools and tool approval |
|
||||
|
||||
## Running
|
||||
|
||||
@@ -41,30 +40,10 @@ export FOUNDRY_MODEL="your-model-deployment-name"
|
||||
# Authenticate with Azure (required for AzureCliCredential)
|
||||
az login
|
||||
|
||||
# Run a sample against the released agent-framework (PEP 723 isolated env)
|
||||
uv run samples/02-agents/harness/harness_research.py
|
||||
# Run the research sample
|
||||
python samples/02-agents/harness/harness_research.py
|
||||
```
|
||||
|
||||
### Running against the local repo
|
||||
|
||||
To run a sample against your **local** `agent-framework` checkout (so it picks
|
||||
up uncommitted changes), use the workspace environment instead of the isolated
|
||||
PEP 723 env. From the `python/` directory, run the script with `uv run python`
|
||||
and add the `textual` UI dependency the harness console needs:
|
||||
|
||||
```bash
|
||||
uv run --with textual python samples/02-agents/harness/harness_research.py
|
||||
uv run --with textual python samples/02-agents/harness/harness_data_processing.py
|
||||
```
|
||||
|
||||
The workspace environment already provides the editable `agent-framework`
|
||||
packages plus the samples' other dependencies (`rich`, `python-dotenv`,
|
||||
`azure-identity`); only `textual` needs to be supplied with `--with`.
|
||||
|
||||
> Note: invoking `uv run python <script>` (with `python`) bypasses the PEP 723
|
||||
> metadata and uses the workspace env; `uv run <script>` (without `python`)
|
||||
> uses the isolated env with the released package.
|
||||
|
||||
## Key Concepts
|
||||
|
||||
### Minimal Setup
|
||||
|
||||
@@ -1,61 +0,0 @@
|
||||
# Build your own agent harness and claw — Python samples
|
||||
|
||||
Runnable Python samples for the [**"Build your own agent harness and claw with Microsoft Agent Framework"** blog](https://devblogs.microsoft.com/agent-framework/build-your-own-claw-and-agent-harness-with-microsoft-agent-framework)
|
||||
series. Each step builds a personal finance / investing assistant on top of
|
||||
`create_harness_agent`, reusing the shared harness `console` package in the parent `harness/`
|
||||
directory.
|
||||
|
||||
- **Part 1 — `claw_step01_meet_your_claw.py`** — the minimal harness.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
1. A Microsoft Foundry project with a deployed model.
|
||||
2. Azure CLI installed and authenticated (`az login`).
|
||||
|
||||
## Environment variables
|
||||
|
||||
```bash
|
||||
export FOUNDRY_PROJECT_ENDPOINT="https://your-project.services.ai.azure.com/api/projects/your-project"
|
||||
export FOUNDRY_MODEL="your-model-deployment-name"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Part 1 — Meet your claw
|
||||
|
||||
Builds the foundation of the assistant on top of `create_harness_agent`.
|
||||
|
||||
### What this sample demonstrates
|
||||
|
||||
- **`create_harness_agent`** — a factory that builds a batteries-included agent: function
|
||||
invocation, per-service-call history persistence, planning (`TodoProvider` +
|
||||
`AgentModeProvider`), and web search.
|
||||
- **A custom function tool** — `get_stock_price`, exposing local data to the agent. Prices are
|
||||
illustrative mock data, not real quotes.
|
||||
- **Web search** — provided automatically by the harness for market news and commentary.
|
||||
- **Planning & modes** — the agent breaks a multi-step request ("review my watchlist") into a todo
|
||||
list and switches between *plan* and *execute* modes.
|
||||
- **Shared harness console** — interactive streaming UI (reused from the parent `harness/console`
|
||||
package) with `/todos`, `/mode`, and `/exit` commands.
|
||||
|
||||
### Running
|
||||
|
||||
```bash
|
||||
# From the repository root, using a PEP 723 compatible runner:
|
||||
uv run python/samples/02-agents/harness/build_your_own_claw/claw_step01_meet_your_claw.py
|
||||
```
|
||||
|
||||
### What to expect
|
||||
|
||||
The sample starts an interactive loop. Try these in order:
|
||||
|
||||
1. `/mode execute` — switch out of the default plan mode; quick lookups don't need a plan.
|
||||
2. `What's the price of MSFT?` — the agent calls the `get_stock_price` tool.
|
||||
3. `Any recent news on NVDA?` — the agent uses web search.
|
||||
4. `Add MSFT, NVDA and SPY to my watch list` — saved to `watchlist.md` in the session's memory.
|
||||
5. `/mode plan` — switch back to plan mode for a bigger, multi-step task.
|
||||
6. `Review my watchlist and recommend some stocks to add` — the agent plans, then executes. Type
|
||||
`/todos` to see the list and `/mode` to inspect the current mode.
|
||||
|
||||
---
|
||||
|
||||
@@ -1,146 +0,0 @@
|
||||
# /// script
|
||||
# requires-python = ">=3.10"
|
||||
# dependencies = [
|
||||
# "agent-framework",
|
||||
# "textual>=6.2.1",
|
||||
# "rich>=13.7.1",
|
||||
# "azure-identity",
|
||||
# "python-dotenv",
|
||||
# ]
|
||||
# ///
|
||||
# Run with any PEP 723 compatible runner, e.g.:
|
||||
# uv run python/samples/02-agents/harness/build_your_own_claw/claw_step01_meet_your_claw.py
|
||||
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
"""Meet your agent harness and claw (Post 1) — Python.
|
||||
|
||||
The first runnable sample from the "Build your own claw with Microsoft Agent Framework" blog
|
||||
series. See: https://devblogs.microsoft.com/agent-framework/meet-your-agent-harness-and-claw.
|
||||
It builds the foundation of a personal finance / investing assistant on top of
|
||||
``create_harness_agent``.
|
||||
|
||||
``create_harness_agent`` is a factory that wires up a batteries-included agent: function
|
||||
invocation, per-service-call history persistence, planning (TodoProvider +
|
||||
AgentModeProvider), and web search. All we add here is finance-focused
|
||||
instructions and a custom ``get_stock_price`` tool.
|
||||
|
||||
This sample reuses the shared harness ``console`` package that lives in the parent
|
||||
``harness/`` directory.
|
||||
|
||||
Environment variables:
|
||||
FOUNDRY_PROJECT_ENDPOINT — Microsoft Foundry project endpoint URL
|
||||
FOUNDRY_MODEL — Model deployment name
|
||||
|
||||
Authentication:
|
||||
Run ``az login`` before running this sample.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import sys
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
from typing import Annotated
|
||||
|
||||
from agent_framework import create_harness_agent
|
||||
from agent_framework.foundry import FoundryChatClient
|
||||
from azure.identity import AzureCliCredential
|
||||
from dotenv import load_dotenv
|
||||
|
||||
# Reuse the shared harness console that lives in the parent ``harness/`` directory.
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
|
||||
from console import build_observers_with_planning, run_agent_async # noqa: E402
|
||||
|
||||
FINANCE_INSTRUCTIONS = """\
|
||||
## Personal Finance Assistant Instructions
|
||||
|
||||
You are a personal finance and investing assistant. You help the user understand their watchlist
|
||||
and the markets. When asked about a stock, look up its current price with the get_stock_price
|
||||
tool, and use web search for recent news, earnings, or analyst commentary.
|
||||
|
||||
### Working style
|
||||
|
||||
- Always verify numbers with a tool rather than relying on memory. Stock prices change.
|
||||
- Cite web sources inline when you use them.
|
||||
- Keep the user's watchlist in a memory file called watchlist.md: read it when reviewing the
|
||||
watchlist, and update it whenever the user adds or removes a ticker.
|
||||
|
||||
### Important
|
||||
|
||||
You provide information and analysis only — you are not a licensed financial advisor and you must
|
||||
not present your output as personalized investment advice. Remind the user to do their own
|
||||
research before making decisions.
|
||||
"""
|
||||
|
||||
# A tiny in-memory price book so the sample runs without any external dependency.
|
||||
# These are illustrative mock prices, not real market quotes.
|
||||
_PRICE_BOOK: dict[str, float] = {
|
||||
"MSFT": 462.97,
|
||||
"AAPL": 229.35,
|
||||
"GOOGL": 178.12,
|
||||
"AMZN": 201.45,
|
||||
"NVDA": 134.81,
|
||||
}
|
||||
|
||||
|
||||
# <get_stock_price>
|
||||
def get_stock_price(
|
||||
symbol: Annotated[str, "The stock ticker symbol, e.g. MSFT or AAPL."],
|
||||
) -> dict[str, object]:
|
||||
"""Get the latest (delayed, illustrative) stock price for a ticker symbol."""
|
||||
ticker = symbol.upper()
|
||||
price = _PRICE_BOOK.get(ticker)
|
||||
if price is None:
|
||||
# Deterministic pseudo-price for unknown symbols so the sample stays self-contained.
|
||||
# Derive a stable seed from the characters — the built-in hash() is randomized per
|
||||
# process (PYTHONHASHSEED), so it would give different prices on every run.
|
||||
seed = 0
|
||||
for ch in ticker:
|
||||
seed = (seed * 31 + ord(ch)) % 1_000_000
|
||||
price = 50.0 + (seed % 45000) / 100.0
|
||||
|
||||
return {
|
||||
"symbol": ticker,
|
||||
"price": round(price, 2),
|
||||
"currency": "USD",
|
||||
"as_of": datetime.now(timezone.utc).isoformat(),
|
||||
}
|
||||
# </get_stock_price>
|
||||
|
||||
|
||||
async def main() -> None:
|
||||
load_dotenv()
|
||||
|
||||
# <create_client>
|
||||
# Construct a chat client. FoundryChatClient reads FOUNDRY_PROJECT_ENDPOINT and FOUNDRY_MODEL
|
||||
# from the environment; AzureCliCredential handles auth (run `az login`, or swap in another
|
||||
# credential). The harness works with ANY chat client — see the providers samples for OpenAI,
|
||||
# Azure OpenAI, Anthropic, Ollama, and more.
|
||||
client = FoundryChatClient(credential=AzureCliCredential())
|
||||
# </create_client>
|
||||
|
||||
# <create_agent>
|
||||
# Turn the chat client into a harness agent with finance instructions and our custom
|
||||
# stock-price tool. Planning (todo + mode) and web search are configured automatically.
|
||||
agent = create_harness_agent(
|
||||
client=client,
|
||||
agent_instructions=FINANCE_INSTRUCTIONS,
|
||||
tools=get_stock_price,
|
||||
)
|
||||
# </create_agent>
|
||||
|
||||
# <run>
|
||||
# Run the interactive console session using the shared harness console helper.
|
||||
await run_agent_async(
|
||||
agent,
|
||||
session=agent.create_session(),
|
||||
observers=build_observers_with_planning(agent),
|
||||
initial_mode="plan",
|
||||
title="💹 Finance Assistant",
|
||||
placeholder="Ask about a stock or say 'review my watchlist'...",
|
||||
)
|
||||
# </run>
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
asyncio.run(main())
|
||||
@@ -13,40 +13,25 @@
|
||||
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
"""Harness Data Processing Assistant with Console UI and tool approvals.
|
||||
"""Harness Data Processing Assistant with Console UI.
|
||||
|
||||
Demonstrates ``create_harness_agent`` configured with a ``FileAccessProvider``
|
||||
to give an agent access to a folder of CSV data files. The agent can read,
|
||||
analyze, and extract information from the data, then write results back as new
|
||||
files via the ``file_access_*`` tools.
|
||||
|
||||
This sample also demonstrates **tool approval**. The ``FileAccessProvider``
|
||||
registers all of its tools with ``approval_mode="always_require"``, so every
|
||||
file operation would normally prompt the host for approval. To keep read-only
|
||||
exploration frictionless while still guarding mutations, the agent is given the
|
||||
:meth:`FileAccessProvider.read_only_tools_auto_approval_rule` auto-approval
|
||||
rule. With this rule:
|
||||
|
||||
- Read-only tools (read, list files, list subdirectories, search) are
|
||||
auto-approved and run without prompting.
|
||||
- Write tools (save and delete) still require explicit approval, so you are
|
||||
asked before the agent modifies the file store.
|
||||
Demonstrates ``create_harness_agent`` configured with the default
|
||||
``FileAccessProvider`` to give an agent access to a folder of CSV data files.
|
||||
The agent can read, analyze, and extract information from the data, then write
|
||||
results back as new files via the ``file_access_*`` tools.
|
||||
|
||||
The sample includes a pre-populated ``working/`` folder with sales transaction
|
||||
data. The ``FileAccessProvider`` is pointed at that folder (resolved relative to
|
||||
this script) so it works regardless of the current working directory. Ask the
|
||||
agent to analyze the data, produce summaries, or create new output files. For
|
||||
example::
|
||||
data. The ``file_access_store`` is set explicitly to that folder (resolved
|
||||
relative to this script) so it works regardless of the current working
|
||||
directory. Ask the agent to analyze the data, produce summaries, or create new
|
||||
output files. For example::
|
||||
|
||||
Please process the sales.csv file by first filtering it to only North region
|
||||
sales, and then calculating the sum of sales by person. I'd like to write the
|
||||
results of the processing to north_region_totals.csv
|
||||
|
||||
When the agent reads ``sales.csv`` it proceeds automatically, but when it tries
|
||||
to save ``north_region_totals.csv`` you are prompted to approve the write.
|
||||
|
||||
Unused harness features (todos, plan/execute mode, web search) are disabled to
|
||||
keep this a simple, conversational data-interaction sample.
|
||||
Unused harness features (file memory, todos, plan/execute mode, web search) are
|
||||
disabled to keep this a simple, conversational data-interaction sample.
|
||||
|
||||
Environment variables:
|
||||
FOUNDRY_PROJECT_ENDPOINT — Azure AI Foundry project endpoint URL
|
||||
@@ -59,7 +44,7 @@ Authentication:
|
||||
import asyncio
|
||||
from pathlib import Path
|
||||
|
||||
from agent_framework import FileAccessProvider, FileSystemAgentFileStore, create_harness_agent
|
||||
from agent_framework import FileSystemAgentFileStore, create_harness_agent
|
||||
from agent_framework.foundry import FoundryChatClient
|
||||
from azure.identity import AzureCliCredential
|
||||
from console import build_default_observers, run_agent_async
|
||||
@@ -106,9 +91,9 @@ async def main() -> None:
|
||||
# with your preferred authentication option.
|
||||
client = FoundryChatClient(credential=AzureCliCredential())
|
||||
|
||||
# Create a harness agent with data-analyst instructions. Unused features are
|
||||
# disabled. The read_only_tools_auto_approval_rule auto-approves the
|
||||
# FileAccessProvider's read-only tools, so only write operations prompt.
|
||||
# Create a harness agent with data-analyst instructions. The FileAccessProvider
|
||||
# is explicitly pointed at the sample's working/ folder so it works regardless
|
||||
# of the current working directory. Unused features are disabled.
|
||||
agent = create_harness_agent(
|
||||
client=client,
|
||||
max_context_window_tokens=MAX_CONTEXT_WINDOW_TOKENS,
|
||||
@@ -117,7 +102,7 @@ async def main() -> None:
|
||||
description="A data analyst assistant that reads, analyzes, and processes data files.",
|
||||
agent_instructions=DATA_ANALYST_INSTRUCTIONS,
|
||||
file_access_store=FileSystemAgentFileStore(working_dir),
|
||||
auto_approval_rules=[FileAccessProvider.read_only_tools_auto_approval_rule],
|
||||
disable_file_memory=True,
|
||||
disable_todo=True,
|
||||
disable_mode=True,
|
||||
disable_web_search=True,
|
||||
|
||||
@@ -29,8 +29,7 @@ def main():
|
||||
logger.debug("Starting Durable Task Agent Sample (Combined Worker + Client)...")
|
||||
silent_handler = logging.NullHandler()
|
||||
# Create and start the worker using helper function and context manager
|
||||
dts_worker = get_worker(log_handler=silent_handler)
|
||||
with dts_worker:
|
||||
with get_worker(log_handler=silent_handler) as dts_worker:
|
||||
# Register agents using helper function
|
||||
setup_worker(dts_worker)
|
||||
# Start the worker
|
||||
|
||||
@@ -29,8 +29,7 @@ def main():
|
||||
logger.debug("Starting Durable Task Multi-Agent Sample (Combined Worker + Client)...")
|
||||
silent_handler = logging.NullHandler()
|
||||
# Create and start the worker using helper function and context manager
|
||||
dts_worker = get_worker(log_handler=silent_handler)
|
||||
with dts_worker:
|
||||
with get_worker(log_handler=silent_handler) as dts_worker:
|
||||
# Register agents using helper function
|
||||
setup_worker(dts_worker)
|
||||
# Start the worker
|
||||
|
||||
@@ -31,8 +31,7 @@ def main():
|
||||
logger.debug("Starting Durable Task Agent Sample with Redis Streaming...")
|
||||
silent_handler = logging.NullHandler()
|
||||
# Create and start the worker using helper function and context manager
|
||||
dts_worker = get_worker(log_handler=silent_handler)
|
||||
with dts_worker:
|
||||
with get_worker(log_handler=silent_handler) as dts_worker:
|
||||
# Register agents and callbacks using helper function
|
||||
setup_worker(dts_worker)
|
||||
# Start the worker
|
||||
|
||||
+1
-2
@@ -34,8 +34,7 @@ def main():
|
||||
logger.debug("Starting Single Agent Orchestration Chaining Sample...")
|
||||
silent_handler = logging.NullHandler()
|
||||
# Create and start the worker using helper function and context manager
|
||||
dts_worker = get_worker(log_handler=silent_handler)
|
||||
with dts_worker:
|
||||
with get_worker(log_handler=silent_handler) as dts_worker:
|
||||
# Register agents and orchestrations using helper function
|
||||
setup_worker(dts_worker)
|
||||
# Start the worker
|
||||
|
||||
+1
-2
@@ -31,8 +31,7 @@ def main():
|
||||
logger.debug("Starting Durable Task Multi-Agent Orchestration Sample (Combined Worker + Client)...")
|
||||
silent_handler = logging.NullHandler()
|
||||
# Create and start the worker using helper function and context manager
|
||||
dts_worker = get_worker(log_handler=silent_handler)
|
||||
with dts_worker:
|
||||
with get_worker(log_handler=silent_handler) as dts_worker:
|
||||
# Register agents and orchestrations using helper function
|
||||
setup_worker(dts_worker)
|
||||
# Start the worker
|
||||
|
||||
+1
-2
@@ -35,8 +35,7 @@ def main():
|
||||
|
||||
silent_handler = logging.NullHandler()
|
||||
# Create and start the worker using helper function and context manager
|
||||
dts_worker = get_worker(log_handler=silent_handler)
|
||||
with dts_worker:
|
||||
with get_worker(log_handler=silent_handler) as dts_worker:
|
||||
# Register agents, orchestrations, and activities using helper function
|
||||
setup_worker(dts_worker)
|
||||
|
||||
|
||||
@@ -31,8 +31,7 @@ def main():
|
||||
logger.debug("Starting Durable Task HITL Content Generation Sample (Combined Worker + Client)...")
|
||||
silent_handler = logging.NullHandler()
|
||||
# Create and start the worker using helper function and context manager
|
||||
dts_worker = get_worker(log_handler=silent_handler)
|
||||
with dts_worker:
|
||||
with get_worker(log_handler=silent_handler) as dts_worker:
|
||||
# Register agent, orchestration, and activities using helper function
|
||||
setup_worker(dts_worker)
|
||||
# Start the worker
|
||||
|
||||
Generated
+4281
-4741
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user