MentorAgent.Server
1.0.0-preview.3
dotnet add package MentorAgent.Server --version 1.0.0-preview.3
NuGet\Install-Package MentorAgent.Server -Version 1.0.0-preview.3
<PackageReference Include="MentorAgent.Server" Version="1.0.0-preview.3" />
<PackageVersion Include="MentorAgent.Server" Version="1.0.0-preview.3" />
<PackageReference Include="MentorAgent.Server" />
paket add MentorAgent.Server --version 1.0.0-preview.3
#r "nuget: MentorAgent.Server, 1.0.0-preview.3"
#:package MentorAgent.Server@1.0.0-preview.3
#addin nuget:?package=MentorAgent.Server&version=1.0.0-preview.3&prerelease
#tool nuget:?package=MentorAgent.Server&version=1.0.0-preview.3&prerelease
MentorAgent.Server
Preview Release — MentorAgent is currently in public preview. APIs may change before the stable release.
Expose a fully-featured AI assistant backend from any ASP.NET Core application — Web API, Minimal API, Blazor Server. No Blazor required on the consumer side.
MentorAgent.Server adds a SignalR hub, SSE streaming endpoint, and bridges the MCP/A2A servers already built into MentorAgent — making your AI backend reachable from any ASP.NET Core application and any client: React, Vue, Angular, MAUI, mobile apps, or any HTTP/SignalR consumer. It is also the required server-side component when pairing with MentorAgent.Blazor for Blazor WebAssembly and Blazor Auto deployments.
Table of Contents
- Package Family
- What MentorAgent.Server exposes
- Getting started
- Connecting clients
- AI providers
- The three-level agent model
- Agent Skills
- Page context and UI actions
- Contextual memory
- RAG — Retrieval-Augmented Generation
- Token & cost optimization
- Middleware, observability & dashboard
- Model routing, structured outputs & evaluation
- MCP — Model Context Protocol
- A2A — Agent-to-Agent
- Security
- Attribute reference
- Persistent conversation history
- Session serialize and restore
- All configuration options
- Requirements
Package Family
| Package | Install when |
|---|---|
| MentorAgent | Blazor Server app |
| MentorAgent.Server ← you are here | Web API / headless backend, or Blazor Auto server-side project |
| MentorAgent.Blazor | Blazor WASM / Blazor Auto client project |
What MentorAgent.Server exposes
| Endpoint | Protocol | Clients |
|---|---|---|
/mentor-hub |
SignalR | Blazor WASM, MAUI, console .NET, any SignalR client |
/mentor/chat |
SSE streaming | React, Vue, Angular, fetch API, curl — no library needed |
/mentor/approve |
HTTP POST | All clients — HITL confirmation/rejection (see HITL note) |
/mcp |
MCP server | Claude Desktop, VS Code Copilot, Cursor, any MCP client |
/.well-known/agent-card.json + /a2a |
A2A agent | Other AI agents, orchestrators |
Getting started
Installation
dotnet add package MentorAgent.Server --prerelease
MentorAgentis included automatically as a transitive dependency — you do not need to install it separately.
Minimal setup (Web API)
// Program.cs — Web API or Minimal API
using MentorAgent.Extensions;
using MentorAgent.Server.Extensions;
builder.Services.AddMentorAgent(options =>
{
options.AppName = "My App";
options.AppDescription = "An order management application";
options.Language = MentorLanguage.English;
options.ChatClient = new AzureOpenAIClient(endpoint, credential)
.GetChatClient("gpt-4o").AsIChatClient();
options.ScanAssemblies = [typeof(Program).Assembly];
});
builder.Services.AddMentorAgentServer(); // ← SignalR hub
var app = builder.Build();
app.MapMentorAgentServer(); // exposes /mentor-hub + /mentor/chat
app.Run();
Blazor Auto — server project
// Server project Program.cs
builder.Services.AddMentorAgent(options => { ... });
builder.Services.AddMentorAgentServer();
app.MapMentorAgentServer();
app.MapMentorAgentMcp(); // optional
app.MapMentorAgentA2A(); // optional
CORS — required for cross-origin clients
⚠️ This is the #1 cause of SignalR connection failures. If your client runs on a different origin than the server — a standalone Blazor WASM app on
:5001, a React dev server on:5173, an Angular app on:4200, etc. — you must configure CORS on the server. Without it, the browser silently blocks the SignalR handshake.
SignalR with browser clients requires credentials, and the CORS spec forbids AllowAnyOrigin() together with AllowCredentials(). You must list every client origin explicitly:
builder.Services.AddCors(options =>
{
options.AddPolicy("MentorAgentClients", policy =>
{
policy
.WithOrigins(
"http://localhost:5001", // Blazor WASM (HTTP)
"https://localhost:7001", // Blazor WASM (HTTPS)
"http://localhost:5173", // React (Vite)
"http://localhost:4200") // Angular
.AllowAnyHeader()
.AllowAnyMethod()
.AllowCredentials(); // ← required for SignalR
});
});
var app = builder.Build();
app.UseCors("MentorAgentClients"); // ← must come before MapMentorAgentServer()
app.MapMentorAgentServer();
You do NOT need CORS when:
- The client is served from the same origin as the server (e.g. Blazor Auto hosted, or the WASM app served by the same ASP.NET Core host). In that case relative URLs like
HubUrl = "/mentor-hub"work with no CORS at all.
When CORS is required, the client must use the server's absolute URL:
// Client (separate origin) — full URL, not a relative path
options.HubUrl = "http://localhost:5169/mentor-hub";
Connecting clients
Option A — SSE (simplest, no library required)
Streaming-only. Best for simple chat UIs that only need text responses.
const response = await fetch('/mentor/chat?message=' + encodeURIComponent(text));
const reader = response.body.getReader();
const decoder = new TextDecoder();
while (true) {
const { done, value } = await reader.read();
if (done) break;
for (const line of decoder.decode(value).split('\n')) {
if (!line.startsWith('data:')) continue;
const event = JSON.parse(line.slice(5));
if (event.type === 'chunk') appendText(event.text);
if (event.type === 'completed') finalize();
if (event.type === 'error') showError(event.message);
}
}
Option B — SignalR (full feature set)
Supports all events: streaming, HITL confirmations, navigation, UI actions, RAG citations, team collaboration, action feedback.
npm install @microsoft/signalr
import * as signalR from '@microsoft/signalr';
const connection = new signalR.HubConnectionBuilder()
.withUrl('/mentor-hub')
.withAutomaticReconnect()
.build();
// ── Subscribe to ALL events (see complete reference below) ───────────────────
connection.on('StreamingChunk', chunk => appendText(chunk));
connection.on('StreamingCompleted', () => finalize());
connection.on('BusyChanged', busy => setSpinner(busy));
connection.on('Error', msg => showError(msg));
connection.on('ActionExecuting', action => showActionBar(action));
connection.on('ActionCompleted', action => hideActionBar(action));
connection.on('ActionFailed', error => showActionError(error));
connection.on('ConfirmationRequired', (id, tool, msg) => showConfirmDialog(id, tool, msg));
connection.on('NavigationRequested', url => router.push(url));
connection.on('RagSourcesReady', sources => showCitations(sources));
connection.on('TeamMemberSpeaking', (team, role) => showTeamActivity(team, role));
connection.on('UIActionRequested', (name, json) => executeUIAction(name, json));
connection.on('UIActionExecuting', name => onUIActionStart(name));
connection.on('UIActionCompleted', name => onUIActionEnd(name));
await connection.start();
// ── Send messages ─────────────────────────────────────────────────────────────
await connection.invoke('SendMessage', 'Mostrami gli ordini pending');
// ── Cancel current request ────────────────────────────────────────────────────
await connection.invoke('CancelRequest');
// ── Reset conversation ────────────────────────────────────────────────────────
await connection.invoke('ResetSession');
// ── Confirm / reject a HITL dialog — use HTTP, NOT a hub method ──────────────
// SignalR processes hub messages sequentially per connection. While SendMessage
// is awaiting confirmation, the dispatcher cannot process any other hub message
// from the same connection — invoking RespondToApproval via hub would deadlock.
await fetch(`/mentor/approve?actionId=${actionId}&approved=true`, { method: 'POST' }); // approve
await fetch(`/mentor/approve?actionId=${actionId}&approved=false`, { method: 'POST' }); // reject
Complete SignalR Protocol Reference
Server → Client events
| Event | Parameters | When fired | What to do |
|---|---|---|---|
StreamingChunk |
chunk: string |
Each streaming token from the AI | Append text to the chat bubble |
StreamingCompleted |
— | AI response fully received | Finalize the message, enable input |
BusyChanged |
busy: boolean |
AI starts/stops processing | Show/hide loading spinner |
Error |
message: string |
Critical error (rate limit, safety block, etc.) | Show error message to user |
ActionExecuting |
action: string |
A tool/agent is executing | Show action feedback bar (e.g. "Consulting specialist…") |
ActionCompleted |
action: string |
Tool execution succeeded | Hide feedback bar |
ActionFailed |
error: string |
Tool execution failed | Show error in feedback bar |
ConfirmationRequired |
actionId: string, toolName: string, message: string |
Destructive action needs approval | Show confirmation dialog, then POST /mentor/approve?actionId=...&approved=true\|false (HTTP — not a hub method, see HITL note) |
NavigationRequested |
url: string |
AI navigated after an action, or user asked to navigate | Call router.push(url) or equivalent |
RagSourcesReady |
sources: RagSource[] |
RAG documents retrieved for this response | Show citation chips below the AI message |
TeamMemberSpeaking |
teamName: string, memberRole: string |
A GroupChat team member is speaking | Show "Team analyzing · Data Analyst" in feedback bar |
UIActionRequested |
actionName: string, parameterJson: string? |
AI invoked a client-side UI action | Execute the registered handler for actionName |
UIActionExecuting |
actionName: string |
UI action started | Optional: show feedback |
UIActionCompleted |
actionName: string |
UI action completed | Optional: hide feedback |
McpServerStatusChanged |
name: string, connected: boolean |
An MCP client server connected (true) or failed/disconnected (false) |
Update the MCP status badge (green/red) for that server |
Client → Server methods
| Method | Parameters | Description |
|---|---|---|
UpdatePageContext |
snapshot: PageContextSnapshot |
Send current page state before each message. Call before SendMessage |
SendMessage |
text: string |
Send user message to the AI |
CancelRequest |
— | Cancel the current in-flight AI request |
ResetSession |
— | Clear conversation history and start fresh |
RespondToApproval |
requestId: string, approved: boolean |
⚠️ Do not use for HITL. SignalR sequential dispatch causes a deadlock while SendMessage is awaiting. Use POST /mentor/approve instead (see below) |
PageContextSnapshot object
Sent before each message so the AI knows the current page state and available UI actions:
interface PageContextSnapshot {
pageName?: string; // e.g. "Orders"
contextData: Record<string, string | null>; // e.g. { activeFilter: "Pending", visibleRows: "15" }
uiActions: UIActionInfo[];
}
interface UIActionInfo {
name: string; // snake_case, e.g. "highlight_row"
description: string; // shown to AI
parameterHint?: string; // e.g. "integer: order ID"
}
RagSource object
JSON property names use camelCase (System.Text.Json default serialization from C#
MentorRagResult).
interface RagSource {
content: string; // document text injected into AI prompt
sourceUrl?: string; // link for citation chip
title?: string; // display title for chip (falls back to sourceUrl)
score: number; // relevance score (higher = more relevant)
}
Complete chat component — React
Two files: the hook that manages the SignalR connection, and the component that renders the UI.
// useMentorHub.ts
import { useEffect, useRef, useState } from 'react';
import * as signalR from '@microsoft/signalr';
export function useMentorHub(hubUrl: string) {
const connRef = useRef<signalR.HubConnection | null>(null);
const streamingRef = useRef(''); // ref to avoid stale closure in StreamingCompleted
const [messages, setMessages] = useState<{ role: string; text: string }[]>([]);
const [streaming, setStreaming] = useState('');
const [busy, setBusy] = useState(false);
const [action, setAction] = useState('');
const [sources, setSources] = useState<any[]>([]);
const [confirm, setConfirm] = useState<{ id: string; msg: string } | null>(null);
const [mcpStatus, setMcpStatus] = useState<Record<string, boolean>>({}); // server name → connected
useEffect(() => {
const conn = new signalR.HubConnectionBuilder()
.withUrl(hubUrl)
.withAutomaticReconnect()
.build();
conn.on('StreamingChunk', c => {
streamingRef.current += c;
setStreaming(p => p + c);
});
conn.on('StreamingCompleted', () => {
setMessages(m => [...m, { role: 'assistant', text: streamingRef.current }]);
streamingRef.current = '';
setStreaming('');
});
conn.on('BusyChanged', b => setBusy(b));
conn.on('Error', e => setMessages(m => [...m, { role: 'error', text: e }]));
conn.on('ActionExecuting', (a: string) => setAction(a));
conn.on('ActionCompleted', (_: string) => setAction(''));
conn.on('ActionFailed', e => setAction(`Error: ${e}`));
conn.on('ConfirmationRequired', (id, tool, msg) => setConfirm({ id, msg }));
conn.on('NavigationRequested', url => router.push(url)); // use your SPA router
conn.on('RagSourcesReady', s => setSources(s));
conn.on('McpServerStatusChanged', (name, connected) => // drives the 🔌 MCP badge
setMcpStatus(p => ({ ...p, [name]: connected })));
conn.on('TeamMemberSpeaking', (t, r) => setAction(`${t} · ${r}`));
conn.on('UIActionExecuting', name => setAction(`UI: ${name}`));
conn.on('UIActionCompleted', (_name: string) => setAction(''));
conn.on('UIActionRequested', (name, json) => {
// dispatch to your own UI action handlers
window.dispatchEvent(new CustomEvent('mentor-ui-action', { detail: { name, json } }));
});
conn.start();
connRef.current = conn;
return () => { conn.stop(); };
}, [hubUrl]);
const sendMessage = async (text: string, snapshot?: any) => {
if (snapshot) await connRef.current?.invoke('UpdatePageContext', snapshot);
setMessages(m => [...m, { role: 'user', text }]);
await connRef.current?.invoke('SendMessage', text);
};
const respond = async (id: string, approved: boolean) => {
setConfirm(null);
// HITL MUST use HTTP POST, not the hub: SignalR dispatches hub messages
// sequentially per connection, so invoking a hub method while SendMessage
// is still awaiting would deadlock. (See the protocol table above.)
await fetch(`${baseUrl}/mentor/approve?actionId=${id}&approved=${approved}`, { method: 'POST' });
};
const cancel = () => connRef.current?.invoke('CancelRequest');
const reset = () => { setMessages([]); connRef.current?.invoke('ResetSession'); };
return { messages, streaming, busy, action, sources, confirm, mcpStatus, sendMessage, respond, cancel, reset };
}
// MentorChat.tsx
import { useState } from 'react';
import { useMentorHub } from './useMentorHub';
export function MentorChat() {
const [input, setInput] = useState('');
const { messages, streaming, busy, action, sources, confirm,
sendMessage, respond, cancel, reset } = useMentorHub('/mentor-hub');
const handleSend = () => {
if (!input.trim() || busy) return;
// Pass a PageContextSnapshot as second argument if you have page state:
// sendMessage(input, { pageName: 'Orders', contextData: {}, uiActions: [] });
sendMessage(input);
setInput('');
};
return (
<div className="mentor-chat">
{/* Messages */}
<div className="messages">
{messages.map((m, i) => (
<div key={i} className={`bubble bubble--${m.role}`}>
{m.text}
</div>
))}
{streaming && (
<div className="bubble bubble--assistant">
{streaming}<span className="cursor">▋</span>
</div>
)}
{busy && !streaming && <div className="typing">···</div>}
</div>
{/* Action feedback bar */}
{action && (
<div className="action-bar">
<span className="pulse" /> {action}
</div>
)}
{/* RAG citations */}
{sources.length > 0 && (
<div className="citations">
{sources.map((s, i) => (
<a key={i} href={s.sourceUrl} target="_blank" className="citation-chip">
📄 {s.title ?? s.sourceUrl}
</a>
))}
</div>
)}
{/* Confirmation dialog */}
{confirm && (
<div className="confirm-dialog">
<p>{confirm.msg}</p>
<button onClick={() => respond(confirm.id, true)}>Confirm</button>
<button onClick={() => respond(confirm.id, false)}>Cancel</button>
</div>
)}
{/* Input */}
<div className="input-bar">
<input
value={input}
onChange={e => setInput(e.target.value)}
onKeyDown={e => e.key === 'Enter' && handleSend()}
placeholder="Ask anything..."
disabled={busy}
/>
{busy
? <button onClick={cancel}>■ Stop</button>
: <button onClick={handleSend} disabled={!input.trim()}>Send</button>
}
<button onClick={reset} title="New conversation">↺</button>
</div>
</div>
);
}
Complete chat component — Angular
Two files: the injectable service and the component.
npm install @microsoft/signalr
// mentor-hub.service.ts
import { Injectable, OnDestroy, signal } from '@angular/core';
import * as signalR from '@microsoft/signalr';
import { Router } from '@angular/router';
@Injectable({ providedIn: 'root' })
export class MentorHubService implements OnDestroy {
// Reactive state — use in templates with {{ messages() }}
messages = signal<{ role: string; text: string }[]>([]);
streaming = signal('');
busy = signal(false);
currentAction = signal('');
ragSources = signal<any[]>([]);
confirmation = signal<{ id: string; tool: string; message: string } | null>(null);
mcpStatus = signal<Record<string, boolean>>({}); // server name → connected
private connection: signalR.HubConnection;
constructor(private router: Router) {
this.connection = new signalR.HubConnectionBuilder()
.withUrl('/mentor-hub')
.withAutomaticReconnect()
.build();
this.registerHandlers();
this.connection.start();
}
private registerHandlers(): void {
this.connection.on('StreamingChunk', (c: string) => this.streaming.update(p => p + c));
this.connection.on('StreamingCompleted', () => {
this.messages.update(m => [...m, { role: 'assistant', text: this.streaming() }]);
this.streaming.set('');
});
this.connection.on('BusyChanged', (b: boolean) => this.busy.set(b));
this.connection.on('Error', (msg: string)=> this.messages.update(m => [...m, { role: 'error', text: msg }]));
this.connection.on('ActionExecuting', (a: string) => this.currentAction.set(a));
this.connection.on('ActionCompleted', (_: string) => this.currentAction.set(''));
this.connection.on('ActionFailed', (e: string) => this.currentAction.set(`Error: ${e}`));
this.connection.on('ConfirmationRequired', (id: string, tool: string, msg: string) =>
this.confirmation.set({ id, tool, message: msg }));
this.connection.on('NavigationRequested', (url: string)=> this.router.navigateByUrl(url));
this.connection.on('RagSourcesReady', (s: any[]) => this.ragSources.set(s));
this.connection.on('McpServerStatusChanged', (name: string, connected: boolean) => // 🔌 MCP badge
this.mcpStatus.update(m => ({ ...m, [name]: connected })));
this.connection.on('TeamMemberSpeaking', (t: string, r: string) => this.currentAction.set(`${t} · ${r}`));
this.connection.on('UIActionExecuting', (name: string) => this.currentAction.set(`UI: ${name}`));
this.connection.on('UIActionCompleted', (_: string) => this.currentAction.set(''));
this.connection.on('UIActionRequested', (name: string, json: string | null) => {
// Dispatch to your own UI action handlers
document.dispatchEvent(new CustomEvent('mentor-ui-action', { detail: { name, json } }));
});
}
async sendMessage(text: string, snapshot?: any): Promise<void> {
if (snapshot) await this.connection.invoke('UpdatePageContext', snapshot);
this.messages.update(m => [...m, { role: 'user', text }]);
await this.connection.invoke('SendMessage', text);
}
async respond(id: string, approved: boolean): Promise<void> {
this.confirmation.set(null);
// HITL MUST use HTTP POST, not the hub — SignalR dispatches hub messages
// sequentially per connection, so a hub call while SendMessage awaits deadlocks.
await fetch(`/mentor/approve?actionId=${id}&approved=${approved}`, { method: 'POST' });
}
cancel = () => this.connection.invoke('CancelRequest');
reset = () => { this.messages.set([]); this.connection.invoke('ResetSession'); };
ngOnDestroy(): void { this.connection.stop(); }
}
// mentor-chat.component.ts
import { Component, signal } from '@angular/core';
import { MentorHubService } from './mentor-hub.service';
import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms';
@Component({
selector: 'app-mentor-chat',
standalone: true,
imports: [CommonModule, FormsModule],
template: `
<div class="mentor-chat">
<div class="messages">
@for (m of hub.messages(); track $index) {
<div class="bubble" [class]="'bubble--' + m.role">{{ m.text }}</div>
}
@if (hub.streaming()) {
<div class="bubble bubble--assistant">
{{ hub.streaming() }}<span class="cursor">▋</span>
</div>
}
@if (hub.busy() && !hub.streaming()) {
<div class="typing">···</div>
}
</div>
@if (hub.currentAction()) {
<div class="action-bar">
<span class="pulse"></span> {{ hub.currentAction() }}
</div>
}
@if (hub.ragSources().length > 0) {
<div class="citations">
@for (s of hub.ragSources(); track $index) {
<a [href]="s.sourceUrl" target="_blank" class="citation-chip">
📄 {{ s.title ?? s.sourceUrl }}
</a>
}
</div>
}
@if (hub.confirmation(); as c) {
<div class="confirm-dialog">
<p>{{ c.message }}</p>
<button (click)="hub.respond(c.id, true)">Confirm</button>
<button (click)="hub.respond(c.id, false)">Cancel</button>
</div>
}
<div class="input-bar">
<input [(ngModel)]="input" (keydown.enter)="send()"
placeholder="Ask anything..." [disabled]="hub.busy()" />
@if (hub.busy()) {
<button (click)="hub.cancel()">■ Stop</button>
} @else {
<button (click)="send()" [disabled]="!input.trim()">Send</button>
}
<button (click)="hub.reset()" title="New conversation">↺</button>
</div>
</div>
`
})
export class MentorChatComponent {
input = '';
constructor(public hub: MentorHubService) {}
send() {
if (!this.input.trim() || this.hub.busy()) return;
this.hub.sendMessage(this.input);
this.input = '';
}
}
Complete chat component — Vue
npm install @microsoft/signalr
// useMentorHub.ts
import { ref, onUnmounted } from 'vue';
import * as signalR from '@microsoft/signalr';
import { useRouter } from 'vue-router';
export function useMentorHub(hubUrl: string) {
const router = useRouter();
const messages = ref<{ role: string; text: string }[]>([]);
const streaming = ref('');
const busy = ref(false);
const currentAction = ref('');
const ragSources = ref<any[]>([]);
const confirmation = ref<{ id: string; tool: string; message: string } | null>(null);
const mcpStatus = ref<Record<string, boolean>>({}); // server name → connected
const connection = new signalR.HubConnectionBuilder()
.withUrl(hubUrl)
.withAutomaticReconnect()
.build();
connection.on('StreamingChunk', (c: string) => streaming.value += c);
connection.on('StreamingCompleted', () => {
messages.value.push({ role: 'assistant', text: streaming.value });
streaming.value = '';
});
connection.on('BusyChanged', (b: boolean) => busy.value = b);
connection.on('Error', (msg: string)=> messages.value.push({ role: 'error', text: msg }));
connection.on('ActionExecuting', (a: string) => currentAction.value = a);
connection.on('ActionCompleted', (_: string) => currentAction.value = '');
connection.on('ActionFailed', (e: string) => currentAction.value = `Error: ${e}`);
connection.on('ConfirmationRequired', (id: string, tool: string, msg: string) =>
confirmation.value = { id, tool, message: msg });
connection.on('NavigationRequested', (url: string)=> router.push(url));
connection.on('RagSourcesReady', (s: any[]) => ragSources.value = s);
connection.on('McpServerStatusChanged', (name: string, connected: boolean) => // 🔌 MCP badge
mcpStatus.value = { ...mcpStatus.value, [name]: connected });
connection.on('TeamMemberSpeaking', (t: string, r: string) => currentAction.value = `${t} · ${r}`);
connection.on('UIActionExecuting', (name: string)=> currentAction.value = `UI: ${name}`);
connection.on('UIActionCompleted', (_: string) => currentAction.value = '');
connection.on('UIActionRequested', (name: string, json: string | null) => {
// Dispatch to your own UI action handlers
document.dispatchEvent(new CustomEvent('mentor-ui-action', { detail: { name, json } }));
});
connection.start();
onUnmounted(() => connection.stop());
const sendMessage = async (text: string, snapshot?: any) => {
if (snapshot) await connection.invoke('UpdatePageContext', snapshot);
messages.value.push({ role: 'user', text });
await connection.invoke('SendMessage', text);
};
const respond = async (id: string, approved: boolean) => {
confirmation.value = null;
// HITL MUST use HTTP POST, not the hub (sequential hub dispatch would deadlock).
await fetch(`/mentor/approve?actionId=${id}&approved=${approved}`, { method: 'POST' });
};
const cancel = () => connection.invoke('CancelRequest');
const reset = () => { messages.value = []; connection.invoke('ResetSession'); };
return { messages, streaming, busy, currentAction, ragSources, confirmation, mcpStatus,
sendMessage, respond, cancel, reset };
}
<template>
<div class="mentor-chat">
<div class="messages">
<div v-for="(m, i) in messages" :key="i" :class="`bubble bubble--${m.role}`">
{{ m.text }}
</div>
<div v-if="streaming" class="bubble bubble--assistant">
{{ streaming }}<span class="cursor">▋</span>
</div>
<div v-if="busy && !streaming" class="typing">···</div>
</div>
<div v-if="currentAction" class="action-bar">
<span class="pulse" /> {{ currentAction }}
</div>
<div v-if="ragSources.length" class="citations">
<a v-for="(s, i) in ragSources" :key="i"
:href="s.sourceUrl" target="_blank" class="citation-chip">
📄 {{ s.title ?? s.sourceUrl }}
</a>
</div>
<div v-if="confirmation" class="confirm-dialog">
<p>{{ confirmation.message }}</p>
<button @click="respond(confirmation.id, true)">Confirm</button>
<button @click="respond(confirmation.id, false)">Cancel</button>
</div>
<div class="input-bar">
<input v-model="input" @keydown.enter="send"
placeholder="Ask anything..." :disabled="busy" />
<button v-if="busy" @click="cancel">■ Stop</button>
<button v-else @click="send" :disabled="!input.trim()">Send</button>
<button @click="reset" title="New conversation">↺</button>
</div>
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue';
import { useMentorHub } from './useMentorHub';
const input = ref('');
const { messages, streaming, busy, currentAction, ragSources, confirmation,
sendMessage, respond, cancel, reset } = useMentorHub('/mentor-hub');
function send() {
if (!input.value.trim() || busy.value) return;
sendMessage(input.value);
input.value = '';
}
</script>
.NET MAUI / console
// Install: Microsoft.AspNetCore.SignalR.Client
var connection = new HubConnectionBuilder()
.WithUrl("http://your-api/mentor-hub")
.WithAutomaticReconnect()
.Build();
connection.On<string>("StreamingChunk", chunk => Console.Write(chunk));
connection.On( "StreamingCompleted", () => Console.WriteLine());
connection.On<bool>( "BusyChanged", busy => { /* show spinner */ });
connection.On<string>("Error", msg => Console.WriteLine($"Error: {msg}"));
connection.On<string>("ActionExecuting", act => Console.WriteLine($"[{act}]"));
connection.On<string>("ActionCompleted", _ => { });
connection.On<string>("ActionFailed", err => Console.WriteLine($"Failed: {err}"));
connection.On<string, string, string>("ConfirmationRequired", async (id, tool, msg) => {
Console.WriteLine($"Confirm: {msg} [y/n]");
var approved = Console.ReadLine() == "y";
// HITL MUST use HTTP POST, not the hub — a hub call while SendMessage awaits would deadlock.
using var http = new HttpClient();
await http.PostAsync($"http://your-api/mentor/approve?actionId={id}&approved={approved.ToString().ToLower()}", null);
});
connection.On<string>("NavigationRequested", url => Console.WriteLine($"Navigate: {url}"));
connection.On<JsonElement[]>("RagSourcesReady", s => Console.WriteLine($"{s.Length} sources"));
connection.On<string, string>("TeamMemberSpeaking", (t, r) => Console.WriteLine($"[{t}] {r}"));
connection.On<string, string?>("UIActionRequested", (name, json) => Console.WriteLine($"UI: {name}({json})"));
connection.On<string, bool>("McpServerStatusChanged", (name, ok) => Console.WriteLine($"MCP {name}: {(ok ? "online" : "offline")}"));
await connection.StartAsync();
await connection.InvokeAsync("SendMessage", "Ciao!");
Console.ReadLine();
await connection.StopAsync();
AI providers
// Azure OpenAI — Chat Completions
options.ChatClient = new AzureOpenAIClient(endpoint, credential)
.GetChatClient("gpt-4o").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 — requires AIAgent
options.Agent = new AIProjectClient(endpoint, credential)
.AsAIAgent(model: "gpt-4o", instructions: "You are a helpful assistant.");
Embedding model (optional)
Configure an embedding model to enable semantic tool filtering and semantic memory relevance (see Token & cost optimization). Everything works without it.
// Azure OpenAI
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();
The three-level agent model
MentorAgent uses a three-level orchestration architecture.
Level 1 — Direct actions
Plain C# methods on any DI-registered class become AI tools:
public class OrderService
{
[Description("Get order details by order ID")]
public async Task<Order> GetOrderAsync(string orderId) => ...;
[MentorAction("cancel_order", Description = "Cancel an order", RequiresConfirmation = true)]
public async Task<string> CancelOrderAsync(string orderId) => ...;
}
Register in DI and scan:
builder.Services.AddScoped<OrderService>();
options.ScanAssemblies = [typeof(Program).Assembly];
Level 2 — Specialized agents (Handoff)
[MentorAgent(Name = "OrderAgent", Description = "Handles all order-related operations")]
public class OrderAgent : IMentorAgent
{
[Description("Process a refund for an order")]
public async Task<string> ProcessRefundAsync(string orderId, decimal amount) => ...;
}
builder.Services.AddScoped<OrderAgent>();
Level 3 — Collaborative teams (Group Chat)
[MentorTeam(
Name = "AnalysisTeam",
Description = "Analyzes business proposals before execution",
TriggerOn = ["analyze", "evaluate", "review"],
HandoffTo = ["OrderAgent"])]
public class AnalysisTeam : IMentorTeam
{
[TeamMember(
Role = "DataAnalyst",
Tools = [typeof(ReportTools)],
Instructions = "Analyze quantitative data and KPIs.")]
public object? Analyst { get; set; }
[TeamMember(
Role = "RiskAnalyst",
Instructions = "Evaluate risks and compliance. Reply APPROVED or REJECTED.")]
public object? RiskAnalyst { get; set; }
[TeamTerminationCondition]
public bool ShouldTerminate(string lastMessage, string lastSpeaker)
=> lastSpeaker == "RiskAnalyst" &&
(lastMessage.Contains("APPROVED") || lastMessage.Contains("REJECTED"));
}
Agent Skills
Progressive disclosure: the AI sees only the skill name and description (~100 tokens) until it decides to load the full instructions.
File-based (place in Skills/ folder):
Skills/
refund-policy/
SKILL.md ← instructions + resources
policy.pdf ← attached resource
Class-based:
[MentorSkill("shipping", Description = "Shipping and tracking operations")]
public class ShippingSkill { }
options.EnableSkills = true;
options.SkillsFolder = "Skills";
Page context and UI actions
When using SignalR, the client sends a page context snapshot before each message. Server-side, the AI sees this context in its system prompt and can invoke UI actions that are executed client-side.
Client sends (before each message):
await connection.invoke('UpdatePageContext', {
pageName: 'Orders',
contextData: { activeFilter: 'Pending', visibleRows: 15 },
uiActions: [
{ name: 'highlight_row', description: 'Highlights a row', parameterHint: 'integer: row ID' },
{ name: 'open_modal', description: 'Opens the create order modal' }
]
});
await connection.invoke('SendMessage', 'Highlight order 42');
Server invokes the UI action — client receives:
connection.on('UIActionRequested', (actionName, paramJson) => {
if (actionName === 'highlight_row') highlightRow(JSON.parse(paramJson));
if (actionName === 'open_modal') openModal();
});
HITL — Confirming actions
When RequiresConfirmation = true, the server sends a ConfirmationRequired event and blocks until the user responds. The client must call POST /mentor/approve — not a hub method.
⚠️ Why not
RespondToApprovalvia hub? ASP.NET Core SignalR processes hub messages sequentially per connection. WhileSendMessageis awaiting the confirmation TCS, the dispatcher cannot process any other hub message from the same connection. CallingRespondToApprovalvia hub would queue forever — a deadlock.
// 1. Receive the confirmation request
connection.on('ConfirmationRequired', (actionId, toolName, message) => {
showConfirmDialog(message, {
onConfirm: () => fetch(`/mentor/approve?actionId=${actionId}&approved=true`, { method: 'POST' }),
onCancel: () => fetch(`/mentor/approve?actionId=${actionId}&approved=false`, { method: 'POST' })
});
});
If the server is on a different origin, use the full URL:
http://localhost:5169/mentor/approve?actionId=...&approved=true.
Page navigation
Register pages in the server project — the AI uses them to navigate autonomously and to understand what pages exist in the application.
// Server project — scanned via options.ScanAssemblies
// One class per page, placed anywhere in the assembly.
[MentorPage(Url = "/orders", Name = "Orders",
Description = "Order list with filters and status management")]
public class OrdersPage { }
[MentorPage(Url = "/products", Name = "Products",
Description = "Product catalog with stock and pricing",
HasUIActions = true, // AI waits for SignalReady() before invoking UI actions
ReadyTimeout = 3000)] // ms — default is 2000
public class ProductsPage { }
[MentorPage(Url = "/fulldemo", Name = "Full Demo",
Description = "Complete feature demo — UIActions, HITL, navigation")]
public class FullDemoPage { }
⚠️ If a page is missing its
[MentorPage]attribute, the AI will say the page does not exist — even if the route is valid. Always add the attribute for every page you want the AI to be aware of.
The AI calls navigate_to("/orders") automatically after relevant actions, or when the user asks to go to a page by name.
Contextual memory
options.UseMemoryContext = true;
options.MemoryContextCount = 10; // max facts injected per session
// Reliable capture (default true): a dedicated post-turn LLM call extracts durable user facts
// (name, role, team, preferences) and stores them — no dependence on the model calling remember().
options.MemoryAutoCapture = true;
// Inject only the memories semantically relevant to the message (identity/preference facts always
// kept) instead of the last N. Requires EmbeddingGenerator (see AI providers).
options.MemoryRelevanceFiltering = true;
How capture works — on Path A (a ChatClient is configured) MemoryAutoCapture is the writer: after each user message a small extraction call saves facts reliably, even for phrasings like "Ciao, mi chiamo Antonio". The redundant remember tool is dropped on this path; forget stays. On Path B (a pre-built Agent, no ChatClient) it falls back to the remember tool. Verify in the logs: [MentorAgent:Memory] Auto-capture saved 1 fact(s): user_name.
⚠️ The default store is in-memory (lost on restart, not shared across instances). For production register a persistent store before
AddMentorAgentServer():
builder.Services.AddSingleton<IMentorMemoryStore, RedisMemoryStore>();
With authentication configured, memory is isolated per user (see Authentication).
RAG — Retrieval-Augmented Generation
builder.Services.AddScoped<IMentorRagSource, MyVectorDbSource>();
options.UseRag = true;
options.RagResultCount = 3;
options.RagMinScore = 0.7f; // relevance threshold — nothing below it is injected
options.ShowRagSources = true; // show citations to the user
Retrieval is semantic and always-on: your IMentorRagSource scores documents (e.g. cosine similarity) and RagMinScore filters them, so a pure command or greeting simply retrieves nothing above the threshold and injects nothing — no keyword pre-gate needed.
Implement IMentorRagSource:
public class MyVectorDbSource : IMentorRagSource
{
public async Task<IReadOnlyList<MentorRagResult>> SearchAsync(
string query, int maxResults, CancellationToken ct)
{
var results = await _vectorDb.SearchAsync(query, maxResults);
return results.Select(r => new MentorRagResult(
Content: r.Text,
SourceUrl: r.Url,
Title: r.Title,
Score: r.Score)).ToList();
}
}
Token & cost optimization
MentorAgent.Server minimizes the tokens sent on every request. Some optimizations are always on; two are opt-in.
Always on: a slim, cache-friendly system prompt (stable prefix, volatile data last) and per-call token logging:
[MentorAgent] Tokens — in: 1979, out: 62, call total: 2041 | session: 1979+62=2041 over 1 call(s)
Semantic tool filtering
Every tool is serialized as a JSON schema into each request — the biggest per-call cost when you have many tools (L1 actions + MCP). With filtering, only the tools semantically relevant to the message are sent; the AI still chooses freely among them. It requires EmbeddingGenerator (see AI providers) — 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 nothing is relevant (e.g. "hello"), only core tools are sent. Log: Tool filtering: 7/47 tools sent (7 core + 0 matched, minScore=0.35).
History compaction
As a conversation grows it is re-sent on every call. Compaction shrinks it intelligently (collapse old tool results → keep the last N turns → hard token-budget backstop) instead of a blunt cut.
options.EnableCompaction = true;
options.CompactionTokenThreshold = 4000; // token budget that triggers compaction
options.CompactionMaxTurns = 8; // recent turns kept intact
In-memory history only (Path A /
ChatClient) — not service-managed history (Foundry, Responses API withstore).
Semantic RAG & memory
Both inject context only when relevant, with no keyword heuristics: RAG via the vector search + RagMinScore (see RAG); memory via MemoryRelevanceFiltering (see Contextual memory). On Path A, MemoryAutoCapture also drops the remember tool schema from every call.
Middleware, observability & dashboard
Robustness, tracing and an admin cost view — all configured on the server.
Middleware hooks (#7)
// Built-in (no code): LLM safety checks on input and output.
options.EnableSafetyCheck = true; // moderate the user message
options.EnableOutputSafetyCheck = true; // moderate the reply (buffers → no live streaming that turn)
// Custom hooks (replace/extend the built-ins):
options.InputGuardrail = (msg, ct) => Task.FromResult(IsSafe(msg)); // replaces EnableSafetyCheck
options.OutputGuardrail = (reply, ct) => Task.FromResult(IsSafeReply(reply)); // replaces EnableOutputSafetyCheck
options.OnToolResult = (tool, result) => Truncate(result, maxChars: 2000); // transform a tool result
options.OnException = ex => ex.Message.Contains("rate", StringComparison.OrdinalIgnoreCase)
? "The service is busy, please retry shortly." : null;
options.ConfigureChatClientPipeline = b => b.UseLogging(); // insert your own DelegatingChatClient / AF middleware
Observability (#8)
options.EnableObservability = true;
options.ObservabilityIncludeSensitiveData = builder.Environment.IsDevelopment(); // dev only
builder.Services.AddOpenTelemetry()
.WithTracing(t => t.AddSource("MentorAgent").AddOtlpExporter())
.WithMetrics(m => m.AddMeter("MentorAgent").AddOtlpExporter());
Emits GenAI-convention spans/metrics for the chat client (LLM) calls + MentorAgent per-turn/tool spans and counters under the source/meter named by ObservabilitySourceName (default "MentorAgent").
Token & cost dashboard (#9)
Admin-only, Azure-style: per-model breakdown (cheap / strong / embedding) with a model selector, temporal charts (tokens / requests / latency), and a per-model cost table. Supply prices, then read the snapshot from the endpoint (or IMentorMetrics.GetSnapshot() in-process):
options.ModelPricing = new Dictionary<string, ModelPrice>(StringComparer.OrdinalIgnoreCase)
{
["gpt-4.1"] = new ModelPrice(2.00m, 8.00m), // cheap chat
["o3"] = new ModelPrice(2.00m, 8.00m), // strong routing
["text-embedding-3-small"] = new ModelPrice(0.02m, 0.00m), // embedding
};
options.DashboardRole = "Admin"; // role required for the endpoint; "" leaves it open (dev only)
MapMentorAgentServer() exposes GET /mentor/admin/metrics returning a MentorMetricsSnapshot — now carrying the per-model breakdown (Models) and hourly time series (MetricsRetention, 7d) plus tokens, cost, deflection and top actions — gated by DashboardRole. Fetch it from your React/Vue admin UI, or bind <MentorDashboard Snapshot="..."/> in a WASM client to get the identical charts. Never expose it to end users. Cost appears only for priced models — key ModelPricing by the model id in the snapshot (for Azure OpenAI, your deployment name).
A non-empty
DashboardRolerequires ASP.NET Core authentication/authorization to be configured (app.UseAuthentication()/app.UseAuthorization()); otherwise the endpoint has authorization metadata with no middleware to enforce it. UseDashboardRole = ""only for local development.
Localization. <MentorDashboard/> is translated through MentorLocalizer (10 languages, English fallback). A WASM/Blazor client has no MentorAgent DI, so pass the language: <MentorDashboard Snapshot="..." Language="MentorLanguage.Italian" />.
Persistence (optional). By default the snapshot is in-RAM and resets on restart. Register an IMentorMetricsStore before AddMentorAgentServer() for durability or an external source — the endpoint then returns await store.QueryAsync() ?? metrics.GetSnapshot():
// Local durability: seed on startup + timed/shutdown flush (JSON/DB).
builder.Services.AddSingleton<IMentorMetricsStore, FileMetricsStore>();
// External source: read the aggregate #8 already exported (Prometheus / Azure Monitor).
builder.Services.AddHttpClient();
builder.Services.AddSingleton<IMentorMetricsStore, PrometheusMetricsStore>(); // or AzureMonitorMetricsStore
Working FileMetricsStore, PrometheusMetricsStore and AzureMonitorMetricsStore ship in the MentorAgentServer sample (Metrics/). The external readers query the same backend the #8 OpenTelemetry export writes to — so persistence and multi-instance aggregation come from #8, and #9 just reads it.
Model routing, structured outputs & evaluation
Model routing (#11)
Cheap model for simple turns, strong model for complex ones — a real cost lever. Set StrongChatClient and pick a strategy (all avoid keyword matching on user text):
options.StrongChatClient = new AzureOpenAIClient(endpoint, credential).GetChatClient("gpt-4o").AsIChatClient();
options.RoutingStrategy = MentorRoutingStrategy.Semantic; // Semantic | Classifier | Cascade | Custom
Semantic— embeds the message, escalates on cosine similarity ≥RoutingThreshold(0.35) to a "complex" exemplar. Multilingual, ~free; requiresEmbeddingGenerator.Classifier— a tiny LLM call labels the turnSIMPLE/COMPLEX.Cascade— serves on cheap, judges completeness, re-runs on strong only if it fell short.Custom— your predicate viaUseStrongModelAsync(async, whole conversation) or legacyUseStrongModel.
Active only when StrongChatClient is set; the chosen model is logged; any routing failure falls back to cheap. The dashboard attributes tokens and cost per model, so cheap vs strong spend is broken out separately (a configured strong model shows up even before any turn escalates to it).
Structured outputs (#12)
Inject IMentorStructured for typed results (JSON schema derived from your type):
public record ExtractedOrder(string Customer, string[] Products, decimal Total);
var order = await structured.GenerateAsync<ExtractedOrder>(userText, "Extract the order details.");
Rich responses — tables & lists (#14 L1)
EnableRichResponses (default true) nudges the coordinator to format structured data as Markdown tables / lists:
options.EnableRichResponses = true; // false → terse plain-text replies
The Blazor/WASM widget renders this automatically (XSS-safe — model text is HTML-encoded before any tag is emitted). If you drive the SSE/hub from a custom React/Vue client, render the Markdown on your side (e.g. react-markdown + remark-gfm) to get the tables.
Evaluation / regression (#10)
MentorEvaluator wraps the Agent Framework's native evaluation (agent.EvaluateAsync + LocalEvaluator). Inject it in your tests to gate CI on token/quality regressions:
var report = await evaluator.RunAsync(
[ new EvalCase("Hello", "A short greeting.", MaxTokens: 300) ],
new MentorEvalOptions { SystemInstructions = mySystemPrompt, Judge = true, MinQuality = 0.6, MaxTotalTokens = 4000 });
report.ThrowIfFailed();
Plug native evaluators for production-grade quality & safety — Checks (e.g. EvalChecks.ToolCalledCheck(...)) and Evaluators (FoundryEvals, or MEAI quality/safety evaluators) both gate the report.
MCP — Model Context Protocol
MCP Client — consume external MCP servers
options.McpServers = [
new MentorMcpServer {
Name = "filesystem",
Command = "npx",
Arguments = ["-y", "@modelcontextprotocol/server-filesystem", "/data"]
}
];
MCP Server — expose actions as MCP tools
options.McpServerEnabled = true;
options.McpServerPath = "/mcp";
options.ShowMcpStatus = true;
app.MapMentorAgentMcp();
Every [MentorAction] method becomes an MCP tool. Connect Claude Desktop, VS Code Copilot, or any MCP client to /mcp.
A2A — Agent-to-Agent
A2A Consumer — call remote A2A agents
options.RemoteAgents = [
new MentorRemoteAgent {
Name = "InventoryAgent",
Description = "Manages warehouse and inventory",
AgentCardUrl = "https://inventory.example.com",
Headers = new Dictionary<string, string> {
["Authorization"] = $"Bearer {apiKey}"
}
}
];
A2A Server — expose as a federatable agent
options.A2AServerEnabled = true;
options.A2AServerPath = "/a2a";
options.A2AServerUrl = "https://myapp.example.com";
app.MapMentorAgentA2A();
Security
// AI-based safety check (detects prompt injection and jailbreaks)
options.EnableSafetyCheck = true; // adds ~200-500ms per message
// Per-user rate limiting
options.RateLimitPerUser = 20; // requires authentication for true per-user isolation
// Role-based actions
[MentorAction("delete_record", RequiredRoles = ["Admin"])]
public Task DeleteAsync(string id) => ...;
// Confirmation dialogs for destructive actions
[MentorAction("cancel_order", RequiresConfirmation = true)]
public Task CancelOrderAsync(string id) => ...;
Authentication — per-user memory, rate limiting, and roles
AddMentorAgentServer() bridges the ASP.NET Core authenticated principal to the MentorAgent core automatically. It reads the current user from the SignalR HubCallerContext.User (hub clients) or HttpContext.User (SSE clients) and resolves the ClaimTypes.NameIdentifier claim. This makes per-user memory, per-user rate limiting and RequiredRoles work for any client — React, Vue, Angular, WASM, MAUI — not only Blazor.
Configure any ASP.NET Core authentication scheme on the server:
// Web API with JWT Bearer
builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
.AddJwtBearer(options => { /* configure your JWT issuer */ });
builder.Services.AddAuthorization();
builder.Services.AddMentorAgent(options => { options.RateLimitPerUser = 20; });
builder.Services.AddMentorAgentServer(); // registers the identity bridge
The client must authenticate its connection — e.g. pass the access token to the SignalR hub:
const connection = new signalR.HubConnectionBuilder()
.withUrl('/mentor-hub', { accessTokenFactory: () => myAccessToken })
.withAutomaticReconnect()
.build();
Without authentication configured, every session shares the key
"anonymous"(shared memory, global rate limit), andRequiredRolesactions fail closed (blocked). The bridge is registered withTryAddScoped, so it never overrides anAuthenticationStateProvidera Blazor host already provides.
Attribute reference
[MentorAction] parameters
| 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 |
[MentorAgent] parameters
| Parameter | Required | Description |
|---|---|---|
Name |
✅ | Agent name — key in the Handoff graph and in the coordinator's system prompt |
Description |
✅ | Capabilities description used by the coordinator to decide when to delegate |
HandoffTo |
— | Names of other [MentorAgent] this agent can hand off to (case-insensitive match) |
Instructions |
— | Custom system prompt. Auto-generated from Name + Description when omitted |
[MentorTeam] parameters
| Parameter | Required | Description |
|---|---|---|
Name |
✅ | Team name |
Description |
✅ | Description used by coordinator to decide when to activate |
TriggerOn |
— | Keywords that hint activation (not hard rules — the AI decides) |
MaxIterations |
— | Max turns before forced termination. Default: 10 |
HandoffTo |
— | L2 agents to delegate execution to after team approves |
[TeamMember] parameters
| Parameter | Required | Description |
|---|---|---|
Role |
✅ | Role name within the team (e.g. "DataAnalyst") |
Instructions |
✅ | System prompt for this member |
Tools |
— | Read-only tool classes this member can call during discussion |
[MentorPage] parameters
| Parameter | Required | Description |
|---|---|---|
Url |
✅ | Page URL (e.g. "/orders") |
Name |
✅ | Human-readable page name injected into the system prompt |
Description |
— | Optional feature description shown to the AI |
HasUIActions |
— | If true, AI waits for PageContext.SignalReady() before executing UI actions. Default: false |
ReadyTimeout |
— | Timeout in ms for SignalReady(). Default: 2000 |
[MentorSkill] parameters
| Parameter | Description |
|---|---|
Name |
Unique skill name in kebab-case (e.g. "expense-report") |
Description |
One-sentence description shown in the skill catalogue |
InstructionsFile |
Path to a markdown file (relative to content root or absolute) |
Instructions |
Inline markdown. Takes precedence over InstructionsFile |
Persistent conversation history
By default, conversation history lives in memory and is lost on app restart. Provide a persistent store via ChatHistoryProvider:
// CosmosDB
options.ChatHistoryProvider = new CosmosChatHistoryProvider(cosmosClient, "my-db", "conversations");
// Custom (implement ChatHistoryProvider from Microsoft Agent Framework)
options.ChatHistoryProvider = new MyRedisChatHistoryProvider(redisConnection);
Session serialize and restore
IMentorSessionManager is automatically registered by AddMentorAgent(). Inject it in any service or controller:
// Inject in a service or controller
public class SessionController(IMentorSessionManager sessionManager) : ControllerBase
{
[HttpGet("session/save")]
public async Task<IActionResult> Save()
{
// Serialize the current session (e.g. save to Redis or DB)
JsonElement? snapshot = await sessionManager.SerializeCurrentSessionAsync(agent);
return Ok(snapshot);
}
[HttpPost("session/restore")]
public async Task<IActionResult> Restore([FromBody] JsonElement snapshot)
{
// Restore on reconnect (e.g. after server restart)
await sessionManager.RestoreSessionAsync(agent, snapshot);
return Ok();
}
}
The widget's reset call (ResetSession hub method) clears history and starts a fresh AgentSession automatically.
All configuration options
Core
| Option | Type | Default | Description |
|---|---|---|---|
AppName |
string |
(required) | Application name for the system prompt |
AppDescription |
string |
"" |
Domain description for richer AI context |
ChatClient |
IChatClient? |
null |
AI provider (recommended) |
Agent |
AIAgent? |
null |
Pre-built AI agent (alternative) |
EmbeddingGenerator |
IEmbeddingGenerator<string, Embedding<float>>? |
null |
Optional embedding model — enables semantic tool filtering |
ScanAssemblies |
Assembly[] |
(required) | Assemblies to scan for agents, actions, pages |
Language |
MentorLanguage |
English |
Language for AI responses |
MentorshipLevel |
MentorshipLevel |
Standard |
AI proactivity: Minimal / Standard / Proactive |
Token & cost optimization
| Option | 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 (core tools always kept) |
ToolFilterMinScore |
float |
0.35 |
Minimum cosine similarity (0–1) for a tool to be relevant |
EnableCompaction |
bool |
false |
Compact long conversation history before each call (in-memory history / Path A only) |
CompactionTokenThreshold |
int |
4000 |
Token budget that triggers compaction |
CompactionMaxTurns |
int |
8 |
Recent turns kept intact |
EmbeddingGeneratoralso powers semantic memory (MemoryRelevanceFiltering, see Memory). RAG relevance is handled by the vector search +RagMinScore(see RAG) — no keyword gating.
Also:
AddMentorAgentServer()builds the coordinator once per SignalR connection (not per message), so external MCP servers are connected once and the conversation session persists across messages.
Middleware, observability & dashboard
| Option | Type | Default | Description |
|---|---|---|---|
InputGuardrail |
Func<string,CancellationToken,Task<bool>>? |
null |
Custom input guardrail (true = safe); replaces the built-in check |
OutputGuardrail |
Func<string,CancellationToken,Task<bool>>? |
null |
Moderate the completed reply (true = safe); buffers the reply then reveals it (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 → default) |
ConfigureChatClientPipeline |
Func<ChatClientBuilder,ChatClientBuilder>? |
null |
Insert custom middleware into the Path A pipeline |
EnableObservability |
bool |
false |
Emit OpenTelemetry traces/metrics + MentorAgent spans/counters |
ObservabilityIncludeSensitiveData |
bool |
false |
Include prompt/response content — Development only |
ObservabilitySourceName |
string |
"MentorAgent" |
ActivitySource/Meter name to .AddSource()/.AddMeter() |
ModelPricing |
IReadOnlyDictionary<string,ModelPrice>? |
null |
Per-model prices for the dashboard cost estimate (none built in) |
DashboardRole |
string |
"Admin" |
Role required for GET /mentor/admin/metrics ("" = open, dev only) |
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 (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 set) |
RoutingThreshold |
float |
0.35 |
Semantic: cosine floor to escalate |
RoutingClassifierClient |
IChatClient? |
null |
Classifier/Cascade: dedicated judge client (defaults to cheap ChatClient) |
Also available as services (resolve from DI):
IMentorStructured(typedGenerateAsync<T>, #12) andMentorEvaluator(token/quality regression harness, #10).
Memory
| Option | Type | Default | Description |
|---|---|---|---|
UseMemoryContext |
bool |
false |
Enable automatic user memory |
MemoryContextCount |
int |
10 |
Max memories injected per session |
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 post-turn extraction saves durable user facts instead of relying on the model to call remember. On Path A (a ChatClient is set) it is the only writer — the redundant remember tool + prompt are dropped (saves tokens); on Path B it falls back to the remember tool. forget always kept. One small model call per user message; set false to opt out |
Agent Skills
| Option | Type | Default | Description |
|---|---|---|---|
EnableSkills |
bool |
false |
Enable skill discovery and load_skill / read_skill_resource tools |
SkillsFolder |
string |
"Skills" |
Folder to scan for file-based skills (SKILL.md) |
RAG
| Option | 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. 0 = no filtering. For keyword search: 2 ≈ two content matches. For vector/cosine similarity: use 0.5–0.75 |
RagSystemPromptTemplate |
string |
"Use the following documents...\n{documents}" |
Prompt template |
ShowRagSources |
bool |
false |
Show citation chips in widget |
RAG is fully semantic: vector search +
RagMinScoreinject nothing on pure commands, so no keyword gating is needed.
MCP
| Option | Type | Default | Description |
|---|---|---|---|
McpServers |
MentorMcpServer[]? |
null |
External MCP servers as L1 tools |
McpServerEnabled |
bool |
false |
Expose as MCP server. Also call app.MapMentorAgentMcp() |
ShowMcpStatus |
bool |
false |
Show MCP status badge in widget |
A2A
| Option | Type | Default | Description |
|---|---|---|---|
RemoteAgents |
MentorRemoteAgent[]? |
null |
Remote A2A agents in the Handoff workflow |
A2AServerEnabled |
bool |
false |
Expose as A2A agent. Also call app.MapMentorAgentA2A() |
A2AServerUrl |
string? |
null |
Full public URL of this agent's A2A endpoint (required when used as remote by other agents) |
AgentCard |
AgentCardInfo? |
null |
A2A Agent Card metadata |
ShowA2AStatus |
bool |
false |
Show A2A status badge in widget |
Security & Limits
| Option | Type | Default | Description |
|---|---|---|---|
EnableSafetyCheck |
bool |
false |
AI-based prompt injection detection |
MaxMessageLength |
int |
4000 |
Max message length (0 = unlimited) |
RateLimitPerUser |
int |
0 |
Max messages per minute per user (0 = disabled) |
RateLimitWindowSecs |
int |
60 |
Rate limiting window in seconds |
RequireConfirmation |
bool |
true |
Global on/off for confirmation dialogs |
IncludeWorkflowExceptionDetails |
bool |
false |
Include stack traces in responses. Never enable in production |
Session & History
| Option | Type | Default | Description |
|---|---|---|---|
MaxSessionMessages |
int |
50 |
Max messages in session history |
ChatHistoryProvider |
ChatHistoryProvider? |
null |
Persistent conversation history provider |
Requirements
- .NET 10.0+
MentorAgentpackage (required dependency — installed automatically)- An AI provider (Azure OpenAI, OpenAI, Ollama, etc.)
Related Packages
| Package | Purpose |
|---|---|
| MentorAgent | Required — AI orchestration engine |
| MentorAgent.Blazor | Blazor WASM client |
| MentorAgent.Abstractions | Shared foundation (transitive — no need to install) |
| 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
- MentorAgent (>= 1.0.0-preview.3)
NuGet packages
This package is not used by any NuGet packages.
GitHub repositories
This package is not used by any popular GitHub repositories.
| Version | Downloads | Last Updated |
|---|---|---|
| 1.0.0-preview.3 | 37 | 7/24/2026 |
| 1.0.0-preview.2 | 63 | 6/22/2026 |
| 1.0.0-preview | 70 | 6/22/2026 |
1.0.0-preview.3
- GET /mentor/admin/metrics now returns the Azure-style per-model snapshot: MentorMetricsSnapshot carries a per-model breakdown (Models: cheap / strong / embedding, each with tokens, requests, latency and cost) plus hourly time series (MetricsRetention, default 7d). Bind it to <MentorDashboard Snapshot=... /> in a WASM/React admin UI for the model selector + temporal charts. Configured models appear even before any turn escalates to them.
- Model routing is strategy-based via MentorRoutingStrategy (Semantic / Classifier / Cascade / Custom) — set StrongChatClient + RoutingStrategy; no keyword matching on user text.
- Fix (README): the #11 note no longer claims cost is priced only with the cheap model — the dashboard breaks cost out per model.
- README: fixed the HITL examples for Angular, Vue and MAUI/console — they now use POST /mentor/approve instead of the deadlocking RespondToApproval hub call.
- README: added the McpServerStatusChanged event to all client examples (React, Angular, Vue, MAUI).
- Multi-user identity bridge: AddMentorAgentServer() now surfaces the SignalR/HTTP authenticated principal to the core, so per-user memory, per-user rate limiting and RequiredRoles work for headless clients (React/Vue/Angular/WASM), not only Blazor.
- README: corrected the authentication section to match the new identity bridge.
- Coordinator scope per connection: AddMentorAgentServer() now builds the coordinator once per SignalR connection (not per hub method), so external MCP servers connect once and the conversation session persists across messages.
- MapMentorAgentServer() now also exposes GET /mentor/admin/metrics — an admin token & cost snapshot (tokens, cost, deflection, top actions) for headless dashboards, gated by MentorOptions.DashboardRole. Plus the shared middleware suite (#7), OpenTelemetry observability (#8) and pricing options (#9) from the core package.