Compare commits
11 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| cd5d282827 | |||
| 535690cd1d | |||
| 85fde62a76 | |||
| 85eb53d412 | |||
| 2d7c8da6b0 | |||
| e0b0b79d9e | |||
| a4f6c26990 | |||
| 1389f304f2 | |||
| 93719f4a34 | |||
| a486374fd8 | |||
| e78604103d |
@@ -66,8 +66,6 @@
|
||||
<Project Path="samples/02-agents/Agents/Agent_Step19_InFunctionLoopCheckpointing/Agent_Step19_InFunctionLoopCheckpointing.csproj" />
|
||||
<Project Path="samples/02-agents/Agents/Agent_Step20_DynamicFunctionTools/Agent_Step20_DynamicFunctionTools.csproj" />
|
||||
<Project Path="samples/02-agents/Agents/Agent_Step21_ShellWithEnvironment/Agent_Step21_ShellWithEnvironment.csproj" />
|
||||
<Project Path="samples/02-agents/Agents/Agent_Step22_AgentMode/Agent_Step22_AgentMode.csproj" />
|
||||
<Project Path="samples/02-agents/Agents/Agent_Step23_TodoList/Agent_Step23_TodoList.csproj" />
|
||||
</Folder>
|
||||
<Folder Name="/Samples/02-agents/DeclarativeAgents/">
|
||||
<Project Path="samples/02-agents/DeclarativeAgents/ChatClient/DeclarativeChatClientAgents.csproj" />
|
||||
|
||||
@@ -23,7 +23,6 @@
|
||||
"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,80 +329,6 @@ 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
|
||||
|
||||
@@ -1,16 +0,0 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<OutputType>Exe</OutputType>
|
||||
<TargetFrameworks>net10.0</TargetFrameworks>
|
||||
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Azure.Identity" />
|
||||
<ProjectReference Include="..\..\..\..\src\Microsoft.Agents.AI.Foundry\Microsoft.Agents.AI.Foundry.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -1,153 +0,0 @@
|
||||
// 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");
|
||||
}
|
||||
@@ -1,62 +0,0 @@
|
||||
# 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
|
||||
```
|
||||
@@ -1,16 +0,0 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<OutputType>Exe</OutputType>
|
||||
<TargetFrameworks>net10.0</TargetFrameworks>
|
||||
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Azure.Identity" />
|
||||
<ProjectReference Include="..\..\..\..\src\Microsoft.Agents.AI.Foundry\Microsoft.Agents.AI.Foundry.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -1,83 +0,0 @@
|
||||
// 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}");
|
||||
}
|
||||
}
|
||||
@@ -1,47 +0,0 @@
|
||||
# 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,9 +47,6 @@ 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 +1,7 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<IsReleased>true</IsReleased>
|
||||
<IsReleaseCandidate>true</IsReleaseCandidate>
|
||||
<!-- GitHub.Copilot.SDK only supports .NET 8.0+ -->
|
||||
<TargetFrameworks>$(TargetFrameworksCore)</TargetFrameworks>
|
||||
<NoWarn>$(NoWarn);GHCP001</NoWarn>
|
||||
@@ -14,12 +14,6 @@
|
||||
|
||||
<Import Project="$(RepoRoot)/dotnet/nuget/nuget-package.props" />
|
||||
|
||||
<!-- Disable package validation baseline until the first release -->
|
||||
<PropertyGroup>
|
||||
<PackageValidationBaselineVersion />
|
||||
<EnablePackageValidation>false</EnablePackageValidation>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\Microsoft.Agents.AI.Abstractions\Microsoft.Agents.AI.Abstractions.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
+1
-1
@@ -34,7 +34,7 @@ internal sealed class EditTableExecutor(EditTable model, WorkflowFormulaState st
|
||||
EvaluationResult<DataValue> addResult = this.Evaluator.GetValue(addItemValue);
|
||||
RecordValue newRecord = BuildRecord(tableValue.Type.ToRecord(), addResult.Value.ToFormula());
|
||||
await tableValue.AppendAsync(newRecord, cancellationToken).ConfigureAwait(false);
|
||||
await this.AssignAsync(variablePath, tableValue, context).ConfigureAwait(false);
|
||||
await this.AssignAsync(variablePath, newRecord, context).ConfigureAwait(false);
|
||||
break;
|
||||
case TableChangeType.Remove:
|
||||
ValueExpression removeItemValue = Throw.IfNull(this.Model.Value, $"{nameof(this.Model)}.{nameof(this.Model.Value)}");
|
||||
|
||||
+1
-1
@@ -33,7 +33,7 @@ internal sealed class EditTableV2Executor(EditTableV2 model, WorkflowFormulaStat
|
||||
EvaluationResult<DataValue> expressionResult = this.Evaluator.GetValue(addItemValue);
|
||||
RecordValue newRecord = BuildRecord(tableValue.Type.ToRecord(), expressionResult.Value.ToFormula());
|
||||
await tableValue.AppendAsync(newRecord, cancellationToken).ConfigureAwait(false);
|
||||
await this.AssignAsync(this.Model.ItemsVariable, tableValue, context).ConfigureAwait(false);
|
||||
await this.AssignAsync(this.Model.ItemsVariable, newRecord, 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(session, chatOptions);
|
||||
ChatHistoryProvider? chatHistoryProvider = this.ResolveChatHistoryProvider(chatOptions);
|
||||
|
||||
if (chatHistoryProvider is not null)
|
||||
{
|
||||
@@ -510,7 +510,7 @@ public sealed partial class ChatClientAgent : AIAgent
|
||||
ChatOptions? chatOptions,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
ChatHistoryProvider? chatHistoryProvider = this.ResolveChatHistoryProvider(session, chatOptions);
|
||||
ChatHistoryProvider? chatHistoryProvider = this.ResolveChatHistoryProvider(chatOptions);
|
||||
|
||||
if (chatHistoryProvider is not null)
|
||||
{
|
||||
@@ -980,33 +980,22 @@ public sealed partial class ChatClientAgent : AIAgent
|
||||
}
|
||||
}
|
||||
|
||||
private ChatHistoryProvider? ResolveChatHistoryProvider(ChatClientAgentSession session, ChatOptions? chatOptions)
|
||||
private ChatHistoryProvider? ResolveChatHistoryProvider(ChatOptions? chatOptions)
|
||||
{
|
||||
// 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;
|
||||
ChatHistoryProvider? provider =
|
||||
chatOptions?.ConversationId is null || IsAGUIProviderName(this._agentMetadata.ProviderName)
|
||||
? this.ChatHistoryProvider
|
||||
: null;
|
||||
|
||||
// If someone provided an override ChatHistoryProvider via AdditionalProperties, we should use that instead.
|
||||
if (chatOptions?.AdditionalProperties?.TryGetValue(out ChatHistoryProvider? overrideProvider) is true)
|
||||
{
|
||||
if (this._agentOptions?.ThrowOnChatHistoryProviderConflict is true && serviceStoresHistory)
|
||||
if (!IsAGUIProviderName(this._agentMetadata.ProviderName) &&
|
||||
this._agentOptions?.ThrowOnChatHistoryProviderConflict is true &&
|
||||
string.IsNullOrWhiteSpace(chatOptions?.ConversationId) is false)
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
$"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)}.");
|
||||
$"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)}.");
|
||||
}
|
||||
|
||||
// Validate that the override provider's StateKeys do not clash with any AIContextProvider's StateKeys.
|
||||
@@ -1041,7 +1030,7 @@ public sealed partial class ChatClientAgent : AIAgent
|
||||
ChatOptions? chatOptions,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var chatHistoryProvider = this.ResolveChatHistoryProvider(session, chatOptions);
|
||||
var chatHistoryProvider = this.ResolveChatHistoryProvider(chatOptions);
|
||||
if (chatHistoryProvider is null)
|
||||
{
|
||||
return messages;
|
||||
|
||||
@@ -115,13 +115,6 @@ 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.
|
||||
@@ -165,11 +158,6 @@ public sealed class ToolApprovalAgent : DelegatingAIAgent
|
||||
yield break;
|
||||
}
|
||||
|
||||
// When the caller did not supply a session, create one and use it for every inner call so
|
||||
// conversation history is preserved across auto-approval re-invocations. See the non-streaming
|
||||
// RunCoreAsync for details.
|
||||
session ??= await this.InnerAgent.CreateSessionAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
// 3. Stream from the inner agent in a loop. If all approval requests from the stream
|
||||
// are auto-approved by standing rules, we immediately re-stream with the collected
|
||||
// approval responses injected. This avoids returning empty streams.
|
||||
|
||||
-144
@@ -411,150 +411,6 @@ 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
|
||||
|
||||
+1
-158
@@ -1962,165 +1962,8 @@ public class ToolApprovalAgentTests
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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).
|
||||
/// Verify that when auto-approval rule does not match, request is surfaced to the caller.
|
||||
/// </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()
|
||||
{
|
||||
|
||||
+10
-45
@@ -33,44 +33,13 @@ public sealed class EditTableExecutorTest(ITestOutputHelper output) : WorkflowAc
|
||||
changeType: TableChangeType.Add,
|
||||
value: new RecordDataValue([new("id", new NumberDataValue(7))]));
|
||||
|
||||
// Verify the variable remains a table containing the added record
|
||||
// Verify the variable now contains the added record
|
||||
FormulaValue resultValue = this.State.Get("MyTable");
|
||||
TableValue resultTable = Assert.IsAssignableFrom<TableValue>(resultValue);
|
||||
Assert.Equal(2, resultTable.Rows.Count());
|
||||
DecimalValue idValue = Assert.IsType<DecimalValue>(resultTable.Rows.Last().Value.GetField("id"));
|
||||
RecordValue resultRecord = Assert.IsAssignableFrom<RecordValue>(resultValue);
|
||||
DecimalValue idValue = Assert.IsType<DecimalValue>(resultRecord.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()
|
||||
{
|
||||
@@ -88,11 +57,9 @@ public sealed class EditTableExecutorTest(ITestOutputHelper output) : WorkflowAc
|
||||
new("name", new StringDataValue("Second"))
|
||||
]));
|
||||
|
||||
// Verify the variable remains a table containing the added record
|
||||
// Verify the variable now contains the added record
|
||||
FormulaValue resultValue = this.State.Get("MyTable");
|
||||
TableValue resultTable = Assert.IsAssignableFrom<TableValue>(resultValue);
|
||||
Assert.Equal(2, resultTable.Rows.Count());
|
||||
RecordValue resultRecord = resultTable.Rows.Last().Value;
|
||||
RecordValue resultRecord = Assert.IsAssignableFrom<RecordValue>(resultValue);
|
||||
DecimalValue idValue = Assert.IsType<DecimalValue>(resultRecord.GetField("id"));
|
||||
Assert.Equal(2, idValue.Value);
|
||||
StringValue nameValue = Assert.IsType<StringValue>(resultRecord.GetField("name"));
|
||||
@@ -116,10 +83,9 @@ public sealed class EditTableExecutorTest(ITestOutputHelper output) : WorkflowAc
|
||||
changeType: TableChangeType.Add,
|
||||
value: new RecordDataValue([new("id", new NumberDataValue(1))]));
|
||||
|
||||
// Verify the variable remains a table containing the added record
|
||||
// Verify the variable now contains the added record
|
||||
FormulaValue resultValue = this.State.Get("MyTable");
|
||||
TableValue resultTable = Assert.IsAssignableFrom<TableValue>(resultValue);
|
||||
RecordValue resultRecord = Assert.Single(resultTable.Rows).Value;
|
||||
RecordValue resultRecord = Assert.IsAssignableFrom<RecordValue>(resultValue);
|
||||
DecimalValue idValue = Assert.IsType<DecimalValue>(resultRecord.GetField("id"));
|
||||
Assert.Equal(1, idValue.Value);
|
||||
}
|
||||
@@ -379,12 +345,11 @@ public sealed class EditTableExecutorTest(ITestOutputHelper output) : WorkflowAc
|
||||
EditTableExecutor action = new(model, this.State);
|
||||
await this.ExecuteAsync(action);
|
||||
|
||||
// Assert - Variable should remain a table containing the newly added record
|
||||
// Assert - Variable should contain the newly added record
|
||||
VerifyModel(model, action);
|
||||
FormulaValue resultValue = this.State.Get("MyTable");
|
||||
TableValue resultTable = Assert.IsAssignableFrom<TableValue>(resultValue);
|
||||
Assert.Equal(2, resultTable.Rows.Count());
|
||||
DecimalValue idValue = Assert.IsType<DecimalValue>(resultTable.Rows.Last().Value.GetField("id"));
|
||||
RecordValue resultRecord = Assert.IsAssignableFrom<RecordValue>(resultValue);
|
||||
DecimalValue idValue = Assert.IsType<DecimalValue>(resultRecord.GetField("id"));
|
||||
Assert.Equal(10, idValue.Value);
|
||||
}
|
||||
|
||||
|
||||
+6
-38
@@ -1,7 +1,6 @@
|
||||
// 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;
|
||||
@@ -145,7 +144,7 @@ public sealed class EditTableV2ExecutorTest(ITestOutputHelper output) : Workflow
|
||||
this.State.Set("TestTable", tableValue);
|
||||
|
||||
// Arrange, Act, Assert
|
||||
await this.ExecuteTestAsync<TableValue>(
|
||||
await this.ExecuteTestAsync<RecordValue>(
|
||||
displayName: nameof(AddItemOperationWithSingleFieldRecordAsync),
|
||||
variableName: "TestTable",
|
||||
changeType: this.CreateAddItemOperation(new RecordDataValue.Builder
|
||||
@@ -155,8 +154,8 @@ public sealed class EditTableV2ExecutorTest(ITestOutputHelper output) : Workflow
|
||||
["Name"] = new StringDataValue("John")
|
||||
}
|
||||
}.Build()),
|
||||
verifyAction: (variableName, resultTable) =>
|
||||
Assert.Equal("John", Assert.Single(resultTable.Rows).Value.GetField("Name").ToObject())
|
||||
verifyAction: (variableName, recordValue) =>
|
||||
Assert.Equal("John", recordValue.GetField("Name").ToObject())
|
||||
);
|
||||
}
|
||||
|
||||
@@ -169,46 +168,15 @@ public sealed class EditTableV2ExecutorTest(ITestOutputHelper output) : Workflow
|
||||
this.State.Set("TestTable", tableValue);
|
||||
|
||||
// Act & Assert
|
||||
await this.ExecuteTestAsync<TableValue>(
|
||||
await this.ExecuteTestAsync<RecordValue>(
|
||||
displayName: nameof(AddItemOperationWithScalarValueAsync),
|
||||
variableName: "TestTable",
|
||||
changeType: this.CreateAddItemOperation(new StringDataValue("TestValue")),
|
||||
verifyAction: (variableName, resultTable) =>
|
||||
Assert.Equal("TestValue", Assert.Single(resultTable.Rows).Value.GetField("Value").ToObject())
|
||||
verifyAction: (variableName, recordValue) =>
|
||||
Assert.Equal("TestValue", recordValue.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()
|
||||
{
|
||||
|
||||
@@ -27,7 +27,6 @@ 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
|
||||
@@ -82,21 +81,6 @@ _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.
|
||||
|
||||
@@ -872,9 +856,10 @@ 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.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.
|
||||
``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).
|
||||
"""
|
||||
try:
|
||||
relative_parts = candidate.relative_to(self._root_path).parts
|
||||
@@ -888,19 +873,18 @@ class FileSystemAgentFileStore(AgentFileStore):
|
||||
for segment in relative_parts:
|
||||
current = current / segment
|
||||
try:
|
||||
is_link = _is_link_or_reparse_point(current)
|
||||
except FileNotFoundError:
|
||||
break
|
||||
is_link = current.is_symlink()
|
||||
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 {probed_path!r} is a symbolic link or reparse point."
|
||||
f"Invalid path: unable to verify whether '{segment}' 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``.
|
||||
@@ -924,9 +908,9 @@ class FileSystemAgentFileStore(AgentFileStore):
|
||||
flags |= os.O_TRUNC
|
||||
else:
|
||||
flags |= os.O_EXCL
|
||||
# ``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.
|
||||
# ``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.
|
||||
nofollow = getattr(os, "O_NOFOLLOW", 0)
|
||||
flags |= nofollow
|
||||
try:
|
||||
@@ -1001,12 +985,7 @@ class FileSystemAgentFileStore(AgentFileStore):
|
||||
directories: list[FileStoreEntry] = []
|
||||
files: list[FileStoreEntry] = []
|
||||
for entry in full_dir.iterdir():
|
||||
try:
|
||||
is_link = _is_link_or_reparse_point(entry)
|
||||
except OSError:
|
||||
# Fail closed when an entry cannot be inspected.
|
||||
continue
|
||||
if is_link:
|
||||
if entry.is_symlink():
|
||||
continue
|
||||
if entry.is_dir():
|
||||
directories.append(FileStoreEntry(entry.name, FileStoreEntry.DIRECTORY))
|
||||
@@ -1060,12 +1039,7 @@ class FileSystemAgentFileStore(AgentFileStore):
|
||||
while directories:
|
||||
current = directories.pop()
|
||||
for entry in current.iterdir():
|
||||
try:
|
||||
is_link = _is_link_or_reparse_point(entry)
|
||||
except OSError:
|
||||
# Fail closed when an entry cannot be inspected.
|
||||
continue
|
||||
if is_link:
|
||||
if entry.is_symlink():
|
||||
continue
|
||||
if entry.is_dir():
|
||||
if recursive:
|
||||
|
||||
@@ -1445,7 +1445,7 @@ class AgentMiddlewareLayer:
|
||||
|
||||
|
||||
def _determine_middleware_type(middleware: Any) -> MiddlewareType:
|
||||
"""Determine the middleware type from function annotations or decorators.
|
||||
"""Determine middleware type using decorator and/or parameter type annotation.
|
||||
|
||||
Args:
|
||||
middleware: The middleware function to analyze.
|
||||
@@ -1456,8 +1456,6 @@ 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)
|
||||
|
||||
@@ -1482,7 +1480,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):
|
||||
@@ -1495,7 +1493,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
|
||||
|
||||
@@ -1509,7 +1507,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)."
|
||||
)
|
||||
|
||||
@@ -3,11 +3,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import stat
|
||||
import subprocess
|
||||
import sys
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
@@ -62,29 +58,6 @@ 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"
|
||||
@@ -447,31 +420,6 @@ 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)
|
||||
@@ -825,22 +773,17 @@ 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.lstat`` raises during the probe, the operation must be refused."""
|
||||
"""If ``Path.is_symlink`` raises during the probe, the operation must be refused."""
|
||||
store = FileSystemAgentFileStore(tmp_path)
|
||||
await store.write("same/same/ok.txt", "content")
|
||||
await store.write("ok.txt", "content")
|
||||
|
||||
original_lstat = Path.lstat
|
||||
failing_path = store.root_path / "same" / "same"
|
||||
def boom(self: Path) -> bool:
|
||||
raise PermissionError("access denied")
|
||||
|
||||
def fail_for_target(self: Path) -> os.stat_result:
|
||||
if self == failing_path:
|
||||
raise PermissionError("access denied")
|
||||
return original_lstat(self)
|
||||
monkeypatch.setattr(Path, "is_symlink", boom)
|
||||
|
||||
monkeypatch.setattr(Path, "lstat", fail_for_target)
|
||||
|
||||
with pytest.raises(ValueError, match=r"'same/same'"):
|
||||
await store.read("same/same/ok.txt")
|
||||
with pytest.raises(ValueError, match="symbolic link or reparse point"):
|
||||
await store.read("ok.txt")
|
||||
|
||||
|
||||
def test_file_access_harness_classes_are_marked_experimental() -> None:
|
||||
|
||||
@@ -2302,54 +2302,3 @@ 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)
|
||||
|
||||
@@ -62,6 +62,7 @@ from agent_framework._types import (
|
||||
TextSpanRegion,
|
||||
UsageDetails,
|
||||
detect_media_type_from_base64,
|
||||
prepend_instructions_to_messages,
|
||||
validate_tool_mode,
|
||||
)
|
||||
from agent_framework.exceptions import (
|
||||
@@ -1376,6 +1377,7 @@ 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
|
||||
@@ -1387,6 +1389,15 @@ 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,14 +411,10 @@ 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"
|
||||
|
||||
# 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"
|
||||
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"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@@ -6200,30 +6196,70 @@ def _create_mock_responses_text_response(*, response_id: str) -> MagicMock:
|
||||
return mock_response
|
||||
|
||||
|
||||
@pytest.mark.parametrize("conversation_id", [None, "resp_456", "conv_abc123"])
|
||||
async def test_instructions_passed_natively_not_as_system_message(
|
||||
conversation_id: str | None,
|
||||
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,
|
||||
) -> 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=["Hello"])],
|
||||
options=options,
|
||||
messages=[Message(role="user", contents=["Continue conversation"])],
|
||||
options={"instructions": "Be helpful.", "conversation_id": conversation_id},
|
||||
)
|
||||
|
||||
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.get("role") == "system" for message in input_messages)
|
||||
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"
|
||||
|
||||
|
||||
def test_with_callable_api_key() -> None:
|
||||
|
||||
@@ -7,8 +7,6 @@ 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). |
|
||||
@@ -27,18 +25,6 @@ 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
|
||||
|
||||
@@ -1,180 +0,0 @@
|
||||
# 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
|
||||
"""
|
||||
@@ -1,127 +0,0 @@
|
||||
# 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
|
||||
"""
|
||||
@@ -1,85 +0,0 @@
|
||||
# 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
|
||||
@@ -1,105 +0,0 @@
|
||||
# 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()
|
||||
@@ -1,7 +0,0 @@
|
||||
# dependencies
|
||||
/node_modules
|
||||
|
||||
# build artifacts
|
||||
*.tsbuildinfo
|
||||
vite.config.js
|
||||
vite.config.d.ts
|
||||
@@ -1,13 +0,0 @@
|
||||
<!doctype html>
|
||||
<!-- Copyright (c) Microsoft. All rights reserved. -->
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>AG-UI Single Agent Demo</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
-1031
File diff suppressed because it is too large
Load Diff
@@ -1,23 +0,0 @@
|
||||
{
|
||||
"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"
|
||||
}
|
||||
}
|
||||
@@ -1,281 +0,0 @@
|
||||
// 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>
|
||||
);
|
||||
}
|
||||
@@ -1,13 +0,0 @@
|
||||
// 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>,
|
||||
);
|
||||
@@ -1,259 +0,0 @@
|
||||
/* 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);
|
||||
}
|
||||
}
|
||||
@@ -1,3 +0,0 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
/// <reference types="vite/client" />
|
||||
@@ -1,20 +0,0 @@
|
||||
{
|
||||
"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" }]
|
||||
}
|
||||
@@ -1,13 +0,0 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"composite": true,
|
||||
"target": "ES2020",
|
||||
"lib": ["ES2020"],
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "Bundler",
|
||||
"allowSyntheticDefaultImports": true,
|
||||
"types": ["node"],
|
||||
"skipLibCheck": true
|
||||
},
|
||||
"include": ["vite.config.ts"]
|
||||
}
|
||||
@@ -1,12 +0,0 @@
|
||||
// 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,
|
||||
},
|
||||
});
|
||||
@@ -1,6 +0,0 @@
|
||||
{
|
||||
"name": "ag_ui_single_agent",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {}
|
||||
}
|
||||
Reference in New Issue
Block a user