MentorAgent 1.0.0-preview.4
dotnet add package MentorAgent --version 1.0.0-preview.4
NuGet\Install-Package MentorAgent -Version 1.0.0-preview.4
<PackageReference Include="MentorAgent" Version="1.0.0-preview.4" />
<PackageVersion Include="MentorAgent" Version="1.0.0-preview.4" />
<PackageReference Include="MentorAgent" />
paket add MentorAgent --version 1.0.0-preview.4
#r "nuget: MentorAgent, 1.0.0-preview.4"
#:package MentorAgent@1.0.0-preview.4
#addin nuget:?package=MentorAgent&version=1.0.0-preview.4&prerelease
#tool nuget:?package=MentorAgent&version=1.0.0-preview.4&prerelease
MentorAgent
Preview Release — MentorAgent is currently in public preview. APIs may change before the stable release.
Package Family
| Package | Install when |
|---|---|
| MentorAgent ← you are here | Blazor Server app (the most common case) |
| MentorAgent.Server | Web API, headless backend, or when you need MCP/A2A/SignalR/SSE endpoints for any ASP.NET Core app |
| MentorAgent.Blazor | Blazor WASM / Blazor Auto client project |
| MentorAgent.Abstractions | Never install directly — transitive dependency included automatically |
MentorAgent is a .NET library that embeds a fully functional AI assistant directly into any Blazor application — with a floating chat widget, multi-agent orchestration, contextual memory, RAG, MCP integration, A2A federation, Agent Skills, and voice support — all configured via a single AddMentorAgent() call.
Built on top of Microsoft Agent Framework (Microsoft.Agents.AI), MentorAgent abstracts the complexity of multi-agent systems into a clean, attribute-driven programming model designed specifically for Blazor developers.
Table of Contents
- What is MentorAgent?
- What can it do?
- Architecture overview
- Getting started
- AI providers
- The three-level agent model
- Page navigation
- Page context and UI actions
- Agent Skills
- Contextual memory
- RAG — Retrieval-Augmented Generation
- MCP — Model Context Protocol
- A2A — Agent-to-Agent
- Persistent conversation history
- Blazor Server vs Blazor WASM
- Built-in AI tools
- Streaming responses
- Session serialize and restore
- Security
- Token & cost optimization
- Middleware & extensibility
- Observability (OpenTelemetry)
- Token & cost dashboard
- Model routing
- Structured outputs
- Rich responses (tables & lists)
- Multimodal image input
- Hosted tools (web search, code interpreter, file search, images, remote MCP)
- Human-in-the-loop tool approval
- Evaluation & regression testing
- Widget customization
- All configuration options
- Extensibility
- Requirements
- Related Packages
- License
What is MentorAgent?
MentorAgent turns any Blazor application into an AI-assisted experience. It provides:
- A floating chat widget rendered automatically in the UI (no manual HTML needed).
- A Main Coordinator agent that understands the application context and routes requests.
- An attribute-driven discovery system that scans your assemblies and turns plain C# methods and classes into AI tools and agents — zero boilerplate.
- Full integration with Microsoft Agent Framework, supporting Handoff Workflows, Group Chat teams, and any AI provider compatible with the
IChatClientabstraction. - RAG — inject relevant documents from any vector database into every AI response.
- MCP — consume external MCP servers as additional tools, and expose MentorAgent's own actions as an MCP server for other AI clients.
- A2A — connect to remote AI agents via the Agent-to-Agent protocol, and expose MentorAgent as a federatable A2A agent.
The developer defines what the AI can do using attributes. MentorAgent handles everything else: prompt building, agent wiring, session management, rate limiting, safety checks, memory, RAG retrieval, MCP connections, A2A federation, Agent Skills, and UI.
Before every LLM call, MentorAgent automatically enriches the system prompt with real-time context: the current URL, the active page name, the authenticated user and their roles, the data registered by the current page via IMentorPageContext, the user's contextual memory, the Agent Skills catalogue, and — when RAG is enabled — the most relevant documents retrieved from your vector store.
UI actions registered by the current page are injected as individually named AI tools (one tool per action, each with its own description and parameter schema), rather than a generic dispatcher. This gives the AI precise knowledge of exactly what it can do on the current page, reducing hallucinations and enabling direct invocation: highlight_row(5) instead of invoke_ui_action("highlight_row", 5).
What can it do?
| Feature | Description |
|---|---|
| 🤖 Multi-agent orchestration | Coordinator + specialized agents connected via Handoff Workflow |
| 👥 Group Chat teams | Multiple agents collaborate in a structured conversation before executing |
| 🛠️ Tool discovery | C# methods become AI tools via [Description] or [MentorAction] |
| 🎯 UI Actions | AI invokes page-level actions (highlight rows, open modals, pre-fill forms) as individually named tools with typed parameters and async support |
| 📖 Agent Skills | Domain knowledge + execution packages — knowledge loaded on demand via load_skill (progressive disclosure), methods registered as L1 tools |
| 🧠 Contextual memory | Remembers user preferences and actions across sessions |
| 🗺️ Page navigation | AI navigates to any page decorated with [MentorPage] |
| 💬 Session management | Conversation history managed by Agent Framework (AgentSession) |
| 💾 Persistent history | Pluggable ChatHistoryProvider (CosmosDB, Redis, custom) |
| 📚 RAG | Retrieval-Augmented Generation — inject relevant documents from any vector DB into every AI response |
| 🔌 MCP Client | Consume external MCP servers as additional Level-1 tools for the coordinator |
| 🖥️ MCP Server | Expose every [MentorAction] method as an MCP tool — any MCP client can connect (Claude Desktop, VS Code, etc.) |
| 🌐 A2A Consumer | Connect to remote A2A agents; they participate in the Handoff workflow like local agents |
| 📡 A2A Server | Expose MentorAgent as a federatable A2A agent — other orchestrators can discover and call it |
| 🎨 Customizable widget | Themes, colors, position, avatar, bot name |
| 🌍 Multi-language | Configurable language for AI responses and the widget UI |
| 🎤 Voice input/output | Browser-native Speech Recognition and Speech Synthesis |
| 🖼️ Multimodal image input | Send images to the assistant — upload, clipboard paste, drag & drop or URL — validated by an allow-list and delivered as native AF image content |
| 🌐 Hosted tools | Let the model provider run web search, a code-interpreter sandbox and file search on its own infrastructure — one option, no code |
| 🔒 Safety check | Optional AI-based prompt injection and jailbreak detection |
| ⏱️ Rate limiting | Per-user message limit with configurable time window (requires authentication for true per-user isolation) |
| ⏹️ Stop button | Cancel any in-flight AI request mid-stream — partial response is preserved in the chat |
| ✅ Confirmation dialogs | Destructive actions ask for user confirmation before execution — including MCP tools, and in the Agent Framework's native ApprovalRequiredAIFunction flow when you want AF interop |
| 🔐 Role-based actions | Actions restricted by ASP.NET Core identity roles |
| 📦 Pluggable memory store | Default in-memory, replaceable with Redis/EF Core/MongoDB |
| 💸 Token & cost optimization | Slim cache-friendly prompt, semantic tool filtering (embeddings), history compaction, RAG/memory gating |
| 🔌 Any AI provider | Azure OpenAI, OpenAI, Ollama, Azure AI Foundry, Anthropic, and more |
Architecture overview
┌──────────────────────────────────────────────────────────────────────┐
│ Blazor Application │
│ │
│ ┌──────────────┐ ┌────────────────────────────────────────┐ │
│ │ ChatWidget │◄────►│ MentorOrchestrator │ │
│ │ (Razor UI) │ │ (Main Coordinator) │ │
│ └──────────────┘ └────────────────┬───────────────────────┘ │
│ │ │
│ ┌──────────────────────────────────────┤ │
│ │ Per-call context injection │ │
│ │ · AppContextProvider (page,user) │ │
│ │ · RagContextProvider (documents) │ │
│ │ · SkillsContextProvider (catalogue)│ │
│ │ · UIActionsMiddleware (per-action │ │
│ │ tools from IMentorPageContext) │ │
│ └──────────────────────────────────────┘ │
│ │ │
│ ┌───────────────────────────┼──────────────┐ │
│ ▼ ▼ ▼ │
│ [MentorAgent] [MentorAgent] [MentorTeam] │
│ OrderAgent CustomerAgent AnalysisTeam │
│ (L2 Handoff) (L2 Handoff) (L3 GroupChat) │
│ │ │ │ │
│ [MentorAction] [MentorAction] [TeamMember] │
│ C# methods C# methods sub-agents │
│ │
│ ┌──────────────────────────────────────────────────────────────┐ │
│ │ External Integrations │ │
│ │ MCP Client │ MCP Server │ A2A Consumer │ A2A Server │ │
│ │ (tools from │ ([MentorAct] │ (remote │ (agent-card.json + │ │
│ │ ext. MCPs) │ as MCP) │ agents as L2)│ /a2a endpoint) │ │
│ └──────────────────────────────────────────────────────────────┘ │
│ │
│ ┌──────────────────────────────────────────────────────────────┐ │
│ │ Static tools always registered on the Coordinator │ │
│ │ navigate_to │ load_skill │ read_skill_resource │ │
│ │ remember │ forget_all │ invoke_ui_action (Path B only) │ │
│ └──────────────────────────────────────────────────────────────┘ │
└──────────────────────────────────────────────────────────────────────┘
│
Microsoft Agent Framework
(ChatClientAgent, HandoffWorkflow,
GroupChatWorkflow, AgentSession)
The MentorOrchestrator builds and drives a HandoffWorkflow (or GroupChatWorkflow for teams). Before every LLM call, three AIContextProviders enrich the system prompt: AppContextProvider (page name, URL, user, roles, memory), RagContextProvider (retrieved documents), and SkillsContextProvider (skill catalogue). UI actions registered by the current page are injected as individual named tools by UIActionsMiddleware at the IChatClient level — with their own function-calling loop handled internally before any response reaches the outer pipeline. MCP tools from external servers are added as Level-1 tools alongside local [MentorAction] methods. Remote A2A agents participate in the Handoff graph exactly like local [MentorAgent] classes.
Getting started
Installation
dotnet add package MentorAgent --prerelease
Minimal setup
In Program.cs:
using MentorAgent.Extensions;
builder.Services.AddMentorAgent(options =>
{
options.AppName = "My App";
options.AppDescription = "An order management application";
options.Language = MentorLanguage.Italian;
// Choose your AI provider (see "AI providers" section below)
options.ChatClient = new AzureOpenAIClient(
new Uri("https://myresource.openai.azure.com"),
new AzureKeyCredential(builder.Configuration["AzureOpenAI:Key"]!))
.GetChatClient("gpt-4o-mini")
.AsIChatClient();
// Assemblies to scan for agents, actions, and pages
options.ScanAssemblies = [typeof(Program).Assembly];
});
Add the widget to your layout
In MainLayout.razor (or App.razor):
@using MentorAgent.Abstractions.Components
<ChatWidget />
That's it. The widget injects its own CSS and JS automatically — no changes to App.razor or _Host.cshtml are required.
AI providers
MentorAgent supports any provider via two entry points: IChatClient (recommended for most cases) or AIAgent (required for providers that don't expose IChatClient).
// ── Azure OpenAI — Chat Completions
options.ChatClient = new AzureOpenAIClient(endpoint, credential)
.GetChatClient("gpt-4o-mini").AsIChatClient();
// ── Azure OpenAI — Responses API (service-managed history)
options.ChatClient = new AzureOpenAIClient(endpoint, credential)
.GetResponsesClient("gpt-4o-mini").AsIChatClient();
// ── OpenAI direct
options.ChatClient = new OpenAIClient("sk-...")
.GetChatClient("gpt-4o").AsIChatClient();
// ── Ollama (local)
options.ChatClient = new OllamaChatClient(new Uri("http://localhost:11434"), "llama3.2");
// ── Azure AI Foundry (AIProjectClient) — requires AIAgent
options.Agent = new AIProjectClient(endpoint, credential)
.AsAIAgent(model: "gpt-4o-mini", instructions: "You are a helpful assistant.");
// ── Anthropic Claude — requires AIAgent
options.Agent = new AnthropicClient() { APIKey = apiKey }
.AsAIAgent(model: "claude-haiku-4-5", instructions: "...");
⚠️ Set either
ChatClientorAgent, not both.
Embedding model (optional)
Configure an embedding model to enable semantic tool filtering and semantic memory relevance (MemoryRelevanceFiltering) — see Token & cost optimization. Optional — everything works without it.
// Azure OpenAI — a separate embedding deployment
options.EmbeddingGenerator = new AzureOpenAIClient(endpoint, credential)
.GetEmbeddingClient("text-embedding-3-small").AsIEmbeddingGenerator();
// OpenAI direct
options.EmbeddingGenerator = new OpenAIClient("sk-...")
.GetEmbeddingClient("text-embedding-3-small").AsIEmbeddingGenerator();
Path A vs Path B — feature matrix
| Feature | ChatClient (Path A) |
AIAgent (Path B) |
|---|---|---|
| L1 actions | ✅ | ✅ |
| L2 Handoff agents | ✅ | ❌ requires IChatClient |
| L3 Group Chat teams | ✅ | ❌ requires IChatClient |
| A2A remote agents | ✅ | ❌ requires IChatClient |
| MCP client tools | ✅ | ✅ |
| RAG | ✅ | ✅ |
| Agent Skills | ✅ | ✅ |
| UI Actions (per-action tools) | ✅ via UIActionsMiddleware |
⚠️ single generic invoke_ui_action dispatcher |
| Safety check | ✅ | ❌ skipped automatically |
| Session history reduction | ✅ configurable | ✅ configured in the agent |
Custom ChatHistoryProvider |
✅ via options | ⚠️ must be set in the pre-built agent |
When using
AIAgent(Path B),ChatHistoryProvidercannot be added after construction — configure it directly in the agent you pass tooptions.Agent.
The three-level agent model
MentorAgent organizes AI capabilities in three progressive levels of complexity.
Level 1 — Actions on a service
The simplest level. Decorate C# methods with [Description] (standard .NET, zero dependencies) or [MentorAction] (adds confirmation, roles, navigation, hints).
// Register the service in DI as usual
builder.Services.AddScoped<OrderService>();
public class OrderService
{
// Simple — [Description] from System.ComponentModel, zero dependencies
[Description("Returns the list of active orders")]
public async Task<List<Order>> GetActiveOrdersAsync() { ... }
// Advanced — full [MentorAction] with all parameters
[MentorAction(
Description = "Cancels an existing order",
Category = "Orders", // groups actions in proactive suggestions
RequiresConfirmation = true, // shows confirmation banner before executing
RequiredRoles = ["Manager"], // only users with this role can trigger it
ProactiveHint = "Suggest checking active orders first", // hint injected into AI prompt
NavigateTo = "/orders")] // AI navigates here automatically after success
public async Task<bool> CancelOrderAsync(int orderId, string reason) { ... }
}
MentorAgent discovers the service via ScanAssemblies and exposes its methods as AI tools automatically. The AI calls the right method based on the user's natural language request.
[MentorAction] parameters summary:
| Parameter | Description |
|---|---|
Description |
Natural language description used as the AI tool description |
Category |
Groups actions in proactive suggestion chips |
RequiresConfirmation |
Shows a confirmation banner before executing. Use for destructive or irreversible operations |
RequiredRoles |
ASP.NET Core identity roles required to invoke the action. Empty = accessible to all |
ProactiveHint |
Hint injected into the AI prompt to guide proactive behaviour |
NavigateTo |
URL the AI navigates to automatically after successful execution |
Level 2 — Specialized agent with Handoff
Decorate a class with [MentorAgent] to create a dedicated AI agent connected to the Main Coordinator via a Handoff Workflow. The coordinator hands off the conversation to the right agent based on the request.
[MentorAgent(
Name = "OrderAgent",
Description = "Handles everything related to orders: creation, tracking, cancellation",
HandoffTo = ["CustomerAgent", "InvoiceAgent"] // can hand off further
)]
public class OrderAgent : IMentorAgent
{
private readonly OrderService _orders;
public OrderAgent(OrderService orders) => _orders = orders;
[Description("Creates a new order for a customer")]
public async Task<Order> CreateOrderAsync(int customerId, List<string> products) { ... }
[Description("Retrieves the status of an order")]
public async Task<OrderStatus> GetOrderStatusAsync(int orderId) { ... }
}
Register the agent in DI:
builder.Services.AddScoped<OrderAgent>();
MentorAgent builds a ChatClientAgent from the class, wires it into the Handoff graph, and generates the system prompt from Name and Description (or you can supply a custom Instructions).
Implementing IMentorAgent is optional but recommended — it gives compile-time safety, enables injecting the agent in tests, and ensures the DI container validates its registration.
[MentorAgent] parameters:
| Parameter | Required | Description |
|---|---|---|
Name |
✅ | Agent name — used as the key in the Handoff graph and in the coordinator's system prompt |
Description |
✅ | Natural language description of the agent's capabilities. Used by the coordinator to decide when to delegate |
HandoffTo |
— | Names of other [MentorAgent] agents this agent can hand off to. Must match the Name values exactly (case-insensitive) |
Instructions |
— | Custom system prompt for this agent. If omitted, MentorAgent auto-generates one from Name and Description |
⚠️ Important: agent names in
HandoffTomust match exactly theNamein the target[MentorAgent]attribute (case-insensitive). A mismatch produces a warning in the logs at startup and silently skips that handoff edge.
Level 3 — Collaborative team (Group Chat)
Use [MentorTeam] to define a Group Chat where multiple AI agents collaborate — debating, analyzing, and approving — before a result is returned to the user or handed off to an L2 agent for execution.
[MentorTeam(
Name = "BusinessAnalysisTeam",
Description = "Analyzes business data and produces an approved action plan",
MaxIterations = 8, // forced termination after 8 turns
TriggerOn = ["analysis", "business plan", "budget"], // keywords that activate this team
HandoffTo = ["OrderAgent", "CustomerAgent"])]
public class BusinessAnalysisTeam : IMentorTeam // IMentorTeam is optional, recommended
{
[TeamMember(
Role = "DataAnalyst",
Tools = [typeof(ReportTools), typeof(OrderTools)],
Instructions = "Analyze the data and propose solutions with numbers.")]
public object? Analyst { get; set; }
[TeamMember(
Role = "BusinessApprover",
Instructions = "Review the analyst's proposal. Reply APPROVED or REJECTED with reasons.")]
public object? Approver { get; set; }
[TeamTerminationCondition]
public bool ShouldTerminate(string lastMessage, string lastSpeaker)
=> lastSpeaker == "BusinessApprover" &&
(lastMessage.Contains("APPROVED") || lastMessage.Contains("REJECTED"));
}
MentorAgent builds a GroupChatWorkflow from this declaration and connects it to the main Handoff graph.
[MentorTeam] parameters:
| Parameter | Required | Description |
|---|---|---|
Name |
✅ | Team name — used as the key in the routing graph and shown in logs |
Description |
✅ | Description of the team's purpose. Used by the coordinator to decide when to activate it |
TriggerOn |
— | Keywords injected into the coordinator's prompt as hints for when to activate this team. Not hard rules — the AI decides |
MaxIterations |
— | Maximum number of turns in the Group Chat before forced termination. Default: 10 |
HandoffTo |
— | L2 agents to delegate execution to after the team approves a plan |
[TeamMember] parameters:
| Parameter | Required | Description |
|---|---|---|
Role |
✅ | Role name within the team (e.g. "DataAnalyst", "Approver") |
Instructions |
✅ | System prompt for this member — defines what it should do and how it should respond |
Tools |
— | Tool classes this member can call during the discussion. Should be read-only — write operations belong in L2 agents via HandoffTo |
[TeamTerminationCondition]is required when you need custom exit logic. Required method signature:bool ShouldTerminate(string lastMessage, string lastSpeaker). Without it, the team runs forMaxIterationsturns with round-robin turn-taking and stops automatically.TriggerOnkeywords are injected into the Coordinator's prompt to help it decide when to activate the team — they are hints, not hard rules.[TeamMember]tools should be read-only (queries, reports). Write operations should be delegated to L2 agents viaHandoffTo.- Implementing
IMentorTeamis optional but recommended for the same reasons asIMentorAgent.
Page navigation
Decorate Blazor pages with [MentorPage] to let the AI navigate to them on user request.
@* Simple page — navigation only *@
@attribute [MentorPage("/orders", "Orders")]
@* Page with UI actions — waits for SignalReady() after navigation *@
@attribute [MentorPage("/orders-interactive", "Interactive Orders",
HasUIActions = true,
ReadyTimeout = 3000,
Description = "Order management with inline editing and filtering")]
Pages are discovered at startup from ScanAssemblies — they don't need to be visited first. When HasUIActions = true, the AI waits for the page to call PageContext.SignalReady() before executing UI actions, ensuring the component is fully mounted.
[MentorPage] parameters:
| Parameter | Required | Description |
|---|---|---|
Url |
✅ | Page URL (e.g. "/orders"). Used by the navigate_to tool |
Name |
✅ | Human-readable page name injected into the system prompt |
Description |
— | Optional description of the page's features shown to the AI |
HasUIActions |
— | If true, the AI waits for PageContext.SignalReady() before executing UI actions. Default: false |
ReadyTimeout |
— | Timeout in milliseconds for SignalReady(). Used only when HasUIActions = true. Default: 2000 |
Page context and UI actions
IMentorPageContext is a scoped service injectable in any Blazor page. It has two responsibilities:
- Share visible data — key/value pairs serialized live into the AI system prompt before every call (filter state, selected item, visible row count, etc.).
- Register UI actions — C# lambdas the AI can invoke directly on the current page without going through a business service (highlight a row, open a modal, pre-fill a form).
How UI actions work (architecture)
Each registered UI action becomes its own named AI tool before every LLM call — injected dynamically via UIActionsMiddleware (a DelegatingChatClient wrapping the raw model). The AI sees highlight_row(parameter: integer: order ID) directly in its tool list, not a generic dispatcher. This:
- Eliminates hallucinated action names (the AI sees exact names, not strings to guess)
- Provides proper parameter schemas for each action
- Enables reliable multi-action chains
UI actions are scoped to the current page: they appear in the tool list only while the page is mounted, and disappear the moment the page calls PageContext.Clear() in Dispose().
Registering UI actions
Four overloads are available, from simple to fully typed and async:
@inject IMentorPageContext PageContext
@implements IDisposable
protected override void OnInitialized()
{
PageContext
.SetPageName("Orders")
// ── Share current UI state with the AI ───────────────────────────────
.Set("ActiveFilter", "Pending")
.Set("VisibleRows", _orders.Count)
.Set("SelectedOrder", _selectedOrder)
// ── 1. Simple action — no parameter ─────────────────────────────────
.RegisterUIAction(
"open_create_modal",
"Opens the new order creation dialog",
_ => OpenCreateModal())
// ── 2. Typed parameter — no manual casting required ──────────────────
.RegisterUIAction<int>(
"highlight_row",
"Highlights an order row by its ID",
id => HighlightRow(id), // id is int, not object?
parameterHint: "integer: order ID")
// ── 3. Typed async — awaited before the AI continues ─────────────────
.RegisterUIActionAsync<int>(
"select_order",
"Selects an order and loads its details panel",
async id => {
await InvokeAsync(() => { _selectedId = id; StateHasChanged(); });
},
parameterHint: "integer: order ID")
// ── 4. Complex typed parameter (DTO deserialized from JSON) ──────────
.RegisterUIActionAsync<OrderFormModel>(
"prefill_form",
"Pre-fills the order form with the provided data",
async model => {
await InvokeAsync(() => { _form = model; StateHasChanged(); });
},
parameterHint: "JSON: { customerId, items, notes }");
// Signal the AI that the page is ready (required when HasUIActions = true)
PageContext.SignalReady();
}
// Always clear in Dispose — removes context and actions from the AI's view
public void Dispose() => PageContext.Clear();
UI action overloads reference
| Overload | Parameter | Execution | Use when |
|---|---|---|---|
RegisterUIAction(name, desc, Action<object?>) |
Raw object? (cast manually) |
Synchronous | Simple no-param or legacy code |
RegisterUIAction<TParam>(name, desc, Action<TParam>) |
Auto-deserialized from JSON | Synchronous | Typed param, sync handler |
RegisterUIActionAsync(name, desc, Func<object?, Task>) |
Raw object? |
Async | No-param async actions |
RegisterUIActionAsync<TParam>(name, desc, Func<TParam, Task>) |
Auto-deserialized from JSON | Async | Typed param, async handler (recommended) |
UnregisterUIAction(name) |
— | — | Remove a specific action dynamically (e.g. when a feature becomes unavailable) |
Context data methods reference
| Method | Description |
|---|---|
SetPageName(name) |
Sets the current page name injected into the system prompt |
Set(key, value) |
Adds or updates a context entry (any serializable value) |
Remove(key) |
Removes a single context entry without clearing everything |
Clear() |
Removes all context data and all registered UI actions. Always call from Dispose() |
Automatic parameter schema generation
For the two typed overloads (RegisterUIAction<TParam> and RegisterUIActionAsync<TParam>), the parameterHint argument is automatically generated from the type via reflection when you omit it. You only need to provide it if you want to override the generated text.
TParam |
Auto-generated parameterHint |
|---|---|
int, long, short, byte |
"integer" |
float, double, decimal |
"number" |
bool |
"boolean" |
string |
"string" |
Guid |
"string (GUID)" |
DateTime, DateTimeOffset |
"string (ISO 8601 date)" |
Status (enum) |
"string (Active\|Inactive\|Pending)" |
int? |
"integer?" |
List<int> |
"array<integer>" |
Dictionary<string,int> |
"object<string,integer>" |
OrderFormModel (class) |
"{ customerId: integer, productName: string, quantity: integer }" |
This means the AI sees a precise, field-by-field description of what to pass — even for complex DTOs — without any manual work:
// parameterHint omitted → auto-generated as "{ customerId: integer, productName: string, quantity: integer }"
.RegisterUIActionAsync<OrderFormModel>(
"prefill_form",
"Pre-fills the order form with the provided data",
async model => {
await InvokeAsync(() => { _form = model; StateHasChanged(); });
})
// Override only when the auto-generated hint is not descriptive enough
.RegisterUIAction<int>(
"highlight_row",
"Highlights an order row by its ID",
id => HighlightRow(id),
parameterHint: "integer: order ID") // ← manual override for extra clarity
Async handlers are awaited — the AI waits for the handler to complete before generating its next response. This makes multi-action chains reliable: the AI can call
select_order(42)and thenhighlight_row(42), knowing each step is done before it proceeds.
The AI automatically receives current page context (URL, page name, registered data) injected into its system prompt on every call — with no extra configuration required.
Agent Skills
Agent Skills are domain knowledge + execution packages with two complementary roles:
| Role | What it provides | When used |
|---|---|---|
| Knowledge | Instructions, policy, rules, workflows (markdown) | Loaded on demand via load_skill |
| Tools | [Description]/[MentorAction] methods on the skill class |
Always available as L1 tools |
Rather than dumping all domain knowledge into the system prompt, MentorAgent uses progressive disclosure:
- The skill catalogue (name + one-line description, ~100 tokens per skill) is always injected into the system prompt.
- When the AI needs expertise, it calls
load_skill("skill-name")to receive the full instructions — then uses that knowledge to call the right methods with the right parameters. - Attached resource files (policy docs, FAQ pages, templates) are accessible via
read_skill_resource.
10 registered skills cost roughly 1,000 tokens in the system prompt instead of the 50,000+ tokens you'd need to inline everything upfront.
Setup
builder.Services.AddMentorAgent(options =>
{
options.EnableSkills = true;
options.SkillsFolder = "Skills"; // relative to content root, or absolute path
options.ScanAssemblies = [typeof(Program).Assembly];
});
Dual-role skill class (knowledge + tools)
This is the most powerful pattern. The class provides both the domain instructions and the executable tools:
// Register in DI — required when the class has methods
builder.Services.AddScoped<ExpenseReportSkill>();
[MentorSkill(
Name = "expense-report",
Description = "Handles expense report filing, validation, and policy checks",
InstructionsFile = "Skills/expense-report/SKILL.md")] // or use Instructions = "..."
public class ExpenseReportSkill
{
private readonly ExpenseRepository _repo;
public ExpenseReportSkill(ExpenseRepository repo) => _repo = repo;
// ── Methods become L1 tools — always registered on the coordinator ──────
[Description("Submits a new expense report for the current user")]
public async Task<string> SubmitExpenseReportAsync(
string category, decimal amount, string notes) { ... }
[MentorAction(
Description = "Approves a pending expense report",
RequiresConfirmation = true,
RequiredRoles = ["Manager"])]
public async Task<bool> ApproveExpenseReportAsync(int reportId) { ... }
[Description("Returns the reimbursement policy for a given expense category")]
public string GetPolicyForCategory(string category) { ... }
}
The flow when a user asks "Submit a €47 expense for today's business lunch":
- AI sees
submit_expense_report_asyncin its tool list - Before calling it, AI calls
load_skill("expense-report")— learns that meals have a €50 limit, require a category and description - AI calls
submit_expense_report_async("Meals", 47, "Business lunch")with policy-correct parameters
Without the skill, the AI guesses. With the skill, the AI knows the rules before it acts.
Knowledge-only skill (no methods)
Use this when the skill provides guidance but all execution is handled by existing service tools:
// No DI registration needed — no methods, no instance required
[MentorSkill(
Name = "refund-policy",
Description = "Return and refund rules for the online store",
Instructions = """
## Refund Policy
- Items can be returned within 30 days of purchase.
- Digital products are non-refundable once downloaded.
- Damaged items: customer submits a photo via the support ticket tool.
- Refunds are processed within 5–7 business days to the original payment method.
""")]
public class RefundPolicySkill { }
File-based skills (auto-discovery)
MentorAgent also discovers skills automatically from the filesystem — no class needed:
Skills/
expense-report/
SKILL.md ← required: skill instructions in markdown
policy.md ← optional: resource accessible via read_skill_resource
examples.md ← optional: additional resource
refund-policy/
SKILL.md
shipping-rules/
SKILL.md
carrier-list.txt
SKILL.md format — the first paragraph after the heading becomes the catalogue description:
# Expense Report Filing
Handles expense report submission, validation, approval workflow, and policy enforcement.
## Eligible Expenses
- Meals: up to €50/day domestic, €80/day international
- Travel: economy class only for flights under 4 hours
...
Class-based overrides file-based — if both define a skill with the same name, the class attribute wins.
How the AI sees skills
System prompt (catalogue only — always injected, ~100 tokens total):
## Available Skills
When a request requires specialised expertise in any area below,
call load_skill with the skill name to get full instructions before responding:
- **expense-report**: Handles expense report filing, validation, and policy checks
- **refund-policy**: Return and refund rules for the online store
- **shipping-rules**: Shipping carrier rules and delivery SLAs
Full instructions and resources are never in the prompt by default — only loaded when the AI decides it needs them.
[MentorSkill] parameters
| Parameter | Description |
|---|---|
Name |
Unique skill name in kebab-case (e.g. "expense-report"). Key for load_skill |
Description |
One-sentence description shown to the AI in the skill catalogue |
InstructionsFile |
Path to a markdown file (absolute or relative to content root). Falls back to Skills/{Name}/SKILL.md |
Instructions |
Inline markdown text. Takes precedence over InstructionsFile |
Agent Skills configuration options
| Option | Type | Default | Description |
|---|---|---|---|
EnableSkills |
bool |
false |
Enables skill discovery and the load_skill / read_skill_resource tools |
SkillsFolder |
string |
"Skills" |
Folder to scan for file-based skills. Relative to content root or absolute |
Contextual memory
Enable the Mentor's ability to remember preferences and past actions across sessions:
options.UseMemoryContext = true;
options.MemoryContextCount = 10; // last N memories injected into the system prompt
By default, memories are stored in RAM (InMemoryMentorMemoryStore). For real persistence, register your own store before AddMentorAgent():
// Redis
builder.Services.AddSingleton<IMentorMemoryStore, RedisMemoryStore>();
// EF Core
builder.Services.AddScoped<IMentorMemoryStore, EfCoreMemoryStore>();
// MongoDB
builder.Services.AddSingleton<IMentorMemoryStore, MongoMemoryStore>();
builder.Services.AddMentorAgent(options => {
options.UseMemoryContext = true;
...
});
⚠️ User isolation: With authentication configured, each user has their own memory (keyed by
ClaimTypes.NameIdentifier). Without authentication, all users share the"anonymous"key — suitable only for single-user apps or development.
How automatic memory works
When UseMemoryContext = true, MentorAgent captures durable facts reliably — without depending on the model proactively calling a tool (which weaker models do inconsistently, e.g. saving on "Mi chiamo Antonio" but not on "Ciao, mi chiamo Antonio").
- Path A (a
ChatClientis configured) — default: after each user message a dedicated, minimal LLM call (MemoryAutoCapture, on by default) extracts durable personal facts and stores them directly. Because it is a separate focused task, it is robust regardless of how the coordinator replies. On this path the now-redundantremembertool and its forceful prompt block are dropped (fewer tokens per call);forgetstays. Setoptions.MemoryAutoCapture = falseto opt out — one fewer model call per message, but facts are then saved less reliably via theremembertool. - Path B (a pre-built
Agent, noChatClient): auto-capture cannot run, so the Coordinator is instructed to call theremembertool itself (forceful prompt block).
Examples of what gets saved automatically:
| User says | Saved as |
|---|---|
"My name is Antonio" |
user_name = Antonio |
"I work in the sales team" |
user_team = sales team |
"Always show me Pending orders first" |
preferred_filter = Pending |
Verify in the logs: [MentorAgent:Memory] Auto-capture saved 1 fact(s): user_name.
At the start of each session, stored memories are injected into the prompt so the AI greets the user by name and honours preferences immediately. With MemoryRelevanceFiltering (requires EmbeddingGenerator) only the memories semantically relevant to the current message are injected — identity/preference facts always kept.
InMemoryMentorMemoryStore — production limitations
The default store has two hard limitations:
- Data is lost on app restart — memories do not survive deployments.
- Does not scale across multiple instances — in a load-balanced environment each server has its own isolated dictionary.
For any production deployment with more than one server instance, or where persistence across restarts is required, register a custom IMentorMemoryStore implementation.
RAG — Retrieval-Augmented Generation
RAG enriches every AI response with documents retrieved from your vector store. Before the LLM call, MentorAgent searches for the most relevant documents and injects them into the coordinator's system prompt — grounding the AI's answers in your actual data.
Setup
Step 1 — Register your RAG source before AddMentorAgent():
// Implement IMentorRagSource with your preferred vector DB
builder.Services.AddScoped<IMentorRagSource, MyVectorDbRagSource>();
// Examples:
// Azure AI Search
builder.Services.AddScoped<IMentorRagSource, AzureSearchRagSource>();
// Qdrant
builder.Services.AddScoped<IMentorRagSource, QdrantRagSource>();
// Any custom implementation
builder.Services.AddScoped<IMentorRagSource, MyCustomRagSource>();
Step 2 — Enable RAG in options:
builder.Services.AddMentorAgent(options =>
{
options.UseRag = true;
options.RagResultCount = 5; // number of documents to inject
options.RagSystemPromptTemplate = "Use the following documents to answer:\n{documents}";
options.ShowRagSources = true; // show citation chips below AI messages
});
Implement IMentorRagSource
public class MyVectorDbRagSource : IMentorRagSource
{
private readonly MyVectorDb _db;
public MyVectorDbRagSource(MyVectorDb db) => _db = db;
public async Task<IReadOnlyList<MentorRagResult>> SearchAsync(
string query, int maxResults, CancellationToken ct = default)
{
var hits = await _db.SearchAsync(query, maxResults, ct);
return hits.Select(h => new MentorRagResult(
Content: h.Text,
SourceUrl: h.Url,
Title: h.Title,
Score: h.Score)).ToList();
}
}
RAG source citations in the widget
When ShowRagSources = true, citation chips appear below each AI message:
[AI response]
──────────────────────
📄 Product Manual v2 ↗
📄 Support FAQ ↗
The chips link to the original SourceUrl of each retrieved document. If more than 3 sources are returned, a "+N more" button collapses the rest.
Architecture note
RAG is applied only at the coordinator level — not on L2/L3 specialist agents. The coordinator performs the vector search once per user message; retrieved documents flow naturally into the conversation context passed to any delegated agent. This avoids redundant searches and keeps costs low.
RAG configuration options
| Option | Default | Description |
|---|---|---|
UseRag |
false |
Enables RAG. Requires a registered IMentorRagSource |
RagResultCount |
5 |
Number of documents retrieved per query |
RagMinScore |
2 |
Minimum relevance score a document must reach to be injected into the prompt. Documents below this threshold are discarded. Scale depends on the IMentorRagSource implementation: for keyword search, 2 ≈ two content matches or one title match; for vector/cosine similarity, typical values are 0.5–0.75. Set to 0 to disable filtering |
RagSystemPromptTemplate |
"Use the following documents to answer:\n{documents}" |
Template injected into the system prompt. Use {documents} as placeholder |
ShowRagSources |
false |
Show citation chips below AI messages in the widget |
RAG is fully semantic: the vector search embeds every message and
RagMinScorediscards anything below the threshold, so a pure command like "update the price of X" simply retrieves nothing relevant and injects nothing — no keyword pre-gate needed.
MCP — Model Context Protocol
MentorAgent supports MCP in both directions: as a client (consuming tools from external MCP servers) and as a server (exposing [MentorAction] methods to any MCP-compatible client).
MCP Client — consuming external MCP servers
External MCP servers (filesystem, databases, APIs, custom tools) become additional Level-1 tools for the coordinator — indistinguishable from local [MentorAction] methods.
HTTP transport (remote server)
builder.Services.AddMentorAgent(options =>
{
options.McpServers = [
new MentorMcpServer
{
Name = "MyApiTools",
ServerUrl = "https://mcp.example.com/mcp",
}
];
});
stdio transport (local process)
Windows note: on Windows,
npx,uvx,python, and other script launchers are.cmdor shell scripts that cannot be started directly by .NET'sProcess.Start. MentorAgent detects this automatically and wraps the command ascmd.exe /c <command> <args>— no changes needed in your configuration.
options.McpServers = [
new MentorMcpServer
{
Name = "filesystem",
Command = "npx",
Arguments = ["-y", "@modelcontextprotocol/server-filesystem", "/tmp"],
AllowedTools = ["read_file", "list_directory"], // null = all tools
RequiresConfirmation = true, // shows confirmation banner for every call
},
new MentorMcpServer
{
Name = "database",
Command = "my-db-mcp-server",
Arguments = ["--connection-string", "Server=..."],
EnvironmentVariables = new Dictionary<string, string>
{
["DB_PASS"] = builder.Configuration["DbPassword"]!
}
}
];
MCP status badge in the widget
When ShowMcpStatus = true, a 🔌 N badge appears in the widget header showing the number of connected servers. Clicking it opens a dropdown with the connection status of each server.
options.ShowMcpStatus = true; // default: false — recommended for development
MentorMcpServer properties:
| Property | Description |
|---|---|
Name |
Display name for the server |
ServerUrl |
HTTP/SSE endpoint URL (HTTP transport) |
Command |
Executable to launch (stdio transport, e.g. "npx") |
Arguments |
Command-line arguments for the process |
EnvironmentVariables |
Environment variables injected into the process |
AllowedTools |
Whitelist of tool names to expose. null = expose all |
RequiresConfirmation |
If true, every call to a tool from this server shows a confirmation banner |
MCP Server — exposing MentorAgent as an MCP server
Every [MentorAction] method discovered by MentorAgent can be exposed as an MCP tool, allowing any MCP-compatible client (Claude Desktop, VS Code Copilot, custom agents) to call your application's business logic directly.
Step 1 — Enable the MCP server in options:
builder.Services.AddMentorAgent(options =>
{
options.McpServerEnabled = true;
options.AppName = "MyApp"; // becomes the MCP server name
});
Step 2 — Map the MCP endpoint in Program.cs:
app.MapMentorAgentMcp(); // default path: /mcp
app.MapMentorAgentMcp("/my-mcp"); // custom path
MCP clients can now connect to https://yourapp/mcp and discover all [MentorAction] tools.
ℹ️
[MentorAction]classes registered as Scoped are fully supported by the MCP server — MentorAgent creates a dedicated DI scope internally to resolve them. For classes that hold stateful resources (e.g. openDbContext), prefer Transient or Singleton registration.
A2A — Agent-to-Agent
MentorAgent supports the Agent-to-Agent (A2A) protocol in both directions: as a consumer (calling remote A2A agents) and as a server (exposing MentorAgent as a federatable agent).
A2A Consumer — calling remote A2A agents
Remote A2A agents participate in the Handoff workflow exactly like local [MentorAgent] classes. The coordinator can hand off to them; they return results to the coordinator when done. This lets you federate specialized agents deployed as separate services.
builder.Services.AddMentorAgent(options =>
{
options.RemoteAgents = [
new MentorRemoteAgent
{
Name = "InventoryAgent",
Description = "Manages warehouse stock and inventory levels",
AgentCardUrl = "https://inventory.example.com", // base URL only — SDK auto-appends /.well-known/agent-card.json
},
new MentorRemoteAgent
{
Name = "BillingAgent",
Description = "Handles invoicing and payment processing",
AgentCardUrl = "https://billing.example.com", // base URL only
Headers = new Dictionary<string, string>
{
["Authorization"] = $"Bearer {builder.Configuration["BillingApiKey"]}"
}
}
];
});
MentorAgent automatically resolves each agent's Agent Card (/.well-known/agent-card.json) at startup, establishes an A2A connection, and adds the agent to the Handoff graph. The coordinator's system prompt is automatically enriched with each remote agent's name and description, so the AI knows when to delegate. The coordinator can then delegate requests to remote agents using natural language — no extra configuration needed.
MentorRemoteAgent properties:
| Property | Description |
|---|---|
Name |
Display name used in the Handoff graph and in the coordinator's system prompt |
Description |
Human-readable description of what this agent does. Injected into the coordinator's system prompt so the AI knows when to route to it |
AgentCardUrl |
Base URL of the remote agent (e.g. https://inventory.example.com). The SDK automatically appends /.well-known/agent-card.json — do NOT include the path |
Headers |
Optional custom HTTP headers (auth tokens, API keys, etc.) injected into all A2A requests to this agent |
RequiredRoles |
Optional ASP.NET Core roles required to use this agent. ⚠️ Per-agent role enforcement is not yet fully implemented |
A2A Server — exposing MentorAgent as an A2A agent
MentorAgent can expose itself as a fully compliant A2A agent, discoverable and callable by any A2A-compatible orchestrator.
Step 1 — Enable the A2A server in options:
builder.Services.AddMentorAgent(options =>
{
options.A2AServerEnabled = true;
options.AppName = "MyApp";
options.AppDescription = "My Blazor AI assistant for order management";
options.AgentCard = new AgentCardInfo { Version = "2.0.0" };
});
Step 2 — Map the A2A endpoints in Program.cs:
app.MapMentorAgentA2A(); // default path: /a2a
app.MapMentorAgentA2A("/agent"); // custom path
This registers two endpoints:
GET /.well-known/agent-card.json— Agent Card describing the agent's name, description, version, and supported interfacesPOST /a2a— A2A task handler that receives messages, routes them throughMentorOrchestrator, and streams back the response
Remote orchestrators can now discover this agent via https://yourapp/.well-known/agent-card.json and send A2A tasks to it.
⚠️
A2AServerUrlis required when other A2A instances will call this agent as a remote. Set it to the full public URL of your A2A endpoint (e.g.http://localhost:5001/a2a). Without it, the Agent Card'sSupportedInterfacescontains only a relative path (/a2a), which causes aUriFormatExceptionin remote consumers when they try to resolve the endpoint.options.A2AServerUrl = "https://myapp.example.com/a2a";
AgentCardInfo properties:
| Property | Default | Description |
|---|---|---|
Version |
"1.0.0" |
Agent version string exposed in the Agent Card |
IconUrl |
null |
URL to the agent's icon image |
Provider |
null |
Provider or organization name |
DocumentationUrl |
null |
URL to the agent's documentation |
Tags |
null |
Additional tags describing capabilities — included in the Agent Card for discovery |
How streaming works: Each incoming A2A task is processed by
MentorAgentA2AHandler, which creates a dedicated DI scope, runsMentorOrchestrator.SendMessageAsync, collects streaming chunks, and returns the complete response viaTaskUpdater.CompleteAsync.
Persistent conversation history
By default, conversation history lives in memory and is lost on app restart.
// CosmosDB
options.ChatHistoryProvider = new CosmosChatHistoryProvider(cosmosClient, "my-db", "conversations");
// Custom (implement ChatHistoryProvider from Microsoft Agent Framework)
options.ChatHistoryProvider = new MyRedisChatHistoryProvider(redisConnection);
See the Agent Framework documentation for available providers.
Built-in AI tools
MentorAgent automatically registers internal tools on the Coordinator. The developer does not declare or register them — they activate based on configuration.
Always-active tools
| Tool | When it is used | Notes |
|---|---|---|
navigate_to |
User asks to go to a page, or an agent needs to navigate before executing UI actions | Discovers pages from [MentorPage] at startup; waits for SignalReady() if HasUIActions = true |
route_to_specialist |
Coordinator delegates a request to a Level-2 agent or remote A2A agent | Auto-registered when at least one [MentorAgent] or MentorRemoteAgent is configured. The coordinator calls it with the target agent's name — the Handoff Workflow routes execution accordingly |
{action_name} |
AI invokes a UI action registered via PageContext.RegisterUIAction*() |
One tool per action — each has its own name, description, and parameter schema. Injected dynamically per-call by UIActionsMiddleware. Only visible when the page is mounted. (Path A only) |
invoke_ui_action |
Generic UI action dispatcher (legacy) | Used only on Path B (pre-built AIAgent). On Path A (ChatClient), each action is its own named tool — this dispatcher is not added. |
Conditional tools (activated by options)
| Tool | Activated by | Notes |
|---|---|---|
remember |
UseMemoryContext = true |
AI saves a user preference or fact silently |
forget_all |
UseMemoryContext = true |
User explicitly asks to reset their memory |
load_skill |
EnableSkills = true |
Returns full markdown instructions for the requested skill |
read_skill_resource |
EnableSkills = true + at least one skill has resource files |
Returns a specific resource file attached to a skill |
Streaming responses
All AI responses are streamed token by token — no waiting for the full response. This is handled automatically by the Orchestrator via RunStreamingAsync. The developer does not need to configure anything.
Response chunks flow through IMentorStateService.OnStreamingChunk → ChatWidget → UI thread-safe update via InvokeAsync(StateHasChanged). When voice output is enabled, the full response is read aloud after streaming completes.
Stop button
While the AI is processing, a ■ Stop button appears above the chat input. Clicking it cancels the current request immediately — the CancellationToken propagated through the entire agent pipeline (RAG, LLM call, tool execution) is cancelled, and any partial response already streamed is kept in the chat.
This works for all request types: standard messages, team deliberations (L3), and post-confirmation responses.
Blazor Server vs Blazor WASM
MentorAgent registers its core services as Scoped, which behaves differently depending on the hosting model:
| Service | Blazor Web App (Server, Auto) | Blazor Hybrid (MAUI) |
|---|---|---|
MentorOrchestrator |
One per circuit | One per app instance |
IMentorSessionManager |
One per circuit | One per app instance |
IMentorPageContext |
One per circuit | One per app instance |
MentorMemoryService |
One per circuit | One per app instance |
IMentorMemoryStore |
Singleton (shared) | Singleton (shared) |
MentorDiscoveryService |
Singleton (shared) | Singleton (shared) |
MentorRateLimiter |
Singleton (shared) | Singleton (shared) |
Supported render modes
| Template / Render mode | Support | Notes |
|---|---|---|
| Blazor Web App — Server | ✅ | Default. AddMentorAgent() only, no extra packages. |
| Blazor Web App — Auto | ✅ | Option A: @rendermode="InteractiveServer" on <ChatWidget /> (simplest). Option B: add MentorAgent.Server + MentorAgent.Blazor for full WASM support. |
| Blazor Web App — WebAssembly | ✅ | Via MentorAgent.Server (server project) + MentorAgent.Blazor (client project). |
| Blazor WASM Standalone | ✅ | Via MentorAgent.Server (API backend) + MentorAgent.Blazor (WASM project). |
| Blazor Hybrid (MAUI) | ✅ | Fully supported with MentorAgent alone. |
| React / Vue / Angular / mobile | ✅ | Via MentorAgent.Server — connect using SignalR or SSE. |
✅ Blazor Web App — Blazor Server (recommended)
The simplest setup. ChatWidget and MentorOrchestrator run in the same server process, communicating via in-memory C# events. No extra packages needed.
// Program.cs
builder.Services.AddMentorAgent(options => { ... });
@* MainLayout.razor *@
@using MentorAgent.Abstractions.Components
<ChatWidget />
✅ Blazor Web App — Auto render mode (simplest WASM option)
In Blazor Auto, the easiest approach is to pin <ChatWidget /> to InteractiveServer — it stays server-side while the rest of the app can be WASM. No new packages needed.
@* MainLayout.razor — server project *@
@using MentorAgent.Abstractions.Components
<ChatWidget @rendermode="InteractiveServer" />
// Program.cs — server project only
builder.Services.AddMentorAgent(options => { ... });
⚠️ Never call
AddMentorAgent()in the client (WASM) project — only in the server project.
✅ Blazor WASM / Blazor Auto (full WASM) — via MentorAgent.Server + MentorAgent.Blazor
For fully client-side WASM rendering or non-Blazor frontends, install the companion packages:
# Server project — MentorAgent is included automatically as a transitive dependency
dotnet add package MentorAgent.Server --prerelease
# Client project (WASM)
dotnet add package MentorAgent.Blazor --prerelease
// Server/Program.cs
builder.Services.AddMentorAgent(options => { ... });
builder.Services.AddMentorAgentServer();
app.MapMentorAgentServer(); // /mentor-hub + /mentor/chat
// Client/Program.cs
builder.Services.AddMentorAgentBlazor(options =>
{
options.HubUrl = "/mentor-hub";
options.BotName = "My Assistant";
options.Language = MentorLanguage.English;
});
@* Client Razor page — identical to Blazor Server *@
@using MentorAgent.Abstractions.Components
<ChatWidget />
⚠️ Two WASM-only setup steps not required in Blazor Server:
- CSS/JS links in
index.html— the staticindex.htmlis served before the .NET runtime starts, so the widget cannot inject its own assets. Add<link href="_content/MentorAgent.Abstractions/css/MentorAgent.css" rel="stylesheet" />and<script src="_content/MentorAgent.Abstractions/js/MentorAgent.js"></script>manually.- CORS + absolute
HubUrlwhen the WASM app and the server are on different origins (different ports). See the MentorAgent.Server and MentorAgent.Blazor READMEs.
How it works: MentorOrchestrator runs on the server; the WASM widget connects via SignalR. Credentials never reach the browser. All features (agents, RAG, MCP, A2A, memory, HITL) are fully supported.
See MentorAgent.Server and MentorAgent.Blazor for complete documentation.
✅ Non-Blazor frontends (React, Vue, Angular, MAUI, mobile)
Install MentorAgent.Server on your ASP.NET Core backend and connect any client via SignalR (@microsoft/signalr) or plain HTTP SSE (/mentor/chat).
See MentorAgent.Server for complete documentation and examples.
Session serialize and restore
IMentorSessionManager exposes two methods for saving and resuming the conversation state (e.g. across page reloads or server restarts):
@inject IMentorSessionManager SessionManager
// Serialize the current session to a JsonElement (e.g. save to localStorage or DB)
JsonElement? snapshot = await SessionManager.SerializeCurrentSessionAsync(agent);
// Restore a previously saved session
if (snapshot.HasValue)
await SessionManager.RestoreSessionAsync(agent, snapshot.Value);
The widget's reset button (inside the chat header) calls ResetSessionAsync() and clears the message list — starting a brand new conversation.
Security
Input safety check
options.EnableSafetyCheck = true; // adds ~200-500ms per message
Before processing each message, the AI evaluates whether it is a prompt injection, jailbreak attempt, or sensitive data extraction. Works in any language automatically.
Rate limiting
options.RateLimitPerUser = 20; // max 20 messages...
options.RateLimitWindowSecs = 60; // ...per minute, per user
⚠️ User identification: With ASP.NET Core authentication configured, each user is identified by their
ClaimTypes.NameIdentifierclaim and gets an independent counter. Without authentication, all sessions share the key"anonymous"— the limit applies globally across all browsers and users, not per individual. For true per-user rate limiting, configure ASP.NET Core authentication.
Role-based actions
[MentorAction(Description = "Approves a budget request", RequiredRoles = ["Finance", "Admin"])]
public async Task<bool> ApproveBudgetAsync(int requestId) { ... }
Roles are verified against the current user's ClaimTypes.Role claim via AuthenticationStateProvider.
Confirmation dialogs
[MentorAction(Description = "Deletes all orders for a customer", RequiresConfirmation = true)]
public async Task<bool> DeleteAllOrdersAsync(int customerId) { ... }
The widget shows a confirmation banner before executing the action. Disable globally with options.RequireConfirmation = false.
Tools you can't annotate (MCP, skills) are gated with MentorMcpServer.RequiresConfirmation or options.RequiresApproval, and the whole flow can run on the Agent Framework's native protocol — see Human-in-the-loop tool approval.
Widget customization
Themes
| Value | Description |
|---|---|
MentorTheme.Default |
Light with blue accents (--mentor-primary: #2563EB). Suits most apps. |
MentorTheme.Dark |
Dark background (#1E1E2E). Auto-adapts to dark-mode apps. |
MentorTheme.Minimal |
No shadows, thin borders. Ideal for flat/clean designs. |
MentorTheme.Custom |
No theme applied at all. You define every CSS variable manually in your own stylesheet — full control. |
When using MentorTheme.Custom, override these CSS variables in your stylesheet:
:root {
--bm-bg-base: #ffffff;
--bm-bg-panel: rgba(255,255,255,0.97);
--bm-bg-surface: #f8fafc;
--bm-bg-elevated: #f1f5f9;
--bm-bg-hover: #e2e8f0;
--bm-text-primary: #0f172a;
--bm-text-secondary:#475569;
--bm-text-muted: #94a3b8;
--bm-accent: #2563EB; /* primary accent color */
--bm-accent-dark: #1d4ed8;
--bm-accent-glow: rgba(37,99,235,.35);
--bm-border: rgba(0,0,0,.1);
--bm-border-accent: rgba(37,99,235,.3);
--bm-shadow-panel: 0 8px 40px rgba(0,0,0,.15);
--bm-shadow-fab: 0 8px 32px rgba(37,99,235,.4);
}
Mentorship level
Controls the AI's verbosity and proactivity:
| Value | Behaviour |
|---|---|
MentorshipLevel.Minimal |
Executes silently. No unsolicited explanations or suggestions. Best for expert users. |
MentorshipLevel.Standard |
Balanced. Full responses with contextual suggestions when relevant. (default) |
MentorshipLevel.Proactive |
Guides the user: explains what it did and why, suggests next steps, warns of risks, proposes alternatives. Best for onboarding or less-experienced users. |
Full example
options.Theme = MentorTheme.Dark;
options.Position = ChatPosition.BottomRight; // BottomLeft, TopRight, TopLeft, SideRight, SideLeft
options.PrimaryColor = "#2563EB"; // overrides theme accent color
options.BotName = "Aria";
options.AvatarUrl = "/images/aria-avatar.png";
options.WelcomeMessage = "Hi! I'm Aria, your assistant. How can I help?";
options.InputPlaceholder = "Ask me anything...";
options.MentorshipLevel = MentorshipLevel.Proactive;
options.EnableSuggestions = true; // proactive suggestion chips below the input
options.EnableActionFeedback = true; // "Executing..." visual feedback during tool calls
options.EnableVoiceInput = true; // microphone — Chrome/Edge only, requires HTTPS in production
options.EnableVoiceOutput = true; // text-to-speech — all modern browsers
Built-in widget features (always active, no configuration needed)
| Feature | Description |
|---|---|
| Unread badge | When the widget is closed and the AI responds, a numeric badge appears on the FAB button (capped at 9+) |
| Typing indicator | Animated dots shown while the AI is processing and no streaming text has arrived yet |
| Action bar | While a tool is executing, a pulsing bar shows the tool display name |
| Stop button | A ■ Stop button appears above the input while the AI is processing. Clicking it cancels the current request immediately — the partial response (if any) is kept in the chat |
| Reset button | Button in the widget header that clears the message list and starts a new AgentSession |
| Voice toggle | When EnableVoiceOutput = true and the browser supports Speech Synthesis, a speaker toggle button appears in the header |
| MCP status badge | When ShowMcpStatus = true, a 🔌 N badge in the header shows connected MCP servers. Click to see details |
| A2A status badge | When ShowA2AStatus = true, a badge in the header shows configured remote A2A agents (name + URL). Click to expand the detail bar |
| RAG citations | When ShowRagSources = true, source chips appear below AI messages linking to the retrieved documents |
Token & cost optimization
MentorAgent minimizes the tokens sent on every request. Some optimizations are always on; two are opt-in.
Always on (no configuration):
- Slim, cache-friendly system prompt — the static instructions are a stable prefix (so the provider can cache them), and volatile data (date/time, URL) is emitted last.
- Token measurement — every model round-trip logs its usage, so you can verify the effect:
[MentorAgent] Tokens — in: 740, out: 95, call total: 835 | session: 740+95=835 over 1 call(s)
Semantic tool filtering
Every tool you register is serialized as a JSON schema into each request — the biggest per-call cost when you have many tools. With tool filtering, only the tools semantically relevant to the user's latest message are sent; the AI still freely chooses among them.
Relevance is computed with an embedding model, so it is reliable and multilingual (an Italian message matches English tool names). It requires EmbeddingGenerator — if enabled without one, filtering is skipped and all tools are sent (with a warning); there is no keyword fallback.
options.EmbeddingGenerator = new AzureOpenAIClient(endpoint, credential)
.GetEmbeddingClient("text-embedding-3-small").AsIEmbeddingGenerator();
options.EnableToolFiltering = true;
options.ToolFilterMaxTools = 12; // max matched business tools (core tools always kept)
options.ToolFilterMinScore = 0.35f; // cosine-similarity threshold (higher = stricter)
Core tools (navigation, memory, routing, teams, skills, UI actions) are always kept. When no tool is relevant (e.g. "hello"), only the core tools are sent. Tool embeddings are computed once and cached.
History compaction
As a conversation grows, its history is re-sent on every call. Compaction shrinks it intelligently instead of a hard cut. It uses the (experimental) Agent Framework compaction pipeline: collapse old tool results → keep the last N turns → hard token-budget backstop.
options.EnableCompaction = true;
options.CompactionTokenThreshold = 4000; // token budget that triggers compaction
options.CompactionMaxTurns = 8; // recent turns kept intact
Applies only to agents with in-memory history (Path A /
ChatClient) — not to service-managed history (Foundry, Responses API withstore).
Semantic RAG and relevance-ranked memory
Both inject context only when relevant, saving tokens — and both are fully semantic (embedding-based, no keyword heuristics):
- RAG: the vector search +
RagMinScorealready ensure nothing irrelevant is injected on pure commands. See RAG configuration options. - Memory: with
MemoryRelevanceFiltering(requiresEmbeddingGenerator), only the memories most similar to the message are injected (identity/preference facts always kept). See Contextual memory.
Middleware & extensibility
Make the request pipeline robust and pluggable — all additive (defaults preserve current behaviour). Prefer the built-in toggles; drop to the custom hooks for full control.
// ── Built-in safety (no code) ─────────────────────────────────────────────────
options.EnableSafetyCheck = true; // LLM check on the USER MESSAGE (prompt-injection / jailbreak)
options.EnableOutputSafetyCheck = true; // LLM check on the ASSISTANT REPLY (leaks system prompt / secrets / PII / harmful)
// ── Custom hooks (full control) ───────────────────────────────────────────────
// Custom input guardrail — REPLACES EnableSafetyCheck. Return true = safe.
options.InputGuardrail = (message, ct) => Task.FromResult(IsSafe(message));
// Custom output guardrail — REPLACES EnableOutputSafetyCheck. Buffers the reply (no live streaming that turn).
options.OutputGuardrail = (reply, ct) => Task.FromResult(IsSafeReply(reply));
// Transform a tool result before it goes back to the model (e.g. cap very long results to save tokens).
options.OnToolResult = (toolName, result) => Truncate(result, maxChars: 2000);
// Map an exception to a friendly message (null → built-in mapping).
options.OnException = ex => ex.Message.Contains("rate", StringComparison.OrdinalIgnoreCase)
? "The service is busy, please retry shortly." : null;
// Public extension point — insert your own DelegatingChatClient / AF middleware, outermost.
options.ConfigureChatClientPipeline = b => b.UseLogging();
Two layers: built-in toggles — EnableSafetyCheck / EnableOutputSafetyCheck, LLM classifiers (one small model call each; require a ChatClient) — and custom hooks that replace or extend them. A custom InputGuardrail / OutputGuardrail takes precedence over the matching built-in. Output moderation (built-in or custom) buffers the reply for that turn, so no unmoderated text is ever streamed; leave both off to keep live token-by-token streaming.
Observability (OpenTelemetry)
Opt in with EnableObservability. MentorAgent then emits GenAI-convention traces/metrics for the chat client (LLM) calls, plus its own per-turn/tool spans and counters — all under the source/meter named by ObservabilitySourceName (default "MentorAgent"). Your app wires the exporter:
options.EnableObservability = true;
options.ObservabilityIncludeSensitiveData = builder.Environment.IsDevelopment(); // prompt/response text: dev only
// Host wiring (App Insights / Aspire / OTLP / console):
builder.Services.AddOpenTelemetry()
.WithTracing(t => t.AddSource("MentorAgent").AddConsoleExporter())
.WithMetrics(m => m.AddMeter("MentorAgent").AddConsoleExporter());
Token & cost dashboard
An admin-only view — never shown in the end-user widget — modelled on Azure OpenAI's monitoring page: a model selector, temporal charts (tokens / requests / latency over time), a per-model cost table, plus deflection and top actions. Usage is attributed per model — the cheap chat model, the strong routing model and the embedding model are tracked separately (tokens, requests, latency), so you see exactly where cost goes. Supply prices (MentorAgent ships none), then drop the component on a protected page:
options.ModelPricing = new Dictionary<string, ModelPrice>(StringComparer.OrdinalIgnoreCase)
{
["gpt-4.1"] = new ModelPrice(InputPer1M: 2.00m, OutputPer1M: 8.00m), // cheap chat
["o3"] = new ModelPrice(InputPer1M: 2.00m, OutputPer1M: 8.00m), // strong routing
["text-embedding-3-small"] = new ModelPrice(InputPer1M: 0.02m, OutputPer1M: 0.00m), // embedding
};
@* Admin page — you own the authorization; the component reads IMentorMetrics in-process. *@
@attribute [Authorize(Roles = "Admin")]
@using MentorAgent.Abstractions.Components
<MentorDashboard Currency="$" />
Cost is shown only for models present in ModelPricing; otherwise tokens are shown with cost “n/d”. Key ModelPricing by the model id the dashboard reports — for Azure OpenAI that is your deployment name, not the base model name. Charts are inline SVG (no JS, no external libraries) so they render on Blazor Server and WASM under a strict CSP, and are theme-aware. Time-series buckets are hourly and kept for MetricsRetention (default 7 days). Headless clients read the same data from IMentorMetrics.GetSnapshot() (or, in MentorAgent.Server, the GET /mentor/admin/metrics endpoint) — the snapshot carries the per-model breakdown and series, so the WASM dashboard is identical.
The dashboard is localized like the rest of the UI (10 languages, English fallback). On Blazor Server it resolves MentorLocalizer from DI and follows options.Language automatically — nothing to pass. In WASM there is no MentorAgent DI, so set the language explicitly:
<MentorDashboard Snapshot="_snapshot" OnRefresh="LoadAsync" Currency="€" Language="MentorLanguage.Italian" />
Persistence (optional)
By default the metrics live only in RAM and reset when the process restarts. Register an IMentorMetricsStore before AddMentorAgent() to change that — same pluggable pattern as IMentorMemoryStore. Each implementation fills only the half of the contract it needs:
// (A) Local durability — persist a JSON/DB snapshot. The dashboard keeps reading live RAM,
// re-seeded from the last save on startup. The library flushes on a timer + on shutdown.
builder.Services.AddSingleton<IMentorMetricsStore, FileMetricsStore>(); // your impl: LoadForSeed + Save
// (B) External source — the dashboard reads the aggregate the OpenTelemetry export already
// pushed to Prometheus / Azure Monitor (historical, multi-instance). SaveAsync stays a no-op
// because OpenTelemetry is the writer; QueryAsync queries the backend on each read.
builder.Services.AddSingleton<IMentorMetricsStore, PrometheusMetricsStore>(); // your impl: QueryAsync
builder.Services.AddMentorAgent(options => { ... });
LoadForSeedAsync restores totals at startup, SaveAsync persists them (timer via MetricsPersistenceInterval, default 30s, + graceful shutdown), and QueryAsync lets an external source serve the authoritative aggregate — the admin endpoint returns await store.QueryAsync() ?? metrics.GetSnapshot(). The default NullMetricsStore keeps the RAM-only behaviour with zero overhead. Working FileMetricsStore, PrometheusMetricsStore and AzureMonitorMetricsStore implementations ship in the MentorAgentServer sample.
Model routing
Route each turn to a cheap model for simple messages and a strong model for complex ones — a real cost lever. ChatClient is the cheap default; set a strong client and pick a routing strategy. Routing is active only when StrongChatClient is set.
// options.ChatClient stays the cheap default (e.g. gpt-4o-mini)
options.StrongChatClient = new AzureOpenAIClient(endpoint, credential)
.GetChatClient("gpt-4o").AsIChatClient(); // strong (escalation)
options.RoutingStrategy = MentorRoutingStrategy.Semantic; // pick a strategy (default: Custom)
Four strategies — all avoid naive keyword matching on user text:
| Strategy | How it decides | Extra cost per turn | Notes |
|---|---|---|---|
Semantic |
Embeds the user message; escalates when cosine similarity to a "complex" exemplar ≥ RoutingThreshold (0.35) |
1 embedding (~free) | Multilingual; requires EmbeddingGenerator. Override RoutingComplexExemplars for your domain |
Classifier |
A tiny LLM call labels the turn SIMPLE/COMPLEX |
1 small LLM call | Most precise. Uses the cheap client, or RoutingClassifierClient |
Cascade |
Serves on cheap, judges completeness, re-runs on strong only if it fell short | cheap + judge (+ strong on hard turns) | Best quality/cost trade-off. Streaming pre-decides like Classifier |
Custom |
Your own predicate — UseStrongModelAsync (async, whole conversation) or legacy UseStrongModel |
none | The escape hatch |
// Custom async router — full control, sees the whole conversation:
options.RoutingStrategy = MentorRoutingStrategy.Custom;
options.UseStrongModelAsync = async (messages, ct) => await MyClassifier.IsComplexAsync(messages, ct);
The decision uses the latest user message and applies to the whole turn; the chosen model is logged (… routing → strong/cheap model). Any routing failure falls back to the cheap model. Semantic without an EmbeddingGenerator, or Custom without a predicate, disables routing (cheap only) with a warning. The dashboard attributes tokens and cost per model, so cheap vs strong spend is broken out separately. Note: the routing decision's own auxiliary calls (Classifier's label, Cascade's cheap probe + completeness judge) are made below the token meter and are not counted.
Structured outputs
Ask the model for a strongly-typed result (its JSON schema is derived from your type) — ideal for auto-filling forms, extracting entities, or typed generative UI, outside the chat flow. Inject IMentorStructured:
public record ExtractedOrder(string Customer, string[] Products, decimal Total);
var order = await structured.GenerateAsync<ExtractedOrder>(
prompt: userText,
instructions: "Extract the order details from the text.");
Returns default when the output can't be parsed or no ChatClient is configured. Requires a provider with structured-output support (OpenAI / Azure OpenAI).
Rich responses (tables & lists)
The chat-facing companion to structured outputs: the widget renders the assistant's Markdown as real UI — tables for tabular/comparative data, bullet/numbered lists, plus fenced code and bold/italic. With EnableRichResponses on (the default), the coordinator is nudged to format data this way:
options.EnableRichResponses = true; // default — set false for terse plain-text replies
Nothing to wire on the client — ask the assistant something tabular (e.g. "list the 5 cheapest products with price and stock") and the reply renders as a table that scrolls horizontally on narrow widgets. The renderer is XSS-safe by construction: every piece of model text is HTML-encoded before any tag is emitted, so the model supplies data, never markup. This is the first level of generative UI; richer interactive and typed UI blocks are planned.
Multimodal image input
Let users send images to the assistant — a screenshot of an error, a photo of a receipt, a product picture — and have the model reason about them. Built on the Agent Framework's native multimodal API: the user turn becomes a ChatMessage carrying a TextContent plus one DataContent (inline upload) or UriContent (remote URL) per image.
Off by default. Enable it and configure the safety limits:
builder.Services.AddMentorAgent(options =>
{
options.EnableImageInput = true; // shows the 📎 and 🔗 buttons in the widget
options.MaxImageBytes = 4 * 1024 * 1024; // per image (default 4 MB)
options.MaxImagesPerMessage = 4; // per turn (default 4)
options.AllowedImageTypes = ["image/png", "image/jpeg", "image/webp"]; // MIME allow-list
});
Your chat model must be vision-capable — e.g.
gpt-4.1,gpt-4o,o3. A text-only deployment will reject the request.
What the user gets
Once enabled, the widget's composer accepts images in four ways, with no extra code:
| Method | How |
|---|---|
| Upload | 📎 button → native file picker (multi-select) |
| Paste | Ctrl+V an image straight from the clipboard (screenshots) |
| Drag & drop | Drop a file onto the composer — or drag an <img> from another page (its URL is attached) |
| URL | 🔗 button → paste a public image link |
Attached images appear as removable thumbnails above the input, then inside the sent message bubble. Client-side checks mirror the server limits so the user gets an instant message; the server re-validates every attachment (allow-list, size, count) before anything reaches the model — per the AF Agent Safety guidance, this is an allow-list, never a deny-list.
Sending images from your own code
IMentorOrchestrator takes an optional attachment list. Text-only calls are unchanged:
await orchestrator.SendMessageAsync("What's wrong in this screenshot?", [
new MentorAttachment
{
MimeType = "image/png",
DataBase64 = Convert.ToBase64String(await File.ReadAllBytesAsync("error.png")),
FileName = "error.png",
},
new MentorAttachment { MimeType = "image/jpeg", Url = "https://cdn.example.com/product.jpg" },
]);
MentorAttachment carries exactly one source — DataBase64 (inline) or Url (remote) — and converts itself to the right AF content type. An image-only turn (empty text) is valid.
Transport
Attachments flow over every transport (see the .Server README for JS/React/Vue/Angular/MAUI examples):
| Transport | Call |
|---|---|
| Blazor Server (in-process) | SendMessageAsync(text, attachments) |
| SignalR hub | SendMessage(text, attachments) — the trailing argument is optional, so 1-argument clients keep working |
| SSE | POST /mentor/chat with { "message": "...", "attachments": [ … ] } (base64 does not fit in a query string; GET remains for text-only) |
Blazor Server note: the widget never marshals image bytes through a single interop call — it streams them via
IJSStreamReference, so you do not need to raiseHubOptions.MaximumReceiveMessageSize.
Hosted tools (web search, code interpreter, file search, images, remote MCP)
Your [MentorAction] methods let the assistant act on your application. Hosted tools let it do things no application code can do: look up today's news, compute an exact number, read an uploaded PDF, draw an image, call someone else's MCP server.
They are the Agent Framework's provider-hosted tools — they run on the model provider's infrastructure while it generates the answer. You write no implementation and nothing executes on your servers; you only declare which ones the model may use.
| Tool | What the model gains | Typical question it unlocks |
|---|---|---|
| Web search | Information past the training cut-off | "What changed in .NET 10?" |
| Code interpreter | Exact computation in a provider-side sandbox | "Sum the first 500 primes" — no more invented digits |
| File search | Retrieval over documents in the provider's vector store | "What does the contract say about penalties?" |
| Image generation | Images returned inline in the message bubble | "Draw a banner for the summer sale" |
| Hosted MCP | Tools from a remote MCP server, called by the provider | "Search the Microsoft Learn docs" |
builder.Services.AddMentorAgent(options =>
{
options.HostedTools = MentorHostedTools.WebSearch | MentorHostedTools.CodeInterpreter;
// File search needs at least one vector store — without it the tool is skipped (fail-closed)
// options.HostedTools |= MentorHostedTools.FileSearch;
// options.FileSearchVectorStoreIds = ["vs_abc123"];
// options.FileSearchMaxResults = 5;
// Image generation — see the Azure note below, this option alone is not enough there.
// options.HostedTools |= MentorHostedTools.ImageGeneration;
// options.HostedImageModel = "gpt-image-1-mini";
// options.HostedImageSize = "1024x1024"; // the cost knob
// Hosted MCP: the PROVIDER connects to the server, so it must be reachable from the
// provider's network (no localhost). Approval is enforced by the provider.
// options.HostedTools |= MentorHostedTools.HostedMcp;
// options.HostedMcpServers = [
// new MentorHostedMcpServer
// {
// Name = "microsoft_learn",
// Url = "https://learn.microsoft.com/api/mcp",
// AllowedTools = ["microsoft_docs_search"],
// RequireApproval = true, // default — the provider pauses and MentorAgent shows the banner
// }
// ];
options.ShowHostedToolsStatus = true; // amber badge in the widget header (dev)
options.ShowHostedToolActivity = true; // default — live "Searching the web…" + citations
});
Image generation on Azure OpenAI needs a request header. Azure resolves which image deployment the hosted tool should use from
x-ms-oai-image-generation-deployment, not from the tool payload —HostedImageModelalone leaves every image turn failing with "imagegen deployment must be provided through header". MentorAgent is handed an already-builtIChatClient, so it cannot add the header; attach it where you construct the Azure client:var azureOptions = new AzureOpenAIClientOptions(); azureOptions.AddPolicy(new ImageDeploymentHeaderPolicy("gpt-image-1-mini"), PipelinePosition.PerCall); var azureClient = new AzureOpenAIClient(endpoint, credential, azureOptions);
ImageDeploymentHeaderPolicyis a ~15-linePipelinePolicythat sets the header on every request — seeMentorAgentServer/Infrastructure/ImageDeploymentHeaderPolicy.csin the samples. MentorAgent repeats the requirement in a startup log line whenever image generation is enabled.
Hosted MCP vs
options.McpServers. Same protocol, opposite direction. WithMcpServersyour process is the MCP client: tools are fetched at startup, invoked locally, and pass through MentorAgent's gate (roles, confirmation, metrics). WithHostedMcpServersthe provider is the client: your server never contacts the MCP server, so approval is the provider's job viaRequireApproval.
What the user sees while a hosted tool runs
A hosted tool runs remotely and can take several seconds with nothing streamed — without feedback the widget looks frozen. With ShowHostedToolActivity (on by default) MentorAgent reports it through the channels the widget already has:
- the action-feedback line — "Searching the web… · .NET 10 release notes", "Running code…", "Searching your documents…", "Generating the image…"
- citation chips — the pages a web search used and the documents a file search matched, through the same panel as RAG sources (so they also need
ShowRagSources) - inline images — generated images are attached to the finished message, exactly like an uploaded image
- a debug log line per call — the only trace a provider-side tool ran at all, since these never reach the function-calling middleware or the per-tool metrics
Providers differ in how much they report. An unrecognised part is skipped rather than guessed at, and the answer itself never depends on any of it.
Two details worth knowing, because they explain what you will see:
- File search has no content type of its own in Microsoft.Extensions.AI 10.6.0 — its call and result arrive as the plain base types, and the documents it matched come back as annotations on the answer text rather than as a result part. MentorAgent reads both, which is why file search now shows activity and citations like the others. It attributes an unnamed hosted call to file search only when
FileSearchis actually enabled: otherwise it logs the call and shows nothing, because a wrong label is worse than none. - Hosted MCP is the only hosted tool that can pause for approval. The request arrives as a normal
ToolApprovalRequestContentand reaches the same confirmation banner as a local tool, showing the tool name and the remote server (Microsoft Docs Search · microsoft_learn) plus the arguments about to be sent.
Keeping them from firing when they are not needed
Hosted tools are the expensive ones — a web search is billed per call, and a file-search turn injects
thousands of extra input tokens — yet they used to be declared on every message, including
"ciao". They were also the one group the semantic tool filter never touched: they are not
AIFunctions and carry no description to embed, so while 40 cheap application tools were being
trimmed each turn, the four costly ones went through untouched.
They are now scored like everything else, from an internal catalogue of descriptions:
options.EnableToolFiltering = true; // the existing filter
options.EmbeddingGenerator = embeddings; // required — this is semantic, never keywords
options.FilterHostedTools = true; // default
// Optional. Default 0.15 — keep it LOW: this is a coarse pre-cut, not the decision
options.HostedToolFilterMinScore = 0.15f;
// Optional: a ceiling that does not depend on a score being right
options.MaxHostedToolCallsPerSession = 10;
But relevance to a tool is not relevance to your application. "Draw me a dog" passes the filter above with a high score — it genuinely is an image request — and is complete nonsense for a shop assistant, which pays for the picture anyway. That is a different question, so it gets a different gate:
options.HostedToolDomainCheck = true; // default false
// options.HostedToolDomainScope = "..."; // null = derived from AppName + AppDescription + page names
Before a hosted tool runs, a small model is asked whether the message is something this assistant should handle at all. It runs once per turn, and only when a hosted tool has already passed relevance filtering — so it costs nothing on ordinary conversation, and a couple of hundred tokens exactly on the turns where a per-call web search or a generated image was about to be paid for.
Why not embeddings here. This was first built as a cosine threshold against the application's own metadata, and it did not work. Raw cosine has no stable zero point: measured on a real turn, "genera immagine di un cane" scored 0.398 against a shop's page names — high not because it was related but because both were short phrases in the same language — and the same request crossed the tool threshold on a one-word rewording. The question is comparative (more like my domain than like everything else?) and a single absolute threshold cannot express it. Embeddings stay where they do discriminate: per-tool relevance, where the same turn scored
web_search0.04 andimage_generation0.35.
HostedToolDomainClassifier replaces the model call with your own predicate — an existing intent
service, per-user policy, or simply to make the check free.
Three layers, three different jobs. Domain gating answers should this application be spending anything on this message at all. Relevance scoring answers which tool, if any; it is probabilistic, so it reduces how often a paid tool fires without promising a maximum. The session cap provides the maximum, and it is what survives a message the other two get wrong.
Tuning is done from data, not guesswork — each turn logs every hosted tool's score at Debug:
[MentorAgent] Hosted tool relevance: hosted:web_search scored 0.040 (min 0.15) — skipped.
[MentorAgent] Hosted tool relevance: hosted:code_interpreter scored 0.080 (min 0.15) — skipped.
[MentorAgent] Hosted tool relevance: hosted:image_generation scored 0.352 (min 0.15) — declared.
[MentorAgent] Hosted tool relevance: hosted:mcp:microsoft_learn scored 0.038 (min 0.15) — skipped.
[MentorAgent] Hosted-tool domain check → OUT of scope (classifier said 'OUT').
[MentorAgent] Hosted tools withheld: the request is outside this application's scope.
hosted:image_generation would have run.
[MentorAgent] Tool filtering: 7/51 tools sent (7 core + 0/40 matched + 0/4 hosted, minScore=0.35).
Taking a tool away is only half the job
Withhold a capability and the model still believes it has one: the system prompt is built once, the tool list is decided per turn. Asked "genera immagine di un cane", an assistant whose image tool had just been withheld answered anyway — with an invented URL introduced as "the image I created for you". A lie to the user, and, whenever such a URL happens to resolve, someone else's licensed content presented as generated output.
So whenever a hosted tool is taken away — by the domain gate or by the session cap — MentorAgent tells the model so, in that same request:
## Capability status for THIS request — MANDATORY, overrides the capability list above
The built-in capabilities … are NOT available on this turn: this request is outside the scope of
this application. … If the user asked for one, reply with ONE short sentence saying plainly that
you cannot do it … NEVER substitute a result you did not receive from a tool in this conversation:
no invented or remembered URL, no Markdown image, no stock photo, no made-up search result …
Same request, after the fix:
Al momento non posso generare immagini, ma posso aiutarti con qualsiasi attività legata alla gestione di prodotti, ordini, clienti o reportistica.
Two details that are not incidental:
- It travels in
ChatOptions.Instructions(per-request), not as an extra system message. UnderUseServiceManagedHistorya message is stored in the conversation, so "image generation is unavailable" would silently follow the user into every later turn. - It is appended to the agent's instructions, never substituted for them — overwriting would drop the persona, the security block and the page list for that turn.
Nothing is sent on turns where nothing was withheld, so ordinary conversation pays nothing and the cached prompt prefix stays intact. The always-on floor lives in the system prompt itself: the hosted-tools block states that the list describes what is configured, not what is available now, and that refusing always beats fabricating.
Fail-open by design. All three controls —
FilterHostedTools,HostedToolDomainCheckandMaxHostedToolCallsPerSession— run inside the semantic tool filter, which is only installed withEnableToolFilteringand anEmbeddingGenerator. Without those, nothing is filtered and hosted tools keep firing on every turn — with a startup warning that names the options it is ignoring, because a control that is switched on but never runs is worse than one that is off. An assistant losing a capability because a model was not configured is worse than one that costs more than it should, and there is no keyword fallback: this project treats guessing relevance from substrings as not implementing the feature at all.
Duplicate tool names are reported at startup. Two tools sharing a name are invisible everywhere else and quietly expensive: both are declared on any turn the name matches, both consume the
ToolFilterMaxToolsbudget, and they share one relevance-score cache entry — so the second tool's description decides the score for the first. MentorAgent logsN duplicate tool name(s) among the filterable tools: …and names them.
Provider support — read this before enabling
Hosted tools are a provider capability, not a MentorAgent one. Availability differs per client:
| Client | Function tools | Web search | Code interpreter | File search | Image gen | Hosted MCP |
|---|---|---|---|---|---|---|
| Azure OpenAI / OpenAI — Responses | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
| Azure OpenAI / OpenAI — Chat Completions | ✅ | ✅¹ | ❌ | ❌ | ❌ | ❌ |
Foundry (AIProjectClient) |
✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
¹ Depends on the deployment; an unsupported one answers 400 unknown_parameter: web_search_options.
Availability also depends on the individual deployment, not just the client type — a model without image generation enabled rejects the call even on the Responses client.
Enabling a tool the client does not support makes the provider reject the call, so MentorAgent logs the enabled set at startup to make the configuration obvious when that happens.
Switch to the Responses client to get the full set:
var azure = new AzureOpenAIClient(endpoint, credential);
options.ChatClient = azure.GetResponsesClient().AsIChatClient(deploymentName);
// ⚠️ Required with Responses: the service owns the conversation and returns a conversation id.
// The Agent Framework refuses to combine that with a local ChatHistoryProvider, so every turn
// would fail with "Only ConversationId or ChatHistoryProvider may be used, but not both".
options.UseServiceManagedHistory = true;
With UseServiceManagedHistory on, MaxSessionMessages and EnableCompaction no longer apply (there is no local history to trim — a warning is logged if compaction is on). Everything else — tools, memory, RAG, guardrails, metrics — is unaffected.
With model routing: the strong model receives the same tool list, so build
StrongChatClienton a client that supports hosted tools too. Otherwise turns work until one gets escalated, and then fail — MentorAgent warns at startup when both are configured.
Limits by design. Hosted tools never reach MentorAgent's function-calling middleware — there is no local invocation to intercept — so RequiredRoles, RequiresConfirmation, OnToolResult and the per-tool metrics do not apply to them. What you can control is what the provider is allowed to do: which tools you declare at all, which vector stores file search may read, which AllowedTools a hosted MCP server may expose, and RequireApproval on that server (the one hosted tool that can pause for a human, because the provider itself supports it — the approval then arrives as a normal ToolApprovalRequestContent and surfaces through the usual banner under HitlMode.Native).
Tell the model they exist. MentorAgent adds the enabled hosted tools to the coordinator's system prompt automatically. Without that, a coordinator holding a long list of application actions — and instructed never to invent capabilities — answers from memory instead of searching or running code. The block costs a handful of tokens and only appears when hosted tools are on.
Human-in-the-loop tool approval
Any action marked [MentorAction(RequiresConfirmation = true)] pauses and shows the confirmation banner before it runs (see Confirmation dialogs). Two more ways to gate a tool — useful for tools you don't own and can't annotate:
builder.Services.AddMentorAgent(options =>
{
// 1. Every tool from an MCP server
options.McpServers = [
new MentorMcpServer {
Name = "filesystem", Command = "npx",
Arguments = ["-y", "@modelcontextprotocol/server-filesystem", "/data"],
RequiresConfirmation = true, // gate the whole server
}
];
// 2. By tool name — applies to L1 actions, MCP tools and skills alike
options.RequiresApproval = tool =>
tool.StartsWith("delete_") || tool is "send_email" or "write_file";
});
Following the AF Agent Safety guidance, gate anything with side effects, that touches sensitive data, or that is irreversible.
Two modes: Blocking (default) and Native
options.HitlMode = MentorHitlMode.Native; // default: MentorHitlMode.Blocking
Blocking (default) |
Native |
|
|---|---|---|
| Mechanism | MentorAgent's middleware parks the AI thread on a TaskCompletionSource |
AF standard: ApprovalRequiredAIFunction → ToolApprovalRequestContent → ToolApprovalResponseContent |
| Model round-trips | 1 for the whole turn | 1 extra per approved call |
| Streaming | Stays alive across the banner | Reply arrives in two segments (before/after the banner) |
| Why choose it | Fewer moving parts, cheaper | Interop — AF workflows, DevUI and AF-native hosts see the standard protocol |
The banner and the client protocol are identical in both modes — same ConfirmationRequired event, same RespondToApproval / POST /mentor/approve reply — so you can flip the mode without touching a single client. On rejection, Native sends the localized denial back to the model as the AF reason, so the assistant explains the refusal instead of inventing one.
Level 2 and Level 3 agents are gated too
Specialists ([MentorAgent]) and team members ([TeamMember]) run their own function-calling loop inside the handoff / group-chat workflow, where the coordinator's middleware cannot reach. Their tools are therefore wrapped in a GatedAIFunction, so the gate travels with the tool instead of with the agent:
// On a Level-2 specialist class — both are enforced, including when the
// coordinator reaches the method through route_to_specialist:
[MentorAction(Description = "Creates an order",
RequiresConfirmation = true,
RequiredRoles = ["OrderManager", "Admin"],
NavigateTo = "/orders")]
public string CreateOrder(int customerId, string items) { … }
Role checks, the confirmation banner, action feedback, NavigateTo, OnToolResult, OnException and the per-tool metrics all apply at every level, through one shared implementation — so a tool behaves identically whether the coordinator calls it directly or a specialist does.
Nested tools always use the blocking confirmation flow, even when
HitlMode = Native: an Agent FrameworkToolApprovalRequestContentraised inside a workflow never surfaces to the orchestrator, so it could not reach the user. The banner and the client protocol are identical either way, and the user is asked exactly once.
Evaluation & regression testing
MentorEvaluator is a thin, convenient wrapper over the Agent Framework's native evaluation framework: it runs each case through a ChatClientAgent via agent.EvaluateAsync(...), scores it with a native LocalEvaluator (deterministic checks) + the framework's token metering, and fails when a run exceeds a token budget or drops below a quality bar — so you can gate CI on prompt/token regressions. Inject MentorEvaluator:
var report = await evaluator.RunAsync(
[
new EvalCase("Hello", "A short friendly greeting.", MaxTokens: 300),
new EvalCase("List 3 CRM benefits.", "Lists 3 clear benefits.", MaxTokens: 600),
],
new MentorEvalOptions
{
SystemInstructions = mySystemPrompt, // the prompt under test
Judge = true, MinQuality = 0.6, // built-in lightweight LLM-judge
MaxTotalTokens = 4000, // token-regression gate
});
report.ThrowIfFailed(); // fail the CI test on regression
The built-in path uses only already-referenced packages. For production-grade quality & safety, plug the framework's native evaluators — no reimplementation:
new MentorEvalOptions
{
// Native deterministic checks applied to every case (Microsoft.Agents.AI).
Checks = [ EvalChecks.KeywordCheck("benefit"), EvalChecks.ToolCalledCheck("get_orders") ],
// Native LLM-judged evaluators — their pass/fail also gates the report:
// • FoundryEvals → Azure AI Foundry (relevance, coherence, groundedness, violence/self-harm/…)
// • MEAI quality/safety evaluators (Microsoft.Extensions.AI.Evaluation.Quality/Safety)
Evaluators = [ new FoundryEvals(projectClient, model, FoundryEvals.Relevance, FoundryEvals.Violence) ],
}
For full-pipeline token checks, drive the mentor and assert on IMentorMetrics.GetSnapshot().
All configuration options
Core
| Property | Type | Default | Description |
|---|---|---|---|
AppName |
string |
(required) | Application name injected into the system prompt |
AppDescription |
string |
"" |
Domain description for richer AI context |
Language |
MentorLanguage |
English |
Language for AI responses and widget UI (see supported values below) |
MentorshipLevel |
MentorshipLevel |
Standard |
Proactivity level of the AI |
ChatClient |
IChatClient? |
null |
AI provider via IChatClient (Azure OpenAI, OpenAI, Ollama…) |
Agent |
AIAgent? |
null |
Pre-built AIAgent (Foundry, Anthropic…) |
EmbeddingGenerator |
IEmbeddingGenerator<string, Embedding<float>>? |
null |
Optional embedding model — enables semantic tool filtering |
ScanAssemblies |
Assembly[] |
(required) | Assemblies to scan for agents, actions, and pages |
Supported MentorLanguage values:
| Value | Language |
|---|---|
MentorLanguage.English |
English |
MentorLanguage.Italian |
Italian |
MentorLanguage.French |
French |
MentorLanguage.German |
German |
MentorLanguage.Spanish |
Spanish |
MentorLanguage.Portuguese |
Portuguese |
MentorLanguage.Dutch |
Dutch |
MentorLanguage.Polish |
Polish |
MentorLanguage.Japanese |
Japanese |
MentorLanguage.Chinese |
Chinese |
Widget
| Property | Type | Default | Description |
|---|---|---|---|
Theme |
MentorTheme |
Default |
Widget visual theme |
Position |
ChatPosition |
BottomRight |
Widget position |
PrimaryColor |
string? |
null |
Custom hex color |
BotName |
string |
"Mentor AI" |
Bot name in widget header |
AvatarUrl |
string? |
null |
Bot avatar image URL |
WelcomeMessage |
string? |
null |
Initial welcome message |
InputPlaceholder |
string |
"Type a message..." |
Input box placeholder |
EnableSuggestions |
bool |
true |
Proactive suggestion chips |
EnableActionFeedback |
bool |
true |
Visual feedback during tool calls |
EnableVoiceInput |
bool |
false |
Microphone via browser Speech Recognition API |
EnableVoiceOutput |
bool |
false |
Text-to-speech via browser Speech Synthesis API |
EnableImageInput |
bool |
false |
Multimodal image input — 📎 upload, paste, drag & drop and 🔗 URL in the composer. Requires a vision-capable chat model |
MaxImageBytes |
int |
4194304 |
Max size per attached image (4 MB). Enforced server-side |
MaxImagesPerMessage |
int |
4 |
Max images per user turn. Enforced server-side |
AllowedImageTypes |
IReadOnlyList<string> |
png, jpeg, gif, webp |
MIME allow-list for attachments (AF Agent Safety — never a deny-list) |
ShowHostedToolsStatus |
bool |
false |
Amber badge in the widget header listing the active hosted tools. Development aid |
Hosted tools
| Property | Type | Default | Description |
|---|---|---|---|
HostedTools |
MentorHostedTools |
None |
Provider-hosted tools to declare: WebSearch, CodeInterpreter, FileSearch, ImageGeneration, HostedMcp (flags). Availability depends on the client and the deployment — see Hosted tools |
FileSearchVectorStoreIds |
IReadOnlyList<string>? |
null |
Vector stores searched by FileSearch. Required when it is enabled — without ids the tool is skipped and a warning is logged (fail-closed) |
FileSearchMaxResults |
int? |
null |
Upper bound on file-search matches. null lets the provider decide |
HostedImageModel |
string? |
null |
Model used by ImageGeneration. On Azure OpenAI this is the deployment name of an image model (e.g. gpt-image-1-mini), which is arbitrary and cannot be guessed — leave it empty only if your deployment is named exactly like the model, or the call fails mid-turn with a deployment-not-found error |
HostedImageSize |
string? |
null |
Generated image size as WIDTHxHEIGHT (e.g. "1024x1024"). The cost knob for image generation — a larger image is billed more. An unparsable value is ignored with a warning |
HostedMcpServers |
IReadOnlyList<MentorHostedMcpServer>? |
null |
Remote MCP servers the provider connects to, used by HostedMcp. Required when it is enabled (fail-closed). Per server: Name, Url, Description, AllowedTools, RequireApproval (default true), AlwaysRequireApprovalTools / NeverRequireApprovalTools, Headers |
ShowHostedToolActivity |
bool |
true |
Report hosted-tool work while it happens: "Searching the web…", the queries, the pages cited (needs ShowRagSources), the images generated. Without it a multi-second provider call looks like a frozen widget |
FilterHostedTools |
bool |
true |
Let the semantic tool filter decide per turn whether each hosted tool is relevant, instead of declaring them on every message. Needs EnableToolFiltering + EmbeddingGenerator; without them nothing is filtered and a warning is logged (fail-open) |
HostedToolFilterMinScore |
float? |
null → 0.15 |
Similarity threshold for hosted tools only — deliberately lower than ToolFilterMinScore, not equal to it: their scores run on a different scale (long English descriptions vs a short message in the user's language), and this is a coarse pre-cut now that HostedToolDomainCheck makes the judgement. Raising it to "be safe" makes the verdict flip between rewordings of the same request. Scores are logged at Debug |
HostedToolDomainCheck |
bool |
false |
Ask a small model whether the message concerns this application before a hosted tool runs. A different question from FilterHostedTools: "draw me a dog" is a real image request and nonsense for a shop, and only this catches it. Runs once per turn and only when a hosted tool already passed relevance — free on ordinary conversation. Fails open |
HostedToolDomainScope |
string? |
null |
The scope the classifier judges against. null derives it from AppName + AppDescription + page names. Write it yourself when the derived text is too narrow (shipping, VAT — legitimate and covered by no tool) or too vague, since a vague scope makes the classifier permissive |
HostedToolDomainClassifier |
Func<string, CancellationToken, Task<bool>>? |
null |
Replaces the model call with your own decision (true = in scope). Reuse an existing intent classifier, apply per-user policy, or make the check free |
MaxHostedToolCallsPerSession |
int |
0 |
Hard cap on hosted-tool calls per session; beyond it they stop being declared. 0 = no cap. Complements the filter rather than replacing it: scoring lowers the frequency, only a counter bounds the worst case |
Session & History
| Property | Type | Default | Description |
|---|---|---|---|
MaxSessionMessages |
int |
50 |
Max messages in session history |
ChatHistoryProvider |
ChatHistoryProvider? |
null |
Persistent conversation history provider |
UseServiceManagedHistory |
bool |
false |
Set when the chat client keeps the conversation on the service (OpenAI/Azure Responses, Foundry, Copilot Studio). MentorAgent then installs no history provider — required, since AF forbids combining a conversation id with a ChatHistoryProvider. Disables MaxSessionMessages and EnableCompaction |
Memory
| Property | Type | Default | Description |
|---|---|---|---|
UseMemoryContext |
bool |
false |
Contextual memory across sessions |
MemoryContextCount |
int |
10 |
Number of recent memories in the prompt |
MemoryRelevanceFiltering |
bool |
false |
Inject only the memories semantically relevant to the current message (embedding cosine; identity/preference facts always kept) instead of the last N — saves tokens. Requires EmbeddingGenerator; without it, falls back to last-N |
MemoryAutoCapture |
bool |
true |
The reliable memory writer: a dedicated post-turn extraction saves durable user facts instead of relying on the model to call remember (weak models do this inconsistently). On Path A (a ChatClient is set) it becomes the only writer — the redundant remember tool + its prompt are dropped (saves tokens); on Path B it falls back to the remember tool. forget is always kept. Adds one small model call per user message; set false to opt out |
Agent Skills
| Property | Type | Default | Description |
|---|---|---|---|
EnableSkills |
bool |
false |
Enables skill discovery and the load_skill / read_skill_resource tools |
SkillsFolder |
string |
"Skills" |
Folder to scan for file-based skills (SKILL.md). Relative to content root or absolute |
RAG
| Property | Type | Default | Description |
|---|---|---|---|
UseRag |
bool |
false |
Enable RAG. Requires a registered IMentorRagSource |
RagResultCount |
int |
5 |
Number of documents retrieved per query |
RagMinScore |
float |
2 |
Minimum relevance score to include a document. 0 = no filtering. Keyword search: integer-like (2 = two matches); vector/cosine: 0.5–0.75 |
RagSystemPromptTemplate |
string |
"Use the following documents to answer:\n{documents}" |
Prompt template. Use {documents} as placeholder |
ShowRagSources |
bool |
false |
Show citation chips below AI messages in the widget |
MCP
| Property | Type | Default | Description |
|---|---|---|---|
McpServers |
MentorMcpServer[]? |
null |
External MCP servers to connect as L1 tools |
McpServerEnabled |
bool |
false |
Expose MentorAgent as an MCP server. Also call app.MapMentorAgentMcp() |
McpServerPath |
string |
"/mcp" |
Default path used as a reference — pass to MapMentorAgentMcp(path) |
ShowMcpStatus |
bool |
false |
Show MCP server connection status badge in the widget header |
A2A
| Property | Type | Default | Description |
|---|---|---|---|
RemoteAgents |
MentorRemoteAgent[]? |
null |
Remote A2A agents to add to the Handoff workflow. Base URL only — SDK auto-appends /.well-known/agent-card.json |
A2AServerEnabled |
bool |
false |
Expose MentorAgent as an A2A agent. Also call app.MapMentorAgentA2A() |
A2AServerPath |
string |
"/a2a" |
Default path used as a reference — pass to MapMentorAgentA2A(path) |
A2AServerUrl |
string? |
null |
Full public URL of this agent's A2A endpoint (e.g. http://localhost:5001/a2a). Written into the Agent Card SupportedInterfaces so remote consumers can resolve the absolute endpoint. Required when this instance is used as a remote agent by other MentorAgent instances |
AgentCard |
AgentCardInfo? |
null |
Metadata for the A2A Agent Card (/.well-known/agent-card.json) |
ShowA2AStatus |
bool |
false |
Show a badge in the widget header listing configured remote A2A agents. Click the badge to see agent names and URLs |
Token & cost optimization
| Property | Type | Default | Description |
|---|---|---|---|
EnableToolFiltering |
bool |
false |
Send only the tools semantically relevant to the message. Requires EmbeddingGenerator; without it, all tools are sent |
ToolFilterMaxTools |
int |
12 |
Max matched business tools to send (core tools always kept on top) |
ToolFilterMinScore |
float |
0.35 |
Minimum cosine similarity (0–1) for a tool to count as relevant. Higher = stricter |
EnableCompaction |
bool |
false |
Compact long conversation history before each call. In-memory history only (Path A) |
CompactionTokenThreshold |
int |
4000 |
Token budget that triggers compaction and the truncation backstop |
CompactionMaxTurns |
int |
8 |
Recent user turns kept intact by the sliding-window step |
EmbeddingGenerator(in the Core table) powers semantic tool filtering and semantic memory (MemoryRelevanceFiltering, in Memory). RAG relevance is handled by the vector search +RagMinScore(in RAG).
Middleware, observability & dashboard
| Property | Type | Default | Description |
|---|---|---|---|
InputGuardrail |
Func<string,CancellationToken,Task<bool>>? |
null |
Custom input guardrail (true = safe). Replaces the built-in check; runs whenever set |
OutputGuardrail |
Func<string,CancellationToken,Task<bool>>? |
null |
Moderate the completed reply (true = safe). When set, the reply is buffered and revealed after moderation (no live streaming that turn) |
OnToolResult |
Func<string,object?,object?>? |
null |
Transform/redact a tool result before it returns to the model |
OnException |
Func<Exception,string?>? |
null |
Map an exception to a user-facing message (null → built-in mapping) |
ConfigureChatClientPipeline |
Func<ChatClientBuilder,ChatClientBuilder>? |
null |
Insert custom middleware into the Path A pipeline (outermost) |
EnableObservability |
bool |
false |
Emit OpenTelemetry traces/metrics (GenAI conventions) + MentorAgent spans/counters |
ObservabilityIncludeSensitiveData |
bool |
false |
Include prompt/response content in telemetry — Development only |
ObservabilitySourceName |
string |
"MentorAgent" |
ActivitySource/Meter name; add via .AddSource(name).AddMeter(name) |
ModelPricing |
IReadOnlyDictionary<string,ModelPrice>? |
null |
Per-model token prices for the dashboard cost estimate (none built in) |
DashboardRole |
string |
"Admin" |
Role required for the admin metrics endpoint ("" = open, dev only) |
MetricsPersistenceInterval |
TimeSpan |
30s |
Flush cadence for a registered IMentorMetricsStore (durable dashboard); also flushes on shutdown |
MetricsRetention |
TimeSpan |
7d |
How far back the dashboard keeps per-model hourly time-series buckets (temporal charts) |
Model routing & AI utilities
| Property / service | Type | Default | Description |
|---|---|---|---|
StrongChatClient |
IChatClient? |
null |
Strong model to escalate to (ChatClient is the cheap default). Routing active only when set |
RoutingStrategy |
MentorRoutingStrategy |
Custom |
Semantic / Classifier / Cascade / Custom — how the cheap↔strong decision is made |
UseStrongModelAsync |
Func<IReadOnlyList<ChatMessage>,CancellationToken,Task<bool>>? |
null |
Custom: async, context-aware router (takes precedence over UseStrongModel) |
UseStrongModel |
Func<string,bool>? |
null |
Custom: legacy sync predicate on the latest user message |
RoutingComplexExemplars |
IReadOnlyList<string>? |
null |
Semantic: example "complex" turns (null → built-in multilingual set) |
RoutingThreshold |
float |
0.35 |
Semantic: cosine floor to escalate (higher = stricter) |
RoutingClassifierClient |
IChatClient? |
null |
Classifier/Cascade: dedicated judge client (defaults to the cheap ChatClient) |
IMentorStructured |
service | — | GenerateAsync<T>(...) — typed/structured generation, see Structured outputs |
MentorEvaluator |
service | — | RunAsync(cases, options) — token/quality regression harness, see Evaluation & regression testing |
Security & Limits
| Property | Type | Default | Description |
|---|---|---|---|
EnableSafetyCheck |
bool |
false |
AI-based prompt injection detection |
MaxMessageLength |
int |
4000 |
Max message length in characters (0 = unlimited) |
RateLimitPerUser |
int |
0 |
Max messages per user per window (0 = disabled). ⚠️ Without authentication all sessions share "anonymous" — limit is global, not per-user |
RateLimitWindowSecs |
int |
60 |
Rate limiting window in seconds |
RequireConfirmation |
bool |
true |
Global on/off for confirmation dialogs |
HitlMode |
MentorHitlMode |
Blocking |
How approval is implemented: Blocking (MentorAgent's own flow, one round-trip) or Native (AF ApprovalRequiredAIFunction, for interop). Same banner and same client protocol either way — see Human-in-the-loop tool approval |
RequiresApproval |
Func<string, bool>? |
null |
Forces approval for a tool by name, on top of [MentorAction(RequiresConfirmation)]. The way to gate tools you don't own (MCP, skills) |
IncludeWorkflowExceptionDetails |
bool |
false |
Include exception stack traces in agent responses. Never enable in production |
Extensibility
| Extension point | How |
|---|---|
| Custom memory store | Implement IMentorMemoryStore, register before AddMentorAgent() |
| Custom history provider | Set options.ChatHistoryProvider |
| Custom AI provider | Set options.ChatClient or options.Agent |
| Custom agent instructions | Set Instructions on [MentorAgent] or [TeamMember] |
| Custom RAG source | Implement IMentorRagSource, register before AddMentorAgent() |
| Custom MCP tools | Add entries to options.McpServers (HTTP or stdio transport) |
| Custom remote agents | Add entries to options.RemoteAgents (A2A protocol) |
| File-based skills | Create Skills/{name}/SKILL.md in the project root with EnableSkills = true |
| Dual-role skill class | Decorate with [MentorSkill] + add [Description]/[MentorAction] methods — knowledge + tools in one class. Register in DI when the class has methods |
| Knowledge-only skill | Decorate with [MentorSkill] — no methods, no DI registration needed |
| Page UI actions (typed) | Use RegisterUIAction<TParam> or RegisterUIActionAsync<TParam> for automatic JSON deserialization |
Requirements
| Requirement | Version |
|---|---|
| .NET | 10.0 |
| Blazor | Web App (Server or Auto render mode), or Hybrid (MAUI) |
| Microsoft.Agents.AI | 1.6.1 |
| Microsoft.Agents.AI.Workflows | 1.6.1 |
| Microsoft.Agents.AI.Hosting.A2A.AspNetCore | 1.6.1-preview |
| Microsoft.Extensions.AI | 10.6.0 |
| ModelContextProtocol.AspNetCore | 1.3.0 |
✅ Blazor WebAssembly is supported via
MentorAgent.Server+MentorAgent.Blazor. See Blazor Server vs Blazor WASM. ✅ Blazor Web App with Auto render mode is supported — either with@rendermode="InteractiveServer"on<ChatWidget />(simplest) or via the full WASM setup withMentorAgent.Server+MentorAgent.Blazor.
Related Packages
| Package | Purpose |
|---|---|
| MentorAgent ← you are here | Blazor Server — the full AI assistant in one package |
| MentorAgent.Server | Any ASP.NET Core app — headless AI backend (SignalR + SSE + MCP + A2A) |
| MentorAgent.Blazor | Blazor WASM / Auto client — the same widget over SignalR |
| MentorAgent.Abstractions | Shared contracts, models and UI components (transitive — never installed directly) |
License
MIT — see LICENSE for details.
| Product | Versions Compatible and additional computed target framework versions. |
|---|---|
| .NET | net10.0 is compatible. net10.0-android was computed. net10.0-browser was computed. net10.0-ios was computed. net10.0-maccatalyst was computed. net10.0-macos was computed. net10.0-tvos was computed. net10.0-windows was computed. |
-
net10.0
- Azure.AI.OpenAI (>= 2.9.0-beta.1)
- Azure.AI.Projects (>= 2.1.0-beta.2)
- Azure.Identity (>= 1.21.0)
- MentorAgent.Abstractions (>= 1.0.0-preview.4)
- Microsoft.Agents.AI (>= 1.6.1)
- Microsoft.Agents.AI.Foundry (>= 1.6.1-preview.260514.1)
- Microsoft.Agents.AI.Hosting.A2A.AspNetCore (>= 1.6.1-preview.260514.1)
- Microsoft.Agents.AI.Workflows (>= 1.6.1)
- Microsoft.AspNetCore.Components.Authorization (>= 10.0.3)
- Microsoft.AspNetCore.Components.Web (>= 10.0.3)
- Microsoft.Extensions.AI (>= 10.6.0)
- Microsoft.Extensions.AI.OpenAI (>= 10.6.0)
- ModelContextProtocol.AspNetCore (>= 1.3.0)
NuGet packages (1)
Showing the top 1 NuGet packages that depend on MentorAgent:
| Package | Downloads |
|---|---|
|
MentorAgent.Server
Expose MentorAgent as a universal AI backend from any ASP.NET Core application. Adds a SignalR hub (/mentor-hub), SSE streaming endpoint (/mentor/chat), MCP server, and A2A agent — no Blazor required. |
GitHub repositories
This package is not used by any popular GitHub repositories.
| Version | Downloads | Last Updated |
|---|---|---|
| 1.0.0-preview.4 | 52 | 8/4/2026 |
| 1.0.0-preview.3 | 60 | 7/24/2026 |
| 1.0.0-preview.2 | 72 | 6/22/2026 |
| 1.0.0-preview | 81 | 6/22/2026 |
1.0.0-preview.4
=== Provider-hosted tools ===================================================
- NEW — options.HostedTools lets the model provider run web search, a sandboxed code interpreter, file search, image generation and remote MCP calls on its own infrastructure: nothing to implement and nothing executed on your servers. Enable the ones you want: MentorHostedTools.WebSearch | CodeInterpreter | FileSearch | ImageGeneration | HostedMcp. Fail-closed — file search is skipped with a warning unless FileSearchVectorStoreIds is set, hosted MCP unless HostedMcpServers is. Availability is a PROVIDER capability, not a MentorAgent one: the Azure OpenAI / OpenAI Responses client and Foundry support the full set, the Chat Completions client supports web search only. The enabled set is logged at startup so a rejected call is easy to diagnose. Because these calls happen on the provider, they never pass through the function-calling middleware: RequiredRoles, RequiresConfirmation and per-tool metrics do not apply to them. The exception is hosted MCP, where the provider itself can pause for approval via MentorHostedMcpServer.RequireApproval and the request arrives as a normal ToolApprovalRequestContent.
- NEW — options.ShowHostedToolActivity (default true) reports provider-side work through the channels the widget already has: "Searching the web…" / "Running code…" / "Generating the image…" in the action-feedback line, pages cited by web search as citation chips (the panel RAG already uses), generated images attached inline to the message, and one Debug log line per call — the only trace these calls leave, since they never reach the middleware or the metrics.
- NEW — options.HostedImageModel and options.HostedImageSize configure image generation. Size (WIDTHxHEIGHT, e.g. "1024x1024") is what the image is billed on, so it is better chosen than inherited; an unparsable value is ignored with a warning rather than throwing at startup. ⚠️ On AZURE OpenAI the model name alone is not enough: Azure resolves the image deployment from the x-ms-oai-image-generation-deployment REQUEST HEADER and otherwise answers "imagegen deployment must be provided through header". MentorAgent is handed an already-built IChatClient and cannot add the header — attach it as a PipelinePolicy where you construct the AzureOpenAIClient (a ~15-line policy; see Infrastructure/ImageDeploymentHeaderPolicy.cs in the samples). The requirement is restated in a startup log line whenever image generation is on.
- NEW — the enabled hosted tools are listed in the coordinator's system prompt. Without this a coordinator holding a long list of application actions, and instructed never to invent capabilities, answers from memory instead of searching or running code. Costs a handful of tokens and only when hosted tools are on.
- NEW — the active hosted-tool set is published to remote clients so a capability badge no longer has to be mirrored by hand: IMentorStateService.OnHostedToolsDeclared, raised from the HostedToolsDeclared event, and ChatWidget prefers it over its local MentorWidgetOptions.HostedTools. A hand-kept copy drifts silently — the badge then claims tools the server dropped, or hides ones it gained.
=== Keeping the expensive tools from firing when they are not needed =========
- NEW — options.FilterHostedTools (default true) puts hosted tools through the semantic tool filter, declaring web search / code interpreter / file search / image generation / hosted MCP only on turns whose message is relevant to them instead of on every message. They were the ONE group the filter never touched — not being AIFunctions they carry no description to embed — so the cheap application tools were trimmed on every call while the expensive ones went through untouched (a web search is billed per call; a file-search turn measured 8249 input tokens against 2870 for a plain one). Descriptions come from an internal catalogue. options.HostedToolFilterMinScore is their own threshold, defaulting to 0.15 — deliberately LOWER than ToolFilterMinScore rather than inherited from it: hosted tools are matched through long English descriptions while your actions carry short descriptions in the user's language, so the two populations score on different scales and one threshold mis-fires on the other. Measured on real turns, an image request scored web_search 0.04-0.07, code_interpreter 0.05-0.08 and image_generation 0.29-0.35: at 0.35 the verdict flipped between two rewordings of the same request, at 0.15 it is stable and the irrelevant tools are still dropped. Every score is logged at Debug so the threshold can be tuned from real traffic rather than guessed.
- NEW — options.HostedToolDomainCheck gates hosted tools on whether the message concerns THIS APPLICATION at all, which is a different question from whether it needs a given tool: "draw me a dog" scores high against the image tool because it genuinely is an image request, and is nonsense for an e-commerce assistant that pays for the picture anyway. A small model is asked once per turn, and ONLY when a hosted tool has already passed relevance filtering — so ordinary conversation costs nothing and a couple of hundred tokens are spent exactly where a per-call web search or a generated image was about to be. Scope text is derived from AppName + AppDescription + page names, or set options.HostedToolDomainScope; options.HostedToolDomainClassifier replaces the model call with your own predicate (an existing intent service, a per-user policy, or simply to make the check free). Fails open on error or an unexpected answer. NOTE this is deliberately NOT embedding similarity: raw cosine has no stable zero point — measured on a real turn, "genera immagine di un cane" scored 0.398 against a shop's page names, high because both were short phrases in the same language rather than because they were related, and the same request moved from 0.290 to 0.352 across a 0.35 threshold on a one-word rewording. Embeddings are kept where they do discriminate, which is per-tool relevance on that same turn.
- NEW — options.MaxHostedToolCallsPerSession caps hosted-tool calls per session (0 = unlimited); once spent they stop being declared. Complementary to the filter, not an alternative: relevance scoring is probabilistic and lowers how often an expensive tool fires on a message that did not need it, while only a counter puts a ceiling on a session. FAIL-OPEN by design: with tool filtering disabled or no EmbeddingGenerator configured, nothing is filtered and hosted tools keep firing — with an explicit startup warning naming the options being ignored. Losing a capability because a model was not configured is worse than costing more than expected, and there is deliberately no keyword fallback.
- Fix (SEVERE — two cost controls were silently inert) — hosted tools were set aside for gating only when FilterHostedTools was on, so turning relevance filtering off also disabled HostedToolDomainCheck and MaxHostedToolCallsPerSession while both still reported as enabled. The partition now triggers on any of the three. The budget is also evaluated after relevance scoring rather than before it, so it withholds (and reports) only when the user actually asked for something a hosted tool would have served — and an exhausted budget no longer pays for a domain-classifier call.
- Fix — a hosted MCP server was keyed as "hosted:mcp:mcp". HostedMcpServerTool.Name is the tool KIND, identical for every server; the configured label is ServerName. Two remote servers would have collapsed onto one key and shared a single embedding, and the embedded text lost the only word saying what the server was about. Now keyed and described by ServerName.
=== Withholding a tool without telling the model makes it fabricate =========
- Fix — when a hosted tool was withheld for a turn the model did not know, and filled the gap by inventing: an image request came back as a fabricated stock-photo URL introduced as "the image I created" — a lie to the user and someone else's licensed content. The system prompt is built once while the tool list is decided per turn, so a block announcing "you can generate images" could contradict the tools actually supplied. The hosted-tools block now states that the list describes what is CONFIGURED rather than what is available now, that a capability may be used only when its tool is present in the current turn, and that a missing one must be reported plainly and NEVER faked.
- Fix (the same failure again — the prompt rule above was not enough) — with the domain check correctly withholding the image tool, the next run still answered "Vado subito a creare l'immagine!" followed by an invented link. A general anti-fabrication paragraph loses against an explicit capability list sitting hundreds of tokens earlier in the same static prompt. MentorAgent now emits a PER-REQUEST notice whenever it withholds a hosted tool — naming the reason (outside this application's scope / session limit reached) and requiring a one-sentence refusal with no substituted result — and the same request then answers "In questo momento non posso generare immagini, ma posso aiutarti con…". The notice travels in ChatOptions.Instructions rather than as an extra system message on purpose: under UseServiceManagedHistory a message is STORED in the conversation, so "image generation is unavailable" would silently follow the user into every later turn. It is APPENDED to the agent's instructions, never substituted, or the turn would lose its persona, security block and page list. Nothing is sent on turns where nothing was withheld, so ordinary conversation keeps its cached prompt prefix. Covered by regression tests.
=== Hosted-tool diagnostics =================================================
- Fix — hosted file search showed no activity and no sources. Microsoft.Extensions.AI 10.6.0 has no content type for it: its call and result arrive as the plain ToolCallContent/ToolResultContent, and the documents it matched come back as CitationAnnotations on the answer text, neither of which was read. Both are now handled, so file search reports "Searching your documents…" and its matched documents as citation chips, exactly like web search. NOTE for anyone extending the activity reporter: FunctionCallContent and FunctionResultContent DERIVE from those same base types, so the generic case excludes them explicitly — without that exclusion every local tool call would be swallowed and function calling would stop working. An unnamed hosted call is attributed to file search only when FileSearch is enabled; otherwise it is logged and nothing is shown, because a wrong label is worse than none.
- Fix — a hosted-MCP approval banner showed the raw call id ("mcpr_0805…") instead of the tool name. ToolApprovalRequestContent.ToolCall is typed as the base ToolCallContent: a local tool arrives as FunctionCallContent, a provider-side MCP call as McpServerToolCallContent, and only the first was recognised. The banner now names the tool and the remote server it will be sent to (e.g. "Microsoft Docs Search · microsoft_learn") along with the arguments, so the user can actually decide.
- Fix — a provider-side failure during a turn (a hosted tool rejected, a deployment missing) arrived as ErrorContent and was logged only as "Stream content: ErrorContent". Those errors never reach the middleware and the turn can complete with zero tokens and no exception, so that line was the only trace and it carried no cause. The message, error code and details are now logged at Error level.
- Fix (image generation was unusable) — enabling MentorHostedTools.ImageGeneration failed EVERY turn with "System.ArgumentNullException: Value cannot be null (Parameter 'value')" raised while the request was being assembled, before any network call. Microsoft.Extensions.AI 10.6.0 converts ImageGenerationOptions.MediaType into the OpenAI output-file-format with no null check, and the tool carries no media type by default; the conversion also only understands full MIME types, so "png" fails exactly like null. MentorAgent now always sends "image/png".
- NEW (diagnostics) — duplicate tool names are reported at startup ("N duplicate tool name(s) among the filterable tools: …"). Any class with [Description] methods becomes an L1 tool, so read-only copies added for team members can shadow the originals: both copies are declared on any turn the name matches, both consume ToolFilterMaxTools, and they share one relevance-score cache entry — so the description embedded second decides the score for both. Found in the samples (40 tools under 34 names, with one tool being called twice in a single turn) after the tool-filter Debug line was changed to print matched/candidates and hosted/hostedTotal.
=== Human-in-the-loop tool approval =========================================
- NEW — approval aligned with the Agent Framework. options.HitlMode picks the mechanism: Blocking (default, unchanged — the middleware parks the turn on a TaskCompletionSource: one model round-trip, streaming stays alive) or Native (the AF standard ApprovalRequiredAIFunction → ToolApprovalRequestContent → ToolApprovalResponseContent: one extra round-trip per approved call, for interop with AF workflows and AF-native hosts). The confirmation banner and the client protocol are IDENTICAL in both modes, so switching needs no client change. On rejection the localized denial travels back to the model as the AF reason.
- NEW — options.RequiresApproval: a predicate that forces approval for a tool by name, on top of the declared confirmations. This is the way to gate tools you do not own (MCP, skills, remote agents) per the AF Agent Safety guidance — side effects, sensitive data, irreversible operations.
- Fix (SECURITY) — tools invoked inside a Level-2 specialist or a Level-3 team bypassed every per-tool gate. Those agents are built as ChatClientAgent instances handed to AgentWorkflowBuilder, so their function calls happen inside the workflow where the MentorAgent middleware never reaches: [MentorAction(RequiresConfirmation = true, RequiredRoles = ["Admin"])] on a specialist method executed with NO confirmation banner and — more seriously — NO role check whenever the coordinator delegated to it. Fixed by attaching the gate to the AIFunction itself so it travels with the tool: role checks, confirmation, action feedback, NavigateTo, OnToolResult/OnException and per-tool metrics now apply at every level, through one shared implementation. Nested tools always use the blocking approval flow even under HitlMode.Native, because an AF ToolApprovalRequestContent raised inside a workflow never surfaces to the orchestrator; the banner and the client protocol are identical and the user is asked exactly once. Confirmation is now also honoured on Level-3 team-member tools, matching the role lookup which always covered them.
- Fix — MentorMcpServer.RequiresConfirmation was documented but never enforced: tools from an MCP server marked that way executed without asking. They are now gated exactly like [MentorAction(RequiresConfirmation = true)], in both HITL modes.
=== Multimodal image input =================================================
- NEW — users can send images to the assistant. Opt in with EnableImageInput; the user turn becomes a native Agent Framework multimodal ChatMessage (TextContent + DataContent for uploads / UriContent for remote URLs). Safety follows the AF Agent Safety guidance — an ALLOW-LIST of MIME types (AllowedImageTypes, default png/jpeg/gif/webp) plus MaxImageBytes (4 MB) and MaxImagesPerMessage (4), all re-validated server-side; rejected attachments are dropped with a warning and the turn still runs. Requires a vision-capable chat model (e.g. gpt-4.1 / gpt-4o / o3). The widget composer accepts images four ways — file upload, clipboard paste, drag & drop, and remote URL — with removable thumbnails before sending and inline images in the message bubble. New API: IMentorOrchestrator.SendMessageAsync(text, attachments) and the MentorAttachment / MentorUserMessage records; an image-only turn (empty text) is valid. Image bytes are streamed via IJSStreamReference, so Blazor Server does not need a raised HubOptions.MaximumReceiveMessageSize.
=== Service-managed history and per-turn decisions ==========================
- NEW — options.UseServiceManagedHistory: required companion for clients that keep the conversation on the service (the Responses API — which hosted tools need — Foundry, Copilot Studio). Those services return a conversation id and the Agent Framework refuses to combine it with a local ChatHistoryProvider, so every turn failed with "Only ConversationId or ChatHistoryProvider may be used, but not both". With this on, MentorAgent installs no history provider; MaxSessionMessages and EnableCompaction stop applying (a warning is logged if compaction is on).
- Fix (SEVERE) — ChatOptions was copied property-by-property in two middlewares, silently dropping ConversationId and with it previous_response_id on the follow-up request. With UseServiceManagedHistory this broke EVERY local tool call: the provider could not match the tool result to its call and failed the turn with "HTTP 400 — No tool call found for function call output with call_id …", which the user saw as "An error occurred while processing the response". Both now use ChatOptions.Clone(). The same hand-copy also dropped Tools, so a page registering UI actions offered the model only those actions instead of the full tool list.
- Fix — tool filtering and model routing re-evaluated their decision on every request, including the follow-up requests the function-invoking chat client makes inside one turn. With service-managed history that follow-up carries only the tool result, so filtering fell back to "core only" and dropped the tool just called, and routing could finish a turn on a different deployment than it started on — both rejected by the provider. Both now decide once per turn.
=== Widget fixes ===========================================================
- Fix — a citation with no URL (a document matched inside a provider vector store, a file produced by the code interpreter) rendered as a link to "#", which navigated to the top of the host page and looked like a broken download. The chip now omits href entirely and is styled as non-interactive.
- Fix — the hosted-tools (amber) and A2A (indigo) chips hard-coded pale dark-theme text, so on the Minimal (light) theme the tool names were nearly invisible against their own tint. They now use CSS variables overridden per theme.