Compare commits

..

14 Commits

Author SHA1 Message Date
Tao Chen 157f37a5a7 Add single agent AGUI sample 2026-07-29 13:23:10 -07:00
westey 7b6d257988 Python: Add TodoProvider and AgentModeProvider samples (#7309)
* Python: Add TodoProvider and AgentModeProvider context provider samples

Add two Python samples under samples/02-agents/context_providers/ mirroring the
.NET samples from #7262:
- todo_provider.py: scripted walkthrough of TodoProvider that plans multi-step
  work and prints the evolving todo list after each turn.
- agent_mode_provider.py: interactive loop using AgentModeProvider with a /mode
  slash command, demonstrating built-in plan/execute and custom modes.

Also index both samples in the context_providers README.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 8725831c-086b-475f-90e6-cdba41d59c33

* Python: Address review comments on AgentModeProvider sample

- Replace the AGENT_MODE_USE_CUSTOM env var with an in-file USE_CUSTOM_MODES
  constant for choosing between built-in and custom modes.
- Use plain input() in the interactive loop instead of asyncio.to_thread.
- Update the README prerequisites to reference the in-file toggle.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 8725831c-086b-475f-90e6-cdba41d59c33

---------

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 8725831c-086b-475f-90e6-cdba41d59c33
2026-07-27 20:28:58 +00:00
Henry Su acbcdaa086 Python: fix(python): handle callable class middleware safely in _determine_middleware_type (#6697) (#7333)
* fix(python): handle callable class middleware safely in _determine_middleware_type (#6697)

* test(python): type-annotate test middleware lists to pass test-typing checks
2026-07-27 19:39:47 +00:00
KXH 0b0dcaa5af fix(dotnet): preserve table after EditTable add (#7324)
Signed-off-by: KXH <shepherdlaurie238@gmail.com>
2026-07-27 18:29:32 +00:00
Copilot 35a8891d67 .NET: Add Microsoft.Agents.AI.LocalCodeAct to release solution filter (#7343)
* Initial plan

* Add Microsoft.Agents.AI.LocalCodeAct to release solution filter

---------

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
2026-07-27 14:18:31 +00:00
westey 28e02d4669 Create session for tool approval agent when non-present (#7310) 2026-07-27 11:07:43 +00:00
Sriraj 5ccd7784a6 Python: Reject Windows junctions in FileSystemAgentFileStore (#7291)
* Python: reject file-store junctions during enumeration

* Python: clarify file-store probe diagnostics
2026-07-27 10:47:57 +00:00
pratik wayase 754cbe5976 Python: [Feature]: Support OpenAI instructions in Responses API (#7292)
* Python: Support OpenAI instructions in Responses API

* fix: address PR comments and fix typing for OpenAIChatOptions
2026-07-27 10:30:34 +00:00
Giles Odigwe c6442de528 .NET: Graduate GitHub Copilot agent to stable (#7313)
Promote Microsoft.Agents.AI.GitHub.Copilot from release candidate to
released by replacing IsReleaseCandidate=true with IsReleased=true, so the
package builds with the stable central version (no -rc suffix). Also clears
the package-validation baseline and disables package validation for this
first stable release, since the package has never shipped a stable NuGet to
validate against (mirrors the Microsoft.Agents.AI.Harness graduation in
#7119). Non-breaking: the package exposes no [Experimental] APIs to un-mark.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: f523064c-60b4-4d18-bf95-c16c5fda9126
2026-07-24 22:46:19 +00:00
Tao Chen 0df184e7dd Python: Fix sub-workflow checkpoint restore to preserve sub-workflow state (#7097)
* Fix sub-workflow checkpoint restore to preserve sub-workflow state

Add Runner.capture_checkpoint_object/restore_from_checkpoint_object (quiescent-only nested checkpoint) and embed a sub_workflow_checkpoint in WorkflowExecutor.on_checkpoint_save/on_checkpoint_restore so a resumed parent restores each sub-workflow's mid-progress state instead of only replaying pending request-info events. Keeps a backward-compat fallback when sub_workflow_checkpoint is absent.

* Move checkpoint-object construction into the runner context

Add RunnerContext.create_checkpoint_object alongside create_checkpoint (create_checkpoint now delegates to it and persists), so Runner.capture_checkpoint_object builds the snapshot via the context instead of a one-off get_messages peek primitive. In-flight messages are captured non-destructively (per-source lists copied). The checkpoint-less capturing contexts (azurefunctions, durabletask) raise NotImplementedError to match create_checkpoint.

* Remove per-execution bookkeeping from WorkflowExecutor

The sub-workflow is a single shared instance, so per-execution ExecutionContext/request routing never provided real isolation. Delegate request/response tracking to the sub-workflow itself: can_handle accepts targeted propagated responses, _handle_response validates against the sub-workflow's pending requests and forwards responses immediately, and on_checkpoint_save embeds only the sub-workflow checkpoint (on_checkpoint_restore keeps a legacy reader for older checkpoints). Also emit the fresh-message/checkpoint-while-pending warning from FunctionalWorkflow.run to match Workflow.run.

* Drop redundant decode in WorkflowExecutor.on_checkpoint_restore

The storage backend already materializes the full checkpoint on load (FileCheckpointStorage decodes recursively; InMemoryCheckpointStorage deep-copies), so the embedded sub_workflow_checkpoint (and legacy execution_contexts) arrive already decoded - like every other executor's on_checkpoint_restore state. Remove the no-op decode_checkpoint_value calls and the now-unused import.

* Clean up

* Do not allow checkpoint storage in sub workflow

* Address comments

* Fix syntax check

* Add warning

---------

Co-authored-by: Evan Mattson <35585003+moonbox3@users.noreply.github.com>
2026-07-24 17:21:41 +00:00
westey d0a0d5a3df .NET: Add TodoProvider and AgentModeProvider samples (#7262)
* Add samples for todo and mode providers

* Address PR review: add Step21 to samples index and trim slash-command input

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

* Print agent mode after each turn in AgentMode sample

Reflects mode changes the agent makes itself via the mode_set tool.

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

---------

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-07-24 12:57:10 +00:00
westey c59a65da5e .NET: fix InMemoryChatHistoryProvider persisting when service stores history (#7284)
* Fix chat history storage bug

* Improve error messaging

* Address PR comment
2026-07-24 10:44:52 +00:00
Evan Mattson e90b6de5a7 Python: Improve python package management operations (#7274)
* improve package mgmt timings

* Address Python release validation review feedback
2026-07-23 22:14:10 +00:00
Atharva Vichare d98ac29115 Python: Fix duplicate function call on approval round-trip (#7267) (#7271)
* Python: Fix duplicate function call on approval round-trip (#7267)

`_replace_approval_contents_with_results` deduped restored function calls
against only the message currently being scanned. On an approval round-trip
the hosting layer replays the stored `function_call` item and its
`mcp_approval_request` item as two separate assistant messages, so the
per-message check never fired and the approval request restored a second
copy of the call.

Only one copy received the function result; the orphaned copy was left
unanswered, which the Responses API rejects with
"No tool output found for function call call_<id>".

Collect existing call ids across all messages instead, and add a restored
call to that set so two approval requests for the same call cannot both
expand.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* Refactor approval placeholder result handling

Refactor approval handling logic to improve clarity and maintainability.

* Refactor test to support reused call IDs after completion

Updated the test to allow reused call IDs after completion, ensuring that a completed call does not suppress later approval requests with the same ID. Adjusted assertions to reflect the new behavior.

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-23 21:27:26 +00:00
62 changed files with 4867 additions and 408 deletions
+2
View File
@@ -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" />
+1
View File
@@ -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
@@ -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>
@@ -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)}");
@@ -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.
@@ -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
@@ -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()
{
@@ -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);
}
@@ -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
View File
@@ -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
View File
@@ -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)."
)
+32 -11
View File
@@ -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
View File
@@ -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>
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": {}
}
+18 -3
View File
@@ -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,