Repository navigation
Added
-
ACTION-RULES.md lists the rules by which a method becomes an action, how actions are named, and how roles and
confirmations add up. Each rule has a name that does not change and at least one test that fails when the rule does;
a rule that comes in, changes or is retired is named in the release notes of its version. The rules of 1.3.0:
scan.assemblies-once,scan.concrete-classes,scan.public-instance-declared,scan.marker,
scan.mentor-action-wins,scan.override-keeps-action,kind.one-per-class,kind.page-is-separate,
kind.agent-owns-methods,kind.skill-methods-level1,kind.team-needs-members,kind.member-tools-leave-level1,
kind.member-tools-same-rules,name.snake-case,name.team-tool,name.shared-warned,
name.called-method-decides,confirm.declared-decides,confirm.default-for-writes,
confirm.description-only-not-defaulted,confirm.predicate-adds,confirm.master-switch,
confirm.beats-read-only,confirm.unconfirmed-writes-warned,roles.any-of,roles.signed-in,
roles.checked-before-confirmation,roles.offered-when-allowed,surface.coordinator-only,
surface.mcp-withholds-gatedandsurface.card-ungated-only. -
An action can ship off, and the application can turn its actions on and off from outside its code.
[MentorAction(Enabled = false)]keeps an action in the catalogue (MentorActionInfo.EnabledByDefaultis false)
and nowhere else: no agent is offered it, no model reads about it, the MCP server does not list it, the A2A card
does not show it, and a call to it does not run. AnIMentorActionGovernor, registered with
AddMentorActionGovernor<T>(), answers for each action whether it is on, and can turn on one the code ships off:
registering one gives whoever implements it the power to widen what the code allows. It is asked once as a session
builds its tools, on everytools/listof the MCP server and before every call, with the session's id; a session
keeps the tools it was built with, and a call to an action turned off meanwhile is refused. There can be one: a
second registration throws. A governor that throws, or does not answer withinActionPolicyTimeout, stops the
action. The A2A card follows the code only, so an action turned on from outside is not on it. New reason codes:
disabled(Denied) andgovernor-failed(PolicyFailed). New rules:enabled.off-by-default,
enabled.external-decidesandsurface.governor-session;surface.mcp-withholds-gatedand
surface.card-ungated-onlynow also leave out an action that is off. -
The governor can also give an action its roles:
IMentorActionGovernor.GetRequiredRolesAsyncanswers in place of
[MentorAction(RequiredRoles = ...)], withnullfor the code's roles and an empty list for none. The answer
decides whom the action is offered to, the check at every call, before a confirmation and again after it, and which
MCP callers see and call it: an action the MCP server withheld only for its roles is published once the governor
requires none.MentorActionDecision.RolesFromOutsidesays when the roles came from the governor. New rule:
roles.external-replace;roles.checked-before-confirmationnow checks the governor's roles when it gives them. -
The governor can also decide whether the user confirms an action:
IMentorActionGovernor.GetConfirmationAsync
answersMentorConfirmationChoice.Codefor the code's confirmation,Requiredto ask even if the code does not,
andNonenot to ask even if the code does — declared, by default for writes, or byRequiresApproval. A
confirmation added counts from the call, one taken away from the next session, and in Native mode no auto-approval
rule lets a required one through. A policy'sConfirmstill asks, a hosted MCP server's approval still comes from
its provider, and withRequireConfirmationoff nobody is asked. An action whose confirmation is taken away still
counts as one that changes data. The MCP server publishes an action that asks for confirmation once the governor
takes it away, and withholds one it requires.MentorActionDecision.ConfirmationFromOutsidesays when the governor
changed whether a call is confirmed. New rule:confirm.external-decides;confirm.predicate-addsand
surface.mcp-withholds-gatednow say what the governor can take away. -
The governor can also rewrite the description the model reads:
IMentorActionGovernor.GetDescriptionAsyncanswers
in place of the code's, withnullor a blank text for the code's. It is asked as a session builds its tools and
on everytools/listof the MCP server, and the answer is what the coordinator, the specialists and the team
members read, what tool filtering ranks, and whattools/listshows; the action's name and argument schema never
change, and the code'sProactiveHintstill follows the description. What the user sees keeps the code's
description: the confirmation banner, the bar of the running action andMentorActionDecision.DisplayName. On a
call to the MCP server the policies are asked with the code's description. New rule:describe.external-replace. -
An
IMentorAgentSourcesays what it loaded and what it left out, and when it is asked is a promise. A source is
asked whenever a coordinator is built: at least once per session — once per Blazor circuit, MAUI app scope or hub
connection, and at every request on/mentor/chatand every task over A2A — andMentorAgentSourceContext.SessionId
names the session, asMentorActionRequest.SessionIddoes. MentorAgent now callsIMentorAgentSource.LoadAsync,
which returns aMentorAgentSourceResult: the agents, and aMentorAgentRejection(Name,ReasonCode,Detail,
Tool) for each definition left out or tool taken away. Its default body returns the agents ofGetAgentsAsync, so
an existing source works unchanged. AnIMentorAgentSourceObserver, registered with
AddMentorAgentSourceObserver<T>(), receives aMentorAgentSourceReportfor each source every time a coordinator
is built, also when the source loaded nothing; a source that throws is reported assource-failed, with the
exception's type and never its message. The reason codes, inMentorAgentRejectionReasons:invalid-definition,
not-an-agent,foreign-tools,invalid-configuration,source-failed,tool-not-foundand
hosted-tool-removed. Novior.MentorAgent.Declarative reports every definition it does not load and every tool
it takes away. New rules:declarative.functions-matchanddeclarative.no-hosted-tools. -
MentorRuntimeOptions.Changeablelists the options that can be changed while the application runs, and from when
a change applies (MentorOptionReadAt). From the next turn of every session:Language,RateLimitPerUser,
RateLimitWindowSecs,ConfirmationMessage,ConfirmationTimeout,ActionPolicyTimeout,EnableActionFeedback,
EnableGenerativeCards,EnableSafetyCheckandCheckAnnouncedActions. From the sessions that begin after the
change:MentorshipLevel,
ProactiveNextSteps,EnableRichResponses,RefuseOutOfScope,ToolFilterMaxToolsandMaxSessionMessages. To
change one, assign it on theIOptions<MentorOptions>value, replacing a collection rather than changing it in
place. Every other option is read before the application starts: set it there. Each option listed has a test that
changes it on a running application, and every option declares when it is read, so a new one cannot ship without
the decision. -
After a turn in which the coordinator called no tool, MentorAgent asks a model whether its reply announces an action
("I'm registering the note…") or reports one as done when nothing ran (MentorOptions.CheckAnnouncedActions, on by
default). If it does, the coordinator gets one more request in the same turn, with the same tools, told to call the
tool now or to say that it was not done. The question names the tools the model was sent, and the answer names the
one that does the action: the second request then says that this tool is there to call now. The instruction is that
request's last message, read after the reply it is about, and never stored. A call to an action of the application
the agent was not given (one that is off for the session) runs nothing, and the turn is checked as one in which
nothing ran. The call is never required and passes the gate as any other, the reply
already shown is not held back, and the second reply is not checked again. The question goes to
ClassifierChatClient, or toChatClientwithout one: a few hundred tokens after every reply that called no tool,
so a small deployment there is worth configuring. Its verdict is logged at Information for every reply it checks,
with the time and the tokens it took. On Path A with the history kept by MentorAgent; not with
UseServiceManagedHistorynor with a pre-builtAgent(BUG-220). -
A call the model makes to an action of the application that the coordinator was not given in the turn — one that
is off, or another agent's — reaches no gate and runs nothing.IMentorActionObservernow receives its one
decision,Deniedwith the new reason codenot-offered, and the call's span says the same, in Blocking and in
Native mode, withCheckAnnouncedActionson or off. A call the coordinator was given keeps the decisions of its
gate, and only those. -
One instance of the application can tell which tenant a request belongs to.
IMentorTenantResolver, registered with
services.AddMentorTenantResolver<T>()(one per application), names it from the caller, the surface and the
request's services;IMentorTenant.Idreads it, in an action, a policy or the governor, and
IMentorTenant.GetIdAsync()resolves it first in a scope that had no turn yet. It is asked when a turn
starts — in a Blazor circuit, on the hub, on/mentor/chat, in an A2A task — and for everytools/listand
tools/callof the MCP server, where each call reads its own. A request is never served without a tenant: a resolver
that throws, or answersnull, empty or white space, refuses it before the model or the action, and a turn whose
tenant is not the one its conversation began with is refused too. Without a resolver there is one tenant, as before,
andIMentorTenant.Idisnull. -
With an
IMentorTenantResolver, two tenants on one instance share no memory, no rate limit and no conversation.
MentorMemoryService.GetUserIdAsync()— the key of the memory and ofRateLimitPerUser— is the tenant's own
(tenant:<id>/<the user's key>), the shared anonymous bucket included, and so is the MCP server's per-caller
allowance. A session snapshot names its tenant and is restored for that tenant only: one of another tenant, one that
names none where a resolver is registered, and one that names a tenant where none is, are refused before the
conversation changes (a 400 on/mentor/session). The history MentorAgent keeps is a coordinator's own, as before; a
singleChatHistoryProviderinstance with a resolver registered is reported at start-up, since it serves every tenant
—ChatHistoryProviderFactoryruns in the conversation's scope, whereIMentorTenantreads the tenant. Without a
resolver every key and every snapshot is as before. -
With an
IMentorTenantResolver, every metric names its tenant: thementoragent.tenanttag is on the four counters
(mentoragent.turns,mentoragent.tool_calls,mentoragent.errors,mentoragent.guardrail_blocks) and on the
mentor.turnspan, the MCP server's calls included, so the whole instance is read per tenant from OpenTelemetry. The
token & cost dashboard andGET /mentor/admin/metricsshow the figures of the reader's tenant only: tokens, cost,
turns, errors, guardrail blocks and top actions. A reader whose tenant the resolver does not name sees none (a 403
on the endpoint).IMentorMetrics.GetSnapshot(string? tenant)andIMentorMetricsStore.QueryAsync(string? tenant, CancellationToken)are new, with defaults that keep existing implementations working. A customIMentorMetrics
shows a tenant nothing rather than the instance's figures (a 204 on the endpoint). A store is asked for a tenant only
when it implements the new overload. A tenant's figures are kept in RAM since the process started; the store saves
and seeds the instance's. Without a resolver the series, the dashboard and the endpoint are as before. -
With an
IMentorTenantResolver, retrieval and the logs know the tenant.MentorRagRequest.TenantIdgives a RAG
source the turn's tenant; MentorAgent filters nothing, so a source that holds several tenants' documents returns
that tenant's. A source that implements only the(query, maxResults)overload never sees it, and a Warning says
so at its first search with a tenant. Every log line of a turn, the application's own code in the turn included,
and of a call to the MCP server is written in a log scope with one pair,mentoragent.tenant, the key of the
metrics and the span; a provider that includes scopes shows it. Without a resolver nothing changes. -
MentorOptions.RagNoResultsNotice(off by default) tells the model when the knowledge-base search of a turn kept no
document — none returned, or every one belowRagMinScore: a question the documents would cover is answered "not
found in the documents" rather than from memory, and a message that needs no document, such as a greeting or an
action, is answered as usual. The text is MentorAgent's, in the language of the turn, in that request's
instructions only. It is not given after a search that failed, nor without a source. -
A knowledge-base search that fails can be monitored. With
EnableObservabilityit is counted on
mentoragent.rag_failures, tagged with the source (mentoragent.rag.source, its type's full name) and the
exception's type (mentoragent.error.type), never its message, and withmentoragent.tenantwhen a resolver is
registered; a Stop during the search is not counted. Each search has amentor.rag.searchspan, with the source,
and the status Error when it failed. The Warning names the source: "The search of … failed — RAG context skipped.". -
IMentorRagSource.OnConversationChangedAsynctells a source that the session's conversation changed, so one that
keeps state for it clears it:MentorConversationChange.ReasonisReset(New conversation),Restored(a saved
conversation took its place) orReplaced(a restored conversation the service no longer knew was replaced,
BUG-110), with the session's id and its tenant. The default does nothing, so an existing source works unchanged. It
is called withUseRagon, on the instance the session searches with, bounded bySafetyCheckTimeout; a source
that throws or does not answer in time is logged as a warning, and the conversation changes anyway. -
MentorVectorStoreRagSource<TRecord>, a knowledge-base source over any collection of
Microsoft.Extensions.VectorData(pgvector, Qdrant, Azure AI Search, an in-memory one), registered with
services.AddMentorVectorStoreRag(sp => collection, new MentorVectorStoreRagOptions<TRecord> { Content = … }).
The options say what of a record is the document, its title, its link and its metadata, and which records a request
may see (Filter, given theMentorRagRequestwith its tenant and user). Scores are read as similarities whatever
the collection's distance function: withCosineDistance, the Postgres default, the source uses 1 − distance, so
RagMinScorekeeps the nearest documents. The function comes fromDistanceFunctionor from the record's
[VectorStoreVector]attribute; when neither names one, or it is a Euclidean, Manhattan or Hamming distance,
registering the source fails. The message is embedded once per turn withMentorOptions.EmbeddingGeneratorand
shared with routing, tool filtering and memory, or with a generator of the source's own; without either, the
collection embeds the text itself.DefaultMinScoreis 0.5. With a tenant resolver and noFilter, the first
search with a tenant is logged as a warning.Novior.MentorAgentnow references
Microsoft.Extensions.VectorData.Abstractions10.10.0, which Agent Framework already brought. -
A provider that ignores
tool_choice: "required"is reported.RequireLookupForRecordQuestionsand
RequireActionForChangeRequestsrequire the first call of the turn withChatToolMode.RequireAny; a provider that
answers such a request without any call — Ollama ignorestool_choice— makes them do nothing, and the first time
it does a warning says so, once per provider client: "The provider answered a request that required a tool call
(tool_choice: required) without calling one: …". A call to a tool the request did not offer is still a call, and a
streamed reply is looked at as it goes by, nothing held back. The README has a recipe for llama-server and vLLM. -
CoordinatorSampling(MentorSamplingOptions): the coordinator's sampling —Temperature,TopP,TopK,
MaxOutputTokens,FrequencyPenalty,PresencePenalty,Seed— on every request of its turns, the forced first
call and the follow-ups of the tool loop included. The option unset, and each value left null, is the provider's
own; in configuration,Mentor:CoordinatorSampling:Temperatureand so on. The classifiers call the provider directly
and keep their own settings: those that ask for temperature 0 still do, the others keep the provider's default, as
before. A negative temperature, a top-p outside (0, 1], or a top-k or output limit below 1 stops the application at
startup. Stop sequences are not offered: one that also occurs in a tool call's arguments would cut the call short. -
WarmUpChatModel: withWarmUpAtStartup, the warm-up also sends the coordinator's model one minimal request, so
the first turn does not pay for opening the connection, the provider's empty prompt cache or a local model still
loading. Every start costs one request per client —ChatClient, andStrongChatClientwhen it is set — with the
coordinator's instructions, tools and sampling, no call allowed (ChatToolMode.None), one short user message and at
most 16 output tokens. It goes straight to the provider client: no history, no memory, no turn on the dashboard;
the warm-up's log line gives its time and tokens. A request that fails is a warning, and the application carries
on; withoutWarmUpAtStartupthe option has no effect, and a startup warning says so. -
MentorEvaluator.RunConversationsAsync: whole conversations (EvalConversation,EvalTurn) through the mentor
itself — the orchestrator, streaming, with its tools, filters, roles, RAG, memory and the history from one turn to
the next — each in a session of its own. A turn can require tools (ExpectTools: "at turn 2 it must call X"),
forbid others (ForbidTools), and set a token budget, a limit on the time to the first token and an expectation
for the judge. Its result addsToolsCalled, read from the tool gate,TimeToFirstToken,TurnDuration,
ConversationandTurn; its tokens are those of its own session, exact while other sessions talk. Every
confirmation is answered no by default: an evaluation does not write data on its own.
MentorEvalOptions.ApproveConfirmationsanswers yes, andPrepareSessionsets the conversation's user or page
before its first turn.RunAsyncis unchanged;SystemInstructions,ChecksandEvaluatorsstay its own. -
EvalTurn.ExpectRefusal: a conversation turn can expect the mentor to refuse —EvalRefusal.OutOfScope
(RefuseOutOfScope),EvalRefusal.Guardrail(the safety check, the host'sInputGuardrail, or the model refusing
to answer) orEvalRefusal.Any. That refusal, shown to the user as an error, then passes the turn and the
conversation goes on; a turn that was answered, refused the other way or failed, fails, and its notes say which. A
safety check that could not run, a message too long, the rate limit and a missing tenant are not refusals: they stay
errors. Without the expectation a refusal fails the turn as before.MentorEvalResult.Refusalreports the refusal a
turn showed, expected or not. -
A
dotnet newtemplate in the repository,templates/mentoragent-blazor: a Blazor Server helpdesk with a
MentorAgent assistant and three actions —list_open_tickets,assign_ticketwith a confirmation,close_ticket
with a confirmation and theAgentrole — the model from the configuration with its key in the user secrets, and a
test sign-in with two users, one without the role. Its test project runs the assistant with a scripted model through
RunConversationsAsync: a support agent who confirms closes a ticket, without a yes the ticket stays open, and
without the role the action is not offered. It is installed from a clone of the repository, not from NuGet:
dotnet new install templates/mentoragent-blazor, thendotnet new mentoragent-blazor -n Helpdesk.
Changed
-
An
IMentorRagSourcethat answers with no list (null) fails at once, with a message that says a source finding
nothing returns an empty list, instead of aNullReferenceExceptionlater on. The turn goes on without documents,
as before. -
Novior.MentorAgent.Declarative: a
kind: functionentry that names no action of the session — unknown to the
application, or off for the session — is taken away from the agent, with a warning, and the agent loads without it.
The Agent Framework used to give the agent a function of that name with nothing behind it: the model could call it,
nothing ran, and the agent stopped without a word in the log. An action that is off is therefore described to no
model through a definition that names it either. -
Every package is now checked at pack time against the public API of 1.2.0, so 1.3.0 cannot break code built
against 1.2.0 by accident. An intended break is listed in this file as Breaking. -
The family moves onto the Microsoft Agent Framework 1.27.0 line (
Microsoft.Agents.AIand
Microsoft.Agents.AI.Workflows1.27.0). Novior.MentorAgent.Declarative:Microsoft.Agents.AI.Declarativemoves
to 1.27.0-rc1, on the same line. The two breaking changes of Agent Framework 1.27 are in parts MentorAgent does not
use:AddFoundryResponsesfor hosted Foundry applications, and the redirect callbacks ofDefaultHttpRequestHandler. -
The packages' description and tags say what MentorAgent is: AI agents in a .NET application under your rules —
roles, confirmations, cost control. The tags ofNovior.MentorAgent,.Serverand.Blazornow include
ai-governance,governance,authorization,roles,approvalandhuman-in-the-loop; thementortag, which
found only namesakes, is gone from all five packages.
Fixed
-
When a restored conversation the service no longer knew was replaced by a new one (BUG-110), the knowledge-base
search kept the recent messages of the conversation that was gone: the first search of the new one was given the
message of the turn that had failed. They are now cleared, as on a reset or a restore. -
The turn's vector of the message (
IMentorQueryEmbedding) was kept on its text alone (BUG-229): a knowledge-base
source that embeds the message with a model of its own was handed the vector ofMentorOptions.EmbeddingGenerator,
or routing, tool filtering and memory were handed the source's, whichever asked first in the turn. Each generator
now gets the vector it makes, still computed once per turn, compared by reference; at most four generators are
remembered, the oldest dropped first, so one built for every call cannot make the cache grow.
Security
- Novior.MentorAgent.Server: BUG-230 (S1):
GET /mentor/chatran a turn for any request the browser sent with the
sign-in cookie, and with cookie authentication (SameSite=Lax) that includes a link clicked on another site: one
link ran a turn as the signed-in user, with the link author's question, even withRequireAuthorization. Both
GETandPOST /mentor/chatnow answer403before the turn when the browser's Fetch Metadata says another site
started the request (Sec-Fetch-Sitecross-siteorsame-site,Sec-Fetch-Modenotcors), and log a warning
chat.cross-site-refusedwithout the question. Your own page's calls, clients without Fetch Metadata andcors
requests work as before. With aSameSite=Nonecookie, or for browsers that send no Fetch Metadata, add a check
of your own: the Server README says how.