Compare commits
14 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 157f37a5a7 | |||
| 7b6d257988 | |||
| acbcdaa086 | |||
| 0b0dcaa5af | |||
| 35a8891d67 | |||
| 28e02d4669 | |||
| 5ccd7784a6 | |||
| 754cbe5976 | |||
| c6442de528 | |||
| 0df184e7dd | |||
| d0a0d5a3df | |||
| c59a65da5e | |||
| e90b6de5a7 | |||
| d98ac29115 |
@@ -66,6 +66,8 @@
|
||||
<Project Path="samples/02-agents/Agents/Agent_Step19_InFunctionLoopCheckpointing/Agent_Step19_InFunctionLoopCheckpointing.csproj" />
|
||||
<Project Path="samples/02-agents/Agents/Agent_Step20_DynamicFunctionTools/Agent_Step20_DynamicFunctionTools.csproj" />
|
||||
<Project Path="samples/02-agents/Agents/Agent_Step21_ShellWithEnvironment/Agent_Step21_ShellWithEnvironment.csproj" />
|
||||
<Project Path="samples/02-agents/Agents/Agent_Step22_AgentMode/Agent_Step22_AgentMode.csproj" />
|
||||
<Project Path="samples/02-agents/Agents/Agent_Step23_TodoList/Agent_Step23_TodoList.csproj" />
|
||||
</Folder>
|
||||
<Folder Name="/Samples/02-agents/DeclarativeAgents/">
|
||||
<Project Path="samples/02-agents/DeclarativeAgents/ChatClient/DeclarativeChatClientAgents.csproj" />
|
||||
|
||||
@@ -23,6 +23,7 @@
|
||||
"src\\Microsoft.Agents.AI.Hosting.AzureFunctions\\Microsoft.Agents.AI.Hosting.AzureFunctions.csproj",
|
||||
"src\\Microsoft.Agents.AI.Hosting.OpenAI\\Microsoft.Agents.AI.Hosting.OpenAI.csproj",
|
||||
"src\\Microsoft.Agents.AI.Hosting\\Microsoft.Agents.AI.Hosting.csproj",
|
||||
"src\\Microsoft.Agents.AI.LocalCodeAct\\Microsoft.Agents.AI.LocalCodeAct.csproj",
|
||||
"src\\Microsoft.Agents.AI.Mcp\\Microsoft.Agents.AI.Mcp.csproj",
|
||||
"src\\Microsoft.Agents.AI.Mem0\\Microsoft.Agents.AI.Mem0.csproj",
|
||||
"src\\Microsoft.Agents.AI.OpenAI\\Microsoft.Agents.AI.OpenAI.csproj",
|
||||
|
||||
@@ -329,6 +329,80 @@ internal static class AgentsSamples
|
||||
],
|
||||
},
|
||||
|
||||
new SampleDefinition
|
||||
{
|
||||
Name = "Agent_Step20_DynamicFunctionTools",
|
||||
ProjectPath = "samples/02-agents/Agents/Agent_Step20_DynamicFunctionTools",
|
||||
RequiredEnvironmentVariables = ["FOUNDRY_PROJECT_ENDPOINT"],
|
||||
OptionalEnvironmentVariables = ["FOUNDRY_MODEL"],
|
||||
MustContain =
|
||||
[
|
||||
"=== Dynamic Function Tools Sample ===",
|
||||
"=== Non-Streaming Mode ===",
|
||||
"=== Streaming Mode ===",
|
||||
"[User]",
|
||||
"[Agent]",
|
||||
],
|
||||
ExpectedOutputDescription =
|
||||
[
|
||||
"The output should show the agent starting with only a RequestTools function and dynamically loading additional tools (weather, time, temperature) as needed.",
|
||||
"The output should contain weather information for Seattle and London, the current time in New York, and a Fahrenheit-to-Celsius temperature conversion.",
|
||||
"The output should demonstrate both non-streaming and streaming modes.",
|
||||
"The output should not contain error messages or stack traces.",
|
||||
],
|
||||
},
|
||||
|
||||
new SampleDefinition
|
||||
{
|
||||
Name = "Agent_Step21_ShellWithEnvironment",
|
||||
ProjectPath = "samples/02-agents/Agents/Agent_Step21_ShellWithEnvironment",
|
||||
RequiredEnvironmentVariables = ["FOUNDRY_PROJECT_ENDPOINT"],
|
||||
OptionalEnvironmentVariables = ["FOUNDRY_MODEL"],
|
||||
MustContain =
|
||||
[
|
||||
"### Stateless mode",
|
||||
"### Persistent mode",
|
||||
"--- Captured environment snapshot ---",
|
||||
],
|
||||
ExpectedOutputDescription =
|
||||
[
|
||||
"The output should show an agent using a shell tool to print the current working directory.",
|
||||
"The output should demonstrate that in stateless mode side effects (such as changing directory) do not carry between calls, while in persistent mode the working directory and an environment variable (DEMO_TOKEN set to 'hello-world') carry across calls.",
|
||||
"The output should include a captured environment snapshot describing the OS, shell, and working directory.",
|
||||
"The output should not contain error messages or stack traces.",
|
||||
],
|
||||
},
|
||||
|
||||
new SampleDefinition
|
||||
{
|
||||
Name = "Agent_Step22_AgentMode",
|
||||
ProjectPath = "samples/02-agents/Agents/Agent_Step22_AgentMode",
|
||||
RequiredEnvironmentVariables = ["FOUNDRY_PROJECT_ENDPOINT"],
|
||||
OptionalEnvironmentVariables = ["FOUNDRY_MODEL"],
|
||||
SkipReason = "Interactive sample that reads console input in a loop and does not exit on its own.",
|
||||
},
|
||||
|
||||
new SampleDefinition
|
||||
{
|
||||
Name = "Agent_Step23_TodoList",
|
||||
ProjectPath = "samples/02-agents/Agents/Agent_Step23_TodoList",
|
||||
RequiredEnvironmentVariables = ["FOUNDRY_PROJECT_ENDPOINT"],
|
||||
OptionalEnvironmentVariables = ["FOUNDRY_MODEL"],
|
||||
MustContain =
|
||||
[
|
||||
"User:",
|
||||
"Agent:",
|
||||
"--- Current todo list ---",
|
||||
],
|
||||
ExpectedOutputDescription =
|
||||
[
|
||||
"The output should show an agent planning a team offsite by breaking the work into a todo list.",
|
||||
"The output should show the todo list being updated as progress is reported (for example marking items complete after the venue is booked and invites are sent) and adjusted when the plan changes to skip catering and add a group hike.",
|
||||
"The current todo list should be printed after each turn, showing item status.",
|
||||
"The output should not contain error messages or stack traces.",
|
||||
],
|
||||
},
|
||||
|
||||
// ── AgentSkills ─────────────────────────────────────────────────────
|
||||
|
||||
new SampleDefinition
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
<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" />
|
||||
<ProjectReference Include="..\..\..\..\src\Microsoft.Agents.AI.Foundry\Microsoft.Agents.AI.Foundry.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -0,0 +1,153 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
// Agent Mode — Switch an agent's operating mode at runtime with AgentModeProvider
|
||||
//
|
||||
// This sample shows how to use the AgentModeProvider, an AIContextProvider that tracks the
|
||||
// agent's current operating "mode" in the session state and exposes tools (mode_get / mode_set)
|
||||
// so the agent can query and switch modes as its work progresses. The mode is folded into the
|
||||
// instructions sent to the model on every turn, so different modes can drive different behavior.
|
||||
//
|
||||
// The sample demonstrates two things:
|
||||
// 1. The built-in default modes ("plan" and "execute") that ship with the provider.
|
||||
// 2. How to customize the available modes via AgentModeProviderOptions.
|
||||
//
|
||||
// It runs a simple interactive loop. In addition to chatting with the agent, you can switch the
|
||||
// agent's mode yourself using a slash command:
|
||||
// /mode — show the current mode
|
||||
// /mode <name> — switch to the named mode
|
||||
// /help — list the available commands and modes
|
||||
// /exit — quit
|
||||
//
|
||||
// When you switch modes with /mode, the provider injects a notification on the next turn so the
|
||||
// agent clearly sees the change and adjusts its behavior accordingly.
|
||||
|
||||
using Azure.AI.Projects;
|
||||
using Azure.Identity;
|
||||
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 model = Environment.GetEnvironmentVariable("FOUNDRY_MODEL") ?? "gpt-5.4-mini";
|
||||
|
||||
// Set AGENT_MODE_USE_CUSTOM=true to run the sample with the custom modes defined below instead of
|
||||
// the provider's built-in "plan" / "execute" defaults.
|
||||
bool useCustomModes = string.Equals(Environment.GetEnvironmentVariable("AGENT_MODE_USE_CUSTOM"), "true", StringComparison.OrdinalIgnoreCase);
|
||||
|
||||
// <create_mode_provider>
|
||||
AgentModeProvider modeProvider;
|
||||
string[] availableModes;
|
||||
|
||||
if (useCustomModes)
|
||||
{
|
||||
// Customize the set of modes by supplying AgentModeProviderOptions. Each mode has a name and a
|
||||
// block of instructions describing how the agent should behave while operating in that mode.
|
||||
// DefaultMode selects the mode new sessions start in (defaults to the first mode when omitted).
|
||||
modeProvider = new AgentModeProvider(new AgentModeProviderOptions
|
||||
{
|
||||
DefaultMode = "concise",
|
||||
Modes =
|
||||
[
|
||||
new AgentModeProviderOptions.AgentMode(
|
||||
"concise",
|
||||
"Answer in a single short sentence. Do not elaborate unless the user explicitly asks for more detail."),
|
||||
new AgentModeProviderOptions.AgentMode(
|
||||
"detailed",
|
||||
"Answer thoroughly. Explain your reasoning, provide examples, and cover relevant edge cases."),
|
||||
],
|
||||
});
|
||||
|
||||
availableModes = ["concise", "detailed"];
|
||||
}
|
||||
else
|
||||
{
|
||||
// Use the provider's built-in modes: "plan" (interactive planning) and "execute" (autonomous
|
||||
// execution). No options are required.
|
||||
modeProvider = new AgentModeProvider();
|
||||
availableModes = ["plan", "execute"];
|
||||
}
|
||||
// </create_mode_provider>
|
||||
|
||||
// Create the agent and attach the mode provider as an AIContextProvider.
|
||||
// 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(new Uri(endpoint), new DefaultAzureCredential())
|
||||
.AsAIAgent(new ChatClientAgentOptions
|
||||
{
|
||||
Name = "ModeAwareAssistant",
|
||||
ChatOptions = new ChatOptions
|
||||
{
|
||||
ModelId = model,
|
||||
Instructions = "You are a helpful assistant. Follow the process and behavior required by your current operating mode.",
|
||||
},
|
||||
AIContextProviders = [modeProvider],
|
||||
});
|
||||
|
||||
using var providerToDispose = modeProvider;
|
||||
|
||||
AgentSession session = await agent.CreateSessionAsync();
|
||||
|
||||
Console.WriteLine("Agent Mode sample. Type a message to chat, or use a slash command.");
|
||||
Console.WriteLine($"Available modes: {string.Join(", ", availableModes)}");
|
||||
Console.WriteLine($"Current mode: {await modeProvider.GetModeAsync(session)}");
|
||||
PrintHelp(availableModes);
|
||||
Console.WriteLine();
|
||||
|
||||
while (true)
|
||||
{
|
||||
Console.Write("> ");
|
||||
string? input = Console.ReadLine()?.Trim();
|
||||
|
||||
// Treat empty input or end-of-stream (Ctrl+D / Ctrl+Z) as a request to exit.
|
||||
if (string.IsNullOrWhiteSpace(input) || input.Equals("/exit", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
break;
|
||||
}
|
||||
|
||||
if (input.Equals("/help", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
PrintHelp(availableModes);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Handle the /mode slash command: "/mode" shows the current mode, "/mode <name>" switches to it.
|
||||
if (input.Equals("/mode", StringComparison.OrdinalIgnoreCase) || input.StartsWith("/mode ", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
string[] parts = input.Split(' ', 2, StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
|
||||
if (parts.Length < 2)
|
||||
{
|
||||
Console.WriteLine($"Current mode: {await modeProvider.GetModeAsync(session)}");
|
||||
continue;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
await modeProvider.SetModeAsync(session, parts[1]);
|
||||
Console.WriteLine($"Switched to \"{parts[1]}\" mode.");
|
||||
}
|
||||
catch (ArgumentException ex)
|
||||
{
|
||||
// SetModeAsync throws when the requested mode is not one of the configured modes.
|
||||
Console.WriteLine(ex.Message);
|
||||
}
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
// Anything else is a message for the agent. The mode provider injects the current mode (and any
|
||||
// pending mode-change notification) into the context for this turn.
|
||||
Console.WriteLine(await agent.RunAsync(input, session));
|
||||
|
||||
// Print the mode after the turn: the agent may have switched it itself via the mode_set tool as
|
||||
// its work progressed, so this reflects any change the agent made during the turn.
|
||||
Console.WriteLine($"Current mode: {await modeProvider.GetModeAsync(session)}");
|
||||
}
|
||||
|
||||
static void PrintHelp(string[] availableModes)
|
||||
{
|
||||
Console.WriteLine("Commands:");
|
||||
Console.WriteLine(" /mode Show the current mode");
|
||||
Console.WriteLine($" /mode <name> Switch mode ({string.Join(" | ", availableModes)})");
|
||||
Console.WriteLine(" /help Show this help");
|
||||
Console.WriteLine(" /exit Quit");
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
# Agent Mode
|
||||
|
||||
This sample demonstrates how to use the `AgentModeProvider` to track and switch an agent's
|
||||
operating **mode** at runtime, and drive different agent behavior depending on the active mode.
|
||||
|
||||
The `AgentModeProvider` is an `AIContextProvider` that stores the current mode in the session
|
||||
state and injects it into the instructions sent to the model on every turn. It also exposes
|
||||
`mode_get` and `mode_set` tools so the agent can query and switch modes on its own as its work
|
||||
progresses.
|
||||
|
||||
## What it demonstrates
|
||||
|
||||
- Attaching an `AgentModeProvider` to an agent via `ChatClientAgentOptions.AIContextProviders`.
|
||||
- The provider's **built-in** modes: `plan` (interactive planning) and `execute` (autonomous execution).
|
||||
- **Customizing** the available modes with `AgentModeProviderOptions` (set the
|
||||
`AGENT_MODE_USE_CUSTOM` environment variable to `true` to switch to a simple `concise` /
|
||||
`detailed` mode set).
|
||||
- Reading and changing the mode from application code with `GetModeAsync` / `SetModeAsync`.
|
||||
- A simple interactive input loop that lets the user switch mode with a slash command. When the
|
||||
mode changes this way, the provider injects a notification on the next turn so the agent adjusts
|
||||
its behavior.
|
||||
|
||||
## Commands
|
||||
|
||||
| Command | Description |
|
||||
|---|---|
|
||||
| `/mode` | Show the current mode |
|
||||
| `/mode <name>` | Switch to the named mode |
|
||||
| `/help` | List the available commands and modes |
|
||||
| `/exit` | Quit (an empty line also exits) |
|
||||
|
||||
Any other input is sent to the agent as a message.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- .NET 10 SDK or later
|
||||
- Microsoft Foundry project endpoint and model configured
|
||||
- Azure CLI installed and authenticated (run `az login`)
|
||||
- User has the required role to invoke models in the Foundry project
|
||||
|
||||
## Running the sample
|
||||
|
||||
Set the required environment variables:
|
||||
|
||||
```powershell
|
||||
$env:FOUNDRY_PROJECT_ENDPOINT="https://your-project-endpoint"
|
||||
$env:FOUNDRY_MODEL="gpt-5.4-mini" # Optional, defaults to gpt-5.4-mini
|
||||
```
|
||||
|
||||
Run the sample:
|
||||
|
||||
```powershell
|
||||
dotnet run
|
||||
```
|
||||
|
||||
To try the custom modes instead of the built-in `plan` / `execute` modes, set the
|
||||
`AGENT_MODE_USE_CUSTOM` environment variable to `true` and re-run:
|
||||
|
||||
```powershell
|
||||
$env:AGENT_MODE_USE_CUSTOM="true"
|
||||
dotnet run
|
||||
```
|
||||
@@ -0,0 +1,16 @@
|
||||
<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" />
|
||||
<ProjectReference Include="..\..\..\..\src\Microsoft.Agents.AI.Foundry\Microsoft.Agents.AI.Foundry.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -0,0 +1,83 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
// Todo List — Track work items across turns with TodoProvider
|
||||
//
|
||||
// This sample shows how to use the TodoProvider, an AIContextProvider that gives an agent a set of
|
||||
// tools for managing a todo list (todos_add, todos_complete, todos_remove, todos_get_remaining,
|
||||
// todos_get_all) along with instructions on how to use them. The todo list is stored in the
|
||||
// session state and persists across turns, so the agent can plan multi-step work, track progress,
|
||||
// and adjust the list as the conversation evolves.
|
||||
//
|
||||
// This is a scripted, non-interactive walkthrough: it sends a sequence of messages to the agent
|
||||
// and, after each turn, prints the agent's reply followed by the current todo list (read directly
|
||||
// from the provider via GetAllTodosAsync). This lets you watch the todo state evolve as the agent
|
||||
// adds, completes, and removes items.
|
||||
|
||||
using Azure.AI.Projects;
|
||||
using Azure.Identity;
|
||||
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 model = Environment.GetEnvironmentVariable("FOUNDRY_MODEL") ?? "gpt-5.4-mini";
|
||||
|
||||
// <create_todo_provider>
|
||||
// Create the TodoProvider and attach it to the agent as an AIContextProvider. The provider
|
||||
// contributes the todo-management tools and instructions to every agent invocation.
|
||||
using var todoProvider = new TodoProvider();
|
||||
|
||||
// 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(new Uri(endpoint), new DefaultAzureCredential())
|
||||
.AsAIAgent(new ChatClientAgentOptions
|
||||
{
|
||||
Name = "PlanningAssistant",
|
||||
ChatOptions = new ChatOptions
|
||||
{
|
||||
ModelId = model,
|
||||
Instructions = "You are a helpful planning assistant. Use your todo list to plan and track multi-step work.",
|
||||
},
|
||||
AIContextProviders = [todoProvider],
|
||||
});
|
||||
// </create_todo_provider>
|
||||
|
||||
AgentSession session = await agent.CreateSessionAsync();
|
||||
|
||||
// A scripted set of turns that exercises the provider end-to-end: the agent should add todos for a
|
||||
// multi-step request, mark items complete as progress is reported, and adjust the list on a change
|
||||
// of plan.
|
||||
string[] userMessages =
|
||||
[
|
||||
"I'm organizing a small team offsite. Can you help me plan it? Break the work into a todo list.",
|
||||
"I've booked the venue and sent out the invites. Please update the list.",
|
||||
"Actually, let's skip catering and instead plan a group hike. Update the plan accordingly.",
|
||||
];
|
||||
|
||||
foreach (string userMessage in userMessages)
|
||||
{
|
||||
Console.WriteLine($"User: {userMessage}");
|
||||
Console.WriteLine($"Agent: {await agent.RunAsync(userMessage, session)}");
|
||||
|
||||
// Read the current todo list straight from the provider and print it so the state is visible.
|
||||
await PrintTodoListAsync(todoProvider, session);
|
||||
Console.WriteLine();
|
||||
}
|
||||
|
||||
static async Task PrintTodoListAsync(TodoProvider todoProvider, AgentSession session)
|
||||
{
|
||||
IReadOnlyList<TodoItem> todos = await todoProvider.GetAllTodosAsync(session);
|
||||
|
||||
Console.WriteLine("--- Current todo list ---");
|
||||
if (todos.Count == 0)
|
||||
{
|
||||
Console.WriteLine(" (empty)");
|
||||
return;
|
||||
}
|
||||
|
||||
foreach (TodoItem todo in todos)
|
||||
{
|
||||
string status = todo.IsComplete ? "x" : " ";
|
||||
Console.WriteLine($" [{status}] {todo.Id}. {todo.Title}");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
# Todo List
|
||||
|
||||
This sample demonstrates how to use the `TodoProvider` to let an agent plan and track multi-step
|
||||
work using a todo list that persists across turns within a session.
|
||||
|
||||
The `TodoProvider` is an `AIContextProvider` that contributes todo-management tools and instructions
|
||||
to the agent, and stores the todo list in the session state. The provider exposes the following
|
||||
tools to the agent:
|
||||
|
||||
- `todos_add` — add one or more todo items (title + optional description).
|
||||
- `todos_complete` — mark one or more items complete, with a reason.
|
||||
- `todos_remove` — remove one or more items by ID.
|
||||
- `todos_get_remaining` — retrieve the incomplete items.
|
||||
- `todos_get_all` — retrieve all items (complete and incomplete).
|
||||
|
||||
## What it demonstrates
|
||||
|
||||
- Attaching a `TodoProvider` to an agent via `ChatClientAgentOptions.AIContextProviders`.
|
||||
- The agent breaking a complex request into trackable todo items, marking items complete as
|
||||
progress is reported, and adjusting the list when the plan changes.
|
||||
- Reading the todo list from application code with `TodoProvider.GetAllTodosAsync`.
|
||||
|
||||
This is a **scripted, non-interactive** walkthrough: it sends a fixed sequence of messages and,
|
||||
after each turn, prints the agent's reply followed by the current todo list so you can watch the
|
||||
state evolve.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- .NET 10 SDK or later
|
||||
- Microsoft Foundry project endpoint and model configured
|
||||
- Azure CLI installed and authenticated (run `az login`)
|
||||
- User has the required role to invoke models in the Foundry project
|
||||
|
||||
## Running the sample
|
||||
|
||||
Set the required environment variables:
|
||||
|
||||
```powershell
|
||||
$env:FOUNDRY_PROJECT_ENDPOINT="https://your-project-endpoint"
|
||||
$env:FOUNDRY_MODEL="gpt-5.4-mini" # Optional, defaults to gpt-5.4-mini
|
||||
```
|
||||
|
||||
Run the sample:
|
||||
|
||||
```powershell
|
||||
dotnet run
|
||||
```
|
||||
@@ -47,6 +47,9 @@ Before you begin, ensure you have the following prerequisites:
|
||||
|[Using compaction pipeline with an agent](./Agent_Step18_CompactionPipeline/)|This sample demonstrates how to use a compaction pipeline to efficiently limit the size of the conversation history for an agent.|
|
||||
|[In-function-loop checkpointing](./Agent_Step19_InFunctionLoopCheckpointing/)|This sample demonstrates how to persist chat history after each service call during a tool-calling loop, enabling crash recovery and mid-run observability.|
|
||||
|[Dynamic function tools](./Agent_Step20_DynamicFunctionTools/)|This sample demonstrates how to dynamically expand the set of function tools available to an agent during a function-calling loop using the ambient FunctionInvocationContext.|
|
||||
|[Shell tool with environment-aware system prompt](./Agent_Step21_ShellWithEnvironment/)|This sample demonstrates how to use the shell tool together with the ShellEnvironmentProvider to run commands in stateless and persistent modes, injecting environment-aware instructions so the agent emits commands in the right shell idiom.|
|
||||
|[Switching agent operating mode](./Agent_Step22_AgentMode/)|This sample demonstrates how to use the AgentModeProvider to track and switch an agent's operating mode at runtime, including the built-in plan/execute modes and custom modes, with a simple input loop that switches mode using a slash command.|
|
||||
|[Tracking work with a todo list](./Agent_Step23_TodoList/)|This sample demonstrates how to use the TodoProvider to let an agent plan and track multi-step work using a todo list that persists across turns, printing the evolving todo list after each turn.|
|
||||
|
||||
## Running the samples from the console
|
||||
|
||||
|
||||
+7
-1
@@ -1,7 +1,7 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<IsReleaseCandidate>true</IsReleaseCandidate>
|
||||
<IsReleased>true</IsReleased>
|
||||
<!-- GitHub.Copilot.SDK only supports .NET 8.0+ -->
|
||||
<TargetFrameworks>$(TargetFrameworksCore)</TargetFrameworks>
|
||||
<NoWarn>$(NoWarn);GHCP001</NoWarn>
|
||||
@@ -14,6 +14,12 @@
|
||||
|
||||
<Import Project="$(RepoRoot)/dotnet/nuget/nuget-package.props" />
|
||||
|
||||
<!-- Disable package validation baseline until the first release -->
|
||||
<PropertyGroup>
|
||||
<PackageValidationBaselineVersion />
|
||||
<EnablePackageValidation>false</EnablePackageValidation>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\Microsoft.Agents.AI.Abstractions\Microsoft.Agents.AI.Abstractions.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
+1
-1
@@ -34,7 +34,7 @@ internal sealed class EditTableExecutor(EditTable model, WorkflowFormulaState st
|
||||
EvaluationResult<DataValue> addResult = this.Evaluator.GetValue(addItemValue);
|
||||
RecordValue newRecord = BuildRecord(tableValue.Type.ToRecord(), addResult.Value.ToFormula());
|
||||
await tableValue.AppendAsync(newRecord, cancellationToken).ConfigureAwait(false);
|
||||
await this.AssignAsync(variablePath, newRecord, context).ConfigureAwait(false);
|
||||
await this.AssignAsync(variablePath, tableValue, context).ConfigureAwait(false);
|
||||
break;
|
||||
case TableChangeType.Remove:
|
||||
ValueExpression removeItemValue = Throw.IfNull(this.Model.Value, $"{nameof(this.Model)}.{nameof(this.Model.Value)}");
|
||||
|
||||
+1
-1
@@ -33,7 +33,7 @@ internal sealed class EditTableV2Executor(EditTableV2 model, WorkflowFormulaStat
|
||||
EvaluationResult<DataValue> expressionResult = this.Evaluator.GetValue(addItemValue);
|
||||
RecordValue newRecord = BuildRecord(tableValue.Type.ToRecord(), expressionResult.Value.ToFormula());
|
||||
await tableValue.AppendAsync(newRecord, cancellationToken).ConfigureAwait(false);
|
||||
await this.AssignAsync(this.Model.ItemsVariable, newRecord, context).ConfigureAwait(false);
|
||||
await this.AssignAsync(this.Model.ItemsVariable, tableValue, context).ConfigureAwait(false);
|
||||
}
|
||||
else if (changeType is ClearItemsOperation)
|
||||
{
|
||||
|
||||
@@ -478,7 +478,7 @@ public sealed partial class ChatClientAgent : AIAgent
|
||||
ChatOptions? chatOptions,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
ChatHistoryProvider? chatHistoryProvider = this.ResolveChatHistoryProvider(chatOptions);
|
||||
ChatHistoryProvider? chatHistoryProvider = this.ResolveChatHistoryProvider(session, chatOptions);
|
||||
|
||||
if (chatHistoryProvider is not null)
|
||||
{
|
||||
@@ -510,7 +510,7 @@ public sealed partial class ChatClientAgent : AIAgent
|
||||
ChatOptions? chatOptions,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
ChatHistoryProvider? chatHistoryProvider = this.ResolveChatHistoryProvider(chatOptions);
|
||||
ChatHistoryProvider? chatHistoryProvider = this.ResolveChatHistoryProvider(session, chatOptions);
|
||||
|
||||
if (chatHistoryProvider is not null)
|
||||
{
|
||||
@@ -980,22 +980,33 @@ public sealed partial class ChatClientAgent : AIAgent
|
||||
}
|
||||
}
|
||||
|
||||
private ChatHistoryProvider? ResolveChatHistoryProvider(ChatOptions? chatOptions)
|
||||
private ChatHistoryProvider? ResolveChatHistoryProvider(ChatClientAgentSession session, ChatOptions? chatOptions)
|
||||
{
|
||||
ChatHistoryProvider? provider =
|
||||
chatOptions?.ConversationId is null || IsAGUIProviderName(this._agentMetadata.ProviderName)
|
||||
? this.ChatHistoryProvider
|
||||
: null;
|
||||
// A service that manages chat history server-side disengages the chat history provider so that history
|
||||
// is not stored in two places. The service is considered to store history when a conversation id is
|
||||
// present either on the options (explicitly supplied by the caller) or on the session (returned by the
|
||||
// service on a previous or the current run).
|
||||
//
|
||||
// The per-service-call persistence check must remain: PerServiceCallChatHistoryPersistingChatClient
|
||||
// calls back into LoadChatHistoryAsync/NotifyProviders (which reach here) precisely when per-service-call
|
||||
// persistence is active, and in its simulated path it stamps a sentinel onto session.ConversationId.
|
||||
// Without this check that sentinel would be mistaken for service-stored history and wrongly disengage
|
||||
// the provider the decorator depends on.
|
||||
bool serviceStoresHistory =
|
||||
!this.RequiresPerServiceCallChatHistoryPersistence
|
||||
&& !IsAGUIProviderName(this._agentMetadata.ProviderName)
|
||||
&& (!string.IsNullOrWhiteSpace(chatOptions?.ConversationId)
|
||||
|| !string.IsNullOrWhiteSpace(session.ConversationId));
|
||||
|
||||
ChatHistoryProvider? provider = serviceStoresHistory ? null : this.ChatHistoryProvider;
|
||||
|
||||
// If someone provided an override ChatHistoryProvider via AdditionalProperties, we should use that instead.
|
||||
if (chatOptions?.AdditionalProperties?.TryGetValue(out ChatHistoryProvider? overrideProvider) is true)
|
||||
{
|
||||
if (!IsAGUIProviderName(this._agentMetadata.ProviderName) &&
|
||||
this._agentOptions?.ThrowOnChatHistoryProviderConflict is true &&
|
||||
string.IsNullOrWhiteSpace(chatOptions?.ConversationId) is false)
|
||||
if (this._agentOptions?.ThrowOnChatHistoryProviderConflict is true && serviceStoresHistory)
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
$"Only {nameof(ChatClientAgentSession.ConversationId)} or {nameof(this.ChatHistoryProvider)} may be used, but not both. The current {nameof(ChatClientAgentSession)} has a {nameof(ChatClientAgentSession.ConversationId)} indicating server-side chat history management, but an override {nameof(this.ChatHistoryProvider)} was provided via {nameof(AgentRunOptions.AdditionalProperties)}.");
|
||||
$"Only {nameof(ChatClientAgentSession.ConversationId)} or {nameof(this.ChatHistoryProvider)} may be used, but not both. A {nameof(ChatClientAgentSession.ConversationId)} indicating server-side chat history management is present (on the {nameof(ChatClientAgentSession)} or the {nameof(this.ChatOptions)}), but an override {nameof(this.ChatHistoryProvider)} was provided via {nameof(AgentRunOptions.AdditionalProperties)}.");
|
||||
}
|
||||
|
||||
// Validate that the override provider's StateKeys do not clash with any AIContextProvider's StateKeys.
|
||||
@@ -1030,7 +1041,7 @@ public sealed partial class ChatClientAgent : AIAgent
|
||||
ChatOptions? chatOptions,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var chatHistoryProvider = this.ResolveChatHistoryProvider(chatOptions);
|
||||
var chatHistoryProvider = this.ResolveChatHistoryProvider(session, chatOptions);
|
||||
if (chatHistoryProvider is null)
|
||||
{
|
||||
return messages;
|
||||
|
||||
@@ -115,6 +115,13 @@ public sealed class ToolApprovalAgent : DelegatingAIAgent
|
||||
return new AgentResponse(new ChatMessage(ChatRole.Assistant, [nextQueuedItem]));
|
||||
}
|
||||
|
||||
// When the caller did not supply a session, create one and use it for every inner call.
|
||||
// The auto-approval loop re-invokes the inner agent with only the injected approval
|
||||
// responses; without a session the inner agent has no conversation history to reconstruct
|
||||
// the original request, which produces an empty request to the underlying service. Threading
|
||||
// a session preserves the history across re-invocations.
|
||||
session ??= await this.InnerAgent.CreateSessionAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
// 3. Call the inner agent in a loop. If the inner agent returns approval requests
|
||||
// that are ALL auto-approved by standing rules, we immediately re-call with the
|
||||
// collected approval responses injected. This avoids returning empty responses.
|
||||
@@ -158,6 +165,11 @@ public sealed class ToolApprovalAgent : DelegatingAIAgent
|
||||
yield break;
|
||||
}
|
||||
|
||||
// When the caller did not supply a session, create one and use it for every inner call so
|
||||
// conversation history is preserved across auto-approval re-invocations. See the non-streaming
|
||||
// RunCoreAsync for details.
|
||||
session ??= await this.InnerAgent.CreateSessionAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
// 3. Stream from the inner agent in a loop. If all approval requests from the stream
|
||||
// are auto-approved by standing rules, we immediately re-stream with the collected
|
||||
// approval responses injected. This avoids returning empty streams.
|
||||
|
||||
+144
@@ -411,6 +411,150 @@ public class ChatClientAgent_ChatHistoryManagementTests
|
||||
Assert.Equal("ConvId", session!.ConversationId);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Regression test for https://github.com/microsoft/agent-framework/issues/6120.
|
||||
/// When the service manages chat history server-side (returns a conversation id), the framework's
|
||||
/// default in-memory chat history provider must not persist the messages, even on the first turn.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task RunAsync_DoesNotUseDefaultInMemoryChatHistoryProvider_WhenConversationIdReturnedAsync()
|
||||
{
|
||||
// Arrange
|
||||
Mock<IChatClient> mockService = new();
|
||||
mockService.Setup(
|
||||
s => s.GetResponseAsync(
|
||||
It.IsAny<IEnumerable<ChatMessage>>(),
|
||||
It.IsAny<ChatOptions>(),
|
||||
It.IsAny<CancellationToken>())).ReturnsAsync(new ChatResponse([new(ChatRole.Assistant, "response")]) { ConversationId = "ConvId" });
|
||||
ChatClientAgent agent = new(mockService.Object, options: new()
|
||||
{
|
||||
ChatOptions = new() { Instructions = "test instructions" },
|
||||
});
|
||||
|
||||
// Act
|
||||
ChatClientAgentSession? session = await agent.CreateSessionAsync() as ChatClientAgentSession;
|
||||
await agent.RunAsync([new(ChatRole.User, "test")], session);
|
||||
|
||||
// Assert
|
||||
Assert.Equal("ConvId", session!.ConversationId);
|
||||
var inMemoryProvider = Assert.IsType<InMemoryChatHistoryProvider>(agent.ChatHistoryProvider);
|
||||
Assert.Empty(inMemoryProvider.GetMessages(session));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Regression test for https://github.com/microsoft/agent-framework/issues/6120.
|
||||
/// The streaming path must also refrain from populating the default in-memory chat history provider
|
||||
/// when the service returns a conversation id.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task RunStreamingAsync_DoesNotUseDefaultInMemoryChatHistoryProvider_WhenConversationIdReturnedAsync()
|
||||
{
|
||||
// Arrange
|
||||
ChatResponseUpdate[] returnUpdates =
|
||||
[
|
||||
new ChatResponseUpdate(role: ChatRole.Assistant, content: "response") { ConversationId = "ConvId" },
|
||||
];
|
||||
Mock<IChatClient> mockService = new();
|
||||
mockService.Setup(
|
||||
s => s.GetStreamingResponseAsync(
|
||||
It.IsAny<IEnumerable<ChatMessage>>(),
|
||||
It.IsAny<ChatOptions>(),
|
||||
It.IsAny<CancellationToken>())).Returns(returnUpdates.ToAsyncEnumerable());
|
||||
ChatClientAgent agent = new(mockService.Object, options: new()
|
||||
{
|
||||
ChatOptions = new() { Instructions = "test instructions" },
|
||||
});
|
||||
|
||||
// Act
|
||||
ChatClientAgentSession? session = await agent.CreateSessionAsync() as ChatClientAgentSession;
|
||||
await foreach (var _ in agent.RunStreamingAsync([new(ChatRole.User, "test")], session))
|
||||
{
|
||||
}
|
||||
|
||||
// Assert
|
||||
Assert.Equal("ConvId", session!.ConversationId);
|
||||
var inMemoryProvider = Assert.IsType<InMemoryChatHistoryProvider>(agent.ChatHistoryProvider);
|
||||
Assert.Empty(inMemoryProvider.GetMessages(session));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Regression test for https://github.com/microsoft/agent-framework/issues/6120.
|
||||
/// Across multiple turns backed by service-stored history, the default in-memory chat history provider
|
||||
/// is never populated and prior turns are not replayed to the service (the service owns the history).
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task RunAsync_MultiTurnServiceStoredHistory_DoesNotPopulateDefaultInMemoryProviderAsync()
|
||||
{
|
||||
// Arrange
|
||||
var capturedInputs = new List<List<ChatMessage>>();
|
||||
Mock<IChatClient> mockService = new();
|
||||
mockService.Setup(
|
||||
s => s.GetResponseAsync(
|
||||
It.IsAny<IEnumerable<ChatMessage>>(),
|
||||
It.IsAny<ChatOptions>(),
|
||||
It.IsAny<CancellationToken>()))
|
||||
.Returns<IEnumerable<ChatMessage>, ChatOptions?, CancellationToken>((msgs, _, _) =>
|
||||
{
|
||||
capturedInputs.Add(msgs.ToList());
|
||||
return Task.FromResult(new ChatResponse([new(ChatRole.Assistant, "response")]) { ConversationId = "ConvId" });
|
||||
});
|
||||
ChatClientAgent agent = new(mockService.Object, options: new()
|
||||
{
|
||||
ChatOptions = new() { Instructions = "test instructions" },
|
||||
});
|
||||
|
||||
// Act
|
||||
ChatClientAgentSession? session = await agent.CreateSessionAsync() as ChatClientAgentSession;
|
||||
await agent.RunAsync([new(ChatRole.User, "first")], session);
|
||||
await agent.RunAsync([new(ChatRole.User, "second")], session);
|
||||
|
||||
// Assert
|
||||
Assert.Equal("ConvId", session!.ConversationId);
|
||||
var inMemoryProvider = Assert.IsType<InMemoryChatHistoryProvider>(agent.ChatHistoryProvider);
|
||||
Assert.Empty(inMemoryProvider.GetMessages(session));
|
||||
|
||||
// The second turn should only send the new user message, since the service owns the history.
|
||||
Assert.Equal(2, capturedInputs.Count);
|
||||
Assert.Single(capturedInputs[1]);
|
||||
Assert.Equal("second", capturedInputs[1][0].Text);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// When the service manages chat history server-side (returns a conversation id), an explicitly-configured
|
||||
/// chat history provider is disengaged just like the default provider, even when all conflict handling is
|
||||
/// disabled. This pins the uniform "service storage disengages any provider" semantics.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task RunAsync_ExplicitChatHistoryProvider_Disengaged_WhenConflictHandlingDisabledAndConversationIdReturnedAsync()
|
||||
{
|
||||
// Arrange
|
||||
Mock<IChatClient> mockService = new();
|
||||
mockService.Setup(
|
||||
s => s.GetResponseAsync(
|
||||
It.IsAny<IEnumerable<ChatMessage>>(),
|
||||
It.IsAny<ChatOptions>(),
|
||||
It.IsAny<CancellationToken>())).ReturnsAsync(new ChatResponse([new(ChatRole.Assistant, "response")]) { ConversationId = "ConvId" });
|
||||
var chatHistoryProvider = new InMemoryChatHistoryProvider();
|
||||
ChatClientAgent agent = new(mockService.Object, options: new()
|
||||
{
|
||||
ChatOptions = new() { Instructions = "test instructions" },
|
||||
ChatHistoryProvider = chatHistoryProvider,
|
||||
ThrowOnChatHistoryProviderConflict = false,
|
||||
ClearOnChatHistoryProviderConflict = false,
|
||||
WarnOnChatHistoryProviderConflict = false,
|
||||
});
|
||||
|
||||
// Act
|
||||
ChatClientAgentSession? session = await agent.CreateSessionAsync() as ChatClientAgentSession;
|
||||
await agent.RunAsync([new(ChatRole.User, "test")], session);
|
||||
|
||||
// Assert — the provider reference is retained (conflict handling disabled), but it is not persisted to
|
||||
// because the service stores history.
|
||||
Assert.Equal("ConvId", session!.ConversationId);
|
||||
Assert.Same(chatHistoryProvider, agent.ChatHistoryProvider);
|
||||
Assert.Empty(chatHistoryProvider.GetMessages(session));
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region ChatHistoryProvider Override Tests
|
||||
|
||||
+158
-1
@@ -1962,8 +1962,165 @@ public class ToolApprovalAgentTests
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verify that when auto-approval rule does not match, request is surfaced to the caller.
|
||||
/// Verify that when no session is supplied, the agent creates one and threads it to the inner
|
||||
/// agent across auto-approval re-invocations. Without a session, the inner agent would receive
|
||||
/// only the injected approval response (with no history) on the second call, producing an empty
|
||||
/// request to the underlying service (repro for issue #7210).
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task RunAsync_AutoApprovalRule_NoSession_CreatesAndThreadsSessionAsync()
|
||||
{
|
||||
// Arrange
|
||||
var createdSession = new ChatClientAgentSession();
|
||||
var approvalRequest = new ToolApprovalRequestContent("req1", new FunctionCallContent("call1", "ReadTool"));
|
||||
|
||||
var capturedSessions = new List<AgentSession?>();
|
||||
var callCount = 0;
|
||||
var innerAgent = new Mock<AIAgent>();
|
||||
innerAgent
|
||||
.Protected()
|
||||
.Setup<ValueTask<AgentSession>>("CreateSessionCoreAsync", ItExpr.IsAny<CancellationToken>())
|
||||
.Returns(new ValueTask<AgentSession>(createdSession));
|
||||
innerAgent
|
||||
.Protected()
|
||||
.Setup<Task<AgentResponse>>("RunCoreAsync",
|
||||
ItExpr.IsAny<IEnumerable<ChatMessage>>(),
|
||||
ItExpr.IsAny<AgentSession?>(),
|
||||
ItExpr.IsAny<AgentRunOptions?>(),
|
||||
ItExpr.IsAny<CancellationToken>())
|
||||
.Callback<IEnumerable<ChatMessage>, AgentSession?, AgentRunOptions?, CancellationToken>(
|
||||
(_, session, _, _) => capturedSessions.Add(session))
|
||||
.ReturnsAsync(() =>
|
||||
{
|
||||
callCount++;
|
||||
if (callCount == 1)
|
||||
{
|
||||
return new AgentResponse([new ChatMessage(ChatRole.Assistant, [approvalRequest])]);
|
||||
}
|
||||
|
||||
return new AgentResponse([new ChatMessage(ChatRole.Assistant, "Done")]);
|
||||
});
|
||||
|
||||
var options = new ToolApprovalAgentOptions
|
||||
{
|
||||
AutoApprovalRules = [ToolApprovalAgent.AllToolsAutoApprovalRule]
|
||||
};
|
||||
var agent = new ToolApprovalAgent(innerAgent.Object, options);
|
||||
|
||||
// Act — invoke WITHOUT a session.
|
||||
var response = await agent.RunAsync([new ChatMessage(ChatRole.User, "Hi")]);
|
||||
|
||||
// Assert — auto-approval re-invoked the inner agent, and both calls received the same,
|
||||
// non-null session so conversation history is preserved across the re-invocation.
|
||||
Assert.Equal(2, callCount);
|
||||
Assert.Equal("Done", response.Text);
|
||||
Assert.Equal(2, capturedSessions.Count);
|
||||
Assert.All(capturedSessions, s => Assert.Same(createdSession, s));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Streaming counterpart of <see cref="RunAsync_AutoApprovalRule_NoSession_CreatesAndThreadsSessionAsync"/>:
|
||||
/// when no session is supplied, the streaming path also creates one and threads it to the inner
|
||||
/// agent across auto-approval re-invocations.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task RunStreamingAsync_AutoApprovalRule_NoSession_CreatesAndThreadsSessionAsync()
|
||||
{
|
||||
// Arrange
|
||||
var createdSession = new ChatClientAgentSession();
|
||||
var approvalRequest = new ToolApprovalRequestContent("req1", new FunctionCallContent("call1", "ReadTool"));
|
||||
|
||||
var capturedSessions = new List<AgentSession?>();
|
||||
var callCount = 0;
|
||||
var innerAgent = new Mock<AIAgent>();
|
||||
innerAgent
|
||||
.Protected()
|
||||
.Setup<ValueTask<AgentSession>>("CreateSessionCoreAsync", ItExpr.IsAny<CancellationToken>())
|
||||
.Returns(new ValueTask<AgentSession>(createdSession));
|
||||
innerAgent
|
||||
.Protected()
|
||||
.Setup<IAsyncEnumerable<AgentResponseUpdate>>("RunCoreStreamingAsync",
|
||||
ItExpr.IsAny<IEnumerable<ChatMessage>>(),
|
||||
ItExpr.IsAny<AgentSession?>(),
|
||||
ItExpr.IsAny<AgentRunOptions?>(),
|
||||
ItExpr.IsAny<CancellationToken>())
|
||||
.Returns<IEnumerable<ChatMessage>, AgentSession?, AgentRunOptions?, CancellationToken>(
|
||||
(_, session, _, ct) =>
|
||||
{
|
||||
capturedSessions.Add(session);
|
||||
callCount++;
|
||||
AgentResponseUpdate[] streamUpdates = callCount == 1
|
||||
? [new AgentResponseUpdate(ChatRole.Assistant, [approvalRequest])]
|
||||
: [new AgentResponseUpdate(ChatRole.Assistant, "Done")];
|
||||
return ToAsyncEnumerableAsync(streamUpdates, ct);
|
||||
});
|
||||
|
||||
var options = new ToolApprovalAgentOptions
|
||||
{
|
||||
AutoApprovalRules = [ToolApprovalAgent.AllToolsAutoApprovalRule]
|
||||
};
|
||||
var agent = new ToolApprovalAgent(innerAgent.Object, options);
|
||||
|
||||
// Act — invoke WITHOUT a session.
|
||||
var updates = new List<AgentResponseUpdate>();
|
||||
await foreach (var update in agent.RunStreamingAsync([new ChatMessage(ChatRole.User, "Hi")]))
|
||||
{
|
||||
updates.Add(update);
|
||||
}
|
||||
|
||||
// Assert — both inner calls received the same, non-null session.
|
||||
Assert.Equal(2, callCount);
|
||||
Assert.Equal("Done", string.Concat(updates.Select(u => u.Text)));
|
||||
Assert.Equal(2, capturedSessions.Count);
|
||||
Assert.All(capturedSessions, s => Assert.Same(createdSession, s));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verify that when no session is supplied, the agent creates exactly one session and threads
|
||||
/// it to the inner agent, even when no approval re-invocation is needed.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task RunAsync_NoApprovalRequest_NoSession_CreatesSingleSessionAsync()
|
||||
{
|
||||
// Arrange
|
||||
var createdSession = new ChatClientAgentSession();
|
||||
var createSessionCallCount = 0;
|
||||
var capturedSessions = new List<AgentSession?>();
|
||||
var innerAgent = new Mock<AIAgent>();
|
||||
innerAgent
|
||||
.Protected()
|
||||
.Setup<ValueTask<AgentSession>>("CreateSessionCoreAsync", ItExpr.IsAny<CancellationToken>())
|
||||
.Returns(() =>
|
||||
{
|
||||
createSessionCallCount++;
|
||||
return new ValueTask<AgentSession>(createdSession);
|
||||
});
|
||||
innerAgent
|
||||
.Protected()
|
||||
.Setup<Task<AgentResponse>>("RunCoreAsync",
|
||||
ItExpr.IsAny<IEnumerable<ChatMessage>>(),
|
||||
ItExpr.IsAny<AgentSession?>(),
|
||||
ItExpr.IsAny<AgentRunOptions?>(),
|
||||
ItExpr.IsAny<CancellationToken>())
|
||||
.Callback<IEnumerable<ChatMessage>, AgentSession?, AgentRunOptions?, CancellationToken>(
|
||||
(_, session, _, _) => capturedSessions.Add(session))
|
||||
.ReturnsAsync(new AgentResponse([new ChatMessage(ChatRole.Assistant, "Done")]));
|
||||
|
||||
var agent = new ToolApprovalAgent(innerAgent.Object, new ToolApprovalAgentOptions
|
||||
{
|
||||
AutoApprovalRules = [ToolApprovalAgent.AllToolsAutoApprovalRule]
|
||||
});
|
||||
|
||||
// Act
|
||||
var response = await agent.RunAsync([new ChatMessage(ChatRole.User, "Hi")]);
|
||||
|
||||
// Assert — a single session was created and threaded to the inner agent.
|
||||
Assert.Equal("Done", response.Text);
|
||||
Assert.Equal(1, createSessionCallCount);
|
||||
Assert.Single(capturedSessions);
|
||||
Assert.Same(createdSession, capturedSessions[0]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RunAsync_AutoApprovalRule_DoesNotMatchSurfacesToCallerAsync()
|
||||
{
|
||||
|
||||
+45
-10
@@ -33,13 +33,44 @@ public sealed class EditTableExecutorTest(ITestOutputHelper output) : WorkflowAc
|
||||
changeType: TableChangeType.Add,
|
||||
value: new RecordDataValue([new("id", new NumberDataValue(7))]));
|
||||
|
||||
// Verify the variable now contains the added record
|
||||
// Verify the variable remains a table containing the added record
|
||||
FormulaValue resultValue = this.State.Get("MyTable");
|
||||
RecordValue resultRecord = Assert.IsAssignableFrom<RecordValue>(resultValue);
|
||||
DecimalValue idValue = Assert.IsType<DecimalValue>(resultRecord.GetField("id"));
|
||||
TableValue resultTable = Assert.IsAssignableFrom<TableValue>(resultValue);
|
||||
Assert.Equal(2, resultTable.Rows.Count());
|
||||
DecimalValue idValue = Assert.IsType<DecimalValue>(resultTable.Rows.Last().Value.GetField("id"));
|
||||
Assert.Equal(7, idValue.Value);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ConsecutiveAddsPreserveTableAsync()
|
||||
{
|
||||
// Arrange
|
||||
FormulaValue tableValue = this.State.Engine.Eval("[{id: 1}]");
|
||||
this.State.Set("MyTable", tableValue);
|
||||
|
||||
EditTable firstAdd = this.CreateModel(
|
||||
nameof(ConsecutiveAddsPreserveTableAsync),
|
||||
"MyTable",
|
||||
TableChangeType.Add,
|
||||
new RecordDataValue([new("id", new NumberDataValue(2))]));
|
||||
EditTable secondAdd = this.CreateModel(
|
||||
nameof(ConsecutiveAddsPreserveTableAsync),
|
||||
"MyTable",
|
||||
TableChangeType.Add,
|
||||
new RecordDataValue([new("id", new NumberDataValue(3))]));
|
||||
|
||||
// Act
|
||||
await this.ExecuteAsync(new EditTableExecutor(firstAdd, this.State));
|
||||
await this.ExecuteAsync(new EditTableExecutor(secondAdd, this.State));
|
||||
|
||||
// Assert
|
||||
TableValue resultTable = Assert.IsAssignableFrom<TableValue>(this.State.Get("MyTable"));
|
||||
decimal[] ids = resultTable.Rows
|
||||
.Select(row => Assert.IsType<DecimalValue>(row.Value.GetField("id")).Value)
|
||||
.ToArray();
|
||||
Assert.Equal([1, 2, 3], ids);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AddItemWithMultipleFieldsAsync()
|
||||
{
|
||||
@@ -57,9 +88,11 @@ public sealed class EditTableExecutorTest(ITestOutputHelper output) : WorkflowAc
|
||||
new("name", new StringDataValue("Second"))
|
||||
]));
|
||||
|
||||
// Verify the variable now contains the added record
|
||||
// Verify the variable remains a table containing the added record
|
||||
FormulaValue resultValue = this.State.Get("MyTable");
|
||||
RecordValue resultRecord = Assert.IsAssignableFrom<RecordValue>(resultValue);
|
||||
TableValue resultTable = Assert.IsAssignableFrom<TableValue>(resultValue);
|
||||
Assert.Equal(2, resultTable.Rows.Count());
|
||||
RecordValue resultRecord = resultTable.Rows.Last().Value;
|
||||
DecimalValue idValue = Assert.IsType<DecimalValue>(resultRecord.GetField("id"));
|
||||
Assert.Equal(2, idValue.Value);
|
||||
StringValue nameValue = Assert.IsType<StringValue>(resultRecord.GetField("name"));
|
||||
@@ -83,9 +116,10 @@ public sealed class EditTableExecutorTest(ITestOutputHelper output) : WorkflowAc
|
||||
changeType: TableChangeType.Add,
|
||||
value: new RecordDataValue([new("id", new NumberDataValue(1))]));
|
||||
|
||||
// Verify the variable now contains the added record
|
||||
// Verify the variable remains a table containing the added record
|
||||
FormulaValue resultValue = this.State.Get("MyTable");
|
||||
RecordValue resultRecord = Assert.IsAssignableFrom<RecordValue>(resultValue);
|
||||
TableValue resultTable = Assert.IsAssignableFrom<TableValue>(resultValue);
|
||||
RecordValue resultRecord = Assert.Single(resultTable.Rows).Value;
|
||||
DecimalValue idValue = Assert.IsType<DecimalValue>(resultRecord.GetField("id"));
|
||||
Assert.Equal(1, idValue.Value);
|
||||
}
|
||||
@@ -345,11 +379,12 @@ public sealed class EditTableExecutorTest(ITestOutputHelper output) : WorkflowAc
|
||||
EditTableExecutor action = new(model, this.State);
|
||||
await this.ExecuteAsync(action);
|
||||
|
||||
// Assert - Variable should contain the newly added record
|
||||
// Assert - Variable should remain a table containing the newly added record
|
||||
VerifyModel(model, action);
|
||||
FormulaValue resultValue = this.State.Get("MyTable");
|
||||
RecordValue resultRecord = Assert.IsAssignableFrom<RecordValue>(resultValue);
|
||||
DecimalValue idValue = Assert.IsType<DecimalValue>(resultRecord.GetField("id"));
|
||||
TableValue resultTable = Assert.IsAssignableFrom<TableValue>(resultValue);
|
||||
Assert.Equal(2, resultTable.Rows.Count());
|
||||
DecimalValue idValue = Assert.IsType<DecimalValue>(resultTable.Rows.Last().Value.GetField("id"));
|
||||
Assert.Equal(10, idValue.Value);
|
||||
}
|
||||
|
||||
|
||||
+38
-6
@@ -1,6 +1,7 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System;
|
||||
using System.Linq;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Agents.AI.Workflows.Declarative.ObjectModel;
|
||||
using Microsoft.Agents.ObjectModel;
|
||||
@@ -144,7 +145,7 @@ public sealed class EditTableV2ExecutorTest(ITestOutputHelper output) : Workflow
|
||||
this.State.Set("TestTable", tableValue);
|
||||
|
||||
// Arrange, Act, Assert
|
||||
await this.ExecuteTestAsync<RecordValue>(
|
||||
await this.ExecuteTestAsync<TableValue>(
|
||||
displayName: nameof(AddItemOperationWithSingleFieldRecordAsync),
|
||||
variableName: "TestTable",
|
||||
changeType: this.CreateAddItemOperation(new RecordDataValue.Builder
|
||||
@@ -154,8 +155,8 @@ public sealed class EditTableV2ExecutorTest(ITestOutputHelper output) : Workflow
|
||||
["Name"] = new StringDataValue("John")
|
||||
}
|
||||
}.Build()),
|
||||
verifyAction: (variableName, recordValue) =>
|
||||
Assert.Equal("John", recordValue.GetField("Name").ToObject())
|
||||
verifyAction: (variableName, resultTable) =>
|
||||
Assert.Equal("John", Assert.Single(resultTable.Rows).Value.GetField("Name").ToObject())
|
||||
);
|
||||
}
|
||||
|
||||
@@ -168,15 +169,46 @@ public sealed class EditTableV2ExecutorTest(ITestOutputHelper output) : Workflow
|
||||
this.State.Set("TestTable", tableValue);
|
||||
|
||||
// Act & Assert
|
||||
await this.ExecuteTestAsync<RecordValue>(
|
||||
await this.ExecuteTestAsync<TableValue>(
|
||||
displayName: nameof(AddItemOperationWithScalarValueAsync),
|
||||
variableName: "TestTable",
|
||||
changeType: this.CreateAddItemOperation(new StringDataValue("TestValue")),
|
||||
verifyAction: (variableName, recordValue) =>
|
||||
Assert.Equal("TestValue", recordValue.GetField("Value").ToObject())
|
||||
verifyAction: (variableName, resultTable) =>
|
||||
Assert.Equal("TestValue", Assert.Single(resultTable.Rows).Value.GetField("Value").ToObject())
|
||||
);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ConsecutiveAddItemOperationsPreserveTableAsync()
|
||||
{
|
||||
// Arrange
|
||||
RecordType recordType = RecordType.Empty().Add("Value", FormulaType.String);
|
||||
RecordValue initialRecord = FormulaValue.NewRecordFromFields(
|
||||
recordType,
|
||||
new NamedValue("Value", FormulaValue.New("Initial")));
|
||||
this.State.Set("TestTable", FormulaValue.NewTable(recordType, initialRecord));
|
||||
|
||||
EditTableV2 firstAdd = this.CreateModel(
|
||||
nameof(ConsecutiveAddItemOperationsPreserveTableAsync),
|
||||
"TestTable",
|
||||
this.CreateAddItemOperation(new StringDataValue("First")));
|
||||
EditTableV2 secondAdd = this.CreateModel(
|
||||
nameof(ConsecutiveAddItemOperationsPreserveTableAsync),
|
||||
"TestTable",
|
||||
this.CreateAddItemOperation(new StringDataValue("Second")));
|
||||
|
||||
// Act
|
||||
await this.ExecuteAsync(new EditTableV2Executor(firstAdd, this.State));
|
||||
await this.ExecuteAsync(new EditTableV2Executor(secondAdd, this.State));
|
||||
|
||||
// Assert
|
||||
TableValue resultTable = Assert.IsAssignableFrom<TableValue>(this.State.Get("TestTable"));
|
||||
string[] values = resultTable.Rows
|
||||
.Select(row => Assert.IsType<StringValue>(row.Value.GetField("Value")).Value)
|
||||
.ToArray();
|
||||
Assert.Equal(["Initial", "First", "Second"], values);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ClearItemsOperationAsync()
|
||||
{
|
||||
|
||||
+42
-16
@@ -28,7 +28,7 @@ For release work, derive the live tier map at release time from `python/PACKAGE_
|
||||
|
||||
## Inputs to confirm before bumping
|
||||
|
||||
1. **The changeset**: explicit commits/PRs the release covers, OR derive from `git log ${LAST_RELEASED_TAG}..origin/main -- python/`.
|
||||
1. **The changeset**: explicit commits/PRs the release covers, OR derive from `git log ${LAST_RELEASED_TAG}..${RELEASE_BASE} -- python/`.
|
||||
2. **Per-package CHANGELOG entries**: which packages will get a line in the new release section. This list IS the bump list.
|
||||
3. **Per-released-package semver bump**: for each released-tier package that has a CHANGELOG entry, decide PATCH / MINOR / MAJOR.
|
||||
4. **Date stamp** (only if any alpha/beta is being bumped): default from the `python-package-management`
|
||||
@@ -55,12 +55,20 @@ If the user states target versions or a date explicitly, use exactly what they s
|
||||
git fetch origin main --tags --quiet
|
||||
git fetch upstream main --tags --quiet 2>/dev/null || true
|
||||
git status
|
||||
|
||||
# Fork clones use upstream/main as the authoritative release base; direct clones use origin/main.
|
||||
if git show-ref --verify --quiet refs/remotes/upstream/main; then
|
||||
RELEASE_BASE=upstream/main
|
||||
else
|
||||
RELEASE_BASE=origin/main
|
||||
fi
|
||||
git log -1 --oneline "$RELEASE_BASE"
|
||||
```
|
||||
|
||||
If the user already has a `bump-py-ver-release-*` branch checked out, use it. Otherwise:
|
||||
|
||||
```bash
|
||||
git checkout -b bump-py-ver-release-YYMMDD origin/main
|
||||
git checkout -b bump-py-ver-release-YYMMDD "$RELEASE_BASE"
|
||||
```
|
||||
|
||||
### 2. Build the live tier map
|
||||
@@ -84,20 +92,20 @@ echo "Compare base: $LAST_RELEASED_TAG"
|
||||
List commits and packages touched:
|
||||
|
||||
```bash
|
||||
git log --oneline ${LAST_RELEASED_TAG}..origin/main -- python/ ':!python/CHANGELOG.md'
|
||||
git log --oneline ${LAST_RELEASED_TAG}..${RELEASE_BASE} -- python/ ':!python/CHANGELOG.md'
|
||||
|
||||
# Per-commit package footprint
|
||||
for sha in $(git log --format='%H' ${LAST_RELEASED_TAG}..origin/main -- python/); do
|
||||
for sha in $(git log --format='%H' ${LAST_RELEASED_TAG}..${RELEASE_BASE} -- python/); do
|
||||
echo "--- $(git show -s --format='%h %s' $sha) ---"
|
||||
git show --name-only --format='' $sha | grep '^python/packages/' | \
|
||||
sed 's|^python/packages/||' | awk -F/ '{print $1}' | sort -u
|
||||
done
|
||||
```
|
||||
|
||||
If the release ultimately tags from `upstream/main` but `origin/main` is behind, also run:
|
||||
When both remotes exist, record whether the fork is behind the authoritative base:
|
||||
|
||||
```bash
|
||||
git log --oneline ${LAST_RELEASED_TAG}..upstream/main -- python/ ':!python/CHANGELOG.md'
|
||||
git rev-list --left-right --count origin/main...upstream/main
|
||||
```
|
||||
|
||||
If user provides an explicit commit/PR list, treat THAT as authoritative.
|
||||
@@ -108,14 +116,14 @@ Aggregate the per-commit footprint into a single union across the whole range. T
|
||||
|
||||
```bash
|
||||
# Union of all touched package directories across the range
|
||||
git log --name-only --format='' ${LAST_RELEASED_TAG}..origin/main -- python/packages/ \
|
||||
git log --name-only --format='' ${LAST_RELEASED_TAG}..${RELEASE_BASE} -- python/packages/ \
|
||||
| grep '^python/packages/' \
|
||||
| sed 's|^python/packages/||' \
|
||||
| awk -F/ '{print $1}' \
|
||||
| sort -u
|
||||
|
||||
# Root-level files (drive a root agent-framework entry if substantive)
|
||||
git log --name-only --format='' ${LAST_RELEASED_TAG}..origin/main \
|
||||
git log --name-only --format='' ${LAST_RELEASED_TAG}..${RELEASE_BASE} \
|
||||
-- python/pyproject.toml python/agent_framework_meta/ python/README.md \
|
||||
2>/dev/null | grep -v '^$' | sort -u
|
||||
```
|
||||
@@ -175,13 +183,13 @@ Before moving on, prove that every ship-affecting touched package has at least o
|
||||
|
||||
```bash
|
||||
# 1. Touched ship-affecting packages and root package files (from step 3a)
|
||||
TOUCHED_PACKAGES=$(git log --name-only --format='' ${LAST_RELEASED_TAG}..origin/main -- python/packages/ \
|
||||
TOUCHED_PACKAGES=$(git log --name-only --format='' ${LAST_RELEASED_TAG}..${RELEASE_BASE} -- python/packages/ \
|
||||
| grep '^python/packages/' \
|
||||
| sed 's|^python/packages/||' \
|
||||
| awk -F/ '{print $1}' \
|
||||
| sort -u)
|
||||
|
||||
ROOT_TOUCHED=$(git log --name-only --format='' ${LAST_RELEASED_TAG}..origin/main \
|
||||
ROOT_TOUCHED=$(git log --name-only --format='' ${LAST_RELEASED_TAG}..${RELEASE_BASE} \
|
||||
-- python/pyproject.toml python/agent_framework_meta/ python/README.md \
|
||||
2>/dev/null | grep -v '^$' | sort -u)
|
||||
|
||||
@@ -279,7 +287,7 @@ Spot-check with `grep '^version' python/pyproject.toml python/packages/*/pyproje
|
||||
Only relevant when `core` itself bumped this cycle. Two policies, pick one explicitly with the user:
|
||||
|
||||
- **Conservative (default)**: raise `agent-framework-core>=X.Y.Z` to the new core version on every non-core package that is ALSO bumping this cycle. Leaves packages-not-bumped at their existing floor.
|
||||
- **Strict per-upstream-doc**: only raise the floor on packages that actually consume a new core API introduced in the bump. This requires per-package code inspection. Use only when the user is comfortable letting `validate-dependency-bounds-test` (lower-resolution pass) catch any mistakes.
|
||||
- **Strict per-upstream-doc**: only raise the floor on packages that actually consume a new core API introduced in the bump. This requires per-package code inspection because release probes use the co-released local core and cannot prove compatibility with an older published core floor.
|
||||
|
||||
When raising a core floor, replace only the `>=OLD` half of the bound you intend to change:
|
||||
|
||||
@@ -294,12 +302,29 @@ If `core` did not bump this cycle, do not touch floors.
|
||||
### 7. Validate
|
||||
|
||||
```bash
|
||||
cd python && uv run poe validate-dependency-bounds-test
|
||||
cd python && uv run poe validate-python-release --base-ref "$RELEASE_BASE"
|
||||
```
|
||||
|
||||
Must exit 0. This is the safety net for selective bumping: the lower-resolution pass catches floors set too low for code that depends on new APIs, and the upper pass catches caps that exclude installable versions. If it fails, the output names the offending bound — fix and re-run before committing. This step also regenerates `uv.lock` to match new bounds.
|
||||
Use the same freshly fetched main ref that the release branch was based on (`upstream/main` above; use `origin/main`
|
||||
when that is the authoritative release base). Must exit 0. This task first regenerates `uv.lock`, then discovers the
|
||||
package `pyproject.toml` files changed from that base and runs their published runtime dependencies and
|
||||
non-development extras through lock-independent `lowest-direct` and `highest` import probes. The probes run in
|
||||
parallel, derive the minimum supported Python minor from each package's internal editable closure, and share a hard
|
||||
300-second deadline. Use `--python` only when the release requires an explicit interpreter override.
|
||||
|
||||
If only prereleases changed (no `core` bump, no floor changes), this validation is still required — `uv.lock` regeneration alone justifies the run.
|
||||
This is the release safety net for selective bumping: the lower probe catches unresolvable or unimportable external
|
||||
floors, internal constraints that reject co-released package versions, and the upper probe catches caps that exclude
|
||||
an installable package set. The JSON report records the concrete versions resolved in both scenarios. It does not
|
||||
replace the package-by-package code inspection required by the strict core-floor policy. If it fails, fix the named
|
||||
package/bound and re-run before committing.
|
||||
|
||||
Do not substitute the workspace-wide `validate-dependency-bounds-test` command here. That command runs every
|
||||
package's full tests and Pyright in separate isolated environments and is intentionally reserved for CI or an
|
||||
explicit dependency-range audit. If the release itself changes an external dependency range, also run
|
||||
`validate-dependency-bounds-project --mode both --package <pkg> --dependency <name>` for that dependency.
|
||||
|
||||
If only prereleases changed (no `core` bump, no floor changes), release validation is still required because the
|
||||
lockfile and both ends of each changed package's published dependency metadata must remain installable.
|
||||
|
||||
### 8. Commit (expect hook retry)
|
||||
|
||||
@@ -349,7 +374,7 @@ The push output includes a `Create a pull request for '<branch>' on GitHub by vi
|
||||
do not infer a local timezone from the user's current shell.
|
||||
- **`Co-Authored-By` trailer.** Never add it. Rewrite/amend if it slipped in.
|
||||
- **Stale inventory in this skill.** Always read `python/PACKAGE_STATUS.md` for the live tier map. Do not trust a hardcoded list.
|
||||
- **Divergent origin vs upstream.** If the release tags from `upstream/main` but `origin/main` is behind, check both — warn if they differ and offer to sync.
|
||||
- **Divergent origin vs upstream.** In fork clones, use freshly fetched `upstream/main` consistently for branch creation, changeset discovery, and release validation. A stale `origin/main` must never become the implicit compare base.
|
||||
- **`--pre` README cleanup on promotion.** When a package is promoted to `released` in this cycle, grep for `pip install agent-framework-<pkg> --pre` in READMEs and drop the `--pre` flag.
|
||||
- **RC counter inflation.** Do not increment `1.0.0rcN` without a CHANGELOG entry for that package. The counter tracks iterations, not calendar.
|
||||
|
||||
@@ -357,5 +382,6 @@ The push output includes a `Create a pull request for '<branch>' on GitHub by vi
|
||||
|
||||
- Package lifecycle and versioning source of truth: `python/.github/skills/python-package-management/SKILL.md`
|
||||
- Lifecycle source of truth: `python/PACKAGE_STATUS.md`
|
||||
- Validator: `python/scripts/dependencies/validate_dependency_bounds.py` (runs `lowest-direct` and `highest` resolution smoke tests; catches floors/caps that don't match the code)
|
||||
- Release validator: `python/scripts/dependencies/validate_dependency_bounds.py --mode release` (changed-package,
|
||||
lock-independent `lowest-direct` and `highest` import probes under a five-minute deadline)
|
||||
- Poe task definitions: `python/pyproject.toml` `[tool.poe.tasks]`
|
||||
|
||||
+16
-3
@@ -45,9 +45,13 @@ uv lock --upgrade-package <dependency-name> && uv run poe install
|
||||
# Refresh exact development dependency-group pins, lockfile, and validation in one run
|
||||
uv run poe upgrade-dev-dependencies
|
||||
|
||||
# First, run workspace-wide lower/upper compatibility gates
|
||||
# Release cuts: refresh uv.lock and probe changed packages at both bound extremes.
|
||||
# The release probe has a shared five-minute deadline.
|
||||
uv run poe validate-python-release --base-ref upstream/main
|
||||
|
||||
# Exhaustive test+typing matrix (slow; use for deliberate dependency-range work or CI)
|
||||
uv run poe validate-dependency-bounds-test
|
||||
# Defaults to --package "*"; pass a package to scope test mode
|
||||
# Defaults to --package "*"; scope locally whenever possible.
|
||||
uv run poe validate-dependency-bounds-test --package core
|
||||
|
||||
# Then expand bounds for one dependency in the target package
|
||||
@@ -66,7 +70,16 @@ uv run poe add-dependency-and-validate-bounds --package core --dependency "<depe
|
||||
- Prerelease (`dev`/`a`/`b`/`rc`) and `<1.0` dependencies should use hard bounds with an explicit upper cap (avoid open-ended ranges).
|
||||
- For `<1.0` dependencies, prefer the broadest validated range the package can really support. That may be a patch line, a minor line, or multiple minor lines when checks/tests show the broader lane is compatible.
|
||||
- Prefer supporting multiple majors when practical; if APIs diverge across supported majors, use version-conditional imports/paths.
|
||||
- For dependency changes, run workspace-wide bound gates first, then `validate-dependency-bounds-project --mode both` for the target package/dependency to keep minimum and maximum constraints current. The same task can also drive repo-wide upper-bound automation by using `--package "*"` and omitting `--dependency`.
|
||||
- For release-only version, lifecycle, pin, and internal-floor edits, use `validate-python-release`. It refreshes
|
||||
`uv.lock`, finds changed package metadata relative to the selected main ref, and runs the changed packages'
|
||||
published runtime dependencies and non-development extras through lock-independent `lowest-direct` and `highest`
|
||||
import probes on the minimum Python minor supported by each package's internal editable closure. The probes run
|
||||
concurrently under one 300-second deadline; pass `--python` only when an explicit interpreter override is needed.
|
||||
- For deliberate external dependency-range changes, use
|
||||
`validate-dependency-bounds-project --mode both` for the target package/dependency to find and validate the actual
|
||||
minimum and maximum constraints. Scope the exhaustive `validate-dependency-bounds-test` matrix to affected
|
||||
packages during local iteration; reserve the workspace-wide form for CI or an intentional full audit. The same
|
||||
project task can drive repo-wide upper-bound automation by using `--package "*"` and omitting `--dependency`.
|
||||
- Prefer targeted lock updates with `uv lock --upgrade-package <dependency-name>` to reduce `uv.lock` merge conflicts.
|
||||
- Use `add-dependency-and-validate-bounds` for package-scoped dependency additions plus bound validation in one command.
|
||||
- Keep shared tooling and source/type-check support in the root or package `dev` group. Put package-specific test
|
||||
|
||||
@@ -115,6 +115,18 @@ class CapturingRunnerContext(RunnerContext):
|
||||
"""Checkpointing not supported in activity context."""
|
||||
raise NotImplementedError("Checkpointing is not supported in Azure Functions activity context")
|
||||
|
||||
async def build_checkpoint(
|
||||
self,
|
||||
workflow_name: str,
|
||||
graph_signature_hash: str,
|
||||
state: State,
|
||||
previous_checkpoint_id: str | None,
|
||||
iteration_count: int,
|
||||
metadata: dict[str, Any] | None = None,
|
||||
) -> WorkflowCheckpoint:
|
||||
"""Checkpointing not supported in activity context."""
|
||||
raise NotImplementedError("Checkpointing is not supported in Azure Functions activity context")
|
||||
|
||||
async def load_checkpoint(self, checkpoint_id: str) -> WorkflowCheckpoint | None:
|
||||
"""Checkpointing not supported in activity context."""
|
||||
raise NotImplementedError("Checkpointing is not supported in Azure Functions activity context")
|
||||
|
||||
@@ -27,6 +27,7 @@ import fnmatch
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
import stat
|
||||
from abc import ABC, abstractmethod
|
||||
from collections.abc import Callable, Mapping, MutableMapping
|
||||
from pathlib import Path
|
||||
@@ -81,6 +82,21 @@ _SEARCH_TIMEOUT_SECONDS = 10.0
|
||||
_ELOOP = errno.ELOOP
|
||||
|
||||
|
||||
def _is_link_or_reparse_point(path: Path) -> bool:
|
||||
"""Return whether ``path`` is a symbolic link, junction, or other reparse point."""
|
||||
path_stat = path.lstat()
|
||||
if stat.S_ISLNK(path_stat.st_mode):
|
||||
return True
|
||||
|
||||
is_junction = getattr(path, "is_junction", None)
|
||||
if callable(is_junction) and is_junction():
|
||||
return True
|
||||
|
||||
reparse_attribute = getattr(stat, "FILE_ATTRIBUTE_REPARSE_POINT", 0)
|
||||
file_attributes = getattr(path_stat, "st_file_attributes", 0)
|
||||
return bool(reparse_attribute and file_attributes & reparse_attribute)
|
||||
|
||||
|
||||
def _compile_search_regex(pattern: str) -> re.Pattern[str]:
|
||||
"""Compile a case-insensitive search regex, enforcing the length cap.
|
||||
|
||||
@@ -856,10 +872,9 @@ class FileSystemAgentFileStore(AgentFileStore):
|
||||
"""Reject any segment between the root and ``candidate`` that is a symlink/reparse point.
|
||||
|
||||
Walks each ancestor down from the root on the *unresolved* candidate so
|
||||
``Path.is_symlink`` observes the on-disk entries instead of their
|
||||
canonical targets. Stops once a segment does not exist on disk so write
|
||||
scenarios remain allowed. ``Path.is_symlink`` detects both POSIX
|
||||
symlinks and Windows reparse points (junctions).
|
||||
``Path.lstat`` observes the on-disk entries instead of their canonical
|
||||
targets. Stops once a segment does not exist on disk so write scenarios
|
||||
remain allowed.
|
||||
"""
|
||||
try:
|
||||
relative_parts = candidate.relative_to(self._root_path).parts
|
||||
@@ -873,18 +888,19 @@ class FileSystemAgentFileStore(AgentFileStore):
|
||||
for segment in relative_parts:
|
||||
current = current / segment
|
||||
try:
|
||||
is_link = current.is_symlink()
|
||||
is_link = _is_link_or_reparse_point(current)
|
||||
except FileNotFoundError:
|
||||
break
|
||||
except OSError as exc:
|
||||
# Fail closed: if we cannot verify whether a segment is a
|
||||
# symlink/reparse point we refuse the operation rather than
|
||||
# silently allow access that may escape the root.
|
||||
probed_path = current.relative_to(self._root_path).as_posix()
|
||||
raise ValueError(
|
||||
f"Invalid path: unable to verify whether '{segment}' is a symbolic link or reparse point."
|
||||
f"Invalid path: unable to verify whether {probed_path!r} is a symbolic link or reparse point."
|
||||
) from exc
|
||||
if is_link:
|
||||
raise ValueError("Invalid path: the resolved path contains a symbolic link or reparse point.")
|
||||
if not current.exists():
|
||||
break
|
||||
|
||||
async def write(self, path: str, content: str, *, overwrite: bool = True) -> None:
|
||||
"""Write ``content`` to the file at ``path``.
|
||||
@@ -908,9 +924,9 @@ class FileSystemAgentFileStore(AgentFileStore):
|
||||
flags |= os.O_TRUNC
|
||||
else:
|
||||
flags |= os.O_EXCL
|
||||
# ``O_NOFOLLOW`` is POSIX-only; on Windows ``Path.is_symlink`` /
|
||||
# reparse-point detection in :meth:`_throw_if_contains_symlink` is the
|
||||
# only line of defence for the leaf segment.
|
||||
# ``O_NOFOLLOW`` is POSIX-only; on Windows the lstat/reparse-point
|
||||
# detection in :meth:`_throw_if_contains_symlink` is the only line of
|
||||
# defence for the leaf segment.
|
||||
nofollow = getattr(os, "O_NOFOLLOW", 0)
|
||||
flags |= nofollow
|
||||
try:
|
||||
@@ -985,7 +1001,12 @@ class FileSystemAgentFileStore(AgentFileStore):
|
||||
directories: list[FileStoreEntry] = []
|
||||
files: list[FileStoreEntry] = []
|
||||
for entry in full_dir.iterdir():
|
||||
if entry.is_symlink():
|
||||
try:
|
||||
is_link = _is_link_or_reparse_point(entry)
|
||||
except OSError:
|
||||
# Fail closed when an entry cannot be inspected.
|
||||
continue
|
||||
if is_link:
|
||||
continue
|
||||
if entry.is_dir():
|
||||
directories.append(FileStoreEntry(entry.name, FileStoreEntry.DIRECTORY))
|
||||
@@ -1039,7 +1060,12 @@ class FileSystemAgentFileStore(AgentFileStore):
|
||||
while directories:
|
||||
current = directories.pop()
|
||||
for entry in current.iterdir():
|
||||
if entry.is_symlink():
|
||||
try:
|
||||
is_link = _is_link_or_reparse_point(entry)
|
||||
except OSError:
|
||||
# Fail closed when an entry cannot be inspected.
|
||||
continue
|
||||
if is_link:
|
||||
continue
|
||||
if entry.is_dir():
|
||||
if recursive:
|
||||
|
||||
@@ -1445,7 +1445,7 @@ class AgentMiddlewareLayer:
|
||||
|
||||
|
||||
def _determine_middleware_type(middleware: Any) -> MiddlewareType:
|
||||
"""Determine middleware type using decorator and/or parameter type annotation.
|
||||
"""Determine the middleware type from function annotations or decorators.
|
||||
|
||||
Args:
|
||||
middleware: The middleware function to analyze.
|
||||
@@ -1456,6 +1456,8 @@ def _determine_middleware_type(middleware: Any) -> MiddlewareType:
|
||||
Raises:
|
||||
MiddlewareException: When middleware type cannot be determined or there's a mismatch.
|
||||
"""
|
||||
middleware_name = getattr(middleware, "__name__", type(middleware).__name__)
|
||||
|
||||
# Check for decorator marker
|
||||
decorator_type: MiddlewareType | None = getattr(middleware, "_middleware_type", None)
|
||||
|
||||
@@ -1480,7 +1482,7 @@ def _determine_middleware_type(middleware: Any) -> MiddlewareType:
|
||||
# Not enough parameters - can't be valid middleware
|
||||
raise MiddlewareException(
|
||||
f"Middleware function must have at least 2 parameters (context, call_next), "
|
||||
f"but {middleware.__name__} has {len(params)}"
|
||||
f"but {middleware_name} has {len(params)}"
|
||||
)
|
||||
except Exception as e:
|
||||
if isinstance(e, MiddlewareException):
|
||||
@@ -1493,7 +1495,7 @@ def _determine_middleware_type(middleware: Any) -> MiddlewareType:
|
||||
if decorator_type != param_type:
|
||||
raise MiddlewareException(
|
||||
f"MiddlewareTypes type mismatch: decorator indicates '{decorator_type.value}' "
|
||||
f"but parameter type indicates '{param_type.value}' for function {middleware.__name__}"
|
||||
f"but parameter type indicates '{param_type.value}' for function {middleware_name}"
|
||||
)
|
||||
return decorator_type
|
||||
|
||||
@@ -1507,7 +1509,7 @@ def _determine_middleware_type(middleware: Any) -> MiddlewareType:
|
||||
|
||||
# Neither decorator nor parameter type specified - throw exception
|
||||
raise MiddlewareException(
|
||||
f"Cannot determine middleware type for function {middleware.__name__}. "
|
||||
f"Cannot determine middleware type for function {middleware_name}. "
|
||||
f"Please either use @agent_middleware/@function_middleware/@chat_middleware decorators "
|
||||
f"or specify parameter types (AgentContext, FunctionInvocationContext, or ChatContext)."
|
||||
)
|
||||
|
||||
@@ -2096,6 +2096,12 @@ def _collect_approval_responses(
|
||||
return fcc_todo
|
||||
|
||||
|
||||
def _is_approval_placeholder_result(content: Content) -> bool:
|
||||
"""Whether a function_result is the stand-in emitted while approval is pending."""
|
||||
result = getattr(content, "result", None)
|
||||
return isinstance(result, str) and "[APPROVAL_PENDING]" in result
|
||||
|
||||
|
||||
def _replace_approval_contents_with_results(
|
||||
messages: list[Message],
|
||||
fcc_todo: dict[str, Content],
|
||||
@@ -2119,12 +2125,30 @@ def _replace_approval_contents_with_results(
|
||||
# Track which call_ids had their placeholders replaced
|
||||
placeholders_replaced: set[str] = set()
|
||||
|
||||
for msg in messages:
|
||||
# First pass - collect existing function call IDs to avoid duplicates
|
||||
existing_call_ids = {
|
||||
content.call_id for content in msg.contents if content.type == "function_call" and content.call_id
|
||||
}
|
||||
# Collect *pending* function call IDs across all messages to avoid duplicates. The
|
||||
# function call and its approval request are frequently carried in separate messages
|
||||
# (e.g. when a hosting layer replays them as separate items on an approval round trip),
|
||||
# so scoping this per-message would let the same call_id be restored twice and leave
|
||||
# the copy without a result unanswered.
|
||||
#
|
||||
# Calls that already carry a real result are excluded: reusing a call_id for a later
|
||||
# invocation is supported, and a completed pair must not suppress the fresh request —
|
||||
# that would drop the new call and attach its result to the old one. Placeholder
|
||||
# results still count as pending, since the call they answer is the one being restored.
|
||||
answered_call_ids = {
|
||||
content.call_id
|
||||
for msg in messages
|
||||
for content in msg.contents
|
||||
if content.type == "function_result" and content.call_id and not _is_approval_placeholder_result(content)
|
||||
}
|
||||
existing_call_ids = {
|
||||
content.call_id
|
||||
for msg in messages
|
||||
for content in msg.contents
|
||||
if content.type == "function_call" and content.call_id and content.call_id not in answered_call_ids
|
||||
}
|
||||
|
||||
for msg in messages:
|
||||
# Track approval requests that should be removed (duplicates)
|
||||
contents_to_remove: list[int] = []
|
||||
|
||||
@@ -2140,6 +2164,8 @@ def _replace_approval_contents_with_results(
|
||||
elif content.function_call is not None:
|
||||
# Put back the function call content only if it doesn't exist
|
||||
msg.contents[content_idx] = content.function_call
|
||||
if content.function_call.call_id:
|
||||
existing_call_ids.add(content.function_call.call_id)
|
||||
elif content.type == "function_approval_response":
|
||||
# Skip hosted tool approvals — they must pass through to the API unchanged
|
||||
if _is_hosted_tool_approval(content):
|
||||
@@ -2169,12 +2195,7 @@ def _replace_approval_contents_with_results(
|
||||
msg.role = "tool"
|
||||
elif content.type == "function_result":
|
||||
# Check if this is a placeholder result that should be replaced
|
||||
if (
|
||||
hasattr(content, "result")
|
||||
and isinstance(content.result, str)
|
||||
and "[APPROVAL_PENDING]" in content.result
|
||||
and content.call_id in result_by_call_id
|
||||
):
|
||||
if _is_approval_placeholder_result(content) and content.call_id in result_by_call_id:
|
||||
# Replace placeholder with actual result
|
||||
msg.contents[content_idx] = result_by_call_id[content.call_id]
|
||||
placeholders_replaced.add(content.call_id)
|
||||
|
||||
@@ -811,6 +811,24 @@ class FunctionalWorkflow:
|
||||
execution is not allowed).
|
||||
"""
|
||||
self._validate_run_params(message, responses, checkpoint_id)
|
||||
# Warn (but don't block) when a fresh message or a checkpoint restore begins while a prior
|
||||
# run left request_info events pending. Mirrors Workflow.run. Delivering responses is the
|
||||
# normal way to complete the pending cycle and is intentionally not warned.
|
||||
if (message is not None or checkpoint_id is not None) and self._last_pending_request_ids:
|
||||
logger.warning(
|
||||
"Workflow %s received %s while %d request_info event(s) are still pending from an "
|
||||
"unfinished request/response cycle; %s. Deliver responses (responses=...) to complete "
|
||||
"the pending cycle before starting new input.",
|
||||
self.name,
|
||||
"a fresh message" if message is not None else "a checkpoint restore",
|
||||
len(self._last_pending_request_ids),
|
||||
(
|
||||
"those requests remain answerable, but this run advances workflow state, so a "
|
||||
"response that arrives later may apply to a workflow that has moved on"
|
||||
if message is not None
|
||||
else "those pending requests will be overwritten by the checkpoint's state"
|
||||
),
|
||||
)
|
||||
if responses and checkpoint_id is None:
|
||||
# Require at least one response key to match a currently-pending
|
||||
# request; prevents silent replay against stale state while still
|
||||
|
||||
@@ -245,7 +245,11 @@ class RunnerImpl:
|
||||
self._state.commit()
|
||||
|
||||
async def create_checkpoint_if_enabled(self) -> None:
|
||||
"""Create a checkpoint if checkpointing is enabled and attach a label and metadata."""
|
||||
"""Create a checkpoint and save the checkpoint to the configured storage if one is configured.
|
||||
|
||||
Note:
|
||||
1. This method has no effect if checkpointing is not enabled in the context.
|
||||
"""
|
||||
if not self._ctx.has_checkpointing():
|
||||
return
|
||||
|
||||
@@ -340,6 +344,57 @@ class RunnerImpl:
|
||||
logger.error(f"Failed to restore from checkpoint {checkpoint_id}: {e}")
|
||||
raise WorkflowCheckpointException(f"Failed to restore from checkpoint {checkpoint_id}") from e
|
||||
|
||||
async def build_checkpoint(self) -> WorkflowCheckpoint:
|
||||
"""Create a checkpoint object.
|
||||
|
||||
Returns:
|
||||
A ``WorkflowCheckpoint``.
|
||||
"""
|
||||
# Persist executor snapshots into committed shared state before exporting it.
|
||||
await self._prepare_checkpoint_state()
|
||||
return await self._ctx.build_checkpoint(
|
||||
self._workflow_name,
|
||||
self._graph_signature_hash,
|
||||
self._state,
|
||||
None,
|
||||
self._iteration,
|
||||
)
|
||||
|
||||
async def restore_checkpoint(self, checkpoint: WorkflowCheckpoint) -> None:
|
||||
"""Restore runner state from an in-memory ``WorkflowCheckpoint`` object.
|
||||
|
||||
Unlike :meth:`restore_from_checkpoint`, this does not load from a storage
|
||||
backend; it applies a checkpoint the caller already holds - for example, a
|
||||
child workflow checkpoint embedded in a parent ``WorkflowExecutor``'s state.
|
||||
|
||||
Restores shared state, executor snapshots, in-flight messages, and pending
|
||||
request_info events, then marks the runner as resumed.
|
||||
|
||||
Args:
|
||||
checkpoint: The checkpoint whose state should be restored.
|
||||
|
||||
Raises:
|
||||
WorkflowCheckpointException: If the checkpoint's graph signature does not
|
||||
match this runner's workflow, or if restoration otherwise fails.
|
||||
"""
|
||||
if self._graph_signature_hash != checkpoint.graph_signature_hash:
|
||||
raise WorkflowCheckpointException(
|
||||
"Workflow graph has changed since the checkpoint was created. "
|
||||
"Please rebuild the original workflow before resuming."
|
||||
)
|
||||
|
||||
try:
|
||||
# Clear first so import_state (which merges) does not leak stale keys from a
|
||||
# prior run on this Workflow instance.
|
||||
self._state.clear()
|
||||
self._state.import_state(checkpoint.state)
|
||||
await self._restore_executor_states()
|
||||
await self._ctx.apply_checkpoint(checkpoint)
|
||||
self._mark_resumed(checkpoint)
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to restore from checkpoint {checkpoint.checkpoint_id}: {e}")
|
||||
raise WorkflowCheckpointException(f"Failed to restore from checkpoint {checkpoint.checkpoint_id}") from e
|
||||
|
||||
async def _save_executor_states(self) -> None:
|
||||
"""Populate executor state by calling checkpoint hooks on executors."""
|
||||
for exec_id, executor in self._executors.items():
|
||||
|
||||
@@ -195,6 +195,34 @@ class RunnerContext(Protocol):
|
||||
"""
|
||||
...
|
||||
|
||||
async def build_checkpoint(
|
||||
self,
|
||||
workflow_name: str,
|
||||
graph_signature_hash: str,
|
||||
state: State,
|
||||
previous_checkpoint_id: CheckpointID | None,
|
||||
iteration_count: int,
|
||||
metadata: dict[str, Any] | None = None,
|
||||
) -> WorkflowCheckpoint:
|
||||
"""Build a checkpoint and return it for the caller to own.
|
||||
|
||||
The checkpoint is constructed in memory and handed back to the caller; nothing is
|
||||
persisted and no checkpoint storage is required.
|
||||
|
||||
Args:
|
||||
workflow_name: The name of the workflow for which the checkpoint is being created.
|
||||
graph_signature_hash: Hash of the workflow graph topology to
|
||||
validate checkpoint compatibility during restore.
|
||||
state: The state to include in the checkpoint.
|
||||
previous_checkpoint_id: The ID of the previous checkpoint, if any, to form a checkpoint chain.
|
||||
iteration_count: The current iteration count of the workflow.
|
||||
metadata: Optional metadata to associate with the checkpoint.
|
||||
|
||||
Returns:
|
||||
A ``WorkflowCheckpoint`` of the current context state.
|
||||
"""
|
||||
...
|
||||
|
||||
async def create_checkpoint(
|
||||
self,
|
||||
workflow_name: str,
|
||||
@@ -204,7 +232,7 @@ class RunnerContext(Protocol):
|
||||
iteration_count: int,
|
||||
metadata: dict[str, Any] | None = None,
|
||||
) -> CheckpointID:
|
||||
"""Create a checkpoint of the current workflow state.
|
||||
"""Persist a checkpoint of the current workflow state to configured storage and return its ID.
|
||||
|
||||
Args:
|
||||
workflow_name: The name of the workflow for which the checkpoint is being created.
|
||||
@@ -219,6 +247,9 @@ class RunnerContext(Protocol):
|
||||
|
||||
Returns:
|
||||
The ID of the created checkpoint.
|
||||
|
||||
Raises:
|
||||
ValueError: If checkpoint storage is not configured.
|
||||
"""
|
||||
...
|
||||
|
||||
@@ -381,6 +412,27 @@ class InProcRunnerContext:
|
||||
def has_checkpointing(self) -> bool:
|
||||
return self._get_effective_checkpoint_storage() is not None
|
||||
|
||||
async def build_checkpoint(
|
||||
self,
|
||||
workflow_name: str,
|
||||
graph_signature_hash: str,
|
||||
state: State,
|
||||
previous_checkpoint_id: CheckpointID | None,
|
||||
iteration_count: int,
|
||||
metadata: dict[str, Any] | None = None,
|
||||
) -> WorkflowCheckpoint:
|
||||
return WorkflowCheckpoint(
|
||||
workflow_name=workflow_name,
|
||||
graph_signature_hash=graph_signature_hash,
|
||||
previous_checkpoint_id=previous_checkpoint_id,
|
||||
# Copy the per-source lists so the snapshot is isolated from later context mutations.
|
||||
messages={source_id: list(messages) for source_id, messages in self._messages.items()},
|
||||
state=state.export_state(),
|
||||
pending_request_info_events=dict(self._pending_request_info_events),
|
||||
iteration_count=iteration_count,
|
||||
metadata=metadata or {},
|
||||
)
|
||||
|
||||
async def create_checkpoint(
|
||||
self,
|
||||
workflow_name: str,
|
||||
@@ -394,15 +446,13 @@ class InProcRunnerContext:
|
||||
if not storage:
|
||||
raise ValueError("Checkpoint storage not configured")
|
||||
|
||||
checkpoint = WorkflowCheckpoint(
|
||||
workflow_name=workflow_name,
|
||||
graph_signature_hash=graph_signature_hash,
|
||||
previous_checkpoint_id=previous_checkpoint_id,
|
||||
messages=dict(self._messages),
|
||||
state=state.export_state(),
|
||||
pending_request_info_events=dict(self._pending_request_info_events),
|
||||
iteration_count=iteration_count,
|
||||
metadata=metadata or {},
|
||||
checkpoint = await self.build_checkpoint(
|
||||
workflow_name,
|
||||
graph_signature_hash,
|
||||
state,
|
||||
previous_checkpoint_id,
|
||||
iteration_count,
|
||||
metadata,
|
||||
)
|
||||
checkpoint_id = await storage.save(checkpoint)
|
||||
logger.debug(f"Created checkpoint {checkpoint_id}")
|
||||
|
||||
@@ -814,10 +814,9 @@ class Workflow(DictConvertible):
|
||||
# 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.)
|
||||
# prior run to drain. Pending request_info events are intentionally
|
||||
# NOT blocked here (they are answered via a follow-up ``responses=...``
|
||||
# run); the warning below surfaces the abandon/overwrite cases instead.
|
||||
# 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.
|
||||
@@ -830,6 +829,33 @@ class Workflow(DictConvertible):
|
||||
"checkpointing; there is no in-process recovery path."
|
||||
)
|
||||
|
||||
# Warn (but don't block) when a fresh message or a checkpoint restore begins while the
|
||||
# workflow still has pending request_info events from an unfinished request/response
|
||||
# cycle. A fresh ``message`` does NOT drop those pending requests - they remain pending and
|
||||
# can still be answered later - but the new run advances executor and shared state, so when
|
||||
# a response for an earlier request eventually arrives the workflow may have moved on,
|
||||
# yielding inconsistent results. A ``checkpoint_id`` restore instead replaces the context's
|
||||
# pending requests with the checkpoint's state. Delivering ``responses`` is the normal way to
|
||||
# answer pending requests and is intentionally not warned. Mirrors the WorkflowExecutor
|
||||
# warning for overlapping sub-workflow executions.
|
||||
if message is not None or checkpoint_id is not None:
|
||||
pending_request_info_events = await self._runner.context.get_pending_request_info_events()
|
||||
if pending_request_info_events:
|
||||
logger.warning(
|
||||
"Workflow %s received %s while %d request_info event(s) are still pending from an "
|
||||
"unfinished request/response cycle; %s. Deliver responses (responses=...) to complete "
|
||||
"the pending cycle before starting new input.",
|
||||
self.id,
|
||||
"a fresh message" if message is not None else "a checkpoint restore",
|
||||
len(pending_request_info_events),
|
||||
(
|
||||
"those requests remain pending, but this run advances executor and shared state, "
|
||||
"so a response that arrives later may apply to a workflow that has moved on"
|
||||
if message is not None
|
||||
else "those pending requests will be overwritten by the checkpoint's state"
|
||||
),
|
||||
)
|
||||
|
||||
initial_executor_fn = self._resolve_execution_mode(message, responses, checkpoint_id, checkpoint_storage)
|
||||
|
||||
async for event in self._run_workflow_with_tracing(
|
||||
|
||||
@@ -4,14 +4,12 @@ import asyncio
|
||||
import logging
|
||||
import sys
|
||||
import types
|
||||
import uuid
|
||||
from dataclasses import dataclass
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from ._workflow import Workflow
|
||||
|
||||
from ._checkpoint_encoding import decode_checkpoint_value
|
||||
from ._const import GLOBAL_KWARGS_KEY, WORKFLOW_RUN_KWARGS_KEY
|
||||
from ._events import (
|
||||
WorkflowEvent,
|
||||
@@ -36,7 +34,12 @@ logger = logging.getLogger(__name__)
|
||||
|
||||
@dataclass
|
||||
class ExecutionContext:
|
||||
"""Context for tracking a single sub-workflow execution."""
|
||||
"""Legacy per-execution bookkeeping.
|
||||
|
||||
Retained only to decode checkpoints written before the sub-workflow's own checkpoint was
|
||||
embedded (see ``WorkflowExecutor.on_checkpoint_restore``). It is no longer used at runtime -
|
||||
the wrapped sub-workflow is the single source of truth for its pending requests.
|
||||
"""
|
||||
|
||||
# The ID of the execution context
|
||||
execution_id: str
|
||||
@@ -161,11 +164,9 @@ class WorkflowExecutor(Executor):
|
||||
# The response handler expects a SubWorkflowResponseMessage wrapping the response data.
|
||||
|
||||
### State Management
|
||||
WorkflowExecutor maintains execution state across request/response cycles:
|
||||
- Tracks pending requests by request_id
|
||||
- Accumulates responses until all expected responses are received
|
||||
- Resumes sub-workflow execution with complete response batch
|
||||
- Handles concurrent executions and multiple pending requests
|
||||
WorkflowExecutor keeps no request/response bookkeeping of its own. The wrapped sub-workflow
|
||||
is the single source of truth for its pending requests; responses are forwarded to it and
|
||||
validated against its own pending request_info events.
|
||||
|
||||
## Type System Integration
|
||||
WorkflowExecutor inherits its type signature from the wrapped workflow:
|
||||
@@ -194,46 +195,21 @@ class WorkflowExecutor(Executor):
|
||||
- Converts to error event in parent context
|
||||
- Provides detailed error information including sub-workflow ID
|
||||
|
||||
## Concurrent Execution Support
|
||||
WorkflowExecutor fully supports multiple concurrent sub-workflow executions:
|
||||
|
||||
### Per-Execution State Isolation
|
||||
Each sub-workflow invocation creates an isolated ExecutionContext:
|
||||
## Overlapping Executions
|
||||
A ``WorkflowExecutor`` wraps a single shared sub-workflow instance and keeps no per-execution
|
||||
state. If a new input arrives while the sub-workflow still has pending request_info events from
|
||||
an unfinished request/response cycle, the new input advances the shared sub-workflow state and
|
||||
can interfere with that cycle - a response arriving later may apply to a sub-workflow that has
|
||||
moved on. This is allowed but logs a warning, and is only safe when the wrapped workflow (and
|
||||
its executors) are stateless.
|
||||
|
||||
.. code-block:: python
|
||||
|
||||
# Multiple concurrent invocations are supported
|
||||
workflow_executor = WorkflowExecutor(my_workflow, id="concurrent_executor")
|
||||
|
||||
# Each invocation gets its own execution context
|
||||
# Execution 1: processes input_1 independently
|
||||
# Execution 2: processes input_2 independently
|
||||
# No state interference between executions
|
||||
|
||||
### Request/Response Coordination
|
||||
Responses are correctly routed to the originating execution:
|
||||
- Each execution tracks its own pending requests and expected responses
|
||||
- Request-to-execution mapping ensures responses reach the correct sub-workflow
|
||||
- Response accumulation is isolated per execution
|
||||
- Automatic cleanup when execution completes
|
||||
|
||||
### Memory Management
|
||||
- Unlimited concurrent executions supported
|
||||
- Each execution has unique UUID-based identification
|
||||
- Cleanup of completed execution contexts
|
||||
- Thread-safe state management for concurrent access
|
||||
|
||||
### Important Considerations
|
||||
**Shared Workflow Instance**: All concurrent executions use the same underlying workflow instance.
|
||||
For proper isolation, ensure that the wrapped workflow and its executors are stateless.
|
||||
|
||||
.. code-block:: python
|
||||
|
||||
# Avoid: Stateful executor with instance variables
|
||||
# Avoid: stateful executor whose instance variables are shared across overlapping runs
|
||||
class StatefulExecutor(Executor):
|
||||
def __init__(self):
|
||||
super().__init__(id="stateful")
|
||||
self.data = [] # This will be shared across concurrent executions!
|
||||
self.data = [] # Shared across overlapping sub-workflow executions!
|
||||
|
||||
## Integration with Parent Workflows
|
||||
Parent workflows can intercept sub-workflow requests:
|
||||
@@ -255,12 +231,18 @@ class WorkflowExecutor(Executor):
|
||||
# Forward to external handler
|
||||
await ctx.request_info(request.source_event, response_type=request.source_event.response_type)
|
||||
|
||||
## Checkpointing
|
||||
The provided sub workflow may not have its own checkpoint storage. The sub workflow checkpointed states will
|
||||
be managed by the parent workflow.
|
||||
|
||||
## Implementation Notes
|
||||
- Sub-workflows run to completion before processing their results
|
||||
- Event processing is atomic - all outputs are forwarded before requests
|
||||
- Response accumulation ensures sub-workflows receive complete response batches
|
||||
- Execution state is maintained for proper resumption after external requests
|
||||
- Concurrent executions are fully isolated and do not interfere with each other
|
||||
- Sub-workflows run to completion (or to idle-with-pending-requests) before their results are processed
|
||||
- Event processing is ordered - outputs are forwarded before requests
|
||||
- Responses are forwarded to the sub-workflow as they arrive; the sub-workflow tracks its own
|
||||
pending requests and resumes when they are answered
|
||||
- The WorkflowExecutor keeps no per-execution bookkeeping; the sub-workflow is the single source
|
||||
of truth for its pending requests. Starting a new execution while the sub-workflow still has
|
||||
pending requests logs a warning and is only safe when the wrapped workflow is stateless
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
@@ -274,19 +256,18 @@ class WorkflowExecutor(Executor):
|
||||
"""Initialize the WorkflowExecutor.
|
||||
|
||||
Args:
|
||||
workflow: The workflow to execute as a sub-workflow.
|
||||
workflow: The workflow to execute as a sub-workflow. This workflow instance (including
|
||||
the executor instances within it) must be unique. If the same instances are shared
|
||||
across multiple WorkflowExecutor instances, it may lead to incorrect behavior.
|
||||
id: Unique identifier for this executor.
|
||||
allow_direct_output: Whether to allow direct output from the sub-workflow.
|
||||
By default, outputs from the sub-workflow are sent to
|
||||
other executors in the parent workflow as messages.
|
||||
When this is set to true, the outputs are yielded
|
||||
directly from the WorkflowExecutor to the parent
|
||||
workflow's event stream.
|
||||
propagate_request: Whether to propagate requests from the sub-workflow to the
|
||||
parent workflow. If set to true, requests from the sub-workflow
|
||||
will be propagated as the original WorkflowEvent to the parent
|
||||
workflow. Otherwise, they will be wrapped in a SubWorkflowRequestMessage,
|
||||
which should be handled by an executor in the parent workflow.
|
||||
allow_direct_output: Whether to allow direct output from the sub-workflow. By default,
|
||||
outputs from the sub-workflow are sent to other executors in the parent workflow as
|
||||
messages. When this is set to true, the outputs are yielded directly from the
|
||||
WorkflowExecutor to the parent workflow's event stream.
|
||||
propagate_request: Whether to propagate requests from the sub-workflow to the parent
|
||||
workflow. If set to true, requests from the sub-workflow will be propagated as the
|
||||
original WorkflowEvent to the parent workflow. Otherwise, they will be wrapped in a
|
||||
SubWorkflowRequestMessage, which should be handled by an executor in the parent workflow.
|
||||
|
||||
Keyword Args:
|
||||
**kwargs: Additional keyword arguments passed to the parent constructor.
|
||||
@@ -294,13 +275,17 @@ class WorkflowExecutor(Executor):
|
||||
super().__init__(id, **kwargs)
|
||||
self.workflow = workflow
|
||||
self.allow_direct_output = allow_direct_output
|
||||
|
||||
# Track execution contexts for concurrent sub-workflow executions
|
||||
self._execution_contexts: dict[str, ExecutionContext] = {} # execution_id -> ExecutionContext
|
||||
# Map request_id to execution_id for response routing
|
||||
self._request_to_execution: dict[str, str] = {} # request_id -> execution_id
|
||||
self._propagate_request = propagate_request
|
||||
|
||||
if self.workflow._runner_context.has_checkpointing(): # type: ignore
|
||||
logger.warning(
|
||||
"Sub workflow %s has its own checkpoint storage configured. "
|
||||
"Sub workflow states are checkpointed by the parent workflow at superstep boundaries. "
|
||||
"Additional checkpointing is only needed if you need to persist sub workflow state "
|
||||
"independently of the parent workflow. ",
|
||||
self.workflow.id,
|
||||
)
|
||||
|
||||
@property
|
||||
def input_types(self) -> list[type[Any] | types.UnionType]:
|
||||
"""Get the input types based on the underlying workflow's input types plus WorkflowExecutor-specific types.
|
||||
@@ -351,11 +336,10 @@ class WorkflowExecutor(Executor):
|
||||
# Always handle SubWorkflowResponseMessage
|
||||
return True
|
||||
|
||||
if (
|
||||
message.original_request_info_event is not None
|
||||
and message.original_request_info_event.request_id in self._request_to_execution
|
||||
):
|
||||
# Handle propagated responses for known requests
|
||||
if message.original_request_info_event is not None:
|
||||
# A propagated response is target-routed back to the executor that issued the request,
|
||||
# so if one reaches this WorkflowExecutor it belongs to our sub-workflow. _handle_response
|
||||
# validates it against the sub-workflow's pending requests and ignores anything unknown.
|
||||
return True
|
||||
|
||||
# For other messages, only handle if the wrapped workflow can accept them as input
|
||||
@@ -372,58 +356,52 @@ class WorkflowExecutor(Executor):
|
||||
input_data: The input data to send to the sub-workflow.
|
||||
ctx: The workflow context from the parent.
|
||||
"""
|
||||
# Create execution context for this sub-workflow run
|
||||
execution_id = str(uuid.uuid4())
|
||||
execution_context = ExecutionContext(
|
||||
execution_id=execution_id,
|
||||
collected_responses={},
|
||||
expected_response_count=0,
|
||||
pending_requests={},
|
||||
# The sub-workflow is a single shared instance. If it still has pending request_info events
|
||||
# from an unfinished request/response cycle, a new input advances its shared state and can
|
||||
# interfere with that cycle - a response arriving later may apply to a sub-workflow that has
|
||||
# moved on. We allow it (the sub-workflow may be stateless) but warn so the risk is visible.
|
||||
pending_requests = await self.workflow._runner_context.get_pending_request_info_events() # pyright: ignore[reportPrivateUsage]
|
||||
if pending_requests:
|
||||
logger.warning(
|
||||
f"WorkflowExecutor {self.id} received a new input message while its sub-workflow "
|
||||
f"({self.workflow.id}) still has {len(pending_requests)} pending request(s) from an "
|
||||
f"unfinished request/response cycle. The sub-workflow is a single shared instance, so the "
|
||||
f"new input advances shared state and can interfere with the in-flight cycle. Ensure the "
|
||||
f"sub-workflow is stateless, or complete the pending cycle before sending new input."
|
||||
)
|
||||
|
||||
logger.debug(f"WorkflowExecutor {self.id} starting sub-workflow {self.workflow.id}")
|
||||
|
||||
# Get kwargs from parent workflow's State to propagate to subworkflow
|
||||
parent_kwargs: dict[str, Any] = ctx.get_state(WORKFLOW_RUN_KWARGS_KEY, {})
|
||||
|
||||
# Extract invocation kwargs recognised by Workflow.run()
|
||||
# The state stores resolved format (with __global__ wrapper for global kwargs).
|
||||
# Unwrap __global__ before passing to the subworkflow so it gets re-resolved
|
||||
# against the subworkflow's own executor IDs.
|
||||
fi_kwargs: dict[str, Any] | None = None
|
||||
ci_kwargs: dict[str, Any] | None = None
|
||||
for key in ("function_invocation_kwargs", "client_kwargs"):
|
||||
resolved = parent_kwargs.get(key)
|
||||
if isinstance(resolved, dict):
|
||||
# Unwrap global sentinel; pass per-executor dicts as-is
|
||||
unwrapped: dict[str, Any] = resolved.get(GLOBAL_KWARGS_KEY, resolved) # type: ignore
|
||||
if key == "function_invocation_kwargs":
|
||||
fi_kwargs = unwrapped # type: ignore
|
||||
else:
|
||||
ci_kwargs = unwrapped # type: ignore
|
||||
|
||||
# Run the sub-workflow and collect all events, passing parent kwargs
|
||||
result = await self.workflow.run(
|
||||
input_data,
|
||||
function_invocation_kwargs=fi_kwargs, # type: ignore
|
||||
client_kwargs=ci_kwargs, # type: ignore
|
||||
)
|
||||
self._execution_contexts[execution_id] = execution_context
|
||||
|
||||
logger.debug(f"WorkflowExecutor {self.id} starting sub-workflow {self.workflow.id} execution {execution_id}")
|
||||
logger.debug(f"WorkflowExecutor {self.id} sub-workflow {self.workflow.id} completed with {len(result)} events")
|
||||
|
||||
try:
|
||||
# Get kwargs from parent workflow's State to propagate to subworkflow
|
||||
parent_kwargs: dict[str, Any] = ctx.get_state(WORKFLOW_RUN_KWARGS_KEY, {})
|
||||
|
||||
# Extract invocation kwargs recognised by Workflow.run()
|
||||
# The state stores resolved format (with __global__ wrapper for global kwargs).
|
||||
# Unwrap __global__ before passing to the subworkflow so it gets re-resolved
|
||||
# against the subworkflow's own executor IDs.
|
||||
fi_kwargs: dict[str, Any] | None = None
|
||||
ci_kwargs: dict[str, Any] | None = None
|
||||
for key in ("function_invocation_kwargs", "client_kwargs"):
|
||||
resolved = parent_kwargs.get(key)
|
||||
if isinstance(resolved, dict):
|
||||
# Unwrap global sentinel; pass per-executor dicts as-is
|
||||
unwrapped: dict[str, Any] = resolved.get(GLOBAL_KWARGS_KEY, resolved) # type: ignore
|
||||
if key == "function_invocation_kwargs":
|
||||
fi_kwargs = unwrapped # type: ignore
|
||||
else:
|
||||
ci_kwargs = unwrapped # type: ignore
|
||||
|
||||
# Run the sub-workflow and collect all events, passing parent kwargs
|
||||
result = await self.workflow.run(
|
||||
input_data,
|
||||
function_invocation_kwargs=fi_kwargs, # type: ignore
|
||||
client_kwargs=ci_kwargs, # type: ignore
|
||||
)
|
||||
|
||||
logger.debug(
|
||||
f"WorkflowExecutor {self.id} sub-workflow {self.workflow.id} "
|
||||
f"execution {execution_id} completed with {len(result)} events"
|
||||
)
|
||||
|
||||
# Process the workflow result using shared logic
|
||||
await self._process_workflow_result(result, execution_context, ctx)
|
||||
finally:
|
||||
# Clean up execution context if it's completed (no pending requests)
|
||||
if execution_id in self._execution_contexts:
|
||||
exec_ctx = self._execution_contexts[execution_id]
|
||||
if not exec_ctx.pending_requests:
|
||||
del self._execution_contexts[execution_id]
|
||||
# Process the workflow result using shared logic
|
||||
await self._process_workflow_result(result, ctx)
|
||||
|
||||
@handler
|
||||
async def handle_message_wrapped_request_response(
|
||||
@@ -433,8 +411,8 @@ class WorkflowExecutor(Executor):
|
||||
) -> None:
|
||||
"""Handle response from parent for a forwarded request.
|
||||
|
||||
This handler accumulates responses and only resumes the sub-workflow
|
||||
when all expected responses have been received for that execution.
|
||||
Forwards the response to the sub-workflow, which resumes and validates it against its
|
||||
own pending requests.
|
||||
|
||||
Args:
|
||||
response: The response to a previous request.
|
||||
@@ -474,61 +452,44 @@ class WorkflowExecutor(Executor):
|
||||
async def on_checkpoint_save(self) -> dict[str, Any]:
|
||||
"""Get the current state of the WorkflowExecutor for checkpointing purposes."""
|
||||
return {
|
||||
"execution_contexts": {
|
||||
execution_id: execution_context for execution_id, execution_context in self._execution_contexts.items()
|
||||
},
|
||||
"request_to_execution": dict(self._request_to_execution),
|
||||
# The sub-workflow's own checkpoint carries everything needed to resume: shared state,
|
||||
# executor snapshots, in-flight messages, and pending request_info events. The
|
||||
# WorkflowExecutor keeps no separate request/response bookkeeping of its own. The
|
||||
# sub-workflow is quiescent here: it ran to idle within this parent superstep before
|
||||
# the parent checkpoints.
|
||||
"sub_workflow_checkpoint": await self.workflow._runner.build_checkpoint(), # pyright: ignore[reportPrivateUsage]
|
||||
}
|
||||
|
||||
@override
|
||||
async def on_checkpoint_restore(self, state: dict[str, Any]) -> None:
|
||||
"""Restore the WorkflowExecutor state from a checkpoint snapshot."""
|
||||
# Validate the state contains the right keys
|
||||
if "execution_contexts" not in state:
|
||||
raise KeyError("Missing 'execution_contexts' in WorkflowExecutor state.")
|
||||
if "request_to_execution" not in state:
|
||||
raise KeyError("Missing 'request_to_execution' in WorkflowExecutor state.")
|
||||
# The storage backend fully materializes the checkpoint on load, checkpointed data arrives as live objects.
|
||||
sub_workflow_checkpoint = state.get("sub_workflow_checkpoint")
|
||||
if sub_workflow_checkpoint is not None:
|
||||
await self.workflow._runner.restore_checkpoint(sub_workflow_checkpoint) # pyright: ignore[reportPrivateUsage]
|
||||
return
|
||||
|
||||
# Validate the execution contexts stored in the state have the right keys and values
|
||||
execution_contexts: dict[str, ExecutionContext] | None = None
|
||||
try:
|
||||
execution_contexts = {
|
||||
key: decode_checkpoint_value(value) for key, value in state["execution_contexts"].items()
|
||||
}
|
||||
except Exception as ex:
|
||||
raise RuntimeError("Failed to deserialize execution context.") from ex
|
||||
|
||||
if not all(
|
||||
isinstance(key, str) and isinstance(value, ExecutionContext) for key, value in execution_contexts.items()
|
||||
):
|
||||
raise ValueError("Execution contexts must have 'str' as key and 'ExecutionContext' as value.")
|
||||
if not all(key == value.execution_id for key, value in execution_contexts.items()):
|
||||
raise ValueError("Execution contexts must have matching keys and IDs.")
|
||||
|
||||
# Validate the request_to_execution map contain the right data
|
||||
request_to_execution = state["request_to_execution"]
|
||||
if not all(isinstance(key, str) and isinstance(value, str) for key, value in request_to_execution.items()):
|
||||
raise ValueError("Request to execution map must have 'str' as key and 'str' as value.")
|
||||
if not all(value in execution_contexts for value in request_to_execution.values()):
|
||||
raise ValueError(
|
||||
"'request_to_execution` contains unknown execution ID that is not part of the execution contexts."
|
||||
)
|
||||
|
||||
self._execution_contexts = execution_contexts
|
||||
self._request_to_execution = request_to_execution
|
||||
|
||||
# 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
|
||||
# API instead of the '_runner_context' object that should be hidden. And the sub workflow
|
||||
# should be rehydrated from a checkpoint object instead of from a subset of the state.
|
||||
# TODO(@taochen): Issue #1614 - how to handle the case when the parent workflow has checkpointing
|
||||
# set up but not the sub workflow?
|
||||
request_info_events = [
|
||||
request_info_event
|
||||
for execution_context in self._execution_contexts.values()
|
||||
for request_info_event in execution_context.pending_requests.values()
|
||||
]
|
||||
# Backward-compatibility fallback for checkpoints written before the sub-workflow checkpoint
|
||||
# was embedded. Those stored per-execution bookkeeping; recover only the pending
|
||||
# request_info events so the sub-workflow re-emits its pending requests. The sub-workflow's
|
||||
# deeper executor/shared state cannot be restored from these older checkpoints.
|
||||
legacy_execution_contexts = state.get("execution_contexts")
|
||||
if not legacy_execution_contexts:
|
||||
return
|
||||
request_info_events: list[WorkflowEvent[Any]] = []
|
||||
for execution_context in legacy_execution_contexts.values():
|
||||
if isinstance(execution_context, ExecutionContext):
|
||||
request_info_events.extend(execution_context.pending_requests.values())
|
||||
if execution_context.collected_responses:
|
||||
logger.warning(
|
||||
"WorkflowExecutor %s restored legacy checkpoint with collected responses for "
|
||||
"execution_id %s. The sub-workflow is the single source of truth for its pending "
|
||||
"requests, so these responses will be ignored. Resume instead from a checkpoint created "
|
||||
"prior to any responses being collected if legacy request/response state must be "
|
||||
"preserved. Legacy execution contexts for sub-workflows will be removed in a future release.",
|
||||
self.id,
|
||||
execution_context.execution_id,
|
||||
)
|
||||
await asyncio.gather(*[
|
||||
self.workflow._runner_context.add_request_info_event(event) # pyright: ignore[reportPrivateUsage]
|
||||
for event in request_info_events
|
||||
@@ -537,7 +498,6 @@ class WorkflowExecutor(Executor):
|
||||
async def _process_workflow_result(
|
||||
self,
|
||||
result: WorkflowRunResult,
|
||||
execution_context: ExecutionContext,
|
||||
ctx: WorkflowContext[Any],
|
||||
) -> None:
|
||||
"""Process the result from a workflow execution.
|
||||
@@ -547,7 +507,6 @@ class WorkflowExecutor(Executor):
|
||||
|
||||
Args:
|
||||
result: The workflow execution result.
|
||||
execution_context: The execution context for this sub-workflow run.
|
||||
ctx: The workflow context.
|
||||
"""
|
||||
# Collect all events from the workflow
|
||||
@@ -586,10 +545,6 @@ class WorkflowExecutor(Executor):
|
||||
for event in request_info_events:
|
||||
request_id = event.request_id
|
||||
response_type = event.response_type
|
||||
# Track the pending request in execution context
|
||||
execution_context.pending_requests[request_id] = event
|
||||
# Map request to execution for response routing
|
||||
self._request_to_execution[request_id] = execution_context.execution_id
|
||||
if self._propagate_request:
|
||||
# In a workflow where the parent workflow does not handle the request, the request
|
||||
# should be propagated via the `request_info` mechanism to an external source. And
|
||||
@@ -600,9 +555,6 @@ class WorkflowExecutor(Executor):
|
||||
# request and handle it directly, a message should be sent.
|
||||
await ctx.send_message(SubWorkflowRequestMessage(source_event=event, executor_id=self.id))
|
||||
|
||||
# Update expected response count for this execution
|
||||
execution_context.expected_response_count = len(request_info_events)
|
||||
|
||||
# Handle final state
|
||||
if workflow_run_state == WorkflowRunState.FAILED:
|
||||
# Find the failed event (type='failed').
|
||||
@@ -621,26 +573,18 @@ class WorkflowExecutor(Executor):
|
||||
await ctx.add_event(error_event)
|
||||
elif workflow_run_state == WorkflowRunState.IDLE:
|
||||
# Sub-workflow is idle - nothing more to do now
|
||||
logger.debug(
|
||||
f"Sub-workflow {self.workflow.id} is idle with {len(self._execution_contexts)} active executions"
|
||||
)
|
||||
logger.debug(f"Sub-workflow {self.workflow.id} is idle")
|
||||
elif workflow_run_state == WorkflowRunState.CANCELLED:
|
||||
# Sub-workflow was cancelled - treat as completion
|
||||
logger.debug(
|
||||
f"Sub-workflow {self.workflow.id} was cancelled with {len(self._execution_contexts)} active executions"
|
||||
)
|
||||
logger.debug(f"Sub-workflow {self.workflow.id} was cancelled")
|
||||
elif workflow_run_state == WorkflowRunState.IN_PROGRESS_PENDING_REQUESTS:
|
||||
# Sub-workflow is still running with pending requests
|
||||
logger.debug(
|
||||
f"Sub-workflow {self.workflow.id} is still in progress with {len(request_info_events)} "
|
||||
f"pending requests with {len(self._execution_contexts)} active executions"
|
||||
f"Sub-workflow {self.workflow.id} is still in progress with {len(request_info_events)} pending requests"
|
||||
)
|
||||
elif workflow_run_state == WorkflowRunState.IDLE_WITH_PENDING_REQUESTS:
|
||||
# Sub-workflow is idle but has pending requests
|
||||
logger.debug(
|
||||
f"Sub-workflow {self.workflow.id} is idle with pending requests: "
|
||||
f"{len(request_info_events)} with {len(self._execution_contexts)} active executions"
|
||||
)
|
||||
logger.debug(f"Sub-workflow {self.workflow.id} is idle with pending requests: {len(request_info_events)}")
|
||||
else:
|
||||
raise RuntimeError(f"Unexpected workflow run state: {workflow_run_state}")
|
||||
|
||||
@@ -650,48 +594,17 @@ class WorkflowExecutor(Executor):
|
||||
response: Any,
|
||||
ctx: WorkflowContext[Any],
|
||||
) -> None:
|
||||
execution_id = self._request_to_execution.get(request_id)
|
||||
if not execution_id or execution_id not in self._execution_contexts:
|
||||
# The sub-workflow is the source of truth for what it is awaiting. Validate the response
|
||||
# against its pending requests and ignore anything unknown or already handled.
|
||||
pending_requests = await self.workflow._runner_context.get_pending_request_info_events() # pyright: ignore[reportPrivateUsage]
|
||||
if request_id not in pending_requests:
|
||||
logger.warning(
|
||||
f"WorkflowExecutor {self.id} received response for unknown request_id: {request_id}. "
|
||||
"This response will be ignored."
|
||||
f"WorkflowExecutor {self.id} received a response for an unknown or already-handled "
|
||||
f"request_id: {request_id}. This response will be ignored."
|
||||
)
|
||||
return
|
||||
|
||||
execution_context = self._execution_contexts[execution_id]
|
||||
|
||||
# Check if we have this pending request in the execution context
|
||||
if request_id not in execution_context.pending_requests:
|
||||
logger.warning(
|
||||
f"WorkflowExecutor {self.id} received response for unknown request_id: "
|
||||
f"{request_id} in execution {execution_id}, ignoring"
|
||||
)
|
||||
return
|
||||
|
||||
# Remove the request from pending list and request mapping
|
||||
execution_context.pending_requests.pop(request_id, None)
|
||||
self._request_to_execution.pop(request_id, None)
|
||||
|
||||
# Accumulate the response in this execution's context
|
||||
execution_context.collected_responses[request_id] = response
|
||||
# Check if we have all expected responses for this execution
|
||||
if len(execution_context.collected_responses) < execution_context.expected_response_count:
|
||||
logger.debug(
|
||||
f"WorkflowExecutor {self.id} execution {execution_id} waiting for more responses: "
|
||||
f"{len(execution_context.collected_responses)}/{execution_context.expected_response_count} received"
|
||||
)
|
||||
return # Wait for more responses
|
||||
|
||||
# Send all collected responses to the sub-workflow
|
||||
responses_to_send = dict(execution_context.collected_responses)
|
||||
execution_context.collected_responses.clear() # Clear for next batch
|
||||
|
||||
try:
|
||||
# Resume the sub-workflow with all collected responses
|
||||
result = await self.workflow.run(responses=responses_to_send)
|
||||
# Process the workflow result using shared logic
|
||||
await self._process_workflow_result(result, execution_context, ctx)
|
||||
finally:
|
||||
# Clean up execution context if it's completed (no pending requests)
|
||||
if not execution_context.pending_requests:
|
||||
del self._execution_contexts[execution_id]
|
||||
# Forward the response to the sub-workflow, which resumes and validates it against its own
|
||||
# pending requests, then process whatever the sub-workflow produces.
|
||||
result = await self.workflow.run(responses={request_id: response})
|
||||
await self._process_workflow_result(result, ctx)
|
||||
|
||||
@@ -2338,6 +2338,42 @@ def test_replace_approval_contents_with_results_uses_result_call_ids_without_pla
|
||||
]
|
||||
|
||||
|
||||
def test_replace_approval_contents_with_results_allows_reused_call_id_after_completion() -> None:
|
||||
"""A completed call must not suppress a later approval request that reuses its id.
|
||||
|
||||
Re-approving the same ``(call_id, function)`` is supported behaviour. If the dedupe
|
||||
matched every occurrence of the id, the fresh request would be dropped and its result
|
||||
attached to the already-answered call, leaving one call with two results.
|
||||
"""
|
||||
from agent_framework._tools import _collect_approval_responses, _replace_approval_contents_with_results
|
||||
|
||||
completed_call = Content.from_function_call(call_id="call_reused", name="run_skill_script", arguments="{}")
|
||||
completed_result = Content.from_function_result(call_id="call_reused", result="first output")
|
||||
_, request, response = _build_approved_tool_roundtrip(
|
||||
call_id="call_reused", approval_id="approval_2", tool_name="run_skill_script"
|
||||
)
|
||||
|
||||
messages = [
|
||||
Message(role="assistant", contents=[completed_call]),
|
||||
Message(role="tool", contents=[completed_result]),
|
||||
Message(role="assistant", contents=[request]),
|
||||
Message(role="user", contents=[response]),
|
||||
]
|
||||
|
||||
_replace_approval_contents_with_results(
|
||||
messages,
|
||||
_collect_approval_responses(messages),
|
||||
[Content.from_function_result(call_id="call_reused", result="second output")],
|
||||
)
|
||||
|
||||
function_calls = [c for m in messages for c in m.contents if c.type == "function_call"]
|
||||
assert [c.call_id for c in function_calls] == ["call_reused", "call_reused"]
|
||||
results = [c for m in messages for c in m.contents if c.type == "function_result"]
|
||||
assert [(c.call_id, c.result) for c in results] == [
|
||||
("call_reused", "first output"),
|
||||
("call_reused", "second output"),
|
||||
]
|
||||
|
||||
def test_replace_approval_contents_with_results_uses_result_call_ids_for_placeholders() -> None:
|
||||
from agent_framework._tools import _collect_approval_responses, _replace_approval_contents_with_results
|
||||
|
||||
|
||||
@@ -3,7 +3,11 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import stat
|
||||
import subprocess
|
||||
import sys
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
@@ -58,6 +62,29 @@ def _text(content: Content) -> str:
|
||||
return content.text
|
||||
|
||||
|
||||
def _create_junction_or_skip(*, link: Path, target: Path) -> None:
|
||||
if sys.platform != "win32":
|
||||
pytest.skip("Windows directory junctions are only available on Windows")
|
||||
|
||||
result = subprocess.run(
|
||||
[os.environ.get("COMSPEC", "cmd"), "/c", "mklink", "/J", str(link), str(target)],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=False,
|
||||
)
|
||||
if result.returncode != 0:
|
||||
pytest.skip(f"Could not create Windows directory junction: {result.stderr or result.stdout}")
|
||||
|
||||
is_junction = getattr(link, "is_junction", None)
|
||||
is_reparse_point = bool(
|
||||
getattr(stat, "FILE_ATTRIBUTE_REPARSE_POINT", 0)
|
||||
and getattr(link.lstat(), "st_file_attributes", 0) & getattr(stat, "FILE_ATTRIBUTE_REPARSE_POINT", 0)
|
||||
)
|
||||
if not (callable(is_junction) and is_junction()) and not is_reparse_point:
|
||||
link.rmdir()
|
||||
pytest.skip("Created junction was not reported as a reparse point")
|
||||
|
||||
|
||||
def test_normalize_relative_path_collapses_and_validates() -> None:
|
||||
"""The path normalizer should accept relative forward/backslash paths and reject unsafe ones."""
|
||||
assert _normalize_relative_path("foo/bar.txt") == "foo/bar.txt"
|
||||
@@ -420,6 +447,31 @@ async def test_filesystem_store_search_and_list_skip_symlinked_directories(tmp_p
|
||||
assert {result.file_name for result in results} == {"inside.md"}
|
||||
|
||||
|
||||
async def test_filesystem_store_search_and_list_skip_junctioned_directories(tmp_path: Path) -> None:
|
||||
"""Recursive search and listing must not follow Windows junctions outside the root."""
|
||||
outside = tmp_path / "outside"
|
||||
outside.mkdir()
|
||||
(outside / "secret.md").write_text("ERROR outside the root", encoding="utf-8")
|
||||
|
||||
root = tmp_path / "root"
|
||||
root.mkdir()
|
||||
(root / "inside.md").write_text("ERROR inside", encoding="utf-8")
|
||||
junction = root / "linked"
|
||||
_create_junction_or_skip(link=junction, target=outside)
|
||||
|
||||
try:
|
||||
store = FileSystemAgentFileStore(root)
|
||||
|
||||
with pytest.raises(ValueError):
|
||||
await store.read("linked/secret.md")
|
||||
assert await _list_dirs(store) == []
|
||||
|
||||
results = await store.search("", "error", recursive=True)
|
||||
assert {result.file_name for result in results} == {"inside.md"}
|
||||
finally:
|
||||
junction.rmdir()
|
||||
|
||||
|
||||
async def test_filesystem_store_search_skips_non_utf8_files(tmp_path: Path) -> None:
|
||||
"""The filesystem store should silently skip non-UTF-8 files instead of aborting the search."""
|
||||
store = FileSystemAgentFileStore(tmp_path)
|
||||
@@ -773,17 +825,22 @@ async def test_run_search_with_timeout_raises_value_error(monkeypatch: pytest.Mo
|
||||
async def test_filesystem_store_symlink_probe_fails_closed_on_oserror(
|
||||
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
"""If ``Path.is_symlink`` raises during the probe, the operation must be refused."""
|
||||
"""If ``Path.lstat`` raises during the probe, the operation must be refused."""
|
||||
store = FileSystemAgentFileStore(tmp_path)
|
||||
await store.write("ok.txt", "content")
|
||||
await store.write("same/same/ok.txt", "content")
|
||||
|
||||
def boom(self: Path) -> bool:
|
||||
raise PermissionError("access denied")
|
||||
original_lstat = Path.lstat
|
||||
failing_path = store.root_path / "same" / "same"
|
||||
|
||||
monkeypatch.setattr(Path, "is_symlink", boom)
|
||||
def fail_for_target(self: Path) -> os.stat_result:
|
||||
if self == failing_path:
|
||||
raise PermissionError("access denied")
|
||||
return original_lstat(self)
|
||||
|
||||
with pytest.raises(ValueError, match="symbolic link or reparse point"):
|
||||
await store.read("ok.txt")
|
||||
monkeypatch.setattr(Path, "lstat", fail_for_target)
|
||||
|
||||
with pytest.raises(ValueError, match=r"'same/same'"):
|
||||
await store.read("same/same/ok.txt")
|
||||
|
||||
|
||||
def test_file_access_harness_classes_are_marked_experimental() -> None:
|
||||
|
||||
@@ -2302,3 +2302,54 @@ class TestChatAgentChatMiddleware:
|
||||
# response = await agent.run("test message")
|
||||
# assert response is not None
|
||||
# assert execution_order == ["before", "after"]
|
||||
|
||||
|
||||
class TestCallableClassMiddlewareErrorHandling:
|
||||
"""Tests for exception handling when using callable class instances as middleware."""
|
||||
|
||||
def test_callable_class_middleware_insufficient_params_raises_middleware_exception(self) -> None:
|
||||
"""Test that callable class instance with insufficient params raises MiddlewareException."""
|
||||
|
||||
class InsufficientParamsMiddleware:
|
||||
async def __call__(self, ctx: Any) -> None:
|
||||
pass
|
||||
|
||||
client = MockBaseChatClient()
|
||||
insufficient_middleware: list[Any] = [InsufficientParamsMiddleware()]
|
||||
with pytest.raises(MiddlewareException) as exc_info:
|
||||
Agent(client=client, middleware=insufficient_middleware)
|
||||
|
||||
assert "InsufficientParamsMiddleware" in str(exc_info.value)
|
||||
assert "must have at least 2 parameters" in str(exc_info.value)
|
||||
|
||||
def test_callable_class_middleware_type_mismatch_raises_middleware_exception(self) -> None:
|
||||
"""Test that callable class instance with decorator/annotation mismatch raises MiddlewareException."""
|
||||
|
||||
class MismatchedCallableMiddleware:
|
||||
_middleware_type = MiddlewareType.AGENT
|
||||
|
||||
async def __call__(self, context: FunctionInvocationContext, call_next: Any) -> None:
|
||||
await call_next()
|
||||
|
||||
client = MockBaseChatClient()
|
||||
mismatched_middleware: list[Any] = [MismatchedCallableMiddleware()]
|
||||
with pytest.raises(MiddlewareException) as exc_info:
|
||||
Agent(client=client, middleware=mismatched_middleware)
|
||||
|
||||
assert "MismatchedCallableMiddleware" in str(exc_info.value)
|
||||
assert "MiddlewareTypes type mismatch" in str(exc_info.value)
|
||||
|
||||
def test_callable_class_middleware_undetermined_type_raises_middleware_exception(self) -> None:
|
||||
"""Test that a callable class instance without annotations or decorator raises MiddlewareException."""
|
||||
|
||||
class UndeterminedCallableMiddleware:
|
||||
async def __call__(self, arg1: Any, arg2: Any) -> None:
|
||||
pass
|
||||
|
||||
client = MockBaseChatClient()
|
||||
undetermined_middleware: list[Any] = [UndeterminedCallableMiddleware()]
|
||||
with pytest.raises(MiddlewareException) as exc_info:
|
||||
Agent(client=client, middleware=undetermined_middleware)
|
||||
|
||||
assert "UndeterminedCallableMiddleware" in str(exc_info.value)
|
||||
assert "Cannot determine middleware type" in str(exc_info.value)
|
||||
|
||||
@@ -257,6 +257,43 @@ class TestHITL:
|
||||
assert outputs == ["Final: Looks great!"]
|
||||
assert result2.get_final_state() == WorkflowRunState.IDLE
|
||||
|
||||
async def test_fresh_message_while_pending_requests_warns(self, caplog: pytest.LogCaptureFixture) -> None:
|
||||
"""A fresh message while request_info events are pending is allowed but logs a warning."""
|
||||
|
||||
@workflow
|
||||
async def review_wf(doc: str, ctx: RunContext) -> str:
|
||||
feedback = await ctx.request_info({"draft": doc}, response_type=str, request_id="req1")
|
||||
return f"Final: {feedback}"
|
||||
|
||||
result1 = await review_wf.run("my doc")
|
||||
assert result1.get_final_state() == WorkflowRunState.IDLE_WITH_PENDING_REQUESTS
|
||||
|
||||
# Starting fresh input while a request is pending does not abandon it, but advances
|
||||
# workflow state so a later response may apply to a moved-on workflow -> warn (but proceed).
|
||||
with caplog.at_level(logging.WARNING):
|
||||
await review_wf.run("another doc")
|
||||
|
||||
assert "request_info event(s) are still pending" in caplog.text
|
||||
assert "a fresh message" in caplog.text
|
||||
|
||||
async def test_responses_while_pending_requests_does_not_warn(self, caplog: pytest.LogCaptureFixture) -> None:
|
||||
"""Delivering responses is the normal completion path and must not warn."""
|
||||
|
||||
@workflow
|
||||
async def review_wf(doc: str, ctx: RunContext) -> str:
|
||||
feedback = await ctx.request_info({"draft": doc}, response_type=str, request_id="req1")
|
||||
return f"Final: {feedback}"
|
||||
|
||||
result1 = await review_wf.run("my doc")
|
||||
assert result1.get_final_state() == WorkflowRunState.IDLE_WITH_PENDING_REQUESTS
|
||||
|
||||
caplog.clear()
|
||||
with caplog.at_level(logging.WARNING):
|
||||
result2 = await review_wf.run(responses={"req1": "Looks great!"})
|
||||
|
||||
assert result2.get_final_state() == WorkflowRunState.IDLE
|
||||
assert "still pending" not in caplog.text
|
||||
|
||||
async def test_untyped_ctx_parameter(self):
|
||||
"""ctx is injected by parameter name even without a RunContext annotation."""
|
||||
|
||||
|
||||
@@ -512,6 +512,98 @@ async def test_runner_reset_iteration_count():
|
||||
assert runner._iteration == 0 # pyright: ignore[reportPrivateUsage]
|
||||
|
||||
|
||||
async def test_runner_capture_and_restore_checkpoint_object_roundtrip():
|
||||
"""build_checkpoint() then restore_checkpoint() must roundtrip.
|
||||
|
||||
Shared state and executor snapshots are captured into an in-memory ``WorkflowCheckpoint``
|
||||
and restored from it without any storage backend (the path the parent WorkflowExecutor
|
||||
uses to checkpoint a nested sub-workflow).
|
||||
"""
|
||||
|
||||
class CounterExecutor(Executor):
|
||||
def __init__(self, id: str) -> None:
|
||||
super().__init__(id=id)
|
||||
self.count = 0
|
||||
|
||||
@handler
|
||||
async def handle(self, message: MockMessage, ctx: WorkflowContext[Any, int]) -> None:
|
||||
self.count += message.data
|
||||
|
||||
async def on_checkpoint_save(self) -> dict[str, Any]:
|
||||
return {"count": self.count}
|
||||
|
||||
async def on_checkpoint_restore(self, state: dict[str, Any]) -> None:
|
||||
self.count = int(state.get("count", 0))
|
||||
|
||||
executor = CounterExecutor(id="counter")
|
||||
state = State()
|
||||
ctx = InProcRunnerContext()
|
||||
runner = Runner([], {executor.id: executor}, state, ctx, "test_name", graph_signature_hash="test_hash")
|
||||
|
||||
# Establish some state to capture.
|
||||
executor.count = 7
|
||||
state.set("shared_key", "shared_value")
|
||||
state.commit()
|
||||
|
||||
checkpoint = await runner.build_checkpoint()
|
||||
assert checkpoint.graph_signature_hash == "test_hash"
|
||||
|
||||
# Mutate after capture; restoring must roll back to the captured snapshot.
|
||||
executor.count = 999
|
||||
state.set("shared_key", "mutated")
|
||||
state.commit()
|
||||
|
||||
await runner.restore_checkpoint(checkpoint)
|
||||
|
||||
assert executor.count == 7
|
||||
assert state.get("shared_key") == "shared_value"
|
||||
assert runner._resumed_from_checkpoint is True # pyright: ignore[reportPrivateUsage]
|
||||
|
||||
|
||||
async def test_runner_build_checkpoint_includes_in_flight_messages():
|
||||
"""build_checkpoint() must snapshot in-flight messages non-destructively."""
|
||||
executor = MockExecutor(id="executor_a")
|
||||
state = State()
|
||||
ctx = InProcRunnerContext()
|
||||
runner = Runner([], {executor.id: executor}, state, ctx, "test_name", graph_signature_hash="test_hash")
|
||||
|
||||
await ctx.send_message(WorkflowMessage(data=MockMessage(data=1), source_id="START"))
|
||||
|
||||
checkpoint = await runner.build_checkpoint()
|
||||
|
||||
# The in-flight message is captured in the snapshot ...
|
||||
assert list(checkpoint.messages.keys()) == ["START"]
|
||||
assert len(checkpoint.messages["START"]) == 1
|
||||
# ... without draining it from the runner (capture is non-destructive).
|
||||
assert await ctx.has_messages() is True
|
||||
|
||||
|
||||
async def test_runner_build_checkpoint_do_not_advance_previous_checkpoint_id():
|
||||
"""build_checkpoint() must not advance _previous_checkpoint_id so a later capture chains to it."""
|
||||
executor = MockExecutor(id="executor_a")
|
||||
state = State()
|
||||
ctx = InProcRunnerContext()
|
||||
runner = Runner([], {executor.id: executor}, state, ctx, "test_name", graph_signature_hash="test_hash")
|
||||
|
||||
# Pre-condition: nothing captured yet, so there is no parent to chain back to.
|
||||
assert runner._previous_checkpoint_id is None # pyright: ignore[reportPrivateUsage]
|
||||
|
||||
first = await runner.build_checkpoint()
|
||||
assert first.previous_checkpoint_id is None
|
||||
|
||||
# Capturing advances the tracked checkpoint id to the newly-created checkpoint ...
|
||||
assert runner._previous_checkpoint_id is None # pyright: ignore[reportPrivateUsage]
|
||||
|
||||
|
||||
async def test_runner_restore_checkpoint_rejects_graph_mismatch():
|
||||
"""restore_checkpoint() must reject a checkpoint from a different graph."""
|
||||
runner = Runner([], {}, State(), InProcRunnerContext(), "test_name", graph_signature_hash="hash-a")
|
||||
|
||||
foreign = WorkflowCheckpoint(workflow_name="test_name", graph_signature_hash="hash-b")
|
||||
with pytest.raises(WorkflowCheckpointException, match="Workflow graph has changed"):
|
||||
await runner.restore_checkpoint(foreign)
|
||||
|
||||
|
||||
class CheckpointingContext(InProcRunnerContext):
|
||||
"""A context that supports checkpointing for testing."""
|
||||
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
import logging
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any
|
||||
from uuid import uuid4
|
||||
|
||||
import pytest
|
||||
from typing_extensions import Never
|
||||
|
||||
from agent_framework import (
|
||||
@@ -15,6 +17,7 @@ from agent_framework import (
|
||||
WorkflowContext,
|
||||
WorkflowEvent,
|
||||
WorkflowExecutor,
|
||||
WorkflowRunState,
|
||||
handler,
|
||||
response_handler,
|
||||
)
|
||||
@@ -465,6 +468,62 @@ async def test_concurrent_sub_workflow_execution() -> None:
|
||||
# (This is implicitly tested by the fact that we got correct results for all emails)
|
||||
|
||||
|
||||
async def test_sub_workflow_warns_on_overlapping_execution(caplog: pytest.LogCaptureFixture) -> None:
|
||||
"""A new input while a prior sub-workflow execution is awaiting responses logs a warning.
|
||||
|
||||
Overlapping executions share one sub-workflow instance and its state, so WorkflowExecutor
|
||||
allows the new execution but warns that it is only safe when the wrapped workflow is stateless.
|
||||
"""
|
||||
|
||||
class TwoInputParent(Executor):
|
||||
def __init__(self) -> None:
|
||||
super().__init__(id="two_input_parent")
|
||||
self._pending: dict[str, SubWorkflowRequestMessage] = {}
|
||||
|
||||
@handler
|
||||
async def start(self, emails: list[str], ctx: WorkflowContext[EmailValidationRequest]) -> None:
|
||||
for email in emails:
|
||||
await ctx.send_message(EmailValidationRequest(email=email))
|
||||
|
||||
@handler
|
||||
async def handle_domain_request(
|
||||
self,
|
||||
sub_workflow_request: SubWorkflowRequestMessage,
|
||||
ctx: WorkflowContext[SubWorkflowResponseMessage],
|
||||
) -> None:
|
||||
domain_request = sub_workflow_request.source_event.data
|
||||
assert isinstance(domain_request, DomainCheckRequest)
|
||||
self._pending[domain_request.id] = sub_workflow_request
|
||||
await ctx.request_info(domain_request, bool)
|
||||
|
||||
@handler
|
||||
async def collect(self, result: ValidationResult, ctx: WorkflowContext) -> None: ...
|
||||
|
||||
parent = TwoInputParent()
|
||||
workflow_executor = WorkflowExecutor(create_email_validation_workflow(), "email_workflow")
|
||||
main_workflow = (
|
||||
WorkflowBuilder(start_executor=parent)
|
||||
.add_edge(parent, workflow_executor)
|
||||
.add_edge(workflow_executor, parent)
|
||||
.build()
|
||||
)
|
||||
|
||||
with caplog.at_level(logging.WARNING, logger="agent_framework._workflows._workflow_executor"):
|
||||
result = await main_workflow.run(["a@domain1.com", "b@domain2.com"])
|
||||
|
||||
# Two inputs are delivered to the same WorkflowExecutor in one superstep: the second execution
|
||||
# starts while the sub-workflow still has a pending request, producing exactly one overlap
|
||||
# warning from the WorkflowExecutor. (The substring is unique to the WorkflowExecutor warning so
|
||||
# it is not confused with the core Workflow.run pending-request warning.)
|
||||
assert len(result.get_request_info_events()) == 2
|
||||
overlap_warnings = [
|
||||
record
|
||||
for record in caplog.records
|
||||
if record.levelno == logging.WARNING and "new input message while its sub-workflow" in record.getMessage()
|
||||
]
|
||||
assert len(overlap_warnings) == 1
|
||||
|
||||
|
||||
# region Checkpoint-related message types and executors for sub-workflow tests
|
||||
|
||||
|
||||
@@ -619,6 +678,59 @@ async def test_sub_workflow_checkpoint_restore_no_duplicate_requests() -> None:
|
||||
assert request_events[0].data.prompt == "Second request"
|
||||
|
||||
|
||||
async def test_sub_workflow_checkpoint_restore_preserves_sub_workflow_state() -> None:
|
||||
"""Resuming a sub-workflow mid-progress must restore its internal executor state.
|
||||
|
||||
Regression guard for the issue where only the WorkflowExecutor's bookkeeping (pending
|
||||
requests) was checkpointed, so a sub-workflow executor that accumulates state across
|
||||
multiple request/response cycles (here ``TwoStepSubWorkflowExecutor._responses``) lost
|
||||
that state on resume. With the sub-workflow's own checkpoint embedded in the parent
|
||||
checkpoint, the second response now completes the two-step flow instead of triggering a
|
||||
spurious third request.
|
||||
"""
|
||||
storage = InMemoryCheckpointStorage()
|
||||
|
||||
# Step 1: run until the first request.
|
||||
workflow1 = _build_checkpoint_test_workflow(storage)
|
||||
first_request_id: str | None = None
|
||||
async for event in workflow1.run("test_value", stream=True):
|
||||
if event.type == "request_info":
|
||||
first_request_id = event.request_id
|
||||
assert first_request_id is not None
|
||||
|
||||
# Step 2: answer the first request so the sub-workflow accumulates internal state
|
||||
# (``_responses == ["first_answer"]``) and emits the second request. This mid-progress
|
||||
# point is what we checkpoint and resume from - the case the no-duplicate test (which
|
||||
# checkpoints at the first request, before any state accrues) does not cover.
|
||||
second_request_id: str | None = None
|
||||
async for event in workflow1.run(stream=True, responses={first_request_id: "first_answer"}):
|
||||
if event.type == "request_info":
|
||||
second_request_id = event.request_id
|
||||
assert second_request_id is not None
|
||||
|
||||
# Resume from the latest checkpoint (captured after the second request was made).
|
||||
checkpoints = await storage.list_checkpoints(workflow_name=workflow1.name)
|
||||
checkpoint_id = max(checkpoints, key=lambda cp: cp.iteration_count).checkpoint_id
|
||||
|
||||
workflow2 = _build_checkpoint_test_workflow(storage)
|
||||
resumed_second_request_id: str | None = None
|
||||
async for event in workflow2.run(checkpoint_id=checkpoint_id, stream=True):
|
||||
if event.type == "request_info":
|
||||
resumed_second_request_id = event.request_id
|
||||
assert resumed_second_request_id is not None
|
||||
assert resumed_second_request_id == second_request_id
|
||||
|
||||
# Step 3: answer the second request. With the sub-workflow's state restored, the two-step
|
||||
# executor completes instead of emitting a spurious third request. If the internal state
|
||||
# were lost, the second answer would be treated as a first answer and a third request
|
||||
# ("Second request") would be emitted.
|
||||
result = await workflow2.run(responses={resumed_second_request_id: "second_answer"})
|
||||
assert result.get_request_info_events() == [], (
|
||||
"Sub-workflow internal state was lost on resume: a spurious extra request was emitted"
|
||||
)
|
||||
assert result.get_final_state() == WorkflowRunState.IDLE
|
||||
|
||||
|
||||
async def test_sub_workflow_intermediate_outputs_propagate_to_parent() -> None:
|
||||
"""A child workflow's intermediate emissions must bubble up through the parent.
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import asyncio
|
||||
import gc
|
||||
import logging
|
||||
import tempfile
|
||||
from collections.abc import AsyncIterable, Awaitable, Sequence
|
||||
from dataclasses import dataclass, field
|
||||
@@ -109,6 +110,38 @@ class MockExecutorRequestApproval(Executor):
|
||||
await ctx.send_message(NumberMessage(data=data))
|
||||
|
||||
|
||||
async def test_fresh_message_while_pending_advances_state_without_abandoning_requests(
|
||||
caplog: pytest.LogCaptureFixture,
|
||||
) -> None:
|
||||
"""A fresh message while a request is pending is allowed but hazardous.
|
||||
|
||||
A fresh ``message`` does NOT abandon the pending request - it can still be answered
|
||||
later - but the new run advances executor state, so a response for the earlier request
|
||||
applies to a workflow that has moved on. The run is allowed and a warning is emitted.
|
||||
"""
|
||||
executor = MockExecutorRequestApproval(id="approver")
|
||||
workflow = WorkflowBuilder(start_executor=executor).build()
|
||||
|
||||
# Turn 1: request approval for data=1 -> workflow idles with a pending request.
|
||||
result1 = await workflow.run(NumberMessage(data=1))
|
||||
assert result1.get_final_state() == WorkflowRunState.IDLE_WITH_PENDING_REQUESTS
|
||||
original_request_id = result1.get_request_info_events()[0].request_id
|
||||
|
||||
# Turn 2: a fresh message for data=2 while the first request is still pending. This is
|
||||
# allowed but warns, and advances the executor's stored state from 1 to 2.
|
||||
with caplog.at_level(logging.WARNING):
|
||||
result2 = await workflow.run(NumberMessage(data=2))
|
||||
assert "request_info event(s) are still pending" in caplog.text
|
||||
assert "a fresh message" in caplog.text
|
||||
assert result2.get_final_state() == WorkflowRunState.IDLE_WITH_PENDING_REQUESTS
|
||||
|
||||
# Turn 3: the ORIGINAL request is still answerable, proving the fresh message did not
|
||||
# abandon it. But because the executor state moved on to 2, the response applies to the
|
||||
# moved-on state and yields 2, not the original 1.
|
||||
result3 = await workflow.run(responses={original_request_id: ApprovalMessage(approved=True)})
|
||||
assert result3.get_outputs() == [2]
|
||||
|
||||
|
||||
async def test_workflow_run_streaming() -> None:
|
||||
"""Test the workflow run stream."""
|
||||
executor_a = IncrementExecutor(id="executor_a")
|
||||
|
||||
@@ -109,6 +109,17 @@ class CapturingRunnerContext(RunnerContext):
|
||||
) -> str:
|
||||
raise NotImplementedError("Checkpointing is not supported in activity context")
|
||||
|
||||
async def build_checkpoint(
|
||||
self,
|
||||
workflow_name: str,
|
||||
graph_signature_hash: str,
|
||||
state: State,
|
||||
previous_checkpoint_id: str | None,
|
||||
iteration_count: int,
|
||||
metadata: dict[str, Any] | None = None,
|
||||
) -> WorkflowCheckpoint:
|
||||
raise NotImplementedError("Checkpointing is not supported in activity context")
|
||||
|
||||
async def load_checkpoint(self, checkpoint_id: str) -> WorkflowCheckpoint | None:
|
||||
raise NotImplementedError("Checkpointing is not supported in activity context")
|
||||
|
||||
|
||||
@@ -62,7 +62,6 @@ from agent_framework._types import (
|
||||
TextSpanRegion,
|
||||
UsageDetails,
|
||||
detect_media_type_from_base64,
|
||||
prepend_instructions_to_messages,
|
||||
validate_tool_mode,
|
||||
)
|
||||
from agent_framework.exceptions import (
|
||||
@@ -1377,7 +1376,6 @@ class RawOpenAIChatClient(
|
||||
"logit_bias", # not supported
|
||||
"seed", # not supported
|
||||
"stop", # not supported
|
||||
"instructions", # already added as system message
|
||||
"response_format", # handled separately
|
||||
"conversation_id", # handled separately
|
||||
"tool_choice", # handled separately
|
||||
@@ -1389,15 +1387,6 @@ class RawOpenAIChatClient(
|
||||
raise ChatClientInvalidRequestException(
|
||||
"prompt_cache_options requires openai>=2.45.0; upgrade the openai package to use it."
|
||||
)
|
||||
|
||||
# messages
|
||||
# Handle instructions by prepending to messages as system message
|
||||
# Only prepend instructions for the first turn (when no conversation/response ID exists)
|
||||
conversation_id = options.get("conversation_id")
|
||||
if (instructions := options.get("instructions")) and not conversation_id:
|
||||
# First turn: prepend instructions as system message
|
||||
messages = prepend_instructions_to_messages(list(messages), instructions, role="system")
|
||||
# Continuation turn: instructions already exist in conversation context, skip prepending
|
||||
request_uses_service_side_storage = False
|
||||
for key in ("conversation_id", "previous_response_id", "conversation"):
|
||||
value = options.get(key)
|
||||
|
||||
@@ -411,10 +411,14 @@ async def test_get_response_with_all_parameters() -> None:
|
||||
assert len(run_options["tools"]) == 1
|
||||
assert run_options["tools"][0]["type"] == "function"
|
||||
assert run_options["tools"][0]["name"] == "get_weather"
|
||||
assert run_options["input"][0]["role"] == "system"
|
||||
assert run_options["input"][0]["content"][0]["text"] == "You are a helpful assistant"
|
||||
assert run_options["input"][1]["role"] == "user"
|
||||
assert run_options["input"][1]["content"][0]["text"] == "Test message"
|
||||
|
||||
# Verify instructions are passed natively, not as a system message
|
||||
assert run_options["instructions"] == "You are a helpful assistant"
|
||||
|
||||
# Verify the input only contains the user message
|
||||
assert len(run_options["input"]) == 1
|
||||
assert run_options["input"][0]["role"] == "user"
|
||||
assert run_options["input"][0]["content"][0]["text"] == "Test message"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@@ -6196,70 +6200,30 @@ def _create_mock_responses_text_response(*, response_id: str) -> MagicMock:
|
||||
return mock_response
|
||||
|
||||
|
||||
async def test_instructions_sent_first_turn_then_skipped_for_continuation() -> None:
|
||||
client = OpenAIChatClient(model="test-model", api_key="test-key")
|
||||
mock_response = _create_mock_responses_text_response(response_id="resp_123")
|
||||
|
||||
with patch.object(client.client.responses, "create", return_value=mock_response) as mock_create:
|
||||
await client.get_response(
|
||||
messages=[Message(role="user", contents=["Hello"])],
|
||||
options={"instructions": "Reply in uppercase."},
|
||||
)
|
||||
|
||||
first_input_messages = mock_create.call_args.kwargs["input"]
|
||||
assert len(first_input_messages) == 2
|
||||
assert first_input_messages[0]["role"] == "system"
|
||||
assert any("Reply in uppercase" in str(c) for c in first_input_messages[0]["content"])
|
||||
assert first_input_messages[1]["role"] == "user"
|
||||
|
||||
await client.get_response(
|
||||
messages=[Message(role="user", contents=["Tell me a joke"])],
|
||||
options={
|
||||
"instructions": "Reply in uppercase.",
|
||||
"conversation_id": "resp_123",
|
||||
},
|
||||
)
|
||||
|
||||
second_input_messages = mock_create.call_args.kwargs["input"]
|
||||
assert len(second_input_messages) == 1
|
||||
assert second_input_messages[0]["role"] == "user"
|
||||
assert not any(message["role"] == "system" for message in second_input_messages)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("conversation_id", ["resp_456", "conv_abc123"])
|
||||
async def test_instructions_not_repeated_for_continuation_ids(
|
||||
conversation_id: str,
|
||||
@pytest.mark.parametrize("conversation_id", [None, "resp_456", "conv_abc123"])
|
||||
async def test_instructions_passed_natively_not_as_system_message(
|
||||
conversation_id: str | None,
|
||||
) -> None:
|
||||
"""Test that instructions are passed to the Responses API natively and not prepended to messages."""
|
||||
client = OpenAIChatClient(model="test-model", api_key="test-key")
|
||||
mock_response = _create_mock_responses_text_response(response_id="resp_456")
|
||||
|
||||
with patch.object(client.client.responses, "create", return_value=mock_response) as mock_create:
|
||||
options: OpenAIChatOptions = {"instructions": "Reply in uppercase."}
|
||||
if conversation_id:
|
||||
options["conversation_id"] = conversation_id
|
||||
|
||||
await client.get_response(
|
||||
messages=[Message(role="user", contents=["Continue conversation"])],
|
||||
options={"instructions": "Be helpful.", "conversation_id": conversation_id},
|
||||
messages=[Message(role="user", contents=["Hello"])],
|
||||
options=options,
|
||||
)
|
||||
|
||||
assert mock_create.call_args.kwargs.get("instructions") == "Reply in uppercase."
|
||||
|
||||
input_messages = mock_create.call_args.kwargs["input"]
|
||||
assert len(input_messages) == 1
|
||||
assert input_messages[0]["role"] == "user"
|
||||
assert not any(message["role"] == "system" for message in input_messages)
|
||||
|
||||
|
||||
async def test_instructions_included_without_conversation_id() -> None:
|
||||
client = OpenAIChatClient(model="test-model", api_key="test-key")
|
||||
mock_response = _create_mock_responses_text_response(response_id="resp_new")
|
||||
|
||||
with patch.object(client.client.responses, "create", return_value=mock_response) as mock_create:
|
||||
await client.get_response(
|
||||
messages=[Message(role="user", contents=["Hello"])],
|
||||
options={"instructions": "You are a helpful assistant."},
|
||||
)
|
||||
|
||||
input_messages = mock_create.call_args.kwargs["input"]
|
||||
assert len(input_messages) == 2
|
||||
assert input_messages[0]["role"] == "system"
|
||||
assert any("helpful assistant" in str(c) for c in input_messages[0]["content"])
|
||||
assert input_messages[1]["role"] == "user"
|
||||
assert not any(message.get("role") == "system" for message in input_messages)
|
||||
|
||||
|
||||
def test_with_callable_api_key() -> None:
|
||||
|
||||
+23
-1
@@ -395,10 +395,32 @@ args = [
|
||||
]
|
||||
|
||||
[tool.poe.tasks.validate-dependency-bounds-test]
|
||||
help = "Run workspace dependency-bound validation in test mode, optionally scoped with -P/--package short names such as `core`."
|
||||
help = "Run the exhaustive workspace dependency-bound test+typing matrix, optionally scoped with -P/--package short names such as `core`."
|
||||
shell = "python -m scripts.dependencies.validate_dependency_bounds --mode test --package \"$project\""
|
||||
args = [{ name = "project", default = "*", options = ["-P", "--package"] }]
|
||||
|
||||
[tool.poe.tasks.validate-python-release]
|
||||
help = "Refresh uv.lock, then run lower/upper import probes for changed package metadata on each package closure's minimum Python."
|
||||
executor = "simple"
|
||||
shell = """
|
||||
command=(
|
||||
python -m scripts.dependencies.validate_dependency_bounds
|
||||
--mode release
|
||||
--base-ref "${base_ref}"
|
||||
--release-timeout-seconds "${timeout}"
|
||||
)
|
||||
if [ -n "${python}" ]; then
|
||||
command+=(--python "${python}")
|
||||
fi
|
||||
"${command[@]}"
|
||||
"""
|
||||
interpreter = "bash"
|
||||
args = [
|
||||
{ name = "base_ref", options = ["-B", "--base-ref"] },
|
||||
{ name = "python", default = "", options = ["--python"] },
|
||||
{ name = "timeout", default = "300", options = ["--timeout-seconds"] },
|
||||
]
|
||||
|
||||
[tool.poe.tasks.validate-dependency-bounds-project]
|
||||
help = "Validate lower and upper dependency bounds for a -P/--package workspace package, optionally narrowed with -M/--mode and -D/--dependency."
|
||||
shell = """
|
||||
|
||||
@@ -7,6 +7,8 @@ These samples demonstrate how to use context providers to enrich agent conversat
|
||||
| File / Folder | Description |
|
||||
|---------------|-------------|
|
||||
| [`simple_context_provider.py`](simple_context_provider.py) | Implement a custom context provider by extending `ContextProvider` to extract and inject structured user information across turns. |
|
||||
| [`todo_provider.py`](todo_provider.py) | Use the built-in `TodoProvider` to give an agent todo-list tools. A scripted walkthrough that plans multi-step work and prints the evolving todo list after each turn. |
|
||||
| [`agent_mode_provider.py`](agent_mode_provider.py) | Use the built-in `AgentModeProvider` to track and switch an agent's operating mode at runtime. An interactive loop with a `/mode` slash command demonstrating the built-in `plan`/`execute` modes and custom modes. |
|
||||
| [`cross_session_observer.py`](cross_session_observer.py) | Detect injected context messages whose origins differ from the current session, via the `Message.additional_properties["_attribution"]["origin_session_ids"]` field. Self-contained — no LLM credentials required. |
|
||||
| [`azure_ai_foundry_memory.py`](azure_ai_foundry_memory.py) | Use `FoundryMemoryProvider` to add semantic memory — automatically retrieves, searches, and stores memories via Microsoft Foundry. |
|
||||
| [`file_access_data_processing/`](file_access_data_processing/) | Use `FileAccessProvider` with `FileSystemAgentFileStore` to give an agent read/write/search access to a folder of CSV data files. See its own [README](file_access_data_processing/README.md). |
|
||||
@@ -25,6 +27,18 @@ These samples demonstrate how to use context providers to enrich agent conversat
|
||||
- `FOUNDRY_MODEL`: Model deployment name
|
||||
- Azure CLI authentication (`az login`)
|
||||
|
||||
**For `todo_provider.py`:**
|
||||
- `FOUNDRY_PROJECT_ENDPOINT`: Your Microsoft Foundry project endpoint
|
||||
- `FOUNDRY_MODEL`: Model deployment name
|
||||
- Azure CLI authentication (`az login`)
|
||||
|
||||
**For `agent_mode_provider.py`:**
|
||||
- `FOUNDRY_PROJECT_ENDPOINT`: Your Microsoft Foundry project endpoint
|
||||
- `FOUNDRY_MODEL`: Model deployment name
|
||||
- Azure CLI authentication (`az login`)
|
||||
- To try the custom `concise`/`detailed` modes instead of the built-in `plan`/`execute` modes, set the in-file `USE_CUSTOM_MODES` constant to `True`.
|
||||
- This sample is interactive: it reads commands from the console in a loop (type `/exit` to quit).
|
||||
|
||||
**For `azure_ai_foundry_memory.py`:**
|
||||
- `FOUNDRY_PROJECT_ENDPOINT`: Your Microsoft Foundry project endpoint
|
||||
- `FOUNDRY_MODEL`: Chat/responses model deployment name
|
||||
|
||||
@@ -0,0 +1,180 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
import asyncio
|
||||
import os
|
||||
|
||||
from agent_framework import Agent, AgentModeProvider, get_agent_mode, set_agent_mode
|
||||
from agent_framework.foundry import FoundryChatClient
|
||||
from azure.identity import AzureCliCredential
|
||||
from dotenv import load_dotenv
|
||||
|
||||
# Load environment variables from .env file
|
||||
load_dotenv()
|
||||
|
||||
"""
|
||||
Agent Mode — Switch an agent's operating mode at runtime with AgentModeProvider
|
||||
|
||||
This sample shows how to use the ``AgentModeProvider``, a ``ContextProvider`` that tracks the
|
||||
agent's current operating "mode" in the session state and exposes tools (``mode_get`` / ``mode_set``)
|
||||
so the agent can query and switch modes as its work progresses. The mode is folded into the
|
||||
instructions sent to the model on every turn, so different modes can drive different behavior.
|
||||
|
||||
The sample demonstrates two things:
|
||||
1. The built-in default modes ("plan" and "execute") that ship with the provider.
|
||||
2. How to customize the available modes via ``default_mode`` / ``mode_instructions``. Flip the
|
||||
``USE_CUSTOM_MODES`` constant below to ``True`` to try a simple concise/detailed mode set.
|
||||
|
||||
It runs a simple interactive loop. In addition to chatting with the agent, you can switch the
|
||||
agent's mode yourself using a slash command:
|
||||
/mode — show the current mode
|
||||
/mode <name> — switch to the named mode
|
||||
/help — list the available commands and modes
|
||||
/exit — quit
|
||||
|
||||
When you switch modes with /mode, the provider injects a notification on the next turn so the agent
|
||||
clearly sees the change and adjusts its behavior accordingly.
|
||||
|
||||
Environment variables:
|
||||
FOUNDRY_PROJECT_ENDPOINT — Microsoft Foundry project endpoint URL
|
||||
FOUNDRY_MODEL — Model deployment name
|
||||
|
||||
Authentication:
|
||||
Run ``az login`` before running this sample.
|
||||
"""
|
||||
|
||||
# Flip to True to run the sample with the custom modes defined below instead of the provider's
|
||||
# built-in "plan" / "execute" defaults.
|
||||
USE_CUSTOM_MODES = False
|
||||
|
||||
|
||||
def print_help(available_modes: tuple[str, ...]) -> None:
|
||||
"""Print the available slash commands and modes."""
|
||||
print("Commands:")
|
||||
print(" /mode Show the current mode")
|
||||
print(f" /mode <name> Switch mode ({' | '.join(available_modes)})")
|
||||
print(" /help Show this help")
|
||||
print(" /exit Quit")
|
||||
|
||||
|
||||
async def main() -> None:
|
||||
client = FoundryChatClient(
|
||||
project_endpoint=os.environ["FOUNDRY_PROJECT_ENDPOINT"],
|
||||
model=os.environ["FOUNDRY_MODEL"],
|
||||
credential=AzureCliCredential(),
|
||||
)
|
||||
|
||||
# <create_mode_provider>
|
||||
if USE_CUSTOM_MODES:
|
||||
# Customize the set of modes by supplying ``mode_instructions``. Each mode maps a name to a
|
||||
# block of instructions describing how the agent should behave while operating in that mode.
|
||||
# ``default_mode`` selects the mode new sessions start in (defaults to the first mode when
|
||||
# omitted).
|
||||
mode_provider = AgentModeProvider(
|
||||
default_mode="concise",
|
||||
mode_instructions={
|
||||
"concise": (
|
||||
"Answer in a single short sentence. Do not elaborate unless the user explicitly "
|
||||
"asks for more detail."
|
||||
),
|
||||
"detailed": (
|
||||
"Answer thoroughly. Explain your reasoning, provide examples, and cover relevant edge cases."
|
||||
),
|
||||
},
|
||||
)
|
||||
else:
|
||||
# Use the provider's built-in modes: "plan" (interactive planning) and "execute" (autonomous
|
||||
# execution). No options are required.
|
||||
mode_provider = AgentModeProvider()
|
||||
# </create_mode_provider>
|
||||
|
||||
available_modes = mode_provider.available_modes
|
||||
|
||||
# Create the agent and attach the mode provider as a context provider.
|
||||
agent = Agent(
|
||||
client=client,
|
||||
name="ModeAwareAssistant",
|
||||
instructions=(
|
||||
"You are a helpful assistant. Follow the process and behavior required by your current operating mode."
|
||||
),
|
||||
context_providers=[mode_provider],
|
||||
)
|
||||
|
||||
session = agent.create_session()
|
||||
|
||||
def current_mode() -> str:
|
||||
"""Read the active mode from the session, validated against the provider's configured modes."""
|
||||
return get_agent_mode(
|
||||
session,
|
||||
source_id=mode_provider.source_id,
|
||||
default_mode=mode_provider.default_mode,
|
||||
available_modes=available_modes,
|
||||
)
|
||||
|
||||
print("Agent Mode sample. Type a message to chat, or use a slash command.")
|
||||
print(f"Available modes: {', '.join(available_modes)}")
|
||||
print(f"Current mode: {current_mode()}")
|
||||
print_help(available_modes)
|
||||
print()
|
||||
|
||||
while True:
|
||||
user_input = input("> ").strip()
|
||||
|
||||
# Treat empty input or /exit as a request to quit.
|
||||
if not user_input or user_input.lower() == "/exit":
|
||||
break
|
||||
|
||||
if user_input.lower() == "/help":
|
||||
print_help(available_modes)
|
||||
continue
|
||||
|
||||
# Handle the /mode slash command: "/mode" shows the current mode, "/mode <name>" switches.
|
||||
if user_input.lower() == "/mode" or user_input.lower().startswith("/mode "):
|
||||
parts = user_input.split(maxsplit=1)
|
||||
if len(parts) < 2:
|
||||
print(f"Current mode: {current_mode()}")
|
||||
continue
|
||||
|
||||
try:
|
||||
# ``set_agent_mode`` records the switch so the provider injects a notification on the
|
||||
# next turn. It raises ValueError when the requested mode is not configured.
|
||||
new_mode = set_agent_mode(
|
||||
session,
|
||||
parts[1],
|
||||
source_id=mode_provider.source_id,
|
||||
available_modes=available_modes,
|
||||
)
|
||||
print(f'Switched to "{new_mode}" mode.')
|
||||
except ValueError as ex:
|
||||
print(ex)
|
||||
|
||||
continue
|
||||
|
||||
# Anything else is a message for the agent. The mode provider injects the current mode (and
|
||||
# any pending mode-change notification) into the context for this turn.
|
||||
print(await agent.run(user_input, session=session))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
asyncio.run(main())
|
||||
|
||||
|
||||
"""
|
||||
Sample interaction (abridged; exact text varies by model):
|
||||
|
||||
Agent Mode sample. Type a message to chat, or use a slash command.
|
||||
Available modes: plan, execute
|
||||
Current mode: plan
|
||||
Commands:
|
||||
/mode Show the current mode
|
||||
/mode <name> Switch mode (plan | execute)
|
||||
/help Show this help
|
||||
/exit Quit
|
||||
|
||||
> Help me plan a blog post about the ocean.
|
||||
Sure — before we start writing, let's outline the sections and audience. ...
|
||||
> /mode execute
|
||||
Switched to "execute" mode.
|
||||
> Go ahead and write it.
|
||||
Working through the plan autonomously now. Here's the first draft ...
|
||||
> /exit
|
||||
"""
|
||||
@@ -0,0 +1,127 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
import asyncio
|
||||
import os
|
||||
|
||||
from agent_framework import Agent, AgentSession, TodoItem, TodoProvider
|
||||
from agent_framework.foundry import FoundryChatClient
|
||||
from azure.identity import AzureCliCredential
|
||||
from dotenv import load_dotenv
|
||||
|
||||
# Load environment variables from .env file
|
||||
load_dotenv()
|
||||
|
||||
"""
|
||||
Todo List — Track work items across turns with TodoProvider
|
||||
|
||||
This sample shows how to use the ``TodoProvider``, a ``ContextProvider`` that gives an agent a set
|
||||
of tools for managing a todo list (``todos_add``, ``todos_complete``, ``todos_remove``,
|
||||
``todos_get_remaining``, ``todos_get_all``) along with instructions on how to use them. The todo
|
||||
list is stored in the session state and persists across turns, so the agent can plan multi-step
|
||||
work, track progress, and adjust the list as the conversation evolves.
|
||||
|
||||
This is a scripted, non-interactive walkthrough: it sends a sequence of messages to the agent and,
|
||||
after each turn, prints the agent's reply followed by the current todo list (read directly from the
|
||||
provider's store). This lets you watch the todo state evolve as the agent adds, completes, and
|
||||
removes items.
|
||||
|
||||
Environment variables:
|
||||
FOUNDRY_PROJECT_ENDPOINT — Microsoft Foundry project endpoint URL
|
||||
FOUNDRY_MODEL — Model deployment name
|
||||
|
||||
Authentication:
|
||||
Run ``az login`` before running this sample.
|
||||
"""
|
||||
|
||||
|
||||
async def print_todo_list(todo_provider: TodoProvider, session: AgentSession) -> None:
|
||||
"""Read the current todo list straight from the provider's store and print it."""
|
||||
# The provider persists todos in its store keyed by ``source_id``; loading them lets the sample
|
||||
# display the state without going through the model.
|
||||
items: list[TodoItem] = await todo_provider.store.load_items(session, source_id=todo_provider.source_id)
|
||||
|
||||
print("--- Current todo list ---")
|
||||
if not items:
|
||||
print(" (empty)")
|
||||
return
|
||||
|
||||
for item in items:
|
||||
mark = "x" if item.is_complete else " "
|
||||
line = f" [{mark}] {item.id}. {item.title}"
|
||||
if item.description:
|
||||
line += f" — {item.description}"
|
||||
print(line)
|
||||
|
||||
|
||||
async def main() -> None:
|
||||
client = FoundryChatClient(
|
||||
project_endpoint=os.environ["FOUNDRY_PROJECT_ENDPOINT"],
|
||||
model=os.environ["FOUNDRY_MODEL"],
|
||||
credential=AzureCliCredential(),
|
||||
)
|
||||
|
||||
# <create_todo_provider>
|
||||
# Create the TodoProvider and attach it to the agent as a context provider. The provider
|
||||
# contributes the todo-management tools and instructions to every agent invocation.
|
||||
todo_provider = TodoProvider()
|
||||
|
||||
agent = Agent(
|
||||
client=client,
|
||||
name="PlanningAssistant",
|
||||
instructions="You are a helpful planning assistant. Use your todo list to plan and track multi-step work.",
|
||||
context_providers=[todo_provider],
|
||||
)
|
||||
# </create_todo_provider>
|
||||
|
||||
# Reuse a single session so the todo state persists across turns.
|
||||
session = agent.create_session()
|
||||
|
||||
# A scripted set of turns that exercises the provider end-to-end: the agent should add todos for
|
||||
# a multi-step request, mark items complete as progress is reported, and adjust the list on a
|
||||
# change of plan.
|
||||
user_messages = [
|
||||
"I'm organizing a small team offsite. Can you help me plan it? Break the work into a todo list.",
|
||||
"I've booked the venue and sent out the invites. Please update the list.",
|
||||
"Actually, let's skip catering and instead plan a group hike. Update the plan accordingly.",
|
||||
]
|
||||
|
||||
for user_message in user_messages:
|
||||
print(f"User: {user_message}")
|
||||
print(f"Agent: {await agent.run(user_message, session=session)}")
|
||||
|
||||
# Print the current todo list so the evolving state is visible after each turn.
|
||||
await print_todo_list(todo_provider, session)
|
||||
print()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
asyncio.run(main())
|
||||
|
||||
|
||||
"""
|
||||
Sample output (abridged; exact text varies by model):
|
||||
|
||||
User: I'm organizing a small team offsite. Can you help me plan it? Break the work into a todo list.
|
||||
Agent: Great — I've broken this into a starter plan. Let me know if you'd like to adjust anything.
|
||||
--- Current todo list ---
|
||||
[ ] 1. Pick a date and confirm attendees
|
||||
[ ] 2. Book a venue
|
||||
[ ] 3. Arrange catering
|
||||
[ ] 4. Send out invites
|
||||
|
||||
User: I've booked the venue and sent out the invites. Please update the list.
|
||||
Agent: Nice work! I've marked the venue and invites as done.
|
||||
--- Current todo list ---
|
||||
[ ] 1. Pick a date and confirm attendees
|
||||
[x] 2. Book a venue
|
||||
[ ] 3. Arrange catering
|
||||
[x] 4. Send out invites
|
||||
|
||||
User: Actually, let's skip catering and instead plan a group hike. Update the plan accordingly.
|
||||
Agent: Done — I removed catering and added a group hike to the plan.
|
||||
--- Current todo list ---
|
||||
[ ] 1. Pick a date and confirm attendees
|
||||
[x] 2. Book a venue
|
||||
[x] 4. Send out invites
|
||||
[ ] 5. Plan a group hike
|
||||
"""
|
||||
@@ -0,0 +1,85 @@
|
||||
# AG-UI Single Agent Demo
|
||||
|
||||
The simplest possible AG-UI integration: a **single chat agent** with **no tools** and **no context providers**,
|
||||
served over the AG-UI protocol and consumed by a small React client.
|
||||
|
||||
Use this sample as the starting point for AG-UI. For a richer, multi-agent example with tool-approval checkpoints
|
||||
and human-in-the-loop resumes, see [`../ag_ui_workflow_handoff`](../ag_ui_workflow_handoff/README.md).
|
||||
|
||||
## Folder Layout
|
||||
|
||||
- `backend/server.py` - FastAPI + AG-UI endpoint wrapping a single `Agent`
|
||||
- `frontend/` - Vite + React AG-UI client UI
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- Python 3.10+
|
||||
- Node.js 18+
|
||||
- npm 9+
|
||||
- Azure AI project + model deployment configured in environment variables:
|
||||
- `FOUNDRY_PROJECT_ENDPOINT`
|
||||
- `FOUNDRY_MODEL`
|
||||
|
||||
## 1) Run Backend
|
||||
|
||||
From the Python repo root:
|
||||
|
||||
```bash
|
||||
cd python
|
||||
uv sync
|
||||
uv run python samples/05-end-to-end/ag_ui_single_agent/backend/server.py
|
||||
```
|
||||
|
||||
Backend default URL:
|
||||
|
||||
- `http://127.0.0.1:8892`
|
||||
- AG-UI endpoint: `POST http://127.0.0.1:8892/agent`
|
||||
|
||||
## 2) Install Frontend Packages (npm)
|
||||
|
||||
From the `python/` directory (where Step 1 left you):
|
||||
|
||||
```bash
|
||||
cd samples/05-end-to-end/ag_ui_single_agent/frontend
|
||||
npm install
|
||||
```
|
||||
|
||||
## 3) Run Frontend Locally
|
||||
|
||||
```bash
|
||||
npm run dev
|
||||
```
|
||||
|
||||
Frontend default URL:
|
||||
|
||||
- `http://127.0.0.1:5173`
|
||||
|
||||
If you changed backend host/port, run with:
|
||||
|
||||
```bash
|
||||
VITE_BACKEND_URL=http://127.0.0.1:8892 npm run dev
|
||||
```
|
||||
|
||||
## 4) Demo Flow to Verify
|
||||
|
||||
1. Click one of the starter prompts (or type your own message).
|
||||
2. Watch the assistant response stream in token by token.
|
||||
3. Send a follow-up that depends on the previous turn (for example: "summarize what you just told me").
|
||||
The client only sends the newest message plus the `thread_id`; the server replays the stored history.
|
||||
4. Click **New Thread** to start a fresh conversation (a new `thread_id`).
|
||||
|
||||
## Conversation History
|
||||
|
||||
The client only ever sends the **newest message** plus a `thread_id`. The backend retains history **server-side**,
|
||||
keyed by that `thread_id`, using an `InMemoryAGUIThreadSnapshotStore`. Because an AG-UI thread id is not an
|
||||
authorization boundary, a `snapshot_scope_resolver` is required whenever a snapshot store is configured; this
|
||||
single-tenant demo maps every request to one shared `"demo"` scope.
|
||||
|
||||
The in-memory store is process-local and not durable. Swap in your own `AGUIThreadSnapshotStore` implementation
|
||||
(and a real scope resolver) for production.
|
||||
|
||||
## What This Validates
|
||||
|
||||
- `add_agent_framework_fastapi_endpoint(...)` with a plain `Agent` (no `AgentFrameworkWorkflow` wrapper)
|
||||
- Streaming assistant text via `TEXT_MESSAGE_START` / `TEXT_MESSAGE_CONTENT` / `TEXT_MESSAGE_END` AG-UI events
|
||||
- Server-side conversation history keyed by `thread_id` via a snapshot store
|
||||
@@ -0,0 +1,105 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
"""AG-UI single-agent demo backend.
|
||||
|
||||
This is the simplest possible AG-UI integration: a single chat agent with no
|
||||
tools and no context providers, exposed over the AG-UI protocol.
|
||||
|
||||
Run this server and pair it with the frontend in `../frontend`.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import os
|
||||
|
||||
import uvicorn
|
||||
from agent_framework import Agent
|
||||
from agent_framework.ag_ui import (
|
||||
InMemoryAGUIThreadSnapshotStore,
|
||||
add_agent_framework_fastapi_endpoint,
|
||||
)
|
||||
from dotenv import load_dotenv
|
||||
from fastapi import FastAPI
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
|
||||
load_dotenv()
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def create_agent() -> Agent:
|
||||
"""Create a single chat agent with no tools and no context providers."""
|
||||
|
||||
from agent_framework.foundry import FoundryChatClient
|
||||
from azure.identity import AzureCliCredential
|
||||
|
||||
client = FoundryChatClient(
|
||||
project_endpoint=os.environ["FOUNDRY_PROJECT_ENDPOINT"],
|
||||
model=os.environ["FOUNDRY_MODEL"],
|
||||
credential=AzureCliCredential(),
|
||||
)
|
||||
|
||||
return Agent(
|
||||
id="assistant",
|
||||
name="assistant",
|
||||
instructions="You are a helpful, concise assistant. Answer the user's questions directly.",
|
||||
client=client,
|
||||
)
|
||||
|
||||
|
||||
def create_app() -> FastAPI:
|
||||
"""Create and configure the FastAPI application."""
|
||||
|
||||
app = FastAPI(title="AG-UI Single Agent Demo")
|
||||
|
||||
cors_origins = [
|
||||
origin.strip() for origin in os.getenv("CORS_ORIGINS", "http://127.0.0.1:5173").split(",") if origin.strip()
|
||||
]
|
||||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
allow_origins=cors_origins,
|
||||
allow_credentials=True,
|
||||
allow_methods=["*"],
|
||||
allow_headers=["*"],
|
||||
)
|
||||
|
||||
add_agent_framework_fastapi_endpoint(
|
||||
app=app,
|
||||
agent=create_agent(),
|
||||
path="/agent",
|
||||
# Persist conversation history server-side, keyed by thread_id, so the
|
||||
# client only ever sends the newest message plus its thread_id.
|
||||
snapshot_store=InMemoryAGUIThreadSnapshotStore(),
|
||||
# AG-UI thread ids are not an authorization boundary, so a scope is required
|
||||
# when a snapshot store is configured. This demo is single-tenant, so every
|
||||
# request maps to one shared scope.
|
||||
snapshot_scope_resolver=lambda _request: "demo",
|
||||
)
|
||||
|
||||
@app.get("/healthz")
|
||||
async def healthz() -> dict[str, str]:
|
||||
return {"status": "ok"}
|
||||
|
||||
return app
|
||||
|
||||
|
||||
app = create_app()
|
||||
|
||||
|
||||
def main() -> None:
|
||||
"""Run the AG-UI single-agent demo backend."""
|
||||
|
||||
logging.basicConfig(level=logging.INFO, format="%(asctime)s - %(name)s - %(levelname)s - %(message)s")
|
||||
|
||||
host = os.getenv("HOST", "127.0.0.1")
|
||||
port = int(os.getenv("PORT", "8892"))
|
||||
|
||||
print(f"AG-UI single-agent demo backend running at http://{host}:{port}")
|
||||
print("AG-UI endpoint: POST /agent")
|
||||
|
||||
uvicorn.run(app, host=host, port=port)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,7 @@
|
||||
# dependencies
|
||||
/node_modules
|
||||
|
||||
# build artifacts
|
||||
*.tsbuildinfo
|
||||
vite.config.js
|
||||
vite.config.d.ts
|
||||
@@ -0,0 +1,13 @@
|
||||
<!doctype html>
|
||||
<!-- Copyright (c) Microsoft. All rights reserved. -->
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>AG-UI Single Agent Demo</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
+1031
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"name": "ag-ui-single-agent-demo-frontend",
|
||||
"private": true,
|
||||
"version": "0.1.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc -b && vite build",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^22.10.1",
|
||||
"@types/react": "^18.3.3",
|
||||
"@types/react-dom": "^18.3.0",
|
||||
"@vitejs/plugin-react": "^6.0.2",
|
||||
"typescript": "^5.5.4",
|
||||
"vite": "^8.0.16"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,281 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
import { FormEvent, useEffect, useMemo, useRef, useState } from "react";
|
||||
|
||||
type AgUiEvent = Record<string, unknown> & { type: string };
|
||||
|
||||
interface ChatMessage {
|
||||
id: string;
|
||||
role: "assistant" | "user" | "system";
|
||||
text: string;
|
||||
}
|
||||
|
||||
const BACKEND_URL = import.meta.env.VITE_BACKEND_URL ?? "http://127.0.0.1:8892";
|
||||
const ENDPOINT = `${BACKEND_URL}/agent`;
|
||||
|
||||
const STARTER_PROMPTS = [
|
||||
"Explain the AG-UI protocol in two sentences.",
|
||||
"Give me three tips for writing clear commit messages.",
|
||||
];
|
||||
|
||||
function randomId(): string {
|
||||
if (typeof crypto !== "undefined" && typeof crypto.randomUUID === "function") {
|
||||
return crypto.randomUUID();
|
||||
}
|
||||
return `id-${Math.random().toString(16).slice(2)}`;
|
||||
}
|
||||
|
||||
function isObject(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === "object" && value !== null;
|
||||
}
|
||||
|
||||
function safeParseJson(value: string): unknown {
|
||||
try {
|
||||
return JSON.parse(value);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export default function App() {
|
||||
const [messages, setMessages] = useState<ChatMessage[]>([]);
|
||||
const [draft, setDraft] = useState("");
|
||||
const [isRunning, setIsRunning] = useState(false);
|
||||
const [statusText, setStatusText] = useState("Ready");
|
||||
|
||||
const threadIdRef = useRef<string>(randomId());
|
||||
const streamingMessageIdRef = useRef<string | null>(null);
|
||||
const transcriptRef = useRef<HTMLDivElement | null>(null);
|
||||
|
||||
const canSend = useMemo(() => draft.trim().length > 0 && !isRunning, [draft, isRunning]);
|
||||
|
||||
useEffect(() => {
|
||||
const node = transcriptRef.current;
|
||||
if (node) {
|
||||
node.scrollTop = node.scrollHeight;
|
||||
}
|
||||
}, [messages]);
|
||||
|
||||
const pushMessage = (message: ChatMessage): void => {
|
||||
setMessages((prev) => [...prev, message]);
|
||||
};
|
||||
|
||||
const appendToStreamingMessage = (messageId: string, delta: string): void => {
|
||||
setMessages((prev) => {
|
||||
const existing = prev.find((message) => message.id === messageId);
|
||||
if (existing) {
|
||||
return prev.map((message) =>
|
||||
message.id === messageId ? { ...message, text: `${message.text}${delta}` } : message,
|
||||
);
|
||||
}
|
||||
return [...prev, { id: messageId, role: "assistant", text: delta }];
|
||||
});
|
||||
};
|
||||
|
||||
const handleEvent = (event: AgUiEvent): void => {
|
||||
switch (event.type) {
|
||||
case "RUN_STARTED":
|
||||
setStatusText("Thinking");
|
||||
break;
|
||||
case "TEXT_MESSAGE_START": {
|
||||
const messageId = typeof event.message_id === "string" ? event.message_id : randomId();
|
||||
streamingMessageIdRef.current = messageId;
|
||||
break;
|
||||
}
|
||||
case "TEXT_MESSAGE_CONTENT": {
|
||||
const messageId =
|
||||
typeof event.message_id === "string" ? event.message_id : streamingMessageIdRef.current ?? randomId();
|
||||
const delta = typeof event.delta === "string" ? event.delta : "";
|
||||
if (delta.length > 0) {
|
||||
setStatusText("Responding");
|
||||
appendToStreamingMessage(messageId, delta);
|
||||
}
|
||||
break;
|
||||
}
|
||||
case "TEXT_MESSAGE_END":
|
||||
streamingMessageIdRef.current = null;
|
||||
break;
|
||||
case "RUN_FINISHED":
|
||||
setStatusText("Ready");
|
||||
setIsRunning(false);
|
||||
break;
|
||||
case "RUN_ERROR": {
|
||||
const errorText = typeof event.message === "string" ? event.message : "The run failed.";
|
||||
pushMessage({ id: randomId(), role: "system", text: `Error: ${errorText}` });
|
||||
setStatusText("Error");
|
||||
setIsRunning(false);
|
||||
break;
|
||||
}
|
||||
default:
|
||||
break;
|
||||
}
|
||||
};
|
||||
|
||||
const streamRun = async (body: Record<string, unknown>): Promise<void> => {
|
||||
const response = await fetch(ENDPOINT, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Accept: "text/event-stream",
|
||||
},
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
|
||||
if (!response.ok || !response.body) {
|
||||
throw new Error(`Request failed: ${response.status}`);
|
||||
}
|
||||
|
||||
const reader = response.body.getReader();
|
||||
const decoder = new TextDecoder("utf-8");
|
||||
let buffer = "";
|
||||
|
||||
const processSseChunk = (rawChunk: string): void => {
|
||||
const dataLines = rawChunk
|
||||
.split("\n")
|
||||
.filter((line) => line.startsWith("data:"))
|
||||
.map((line) => line.slice(5).trim());
|
||||
|
||||
if (dataLines.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const parsed = safeParseJson(dataLines.join("\n"));
|
||||
if (isObject(parsed) && typeof parsed.type === "string") {
|
||||
handleEvent(parsed as AgUiEvent);
|
||||
}
|
||||
};
|
||||
|
||||
while (true) {
|
||||
const { value, done } = await reader.read();
|
||||
if (done) {
|
||||
break;
|
||||
}
|
||||
|
||||
buffer += decoder.decode(value, { stream: true });
|
||||
|
||||
while (true) {
|
||||
const boundaryIndex = buffer.indexOf("\n\n");
|
||||
if (boundaryIndex < 0) {
|
||||
break;
|
||||
}
|
||||
const rawEvent = buffer.slice(0, boundaryIndex);
|
||||
buffer = buffer.slice(boundaryIndex + 2);
|
||||
processSseChunk(rawEvent);
|
||||
}
|
||||
}
|
||||
|
||||
const tail = buffer.trim();
|
||||
if (tail.length > 0) {
|
||||
processSseChunk(tail);
|
||||
}
|
||||
};
|
||||
|
||||
const sendMessage = async (text: string): Promise<void> => {
|
||||
const trimmed = text.trim();
|
||||
if (trimmed.length === 0 || isRunning) {
|
||||
return;
|
||||
}
|
||||
|
||||
pushMessage({ id: randomId(), role: "user", text: trimmed });
|
||||
setDraft("");
|
||||
setIsRunning(true);
|
||||
setStatusText("Connecting");
|
||||
streamingMessageIdRef.current = null;
|
||||
|
||||
try {
|
||||
await streamRun({
|
||||
thread_id: threadIdRef.current,
|
||||
run_id: randomId(),
|
||||
messages: [{ role: "user", content: trimmed }],
|
||||
});
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : "Unknown error";
|
||||
pushMessage({ id: randomId(), role: "system", text: `Network error: ${message}` });
|
||||
setStatusText("Network error");
|
||||
setIsRunning(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSubmit = (event: FormEvent<HTMLFormElement>): void => {
|
||||
event.preventDefault();
|
||||
void sendMessage(draft);
|
||||
};
|
||||
|
||||
const startNewThread = (): void => {
|
||||
threadIdRef.current = randomId();
|
||||
streamingMessageIdRef.current = null;
|
||||
setMessages([]);
|
||||
setDraft("");
|
||||
setStatusText("Ready");
|
||||
setIsRunning(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="page-shell">
|
||||
<header className="hero">
|
||||
<div>
|
||||
<p className="eyebrow">Agent Framework · AG-UI</p>
|
||||
<h1>Single Agent Chat</h1>
|
||||
<p className="subtitle">
|
||||
The simplest AG-UI integration: one chat agent with no tools and no context providers, streamed to a React
|
||||
client over Server-Sent Events.
|
||||
</p>
|
||||
</div>
|
||||
<div className="status-pill" data-running={isRunning}>
|
||||
<span>Status</span>
|
||||
<strong>{statusText}</strong>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main className="card chat-card">
|
||||
<div className="chat-toolbar">
|
||||
<h2>Conversation</h2>
|
||||
<button type="button" className="ghost-button" onClick={startNewThread} disabled={isRunning}>
|
||||
New Thread
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="transcript" ref={transcriptRef}>
|
||||
{messages.length === 0 ? (
|
||||
<div className="empty-state">
|
||||
<p>Start the conversation with a prompt:</p>
|
||||
<div className="starter-prompts">
|
||||
{STARTER_PROMPTS.map((prompt) => (
|
||||
<button
|
||||
key={prompt}
|
||||
type="button"
|
||||
className="starter-prompt"
|
||||
onClick={() => void sendMessage(prompt)}
|
||||
disabled={isRunning}
|
||||
>
|
||||
{prompt}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
messages.map((message) => (
|
||||
<div key={message.id} className={`bubble bubble-${message.role}`}>
|
||||
<span className="bubble-role">{message.role}</span>
|
||||
<p>{message.text}</p>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
|
||||
<form className="composer" onSubmit={handleSubmit}>
|
||||
<input
|
||||
type="text"
|
||||
value={draft}
|
||||
placeholder="Send a message..."
|
||||
onChange={(event) => setDraft(event.target.value)}
|
||||
disabled={isRunning}
|
||||
/>
|
||||
<button type="submit" className="send-button" disabled={!canSend}>
|
||||
Send
|
||||
</button>
|
||||
</form>
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
import React from "react";
|
||||
import ReactDOM from "react-dom/client";
|
||||
|
||||
import App from "./App";
|
||||
import "./styles.css";
|
||||
|
||||
ReactDOM.createRoot(document.getElementById("root")!).render(
|
||||
<React.StrictMode>
|
||||
<App />
|
||||
</React.StrictMode>,
|
||||
);
|
||||
@@ -0,0 +1,259 @@
|
||||
/* Copyright (c) Microsoft. All rights reserved. */
|
||||
|
||||
:root {
|
||||
--page-bg: #edf4f8;
|
||||
--panel-bg: #fdfdfd;
|
||||
--ink: #132534;
|
||||
--muted: #607487;
|
||||
--line: #c6d6e2;
|
||||
--teal: #1f9d8b;
|
||||
--teal-dark: #11756a;
|
||||
--shadow: 0 20px 45px rgb(15 35 51 / 14%);
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
font-family: "IBM Plex Sans", "Avenir Next", "Helvetica Neue", sans-serif;
|
||||
color: var(--ink);
|
||||
background:
|
||||
radial-gradient(circle at 12% 8%, rgb(31 157 139 / 20%) 0%, transparent 28%),
|
||||
radial-gradient(circle at 88% 18%, rgb(255 154 60 / 20%) 0%, transparent 30%),
|
||||
linear-gradient(150deg, #eff6fa 0%, #dceaf3 46%, #e7f1f6 100%);
|
||||
}
|
||||
|
||||
.page-shell {
|
||||
min-height: 100vh;
|
||||
max-width: 860px;
|
||||
margin: 0 auto;
|
||||
padding: 28px;
|
||||
animation: fade-in 320ms ease-out;
|
||||
}
|
||||
|
||||
.hero {
|
||||
display: flex;
|
||||
gap: 20px;
|
||||
justify-content: space-between;
|
||||
align-items: flex-end;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.eyebrow {
|
||||
margin: 0;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.16em;
|
||||
font-size: 0.72rem;
|
||||
color: var(--teal-dark);
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.hero h1 {
|
||||
margin: 6px 0 8px;
|
||||
font-size: clamp(1.6rem, 2.8vw, 2.4rem);
|
||||
line-height: 1.15;
|
||||
}
|
||||
|
||||
.subtitle {
|
||||
margin: 0;
|
||||
max-width: 60ch;
|
||||
color: var(--muted);
|
||||
line-height: 1.45;
|
||||
}
|
||||
|
||||
.status-pill {
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 999px;
|
||||
padding: 10px 16px;
|
||||
background: #fff;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-width: 150px;
|
||||
box-shadow: 0 8px 20px rgb(19 37 52 / 8%);
|
||||
}
|
||||
|
||||
.status-pill span {
|
||||
font-size: 0.72rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.08em;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.status-pill strong {
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.status-pill[data-running="true"] {
|
||||
border-color: var(--teal);
|
||||
}
|
||||
|
||||
.card {
|
||||
background: var(--panel-bg);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 18px;
|
||||
box-shadow: var(--shadow);
|
||||
padding: 18px;
|
||||
}
|
||||
|
||||
.chat-card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 14px;
|
||||
min-height: 60vh;
|
||||
}
|
||||
|
||||
.chat-toolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.chat-toolbar h2 {
|
||||
margin: 0;
|
||||
font-size: 1.1rem;
|
||||
}
|
||||
|
||||
.ghost-button {
|
||||
border: 1px solid var(--line);
|
||||
background: #fff;
|
||||
color: var(--teal-dark);
|
||||
border-radius: 999px;
|
||||
padding: 6px 14px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.ghost-button:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.transcript {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
padding: 6px 2px;
|
||||
max-height: 52vh;
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
color: var(--muted);
|
||||
display: grid;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.starter-prompts {
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.starter-prompt {
|
||||
text-align: left;
|
||||
border: 1px dashed var(--line);
|
||||
background: #f6fafc;
|
||||
border-radius: 12px;
|
||||
padding: 12px 14px;
|
||||
color: var(--ink);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.starter-prompt:hover:not(:disabled) {
|
||||
border-color: var(--teal);
|
||||
}
|
||||
|
||||
.starter-prompt:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.bubble {
|
||||
border-radius: 14px;
|
||||
padding: 10px 14px;
|
||||
max-width: 82%;
|
||||
border: 1px solid var(--line);
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.bubble p {
|
||||
margin: 4px 0 0;
|
||||
white-space: pre-wrap;
|
||||
line-height: 1.45;
|
||||
}
|
||||
|
||||
.bubble-role {
|
||||
font-size: 0.68rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.08em;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.bubble-user {
|
||||
align-self: flex-end;
|
||||
background: var(--teal);
|
||||
border-color: var(--teal-dark);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.bubble-user .bubble-role {
|
||||
color: rgb(255 255 255 / 80%);
|
||||
}
|
||||
|
||||
.bubble-assistant {
|
||||
align-self: flex-start;
|
||||
}
|
||||
|
||||
.bubble-system {
|
||||
align-self: center;
|
||||
background: #fff4e6;
|
||||
border-color: #ffcf99;
|
||||
color: #8a5200;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.composer {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.composer input {
|
||||
flex: 1;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 12px;
|
||||
padding: 12px 14px;
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.composer input:focus {
|
||||
outline: none;
|
||||
border-color: var(--teal);
|
||||
}
|
||||
|
||||
.send-button {
|
||||
border: none;
|
||||
background: var(--teal);
|
||||
color: #fff;
|
||||
border-radius: 12px;
|
||||
padding: 12px 22px;
|
||||
font-weight: 700;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.send-button:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
@keyframes fade-in {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(6px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
/// <reference types="vite/client" />
|
||||
@@ -0,0 +1,20 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2020",
|
||||
"useDefineForClassFields": true,
|
||||
"lib": ["ES2020", "DOM", "DOM.Iterable"],
|
||||
"module": "ESNext",
|
||||
"skipLibCheck": true,
|
||||
"moduleResolution": "Bundler",
|
||||
"allowImportingTsExtensions": false,
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"noEmit": true,
|
||||
"jsx": "react-jsx",
|
||||
"strict": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true
|
||||
},
|
||||
"include": ["src"],
|
||||
"references": [{ "path": "./tsconfig.node.json" }]
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"composite": true,
|
||||
"target": "ES2020",
|
||||
"lib": ["ES2020"],
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "Bundler",
|
||||
"allowSyntheticDefaultImports": true,
|
||||
"types": ["node"],
|
||||
"skipLibCheck": true
|
||||
},
|
||||
"include": ["vite.config.ts"]
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
import { defineConfig } from "vite";
|
||||
import react from "@vitejs/plugin-react";
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
server: {
|
||||
host: "127.0.0.1",
|
||||
port: 5173,
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"name": "ag_ui_single_agent",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {}
|
||||
}
|
||||
@@ -12,10 +12,19 @@ Run the commands below from the `python/` directory.
|
||||
|
||||
- `validate_dependency_bounds.py`
|
||||
- Main entrypoint for dependency-bound workflows.
|
||||
- Supports `test`, `lower`, `upper`, and `both` modes.
|
||||
- `test` runs workspace-wide smoke validation at the lower and upper ends of the currently allowed ranges.
|
||||
- Supports `release`, `test`, `lower`, `upper`, and `both` modes.
|
||||
- `release` refreshes `uv.lock`, then runs changed packages through fast lock-independent lower/upper import probes.
|
||||
- `test` runs the exhaustive workspace test+typing compatibility matrix.
|
||||
- `lower`, `upper`, and `both` dispatch to the lower/upper optimizer implementations for one package.
|
||||
|
||||
- `_dependency_bounds_release_impl.py`
|
||||
- Discovers package metadata changed from the selected release base.
|
||||
- Resolves published runtime dependencies and non-development extras independently of `uv.lock` with both
|
||||
`lowest-direct` and `highest` strategies.
|
||||
- Derives the minimum supported Python minor from each changed package's internal editable dependency closure.
|
||||
- Imports each changed package and records resolved dependency versions in a JSON report.
|
||||
- Runs probes concurrently under one five-minute deadline.
|
||||
|
||||
- `upgrade_dev_dependencies.py`
|
||||
- Refreshes exact dev dependency pins across the root `pyproject.toml` and package `pyproject.toml` files.
|
||||
- Reuses the same version-selection logic as the upper-bound tooling so direct dev-tooling refreshes and dependency-range expansion stay consistent.
|
||||
@@ -45,6 +54,7 @@ These are the normal user-facing entrypoints:
|
||||
```bash
|
||||
uv run poe upgrade-dev-dependency-pins
|
||||
uv run poe upgrade-dev-dependencies
|
||||
uv run poe validate-python-release --base-ref upstream/main
|
||||
uv run poe validate-dependency-bounds-test
|
||||
uv run poe validate-dependency-bounds-test --package core
|
||||
uv run poe validate-dependency-bounds-project --mode both --package core --dependency "<dependency-name>"
|
||||
@@ -52,7 +62,10 @@ uv run poe validate-dependency-bounds-project --mode both --package core --depen
|
||||
|
||||
- `upgrade-dev-dependency-pins` only refreshes exact dev pins in `pyproject.toml` files.
|
||||
- `upgrade-dev-dependencies` refreshes dev pins (using task above), runs `uv lock --upgrade`, reinstalls from the frozen lockfile, then runs `check`, `typing`, and `test`.
|
||||
- `validate-dependency-bounds-test` runs the repo-wide lower/upper smoke gate.
|
||||
- `validate-python-release` is the bounded release gate: it refreshes `uv.lock`, finds changed package metadata,
|
||||
and probes both dependency-bound extremes without reusing the lockfile.
|
||||
- `validate-dependency-bounds-test` runs the exhaustive package test+typing matrix and is intentionally not part of
|
||||
the routine release path.
|
||||
- `validate-dependency-bounds-project` is the single package-scoped task; use `--mode lower`, `--mode upper`, or `--mode both` for the target package/dependency pair. Its `--package` argument defaults to `*`, and `--dependency` is optional, so automation can also use it for repo-wide upper-bound runs.
|
||||
|
||||
### GitHub Actions workflows
|
||||
@@ -76,6 +89,7 @@ These are useful for debugging or targeted manual runs:
|
||||
|
||||
```bash
|
||||
python -m scripts.dependencies.upgrade_dev_dependencies --dry-run --version-source lock
|
||||
python -m scripts.dependencies.validate_dependency_bounds --mode release --base-ref upstream/main --dry-run
|
||||
python -m scripts.dependencies.validate_dependency_bounds --mode test --package core --dry-run
|
||||
python -m scripts.dependencies.validate_dependency_bounds --mode both --package core --dependencies openai --dry-run
|
||||
python -m scripts.dependencies._dependency_bounds_lower_impl --packages core --dependencies openai --dry-run
|
||||
@@ -89,6 +103,7 @@ Use the direct lower/upper implementation modules mainly for debugging or develo
|
||||
The validators write JSON reports into this folder:
|
||||
|
||||
- `dependency-bounds-test-results.json`
|
||||
- `dependency-bounds-release-results.json`
|
||||
- `dependency-lower-bound-results.json`
|
||||
- `dependency-range-results.json`
|
||||
|
||||
|
||||
@@ -0,0 +1,574 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
# ruff:file-ignore[suspicious-subprocess-import, subprocess-without-shell-equals-true]
|
||||
|
||||
"""Fast, lock-independent dependency-bound probes for Python release cuts."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import concurrent.futures
|
||||
import json
|
||||
import os
|
||||
import subprocess
|
||||
import time
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
from typing import Any, cast
|
||||
|
||||
import tomli
|
||||
from packaging.requirements import InvalidRequirement, Requirement
|
||||
from packaging.specifiers import SpecifierSet
|
||||
from packaging.utils import canonicalize_name
|
||||
from packaging.version import Version
|
||||
from rich import print
|
||||
|
||||
from scripts.task_runner import discover_projects, project_filter_matches
|
||||
|
||||
_PROBE_RESULT_PREFIX = "DEPENDENCY_BOUNDS_RELEASE_RESULT="
|
||||
_RESOLUTION_SCENARIOS = (("lower", "lowest-direct"), ("upper", "highest"))
|
||||
|
||||
|
||||
@dataclass
|
||||
class ReleaseProject:
|
||||
"""Published metadata needed to build a release probe."""
|
||||
|
||||
project_path: Path
|
||||
package_name: str
|
||||
requires_python: str
|
||||
dependencies: tuple[str, ...]
|
||||
optional_dependencies: dict[str, tuple[str, ...]]
|
||||
import_modules: tuple[str, ...]
|
||||
|
||||
|
||||
@dataclass
|
||||
class ReleaseProbePlan:
|
||||
"""One changed package and the local projects needed to resolve it."""
|
||||
|
||||
project_path: Path
|
||||
package_name: str
|
||||
editable_specs: tuple[str, ...]
|
||||
import_modules: tuple[str, ...]
|
||||
reported_distributions: tuple[str, ...]
|
||||
python_version: str
|
||||
|
||||
|
||||
def _utc_now() -> str:
|
||||
return datetime.now(timezone.utc).isoformat()
|
||||
|
||||
|
||||
def _write_json(path: Path, payload: dict[str, Any]) -> None:
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
path.write_text(json.dumps(payload, indent=2, sort_keys=False))
|
||||
|
||||
|
||||
def _truncate_error(stdout: str, stderr: str, *, max_chars: int = 3000) -> str:
|
||||
combined = "\n".join(part for part in (stderr.strip(), stdout.strip()) if part)
|
||||
if len(combined) <= max_chars:
|
||||
return combined
|
||||
return f"...\n{combined[-max_chars:]}"
|
||||
|
||||
|
||||
def _string_requirements(values: object) -> tuple[str, ...]:
|
||||
if not isinstance(values, list):
|
||||
return ()
|
||||
return tuple(value for value in cast(list[object], values) if isinstance(value, str))
|
||||
|
||||
|
||||
def _discover_import_modules(project_path: Path, config: dict[str, Any]) -> tuple[str, ...]:
|
||||
"""Discover top-level import names from the project's build configuration."""
|
||||
modules: set[str] = set()
|
||||
tool = cast(dict[str, Any], config.get("tool", {}) or {})
|
||||
|
||||
flit = cast(dict[str, Any], tool.get("flit", {}) or {})
|
||||
flit_module_config = cast(dict[str, Any], flit.get("module", {}) or {})
|
||||
flit_module = flit_module_config.get("name")
|
||||
if isinstance(flit_module, str) and flit_module:
|
||||
modules.add(flit_module)
|
||||
|
||||
hatch = cast(dict[str, Any], tool.get("hatch", {}) or {})
|
||||
hatch_build = cast(dict[str, Any], hatch.get("build", {}) or {})
|
||||
hatch_targets = cast(dict[str, Any], hatch_build.get("targets", {}) or {})
|
||||
hatch_wheel = cast(dict[str, Any], hatch_targets.get("wheel", {}) or {})
|
||||
hatch_packages = hatch_wheel.get("packages", [])
|
||||
if isinstance(hatch_packages, list):
|
||||
for package in cast(list[object], hatch_packages):
|
||||
if isinstance(package, str) and package:
|
||||
modules.add(Path(package).name.split(".", 1)[0])
|
||||
|
||||
setuptools = cast(dict[str, Any], tool.get("setuptools", {}) or {})
|
||||
setuptools_packages = setuptools.get("packages", [])
|
||||
if isinstance(setuptools_packages, list):
|
||||
for package in cast(list[object], setuptools_packages):
|
||||
if isinstance(package, str) and package:
|
||||
modules.add(package.split(".", 1)[0])
|
||||
|
||||
if not modules:
|
||||
for candidate in project_path.glob("agent_framework*"):
|
||||
if candidate.is_dir() and (candidate / "__init__.py").exists():
|
||||
modules.add(candidate.name)
|
||||
elif candidate.is_file() and candidate.suffix == ".py":
|
||||
modules.add(candidate.stem)
|
||||
|
||||
return tuple(sorted(modules))
|
||||
|
||||
|
||||
def _load_release_project(workspace_root: Path, project_path: Path) -> ReleaseProject:
|
||||
pyproject_file = workspace_root / project_path / "pyproject.toml"
|
||||
with pyproject_file.open("rb") as file:
|
||||
config = tomli.load(file)
|
||||
|
||||
project = cast(dict[str, Any], config.get("project", {}) or {})
|
||||
package_name = str(project.get("name", "")).strip()
|
||||
if not package_name:
|
||||
raise RuntimeError(f"Missing project.name in {pyproject_file}")
|
||||
requires_python = str(project.get("requires-python", "")).strip()
|
||||
if not requires_python:
|
||||
raise RuntimeError(f"Missing project.requires-python in {pyproject_file}")
|
||||
|
||||
optional_dependencies: dict[str, tuple[str, ...]] = {}
|
||||
optional_config = cast(dict[str, object], project.get("optional-dependencies", {}) or {})
|
||||
for extra_name, requirements in optional_config.items():
|
||||
optional_dependencies[extra_name] = _string_requirements(requirements)
|
||||
|
||||
return ReleaseProject(
|
||||
project_path=project_path,
|
||||
package_name=package_name,
|
||||
requires_python=requires_python,
|
||||
dependencies=_string_requirements(project.get("dependencies", [])),
|
||||
optional_dependencies=optional_dependencies,
|
||||
import_modules=_discover_import_modules(pyproject_file.parent, config),
|
||||
)
|
||||
|
||||
|
||||
def _build_release_project_map(workspace_root: Path) -> dict[str, ReleaseProject]:
|
||||
project_paths = [Path("."), *sorted(set(discover_projects(workspace_root / "pyproject.toml")))]
|
||||
projects: dict[str, ReleaseProject] = {}
|
||||
for project_path in project_paths:
|
||||
pyproject_file = workspace_root / project_path / "pyproject.toml"
|
||||
if not pyproject_file.exists():
|
||||
continue
|
||||
project = _load_release_project(workspace_root, project_path)
|
||||
projects[canonicalize_name(project.package_name)] = project
|
||||
return projects
|
||||
|
||||
|
||||
def _changed_release_project_paths(workspace_root: Path, base_ref: str) -> set[Path]:
|
||||
command = [
|
||||
"git",
|
||||
"diff",
|
||||
"--relative",
|
||||
"--name-only",
|
||||
"--diff-filter=ACMR",
|
||||
base_ref,
|
||||
"--",
|
||||
"pyproject.toml",
|
||||
"packages/*/pyproject.toml",
|
||||
]
|
||||
result = subprocess.run(command, cwd=workspace_root, capture_output=True, text=True, check=False)
|
||||
if result.returncode != 0:
|
||||
error = _truncate_error(result.stdout, result.stderr)
|
||||
raise RuntimeError(f"Unable to compare release metadata with {base_ref}.\n{error}")
|
||||
|
||||
project_paths: set[Path] = set()
|
||||
for line in result.stdout.splitlines():
|
||||
changed_file = Path(line.strip())
|
||||
if changed_file == Path("pyproject.toml"):
|
||||
project_paths.add(Path("."))
|
||||
elif len(changed_file.parts) == 3 and changed_file.parts[0] == "packages":
|
||||
project_paths.add(changed_file.parent)
|
||||
return project_paths
|
||||
|
||||
|
||||
def _selected_release_projects(
|
||||
*,
|
||||
workspace_root: Path,
|
||||
projects: dict[str, ReleaseProject],
|
||||
base_ref: str,
|
||||
package_filter: str | None,
|
||||
) -> list[ReleaseProject]:
|
||||
if package_filter:
|
||||
selected = [
|
||||
project
|
||||
for project in projects.values()
|
||||
if project_filter_matches(project.project_path, package_filter, [project.package_name])
|
||||
]
|
||||
else:
|
||||
changed_paths = _changed_release_project_paths(workspace_root, base_ref)
|
||||
selected = [project for project in projects.values() if project.project_path in changed_paths]
|
||||
|
||||
return sorted(selected, key=lambda project: str(project.project_path))
|
||||
|
||||
|
||||
def _requirements_for_extras(project: ReleaseProject, extras: set[str]) -> tuple[str, ...]:
|
||||
requirements = list(project.dependencies)
|
||||
for extra_name in sorted(extras):
|
||||
requirements.extend(project.optional_dependencies.get(extra_name, ()))
|
||||
return tuple(requirements)
|
||||
|
||||
|
||||
def _minimum_python_version(projects: list[ReleaseProject]) -> str:
|
||||
"""Return the lowest Python minor supported by every project in a probe closure."""
|
||||
constraints = [project.requires_python for project in projects]
|
||||
combined = SpecifierSet(",".join(constraints))
|
||||
lower_bounds = [
|
||||
Version(specifier.version.rstrip(".*"))
|
||||
for specifier in combined
|
||||
if specifier.operator in {">", ">=", "~=", "=="} and specifier.version.rstrip(".*")
|
||||
]
|
||||
if not lower_bounds:
|
||||
package_names = ", ".join(sorted(project.package_name for project in projects))
|
||||
raise RuntimeError(f"Unable to derive a Python floor from requires-python for: {package_names}")
|
||||
|
||||
floor = max(lower_bounds)
|
||||
python_version = f"{floor.major}.{floor.minor}"
|
||||
first_patch = Version(python_version)
|
||||
later_patch = Version(f"{python_version}.999999")
|
||||
if first_patch not in combined and later_patch not in combined:
|
||||
package_names = ", ".join(sorted(project.package_name for project in projects))
|
||||
raise RuntimeError(
|
||||
f"No Python {python_version} interpreter satisfies the combined requires-python constraints for: "
|
||||
f"{package_names}"
|
||||
)
|
||||
return python_version
|
||||
|
||||
|
||||
def _build_release_probe_plan(
|
||||
workspace_root: Path,
|
||||
target: ReleaseProject,
|
||||
projects: dict[str, ReleaseProject],
|
||||
) -> ReleaseProbePlan:
|
||||
"""Build the exact internal editable closure for one changed package."""
|
||||
target_name = canonicalize_name(target.package_name)
|
||||
# Development extras are contributor tooling, not runtime compatibility surface.
|
||||
requested_extras: dict[str, set[str]] = {
|
||||
target_name: {extra for extra in target.optional_dependencies if extra != "dev"}
|
||||
}
|
||||
processed_extras: dict[str, set[str]] = {}
|
||||
pending = [target_name]
|
||||
|
||||
while pending:
|
||||
package_name = pending.pop()
|
||||
project = projects[package_name]
|
||||
extras = requested_extras[package_name]
|
||||
if processed_extras.get(package_name) == extras:
|
||||
continue
|
||||
processed_extras[package_name] = set(extras)
|
||||
|
||||
for requirement_text in _requirements_for_extras(project, extras):
|
||||
try:
|
||||
requirement = Requirement(requirement_text)
|
||||
except InvalidRequirement:
|
||||
continue
|
||||
dependency_name = canonicalize_name(requirement.name)
|
||||
if dependency_name not in projects:
|
||||
continue
|
||||
previous = requested_extras.setdefault(dependency_name, set())
|
||||
updated = previous | set(requirement.extras)
|
||||
if dependency_name not in processed_extras or updated != previous:
|
||||
requested_extras[dependency_name] = updated
|
||||
pending.append(dependency_name)
|
||||
|
||||
target_extras = sorted(requested_extras[target_name])
|
||||
target_path = (workspace_root / target.project_path).resolve()
|
||||
target_spec = str(target_path)
|
||||
if target_extras:
|
||||
target_spec = f"{target_spec}[{','.join(target_extras)}]"
|
||||
|
||||
editable_specs = [target_spec]
|
||||
for package_name in sorted(requested_extras):
|
||||
if package_name == target_name:
|
||||
continue
|
||||
editable_specs.append(str((workspace_root / projects[package_name].project_path).resolve()))
|
||||
|
||||
target_requirements = _requirements_for_extras(target, set(target_extras))
|
||||
reported_distributions = {canonicalize_name(target.package_name)}
|
||||
for requirement_text in target_requirements:
|
||||
try:
|
||||
reported_distributions.add(canonicalize_name(Requirement(requirement_text).name))
|
||||
except InvalidRequirement:
|
||||
continue
|
||||
|
||||
return ReleaseProbePlan(
|
||||
project_path=target.project_path,
|
||||
package_name=target.package_name,
|
||||
editable_specs=tuple(editable_specs),
|
||||
import_modules=target.import_modules,
|
||||
reported_distributions=tuple(sorted(reported_distributions)),
|
||||
python_version=_minimum_python_version([projects[package_name] for package_name in requested_extras]),
|
||||
)
|
||||
|
||||
|
||||
def _build_release_probe_command(
|
||||
plan: ReleaseProbePlan,
|
||||
*,
|
||||
resolution: str,
|
||||
python_override: str | None = None,
|
||||
) -> list[str]:
|
||||
probe_script = f"""
|
||||
import importlib
|
||||
import json
|
||||
from importlib.metadata import PackageNotFoundError, version
|
||||
|
||||
modules = {plan.import_modules!r}
|
||||
distributions = {plan.reported_distributions!r}
|
||||
for module_name in modules:
|
||||
importlib.import_module(module_name)
|
||||
versions = {{}}
|
||||
for distribution_name in distributions:
|
||||
try:
|
||||
versions[distribution_name] = version(distribution_name)
|
||||
except PackageNotFoundError:
|
||||
versions[distribution_name] = None
|
||||
print({_PROBE_RESULT_PREFIX!r} + json.dumps({{"imports": modules, "versions": versions}}, sort_keys=True))
|
||||
"""
|
||||
command = [
|
||||
"uv",
|
||||
"--no-progress",
|
||||
"run",
|
||||
"--isolated",
|
||||
"--no-project",
|
||||
"--python",
|
||||
python_override or plan.python_version,
|
||||
"--resolution",
|
||||
resolution,
|
||||
"--prerelease",
|
||||
"if-necessary-or-explicit",
|
||||
"--quiet",
|
||||
]
|
||||
for editable_spec in plan.editable_specs:
|
||||
command.extend(["--with-editable", editable_spec])
|
||||
command.extend(["python", "-c", probe_script])
|
||||
return command
|
||||
|
||||
|
||||
def _parse_probe_payload(stdout: str) -> dict[str, Any] | None:
|
||||
for line in reversed(stdout.splitlines()):
|
||||
if line.startswith(_PROBE_RESULT_PREFIX):
|
||||
try:
|
||||
payload = json.loads(line.removeprefix(_PROBE_RESULT_PREFIX))
|
||||
except json.JSONDecodeError:
|
||||
return None
|
||||
return cast(dict[str, Any], payload) if isinstance(payload, dict) else None
|
||||
return None
|
||||
|
||||
|
||||
def _run_release_probe(
|
||||
plan: ReleaseProbePlan,
|
||||
*,
|
||||
scenario_name: str,
|
||||
resolution: str,
|
||||
python_override: str | None,
|
||||
deadline: float,
|
||||
dry_run: bool,
|
||||
) -> dict[str, Any]:
|
||||
python_version = python_override or plan.python_version
|
||||
command = _build_release_probe_command(plan, resolution=resolution, python_override=python_override)
|
||||
started = time.monotonic()
|
||||
if dry_run:
|
||||
print(f"[cyan]DRY RUN[/cyan] {' '.join(command)}")
|
||||
return {
|
||||
"project_path": str(plan.project_path),
|
||||
"package_name": plan.package_name,
|
||||
"scenario": scenario_name,
|
||||
"resolution": resolution,
|
||||
"python": python_version,
|
||||
"status": "dry-run",
|
||||
"duration_seconds": 0.0,
|
||||
"payload": None,
|
||||
"error": None,
|
||||
}
|
||||
|
||||
remaining_seconds = deadline - started
|
||||
if remaining_seconds <= 0:
|
||||
return {
|
||||
"project_path": str(plan.project_path),
|
||||
"package_name": plan.package_name,
|
||||
"scenario": scenario_name,
|
||||
"resolution": resolution,
|
||||
"python": python_version,
|
||||
"status": "failed",
|
||||
"duration_seconds": 0.0,
|
||||
"payload": None,
|
||||
"error": "The shared release-validation deadline elapsed before this probe started.",
|
||||
}
|
||||
|
||||
env = dict(os.environ)
|
||||
env.pop("VIRTUAL_ENV", None)
|
||||
try:
|
||||
result = subprocess.run(
|
||||
command,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=remaining_seconds,
|
||||
check=False,
|
||||
env=env,
|
||||
)
|
||||
except subprocess.TimeoutExpired as exc:
|
||||
stdout = exc.stdout.decode(errors="replace") if isinstance(exc.stdout, bytes) else (exc.stdout or "")
|
||||
stderr = exc.stderr.decode(errors="replace") if isinstance(exc.stderr, bytes) else (exc.stderr or "")
|
||||
return {
|
||||
"project_path": str(plan.project_path),
|
||||
"package_name": plan.package_name,
|
||||
"scenario": scenario_name,
|
||||
"resolution": resolution,
|
||||
"python": python_version,
|
||||
"status": "failed",
|
||||
"duration_seconds": round(time.monotonic() - started, 3),
|
||||
"payload": None,
|
||||
"error": f"Release probe exceeded the shared deadline.\n{_truncate_error(stdout, stderr)}",
|
||||
}
|
||||
|
||||
payload = _parse_probe_payload(result.stdout) if result.returncode == 0 else None
|
||||
error = None
|
||||
if result.returncode != 0:
|
||||
error = _truncate_error(result.stdout, result.stderr)
|
||||
elif payload is None:
|
||||
error = "Probe completed without emitting its dependency-version payload."
|
||||
|
||||
return {
|
||||
"project_path": str(plan.project_path),
|
||||
"package_name": plan.package_name,
|
||||
"scenario": scenario_name,
|
||||
"resolution": resolution,
|
||||
"python": python_version,
|
||||
"status": "passed" if error is None else "failed",
|
||||
"duration_seconds": round(time.monotonic() - started, 3),
|
||||
"payload": payload,
|
||||
"error": error,
|
||||
}
|
||||
|
||||
|
||||
def _refresh_lockfile(
|
||||
*,
|
||||
workspace_root: Path,
|
||||
deadline: float,
|
||||
dry_run: bool,
|
||||
) -> dict[str, Any]:
|
||||
command = ["uv", "lock", "--prerelease", "if-necessary-or-explicit"]
|
||||
if dry_run:
|
||||
print(f"[cyan]DRY RUN[/cyan] {' '.join(command)}")
|
||||
return {"status": "dry-run", "duration_seconds": 0.0, "error": None}
|
||||
|
||||
started = time.monotonic()
|
||||
remaining_seconds = deadline - started
|
||||
if remaining_seconds <= 0:
|
||||
return {
|
||||
"status": "failed",
|
||||
"duration_seconds": 0.0,
|
||||
"error": "The shared release-validation deadline elapsed before uv.lock refresh started.",
|
||||
}
|
||||
try:
|
||||
result = subprocess.run(
|
||||
command,
|
||||
cwd=workspace_root,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=remaining_seconds,
|
||||
check=False,
|
||||
)
|
||||
except subprocess.TimeoutExpired as exc:
|
||||
stdout = exc.stdout.decode(errors="replace") if isinstance(exc.stdout, bytes) else (exc.stdout or "")
|
||||
stderr = exc.stderr.decode(errors="replace") if isinstance(exc.stderr, bytes) else (exc.stderr or "")
|
||||
return {
|
||||
"status": "failed",
|
||||
"duration_seconds": round(time.monotonic() - started, 3),
|
||||
"error": f"uv.lock refresh exceeded the shared deadline.\n{_truncate_error(stdout, stderr)}",
|
||||
}
|
||||
|
||||
error = None if result.returncode == 0 else _truncate_error(result.stdout, result.stderr)
|
||||
return {
|
||||
"status": "passed" if error is None else "failed",
|
||||
"duration_seconds": round(time.monotonic() - started, 3),
|
||||
"error": error,
|
||||
}
|
||||
|
||||
|
||||
def run_release_mode(
|
||||
*,
|
||||
workspace_root: Path,
|
||||
base_ref: str,
|
||||
package_filter: str | None,
|
||||
parallelism: int,
|
||||
python_override: str | None,
|
||||
deadline_seconds: int,
|
||||
dry_run: bool,
|
||||
output_json: Path,
|
||||
) -> int:
|
||||
"""Run fast lower/upper release probes for changed package metadata."""
|
||||
deadline = time.monotonic() + deadline_seconds
|
||||
projects = _build_release_project_map(workspace_root)
|
||||
selected = _selected_release_projects(
|
||||
workspace_root=workspace_root,
|
||||
projects=projects,
|
||||
base_ref=base_ref,
|
||||
package_filter=package_filter,
|
||||
)
|
||||
if not selected:
|
||||
print(f"[red]No changed package pyproject.toml files found relative to {base_ref}.[/red]")
|
||||
return 1
|
||||
|
||||
lock_result = _refresh_lockfile(workspace_root=workspace_root, deadline=deadline, dry_run=dry_run)
|
||||
if lock_result["status"] == "failed":
|
||||
print("[red]uv.lock refresh failed.[/red]")
|
||||
print(f"[red]{lock_result['error']}[/red]")
|
||||
return 1
|
||||
|
||||
plans = [_build_release_probe_plan(workspace_root, project, projects) for project in selected]
|
||||
work_items = [
|
||||
(plan, scenario_name, resolution) for plan in plans for scenario_name, resolution in _RESOLUTION_SCENARIOS
|
||||
]
|
||||
report: dict[str, Any] = {
|
||||
"started_at": _utc_now(),
|
||||
"mode": "release",
|
||||
"workspace_root": str(workspace_root),
|
||||
"base_ref": base_ref,
|
||||
"python_override": python_override,
|
||||
"deadline_seconds": deadline_seconds,
|
||||
"dry_run": dry_run,
|
||||
"lockfile": lock_result,
|
||||
"packages": [str(plan.project_path) for plan in plans],
|
||||
"probes": [],
|
||||
"summary": {"probes_total": len(work_items), "probes_passed": 0, "probes_failed": 0},
|
||||
}
|
||||
_write_json(output_json, report)
|
||||
print(
|
||||
f"[bold]Running {len(work_items)} lock-independent release probes for {len(plans)} package(s) "
|
||||
f"with a shared {deadline_seconds}s deadline[/bold]"
|
||||
)
|
||||
print(f"[cyan]Writing dependency-bounds release report to {output_json}[/cyan]")
|
||||
|
||||
max_workers = max(1, min(parallelism, len(work_items)))
|
||||
with concurrent.futures.ThreadPoolExecutor(max_workers=max_workers) as executor:
|
||||
futures = [
|
||||
executor.submit(
|
||||
_run_release_probe,
|
||||
plan,
|
||||
scenario_name=scenario_name,
|
||||
resolution=resolution,
|
||||
python_override=python_override,
|
||||
deadline=deadline,
|
||||
dry_run=dry_run,
|
||||
)
|
||||
for plan, scenario_name, resolution in work_items
|
||||
]
|
||||
for future in concurrent.futures.as_completed(futures):
|
||||
result = future.result()
|
||||
report["probes"].append(result)
|
||||
if result["status"] in {"passed", "dry-run"}:
|
||||
report["summary"]["probes_passed"] += 1
|
||||
print(
|
||||
f"[green]{result['project_path']}: {result['scenario']} passed on Python {result['python']} "
|
||||
f"({result['duration_seconds']:.1f}s)[/green]"
|
||||
)
|
||||
else:
|
||||
report["summary"]["probes_failed"] += 1
|
||||
print(f"[red]{result['project_path']}: {result['scenario']} failed[/red]")
|
||||
print(f"[red]{result['error']}[/red]")
|
||||
report["updated_at"] = _utc_now()
|
||||
_write_json(output_json, report)
|
||||
|
||||
if report["summary"]["probes_failed"]:
|
||||
print("[bold red]Release dependency-bound validation failed.[/bold red]")
|
||||
return 1
|
||||
print("[bold green]Release dependency-bound validation completed successfully.[/bold green]")
|
||||
return 0
|
||||
@@ -0,0 +1,217 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
import json
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from subprocess import CompletedProcess
|
||||
|
||||
import pytest
|
||||
|
||||
from scripts.dependencies._dependency_bounds_release_impl import (
|
||||
_PROBE_RESULT_PREFIX,
|
||||
ReleaseProbePlan,
|
||||
_build_release_probe_command,
|
||||
_build_release_probe_plan,
|
||||
_build_release_project_map,
|
||||
_changed_release_project_paths,
|
||||
_parse_probe_payload,
|
||||
run_release_mode,
|
||||
)
|
||||
from scripts.dependencies.validate_dependency_bounds import main
|
||||
|
||||
|
||||
def _write_project(path: Path, content: str) -> None:
|
||||
path.mkdir(parents=True, exist_ok=True)
|
||||
(path / "pyproject.toml").write_text(content)
|
||||
|
||||
|
||||
def test_release_probe_uses_only_the_required_internal_dependency_closure(tmp_path: Path) -> None:
|
||||
_write_project(
|
||||
tmp_path,
|
||||
"""
|
||||
[project]
|
||||
name = "agent-framework"
|
||||
version = "1.2.0"
|
||||
requires-python = ">=3.10"
|
||||
dependencies = ["agent-framework-core[all]==1.2.0"]
|
||||
|
||||
[tool.uv.workspace]
|
||||
members = ["packages/*"]
|
||||
|
||||
[tool.flit.module]
|
||||
name = "agent_framework_meta"
|
||||
""",
|
||||
)
|
||||
_write_project(
|
||||
tmp_path / "packages/core",
|
||||
"""
|
||||
[project]
|
||||
name = "agent-framework-core"
|
||||
version = "1.2.0"
|
||||
requires-python = ">=3.10"
|
||||
dependencies = ["pydantic>=2,<3"]
|
||||
|
||||
[project.optional-dependencies]
|
||||
all = ["agent-framework-connector>=1,<2"]
|
||||
dev = ["pytest>=9"]
|
||||
|
||||
[tool.flit.module]
|
||||
name = "agent_framework"
|
||||
""",
|
||||
)
|
||||
_write_project(
|
||||
tmp_path / "packages/connector",
|
||||
"""
|
||||
[project]
|
||||
name = "agent-framework-connector"
|
||||
version = "1.0.0"
|
||||
requires-python = ">=3.10"
|
||||
dependencies = ["agent-framework-core>=1,<2", "httpx>=0.27,<1"]
|
||||
|
||||
[tool.flit.module]
|
||||
name = "agent_framework_connector"
|
||||
""",
|
||||
)
|
||||
_write_project(
|
||||
tmp_path / "packages/provider",
|
||||
"""
|
||||
[project]
|
||||
name = "agent-framework-provider"
|
||||
version = "1.0.0"
|
||||
requires-python = ">=3.11"
|
||||
dependencies = ["agent-framework-core>=1,<2", "openai>=2,<3"]
|
||||
|
||||
[tool.flit.module]
|
||||
name = "agent_framework_provider"
|
||||
""",
|
||||
)
|
||||
|
||||
projects = _build_release_project_map(tmp_path)
|
||||
provider_plan = _build_release_probe_plan(tmp_path, projects["agent-framework-provider"], projects)
|
||||
provider_editables = "\n".join(provider_plan.editable_specs)
|
||||
|
||||
assert "packages/provider" in provider_editables
|
||||
assert "packages/core" in provider_editables
|
||||
assert "packages/connector" not in provider_editables
|
||||
assert provider_plan.python_version == "3.11"
|
||||
|
||||
root_plan = _build_release_probe_plan(tmp_path, projects["agent-framework"], projects)
|
||||
root_editables = "\n".join(root_plan.editable_specs)
|
||||
assert "packages/core" in root_editables
|
||||
assert "packages/connector" in root_editables
|
||||
assert "pytest" not in root_plan.reported_distributions
|
||||
assert root_plan.python_version == "3.10"
|
||||
|
||||
|
||||
def test_release_probe_command_is_lock_independent_and_uses_bound_resolution(tmp_path: Path) -> None:
|
||||
plan = ReleaseProbePlan(
|
||||
project_path=Path("packages/openai"),
|
||||
package_name="agent-framework-openai",
|
||||
editable_specs=(str(tmp_path / "packages/openai"), str(tmp_path / "packages/core")),
|
||||
import_modules=("agent_framework_openai",),
|
||||
reported_distributions=("agent-framework-openai", "openai"),
|
||||
python_version="3.11",
|
||||
)
|
||||
|
||||
command = _build_release_probe_command(plan, resolution="lowest-direct")
|
||||
|
||||
assert "--no-project" in command
|
||||
assert command[command.index("--resolution") + 1] == "lowest-direct"
|
||||
assert command[command.index("--python") + 1] == "3.11"
|
||||
assert command[command.index("--prerelease") + 1] == "if-necessary-or-explicit"
|
||||
assert command.count("--with-editable") == 2
|
||||
assert "pytest" not in command
|
||||
assert "pyright" not in command
|
||||
|
||||
overridden_command = _build_release_probe_command(plan, resolution="highest", python_override="3.12")
|
||||
assert overridden_command[overridden_command.index("--python") + 1] == "3.12"
|
||||
|
||||
|
||||
def test_changed_release_projects_are_relative_to_python_workspace(tmp_path: Path, monkeypatch) -> None:
|
||||
def fake_run(*args, **kwargs) -> CompletedProcess[str]:
|
||||
return CompletedProcess(
|
||||
args=args[0],
|
||||
returncode=0,
|
||||
stdout="pyproject.toml\npackages/core/pyproject.toml\nREADME.md\n",
|
||||
stderr="",
|
||||
)
|
||||
|
||||
monkeypatch.setattr("scripts.dependencies._dependency_bounds_release_impl.subprocess.run", fake_run)
|
||||
|
||||
assert _changed_release_project_paths(tmp_path, "upstream/main") == {Path("."), Path("packages/core")}
|
||||
|
||||
|
||||
def test_parse_probe_payload_uses_the_last_valid_marker() -> None:
|
||||
first_payload = json.dumps({"versions": {"openai": "2.25.0"}})
|
||||
last_payload = {"imports": ["agent_framework_openai"], "versions": {"openai": "2.47.0"}}
|
||||
stdout = "\n".join((
|
||||
f"{_PROBE_RESULT_PREFIX}{first_payload}",
|
||||
"unrelated subprocess output",
|
||||
f"{_PROBE_RESULT_PREFIX}{json.dumps(last_payload)}",
|
||||
))
|
||||
|
||||
assert _parse_probe_payload(stdout) == last_payload
|
||||
assert _parse_probe_payload(f"{_PROBE_RESULT_PREFIX}not-json") is None
|
||||
assert _parse_probe_payload(f"{_PROBE_RESULT_PREFIX}[]") is None
|
||||
assert _parse_probe_payload("unrelated subprocess output") is None
|
||||
|
||||
|
||||
def test_run_release_mode_dry_run_uses_selected_package_python_floor(tmp_path: Path) -> None:
|
||||
_write_project(
|
||||
tmp_path,
|
||||
"""
|
||||
[project]
|
||||
name = "agent-framework"
|
||||
version = "1.2.0"
|
||||
requires-python = ">=3.10"
|
||||
dependencies = []
|
||||
|
||||
[tool.uv.workspace]
|
||||
members = ["packages/*"]
|
||||
|
||||
[tool.flit.module]
|
||||
name = "agent_framework_meta"
|
||||
""",
|
||||
)
|
||||
_write_project(
|
||||
tmp_path / "packages/provider",
|
||||
"""
|
||||
[project]
|
||||
name = "agent-framework-provider"
|
||||
version = "1.0.0"
|
||||
requires-python = ">=3.11"
|
||||
dependencies = []
|
||||
|
||||
[tool.flit.module]
|
||||
name = "agent_framework_provider"
|
||||
""",
|
||||
)
|
||||
output_json = tmp_path / "release-results.json"
|
||||
|
||||
exit_code = run_release_mode(
|
||||
workspace_root=tmp_path,
|
||||
base_ref="HEAD",
|
||||
package_filter="provider",
|
||||
parallelism=2,
|
||||
python_override=None,
|
||||
deadline_seconds=300,
|
||||
dry_run=True,
|
||||
output_json=output_json,
|
||||
)
|
||||
|
||||
assert exit_code == 0
|
||||
report = json.loads(output_json.read_text())
|
||||
assert report["python_override"] is None
|
||||
assert report["summary"] == {"probes_total": 2, "probes_passed": 2, "probes_failed": 0}
|
||||
assert {probe["python"] for probe in report["probes"]} == {"3.11"}
|
||||
assert {probe["status"] for probe in report["probes"]} == {"dry-run"}
|
||||
|
||||
|
||||
def test_release_mode_rejects_blank_base_ref(monkeypatch, capsys) -> None:
|
||||
monkeypatch.setattr(sys, "argv", ["validate_dependency_bounds", "--mode", "release", "--base-ref", " "])
|
||||
|
||||
with pytest.raises(SystemExit) as exc_info:
|
||||
main()
|
||||
|
||||
assert exc_info.value.code == 2
|
||||
assert "release mode requires --base-ref" in capsys.readouterr().err
|
||||
@@ -1,9 +1,10 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
# ruff: noqa: S404, S603
|
||||
# ruff:file-ignore[suspicious-subprocess-import, subprocess-without-shell-equals-true]
|
||||
|
||||
"""Unified dependency-bound validation entrypoint.
|
||||
|
||||
Modes:
|
||||
- release: run fast lock-independent lower/upper import probes for changed release packages.
|
||||
- test: run workspace-wide compatibility gates at lower and upper resolutions.
|
||||
- lower: run lower-bound expansion for one package.
|
||||
- upper: run upper-bound expansion for one package.
|
||||
@@ -28,6 +29,7 @@ from pathlib import Path
|
||||
import tomli
|
||||
from rich import print
|
||||
|
||||
from scripts.dependencies._dependency_bounds_release_impl import run_release_mode
|
||||
from scripts.dependencies._dependency_bounds_runtime import (
|
||||
extend_command_with_runtime_tools,
|
||||
extend_command_with_task,
|
||||
@@ -363,15 +365,16 @@ def main() -> None:
|
||||
"""Parse arguments and run the requested dependency-bound mode."""
|
||||
parser = argparse.ArgumentParser(
|
||||
description=(
|
||||
"Unified dependency-bound workflow. Use mode=test for workspace-wide lower+upper gates, "
|
||||
"Unified dependency-bound workflow. Use mode=release for fast release sanity probes, "
|
||||
"mode=test for the exhaustive workspace lower+upper matrix, "
|
||||
"or lower/upper/both for package-scoped or workspace-wide bound expansion."
|
||||
)
|
||||
)
|
||||
parser.add_argument(
|
||||
"--mode",
|
||||
required=True,
|
||||
choices=("test", "lower", "upper", "both"),
|
||||
help="Execution mode: test (global) or lower/upper/both (package-scoped).",
|
||||
choices=("release", "test", "lower", "upper", "both"),
|
||||
help="Execution mode: release/test gates or lower/upper/both bound expansion.",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--package",
|
||||
@@ -422,11 +425,49 @@ def main() -> None:
|
||||
default="scripts/dependencies/dependency-bounds-test-results.json",
|
||||
help="Output report path for test mode.",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--base-ref",
|
||||
default=None,
|
||||
help="Git base used to discover changed package metadata in release mode (required unless --package is set).",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--python",
|
||||
default=None,
|
||||
help="Optional Python override for release probes (defaults to each package closure's requires-python floor).",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--release-timeout-seconds",
|
||||
type=int,
|
||||
default=300,
|
||||
help="Shared wall-clock deadline for all release probes.",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--release-output-json",
|
||||
default="scripts/dependencies/dependency-bounds-release-results.json",
|
||||
help="Output report path for release mode.",
|
||||
)
|
||||
args = parser.parse_args()
|
||||
|
||||
workspace_root = Path(__file__).resolve().parents[2]
|
||||
normalized_package = None if args.package in {None, "", "*"} else args.package
|
||||
|
||||
if args.mode == "release":
|
||||
base_ref = args.base_ref.strip() if args.base_ref else ""
|
||||
python_override = args.python.strip() if args.python else None
|
||||
if not base_ref and normalized_package is None:
|
||||
parser.error("release mode requires --base-ref unless --package selects one package explicitly")
|
||||
exit_code = run_release_mode(
|
||||
workspace_root=workspace_root,
|
||||
base_ref=base_ref or "HEAD",
|
||||
package_filter=normalized_package,
|
||||
parallelism=args.parallelism,
|
||||
python_override=python_override,
|
||||
deadline_seconds=args.release_timeout_seconds,
|
||||
dry_run=args.dry_run,
|
||||
output_json=(workspace_root / args.release_output_json).resolve(),
|
||||
)
|
||||
raise SystemExit(exit_code)
|
||||
|
||||
if args.mode == "test":
|
||||
exit_code = _run_test_mode(
|
||||
workspace_root=workspace_root,
|
||||
|
||||
Reference in New Issue
Block a user