diff --git a/.claude/rules/voice-components.md b/.claude/rules/voice-components.md
index 96c136b6..ab92c6cb 100644
--- a/.claude/rules/voice-components.md
+++ b/.claude/rules/voice-components.md
@@ -35,6 +35,7 @@ flowchart TB
Loop["VoiceConversationLoop"]
UTH["UserTurnHandler"]
Act["VoiceActivity"]
+ PhoneCall["Calls/PhoneCall G1, CallStarted, LineSpoken, end, CallEnded"]
end
subgraph Speech["Speech control"]
@@ -47,6 +48,7 @@ flowchart TB
Hear["ReplyHearing what caller heard"]
Fill["ToolFillerScope → FillerScheduler"]
Met["TurnMetrics"]
+ VN["VoiceNotices state → VoiceStateChanged"]
end
subgraph Engine["AgentCore.Application"]
@@ -60,16 +62,21 @@ flowchart TB
Conn --> Pump & Sender & Loop
Pump --> Reader --> In
In -->|"ConversationInput: Started / Utterance / Barge"| Loop
- Loop -->|GetOrOpen / Close| Sessions --> CS
+ Loop -->|Admit / Start / End / Close| PhoneCall -->|GetOrOpen / Close| Sessions --> CS
+ PhoneCall -.->|"replaced: a newer socket took the call id over"| Loop -.->|"cancel, close as replaced"| Conn
Loop -->|interim / final| UTH
Loop -->|Barge| Act
Loop -->|Barge: StopAsync| Out
+ Act -->|each reply's ReplyHearing| Loop
+ Loop -->|"line chain: HeardAsync → LineSpoken"| PhoneCall
UTH -->|TryGenerateReply| Act
UTH --> VS
Act --> Stream
Act --> Pipe
Act -->|schedule| Sched
VS --> Sched
+ VS -->|AgentStateChanged / UserStateChanged| VN
+ VN -->|Hooks.Raise| CS
VS -->|Say| Say
Sched -->|authorize| Handle
Pipe --> Handle
@@ -79,14 +86,14 @@ flowchart TB
Say --> Fwd
Pipe --> Hear -->|TurnCut| Stream
Pipe --> Fill -->|Say| VS
- Pipe --> Met
+ Pipe --> Met -->|TurnLatency notice| CS
Out --> Sender -->|JSON frames| Caller
Conn --> Obs
class Conn,Pump,Reader,In,Out,Sender,Obs vendor
class MapCall,Boot port
- class Loop,UTH,Act turn
- class VS,Sched,Handle,Pipe,Say,Fwd,Hear,Fill,Met speech
+ class Loop,UTH,Act,PhoneCall turn
+ class VS,Sched,Handle,Pipe,Say,Fwd,Hear,Fill,Met,VN speech
class Stream,Sessions,CS engine
```
@@ -105,16 +112,18 @@ sequenceDiagram
T->>P: setup frame
P->>L: Started(conversationId)
- L->>L: GetOrOpen session, make VoiceActivity + UserTurnHandler
+ L->>L: PhoneCall.AdmitAsync (G1) + StartAsync, make VoiceActivity + UserTurnHandler
T->>P: interim transcript
P->>L: Utterance(IsFinal=false)
L->>U: OnInterimTranscript → user Speaking
T->>P: final transcript
P->>L: Utterance(IsFinal=true)
+ L->>L: queue the caller line behind any held agent line
L->>U: OnFinalTranscript
- U->>A: InterruptByFinalTranscript (cut old speech)
+ U->>A: InterruptByFinalTranscript (cut old speech; earlier replies' barge windows wait HeardTextWait, then close)
U->>A: TryGenerateReply(text)
A->>E: Start engine turn
+ A->>L: reply's ReplyHearing → held agent line queued
A->>S: ScheduleSpeech(handle)
S->>R: authorize speech
R->>R: wait for user silence
@@ -128,5 +137,6 @@ sequenceDiagram
end
end
R->>S: speech done → agent Listening, away timer armed
+ Note over L,R: The held agent line is raised (PhoneCall.HeardAsync, the reply's turn) with ReplyHearing.HeardText once the speech is done and its barge window closed: a barge report for it; the next final prompt, once the report that may follow it had its HeardTextWait (a later report changes nothing, and is logged); or DrainAsync, at once (an interrupted reply settled its cut already, so its window closes when its speech is done)
Note over T,O: Barge-in: Telnyx sends Barge → Loop calls Output.StopAsync and VoiceActivity.InterruptByAudioActivity → ReplyHearing cuts the engine turn to what was heard
```
diff --git a/config/example.yaml b/config/example.yaml
index 5882b621..cab7f2fa 100644
--- a/config/example.yaml
+++ b/config/example.yaml
@@ -21,7 +21,7 @@
# 5. tools what an agent may call
# 6. agents models, prompts, knowledge
# 7. entries what a caller reaches: a stage machine or one agent
-# 8. providers adapters: llm, conversation, speech, telephony, moderation, embeddings, knowledge,
+# 8. providers adapters: llm, conversation, speech, moderation, embeddings, knowledge,
# telemetry
# 9. evaluation the judge that scores finished turns
@@ -360,16 +360,38 @@ providers:
- { kind: openai, model: gpt-4.1, as: judge } # evaluation only, chosen on judgement
- { kind: openai, model: gpt-4.1-nano, as: cheap, webSearch: false }
- # Three voice planes. They are not duplicates.
+ # Two voice planes. They are not duplicates.
# conversation the media pipe. It carries the audio and owns the route /v1/{entry}/call.
# speech recognition (stt) and synthesis (tts), one role at a time.
- # telephony call control: dial, transfer, hang up. It must outlive any one media leg,
- # because a warm transfer creates a second leg.
conversation: { kind: telnyx-relay }
+ # The call route only answers a caller that proves it is the vendor. Telnyx signs no relay socket, so its
+ # TeXML URL carries a shared key: wss:///v1//call?key=. Without the
+ # TELNYX_RELAY_KEY secret the host does not start.
+ # The GPT-Live phone path instead. It speaks for itself, so it needs no speech: block. AgentCore adds
+ # no prompt text of its own: GPT-Live hears only what this block says, plus the host's per-call brief.
+ # Without a "never guess, delegate" paragraph GPT-Live made up product facts in 4 of 6 test calls,
+ # and it cannot hang up, so tell it to pass an end request to the backend. OpenAI signs every call's
+ # webhook, and the route checks it with the OPENAI_WEBHOOK_SECRET secret.
+ # conversation:
+ # kind: openai-live
+ # live:
+ # instructions: | # required: GPT-Live's whole prompt
+ # You are the phone voice of the backend. Be brief.
+ # You know no facts yourself. For any question that needs a fact, delegate to the backend and
+ # wait for its answer. Never guess a fact.
+ # You cannot end the call yourself. When the caller wants to end or hang up the call, do not
+ # say goodbye. Delegate it to the backend right away, then speak only the backend's answer.
+ # greeting: "Greet the caller as your instructions say, then pause and listen."
+ # # optional: without it GPT-Live waits for the caller to speak
+ # voice: marin # optional, default marin
+ # model: gpt-live-1 # optional, default gpt-live-1
+ # transferSeconds: 45 # optional, default 45. OpenAI never says if a transfer worked: the
+ # # SIP peer hangs up the AI leg once the line took the call. A call still
+ # # up after this long counts as a failed transfer and is hung up. Set it
+ # # longer than the peer rings the target.
speech:
stt: { kind: telnyx-relay } # bundled with the relay, so it matches conversation
tts: { kind: telnyx-relay } # bundled with the relay, so it matches conversation
- telephony: { kind: telnyx }
# Reads what the CALLER said, before the model runs. A flagged turn never reaches the model and
# speaks refusalReply instead. Reads the openai-api-key secret, the same one llm reads.
diff --git a/src/AgentCore.Application/Audit/AuditConversationObserver.cs b/src/AgentCore.Application/Audit/AuditConversationObserver.cs
deleted file mode 100644
index ec9dc335..00000000
--- a/src/AgentCore.Application/Audit/AuditConversationObserver.cs
+++ /dev/null
@@ -1,59 +0,0 @@
-using AgentCore.Application.Ports;
-using AgentCore.Application.Runtime;
-using AgentCore.Domain.Audit;
-
-namespace AgentCore.Application.Audit
-{
- ///
- /// Turns the durable facts of a call into the append-only chain of D23.
- ///
- internal sealed class AuditConversationObserver : IConversationObserver
- {
- private readonly IAuditSinkPort _sink;
-
- /// Creates the observer that writes the chain.
- /// Where the rows go.
- /// is .
- public AuditConversationObserver(IAuditSinkPort sink)
- {
- ArgumentNullException.ThrowIfNull(sink);
-
- _sink = sink;
- }
-
- ///
- public ValueTask OnConversationEventAsync(ConversationEvent conversationEvent, CancellationToken cancellationToken)
- {
- ArgumentNullException.ThrowIfNull(conversationEvent);
-
- // A diagnostic-only fact took no identity, so there is no row to write and nothing to report.
- if (conversationEvent.EventId is not { } eventId)
- {
- return ValueTask.CompletedTask;
- }
-
- // The two enums are not interchangeable, and a value outside the closed set is dropped rather
- // than thrown over: the turn loop pairs an identity with a stored kind, so this is unreachable
- // for any event the session raises, and audit is a record of the conversation and never a part of it.
- if (!ConversationEventKinds.TryGetAuditKind(conversationEvent.Kind, out AuditEventKind kind))
- {
- return ValueTask.CompletedTask;
- }
-
- AuditEvent auditEvent = new()
- {
- ConversationId = conversationEvent.ConversationId,
- EventId = eventId,
- Kind = kind,
- OccurredAt = conversationEvent.OccurredAt,
- TurnIndex = conversationEvent.TurnIndex,
- AmendsEventId = conversationEvent.AmendsEventId,
- Payload = conversationEvent.Payload,
- };
-
- // The token of the run belongs to the dispatcher, which passes CancellationToken.None: the
- // enqueue belongs to the record of the conversation, not to a turn the caller may have cancelled.
- return _sink.AppendAsync(auditEvent, cancellationToken);
- }
- }
-}
diff --git a/src/AgentCore.Application/Audit/QueuedAuditSink.cs b/src/AgentCore.Application/Audit/QueuedAuditSink.cs
index 49a692d9..50dbf514 100644
--- a/src/AgentCore.Application/Audit/QueuedAuditSink.cs
+++ b/src/AgentCore.Application/Audit/QueuedAuditSink.cs
@@ -15,7 +15,7 @@ namespace AgentCore.Application.Audit
///
public sealed class QueuedAuditSink : IAuditSinkPort, IAsyncDisposable, IDisposable
{
- /// The number of events the queue holds before it starts dropping.
+ /// The number of events the queue holds before an append waits.
public const int DefaultCapacity = 10_000;
/// The most events the writer hands the store in one conversation.
@@ -60,7 +60,7 @@ public sealed class QueuedAuditSink : IAuditSinkPort, IAsyncDisposable, IDisposa
/// . The library never throws for want of one.
///
///
- /// The number of events the queue holds before it drops, or .
+ /// The number of events the queue holds before an append waits, or .
///
///
/// The most events one call to the store carries, or .
@@ -101,26 +101,32 @@ public QueuedAuditSink(
_writer = Task.Run(WriteAsync, CancellationToken.None);
}
+ ///
+ /// Gets or sets what waits for before it waits for the queue: the delivery of every
+ /// notice already raised to the audit hook. The ASP.NET boot sets it.
+ ///
+ internal Func? Upstream { get; set; }
+
///
- public ValueTask AppendAsync(AuditEvent auditEvent, CancellationToken cancellationToken = default)
+ public async ValueTask AppendAsync(AuditEvent auditEvent, CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(auditEvent);
// Counted before the write, so FlushAsync never reads zero while an event is in the channel.
_ = Interlocked.Increment(ref _pending);
- if (_queue.Writer.TryWrite(auditEvent))
+ try
{
- return ValueTask.CompletedTask;
+ // A full queue makes any caller wait. Behind the audit hook the caller is the hook's own reader, off
+ // the turn; code that calls this directly waits on its own thread until the writer makes room.
+ await _queue.Writer.WriteAsync(auditEvent, cancellationToken).ConfigureAwait(false);
+ }
+ catch (Exception exception) when (exception is ChannelClosedException or OperationCanceledException)
+ {
+ // The sink is disposed, or the caller gave up: nothing will take the row, so it is reported.
+ _ = Interlocked.Decrement(ref _pending);
+ Report(auditEvent);
}
-
- // Either the queue is full or the sink is disposed. Both are a drop, and both are reported:
- // the caller is a turn loop, and a turn is never told that its record was lost.
- _ = Interlocked.Decrement(ref _pending);
-
- Report(auditEvent);
-
- return ValueTask.CompletedTask;
}
/// Waits until every accepted event has been handed to the store.
@@ -128,6 +134,11 @@ public ValueTask AppendAsync(AuditEvent auditEvent, CancellationToken cancellati
/// A task that completes when nothing is left in the queue.
public async ValueTask FlushAsync(CancellationToken cancellationToken = default)
{
+ if (Upstream is { } upstream)
+ {
+ await upstream().WaitAsync(cancellationToken).ConfigureAwait(false);
+ }
+
while (Volatile.Read(ref _pending) > 0)
{
cancellationToken.ThrowIfCancellationRequested();
@@ -249,7 +260,7 @@ await _retry.ExecuteAsync(
}
/// Writes the one line a dropped event is worth, and never throws.
- /// The event the queue had no room for.
+ /// The event nothing will take: the queue was closed, or the caller gave up.
private void Report(AuditEvent auditEvent)
{
try
diff --git a/src/AgentCore.Application/Configuration/Compilation/AgentApproval.cs b/src/AgentCore.Application/Configuration/Compilation/AgentApproval.cs
index b3e31849..196f03f4 100644
--- a/src/AgentCore.Application/Configuration/Compilation/AgentApproval.cs
+++ b/src/AgentCore.Application/Configuration/Compilation/AgentApproval.cs
@@ -1,4 +1,7 @@
using AgentCore.Application.Configuration.Schema;
+using AgentCore.Application.Hooks;
+using AgentCore.Application.Hooks.Engine;
+using AgentCore.Application.Hooks.Layers;
using AgentCore.Application.Runtime.Harness;
using Microsoft.Agents.AI;
using Microsoft.Extensions.AI;
@@ -47,45 +50,71 @@ public static bool Matches(string pattern, string toolName)
/// The agents.defaults section, or .
/// The agent being compiled.
/// The tools the agent offers the model, if any.
+ /// The hooks of the compile.
///
- /// The pending queue and the standing-rule keys when the agent may surface an approval —
- /// through auto: , which adds the approval layer, or through an approval-required tool —
- /// else empty. An absent key is never kept, so both ride together.
+ /// The pending queue, the standing-rule and the held-answer keys when the agent may surface an approval —
+ /// through auto: , which adds the approval layer, through an approval-required tool, or through a
+ /// BeforeRun hook, which can add one at run time — else empty. An absent key is never kept, so all three ride together.
///
public static IReadOnlyList StateKeysFor(
- AgentDefaults? defaults, AgentConfiguration item, IEnumerable? tools)
+ AgentDefaults? defaults, AgentConfiguration item, IEnumerable? tools, HookRuntime hooks)
{
ArgumentNullException.ThrowIfNull(item);
+ ArgumentNullException.ThrowIfNull(hooks);
- return Compose(defaults, item).Count == 0
- && tools?.OfType().Any() != true
+ return !MayAsk(defaults, item, tools, hooks)
? []
- : [PendingApprovalQueue.PendingStateKey, PendingApprovalQueue.StandingStateKey];
+ : [PendingApprovalQueue.PendingStateKey, PendingApprovalQueue.StandingStateKey, PendingApprovalQueue.HeldStateKey];
}
///
- /// Bakes one agent's auto: into its pipeline as UseToolApproval rules. No
- /// patterns, no layer — the document pays nothing for approval it never declares.
+ /// Bakes one agent's auto: and the BeforeToolApproval hooks into its pipeline as one UseToolApproval rule,
+ /// with the deny layer outside it when a hook overrides BeforeToolApproval, directly outside
+ /// it, and innermost whenever the agent can ask. An agent that can ask nothing gets no layer — the document pays nothing for
+ /// approval it never declares.
///
/// The compiled agent.
/// The agents.defaults section, or .
/// The agent being compiled.
+ /// The tools the agent offers the model, if any.
+ /// The hooks of the compile.
/// The approval-wrapped agent, or unchanged.
- public static AIAgent Apply(AIAgent agent, AgentDefaults? defaults, AgentConfiguration item)
+ public static AIAgent Apply(
+ AIAgent agent, AgentDefaults? defaults, AgentConfiguration item, IEnumerable? tools, HookRuntime hooks)
{
ArgumentNullException.ThrowIfNull(agent);
ArgumentNullException.ThrowIfNull(item);
+ ArgumentNullException.ThrowIfNull(hooks);
+
+ IReadOnlyList auto = Compose(defaults, item);
+ bool gated = hooks.Gates.Overrides(GatePoint.BeforeToolApproval);
+ if (!gated && !MayAsk(defaults, item, tools, hooks))
+ {
+ return agent;
+ }
+
+ AIAgent held = ApprovalAnswerHold.Use(new AIAgentBuilder(agent)).Build();
+ if (auto.Count == 0 && !gated)
+ {
+ return held;
+ }
+
+ AIAgentBuilder builder = new(held);
+ if (gated)
+ {
+ builder = ApprovalGateLayer.UseDenials(builder);
+ }
- return Compose(defaults, item) is not { Count: > 0 } auto
- ? agent
- : new AIAgentBuilder(agent)
- .UseToolApproval(new ToolApprovalAgentOptions { AutoApprovalRules = [.. auto.Select(ToRule)] })
+ return ApprovalQueueDrain.Use(builder)
+ .UseToolApproval(new ToolApprovalAgentOptions { AutoApprovalRules = [ApprovalGateLayer.Rule(auto, hooks)] })
.Build();
}
- private static Func> ToRule(string pattern)
+ private static bool MayAsk(AgentDefaults? defaults, AgentConfiguration item, IEnumerable? tools, HookRuntime hooks)
{
- return context => ValueTask.FromResult(Matches(pattern, context.FunctionCallContent.Name));
+ return Compose(defaults, item).Count > 0
+ || tools?.OfType().Any() == true
+ || hooks.Gates.Overrides(GatePoint.BeforeRun);
}
}
}
diff --git a/src/AgentCore.Application/Configuration/Compilation/AgentCompilationContext.cs b/src/AgentCore.Application/Configuration/Compilation/AgentCompilationContext.cs
index 162c068c..e636c0ae 100644
--- a/src/AgentCore.Application/Configuration/Compilation/AgentCompilationContext.cs
+++ b/src/AgentCore.Application/Configuration/Compilation/AgentCompilationContext.cs
@@ -1,6 +1,7 @@
using AgentCore.Application.Runtime.Compaction;
using AgentCore.Application.Configuration.Validation;
using AgentCore.Application.Evaluation;
+using AgentCore.Application.Hooks;
using AgentCore.Application.Knowledge;
using AgentCore.Application.Ports;
using AgentCore.Application.Secrets;
@@ -46,7 +47,7 @@ public AgentCompilationContext(IChatClientFactory chatClients)
public PromptModerator? Moderation { get; init; }
///
- /// Gets or sets the backing store of store 1, or for memory.
+ /// Gets or sets the backing store of the conversation history, or for memory.
///
public IConversationStore? ConversationStore { get; init; }
@@ -72,8 +73,9 @@ public AgentCompilationContext(IChatClientFactory chatClients)
public ILoggerFactory? Loggers { get; init; }
///
- /// Gets or sets the clock a clock: agent reads the date from, or
- /// for .
+ /// Gets or sets the compile's clock, or for . A
+ /// clock: agent reads the date from it, and the hooks' gate deadlines, notice timeouts and idle
+ /// eviction run on its timers.
///
public TimeProvider? Clock { get; init; }
@@ -89,6 +91,13 @@ public AgentCompilationContext(IChatClientFactory chatClients)
///
public ResolvedSecrets? Secrets { get; init; }
+ ///
+ /// Gets or sets the hooks every compiled agent runs, AgentCore's built-ins first, or
+ /// for none. Gates compile into the agents from this list; a hook passed only to a
+ /// hears notices but runs no gate.
+ ///
+ public IReadOnlyList? Hooks { get; init; }
+
///
/// Gets or sets the compaction stages every agent runs.
///
diff --git a/src/AgentCore.Application/Configuration/Compilation/AgentContextProviderCompiler.cs b/src/AgentCore.Application/Configuration/Compilation/AgentContextProviderCompiler.cs
index 987f6315..cbfcd0f9 100644
--- a/src/AgentCore.Application/Configuration/Compilation/AgentContextProviderCompiler.cs
+++ b/src/AgentCore.Application/Configuration/Compilation/AgentContextProviderCompiler.cs
@@ -1,5 +1,7 @@
using AgentCore.Application.Configuration.Parsing;
using AgentCore.Application.Configuration.Schema;
+using AgentCore.Application.Hooks;
+using AgentCore.Application.Hooks.Layers;
using AgentCore.Application.Knowledge;
using AgentCore.Application.Ports;
using AgentCore.Application.Runtime.Compaction;
@@ -24,7 +26,7 @@ internal static class AgentContextProviderCompiler
/// The compile-time seams.
/// This agent's JSON pointer.
/// Resolves an agents.items id to its compiled agent, or when undeclared.
- /// Store 1, or on a row whose session carries no history and so has nothing to summarise.
+ /// The conversation history, or on a row whose session carries no history and so has nothing to summarise.
public static List Build(
AgentsWalk walk,
AgentConfiguration item,
@@ -61,10 +63,15 @@ public static List Build(
providers.Add(new ReaderContentFilterProvider());
CompactionStages stages = context.Compaction ?? CompactionStrategyFactory.Create(context.ChatClients.GetChatClient(model), contextWindow);
- providers.AddRange(CompactionStrategyFactory.BuildProviders(stages, history));
+ providers.AddRange(CompactionStrategyFactory.BuildProviders(stages, history, walk.Hooks));
AgentHarnessProviders.Add(providers, defaults, item, context, pointer, resolve, walk.Background);
+ if (walk.Hooks.Gates.Overrides(GatePoint.BeforeRun))
+ {
+ providers.Add(new RunGateProvider(walk.Hooks));
+ }
+
if (AgentKnowledge.Compose(defaults, item) is not { } composed)
{
return providers;
diff --git a/src/AgentCore.Application/Configuration/Compilation/AgentDelegationTool.cs b/src/AgentCore.Application/Configuration/Compilation/AgentDelegationTool.cs
index b381ce9c..3a570f8a 100644
--- a/src/AgentCore.Application/Configuration/Compilation/AgentDelegationTool.cs
+++ b/src/AgentCore.Application/Configuration/Compilation/AgentDelegationTool.cs
@@ -1,10 +1,11 @@
using System.Text.Json;
using System.Text.Json.Nodes;
using AgentCore.Application.Configuration.Schema;
-using AgentCore.Application.Runtime;
using AgentCore.Application.Tools;
using Microsoft.Agents.AI;
using Microsoft.Extensions.AI;
+using AgentCore.Application.Runtime.Agents;
+using AgentCore.Application.Runtime.Turn;
namespace AgentCore.Application.Configuration.Compilation
{
diff --git a/src/AgentCore.Application/Configuration/Compilation/AgentHarnessProviders.cs b/src/AgentCore.Application/Configuration/Compilation/AgentHarnessProviders.cs
index 34f89702..e34f4292 100644
--- a/src/AgentCore.Application/Configuration/Compilation/AgentHarnessProviders.cs
+++ b/src/AgentCore.Application/Configuration/Compilation/AgentHarnessProviders.cs
@@ -1,8 +1,12 @@
using AgentCore.Application.Configuration.Parsing;
using AgentCore.Application.Configuration.Schema;
+using AgentCore.Application.Hooks;
+using AgentCore.Application.Hooks.Engine;
+using AgentCore.Application.Hooks.Layers;
using AgentCore.Application.Runtime.Harness;
using AgentCore.Application.Runtime.Turn;
using Microsoft.Agents.AI;
+using Microsoft.Extensions.AI;
namespace AgentCore.Application.Configuration.Compilation
{
@@ -119,22 +123,33 @@ public static void Add(
#pragma warning disable MAAI001 // The loop family is evaluation-only in Microsoft.Agents.AI 1.21.0.
///
- /// Wraps one compiled agent in its loop: block: a over the
- /// until: evaluators in document order, capped at maxRounds: . No block, no wrap.
+ /// Wraps one compiled agent in its loop: block: a over the until:
+ /// evaluators in document order, then the AfterRun hook evaluator, capped at maxRounds: (the framework's
+ /// default of 10 when the block names none, and 3 with no loop: ). No block and no AfterRun hook, no wrap. The
+ /// loop's own messages never reach the caller.
///
/// The compiled agent.
/// The agents.defaults section, or .
/// The agent being compiled.
/// This agent's JSON pointer, for a loop: failure.
+ /// The compiled hooks; AfterRun is added when one overrides .
/// The loop-wrapped agent, or unchanged.
- public static AIAgent ApplyLoop(AIAgent agent, AgentDefaults? defaults, AgentConfiguration item, string pointer)
+ public static AIAgent ApplyLoop(AIAgent agent, AgentDefaults? defaults, AgentConfiguration item, string pointer, HookRuntime hooks)
{
ArgumentNullException.ThrowIfNull(agent);
ArgumentNullException.ThrowIfNull(item);
+ ArgumentNullException.ThrowIfNull(hooks);
+
+ bool gated = hooks.Gates.Overrides(GatePoint.AfterRun);
if (item.Loop is not { } loop)
{
- return agent;
+ return gated
+ ? new LoopAgent(
+ agent,
+ [RunEndEvaluator(hooks)],
+ new LoopAgentOptions { MaxIterations = RunEndGateChain.DefaultMaxIterations, ExcludeOnBehalfOfMessages = true })
+ : agent;
}
if (loop.Until is not { Count: > 0 })
@@ -185,8 +200,28 @@ public static AIAgent ApplyLoop(AIAgent agent, AgentDefaults? defaults, AgentCon
}
}
- // Every iteration's text reaches the stream; there are no per-iteration entries (decision 14).
- return new LoopAgent(agent, evaluators, new LoopAgentOptions { MaxIterations = loop.MaxRounds });
+ if (gated)
+ {
+ evaluators.Add(RunEndEvaluator(hooks));
+ }
+
+ return new LoopAgent(agent, evaluators, new LoopAgentOptions { MaxIterations = loop.MaxRounds, ExcludeOnBehalfOfMessages = true });
+ }
+
+ // Last in the list: MAF acts on the first evaluator that asks to continue, so the document's until: decides
+ // first. Only ContinueWithMessages sends the hook's message verbatim.
+ private static DelegateLoopEvaluator RunEndEvaluator(HookRuntime hooks)
+ {
+ return new DelegateLoopEvaluator(async (context, cancellationToken) =>
+ await RunEndGateChain.ContinueAsync(
+ hooks,
+ TurnInvocation.From(context.RunOptions),
+ context.Agent.Name,
+ context.LastResponse.Text,
+ context.Iteration,
+ cancellationToken).ConfigureAwait(false) is { } message
+ ? LoopEvaluation.ContinueWithMessages([new ChatMessage(ChatRole.User, message)])
+ : LoopEvaluation.Stop());
}
#pragma warning restore MAAI001
@@ -195,7 +230,7 @@ public static AIAgent ApplyLoop(AIAgent agent, AgentDefaults? defaults, AgentCon
///
/// The state keys of the harness providers among — never the
- /// history provider's key, which is the transcript and belongs to store 1.
+ /// history provider's key, which is the transcript and belongs to the conversation history.
///
internal static IReadOnlySet StateKeysOf(IEnumerable providers)
{
diff --git a/src/AgentCore.Application/Configuration/Compilation/AgentsWalk.cs b/src/AgentCore.Application/Configuration/Compilation/AgentsWalk.cs
index c6018469..1c7b4466 100644
--- a/src/AgentCore.Application/Configuration/Compilation/AgentsWalk.cs
+++ b/src/AgentCore.Application/Configuration/Compilation/AgentsWalk.cs
@@ -1,4 +1,5 @@
using AgentCore.Application.Configuration.Schema;
+using AgentCore.Application.Hooks.Engine;
using Microsoft.Agents.AI;
namespace AgentCore.Application.Configuration.Compilation
@@ -6,13 +7,15 @@ namespace AgentCore.Application.Configuration.Compilation
#pragma warning disable MAAI001 // BackgroundAgentsProvider is evaluation-only in Microsoft.Agents.AI 1.21.0.
/// What one walk over agents.items shares between every agent it compiles.
/// The agents.defaults section, or .
- /// The document's ambiguity wiring (§8), built once rather than re-derived per agent.
+ /// The document's ambiguity wiring, built once rather than re-derived per agent.
/// The providers.knowledge.scope block, or .
/// Collects the built background providers, so the conversation can release their sessions when it ends.
+ /// The hooks of this compile.
internal sealed record AgentsWalk(
AgentDefaults? Defaults,
ResolvedClarification Clarification,
KnowledgeScopeConfiguration? Scope,
- ICollection Background);
+ ICollection Background,
+ HookRuntime Hooks);
#pragma warning restore MAAI001
}
diff --git a/src/AgentCore.Application/Configuration/Compilation/CompileTableRow.cs b/src/AgentCore.Application/Configuration/Compilation/CompileTableRow.cs
index 389c2ade..8e74feab 100644
--- a/src/AgentCore.Application/Configuration/Compilation/CompileTableRow.cs
+++ b/src/AgentCore.Application/Configuration/Compilation/CompileTableRow.cs
@@ -12,7 +12,7 @@ internal abstract class CompileTableRow
/// Gets the this row compiles.
internal abstract CompiledAgentShape Shape { get; }
- /// Gets whether the row answers its runs out of store 1 on its own session.
+ /// Gets whether the row answers its runs out of the conversation history on its own session.
internal virtual bool SessionCarriesHistory => false;
/// Builds the entry agent of one entry, and the stage table when the row has one.
@@ -25,7 +25,7 @@ internal abstract class CompileTableRow
///
/// when a conversation on this entry keeps one MAF session across turns
/// (!SessionCarriesHistory && HarnessStateKeys.Count > 0 , the same test
- /// runs). Only the graph rows read it.
+ /// runs). Only the graph rows read it.
///
/// The agent a turn runs, and the agent id each policy.stages entry names.
/// The entry does not compile through this row.
diff --git a/src/AgentCore.Application/Configuration/Compilation/CompiledAgent.cs b/src/AgentCore.Application/Configuration/Compilation/CompiledAgent.cs
index 1e5dd8b8..dd0e5485 100644
--- a/src/AgentCore.Application/Configuration/Compilation/CompiledAgent.cs
+++ b/src/AgentCore.Application/Configuration/Compilation/CompiledAgent.cs
@@ -1,5 +1,6 @@
using AgentCore.Application.Configuration.Schema;
using AgentCore.Application.Configuration.Validation;
+using AgentCore.Application.Hooks.Engine;
using AgentCore.Application.Policy;
using AgentCore.Application.Ports;
using AgentCore.Application.Transcript;
@@ -46,7 +47,7 @@ internal CompiledAgent(CompiledAgentBuilder builder)
}
///
- /// Gets whether the row answers its runs out of store 1 on its own session, rather than the
+ /// Gets whether the row answers its runs out of the conversation history on its own session, rather than the
/// request messages.
///
internal bool SessionCarriesHistory => _row.SessionCarriesHistory;
@@ -83,10 +84,13 @@ internal CompiledAgent(CompiledAgentBuilder builder)
internal IReadOnlySet? OutputAgents => _layers.OutputAgents;
///
- /// Gets store 1 of every conversation this agent answers.
+ /// Gets the conversation history of every conversation this agent answers.
///
internal AgentCoreChatHistoryProvider History => _document.History;
+ /// Gets the hooks this entry was compiled with.
+ internal HookRuntime Hooks => _document.Hooks;
+
/// Gets the store this agent's conversations and every word of them are kept in.
internal IConversationStore ConversationStore => _document.Conversations;
diff --git a/src/AgentCore.Application/Configuration/Compilation/CompiledDocument.cs b/src/AgentCore.Application/Configuration/Compilation/CompiledDocument.cs
index 2f6144e6..168214c2 100644
--- a/src/AgentCore.Application/Configuration/Compilation/CompiledDocument.cs
+++ b/src/AgentCore.Application/Configuration/Compilation/CompiledDocument.cs
@@ -1,4 +1,5 @@
using AgentCore.Application.Configuration.Schema;
+using AgentCore.Application.Hooks.Engine;
using AgentCore.Application.Ports;
using AgentCore.Application.Transcript;
@@ -7,9 +8,11 @@ namespace AgentCore.Application.Configuration.Compilation
/// What every entry of one document shares once compiled.
/// The loaded document.
/// The store every conversation and every word of it is kept in.
- /// Store 1, over .
+ /// The conversation history, over .
+ /// The hooks of this compile, shared by every entry.
internal sealed record CompiledDocument(
AgentCoreConfiguration Configuration,
IConversationStore Conversations,
- AgentCoreChatHistoryProvider History);
+ AgentCoreChatHistoryProvider History,
+ HookRuntime Hooks);
}
diff --git a/src/AgentCore.Application/Configuration/Compilation/ConfigurationCompiler.cs b/src/AgentCore.Application/Configuration/Compilation/ConfigurationCompiler.cs
index e28bfac0..45f8bb48 100644
--- a/src/AgentCore.Application/Configuration/Compilation/ConfigurationCompiler.cs
+++ b/src/AgentCore.Application/Configuration/Compilation/ConfigurationCompiler.cs
@@ -1,12 +1,14 @@
using AgentCore.Application.Configuration.Parsing;
using AgentCore.Application.Configuration.Schema;
using AgentCore.Application.Conversation.Memory;
+using AgentCore.Application.Hooks.Engine;
+using AgentCore.Application.Hooks.Layers;
using AgentCore.Application.Llm;
using AgentCore.Application.Ports;
-using AgentCore.Application.Runtime;
using AgentCore.Application.Transcript;
using Microsoft.Agents.AI;
using Microsoft.Extensions.AI;
+using AgentCore.Application.Runtime.ToolCalls;
namespace AgentCore.Application.Configuration.Compilation
{
///
@@ -76,7 +78,7 @@ private static SingleAgentRow SelectSingleAgentRow(string agentId, string agentP
/// Compiles one document into one agent per entry.
/// The loaded document.
/// The seams the document names.
- /// The compiled agents, keyed by entry name. Each is a process singleton: see T44.
+ /// The compiled agents, keyed by entry name. Each is a process singleton.
/// An entry does not compile.
public static IReadOnlyDictionary CompileAll(
AgentCoreConfiguration configuration,
@@ -86,7 +88,8 @@ public static IReadOnlyDictionary CompileAll(
ArgumentNullException.ThrowIfNull(context);
IConversationStore conversations = context.ConversationStore ?? new InMemoryConversationStore();
- CompiledDocument document = new(configuration, conversations, new AgentCoreChatHistoryProvider(conversations));
+ HookRuntime hooks = HookRuntime.Create(context.Hooks ?? [], context.Loggers, context.Clock);
+ CompiledDocument document = new(configuration, conversations, new AgentCoreChatHistoryProvider(conversations), hooks);
Dictionary built = new(StringComparer.Ordinal);
Dictionary compiled = new(StringComparer.Ordinal);
@@ -100,7 +103,7 @@ public static IReadOnlyDictionary CompileAll(
string key = shape is CompiledAgentShape.SingleAgent or CompiledAgentShape.Policy ? "session" : shape.ToString();
if (!built.TryGetValue(key, out CompiledAgentSet? shared))
{
- shared = BuildAgents(configuration, context, row.SessionCarriesHistory ? document.History : null);
+ shared = BuildAgents(configuration, context, row.SessionCarriesHistory ? document.History : null, document.Hooks);
built[key] = shared;
}
@@ -112,7 +115,8 @@ public static IReadOnlyDictionary CompileAll(
entry.RefusalReply ?? configuration.RefusalReply,
context.Moderation,
row.SpokenAuthors(configuration, entry),
- document.History);
+ document.History,
+ document.Hooks);
compiled[entryName] = new CompiledAgentBuilder
{
@@ -133,12 +137,14 @@ public static IReadOnlyDictionary CompileAll(
/// Builds one ChatClientAgent for each agents.items entry.
/// The loaded document.
/// The seams the document names.
- /// Store 1, or to leave the framework default in place.
+ /// The conversation history, or to leave the framework default in place.
+ /// The hooks of this compile.
/// The agents keyed by id, with what every one of them declared.
private static CompiledAgentSet BuildAgents(
AgentCoreConfiguration configuration,
AgentCompilationContext context,
- AgentCoreChatHistoryProvider? history)
+ AgentCoreChatHistoryProvider? history,
+ HookRuntime hooks)
{
Dictionary agents = new(StringComparer.Ordinal);
HashSet harnessStateKeys = new(StringComparer.Ordinal);
@@ -172,7 +178,8 @@ private static CompiledAgentSet BuildAgents(
section.Defaults,
ResolvedClarification.From(configuration),
configuration.Providers?.Knowledge?.Scope,
- backgroundProviders);
+ backgroundProviders,
+ hooks);
foreach (AgentConfiguration item in section.Items)
{
@@ -212,10 +219,10 @@ private static CompiledAgentSet BuildAgents(
List? compiledTools = AgentToolCompiler.Build(
item, item.Model ?? section.Defaults?.Model, tools, context, pointer, Resolve, backgroundProviders);
- harnessStateKeys.UnionWith(AgentApproval.StateKeysFor(section.Defaults, item, compiledTools));
+ harnessStateKeys.UnionWith(AgentApproval.StateKeysFor(section.Defaults, item, compiledTools, hooks));
ChatClientAgent built = new(
- WithToolFailureAuditing(context.ChatClients.GetChatClient(item.Model ?? section.Defaults?.Model)),
+ WithToolFailureAuditing(context.ChatClients.GetChatClient(item.Model ?? section.Defaults?.Model), hooks),
new ChatClientAgentOptions
{
Id = GraphExecutorId(item.Id),
@@ -232,12 +239,12 @@ private static CompiledAgentSet BuildAgents(
path.RemoveAt(path.Count - 1);
- AIAgent instrumented = new AIAgentBuilder(built)
+ AIAgent instrumented = new AIAgentBuilder(ToolHookMiddleware.Apply(built, hooks))
.UseOpenTelemetry(configure: static agent => agent.EnableSensitiveData = false)
.Build();
- AIAgent approved = AgentApproval.Apply(instrumented, section.Defaults, item);
- AIAgent looped = AgentHarnessProviders.ApplyLoop(approved, section.Defaults, item, pointer);
+ AIAgent approved = AgentApproval.Apply(instrumented, section.Defaults, item, compiledTools, hooks);
+ AIAgent looped = AgentHarnessProviders.ApplyLoop(approved, section.Defaults, item, pointer, hooks);
agents[id] = looped;
return looped;
@@ -253,13 +260,14 @@ private static string GraphExecutorId(string id)
return Convert.ToHexStringLower(System.Text.Encoding.UTF8.GetBytes(id));
}
- /// Puts the auditing function-invocation loop into the pipeline of one agent.
- private static AuditingFunctionInvokingChatClient WithToolFailureAuditing(IChatClient model)
+ /// Puts the auditing function-invocation loop and the model gates into the pipeline of one agent.
+ private static AuditingFunctionInvokingChatClient WithToolFailureAuditing(IChatClient model, HookRuntime hooks)
{
return new(model.AsBuilder()
.UseOpenTelemetry(configure: static client => client.EnableSensitiveData = false)
.ConfigureOptions(ConversationRequestStamp.Apply)
.Use(static innerClient => new ModelFacingChatClient(innerClient))
+ .Use(innerClient => new ModelHookChatClient(innerClient, hooks))
.Build());
}
@@ -292,7 +300,6 @@ internal static ConfigurationLoadException Fail(string pointer, string message)
Pointer = pointer,
Message = message,
- // The compile table is a shape rule over the whole document, like check 1.
Check = ConfigurationCheck.DocumentSchema,
});
}
diff --git a/src/AgentCore.Application/Configuration/Compilation/ExplicitGraphRow.cs b/src/AgentCore.Application/Configuration/Compilation/ExplicitGraphRow.cs
index 3af4aad6..dc67ce10 100644
--- a/src/AgentCore.Application/Configuration/Compilation/ExplicitGraphRow.cs
+++ b/src/AgentCore.Application/Configuration/Compilation/ExplicitGraphRow.cs
@@ -1,9 +1,10 @@
using AgentCore.Application.Configuration.Parsing;
using AgentCore.Application.Configuration.Schema;
using AgentCore.Application.Configuration.Validation;
-using AgentCore.Application.Runtime;
using Microsoft.Agents.AI;
using Microsoft.Agents.AI.Workflows;
+using AgentCore.Application.Runtime.Agents;
+using AgentCore.Application.Runtime.Agents.Graph;
namespace AgentCore.Application.Configuration.Compilation
{
@@ -119,7 +120,7 @@ private static AIAgentBinding BindNode(
throw ConfigurationCompiler.FailBackgroundInGraph(ConfigurationError.AppendPointer(pointer, "agent"), agentId, entryName);
}
- return new AIAgentBinding(agent, new AIAgentHostOptions { EmitAgentUpdateEvents = true });
+ return new AIAgentBinding(new GraphParticipantAgent(agent), new AIAgentHostOptions { EmitAgentUpdateEvents = true });
}
/// Adds every edge, with a gate in front of each guarded one.
@@ -193,7 +194,6 @@ private static FunctionExecutor Gate(int index, GuardReference guard, IG
true);
}
- /// An explicit graph answers from its output: true nodes.
internal override HashSet? SpokenAuthors(AgentCoreConfiguration configuration, EntryConfiguration entry)
{
HashSet outputs = new(StringComparer.Ordinal);
diff --git a/src/AgentCore.Application/Configuration/Compilation/PatternGraphRow.cs b/src/AgentCore.Application/Configuration/Compilation/PatternGraphRow.cs
index b4d645c3..aa01e22a 100644
--- a/src/AgentCore.Application/Configuration/Compilation/PatternGraphRow.cs
+++ b/src/AgentCore.Application/Configuration/Compilation/PatternGraphRow.cs
@@ -1,8 +1,8 @@
using AgentCore.Application.Configuration.Parsing;
using AgentCore.Application.Configuration.Schema;
-using AgentCore.Application.Runtime;
using Microsoft.Agents.AI;
using Microsoft.Agents.AI.Workflows;
+using AgentCore.Application.Runtime.Agents.Graph;
namespace AgentCore.Application.Configuration.Compilation
{
@@ -46,7 +46,7 @@ internal override EntryBuild BuildEntry(
throw ConfigurationCompiler.FailBackgroundInGraph(ConfigurationError.AppendPointer(agentsPointer, index), id, entryName);
}
- participants.Add(agent);
+ participants.Add(new GraphParticipantAgent(agent));
}
if (participants.Count == 0)
@@ -66,11 +66,6 @@ internal override EntryBuild BuildEntry(
return new EntryBuild(new GraphFaultAgent(workflow.AsAIAgent(name: entryName), drain: reusesGraphSession), NoStages());
}
- ///
- /// A sequential graph answers from its last participant. Concurrent aggregates every
- /// participant, and handoff and group chat both end wherever the conversation took them, so on
- /// those patterns any participant may legitimately speak last and no filter applies.
- ///
internal override HashSet? SpokenAuthors(AgentCoreConfiguration configuration, EntryConfiguration entry)
{
return entry.Graph is { Pattern: GraphPattern.Sequential, Agents.Count: > 0 } graph
diff --git a/src/AgentCore.Application/Configuration/Compilation/TurnLayers.cs b/src/AgentCore.Application/Configuration/Compilation/TurnLayers.cs
index 050ed800..e336bf93 100644
--- a/src/AgentCore.Application/Configuration/Compilation/TurnLayers.cs
+++ b/src/AgentCore.Application/Configuration/Compilation/TurnLayers.cs
@@ -1,24 +1,30 @@
using AgentCore.Application.Evaluation;
-using AgentCore.Application.Runtime;
+using AgentCore.Application.Hooks;
+using AgentCore.Application.Hooks.Engine;
+using AgentCore.Application.Hooks.Layers;
using AgentCore.Application.Transcript;
using Microsoft.Agents.AI;
+using AgentCore.Application.Runtime.Agents;
+using AgentCore.Application.Runtime.Turn.Lifecycle;
namespace AgentCore.Application.Configuration.Compilation
{
- /// The turn layers one entry puts on every agent a turn runs: the two disposition layers, and the seal outside them.
+ /// The turn layers one entry puts on every agent a turn runs: the turn gate, moderation and the fallback, and the seal outside them.
/// The resolved line the caller hears when a turn fails.
/// The resolved line the caller hears when the agent refuses to answer.
/// The endpoint seam, or to moderate nothing.
/// The agents whose reply the caller hears, or for all.
- /// Store 1, which the seal commits every turn to.
+ /// The conversation history, which the seal commits every turn to.
+ /// The hooks of this compile; the turn gate sits between the seal and moderation when a hook overrides it.
internal sealed record TurnLayers(
string FallbackReply,
string RefusalReply,
PromptModerator? Moderation,
IReadOnlySet? OutputAgents,
- AgentCoreChatHistoryProvider History)
+ AgentCoreChatHistoryProvider History,
+ HookRuntime Hooks)
{
- /// Puts the layers on one agent, outermost first: the seal, then moderation, then the fallback.
+ /// Puts the layers on one agent, outermost first: the seal, the turn gate, moderation, then the fallback.
/// The compiled agent of one entry, or of one policy: stage.
/// The agent the turn loop runs.
public AIAgent Apply(AIAgent agent)
@@ -26,6 +32,11 @@ public AIAgent Apply(AIAgent agent)
AIAgentBuilder builder = new AIAgentBuilder(agent)
.Use(inner => new ConversationTurnAgent(inner, History));
+ if (Hooks.Gates.Overrides(GatePoint.BeforeTurn))
+ {
+ builder = builder.Use(inner => new TurnGateAgent(inner, Hooks, RefusalReply));
+ }
+
if (Moderation is not null)
{
builder = builder.Use(inner => new ModerationAgent(inner, Moderation, RefusalReply, ModerationAgent.DefaultTimeout));
diff --git a/src/AgentCore.Application/Configuration/Schema/ConversationProviderConfiguration.cs b/src/AgentCore.Application/Configuration/Schema/ConversationProviderConfiguration.cs
index 93b6e8cf..6328d0b6 100644
--- a/src/AgentCore.Application/Configuration/Schema/ConversationProviderConfiguration.cs
+++ b/src/AgentCore.Application/Configuration/Schema/ConversationProviderConfiguration.cs
@@ -21,6 +21,19 @@ public sealed record ConversationProviderConfiguration
/// Gets the largest inbound frame the socket accepts, in bytes, or null for the adapter's default.
public int? MaxFrameBytes { get; init; }
+ ///
+ /// Gets how long the call gate (BeforeCall) gives each hook to accept or reject an offered call, in seconds, or null
+ /// for 5.
+ ///
+ public int? AnswerSeconds { get; init; }
+
+ ///
+ /// Gets the live: block: settings that only the vendor names reads and checks. Undefined
+ /// when the document writes none.
+ ///
+ [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingDefault)]
+ public JsonElement Live { get; init; }
+
///
/// Gets the caller-away prompt a voice adapter opens: { timeoutSeconds, say } . Voice conversations only.
///
diff --git a/src/AgentCore.Application/Configuration/Schema/ProvidersConfiguration.cs b/src/AgentCore.Application/Configuration/Schema/ProvidersConfiguration.cs
index 07d318d8..77dd78f1 100644
--- a/src/AgentCore.Application/Configuration/Schema/ProvidersConfiguration.cs
+++ b/src/AgentCore.Application/Configuration/Schema/ProvidersConfiguration.cs
@@ -6,12 +6,6 @@ namespace AgentCore.Application.Configuration.Schema
///
/// How a card's payload is named in the collection this deployment reads.
///
- ///
- /// AgentCore ships no field names. Every role starts unmapped, and stays unmapped until this
- /// document names the payload path that fills it. A collection built by any ingester is therefore
- /// expressible here, and no ingester's naming is privileged. An unmapped role is simply absent from
- /// the card: it is never guessed, and never silently read off a name AgentCore chose.
- ///
public sealed record KnowledgeFieldsConfiguration
{
///
@@ -22,11 +16,9 @@ public sealed record KnowledgeFieldsConfiguration
public string? Body { get; init; }
/// Gets the field holding the card id, or null when the collection carries none.
- /// The point's own key stands in for an unmapped id.
public string? Id { get; init; }
/// Gets the full-text-indexed field the required-term leg matches on.
- /// Unmapped, the search ranks by vector alone and the analyzer is never consulted.
public string? Lexical { get; init; }
/// Gets the field holding the citation's source label.
@@ -40,11 +32,6 @@ public sealed record KnowledgeFieldsConfiguration
}
/// How an open KnowledgeScope 's facet keys become payload paths.
- ///
- /// There is no default template, because a template is a claim about where one particular
- /// collection keeps its facets. A deployment whose agents never scope needs none; one whose agents
- /// do must say where to look.
- ///
public sealed record KnowledgeScopeConfiguration
{
///
@@ -83,11 +70,6 @@ public sealed record KnowledgeFilterableFacetConfiguration
///
/// The lookup that turns what the person said into the one value a facet is stored under.
///
- ///
- /// A vocabulary of thousands cannot sit in a prompt. The model instead searches a small index — the
- /// cards under one other facet — and copies the value off the card it finds. This block is printed
- /// on the search tool, not run by the host: the model does the lookup itself.
- ///
public sealed record KnowledgeFacetResolveConfiguration
{
/// Gets the facet and value that narrow the lookup search to the index cards.
@@ -111,10 +93,6 @@ public sealed record KnowledgeFacetValueConfiguration
}
/// Which facets a card may opt out of, and the value it opts out with.
- ///
- /// Named per facet, never global. A wildcard reaching an isolation facet such as a customer id
- /// would serve one mis-tagged card to every caller, which is the failure the scope exists to stop.
- ///
public sealed record KnowledgeWildcardConfiguration
{
/// Gets the payload value that satisfies any scope on a named facet.
@@ -138,17 +116,9 @@ public enum KnowledgeLinkLookup
}
/// How a card's links to other cards are read and followed.
- ///
- /// The whole block is opt-in: a document without links: never expands. On a configured
- /// block, a collection carrying nothing at is still inert.
- ///
public sealed record KnowledgeLinksConfiguration
{
/// The uuid5 namespace used when the document names none.
- ///
- /// This is RFC 4122's own URL namespace, not any ingester's choice. uuid5 is undefined
- /// without a namespace, so unlike a field name it cannot be left unset.
- ///
public const string DefaultNamespace = "url";
///
@@ -226,7 +196,6 @@ public sealed record KnowledgeProviderConfiguration
///
/// Gets the IKnowledgeCitationFormatter name that writes each card's source label.
///
- /// Read only by agents that declare knowledge: { citations: true } .
public string Citation { get; init; } = DefaultCitation;
///
@@ -246,7 +215,7 @@ public sealed record KnowledgeProviderConfiguration
///
/// How the knowledge search asks the caller which machine they meant, when the scope narrows to more
- /// than one and the search itself came back empty. See section 4 of the ambiguity design.
+ /// than one and the search itself came back empty.
///
public sealed record KnowledgeAmbiguityConfiguration
{
@@ -293,7 +262,7 @@ public sealed record TelemetryProviderConfiguration
///
public const int DefaultExportIntervalMilliseconds = 60_000;
- /// The lowest interval T61 allows, in milliseconds.
+ /// The lowest export interval allowed, in milliseconds.
public const int MinimumExportIntervalMilliseconds = 60_000;
/// The extra ActivitySource names listened to when the document lists none.
@@ -319,7 +288,7 @@ public sealed record TelemetryProviderConfiguration
/// Gets the service.name every exported signal carries.
public string ServiceName { get; init; } = DefaultServiceName;
- /// Gets how often metrics are sent, in milliseconds, never below the T61 floor.
+ /// Gets how often metrics are sent, in milliseconds, never below the minimum.
[JsonPropertyName("exportIntervalMs")]
public int ExportIntervalMilliseconds
{
@@ -348,9 +317,6 @@ public sealed record ProvidersConfiguration
/// Gets the two speech roles, or .
public SpeechProviderConfiguration? Speech { get; init; }
- /// Gets the telephony provider, or .
- public VendorProviderConfiguration? Telephony { get; init; }
-
/// Gets the moderation provider, or .
public VendorProviderConfiguration? Moderation { get; init; }
diff --git a/src/AgentCore.Application/Configuration/Schema/VendorProviderConfiguration.cs b/src/AgentCore.Application/Configuration/Schema/VendorProviderConfiguration.cs
index aed0475b..cd3e7480 100644
--- a/src/AgentCore.Application/Configuration/Schema/VendorProviderConfiguration.cs
+++ b/src/AgentCore.Application/Configuration/Schema/VendorProviderConfiguration.cs
@@ -1,7 +1,7 @@
namespace AgentCore.Application.Configuration.Schema
{
///
- /// A provider that names one vendor and nothing else, such as speech or telephony.
+ /// A provider that names one vendor and nothing else, such as audit or conversations.
///
public sealed record VendorProviderConfiguration
{
diff --git a/src/AgentCore.Application/Configuration/Schema/agentcore-v1.schema.json b/src/AgentCore.Application/Configuration/Schema/agentcore-v1.schema.json
index 7184c264..550425f0 100644
--- a/src/AgentCore.Application/Configuration/Schema/agentcore-v1.schema.json
+++ b/src/AgentCore.Application/Configuration/Schema/agentcore-v1.schema.json
@@ -1190,8 +1190,7 @@
"providers": {
"type": "object",
"required": [
- "conversation",
- "speech"
+ "conversation"
],
"additionalProperties": false,
"properties": {
@@ -1208,9 +1207,6 @@
"speech": {
"$ref": "#/$defs/speechProvider"
},
- "telephony": {
- "$ref": "#/$defs/vendorProvider"
- },
"moderation": {
"$ref": "#/$defs/vendorProvider",
"description": "The adapter that reads what the caller said through a moderation endpoint, such as openai. A document that names none moderates nothing."
@@ -1338,7 +1334,7 @@
},
"conversationProvider": {
"type": "object",
- "description": "The media plane: who carries the conversation and owns its inbound route. Not providers.speech, which is recognition and synthesis, and not providers.telephony, which is conversation control.",
+ "description": "The media plane: who carries the conversation and owns its inbound route. Not providers.speech, which is recognition and synthesis.",
"required": [
"kind"
],
@@ -1366,6 +1362,8 @@
"maximum": 2147483647,
"description": "The largest inbound frame the socket accepts. The ceiling is int.MaxValue, which is the largest buffer length the socket can be given."
},
+ "answerSeconds": { "type": "integer", "minimum": 1, "maximum": 60, "description": "How long the call gate gives each hook to accept or reject an offered call, in seconds. Omit it for 5." },
+ "live": { "type": "object", "description": "The openai-live vendor's own settings: instructions, greeting, voice, model, transferSeconds. Only that adapter reads and checks them." },
"userAway": {
"type": ["object", "null"],
"description": "When the caller counts as away, and what the agent says once they do. Voice conversations only. Omit the key to keep the shipped default (15 s, \"Are you still there?\"); write null to turn the away prompt off.",
diff --git a/src/AgentCore.Application/Conversation/Actions/CallTransfer.cs b/src/AgentCore.Application/Conversation/Actions/CallTransfer.cs
new file mode 100644
index 00000000..00c9f180
--- /dev/null
+++ b/src/AgentCore.Application/Conversation/Actions/CallTransfer.cs
@@ -0,0 +1,18 @@
+namespace AgentCore.Application.Conversation.Actions
+{
+ /// One transfer an carries out.
+ public sealed record CallTransfer
+ {
+ /// Gets the conversation the call carries.
+ public required string ConversationId { get; init; }
+
+ /// Gets the line, as the named it.
+ public required Uri Target { get; init; }
+
+ ///
+ /// Gets the call's own transport headers, such as SIP headers, by name, case-insensitive. A host finds its phone
+ /// system's id for the call here when the system that placed the call put it in.
+ ///
+ public required IReadOnlyDictionary Headers { get; init; }
+ }
+}
diff --git a/src/AgentCore.Application/Conversation/Actions/CallTransferOutcome.cs b/src/AgentCore.Application/Conversation/Actions/CallTransferOutcome.cs
new file mode 100644
index 00000000..3491d318
--- /dev/null
+++ b/src/AgentCore.Application/Conversation/Actions/CallTransferOutcome.cs
@@ -0,0 +1,12 @@
+namespace AgentCore.Application.Conversation.Actions
+{
+ /// How an went. A channel reads every value it does not know as .
+ public enum CallTransferOutcome
+ {
+ /// The line did not take the call; the caller is still on this call.
+ NotTaken = 0,
+
+ /// The line took the call; this side hangs its leg up.
+ Taken = 1,
+ }
+}
diff --git a/src/AgentCore.Application/Conversation/Actions/ConversationAction.cs b/src/AgentCore.Application/Conversation/Actions/ConversationAction.cs
new file mode 100644
index 00000000..4a92ab37
--- /dev/null
+++ b/src/AgentCore.Application/Conversation/Actions/ConversationAction.cs
@@ -0,0 +1,13 @@
+namespace AgentCore.Application.Conversation.Actions
+{
+ ///
+ /// The base of every action a conversation can be asked for. Its constructor is closed to other assemblies: every
+ /// channel must know what each action means, and an action a host made up would reach channels that cannot read it.
+ ///
+ public abstract record ConversationAction
+ {
+ private protected ConversationAction()
+ {
+ }
+ }
+}
diff --git a/src/AgentCore.Application/Conversation/Actions/ConversationActionResult.cs b/src/AgentCore.Application/Conversation/Actions/ConversationActionResult.cs
new file mode 100644
index 00000000..c2739a33
--- /dev/null
+++ b/src/AgentCore.Application/Conversation/Actions/ConversationActionResult.cs
@@ -0,0 +1,18 @@
+namespace AgentCore.Application.Conversation.Actions
+{
+ ///
+ /// The answer to an action request. It is a value and not an exception, so the tool can turn a refusal into words
+ /// for the model; AgentCore writes no text for the model.
+ ///
+ public enum ConversationActionResult
+ {
+ /// Accepted. It runs after the current answer was delivered, so a caller hears the answer first.
+ Scheduled = 0,
+
+ /// The channel has no way to do it, such as a web chat asked to transfer a call. The tool picks the fallback.
+ NotSupported = 1,
+
+ /// Refused: an end was already asked for, and a later action would contradict it.
+ Ending = 2,
+ }
+}
diff --git a/src/AgentCore.Application/Conversation/Actions/EndConversationAction.cs b/src/AgentCore.Application/Conversation/Actions/EndConversationAction.cs
new file mode 100644
index 00000000..4e354bdd
--- /dev/null
+++ b/src/AgentCore.Application/Conversation/Actions/EndConversationAction.cs
@@ -0,0 +1,11 @@
+using AgentCore.Domain.Audit;
+
+namespace AgentCore.Application.Conversation.Actions
+{
+ ///
+ /// The same end as ConversationSession.EndConversation , as an action, so a tool ends its conversation through
+ /// the one door it uses for every other action.
+ ///
+ /// Stored as the conversation.ended token, so it becomes part of the audit record.
+ public sealed record EndConversationAction(ConversationEndReason Reason) : ConversationAction;
+}
diff --git a/src/AgentCore.Application/Conversation/Actions/ICallTransfer.cs b/src/AgentCore.Application/Conversation/Actions/ICallTransfer.cs
new file mode 100644
index 00000000..56a4c45f
--- /dev/null
+++ b/src/AgentCore.Application/Conversation/Actions/ICallTransfer.cs
@@ -0,0 +1,17 @@
+namespace AgentCore.Application.Conversation.Actions
+{
+ ///
+ /// A host that moves a call to another line itself, such as through its phone system's API. A phone channel that can
+ /// transfer (GPT-Live today) hands a registered one each in place of its own transfer,
+ /// and takes its answer as the outcome: the vendor's own transfer may report none (OpenAI's SIP refer, probe T1).
+ /// Register it as a singleton: a call outlives the request that started it.
+ ///
+ public interface ICallTransfer
+ {
+ /// Moves the call. It runs once the caller heard the answer that asked for it.
+ /// The call and its target.
+ /// Cancels the move.
+ /// Whether the line took the call.
+ Task TransferAsync(CallTransfer transfer, CancellationToken cancellationToken);
+ }
+}
diff --git a/src/AgentCore.Application/Conversation/Actions/IConversationControl.cs b/src/AgentCore.Application/Conversation/Actions/IConversationControl.cs
new file mode 100644
index 00000000..75966561
--- /dev/null
+++ b/src/AgentCore.Application/Conversation/Actions/IConversationControl.cs
@@ -0,0 +1,14 @@
+namespace AgentCore.Application.Conversation.Actions
+{
+ ///
+ /// One door for every action, so a new action needs no new method here and no new parameter on a tool.
+ ///
+ public interface IConversationControl
+ {
+ /// Returns at once and runs nothing inline, because an action must wait until the current answer was delivered.
+ /// One of the actions AgentCore defines.
+ /// Whether the action will run, and if not, why, so the tool can tell the model.
+ /// is .
+ ConversationActionResult Request(ConversationAction action);
+ }
+}
diff --git a/src/AgentCore.Application/Conversation/Actions/TransferAction.cs b/src/AgentCore.Application/Conversation/Actions/TransferAction.cs
new file mode 100644
index 00000000..f7f47563
--- /dev/null
+++ b/src/AgentCore.Application/Conversation/Actions/TransferAction.cs
@@ -0,0 +1,35 @@
+namespace AgentCore.Application.Conversation.Actions
+{
+ ///
+ /// Hands a phone call to another line. It waits until the caller heard the current answer, because a transfer that
+ /// cuts the reply loses what the caller was just told.
+ ///
+ public sealed record TransferAction : ConversationAction
+ {
+ /// Checks the target here, so a bad target fails in the tool that made it and not later on the call.
+ /// A tel: , sip: or sips: URI, the forms a SIP REFER can carry.
+ /// is .
+ /// is not an absolute tel, sip or sips URI.
+ public TransferAction(Uri target)
+ {
+ ArgumentNullException.ThrowIfNull(target);
+ if (!target.IsAbsoluteUri || target.Scheme is not ("tel" or "sip" or "sips"))
+ {
+ throw new ArgumentException($"A transfer target must be a tel:, sip: or sips: URI, not '{target}'.", nameof(target));
+ }
+
+ Target = target;
+ }
+
+ /// Gets the target. It is a URI and not a phone number, so a PSTN number and a SIP endpoint both fit.
+ public Uri Target { get; }
+
+ ///
+ /// Gets the instruction the voice follows when the line did not take the call, after which the call goes on. The
+ /// host writes it, because AgentCore writes no text for the model. Without it a failed transfer hangs up. A voice
+ /// may keep it for the rest of the call (GPT-Live appends it to its session instructions, as it does the
+ /// greeting), so word it as one past event, such as "the transfer you just started did not go through".
+ ///
+ public string? IfFailed { get; init; }
+ }
+}
diff --git a/src/AgentCore.Application/Conversation/ConversationRecord.cs b/src/AgentCore.Application/Conversation/ConversationRecord.cs
index 6893f146..3edcea38 100644
--- a/src/AgentCore.Application/Conversation/ConversationRecord.cs
+++ b/src/AgentCore.Application/Conversation/ConversationRecord.cs
@@ -2,7 +2,7 @@
namespace AgentCore.Application.Conversation
{
- /// One conversation, apart from its words. It is one row of store 0.
+ /// One conversation, apart from its words. It is one row of the conversation store.
/// The conversation this describes. It is the join to stores 1 and 3.
/// What to show in a list, or until one is made.
/// Whether the conversation is still listed as usual.
@@ -10,8 +10,8 @@ namespace AgentCore.Application.Conversation
/// A consumer's own fields, or .
/// When the conversation row was made. UTC.
///
- /// When store 1 last wrote a word of this conversation, or when it holds none. UTC.
- /// Derived at read time; store 0 keeps no such column.
+ /// When the message store last wrote a word of this conversation, or when it holds none. UTC.
+ /// Derived at read time; the conversation store keeps no such column.
///
///
/// What the session of this conversation held, or for a conversation that has run no turn
diff --git a/src/AgentCore.Application/Conversation/ConversationStoreFactory.cs b/src/AgentCore.Application/Conversation/ConversationStoreFactory.cs
index 54fa0385..9a5de7e4 100644
--- a/src/AgentCore.Application/Conversation/ConversationStoreFactory.cs
+++ b/src/AgentCore.Application/Conversation/ConversationStoreFactory.cs
@@ -7,7 +7,7 @@
namespace AgentCore.Application.Conversation
{
///
- /// Opens the store 0 backing the document names, from the adapters the host registered.
+ /// Opens the conversation store the document names, from the adapters the host registered.
///
public static class ConversationStoreFactory
{
diff --git a/src/AgentCore.Application/Conversation/Conversations.cs b/src/AgentCore.Application/Conversation/Conversations.cs
index 5d999f22..a04ec4ae 100644
--- a/src/AgentCore.Application/Conversation/Conversations.cs
+++ b/src/AgentCore.Application/Conversation/Conversations.cs
@@ -174,6 +174,12 @@ public ValueTask> AppendAsync(
return Store.AppendAsync(conversationId, messages, state, cancellationToken);
}
+ ///
+ public ValueTask SaveStateAsync(string conversationId, ConversationSessionState state, CancellationToken cancellationToken = default)
+ {
+ return Store.SaveStateAsync(conversationId, state, cancellationToken);
+ }
+
///
public ValueTask AppendMessageAsync(string conversationId, ChatMessage message, CancellationToken cancellationToken = default)
{
diff --git a/src/AgentCore.Application/Conversation/Memory/InMemoryConversationStates.cs b/src/AgentCore.Application/Conversation/Memory/InMemoryConversationStates.cs
new file mode 100644
index 00000000..2e7cd43f
--- /dev/null
+++ b/src/AgentCore.Application/Conversation/Memory/InMemoryConversationStates.cs
@@ -0,0 +1,38 @@
+namespace AgentCore.Application.Conversation.Memory
+{
+ ///
+ /// The resume state of each conversation, kept beside its row rather than on it. A state never replaces a newer
+ /// one. The store's lock guards every call.
+ ///
+ internal sealed class InMemoryConversationStates
+ {
+ private readonly Dictionary _states = new(StringComparer.Ordinal);
+
+ /// Reads a conversation's state, or when none is stored.
+ internal ConversationSessionState? Of(string conversationId)
+ {
+ return _states.GetValueOrDefault(conversationId);
+ }
+
+ /// Reads the turn a conversation takes next, by its stored state.
+ internal int NextTurnIndex(string conversationId)
+ {
+ return Of(conversationId)?.NextTurnIndex ?? 0;
+ }
+
+ /// Keeps a state, unless it is or the stored one names a later next turn.
+ internal void Save(string conversationId, ConversationSessionState? state)
+ {
+ if (state is not null && state.NextTurnIndex >= NextTurnIndex(conversationId))
+ {
+ _states[conversationId] = state;
+ }
+ }
+
+ /// Drops a conversation's state.
+ internal void Forget(string conversationId)
+ {
+ _ = _states.Remove(conversationId);
+ }
+ }
+}
diff --git a/src/AgentCore.Application/Conversation/Memory/InMemoryConversationStore.cs b/src/AgentCore.Application/Conversation/Memory/InMemoryConversationStore.cs
index 47e40d91..186ed584 100644
--- a/src/AgentCore.Application/Conversation/Memory/InMemoryConversationStore.cs
+++ b/src/AgentCore.Application/Conversation/Memory/InMemoryConversationStore.cs
@@ -6,7 +6,6 @@
namespace AgentCore.Application.Conversation.Memory
{
/// The store backing that keeps every row in this process: a conversation, and its words.
- /// Creates the store.
/// Where created_at comes from, or for the system clock.
public sealed class InMemoryConversationStore(TimeProvider? timeProvider = null) : IConversationStore
{
@@ -16,8 +15,7 @@ public sealed class InMemoryConversationStore(TimeProvider? timeProvider = null)
private readonly InMemoryConversationWords _words = new();
- /// The resume blob of each conversation, beside the row rather than on it.
- private readonly Dictionary _state = [];
+ private readonly InMemoryConversationStates _states = new();
private readonly InMemoryConversationClaims _claims = new();
@@ -43,7 +41,7 @@ public ValueTask CreateAsync(string conversationId, Cancella
return ValueTask.FromResult(existing with
{
- State = _state.GetValueOrDefault(conversationId),
+ State = _states.Of(conversationId),
NextOrdinal = _words.NextOrdinal(conversationId),
});
}
@@ -62,7 +60,7 @@ public ValueTask CreateAsync(string conversationId, Cancella
? null
: conversation with
{
- State = _state.GetValueOrDefault(conversationId),
+ State = _states.Of(conversationId),
NextOrdinal = _words.NextOrdinal(conversationId),
});
}
@@ -130,12 +128,7 @@ public ValueTask DeleteAsync(string conversationId, CancellationToken cancellati
{
ArgumentNullException.ThrowIfNull(conversationId);
- lock (_lock)
- {
- Forget(conversationId);
- }
-
- return default;
+ return UnderLock(() => Forget(conversationId));
}
///
@@ -175,12 +168,7 @@ public ValueTask AttachPrincipalAsync(
ArgumentNullException.ThrowIfNull(principalKey);
ArgumentNullException.ThrowIfNull(role);
- lock (_lock)
- {
- _claims.Attach(conversationId, principalKey);
- }
-
- return default;
+ return UnderLock(() => _claims.Attach(conversationId, principalKey));
}
///
@@ -190,12 +178,7 @@ public ValueTask DetachPrincipalAsync(
ArgumentNullException.ThrowIfNull(conversationId);
ArgumentNullException.ThrowIfNull(principalKey);
- lock (_lock)
- {
- _claims.Detach(conversationId, principalKey);
- }
-
- return default;
+ return UnderLock(() => _claims.Detach(conversationId, principalKey));
}
///
@@ -217,10 +200,10 @@ public ValueTask> AppendAsync(
{
if (!_conversations.TryGetValue(conversationId, out ConversationRecord? conversation))
{
- throw new InvalidOperationException($"Store 0 holds no conversation '{conversationId}' to append words to.");
+ throw new InvalidOperationException($"The conversation store holds no conversation '{conversationId}' to append words to.");
}
- int fallbackTurnIndex = _state.GetValueOrDefault(conversationId)?.NextTurnIndex ?? 0;
+ int fallbackTurnIndex = _states.NextTurnIndex(conversationId);
if (messages.Min(message => message.TurnIndex) is { } named && named < fallbackTurnIndex)
{
throw new ConversationTurnConflictException(
@@ -232,15 +215,26 @@ public ValueTask> AppendAsync(
DateTimeOffset now = _time.GetUtcNow();
_conversations[conversationId] = conversation with { LastMessageAt = now };
- if (state is not null && state.NextTurnIndex >= fallbackTurnIndex)
- {
- _state[conversationId] = state;
- }
+ _states.Save(conversationId, state);
return ValueTask.FromResult(rows);
}
}
+ ///
+ public ValueTask SaveStateAsync(
+ string conversationId, ConversationSessionState state, CancellationToken cancellationToken = default)
+ {
+ ArgumentNullException.ThrowIfNull(conversationId);
+ ArgumentNullException.ThrowIfNull(state);
+
+ return Amend(conversationId, conversation =>
+ {
+ _states.Save(conversationId, state);
+ return conversation;
+ });
+ }
+
///
public ValueTask RewriteAsync(
string conversationId, string messageId, ChatMessage content, CancellationToken cancellationToken = default)
@@ -249,12 +243,7 @@ public ValueTask RewriteAsync(
ArgumentException.ThrowIfNullOrEmpty(messageId);
ArgumentNullException.ThrowIfNull(content);
- lock (_lock)
- {
- _words.Rewrite(conversationId, messageId, content);
- }
-
- return default;
+ return UnderLock(() => _words.Rewrite(conversationId, messageId, content));
}
///
@@ -263,12 +252,7 @@ public ValueTask DeleteMessageAsync(string conversationId, string messageId, Can
ArgumentNullException.ThrowIfNull(conversationId);
ArgumentException.ThrowIfNullOrEmpty(messageId);
- lock (_lock)
- {
- _words.Delete(conversationId, messageId);
- }
-
- return default;
+ return UnderLock(() => _words.Delete(conversationId, messageId));
}
///
@@ -277,10 +261,7 @@ public ValueTask> ReadForSessionAsync(
{
ArgumentNullException.ThrowIfNull(conversationId);
- lock (_lock)
- {
- return ValueTask.FromResult(_words.ReadForSession(conversationId));
- }
+ return UnderLock(() => _words.ReadForSession(conversationId));
}
///
@@ -289,10 +270,7 @@ public ValueTask> ReadWindowAsync(
{
ArgumentNullException.ThrowIfNull(conversationId);
- lock (_lock)
- {
- return ValueTask.FromResult(_words.Read(conversationId, window));
- }
+ return UnderLock(() => _words.Read(conversationId, window));
}
///
@@ -302,10 +280,7 @@ public ValueTask> ReadWindowAsync(
ArgumentNullException.ThrowIfNull(conversationId);
ArgumentException.ThrowIfNullOrEmpty(messageId);
- lock (_lock)
- {
- return ValueTask.FromResult(_words.OrdinalOf(conversationId, messageId));
- }
+ return UnderLock(() => _words.OrdinalOf(conversationId, messageId));
}
///
@@ -314,10 +289,7 @@ public ValueTask TruncateAsync(
{
ArgumentNullException.ThrowIfNull(conversationId);
- lock (_lock)
- {
- return ValueTask.FromResult(_words.Truncate(conversationId, fromOrdinal));
- }
+ return UnderLock(() => _words.Truncate(conversationId, fromOrdinal));
}
///
@@ -325,10 +297,7 @@ public ValueTask EraseAsync(string conversationId, CancellationToken cancel
{
ArgumentNullException.ThrowIfNull(conversationId);
- lock (_lock)
- {
- return ValueTask.FromResult(_words.Erase(conversationId));
- }
+ return UnderLock(() => _words.Erase(conversationId));
}
///
@@ -337,12 +306,7 @@ public ValueTask SaveContinuationAsync(string responseId, string conversationId,
ArgumentNullException.ThrowIfNull(responseId);
ArgumentNullException.ThrowIfNull(conversationId);
- lock (_lock)
- {
- _continuations.Save(responseId, conversationId, _time.GetUtcNow());
- }
-
- return default;
+ return UnderLock(() => _continuations.Save(responseId, conversationId, _time.GetUtcNow()));
}
///
@@ -350,10 +314,7 @@ public ValueTask SaveContinuationAsync(string responseId, string conversationId,
{
ArgumentNullException.ThrowIfNull(responseId);
- lock (_lock)
- {
- return ValueTask.FromResult(_continuations.Find(responseId));
- }
+ return UnderLock(() => _continuations.Find(responseId));
}
///
@@ -361,12 +322,7 @@ public ValueTask DeleteContinuationAsync(string responseId, CancellationToken ca
{
ArgumentNullException.ThrowIfNull(responseId);
- lock (_lock)
- {
- _continuations.Forget(responseId);
- }
-
- return default;
+ return UnderLock(() => _continuations.Forget(responseId));
}
/// Drops a conversation, its claims, its state, its continuation and its words. Runs under the lock.
@@ -374,11 +330,29 @@ private void Forget(string conversationId)
{
_ = _conversations.Remove(conversationId);
_claims.Forget(conversationId);
- _ = _state.Remove(conversationId);
+ _states.Forget(conversationId);
_continuations.ForgetConversation(conversationId);
_words.Forget(conversationId);
}
+ private ValueTask UnderLock(Action work)
+ {
+ lock (_lock)
+ {
+ work();
+ }
+
+ return default;
+ }
+
+ private ValueTask UnderLock(Func read)
+ {
+ lock (_lock)
+ {
+ return ValueTask.FromResult(read());
+ }
+ }
+
private ValueTask Amend(string conversationId, Func amend)
{
lock (_lock)
diff --git a/src/AgentCore.Application/Diagnostics/Log.cs b/src/AgentCore.Application/Diagnostics/Log.cs
index 840cd3ba..4726278d 100644
--- a/src/AgentCore.Application/Diagnostics/Log.cs
+++ b/src/AgentCore.Application/Diagnostics/Log.cs
@@ -5,13 +5,12 @@
namespace AgentCore.Application.Diagnostics
{
///
- /// Every line the turn loop writes: extraction, moderation, the transcript, and knowledge retrieval. Three
- /// of them are the "log once" rows of section 8.7. The session owner's own lines — its workspace folder,
+ /// Every line the turn loop writes: extraction, moderation, the transcript, and knowledge retrieval. The session owner's own lines — its workspace folder,
/// and its close and idle-expiry routine — are .
///
internal static partial class Log
{
- /// Section 8.7, row two. The extractor returned an invalid object.
+ /// The extractor returned an invalid object.
/// The logger of the session.
/// The id of the conversation.
/// The zero-based index of the turn that just ran.
@@ -23,7 +22,7 @@ internal static partial class Log
+ "The slots stay unchanged and the conversation continues.")]
public static partial void ExtractionFailed(ILogger logger, string conversationId, int turnIndex, string reason);
- /// Section 8.7, row six. A tool failed four times in a row and the run threw.
+ /// A tool failed four times in a row and the run threw.
/// The logger of the session.
/// The id of the conversation.
/// The zero-based index of the turn that just ran.
@@ -36,7 +35,7 @@ internal static partial class Log
public static partial void ToolBudgetSpent(ILogger logger, string conversationId, int turnIndex, Exception exception);
///
- /// Section 8.7, row six, the other cause of it: a fault above the fallback layer, so no tool ever ran.
+ /// A fault above the fallback layer, so no tool ever ran.
///
/// The logger of the session.
/// The id of the conversation.
@@ -49,7 +48,7 @@ internal static partial class Log
+ "The turn spoke the fallback and the conversation continues.")]
public static partial void TurnRunFaulted(ILogger logger, string conversationId, int turnIndex, Exception exception);
- /// Section 8.7, last row. The run returned quietly with no text.
+ /// The run returned quietly with no text.
/// The logger of the session.
/// The id of the conversation.
/// The zero-based index of the turn that just ran.
@@ -60,7 +59,7 @@ internal static partial class Log
+ "The run reached 40 tool rounds, or the model answered nothing.")]
public static partial void EmptyReply(ILogger logger, string conversationId, int turnIndex);
- /// Section 8.7, row five. A guard threw at run time, or its rule did not parse.
+ /// A guard threw at run time, or its rule did not parse.
/// The logger of the composition root.
/// The guard name, or the rule text of an inline guard.
/// The cause.
@@ -70,7 +69,7 @@ internal static partial class Log
Message = "The guard {Guard} failed. It is treated as false and the conversation continues.")]
public static partial void GuardFailed(ILogger logger, string guard, Exception exception);
- /// An observer of the conversation refused an event, or faulted behind its own enqueue.
+ /// The audit store refused a row, or the queue behind it faulted.
/// The logger of the session.
/// The id of the conversation.
/// The wire token of the event kind.
@@ -106,18 +105,21 @@ internal static partial class Log
+ "The turn ran unchecked, because moderation fails open.")]
public static partial void ModerationUnavailable(ILogger logger, string conversationId, int turnIndex, string reason);
- /// The audit queue had no room, so the event was dropped.
+ ///
+ /// The audit queue did not take an event: the queue was closed, or the caller cancelled the append. A full queue
+ /// makes the caller wait instead.
+ ///
/// The logger of the queue.
/// The id of the conversation the dropped event belongs to.
/// The identity of the dropped event.
[LoggerMessage(
EventId = 8,
Level = LogLevel.Error,
- Message = "The audit queue was full, so event {EventId} of conversation {ConversationId} was dropped. "
- + "The conversation continues and the chain has a gap.")]
+ Message = "The audit queue did not take event {EventId} of conversation {ConversationId}: the queue was closed, "
+ + "or the append was cancelled. The conversation continues and the chain has a gap.")]
public static partial void AuditQueueFull(ILogger logger, string conversationId, Guid eventId);
- /// A store 1 write was refused, so the turn has no durable record.
+ /// A conversation history write was refused, so the turn has no durable record.
/// The logger of the session.
/// The id of the conversation.
/// The zero-based index of the turn that was being written.
@@ -141,7 +143,7 @@ internal static partial class Log
public static partial void KnowledgeRetrieved(
ILogger logger, string agent, int cardCount, KnowledgeAuditRecord.LogView record);
- /// A19. One knowledge retrieval threw, and this is the only place the cause survives.
+ /// One knowledge retrieval threw, and this is the only place the cause survives.
/// The logger of the knowledge provider.
/// The id of the agent that asked.
/// The loggable part of the retrieval, as a structured field.
@@ -196,7 +198,7 @@ public static partial void KnowledgeScopeComposed(
int turnIndex,
IReadOnlyDictionary origins);
- /// §8's probe answered: it dropped one facet, re-searched, and found this many candidates.
+ /// The probe answered: it dropped one facet, re-searched, and found this many candidates.
/// The logger of the knowledge provider.
/// The id of the agent that asked.
/// The facet the probe dropped.
@@ -208,7 +210,7 @@ public static partial void KnowledgeScopeComposed(
public static partial void KnowledgeProbeRan(ILogger logger, string agent, string facet, int candidateCount);
///
- /// §8 step 4's probe search threw or timed out. The turn was told the knowledge base holds nothing
+ /// The probe search threw or timed out. The turn was told the knowledge base holds nothing
/// rather than that it is unreachable, because the main search that already ran is what answers for
/// reachability; the probe is an extra question on top of it.
///
@@ -242,7 +244,7 @@ public static partial void KnowledgeScopeComposed(
+ "the tool from this agent.")]
public static partial void HostedToolDropped(ILogger logger, string agentId, string toolId, string marker, string model);
- /// Store 1 could not be read as a turn opened, so the turn ran on the words already held.
+ /// The conversation history could not be read as a turn opened, so the turn ran on the words already held.
/// The logger of the session.
/// The id of the conversation.
/// The zero-based index of the turn that was opening.
diff --git a/src/AgentCore.Application/Diagnostics/LoggingConversationObserver.cs b/src/AgentCore.Application/Diagnostics/LoggingConversationObserver.cs
deleted file mode 100644
index 82b1be94..00000000
--- a/src/AgentCore.Application/Diagnostics/LoggingConversationObserver.cs
+++ /dev/null
@@ -1,111 +0,0 @@
-using AgentCore.Application.Ports;
-using AgentCore.Application.Runtime;
-using AgentCore.Domain.Audit;
-using Microsoft.Extensions.Logging;
-using Microsoft.Extensions.Logging.Abstractions;
-
-namespace AgentCore.Application.Diagnostics
-{
- ///
- /// Writes the "log once" rows of section 8.7, one line for each fact that earns one.
- ///
- ///
- /// Where the lines go, or for . The
- /// library never throws for want of one.
- ///
- internal sealed class LoggingConversationObserver(ILogger? logger = null) : IConversationObserver
- {
- /// The turn a line names when the fact carried no index. No turn has it.
- private const int NoTurn = -1;
-
- private readonly ILogger _logger = logger ?? NullLogger.Instance;
-
- ///
- public ValueTask OnConversationEventAsync(ConversationEvent conversationEvent, CancellationToken cancellationToken)
- {
- ArgumentNullException.ThrowIfNull(conversationEvent);
-
- switch (conversationEvent.Kind)
- {
- case ConversationEventKind.EmptyReply:
- Log.EmptyReply(_logger, conversationEvent.ConversationId, TurnOf(conversationEvent));
- break;
-
- case ConversationEventKind.ExtractionFailed:
- Log.ExtractionFailed(
- _logger,
- conversationEvent.ConversationId,
- TurnOf(conversationEvent),
- Detail(conversationEvent, ConversationEventPayloadKeys.Reason));
- break;
-
- case ConversationEventKind.PromptFlagged:
- Log.PromptRefused(
- _logger,
- conversationEvent.ConversationId,
- TurnOf(conversationEvent),
- Detail(conversationEvent, AuditPayloadKeys.ModerationCategories));
- break;
-
- case ConversationEventKind.ModerationUnavailable:
- Log.ModerationUnavailable(
- _logger,
- conversationEvent.ConversationId,
- TurnOf(conversationEvent),
- Detail(conversationEvent, ConversationEventPayloadKeys.Reason));
- break;
-
- case ConversationEventKind.StateRestorePartial:
- Log.StateRestorePartial(
- _logger,
- conversationEvent.ConversationId,
- Detail(conversationEvent, ConversationEventPayloadKeys.Reason));
- break;
-
- case ConversationEventKind.TurnRefused:
- Log.TurnRefused(
- _logger,
- conversationEvent.ConversationId,
- TurnOf(conversationEvent),
- Detail(conversationEvent, AuditPayloadKeys.RefusedReason));
- break;
-
- case ConversationEventKind.ConversationStarted:
- case ConversationEventKind.ModerationClean:
- case ConversationEventKind.TurnCompleted:
- case ConversationEventKind.ReplyInterrupted:
- case ConversationEventKind.TurnSuperseded:
- case ConversationEventKind.ConversationEnded:
- break;
-
- case ConversationEventKind.TranscriptWriteFailed:
- case ConversationEventKind.TranscriptResyncFailed:
- case ConversationEventKind.ToolFailed:
- case ConversationEventKind.RunFaulted:
- break;
-
- default:
- break;
- }
-
- return ValueTask.CompletedTask;
- }
-
- /// Reads the turn a line names.
- /// The fact being reported.
- /// Its turn index, or when it carried none.
- private static int TurnOf(ConversationEvent conversationEvent)
- {
- return conversationEvent.TurnIndex ?? NoTurn;
- }
-
- /// Reads one detail a line reports.
- /// The fact being reported.
- /// The payload key the detail sits under.
- /// The detail, or an empty string when the fact carried none.
- private static string Detail(ConversationEvent conversationEvent, string key)
- {
- return conversationEvent.Payload.TryGetValue(key, out string? detail) ? detail : string.Empty;
- }
- }
-}
diff --git a/src/AgentCore.Application/Diagnostics/TelemetryConversationObserver.cs b/src/AgentCore.Application/Diagnostics/TelemetryConversationObserver.cs
deleted file mode 100644
index 984e22e0..00000000
--- a/src/AgentCore.Application/Diagnostics/TelemetryConversationObserver.cs
+++ /dev/null
@@ -1,76 +0,0 @@
-using AgentCore.Application.Ports;
-using AgentCore.Application.Runtime;
-using AgentCore.Domain.Audit;
-
-namespace AgentCore.Application.Diagnostics
-{
- ///
- /// Counts the facts of a conversation on the three instruments of section 8.6.
- ///
- internal sealed class TelemetryConversationObserver : IConversationObserver
- {
- ///
- public ValueTask OnConversationEventAsync(ConversationEvent conversationEvent, CancellationToken cancellationToken)
- {
- ArgumentNullException.ThrowIfNull(conversationEvent);
-
- switch (conversationEvent.Kind)
- {
- case ConversationEventKind.PromptFlagged:
- AgentCoreTelemetry.RecordModeration(AgentCoreTelemetry.ModerationFlagged);
- break;
-
- case ConversationEventKind.ModerationUnavailable:
- AgentCoreTelemetry.RecordModeration(AgentCoreTelemetry.ModerationUnavailable);
- break;
-
- case ConversationEventKind.ModerationClean:
- AgentCoreTelemetry.RecordModeration(AgentCoreTelemetry.ModerationClean);
- break;
-
- case ConversationEventKind.ToolFailed:
- if (!conversationEvent.Payload.ContainsKey(AuditPayloadKeys.ToolCallId))
- {
- AgentCoreTelemetry.RecordFailure(AgentCoreTelemetry.FailureTool);
- }
-
- break;
-
- case ConversationEventKind.EmptyReply:
- AgentCoreTelemetry.RecordFailure(AgentCoreTelemetry.FailureEmptyReply);
- break;
-
- case ConversationEventKind.RunFaulted:
- AgentCoreTelemetry.RecordFailure(AgentCoreTelemetry.FailureRun);
- break;
-
- case ConversationEventKind.ExtractionFailed:
- AgentCoreTelemetry.RecordFailure(AgentCoreTelemetry.FailureExtraction);
- break;
-
- case ConversationEventKind.ConversationStarted:
- case ConversationEventKind.TurnCompleted:
- case ConversationEventKind.ReplyInterrupted:
- case ConversationEventKind.TurnSuperseded:
- case ConversationEventKind.TurnRefused:
- case ConversationEventKind.ConversationEnded:
- break;
-
- case ConversationEventKind.TranscriptWriteFailed:
- case ConversationEventKind.StateRestorePartial:
- case ConversationEventKind.TranscriptResyncFailed:
- break;
-
- default:
- break;
- }
-
- if (ConversationEventKinds.TryGetAuditKind(conversationEvent.Kind, out AuditEventKind auditKind))
- {
- AgentCoreTelemetry.RecordAuditEvent(AuditEventKinds.ToToken(auditKind));
- }
-
- return ValueTask.CompletedTask;
- }
- }
-}
diff --git a/src/AgentCore.Application/Evaluation/ModerationVerdict.cs b/src/AgentCore.Application/Evaluation/ModerationVerdict.cs
index 723f090c..96c3cef5 100644
--- a/src/AgentCore.Application/Evaluation/ModerationVerdict.cs
+++ b/src/AgentCore.Application/Evaluation/ModerationVerdict.cs
@@ -5,33 +5,6 @@ namespace AgentCore.Application.Evaluation
///
/// What the moderation endpoint answered about one piece of text.
///
- ///
- ///
- /// Decision D13 makes the moderation port and refuses a second one, so the
- /// verdict has to travel on the result the evaluator already returns. This type is that carrier. The
- /// moderation adapter attaches it to its metric, and the caller reads it back through
- /// . Nothing between the two parses a metric name, a reason, or a metadata
- /// string.
- ///
- ///
- /// It lives in Application/Evaluation/ and not beside the adapter, for two reasons. D3 forbids
- /// AgentCore.Application from referencing AgentCore.Infrastructure , and the code that
- /// records a flagged turn sits in , which is in Application. And
- /// D13 promises that replacing OpenAI with a self-hosted classifier behind the same
- /// is a one-file change, which holds only while this type names no vendor.
- ///
- ///
- /// The categories keep the order the endpoint returned them in, because
- /// promises that order to a reader
- /// of the audit chain. Nothing here sorts them, renames them, or checks them against a list: the
- /// taxonomy belongs to the endpoint and it is open. A category this library never named still
- /// reaches the chain.
- ///
- ///
- /// is the shape this follows: an that
- /// carries a typed payload beside a metric.
- ///
- ///
public sealed class ModerationVerdict : EvaluationContext
{
/// The name this verdict carries on the metric.
@@ -47,11 +20,6 @@ public sealed class ModerationVerdict : EvaluationContext
/// endpoint flagged nothing.
///
/// The categories are .
- ///
- /// The endpoint owns , and this type never recomputes it from
- /// . A model that flags text without naming a category still reads
- /// as flagged, and a threshold the vendor moves does not drift the answer.
- ///
public ModerationVerdict(bool flagged, IEnumerable categories)
: this(flagged, Materialize(categories))
{
@@ -75,18 +43,6 @@ private ModerationVerdict(bool flagged, string[] categories)
/// The verdict, when one metric carries it.
/// when the endpoint answered.
/// The result is .
- ///
- ///
- /// A verdict is present only when the endpoint answered. An evaluation that failed, timed
- /// out, or read a body it could not parse carries an inconclusive metric and no verdict, so this
- /// answers . The caller then knows the text is unchecked, which is a
- /// different fact from "the endpoint checked it and flagged nothing".
- ///
- ///
- /// It searches every metric and names none, so the caller compiles against this assembly alone
- /// and never against the adapter that produced the result.
- ///
- ///
public static bool TryRead(EvaluationResult result, out ModerationVerdict? verdict)
{
ArgumentNullException.ThrowIfNull(result);
diff --git a/src/AgentCore.Application/Evaluation/PromptModerator.cs b/src/AgentCore.Application/Evaluation/PromptModerator.cs
index 2bac8dea..0a4b369d 100644
--- a/src/AgentCore.Application/Evaluation/PromptModerator.cs
+++ b/src/AgentCore.Application/Evaluation/PromptModerator.cs
@@ -6,32 +6,6 @@ namespace AgentCore.Application.Evaluation
///
/// Reads which moderation categories flagged what the caller said.
///
- ///
- ///
- /// The agent moderates the caller's words BEFORE the model runs, and refuses the turn when the
- /// endpoint flags them. That is a decision of the owner, taken on 2026-08-13, and it departs from
- /// section 11 item 11, which asked for the agent's reply to be moderated and recorded instead.
- /// Reply moderation is withdrawn, not deferred: the reply comes from a model that already
- /// carries safety training, so a second check of its own output buys little. The caller's words are
- /// the text nothing else has filtered.
- ///
- ///
- /// D13 makes the moderation port and refuses a second one, so this type is
- /// not a port. It is the one place that turns an into the fact the
- /// audit chain records, and it exists so that holds no registry
- /// key, no metric name, and no decode.
- ///
- ///
- /// D13 also says moderation checks EVERY turn, because the endpoint is free at any volume and counts
- /// against no usage limit: "Sampling buys nothing when the conversation is free." Nothing here reads
- /// , and nothing should.
- ///
- ///
- /// Nothing here catches. A timeout, a log line, and the decision to answer anyway all belong
- /// to , which owns the turn and the clock. This type calls the
- /// evaluator and reads the answer.
- ///
- ///
public sealed class PromptModerator
{
/// The name the composition root registers the moderation evaluator under.
@@ -57,11 +31,6 @@ public PromptModerator(IEvaluator evaluator)
/// .
///
/// The registry is .
- ///
- /// A host that registers no moderation evaluator runs every turn and refuses none, exactly as a
- /// host that binds no audit sink still answers a conversation. Moderation needs a vendor account, and a
- /// library that refused to run without one would be unusable in a test.
- ///
public static PromptModerator? FromRegistry(EvaluatorRegistry registry)
{
ArgumentNullException.ThrowIfNull(registry);
@@ -79,13 +48,6 @@ public PromptModerator(IEvaluator evaluator)
/// flagged nothing.
///
/// The text is .
- ///
- /// An endpoint that did not answer reads as "nothing flagged", and the turn goes on. That
- /// is the fail-open rule, and it is deliberate: a vendor outage must not refuse every caller on
- /// a support line. answers for a
- /// failed evaluation, so the two cases meet here and the caller of this method cannot tell them
- /// apart. counts them apart on a metric instead.
- ///
public async ValueTask> FlaggedCategoriesAsync(
string callerText,
CancellationToken cancellationToken = default)
diff --git a/src/AgentCore.Application/Hooks/AgentHook.cs b/src/AgentCore.Application/Hooks/AgentHook.cs
new file mode 100644
index 00000000..d55e754e
--- /dev/null
+++ b/src/AgentCore.Application/Hooks/AgentHook.cs
@@ -0,0 +1,278 @@
+using AgentCore.Application.Hooks.Gates;
+using AgentCore.Application.Hooks.Notices;
+
+namespace AgentCore.Application.Hooks
+{
+ ///
+ /// The one hook class. Consumers and AgentCore itself derive from it and override only what they need.
+ ///
+ public abstract class AgentHook
+ {
+ /// A transport offers a call (GPT-Live webhook, Telnyx setup frame). Decide with or .
+ /// The facts and the verbs of this gate.
+ /// Cancelled at the gate's deadline.
+ /// A task that completes when the hook decided.
+ public virtual ValueTask BeforeCallAsync(CallGate gate, CancellationToken cancellationToken) => default;
+
+ /// Every AgentCore route, HTTP or call, needs an entry. Decide with or .
+ /// The facts and the verbs of this gate.
+ /// Cancelled at the gate's deadline.
+ /// A task that completes when the hook decided.
+ public virtual ValueTask BeforeEntryAsync(EntryGate gate, CancellationToken cancellationToken) => default;
+
+ /// The user's message arrives, before moderation and the model.
+ /// The facts and the verbs of this gate.
+ /// Cancelled at the gate's deadline.
+ /// A task that completes when the hook decided.
+ public virtual ValueTask BeforeTurnAsync(TurnGate gate, CancellationToken cancellationToken) => default;
+
+ /// Once per run, when the context is built. A graph participant or an agent-as-tool child is a run of its own, so it fires again with set.
+ /// The facts and the verbs of this gate.
+ /// Cancelled at the gate's deadline.
+ /// A task that completes when the hook decided.
+ public virtual ValueTask BeforeRunAsync(RunGate gate, CancellationToken cancellationToken) => default;
+
+ /// AgentCore is about to compact.
+ /// The facts and the verbs of this gate.
+ /// Cancelled at the gate's deadline.
+ /// A task that completes when the hook decided.
+ public virtual ValueTask BeforeCompactionAsync(CompactionGate gate, CancellationToken cancellationToken) => default;
+
+ /// Before each model round trip.
+ /// The facts and the verbs of this gate.
+ /// Cancelled at the gate's deadline.
+ /// A task that completes when the hook decided.
+ public virtual ValueTask BeforeModelAsync(ModelGate gate, CancellationToken cancellationToken) => default;
+
+ /// After each round trip, before tools run or text leaves. Overriding this method buffers every streamed model round of every turn before any of it reaches the caller.
+ /// The facts and the verbs of this gate.
+ /// Cancelled at the gate's deadline.
+ /// A task that completes when the hook decided.
+ public virtual ValueTask AfterModelAsync(ModelResultGate gate, CancellationToken cancellationToken) => default;
+
+ /// A round trip throws.
+ /// The facts and the verbs of this gate.
+ /// Cancelled at the gate's deadline.
+ /// A task that completes when the hook decided.
+ public virtual ValueTask AfterModelFailedAsync(ModelFailureGate gate, CancellationToken cancellationToken) => default;
+
+ /// A tool that needs approval is called.
+ /// The facts and the verbs of this gate.
+ /// Cancelled at the gate's deadline.
+ /// A task that completes when the hook decided.
+ public virtual ValueTask BeforeToolApprovalAsync(ApprovalGate gate, CancellationToken cancellationToken) => default;
+
+ /// Before each tool call.
+ /// The facts and the verbs of this gate.
+ /// Cancelled at the gate's deadline.
+ /// A task that completes when the hook decided.
+ public virtual ValueTask BeforeToolAsync(ToolGate gate, CancellationToken cancellationToken) => default;
+
+ /// After a tool returns.
+ /// The facts and the verbs of this gate.
+ /// Cancelled at the gate's deadline.
+ /// A task that completes when the hook decided.
+ public virtual ValueTask AfterToolAsync(ToolResultGate gate, CancellationToken cancellationToken) => default;
+
+ /// A tool throws.
+ /// The facts and the verbs of this gate.
+ /// Cancelled at the gate's deadline.
+ /// A task that completes when the hook decided.
+ public virtual ValueTask AfterToolFailedAsync(ToolFailureGate gate, CancellationToken cancellationToken) => default;
+
+ /// The run has a reply, before the turn is sealed.
+ /// The facts and the verbs of this gate.
+ /// Cancelled at the gate's deadline.
+ /// A task that completes when the hook decided.
+ public virtual ValueTask AfterRunAsync(RunEndGate gate, CancellationToken cancellationToken) => default;
+
+ /// Boot finished.
+ /// The fact.
+ /// Cancelled when passes.
+ /// A task that completes when the hook is done with the notice.
+ public virtual ValueTask OnHostStartedAsync(HostStarted notice, CancellationToken cancellationToken) => default;
+
+ /// Shutdown begins.
+ /// The fact.
+ /// Cancelled when passes.
+ /// A task that completes when the hook is done with the notice.
+ public virtual ValueTask OnHostStoppingAsync(HostStopping notice, CancellationToken cancellationToken) => default;
+
+ /// The hourly retention sweep ran.
+ /// The fact.
+ /// Cancelled when passes.
+ /// A task that completes when the hook is done with the notice.
+ public virtual ValueTask OnRetentionSweptAsync(RetentionSwept notice, CancellationToken cancellationToken) => default;
+
+ /// A call is accepted and the session is open.
+ /// The fact.
+ /// Cancelled when passes.
+ /// A task that completes when the hook is done with the notice.
+ public virtual ValueTask OnCallStartedAsync(CallStarted notice, CancellationToken cancellationToken) => default;
+
+ /// A spoken line closes.
+ /// The fact.
+ /// Cancelled when passes.
+ /// A task that completes when the hook is done with the notice.
+ public virtual ValueTask OnLineSpokenAsync(LineSpoken notice, CancellationToken cancellationToken) => default;
+
+ /// After the store opens.
+ /// The fact.
+ /// Cancelled when passes.
+ /// A task that completes when the hook is done with the notice.
+ public virtual ValueTask OnConversationStartedAsync(ConversationStarted notice, CancellationToken cancellationToken) => default;
+
+ /// Idle unload or close; the conversation lives on in the store.
+ /// The fact.
+ /// Cancelled when passes.
+ /// A task that completes when the hook is done with the notice.
+ public virtual ValueTask OnConversationUnloadedAsync(ConversationUnloaded notice, CancellationToken cancellationToken) => default;
+
+ /// The conversation ends. Once per conversation.
+ /// The fact.
+ /// Cancelled when passes.
+ /// A task that completes when the hook is done with the notice.
+ public virtual ValueTask OnConversationEndedAsync(ConversationEnded notice, CancellationToken cancellationToken) => default;
+
+ /// The turn is admitted and its agent chosen.
+ /// The fact.
+ /// Cancelled when passes.
+ /// A task that completes when the hook is done with the notice.
+ public virtual ValueTask OnTurnStartedAsync(TurnStarted notice, CancellationToken cancellationToken) => default;
+
+ /// Any refusal.
+ /// The fact.
+ /// Cancelled when passes.
+ /// A task that completes when the hook is done with the notice.
+ public virtual ValueTask OnTurnRefusedAsync(TurnRefused notice, CancellationToken cancellationToken) => default;
+
+ /// Edit-and-resend withdrew turns.
+ /// The fact.
+ /// Cancelled when passes.
+ /// A task that completes when the hook is done with the notice.
+ public virtual ValueTask OnTurnSupersededAsync(TurnSuperseded notice, CancellationToken cancellationToken) => default;
+
+ /// Moderation decided.
+ /// The fact.
+ /// Cancelled when passes.
+ /// A task that completes when the hook is done with the notice.
+ public virtual ValueTask OnInputModeratedAsync(InputModerated notice, CancellationToken cancellationToken) => default;
+
+ /// A knowledge search finished.
+ /// The fact.
+ /// Cancelled when passes.
+ /// A task that completes when the hook is done with the notice.
+ public virtual ValueTask OnKnowledgeSearchedAsync(KnowledgeSearched notice, CancellationToken cancellationToken) => default;
+
+ /// A skill was pinned or loaded.
+ /// The fact.
+ /// Cancelled when passes.
+ /// A task that completes when the hook is done with the notice.
+ public virtual ValueTask OnSkillLoadedAsync(SkillLoaded notice, CancellationToken cancellationToken) => default;
+
+ /// Compaction finished or failed.
+ /// The fact.
+ /// Cancelled when passes.
+ /// A task that completes when the hook is done with the notice.
+ public virtual ValueTask OnCompactedAsync(Compacted notice, CancellationToken cancellationToken) => default;
+
+ /// Each output update that leaves the turn. Overriding this method makes AgentCore raise it for every update.
+ /// The fact.
+ /// Cancelled when passes.
+ /// A task that completes when the hook is done with the notice.
+ public virtual ValueTask OnReplyUpdatedAsync(ReplyUpdated notice, CancellationToken cancellationToken) => default;
+
+ /// Each model round trip ended.
+ /// The fact.
+ /// Cancelled when passes.
+ /// A task that completes when the hook is done with the notice.
+ public virtual ValueTask OnModelCalledAsync(ModelCalled notice, CancellationToken cancellationToken) => default;
+
+ /// Each tool call ended. One per call.
+ /// The fact.
+ /// Cancelled when passes.
+ /// A task that completes when the hook is done with the notice.
+ public virtual ValueTask OnToolCalledAsync(ToolCalled notice, CancellationToken cancellationToken) => default;
+
+ /// Approval asked or answered.
+ /// The fact.
+ /// Cancelled when passes.
+ /// A task that completes when the hook is done with the notice.
+ public virtual ValueTask OnApprovalChangedAsync(ApprovalChanged notice, CancellationToken cancellationToken) => default;
+
+ /// file.publish stored a file.
+ /// The fact.
+ /// Cancelled when passes.
+ /// A task that completes when the hook is done with the notice.
+ public virtual ValueTask OnFilePublishedAsync(FilePublished notice, CancellationToken cancellationToken) => default;
+
+ /// An agent-as-tool run began.
+ /// The fact.
+ /// Cancelled when passes.
+ /// A task that completes when the hook is done with the notice.
+ public virtual ValueTask OnSubagentStartedAsync(SubagentStarted notice, CancellationToken cancellationToken) => default;
+
+ /// An agent-as-tool run ended.
+ /// The fact.
+ /// Cancelled when passes.
+ /// A task that completes when the hook is done with the notice.
+ public virtual ValueTask OnSubagentEndedAsync(SubagentEnded notice, CancellationToken cancellationToken) => default;
+
+ /// The turn is sealed.
+ /// The fact.
+ /// Cancelled when passes.
+ /// A task that completes when the hook is done with the notice.
+ public virtual ValueTask OnTurnCompletedAsync(TurnCompleted notice, CancellationToken cancellationToken) => default;
+
+ /// The caller spoke over the reply (cut or recut).
+ /// The fact.
+ /// Cancelled when passes.
+ /// A task that completes when the hook is done with the notice.
+ public virtual ValueTask OnReplyCutAsync(ReplyCut notice, CancellationToken cancellationToken) => default;
+
+ /// The user or agent voice state changed.
+ /// The fact.
+ /// Cancelled when passes.
+ /// A task that completes when the hook is done with the notice.
+ public virtual ValueTask OnVoiceStateChangedAsync(VoiceStateChanged notice, CancellationToken cancellationToken) => default;
+
+ /// Voice turn timing is known.
+ /// The fact.
+ /// Cancelled when passes.
+ /// A task that completes when the hook is done with the notice.
+ public virtual ValueTask OnTurnLatencyAsync(TurnLatency notice, CancellationToken cancellationToken) => default;
+
+ /// The stage machine moved.
+ /// The fact.
+ /// Cancelled when passes.
+ /// A task that completes when the hook is done with the notice.
+ public virtual ValueTask OnStageChangedAsync(StageChanged notice, CancellationToken cancellationToken) => default;
+
+ /// A non-fatal fault.
+ /// The fact.
+ /// Cancelled when passes.
+ /// A task that completes when the hook is done with the notice.
+ public virtual ValueTask OnFaultAsync(Fault notice, CancellationToken cancellationToken) => default;
+
+ /// A started call left. Once per call, also after .
+ /// The fact.
+ /// Cancelled when passes.
+ /// A task that completes when the hook is done with the notice.
+ public virtual ValueTask OnCallEndedAsync(CallEnded notice, CancellationToken cancellationToken) => default;
+
+ /// Gets how a gate of this hook fails. Override per point to fail closed where the gate fails open.
+ /// The gate point.
+ /// The point's unless overridden.
+ public virtual HookFailure FailureFor(GatePoint point)
+ {
+ ArgumentNullException.ThrowIfNull(point);
+ return point.DefaultFailure;
+ }
+
+ ///
+ /// Gets how long one notice may take before AgentCore abandons it and goes on, or to
+ /// wait for every notice, logging every 30 s while it waits.
+ ///
+ public virtual TimeSpan? NoticeTimeout => TimeSpan.FromSeconds(30);
+ }
+}
diff --git a/src/AgentCore.Application/Hooks/BuiltIn/AuditHook.cs b/src/AgentCore.Application/Hooks/BuiltIn/AuditHook.cs
new file mode 100644
index 00000000..0e9fd22a
--- /dev/null
+++ b/src/AgentCore.Application/Hooks/BuiltIn/AuditHook.cs
@@ -0,0 +1,126 @@
+using System.Globalization;
+using AgentCore.Application.Diagnostics;
+using AgentCore.Application.Hooks.Notices;
+using AgentCore.Application.Ports;
+using AgentCore.Domain.Audit;
+using Microsoft.Extensions.Logging;
+
+namespace AgentCore.Application.Hooks.BuiltIn
+{
+ ///
+ /// Turns the durable facts of a conversation into the append-only audit chain: the 8 audit kinds, with
+ /// as the row id. It never times out, so a slow store is waited out,
+ /// and behind QueuedAuditSink a full queue makes it wait rather than drop.
+ ///
+ /// Where the rows go.
+ /// Where a refused row is reported.
+ internal sealed class AuditHook(IAuditSinkPort sink, ILogger logger) : AgentHook
+ {
+ private static readonly IReadOnlyDictionary NoPayload = new Dictionary(StringComparer.Ordinal);
+
+ ///
+ public override TimeSpan? NoticeTimeout => null;
+
+ ///
+ public override ValueTask OnConversationStartedAsync(ConversationStarted notice, CancellationToken cancellationToken) =>
+ AppendAsync(notice, AuditEventKind.ConversationStarted, NoPayload, amends: null, cancellationToken);
+
+ ///
+ public override ValueTask OnConversationEndedAsync(ConversationEnded notice, CancellationToken cancellationToken)
+ {
+ Dictionary payload = new(StringComparer.Ordinal) { [AuditPayloadKeys.EndReason] = ConversationEndReasons.ToToken(notice.Reason) };
+ if (notice.TerminalStage is { Length: > 0 } stage)
+ {
+ payload[AuditPayloadKeys.StageAfter] = stage;
+ }
+
+ return AppendAsync(notice, AuditEventKind.ConversationEnded, payload, amends: null, cancellationToken);
+ }
+
+ ///
+ public override ValueTask OnTurnRefusedAsync(TurnRefused notice, CancellationToken cancellationToken) =>
+ !AuditRules.IsAudited(notice)
+ ? ValueTask.CompletedTask
+ : AppendAsync(notice, AuditEventKind.TurnRefused,
+ new Dictionary(StringComparer.Ordinal) { [AuditPayloadKeys.RefusedReason] = TurnRefusalTokens.ToToken(notice.Reason) },
+ amends: null, cancellationToken);
+
+ ///
+ public override ValueTask OnTurnSupersededAsync(TurnSuperseded notice, CancellationToken cancellationToken) =>
+ AppendAsync(notice, AuditEventKind.TurnSuperseded, new Dictionary(StringComparer.Ordinal)
+ {
+ [AuditPayloadKeys.WithdrewFromTurnIndex] = notice.WithdrewFrom.ToString(CultureInfo.InvariantCulture),
+ [AuditPayloadKeys.WithdrewThroughTurnIndex] = notice.WithdrewThrough.ToString(CultureInfo.InvariantCulture),
+ }, amends: null, cancellationToken);
+
+ ///
+ public override ValueTask OnInputModeratedAsync(InputModerated notice, CancellationToken cancellationToken) =>
+ !AuditRules.IsAudited(notice)
+ ? ValueTask.CompletedTask
+ : AppendAsync(notice, AuditEventKind.PromptFlagged,
+ new Dictionary(StringComparer.Ordinal) { [AuditPayloadKeys.ModerationCategories] = string.Join(',', notice.Categories) },
+ amends: null, cancellationToken);
+
+ ///
+ public override ValueTask OnToolCalledAsync(ToolCalled notice, CancellationToken cancellationToken) =>
+ !AuditRules.IsAudited(notice)
+ ? ValueTask.CompletedTask
+ : AppendAsync(notice, AuditEventKind.ToolFailed, new Dictionary(StringComparer.Ordinal)
+ {
+ [AuditPayloadKeys.ToolName] = notice.ToolName,
+ [AuditPayloadKeys.ToolCallId] = notice.CallId,
+ [AuditPayloadKeys.ToolFailureKind] = ToolFailureKinds.ToToken(notice.FailureKind ?? ToolFailureKind.Faulted),
+ [AuditPayloadKeys.ToolError] = notice.Failure ?? string.Empty,
+ }, amends: null, cancellationToken);
+
+ ///
+ public override ValueTask OnTurnCompletedAsync(TurnCompleted notice, CancellationToken cancellationToken) =>
+ AppendAsync(notice, AuditEventKind.TurnCompleted, new Dictionary(StringComparer.Ordinal)
+ {
+ [AuditPayloadKeys.ReplyTextSha256] = AuditHash.OfText(notice.ReplyText).Value,
+ [AuditPayloadKeys.StageBefore] = notice.StageBefore,
+ [AuditPayloadKeys.StageAfter] = notice.StageAfter,
+ }, amends: null, cancellationToken);
+
+ ///
+ public override ValueTask OnReplyCutAsync(ReplyCut notice, CancellationToken cancellationToken)
+ {
+ Dictionary payload = new(StringComparer.Ordinal)
+ {
+ [AuditPayloadKeys.UtteranceUntilInterruptSha256] = AuditHash.OfText(notice.HeardText).Value,
+ };
+
+ if (notice.Played is { } played)
+ {
+ payload[AuditPayloadKeys.DurationUntilInterruptMs] = ((long)played.TotalMilliseconds).ToString(CultureInfo.InvariantCulture);
+ }
+
+ return AppendAsync(notice, AuditEventKind.ReplyInterrupted, payload, notice.AmendsEventId, cancellationToken);
+ }
+
+ private async ValueTask AppendAsync(
+ HookNotice notice, AuditEventKind kind, IReadOnlyDictionary payload, Guid? amends, CancellationToken cancellationToken)
+ {
+ AuditEvent row = new()
+ {
+ ConversationId = notice.Scope.ConversationId!,
+ EventId = notice.EventId,
+ Kind = kind,
+ OccurredAt = notice.Scope.OccurredAt,
+ TurnIndex = notice.Scope.TurnIndex,
+ AmendsEventId = amends,
+ Payload = payload,
+ };
+
+ try
+ {
+ await sink.AppendAsync(row, cancellationToken).ConfigureAwait(false);
+ }
+ catch (Exception exception) when (exception is not OperationCanceledException || !cancellationToken.IsCancellationRequested)
+ {
+ Log.AuditAppendFailed(logger, row.ConversationId, AuditEventKinds.ToToken(kind), exception);
+ throw;
+ }
+ }
+ }
+}
diff --git a/src/AgentCore.Application/Hooks/BuiltIn/AuditRules.cs b/src/AgentCore.Application/Hooks/BuiltIn/AuditRules.cs
new file mode 100644
index 00000000..35efe788
--- /dev/null
+++ b/src/AgentCore.Application/Hooks/BuiltIn/AuditRules.cs
@@ -0,0 +1,21 @@
+using AgentCore.Application.Hooks.Notices;
+
+namespace AgentCore.Application.Hooks.BuiltIn
+{
+ ///
+ /// Which facts the audit chain writes a row for, where a notice type alone does not say. The audit hook writes
+ /// by these rules and the telemetry hook counts by them, so the counter never drifts from the rows.
+ ///
+ internal static class AuditRules
+ {
+ /// A fault the model cannot answer, or a tool the model named that no one declared, is a tool.failed row.
+ internal static bool IsAudited(ToolCalled notice) =>
+ notice is { Outcome: ToolOutcome.Failed, Fatal: true } or { Outcome: ToolOutcome.Undeclared };
+
+ /// A refusal after conversation.ended is no row: nothing is appended behind the end.
+ internal static bool IsAudited(TurnRefused notice) => !notice.AfterEnd;
+
+ /// Only flagged words are a prompt.flagged row.
+ internal static bool IsAudited(InputModerated notice) => notice.Verdict == InputVerdict.Flagged;
+ }
+}
diff --git a/src/AgentCore.Application/Hooks/BuiltIn/CallBriefHook.cs b/src/AgentCore.Application/Hooks/BuiltIn/CallBriefHook.cs
new file mode 100644
index 00000000..88d9c116
--- /dev/null
+++ b/src/AgentCore.Application/Hooks/BuiltIn/CallBriefHook.cs
@@ -0,0 +1,61 @@
+using System.Collections.Concurrent;
+using AgentCore.Application.Hooks.Gates;
+using AgentCore.Application.Transcript;
+
+namespace AgentCore.Application.Hooks.BuiltIn
+{
+ ///
+ /// Adds an accepted call's brief to every run of the call's conversation, as instructions.
+ /// AgentCore never reads, wraps, or changes the brief; the host writes all of it.
+ ///
+ internal sealed class CallBriefHook : AgentHook
+ {
+ ///
+ /// The one line a call whose vendor speaks for itself adds to every run, so the agent reads the
+ /// lines in its history as already heard.
+ ///
+ internal const string FrontVoiceNote =
+ $"Messages from {FrontVoice.AuthorName} are what the phone's front voice already said to the caller: answer only the caller's latest message, and correct a {FrontVoice.AuthorName} line only if it was wrong.";
+
+ private readonly ConcurrentDictionary _briefs = new(StringComparer.Ordinal);
+
+ /// Files the brief of the call that runs on .
+ /// The conversation the call runs on.
+ /// The brief, added to every run verbatim, or for none.
+ /// Whether the call's vendor speaks for itself, so every run also gets .
+ internal void Set(string conversationId, string? brief, bool frontVoice = false)
+ {
+ ArgumentException.ThrowIfNullOrEmpty(conversationId);
+ _briefs[conversationId] = (brief, frontVoice);
+ }
+
+ /// Forgets the brief: the call ended.
+ /// The conversation the call ran on.
+ internal void Clear(string conversationId)
+ {
+ ArgumentNullException.ThrowIfNull(conversationId);
+ _ = _briefs.TryRemove(conversationId, out _);
+ }
+
+ ///
+ public override ValueTask BeforeRunAsync(RunGate gate, CancellationToken cancellationToken)
+ {
+ ArgumentNullException.ThrowIfNull(gate);
+
+ if (gate.Scope.ConversationId is { } conversationId && _briefs.TryGetValue(conversationId, out (string? Brief, bool FrontVoice) call))
+ {
+ if (call.Brief is { } brief)
+ {
+ gate.AddInstructions(brief);
+ }
+
+ if (call.FrontVoice)
+ {
+ gate.AddInstructions(FrontVoiceNote);
+ }
+ }
+
+ return default;
+ }
+ }
+}
diff --git a/src/AgentCore.Application/Hooks/BuiltIn/LoggingHook.cs b/src/AgentCore.Application/Hooks/BuiltIn/LoggingHook.cs
new file mode 100644
index 00000000..bbdb6ba9
--- /dev/null
+++ b/src/AgentCore.Application/Hooks/BuiltIn/LoggingHook.cs
@@ -0,0 +1,85 @@
+using AgentCore.Application.Diagnostics;
+using AgentCore.Application.Hooks.Notices;
+using Microsoft.Extensions.Logging;
+using Microsoft.Extensions.Logging.Abstractions;
+using AgentCore.Application.Runtime.Turn.Lifecycle;
+
+namespace AgentCore.Application.Hooks.BuiltIn
+{
+ ///
+ /// The "log once" failure lines, read from notices. It never writes what the caller or the agent said.
+ ///
+ /// Where the lines go, or for nowhere.
+ internal sealed class LoggingHook(ILogger? logger) : AgentHook
+ {
+ /// The turn a line names when the notice carried no index. No turn has it.
+ private const int NoTurn = -1;
+
+ private readonly ILogger _logger = logger ?? NullLogger.Instance;
+
+ ///
+ public override ValueTask OnTurnRefusedAsync(TurnRefused notice, CancellationToken cancellationToken)
+ {
+ Log.TurnRefused(_logger, IdOf(notice), TurnOf(notice), TurnRefusalTokens.ToToken(notice.Reason));
+ return ValueTask.CompletedTask;
+ }
+
+ ///
+ public override ValueTask OnInputModeratedAsync(InputModerated notice, CancellationToken cancellationToken)
+ {
+ switch (notice.Verdict)
+ {
+ case InputVerdict.Flagged:
+ Log.PromptRefused(_logger, IdOf(notice), TurnOf(notice), string.Join(',', notice.Categories));
+ break;
+ case InputVerdict.Unavailable:
+ Log.ModerationUnavailable(_logger, IdOf(notice), TurnOf(notice), notice.Reason == ModerationUnavailableReason.TimedOut
+ ? TurnFailureReasons.ModerationTimedOut
+ : TurnFailureReasons.ModerationFaulted);
+ break;
+ case InputVerdict.Clean:
+ default:
+ break;
+ }
+
+ return ValueTask.CompletedTask;
+ }
+
+ ///
+ public override ValueTask OnTurnCompletedAsync(TurnCompleted notice, CancellationToken cancellationToken)
+ {
+ TurnFailureAccounting.Write(
+ _logger, IdOf(notice), TurnOf(notice), TurnFailureAccounting.Of(notice.Outcome, notice.FailedInTool), notice.Cause);
+ return ValueTask.CompletedTask;
+ }
+
+ ///
+ public override ValueTask OnFaultAsync(Fault notice, CancellationToken cancellationToken)
+ {
+ switch (notice)
+ {
+ case { Kind: FaultKind.ExtractionFailed }:
+ Log.ExtractionFailed(_logger, IdOf(notice), TurnOf(notice), notice.Message);
+ break;
+ case { Kind: FaultKind.StateRestorePartial }:
+ Log.StateRestorePartial(_logger, IdOf(notice), notice.Message);
+ break;
+ case { Kind: FaultKind.TranscriptWriteFailed, Cause: { } cause }:
+ Log.TranscriptWriteFailed(_logger, IdOf(notice), TurnOf(notice), cause);
+ break;
+ case { Kind: FaultKind.TranscriptResyncFailed, Cause: { } cause }:
+ Log.TranscriptResyncFailed(_logger, IdOf(notice), TurnOf(notice), cause);
+ break;
+ default:
+ // HookFailed is logged by the engine itself; the busy-mark faults keep their own direct lines.
+ break;
+ }
+
+ return ValueTask.CompletedTask;
+ }
+
+ private static string IdOf(HookNotice notice) => notice.Scope.ConversationId ?? string.Empty;
+
+ private static int TurnOf(HookNotice notice) => notice.Scope.TurnIndex ?? NoTurn;
+ }
+}
diff --git a/src/AgentCore.Application/Hooks/BuiltIn/TelemetryHook.cs b/src/AgentCore.Application/Hooks/BuiltIn/TelemetryHook.cs
new file mode 100644
index 00000000..8ef20414
--- /dev/null
+++ b/src/AgentCore.Application/Hooks/BuiltIn/TelemetryHook.cs
@@ -0,0 +1,100 @@
+using AgentCore.Application.Diagnostics;
+using AgentCore.Application.Hooks.Notices;
+using AgentCore.Domain.Audit;
+
+namespace AgentCore.Application.Hooks.BuiltIn
+{
+ ///
+ /// Today's counters, read from notices: failures, moderation verdicts, and the audit rows the chain writes.
+ /// Spans stay inline: an OTel activity must flow with the call.
+ ///
+ internal sealed class TelemetryHook : AgentHook
+ {
+ ///
+ public override ValueTask OnConversationStartedAsync(ConversationStarted notice, CancellationToken cancellationToken) =>
+ Audited(AuditEventKind.ConversationStarted);
+
+ ///
+ public override ValueTask OnConversationEndedAsync(ConversationEnded notice, CancellationToken cancellationToken) =>
+ Audited(AuditEventKind.ConversationEnded);
+
+ ///
+ public override ValueTask OnTurnRefusedAsync(TurnRefused notice, CancellationToken cancellationToken) =>
+ AuditRules.IsAudited(notice) ? Audited(AuditEventKind.TurnRefused) : ValueTask.CompletedTask;
+
+ ///
+ public override ValueTask OnTurnSupersededAsync(TurnSuperseded notice, CancellationToken cancellationToken) =>
+ Audited(AuditEventKind.TurnSuperseded);
+
+ ///
+ public override ValueTask OnReplyCutAsync(ReplyCut notice, CancellationToken cancellationToken) =>
+ Audited(AuditEventKind.ReplyInterrupted);
+
+ ///
+ public override ValueTask OnInputModeratedAsync(InputModerated notice, CancellationToken cancellationToken)
+ {
+ AgentCoreTelemetry.RecordModeration(notice.Verdict switch
+ {
+ InputVerdict.Flagged => AgentCoreTelemetry.ModerationFlagged,
+ InputVerdict.Unavailable => AgentCoreTelemetry.ModerationUnavailable,
+ InputVerdict.Clean => AgentCoreTelemetry.ModerationClean,
+ _ => throw new ArgumentOutOfRangeException(nameof(notice), notice.Verdict, "The verdict is outside the closed set."),
+ });
+
+ return AuditRules.IsAudited(notice) ? Audited(AuditEventKind.PromptFlagged) : ValueTask.CompletedTask;
+ }
+
+ ///
+ public override ValueTask OnToolCalledAsync(ToolCalled notice, CancellationToken cancellationToken) =>
+ AuditRules.IsAudited(notice) ? Audited(AuditEventKind.ToolFailed) : ValueTask.CompletedTask;
+
+ ///
+ public override ValueTask OnTurnCompletedAsync(TurnCompleted notice, CancellationToken cancellationToken)
+ {
+ TurnFailureAccounting.Count(TurnFailureAccounting.Of(notice.Outcome, notice.FailedInTool));
+
+ return Audited(AuditEventKind.TurnCompleted);
+ }
+
+ ///
+ public override ValueTask OnFaultAsync(Fault notice, CancellationToken cancellationToken)
+ {
+ if (notice.Kind == FaultKind.ExtractionFailed)
+ {
+ AgentCoreTelemetry.RecordFailure(AgentCoreTelemetry.FailureExtraction);
+ }
+
+ return ValueTask.CompletedTask;
+ }
+
+ ///
+ public override ValueTask OnTurnLatencyAsync(TurnLatency notice, CancellationToken cancellationToken)
+ {
+ switch (notice.Metric)
+ {
+ case LatencyMetric.TimeToFirstToken:
+ AgentCoreTelemetry.RecordTimeToFirstToken(notice.Value);
+ break;
+ case LatencyMetric.TimeToFirstSpeech:
+ AgentCoreTelemetry.RecordTimeToFirstSpeech(notice.Value);
+ break;
+ case LatencyMetric.TimeToReplyEnd:
+ AgentCoreTelemetry.RecordTimeToReplyEnd(notice.Value);
+ break;
+ case LatencyMetric.BargeIn:
+ AgentCoreTelemetry.RecordBargeInLatency(notice.Value);
+ break;
+ default:
+ break;
+ }
+
+ return ValueTask.CompletedTask;
+ }
+
+ private static ValueTask Audited(AuditEventKind kind)
+ {
+ AgentCoreTelemetry.RecordAuditEvent(AuditEventKinds.ToToken(kind));
+ return ValueTask.CompletedTask;
+ }
+ }
+}
diff --git a/src/AgentCore.Application/Hooks/BuiltIn/TurnFailureAccounting.cs b/src/AgentCore.Application/Hooks/BuiltIn/TurnFailureAccounting.cs
new file mode 100644
index 00000000..538c5001
--- /dev/null
+++ b/src/AgentCore.Application/Hooks/BuiltIn/TurnFailureAccounting.cs
@@ -0,0 +1,102 @@
+using AgentCore.Application.Diagnostics;
+using AgentCore.Application.Hooks.Notices;
+using Microsoft.Extensions.Logging;
+
+namespace AgentCore.Application.Hooks.BuiltIn
+{
+ ///
+ /// Which failure a turn met, the counter it bumps and the line it logs. The telemetry hook and
+ /// the logging hook read it from ; a turn the store refused raises no such notice, so
+ /// the session calls for it. One mapping, so a counter and a line never disagree.
+ ///
+ internal static class TurnFailureAccounting
+ {
+ /// The failure row a turn met.
+ internal enum Row
+ {
+ /// The turn met none.
+ None,
+
+ /// The run returned no text.
+ EmptyReply,
+
+ /// A tool spent its retry budget.
+ ToolBudget,
+
+ /// The run faulted above the fallback layer.
+ RunFault,
+ }
+
+ /// Reads the row a turn met.
+ /// How the turn ended.
+ /// Whether the fallback came from a tool that spent its retry budget.
+ /// The row, or .
+ internal static Row Of(TurnOutcome outcome, bool failedInTool) => (outcome, failedInTool) switch
+ {
+ (TurnOutcome.Empty, _) => Row.EmptyReply,
+ (TurnOutcome.Fallback, true) => Row.ToolBudget,
+ (TurnOutcome.Fallback or TurnOutcome.Faulted, _) => Row.RunFault,
+ _ => Row.None,
+ };
+
+ /// Counts the row on agentcore.turn.failures .
+ /// The row.
+ internal static void Count(Row row)
+ {
+ string? kind = row switch
+ {
+ Row.EmptyReply => AgentCoreTelemetry.FailureEmptyReply,
+ Row.ToolBudget => AgentCoreTelemetry.FailureTool,
+ Row.RunFault => AgentCoreTelemetry.FailureRun,
+ Row.None => null,
+ _ => throw new ArgumentOutOfRangeException(nameof(row), row, "The row is outside the closed set."),
+ };
+
+ if (kind is not null)
+ {
+ AgentCoreTelemetry.RecordFailure(kind);
+ }
+ }
+
+ /// Writes the row's line. A tool or run row with no exception writes nothing: the line is its stack trace.
+ /// Where the line goes.
+ /// The conversation.
+ /// The turn.
+ /// The row.
+ /// The exception behind the failure, or .
+ internal static void Write(ILogger logger, string conversationId, int turnIndex, Row row, Exception? cause)
+ {
+ switch (row)
+ {
+ case Row.EmptyReply:
+ Log.EmptyReply(logger, conversationId, turnIndex);
+ break;
+ case Row.ToolBudget when cause is not null:
+ Log.ToolBudgetSpent(logger, conversationId, turnIndex, cause);
+ break;
+ case Row.RunFault when cause is not null:
+ Log.TurnRunFaulted(logger, conversationId, turnIndex, cause);
+ break;
+ case Row.None:
+ case Row.ToolBudget:
+ case Row.RunFault:
+ default:
+ break;
+ }
+ }
+
+ /// Counts and logs the row of a turn the store refused, which no hook hears about.
+ /// Where the line goes.
+ /// The conversation.
+ /// The turn.
+ /// How the turn ended.
+ /// Whether the fallback came from a tool that spent its retry budget.
+ /// The exception behind the failure, or .
+ internal static void Account(ILogger logger, string conversationId, int turnIndex, TurnOutcome outcome, bool failedInTool, Exception? cause)
+ {
+ Row row = Of(outcome, failedInTool);
+ Count(row);
+ Write(logger, conversationId, turnIndex, row, cause);
+ }
+ }
+}
diff --git a/src/AgentCore.Application/Hooks/BuiltInHooks.cs b/src/AgentCore.Application/Hooks/BuiltInHooks.cs
new file mode 100644
index 00000000..1616a57d
--- /dev/null
+++ b/src/AgentCore.Application/Hooks/BuiltInHooks.cs
@@ -0,0 +1,22 @@
+using AgentCore.Application.Hooks.BuiltIn;
+using AgentCore.Application.Ports;
+using Microsoft.Extensions.Logging;
+using Microsoft.Extensions.Logging.Abstractions;
+
+namespace AgentCore.Application.Hooks
+{
+ /// AgentCore's own hooks, for a host that builds sessions without ASP.NET. They run first.
+ public static class BuiltInHooks
+ {
+ /// Creates the built-in hooks in the order they run.
+ /// Where the audit chain is appended.
+ /// Where the built-ins report, such as the "log once" lines and a row the sink refused, or for nowhere.
+ /// The hooks; put them first in AgentCompilationContext.Hooks .
+ /// is .
+ public static IReadOnlyList Create(IAuditSinkPort auditSink, ILogger? logger = null)
+ {
+ ArgumentNullException.ThrowIfNull(auditSink);
+ return [new TelemetryHook(), new LoggingHook(logger), new AuditHook(auditSink, logger ?? NullLogger.Instance)];
+ }
+ }
+}
diff --git a/src/AgentCore.Application/Hooks/Engine/ApprovalDecision.cs b/src/AgentCore.Application/Hooks/Engine/ApprovalDecision.cs
new file mode 100644
index 00000000..ebd64c75
--- /dev/null
+++ b/src/AgentCore.Application/Hooks/Engine/ApprovalDecision.cs
@@ -0,0 +1,7 @@
+namespace AgentCore.Application.Hooks.Engine
+{
+ /// What the BeforeToolApproval hook chain decided about one tool call.
+ /// approved, denied, left to the patterns or a human.
+ /// The denial's reason, or .
+ internal sealed record ApprovalDecision(bool? Approved, string? Reason);
+}
diff --git a/src/AgentCore.Application/Hooks/Engine/Delivery.cs b/src/AgentCore.Application/Hooks/Engine/Delivery.cs
new file mode 100644
index 00000000..2fdc1160
--- /dev/null
+++ b/src/AgentCore.Application/Hooks/Engine/Delivery.cs
@@ -0,0 +1,7 @@
+namespace AgentCore.Application.Hooks.Engine
+{
+ /// One item of a hook's channel: a notice, or a flush barrier that completes when the reader reaches it.
+ /// The notice to hand the hook, or for a barrier.
+ /// Completed when the reader reaches this item, or for a notice.
+ internal readonly record struct Delivery(HookNotice? Notice, TaskCompletionSource? Barrier);
+}
diff --git a/src/AgentCore.Application/Hooks/Engine/GateRunner.cs b/src/AgentCore.Application/Hooks/Engine/GateRunner.cs
new file mode 100644
index 00000000..92d0cc13
--- /dev/null
+++ b/src/AgentCore.Application/Hooks/Engine/GateRunner.cs
@@ -0,0 +1,178 @@
+using AgentCore.Application.Hooks.Notices;
+using Microsoft.Extensions.Logging;
+
+namespace AgentCore.Application.Hooks.Engine
+{
+ /// Runs one gate's chain: each overriding hook in order, each under the gate's deadline.
+ /// Which hooks override which gate.
+ /// Where a failing hook is reported.
+ /// The clock the deadlines run on.
+ internal sealed class GateRunner(HookTable table, ILogger logger, TimeProvider timers)
+ {
+ internal HookTable Table => table;
+
+ internal bool Overrides(GatePoint point) => table.Overrides(point);
+
+ /// Runs the chain and returns the value the hooks left.
+ /// The gate's view type.
+ /// The value the chain decides.
+ /// The gate.
+ /// Where and when it fires.
+ /// The value before any hook.
+ /// Builds one hook's view from the value so far.
+ /// Calls the hook's gate method.
+ /// Applies one view's staged verbs to the value. Called only for a hook that returned in time, after its view is sealed.
+ /// Applies the gate's safe verb, for a hook that fails closed.
+ /// Raises Fault(HookFailed) to every hook but the failing one, or .
+ /// The turn's token. Its cancellation propagates and is not a hook failure.
+ /// How long each hook may take, or for 's own deadline.
+ /// The value after the last hook that ran.
+ internal async ValueTask RunAsync(
+ GatePoint point,
+ HookScope scope,
+ TState state,
+ Func open,
+ Func invoke,
+ Func fold,
+ Func failClosed,
+ Action? raiseFault,
+ CancellationToken cancellationToken,
+ TimeSpan? deadline = null)
+ where TGate : HookGate
+ {
+ TimeSpan limit = deadline ?? point.Deadline;
+ foreach (AgentHook hook in table.For(point))
+ {
+ TGate view = open(state);
+ HookRun run;
+ try
+ {
+ run = await InvokeAsync(hook, view, invoke, limit, cancellationToken).ConfigureAwait(false);
+ }
+ finally
+ {
+ view.Seal();
+ }
+
+ if (run.Failure is null)
+ {
+ state = fold(state, view);
+ if (view.IsTerminal)
+ {
+ break;
+ }
+
+ continue;
+ }
+
+ Report(point, hook, scope, run, limit, raiseFault);
+
+ if (FailureOf(hook, point) == HookFailure.Closed)
+ {
+ return failClosed(state);
+ }
+ }
+
+ return state;
+ }
+
+ private static HookFailure FailureOf(AgentHook hook, GatePoint point)
+ {
+ try
+ {
+ return hook.FailureFor(point);
+ }
+ catch (Exception exception) when (exception is not OutOfMemoryException)
+ {
+ return point.DefaultFailure;
+ }
+ }
+
+ // A hook's own TimeoutException faulted its task; the wait's own timeout is a fresh exception.
+ private static bool ThrownBy(Task running, Exception exception) =>
+ running.Exception?.InnerExceptions.Contains(exception) == true;
+
+ // Disposing a source unhooks it from the turn token and stops its timer, so a hook still running would
+ // never see its token cancelled. The sources live until the hook ends, however long that is.
+ private static void ReleaseWhenDone(Task running, CancellationTokenSource deadline, CancellationTokenSource linked)
+ {
+ if (running.IsCompleted)
+ {
+ linked.Dispose();
+ deadline.Dispose();
+ return;
+ }
+
+ _ = running.ContinueWith(
+ task =>
+ {
+ _ = task.Exception;
+ linked.Dispose();
+ deadline.Dispose();
+ },
+ CancellationToken.None,
+ TaskContinuationOptions.ExecuteSynchronously,
+ TaskScheduler.Default);
+ }
+
+ private async ValueTask InvokeAsync(
+ AgentHook hook,
+ TGate view,
+ Func invoke,
+ TimeSpan limit,
+ CancellationToken cancellationToken)
+ {
+ CancellationTokenSource deadline = new(limit, timers);
+ CancellationTokenSource linked = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken, deadline.Token);
+
+ // The hook starts on the pool, so one that blocks its thread before its first await still
+ // misses the deadline instead of holding the turn.
+ Task running = Task.Run(() => invoke(hook, view, linked.Token).AsTask(), CancellationToken.None);
+ try
+ {
+ await running.WaitAsync(limit, timers, cancellationToken).ConfigureAwait(false);
+ return HookRun.Completed;
+ }
+ catch (TimeoutException exception) when (!ThrownBy(running, exception))
+ {
+ return new HookRun(exception, Abandoned: true);
+ }
+ catch (Exception exception) when (!cancellationToken.IsCancellationRequested)
+ {
+ return new HookRun(exception, Abandoned: false);
+ }
+ finally
+ {
+ ReleaseWhenDone(running, deadline, linked);
+ }
+ }
+
+ private void Report(GatePoint point, AgentHook hook, HookScope scope, HookRun run, TimeSpan limit, Action? raiseFault)
+ {
+ Exception failure = run.Failure!;
+ string hookType = hook.GetType().FullName ?? hook.GetType().Name;
+ string conversationId = scope.ConversationId ?? string.Empty;
+
+ if (run.Abandoned)
+ {
+ HookLog.GateAbandoned(logger, hookType, point.Name, conversationId, (long)limit.TotalMilliseconds);
+ }
+ else
+ {
+ HookLog.GateFailed(logger, hookType, point.Name, conversationId, failure);
+ }
+
+ raiseFault?.Invoke(
+ new Fault(scope, FaultKind.HookFailed, $"{hookType} failed at gate {point.Name}: {failure.GetType().Name}: {failure.Message}", failure),
+ hook);
+ }
+
+ /// How one hook's call ended.
+ /// What went wrong, or when the hook returned in time.
+ /// Whether the hook missed the deadline, rather than threw in time.
+ private readonly record struct HookRun(Exception? Failure, bool Abandoned)
+ {
+ public static HookRun Completed => default;
+ }
+ }
+}
diff --git a/src/AgentCore.Application/Hooks/Engine/HookLog.cs b/src/AgentCore.Application/Hooks/Engine/HookLog.cs
new file mode 100644
index 00000000..671a8896
--- /dev/null
+++ b/src/AgentCore.Application/Hooks/Engine/HookLog.cs
@@ -0,0 +1,100 @@
+using Microsoft.Extensions.Logging;
+
+namespace AgentCore.Application.Hooks.Engine
+{
+ /// Every line the hook engine writes. Each names the hook type and the gate or notice.
+ internal static partial class HookLog
+ {
+ /// A gate hook threw.
+ /// The engine's logger.
+ /// The full name of the hook's type.
+ /// The gate point's name.
+ /// The id of the conversation, empty before one exists.
+ /// What the hook threw.
+ [LoggerMessage(
+ EventId = 200,
+ Level = LogLevel.Warning,
+ Message = "The hook {HookType} failed at gate {Gate} of conversation {ConversationId}. Its verbs were dropped.")]
+ public static partial void GateFailed(ILogger logger, string hookType, string gate, string conversationId, Exception exception);
+
+ /// A gate hook missed its deadline and was abandoned.
+ /// The engine's logger.
+ /// The full name of the hook's type.
+ /// The gate point's name.
+ /// The id of the conversation, empty before one exists.
+ /// The gate's deadline, in milliseconds.
+ [LoggerMessage(
+ EventId = 201,
+ Level = LogLevel.Warning,
+ Message = "The hook {HookType} missed the {DeadlineMs} ms deadline of gate {Gate} of conversation {ConversationId}. It was abandoned and its verbs were dropped.")]
+ public static partial void GateAbandoned(ILogger logger, string hookType, string gate, string conversationId, long deadlineMs);
+
+ /// A notice hook threw.
+ /// The engine's logger.
+ /// The full name of the hook's type.
+ /// The notice's type name.
+ /// The id of the conversation, empty for a host notice.
+ /// What the hook threw.
+ [LoggerMessage(
+ EventId = 202,
+ Level = LogLevel.Warning,
+ Message = "The hook {HookType} failed on the notice {NoticeType} of conversation {ConversationId}.")]
+ public static partial void NoticeFailed(ILogger logger, string hookType, string noticeType, string conversationId, Exception exception);
+
+ /// A notice hook passed its NoticeTimeout and the delivery was abandoned.
+ /// The engine's logger.
+ /// The full name of the hook's type.
+ /// The notice's type name.
+ /// The id of the conversation, empty for a host notice.
+ /// The hook's notice timeout, in milliseconds.
+ [LoggerMessage(
+ EventId = 203,
+ Level = LogLevel.Warning,
+ Message = "The hook {HookType} did not finish the notice {NoticeType} of conversation {ConversationId} within {TimeoutMs} ms. The delivery was abandoned.")]
+ public static partial void NoticeAbandoned(ILogger logger, string hookType, string noticeType, string conversationId, long timeoutMs);
+
+ /// A notice hook with no timeout is still working on one notice.
+ /// The engine's logger.
+ /// The full name of the hook's type.
+ /// The notice's type name.
+ /// The id of the conversation, empty for a host notice.
+ [LoggerMessage(
+ EventId = 204,
+ Level = LogLevel.Warning,
+ Message = "The hook {HookType} is still working on the notice {NoticeType} of conversation {ConversationId}. Its later notices wait behind it.")]
+ public static partial void NoticeStillWaiting(ILogger logger, string hookType, string noticeType, string conversationId);
+
+ /// The host stopped before every notice was delivered.
+ /// The engine's logger.
+ /// How many mailboxes were still draining.
+ /// How long the stop waited, in milliseconds.
+ [LoggerMessage(
+ EventId = 205,
+ Level = LogLevel.Warning,
+ Message = "The host stopped before the hooks of {Mailboxes} conversations finished their notices; it waited {TimeoutMs} ms.")]
+ public static partial void DrainTimedOut(ILogger logger, int mailboxes, long timeoutMs);
+
+ /// The engine itself failed while it delivered a notice; the hook's reader goes on with the next one.
+ /// The engine's logger.
+ /// The full name of the hook's type.
+ /// The notice's type name.
+ /// The id of the conversation, empty for a host notice.
+ /// What went wrong.
+ [LoggerMessage(
+ EventId = 206,
+ Level = LogLevel.Error,
+ Message = "AgentCore failed to deliver the notice {NoticeType} of conversation {ConversationId} to the hook {HookType}. The hook's later notices still go out.")]
+ public static partial void DeliveryFailed(ILogger logger, string hookType, string noticeType, string conversationId, Exception exception);
+
+ /// A hook kept denying tool calls past the approval layer's round cap, so the turn ends as a fault.
+ /// The engine's logger.
+ /// The id of the conversation.
+ /// The turn that ended.
+ /// How many model rounds of denials the layer answered.
+ [LoggerMessage(
+ EventId = 207,
+ Level = LogLevel.Warning,
+ Message = "A hook denied tool calls of turn {TurnIndex} of conversation {ConversationId} after {MaxRounds} model rounds of denials. The turn ends as a fault, and no person is asked.")]
+ public static partial void DenialRoundsExhausted(ILogger logger, string conversationId, int turnIndex, int maxRounds);
+ }
+}
diff --git a/src/AgentCore.Application/Hooks/Engine/HookReader.cs b/src/AgentCore.Application/Hooks/Engine/HookReader.cs
new file mode 100644
index 00000000..a03a7d1a
--- /dev/null
+++ b/src/AgentCore.Application/Hooks/Engine/HookReader.cs
@@ -0,0 +1,295 @@
+using System.Threading.Channels;
+using AgentCore.Application.Hooks.Notices;
+
+namespace AgentCore.Application.Hooks.Engine
+{
+ ///
+ /// One hook's queue inside one mailbox, and the task that reads it. Notices reach the hook one at a time, in
+ /// the order the mailbox wrote them.
+ ///
+ internal sealed class HookReader
+ {
+ /// The longest delay a timer takes: uint.MaxValue - 1 ms, about 49.7 days.
+ internal static readonly TimeSpan LongestTimeout = TimeSpan.FromMilliseconds(uint.MaxValue - 1);
+
+ private static readonly TimeSpan DefaultTimeout = TimeSpan.FromSeconds(30);
+
+ private readonly Channel _channel = Channel.CreateUnbounded(new UnboundedChannelOptions { SingleReader = true });
+
+ private readonly Mailbox _box;
+
+ private readonly IReadOnlySet _wants;
+
+ private readonly TimeSpan? _timeout;
+
+ private Task? _reading;
+
+ private int _pending;
+
+ internal HookReader(Mailbox box, AgentHook hook, IReadOnlySet wants)
+ {
+ _box = box;
+ Hook = hook;
+ _wants = wants;
+ _timeout = TimeoutOf(hook);
+ }
+
+ internal AgentHook Hook { get; }
+
+ internal Task Completion => Volatile.Read(ref _reading) ?? Task.CompletedTask;
+
+ /// Gets whether a notice was ever written here, which starts the reading task.
+ internal bool Started => Volatile.Read(ref _reading) is not null;
+
+ internal bool IsIdle => Volatile.Read(ref _pending) == 0;
+
+ internal bool Wants(HookNotice notice) => _wants.Contains(notice.GetType());
+
+ /// Queues one notice. Runs under the mailbox lock, so queue order is Sequence order.
+ internal void Write(HookNotice notice)
+ {
+ _ = Interlocked.Increment(ref _pending);
+ if (!_channel.Writer.TryWrite(new Delivery(notice, null)))
+ {
+ _ = Interlocked.Decrement(ref _pending);
+ return;
+ }
+
+ if (_reading is null)
+ {
+ Volatile.Write(ref _reading, StartReading());
+ }
+ }
+
+ /// Queues a barrier. Runs under the mailbox lock. Completes once every earlier notice was handled.
+ internal Task Barrier()
+ {
+ if (_reading is null)
+ {
+ return Task.CompletedTask;
+ }
+
+ TaskCompletionSource reached = new(TaskCreationOptions.RunContinuationsAsynchronously);
+ _ = Interlocked.Increment(ref _pending);
+ if (_channel.Writer.TryWrite(new Delivery(null, reached)))
+ {
+ return reached.Task;
+ }
+
+ _ = Interlocked.Decrement(ref _pending);
+ return Task.CompletedTask;
+ }
+
+ internal void Complete()
+ {
+ _ = _channel.Writer.TryComplete();
+ }
+
+ // A timer refuses a negative delay or one past LongestTimeout. Longer than a timer can wait means
+ // "wait for every notice", which is what null says; a negative one is a mistake and gets the default.
+ private static TimeSpan? TimeoutOf(AgentHook hook)
+ {
+ TimeSpan? timeout;
+ try
+ {
+ timeout = hook.NoticeTimeout;
+ }
+ catch (Exception exception) when (exception is not OutOfMemoryException)
+ {
+ return DefaultTimeout;
+ }
+
+ return timeout switch
+ {
+ null => null,
+ { } value when value == Timeout.InfiniteTimeSpan || value > LongestTimeout => null,
+ { } value when value < TimeSpan.Zero => DefaultTimeout,
+ _ => timeout,
+ };
+ }
+
+ private static string NameOf(AgentHook hook) => hook.GetType().FullName ?? hook.GetType().Name;
+
+ // The reader lives as long as the mailbox. Started with the raiser's context, every later delivery would
+ // run inside the first turn's trace, logging scopes and run context, and keep them alive.
+ private Task StartReading()
+ {
+ bool restoreFlow = false;
+ try
+ {
+ if (!ExecutionContext.IsFlowSuppressed())
+ {
+ _ = ExecutionContext.SuppressFlow();
+ restoreFlow = true;
+ }
+
+ return Task.Run(ReadAsync);
+ }
+ finally
+ {
+ if (restoreFlow)
+ {
+ ExecutionContext.RestoreFlow();
+ }
+ }
+ }
+
+ private async Task ReadAsync()
+ {
+ ChannelReader reader = _channel.Reader;
+ while (true)
+ {
+ bool more;
+ using (CancellationTokenSource idle = new(NoticeHub.IdleAfter, _box.Hub.Timers))
+ {
+ try
+ {
+ more = await reader.WaitToReadAsync(idle.Token).ConfigureAwait(false);
+ }
+ catch (OperationCanceledException) when (idle.IsCancellationRequested)
+ {
+ // An evicted mailbox completes this channel, so the next wait returns false and the loop ends.
+ _ = _box.TryEvict();
+ continue;
+ }
+ }
+
+ if (!more)
+ {
+ return;
+ }
+
+ while (reader.TryRead(out Delivery delivery))
+ {
+ await HandleAsync(delivery).ConfigureAwait(false);
+ }
+ }
+ }
+
+ // A reader that died would leave its channel growing, its mailbox never evicted, and every flush waiting.
+ private async Task HandleAsync(Delivery delivery)
+ {
+ try
+ {
+ if (delivery.Barrier is { } barrier)
+ {
+ _ = barrier.TrySetResult();
+ }
+ else
+ {
+ await DeliverAsync(delivery.Notice!).ConfigureAwait(false);
+ }
+ }
+ catch (Exception exception) when (exception is not OutOfMemoryException)
+ {
+ EngineFailed(delivery.Notice, exception);
+ }
+ finally
+ {
+ _ = Interlocked.Decrement(ref _pending);
+ }
+ }
+
+ private async Task DeliverAsync(HookNotice notice)
+ {
+ using CancellationTokenSource deadline = _timeout is { } limit ? new(limit, _box.Hub.Timers) : new();
+
+ Task running;
+ try
+ {
+ running = NoticeDispatch.DeliverAsync(Hook, notice, deadline.Token).AsTask();
+ }
+ catch (Exception exception) when (exception is not OutOfMemoryException)
+ {
+ Failed(notice, exception);
+ return;
+ }
+
+ if (_timeout is { } timeout)
+ {
+ await WithinAsync(notice, running, timeout, deadline).ConfigureAwait(false);
+ }
+ else
+ {
+ await PatientlyAsync(notice, running).ConfigureAwait(false);
+ }
+ }
+
+ private async Task WithinAsync(HookNotice notice, Task running, TimeSpan timeout, CancellationTokenSource deadline)
+ {
+ try
+ {
+ await running.WaitAsync(timeout, _box.Hub.Timers).ConfigureAwait(false);
+ }
+ // A hook that honours its token gives up at the deadline too; that is the same missed deadline.
+ catch (Exception exception) when ((exception is TimeoutException && !running.IsCompleted)
+ || (exception is OperationCanceledException && deadline.IsCancellationRequested))
+ {
+ HookLog.NoticeAbandoned(_box.Hub.Logger, NameOf(Hook), notice.GetType().Name, notice.Scope.ConversationId ?? string.Empty, (long)timeout.TotalMilliseconds);
+ _ = running.ContinueWith(static task => task.Exception, CancellationToken.None, TaskContinuationOptions.OnlyOnFaulted, TaskScheduler.Default);
+ RaiseFault(notice, $"{NameOf(Hook)} did not finish {notice.GetType().Name} within {timeout.TotalMilliseconds:0} ms.", cause: null);
+ }
+ catch (Exception exception) when (exception is not OutOfMemoryException)
+ {
+ Failed(notice, exception);
+ }
+ }
+
+ private async Task PatientlyAsync(HookNotice notice, Task running)
+ {
+ bool reported = false;
+ while (true)
+ {
+ try
+ {
+ await running.WaitAsync(NoticeHub.StillWaitingEvery, _box.Hub.Timers).ConfigureAwait(false);
+ return;
+ }
+ catch (TimeoutException) when (!running.IsCompleted)
+ {
+ HookLog.NoticeStillWaiting(_box.Hub.Logger, NameOf(Hook), notice.GetType().Name, notice.Scope.ConversationId ?? string.Empty);
+ if (!reported)
+ {
+ reported = true;
+ RaiseFault(notice, $"{NameOf(Hook)} is still working on {notice.GetType().Name}.", cause: null);
+ }
+ }
+ catch (Exception exception) when (exception is not OutOfMemoryException)
+ {
+ Failed(notice, exception);
+ return;
+ }
+ }
+ }
+
+ private void EngineFailed(HookNotice? notice, Exception exception)
+ {
+ try
+ {
+ HookLog.DeliveryFailed(_box.Hub.Logger, NameOf(Hook), notice?.GetType().Name ?? string.Empty, notice?.Scope.ConversationId ?? string.Empty, exception);
+ }
+ catch (Exception logFailure) when (logFailure is not OutOfMemoryException)
+ {
+ // The logger itself throws. Nothing is left to report to, and the reader must go on.
+ _ = logFailure;
+ }
+ }
+
+ private void Failed(HookNotice notice, Exception exception)
+ {
+ HookLog.NoticeFailed(_box.Hub.Logger, NameOf(Hook), notice.GetType().Name, notice.Scope.ConversationId ?? string.Empty, exception);
+ RaiseFault(notice, $"{NameOf(Hook)} failed on {notice.GetType().Name}: {exception.GetType().Name}: {exception.Message}", exception);
+ }
+
+ private void RaiseFault(HookNotice notice, string message, Exception? cause)
+ {
+ // Two hooks that both throw on faults would otherwise trade HookFailed notices forever.
+ if (notice is Fault { Kind: FaultKind.HookFailed })
+ {
+ return;
+ }
+
+ _ = _box.Hub.Raise(new Fault(notice.Scope, FaultKind.HookFailed, message, cause), _box.Hub.Timers, except: Hook);
+ }
+ }
+}
diff --git a/src/AgentCore.Application/Hooks/Engine/HookRuntime.cs b/src/AgentCore.Application/Hooks/Engine/HookRuntime.cs
new file mode 100644
index 00000000..880483c1
--- /dev/null
+++ b/src/AgentCore.Application/Hooks/Engine/HookRuntime.cs
@@ -0,0 +1,82 @@
+using AgentCore.Application.Hooks.Notices;
+using Microsoft.Extensions.Logging;
+using Microsoft.Extensions.Logging.Abstractions;
+
+namespace AgentCore.Application.Hooks.Engine
+{
+ /// The hooks of one compile: which hook overrides what, the gate runner, and the notice hub.
+ internal sealed class HookRuntime : IAsyncDisposable
+ {
+ /// How long a stop waits for queued notices when the host names no shutdown timeout.
+ internal static readonly TimeSpan DefaultStopTimeout = TimeSpan.FromSeconds(30);
+
+ private HookRuntime(HookTable gates, NoticeHub notices, ILogger logger, TimeProvider timers)
+ {
+ Gates = new GateRunner(gates, logger, timers);
+ Notices = notices;
+ Logger = logger;
+ Timers = timers;
+ }
+
+ internal static HookRuntime Empty { get; } = Create([], loggers: null);
+
+ internal HookTable Table => Gates.Table;
+
+ internal GateRunner Gates { get; }
+
+ internal NoticeHub Notices { get; }
+
+ internal ILogger Logger { get; }
+
+ internal TimeProvider Timers { get; }
+
+ /// Gets or sets how long a dispose waits for queued notices: the host's shutdown timeout.
+ internal TimeSpan StopTimeout { get; set; } = DefaultStopTimeout;
+
+ /// Builds the runtime of one compile.
+ /// The hooks, built-ins first.
+ /// Where the engine logs, or .
+ /// The clock of the deadlines and timeouts. Production passes nothing: .
+ internal static HookRuntime Create(IReadOnlyList hooks, ILoggerFactory? loggers, TimeProvider? timers = null)
+ {
+ HookTable table = HookTable.Build(hooks);
+ ILogger logger = loggers?.CreateLogger("AgentCore.Hooks") ?? NullLogger.Instance;
+ TimeProvider clock = timers ?? TimeProvider.System;
+ return new HookRuntime(table, new NoticeHub(table, logger, clock), logger, clock);
+ }
+
+ /// Builds the runtime of a factory with hooks of its own: the same gates, a notice hub over both sets.
+ /// A hook overrides a gate and was not compiled in.
+ internal HookRuntime WithNoticeHooks(IEnumerable hooks)
+ {
+ ArgumentNullException.ThrowIfNull(hooks);
+
+ AgentHook[] added = [.. hooks];
+ foreach (AgentHook hook in added)
+ {
+ ArgumentNullException.ThrowIfNull(hook, nameof(hooks));
+ if (!Table.Hooks.Contains(hook, ReferenceEqualityComparer.Instance) && HookTable.Build([hook]).OverridesAnyGate(hook))
+ {
+ throw new ArgumentException(
+ $"The hook '{hook.GetType().FullName}' overrides a gate, and gates are compiled into the agent. "
+ + $"Pass it in {nameof(Configuration.Compilation.AgentCompilationContext)}.{nameof(Configuration.Compilation.AgentCompilationContext.Hooks)}.",
+ nameof(hooks));
+ }
+ }
+
+ HookTable notices = HookTable.Build([.. Table.Hooks, .. added]);
+ return new HookRuntime(Table, new NoticeHub(notices, Logger, Timers), Logger, Timers);
+ }
+
+ /// Raises a gate's Fault(HookFailed) where no session is open (BeforeCall, BeforeEntry).
+ internal void RaiseHostFault(Fault fault, AgentHook except)
+ {
+ _ = Notices.Raise(fault, Timers, except: except);
+ }
+
+ internal ValueTask StopAsync(TimeSpan timeout) => Notices.StopAsync(timeout);
+
+ ///
+ public ValueTask DisposeAsync() => StopAsync(StopTimeout);
+ }
+}
diff --git a/src/AgentCore.Application/Hooks/Engine/HookScopes.cs b/src/AgentCore.Application/Hooks/Engine/HookScopes.cs
new file mode 100644
index 00000000..78649038
--- /dev/null
+++ b/src/AgentCore.Application/Hooks/Engine/HookScopes.cs
@@ -0,0 +1,11 @@
+namespace AgentCore.Application.Hooks.Engine
+{
+ /// Scopes for the gates and notices that have no session.
+ internal static class HookScopes
+ {
+ internal static HookScope Host(DateTimeOffset now) => new(null, null, null, null, Guid.Empty, 0, now);
+
+ internal static HookScope ForGate(string? conversationId, string? entry, DateTimeOffset now) =>
+ new(conversationId, entry, null, null, Guid.Empty, 0, now);
+ }
+}
diff --git a/src/AgentCore.Application/Hooks/Engine/HookTable.cs b/src/AgentCore.Application/Hooks/Engine/HookTable.cs
new file mode 100644
index 00000000..acce6e8a
--- /dev/null
+++ b/src/AgentCore.Application/Hooks/Engine/HookTable.cs
@@ -0,0 +1,115 @@
+using System.Reflection;
+
+namespace AgentCore.Application.Hooks.Engine
+{
+ ///
+ /// What each registered hook overrides, found once by reflection. A method no hook overrides
+ /// has an empty array here, so its gate or notice costs one length check.
+ ///
+ internal sealed class HookTable
+ {
+ private static readonly MethodInfo[] NoticeMethods = [.. typeof(AgentHook)
+ .GetMethods(BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly)
+ .Where(static method => method.IsVirtual
+ && method.GetParameters() is [{ ParameterType: var type }, _]
+ && typeof(HookNotice).IsAssignableFrom(type))];
+
+ private static readonly IReadOnlySet NoNotices = new HashSet();
+
+ private readonly Dictionary _gates;
+
+ private readonly Dictionary> _notices;
+
+ private readonly HashSet _wanted;
+
+ private HookTable(IReadOnlyList hooks)
+ {
+ Hooks = hooks;
+ _gates = GatePoint.All.ToDictionary(
+ static point => point,
+ point => hooks.Where(hook => Overrides(hook, typeof(AgentHook).GetMethod(point.MethodName)!)).ToArray());
+ _notices = new Dictionary>(ReferenceEqualityComparer.Instance);
+
+ foreach (AgentHook hook in hooks)
+ {
+ HashSet types = [.. NoticeMethods
+ .Where(method => Overrides(hook, method))
+ .Select(static method => method.GetParameters()[0].ParameterType)];
+
+ if (types.Count > 0)
+ {
+ _notices[hook] = types;
+ }
+ }
+
+ NoticeHooks = [.. hooks.Where(_notices.ContainsKey)];
+ _wanted = [.. _notices.Values.SelectMany(static types => types)];
+ }
+
+ /// Gets a table with no hooks.
+ internal static HookTable Empty { get; } = new([]);
+
+ /// Gets every hook, in the order they run: AgentCore's built-ins first, then the host's.
+ internal IReadOnlyList Hooks { get; }
+
+ /// Gets the hooks that override at least one notice method, in run order.
+ internal IReadOnlyList NoticeHooks { get; }
+
+ /// Builds the table. An instance listed twice counts once, at its first place.
+ /// The hooks in run order.
+ /// The table.
+ internal static HookTable Build(IReadOnlyList hooks)
+ {
+ ArgumentNullException.ThrowIfNull(hooks);
+
+ List distinct = [];
+ HashSet seen = new(ReferenceEqualityComparer.Instance);
+ foreach (AgentHook hook in hooks)
+ {
+ ArgumentNullException.ThrowIfNull(hook, nameof(hooks));
+ if (seen.Add(hook))
+ {
+ distinct.Add(hook);
+ }
+ }
+
+ return new HookTable(distinct);
+ }
+
+ internal AgentHook[] For(GatePoint point) => _gates[point];
+
+ internal bool Overrides(GatePoint point) => _gates[point].Length > 0;
+
+ internal IReadOnlySet NoticesOf(AgentHook hook) =>
+ _notices.TryGetValue(hook, out IReadOnlySet? types) ? types : NoNotices;
+
+ internal bool Wants(Type noticeType) => _wanted.Contains(noticeType);
+
+ internal bool OverridesAnyGate(AgentHook hook) =>
+ GatePoint.All.Any(point => Array.Exists(_gates[point], listed => ReferenceEquals(listed, hook)));
+
+ /// Whether a hook type overrides one gate, read from the type alone.
+ /// The hook's type.
+ /// The gate.
+ /// when the type overrides the gate's method.
+ internal static bool TypeOverrides(Type hookType, GatePoint point)
+ {
+ return TypeOverrides(hookType, typeof(AgentHook).GetMethod(point.MethodName)!);
+ }
+
+ private static bool Overrides(AgentHook hook, MethodInfo baseMethod) => TypeOverrides(hook.GetType(), baseMethod);
+
+ // GetBaseDefinition tells an override apart from a method hidden with `new`, which the base dispatch never calls.
+ private static bool TypeOverrides(Type hookType, MethodInfo baseMethod)
+ {
+ MethodInfo? found = hookType.GetMethod(
+ baseMethod.Name,
+ BindingFlags.Public | BindingFlags.Instance,
+ [.. baseMethod.GetParameters().Select(static parameter => parameter.ParameterType)]);
+
+ return found is not null
+ && found.DeclaringType != typeof(AgentHook)
+ && found.GetBaseDefinition() == baseMethod;
+ }
+ }
+}
diff --git a/src/AgentCore.Application/Hooks/Engine/Mailbox.cs b/src/AgentCore.Application/Hooks/Engine/Mailbox.cs
new file mode 100644
index 00000000..19d64322
--- /dev/null
+++ b/src/AgentCore.Application/Hooks/Engine/Mailbox.cs
@@ -0,0 +1,205 @@
+using AgentCore.Application.Hooks.Notices;
+
+namespace AgentCore.Application.Hooks.Engine
+{
+ ///
+ /// The notices of one conversation id, or of the host. Its private lock makes the Sequence count and the
+ /// channel writes one step, so Sequence order is delivery order. No turn code holds this lock,
+ /// and no hook code runs under it.
+ ///
+ internal sealed class Mailbox
+ {
+ private readonly Lock _lock = new();
+
+ private readonly string? _conversationId;
+
+ private readonly HookReader[] _readers;
+
+ private long _sequence;
+
+ private Guid _lastSession;
+
+ private (Guid Session, int LastTurn)? _ended;
+
+ private int _pins;
+
+ private bool _closed;
+
+ internal Mailbox(NoticeHub hub, string? conversationId)
+ {
+ Hub = hub;
+ _conversationId = conversationId;
+ _readers = [.. hub.Table.NoticeHooks.Select(hook => new HookReader(this, hook, hub.Table.NoticesOf(hook)))];
+ }
+
+ internal NoticeHub Hub { get; }
+
+ internal IEnumerable Readers => _readers.Select(static reader => reader.Completion);
+
+ /// Stamps and queues one notice.
+ /// when this mailbox was closed and the caller must look up a new one.
+ internal bool TryRaise(
+ HookNotice notice, TimeProvider clock, int? endsAfterTurn, AgentHook? except, DateTimeOffset? occurredAt, out Guid eventId)
+ {
+ // A version 7 Guid reads the wall clock, which can cost microseconds; its random bits never sort
+ // within one millisecond anyway, so it is made before the lock and only stamped under it.
+ eventId = Guid.CreateVersion7();
+ lock (_lock)
+ {
+ if (_closed)
+ {
+ return false;
+ }
+
+ HookScope scope = notice.Scope;
+ if (HeldBackByTheEnd(notice))
+ {
+ return true;
+ }
+
+ HookNotice stamped = notice with
+ {
+ Scope = scope with { Sequence = ++_sequence, OccurredAt = occurredAt ?? clock.GetUtcNow() },
+ EventId = eventId,
+ };
+
+ if (scope.SessionId != Guid.Empty)
+ {
+ _lastSession = scope.SessionId;
+ }
+
+ if (stamped is ConversationEnded)
+ {
+ _ended = (scope.SessionId, endsAfterTurn ?? -1);
+ }
+
+ foreach (HookReader reader in _readers)
+ {
+ if (!ReferenceEquals(reader.Hook, except) && reader.Wants(stamped))
+ {
+ reader.Write(stamped);
+ }
+ }
+
+ return true;
+ }
+ }
+
+ /// Records an end no hook hears, so the ended rule still holds back the session's later turns.
+ internal void MarkEnded(Guid sessionId, int? endsAfterTurn)
+ {
+ lock (_lock)
+ {
+ _ended = (sessionId, endsAfterTurn ?? -1);
+ }
+ }
+
+ internal bool LoadedBefore(Guid sessionId)
+ {
+ lock (_lock)
+ {
+ return !_closed && _lastSession != Guid.Empty && _lastSession != sessionId;
+ }
+ }
+
+ internal Task FlushAsync()
+ {
+ lock (_lock)
+ {
+ return _closed ? Task.CompletedTask : Task.WhenAll(_readers.Select(static reader => reader.Barrier()));
+ }
+ }
+
+ /// Keeps this mailbox while a session is loaded, so its Sequence does not restart.
+ /// when this mailbox was closed and the caller must look up a new one.
+ internal bool TryPin()
+ {
+ lock (_lock)
+ {
+ if (_closed)
+ {
+ return false;
+ }
+
+ _pins++;
+ return true;
+ }
+ }
+
+ /// Lets go of one pin. A mailbox no notice ever reached has no reader to evict it, so the last release does.
+ internal void Release()
+ {
+ lock (_lock)
+ {
+ if (_pins == 0)
+ {
+ return;
+ }
+
+ _pins--;
+ if (_pins == 0 && !_closed && !Array.Exists(_readers, static reader => reader.Started))
+ {
+ CloseAndForgetLocked();
+ }
+ }
+ }
+
+ /// Removes this mailbox when no session pins it and every reader is idle. A racing raise then makes a new one.
+ internal bool TryEvict()
+ {
+ lock (_lock)
+ {
+ if (_closed)
+ {
+ return true;
+ }
+
+ if (_pins > 0 || !Array.TrueForAll(_readers, static reader => reader.IsIdle))
+ {
+ return false;
+ }
+
+ CloseAndForgetLocked();
+ return true;
+ }
+ }
+
+ /// Takes no more notices. The readers finish what is queued, then end.
+ internal void Close()
+ {
+ lock (_lock)
+ {
+ _closed = true;
+ foreach (HookReader reader in _readers)
+ {
+ reader.Complete();
+ }
+ }
+ }
+
+ // After ConversationEnded, only the turns admitted before the end get through. Five facts are
+ // exempt: a refusal records no words and says AfterEnd itself; the unload after the end and
+ // a Fault about the conversation itself (a hook that failed on ConversationEnded, say) must still reach the
+ // other hooks; a LineSpoken is a record of the call, not a turn notice, so the caller's goodbye after the
+ // end still reaches the transcript; a CallEnded closes the call that the end left on the line. A Fault
+ // inside a turn follows its turn, so it never outruns that turn's notices.
+ private bool HeldBackByTheEnd(HookNotice notice)
+ {
+ HookScope scope = notice.Scope;
+ return _ended is { } ended
+ && scope.SessionId == ended.Session
+ && notice is not (TurnRefused or ConversationUnloaded or LineSpoken or CallEnded or Fault { Scope.TurnIndex: null })
+ && !(scope.TurnIndex is int turn && turn <= ended.LastTurn);
+ }
+
+ private void CloseAndForgetLocked()
+ {
+ _closed = true;
+ Hub.Forget(_conversationId, this);
+ foreach (HookReader reader in _readers)
+ {
+ reader.Complete();
+ }
+ }
+ }
+}
diff --git a/src/AgentCore.Application/Hooks/Engine/NoticeDispatch.cs b/src/AgentCore.Application/Hooks/Engine/NoticeDispatch.cs
new file mode 100644
index 00000000..b7c4584b
--- /dev/null
+++ b/src/AgentCore.Application/Hooks/Engine/NoticeDispatch.cs
@@ -0,0 +1,45 @@
+using AgentCore.Application.Hooks.Notices;
+
+namespace AgentCore.Application.Hooks.Engine
+{
+ /// Calls the method that matches a notice's type.
+ internal static class NoticeDispatch
+ {
+ internal static ValueTask DeliverAsync(AgentHook hook, HookNotice notice, CancellationToken cancellationToken)
+ {
+ return notice switch
+ {
+ HostStarted n => hook.OnHostStartedAsync(n, cancellationToken),
+ HostStopping n => hook.OnHostStoppingAsync(n, cancellationToken),
+ RetentionSwept n => hook.OnRetentionSweptAsync(n, cancellationToken),
+ CallStarted n => hook.OnCallStartedAsync(n, cancellationToken),
+ LineSpoken n => hook.OnLineSpokenAsync(n, cancellationToken),
+ CallEnded n => hook.OnCallEndedAsync(n, cancellationToken),
+ ConversationStarted n => hook.OnConversationStartedAsync(n, cancellationToken),
+ ConversationUnloaded n => hook.OnConversationUnloadedAsync(n, cancellationToken),
+ ConversationEnded n => hook.OnConversationEndedAsync(n, cancellationToken),
+ TurnStarted n => hook.OnTurnStartedAsync(n, cancellationToken),
+ TurnRefused n => hook.OnTurnRefusedAsync(n, cancellationToken),
+ TurnSuperseded n => hook.OnTurnSupersededAsync(n, cancellationToken),
+ InputModerated n => hook.OnInputModeratedAsync(n, cancellationToken),
+ KnowledgeSearched n => hook.OnKnowledgeSearchedAsync(n, cancellationToken),
+ SkillLoaded n => hook.OnSkillLoadedAsync(n, cancellationToken),
+ Compacted n => hook.OnCompactedAsync(n, cancellationToken),
+ ReplyUpdated n => hook.OnReplyUpdatedAsync(n, cancellationToken),
+ ModelCalled n => hook.OnModelCalledAsync(n, cancellationToken),
+ ToolCalled n => hook.OnToolCalledAsync(n, cancellationToken),
+ ApprovalChanged n => hook.OnApprovalChangedAsync(n, cancellationToken),
+ FilePublished n => hook.OnFilePublishedAsync(n, cancellationToken),
+ SubagentStarted n => hook.OnSubagentStartedAsync(n, cancellationToken),
+ SubagentEnded n => hook.OnSubagentEndedAsync(n, cancellationToken),
+ TurnCompleted n => hook.OnTurnCompletedAsync(n, cancellationToken),
+ ReplyCut n => hook.OnReplyCutAsync(n, cancellationToken),
+ VoiceStateChanged n => hook.OnVoiceStateChangedAsync(n, cancellationToken),
+ TurnLatency n => hook.OnTurnLatencyAsync(n, cancellationToken),
+ StageChanged n => hook.OnStageChangedAsync(n, cancellationToken),
+ Fault n => hook.OnFaultAsync(n, cancellationToken),
+ _ => throw new ArgumentOutOfRangeException(nameof(notice), notice.GetType().Name, "The notice is outside the closed set."),
+ };
+ }
+ }
+}
diff --git a/src/AgentCore.Application/Hooks/Engine/NoticeHub.cs b/src/AgentCore.Application/Hooks/Engine/NoticeHub.cs
new file mode 100644
index 00000000..95c935b8
--- /dev/null
+++ b/src/AgentCore.Application/Hooks/Engine/NoticeHub.cs
@@ -0,0 +1,193 @@
+using System.Collections.Concurrent;
+using AgentCore.Application.Hooks.Notices;
+using Microsoft.Extensions.Logging;
+
+namespace AgentCore.Application.Hooks.Engine
+{
+ /// Raises notices: one mailbox per conversation id, one for the host. It never runs hook code.
+ /// Which hooks want which notices.
+ /// Where a failing or slow hook is reported.
+ /// The clock of the notice timeouts, the slow-hook log, and the idle eviction.
+ internal sealed class NoticeHub(HookTable table, ILogger logger, TimeProvider timers)
+ {
+ internal static readonly TimeSpan IdleAfter = TimeSpan.FromSeconds(60);
+
+ internal static readonly TimeSpan StillWaitingEvery = TimeSpan.FromSeconds(30);
+
+ private readonly ConcurrentDictionary _mailboxes = new(StringComparer.Ordinal);
+
+ private Mailbox? _host;
+
+ private int _stopped;
+
+ internal HookTable Table => table;
+
+ internal ILogger Logger => logger;
+
+ internal TimeProvider Timers => timers;
+
+ internal int MailboxCount => _mailboxes.Count;
+
+ internal bool Wants(Type noticeType) => table.Wants(noticeType);
+
+ /// Stamps one notice and queues it for every hook that wants it.
+ /// The fact, with its scope's conversation, session and turn already set.
+ /// The clock is read from: the session's.
+ /// For : the last turn admitted before the end, or -1.
+ /// A hook that must not get this notice: the one a Fault(HookFailed) is about.
+ ///
+ /// The moment the fact happened, when the raiser read it earlier than the raise (a turn's end), or
+ /// to stamp it from .
+ ///
+ /// The notice's . A notice nobody wants still gets a fresh one.
+ internal Guid Raise(
+ HookNotice notice, TimeProvider clock, int? endsAfterTurn = null, AgentHook? except = null, DateTimeOffset? occurredAt = null)
+ {
+ ArgumentNullException.ThrowIfNull(notice);
+ ArgumentNullException.ThrowIfNull(clock);
+
+ if (!table.Wants(notice.GetType()))
+ {
+ // The ended rule matters only where readers exist, so an end nobody hears never makes a mailbox.
+ if (notice is ConversationEnded && notice.Scope.ConversationId is { } id && _mailboxes.TryGetValue(id, out Mailbox? box))
+ {
+ box.MarkEnded(notice.Scope.SessionId, endsAfterTurn);
+ }
+
+ return Guid.CreateVersion7();
+ }
+
+ while (Volatile.Read(ref _stopped) == 0)
+ {
+ if (MailboxFor(notice.Scope.ConversationId).TryRaise(notice, clock, endsAfterTurn, except, occurredAt, out Guid eventId))
+ {
+ return eventId;
+ }
+ }
+
+ return Guid.CreateVersion7();
+ }
+
+ /// Completes when every notice raised so far for this conversation was handled by every hook.
+ internal Task FlushAsync(string? conversationId)
+ {
+ Mailbox? box = conversationId is null ? Volatile.Read(ref _host) : _mailboxes.GetValueOrDefault(conversationId);
+ return box?.FlushAsync() ?? Task.CompletedTask;
+ }
+
+ /// Completes when every notice raised so far, in every conversation and on the host, was handled.
+ internal Task FlushAllAsync()
+ {
+ List barriers = [.. _mailboxes.Values.Select(static box => box.FlushAsync())];
+ if (Volatile.Read(ref _host) is { } host)
+ {
+ barriers.Add(host.FlushAsync());
+ }
+
+ return Task.WhenAll(barriers);
+ }
+
+ /// Whether this process still holds the conversation's mailbox from another session.
+ internal bool LoadedBefore(string conversationId, Guid sessionId)
+ {
+ return _mailboxes.TryGetValue(conversationId, out Mailbox? box) && box.LoadedBefore(sessionId);
+ }
+
+ ///
+ /// Keeps the conversation's mailbox, and so its Sequence , while a session of it is loaded. Every pin
+ /// is matched by one .
+ ///
+ internal void Pin(string conversationId)
+ {
+ ArgumentNullException.ThrowIfNull(conversationId);
+
+ if (table.NoticeHooks.Count == 0)
+ {
+ return;
+ }
+
+ while (Volatile.Read(ref _stopped) == 0 && !MailboxFor(conversationId).TryPin())
+ {
+ // The mailbox was evicted between the lookup and the pin; the next lookup makes a new one.
+ }
+ }
+
+ /// Lets go of one . The mailbox is evicted once it is idle and no pin is left.
+ internal void Release(string conversationId)
+ {
+ ArgumentNullException.ThrowIfNull(conversationId);
+
+ if (_mailboxes.TryGetValue(conversationId, out Mailbox? box))
+ {
+ box.Release();
+ }
+ }
+
+ /// Closes every mailbox and waits, up to , for the queued notices.
+ internal async ValueTask StopAsync(TimeSpan timeout)
+ {
+ if (Interlocked.Exchange(ref _stopped, 1) == 1)
+ {
+ return;
+ }
+
+ List boxes = [.. _mailboxes.Values];
+ if (Volatile.Read(ref _host) is { } host)
+ {
+ boxes.Add(host);
+ }
+
+ foreach (Mailbox box in boxes)
+ {
+ box.Close();
+ }
+
+ Task drained = Task.WhenAll(boxes.SelectMany(static box => box.Readers));
+
+ // A timer refuses a wait past HookReader.LongestTimeout; one that long is no limit at all.
+ if (timeout == Timeout.InfiniteTimeSpan || timeout > HookReader.LongestTimeout)
+ {
+ await drained.ConfigureAwait(false);
+ return;
+ }
+
+ try
+ {
+ await drained.WaitAsync(timeout < TimeSpan.Zero ? TimeSpan.Zero : timeout, timers).ConfigureAwait(false);
+ }
+ catch (TimeoutException)
+ {
+ int draining = boxes.Count(static box => box.Readers.Any(static reader => !reader.IsCompleted));
+ HookLog.DrainTimedOut(logger, draining, (long)timeout.TotalMilliseconds);
+ }
+ }
+
+ internal void Forget(string? conversationId, Mailbox box)
+ {
+ if (conversationId is null)
+ {
+ _ = Interlocked.CompareExchange(ref _host, null, box);
+ return;
+ }
+
+ _ = _mailboxes.TryRemove(KeyValuePair.Create(conversationId, box));
+ }
+
+ // A mailbox GetOrAdd builds but a racing caller discards never gets a write, so it never starts a reader.
+ private Mailbox MailboxFor(string? conversationId)
+ {
+ if (conversationId is not null)
+ {
+ return _mailboxes.GetOrAdd(conversationId, static (id, hub) => new Mailbox(hub, id), this);
+ }
+
+ if (Volatile.Read(ref _host) is { } host)
+ {
+ return host;
+ }
+
+ Mailbox made = new(this, null);
+ return Interlocked.CompareExchange(ref _host, made, null) ?? made;
+ }
+ }
+}
diff --git a/src/AgentCore.Application/Hooks/Engine/SessionHooks.cs b/src/AgentCore.Application/Hooks/Engine/SessionHooks.cs
new file mode 100644
index 00000000..0bb742fb
--- /dev/null
+++ b/src/AgentCore.Application/Hooks/Engine/SessionHooks.cs
@@ -0,0 +1,126 @@
+using System.Collections.Concurrent;
+using AgentCore.Application.Hooks.Notices;
+
+namespace AgentCore.Application.Hooks.Engine
+{
+ ///
+ /// The notices of one loaded session. Every raise site of the session and of its turn's layers goes through
+ /// this (the turn carries it as TurnInvocation.Hooks ). It pins the conversation's mailbox while the
+ /// session is loaded, so an idle eviction never restarts the conversation's Sequence .
+ ///
+ internal sealed class SessionHooks
+ {
+ private readonly HookRuntime _runtime;
+
+ private readonly string _conversationId;
+
+ private readonly string _entry;
+
+ private readonly TimeProvider _time;
+
+ private int _ended;
+
+ private int _started;
+
+ private int _released;
+
+ internal SessionHooks(HookRuntime runtime, string conversationId, string entry, TimeProvider time)
+ {
+ _runtime = runtime;
+ _conversationId = conversationId;
+ _entry = entry;
+ _time = time;
+ runtime.Notices.Pin(conversationId);
+ }
+
+ internal HookRuntime Runtime => _runtime;
+
+ internal string ConversationId => _conversationId;
+
+ /// Gets this session instance's id: a version 7 Guid, so it sorts by load time.
+ internal Guid SessionId { get; } = Guid.CreateVersion7();
+
+ internal TimeProvider Time => _time;
+
+ ///
+ /// Gets the BeforeToolApproval decisions still open, by function call id: a denial the deny layer has not answered yet, or a call
+ /// left to a human who has not answered yet. MAF asks the auto rule again about every queued request on every run,
+ /// so this is what keeps the hooks to one decision per call.
+ ///
+ internal ConcurrentDictionary ApprovalDecisions { get; } = new(StringComparer.Ordinal);
+
+ internal HookScope Scope(int? turnIndex, string? stage) =>
+ new(_conversationId, _entry, turnIndex, stage, SessionId, 0, _time.GetUtcNow());
+
+ internal bool Wants()
+ where TNotice : HookNotice => _runtime.Notices.Wants(typeof(TNotice));
+
+ /// Raises one notice of this session.
+ /// The fact.
+ /// When it happened, if it was read before the raise (a turn's end), or for now.
+ internal Guid Raise(HookNotice notice, DateTimeOffset? occurredAt = null) => _runtime.Notices.Raise(notice, _time, occurredAt: occurredAt);
+
+ ///
+ /// Raises , once per session. The caller holds the session's turn lock, so the
+ /// start always takes a lower Sequence than the end or refusal that may make it.
+ ///
+ internal void RaiseStarted(ConversationOrigin origin)
+ {
+ if (Interlocked.Exchange(ref _started, 1) == 0)
+ {
+ _ = Raise(new ConversationStarted(Scope(turnIndex: null, stage: null), origin));
+ }
+ }
+
+ ///
+ /// Raises the start of a session that ends or refuses a turn before it opened its store, so no end or refusal is
+ /// ever the first fact of a session. The caller holds the session's turn lock.
+ ///
+ internal void EnsureStarted()
+ {
+ if (Volatile.Read(ref _started) == 0)
+ {
+ RaiseStarted(_runtime.Notices.LoadedBefore(_conversationId, SessionId) ? ConversationOrigin.Reloaded : ConversationOrigin.Unopened);
+ }
+ }
+
+ /// Raises the end, once. From then on, only notices of turns up to get through.
+ /// when this session had already ended.
+ internal bool RaiseEnd(ConversationEnded notice, int lastAdmittedTurn)
+ {
+ if (Interlocked.Exchange(ref _ended, 1) == 1)
+ {
+ return false;
+ }
+
+ _ = _runtime.Notices.Raise(notice, _time, endsAfterTurn: lastAdmittedTurn);
+ return true;
+ }
+
+ internal void RaiseFault(Fault fault, AgentHook except)
+ {
+ _ = _runtime.Notices.Raise(fault, _time, except: except);
+ }
+
+ internal ConversationOrigin OriginOf(bool storeHeldConversation)
+ {
+ if (_runtime.Notices.LoadedBefore(_conversationId, SessionId))
+ {
+ return ConversationOrigin.Reloaded;
+ }
+
+ return storeHeldConversation ? ConversationOrigin.Resumed : ConversationOrigin.New;
+ }
+
+ internal Task FlushAsync() => _runtime.Notices.FlushAsync(_conversationId);
+
+ /// Lets go of the mailbox pin, once. The session was disposed or unloaded.
+ internal void Release()
+ {
+ if (Interlocked.Exchange(ref _released, 1) == 0)
+ {
+ _runtime.Notices.Release(_conversationId);
+ }
+ }
+ }
+}
diff --git a/src/AgentCore.Application/Hooks/Engine/TurnRounds.cs b/src/AgentCore.Application/Hooks/Engine/TurnRounds.cs
new file mode 100644
index 00000000..4174dc4c
--- /dev/null
+++ b/src/AgentCore.Application/Hooks/Engine/TurnRounds.cs
@@ -0,0 +1,13 @@
+namespace AgentCore.Application.Hooks.Engine
+{
+ /// Numbers the model round trips of one turn, from 0. Nested runs share their parent's counter.
+ internal sealed class TurnRounds
+ {
+ private int _next = -1;
+
+ internal int Next() => Interlocked.Increment(ref _next);
+
+ /// Gets how many model round trips the turn has made so far.
+ internal int Count => Volatile.Read(ref _next) + 1;
+ }
+}
diff --git a/src/AgentCore.Application/Hooks/GatePoint.cs b/src/AgentCore.Application/Hooks/GatePoint.cs
new file mode 100644
index 00000000..514e3d53
--- /dev/null
+++ b/src/AgentCore.Application/Hooks/GatePoint.cs
@@ -0,0 +1,81 @@
+namespace AgentCore.Application.Hooks
+{
+ ///
+ /// One of the 13 points where AgentCore awaits its hooks. The point, not the hook, fixes the deadline and the
+ /// default failure policy.
+ ///
+ public sealed class GatePoint
+ {
+ private GatePoint(string name, TimeSpan deadline, HookFailure defaultFailure)
+ {
+ Name = name;
+ Deadline = deadline;
+ DefaultFailure = defaultFailure;
+ MethodName = name + "Async";
+ }
+
+ /// Gets the gate where a transport offers a call. Fails closed: the call is rejected as unavailable.
+ public static GatePoint BeforeCall { get; } = new(nameof(BeforeCall), TimeSpan.FromSeconds(5), HookFailure.Closed);
+
+ /// Gets the gate where a request needs an entry. Fails closed: the request is refused.
+ public static GatePoint BeforeEntry { get; } = new(nameof(BeforeEntry), TimeSpan.FromSeconds(2), HookFailure.Closed);
+
+ /// Gets the gate where the user's message arrives, before moderation and the model.
+ public static GatePoint BeforeTurn { get; } = new(nameof(BeforeTurn), TimeSpan.FromSeconds(2), HookFailure.Open);
+
+ /// Gets the gate once per run, when its context is built.
+ public static GatePoint BeforeRun { get; } = new(nameof(BeforeRun), TimeSpan.FromSeconds(2), HookFailure.Open);
+
+ /// Gets the gate where AgentCore is about to compact.
+ public static GatePoint BeforeCompaction { get; } = new(nameof(BeforeCompaction), TimeSpan.FromSeconds(5), HookFailure.Open);
+
+ /// Gets the gate before each model round trip.
+ public static GatePoint BeforeModel { get; } = new(nameof(BeforeModel), TimeSpan.FromSeconds(1), HookFailure.Open);
+
+ /// Gets the gate after each round trip, before tools run or text leaves.
+ public static GatePoint AfterModel { get; } = new(nameof(AfterModel), TimeSpan.FromSeconds(1), HookFailure.Open);
+
+ /// Gets the gate where a round trip threw.
+ public static GatePoint AfterModelFailed { get; } = new(nameof(AfterModelFailed), TimeSpan.FromSeconds(1), HookFailure.Open);
+
+ /// Gets the gate where a tool that needs approval was called.
+ public static GatePoint BeforeToolApproval { get; } = new(nameof(BeforeToolApproval), TimeSpan.FromSeconds(2), HookFailure.Open);
+
+ /// Gets the gate before each tool call.
+ public static GatePoint BeforeTool { get; } = new(nameof(BeforeTool), TimeSpan.FromSeconds(2), HookFailure.Open);
+
+ /// Gets the gate after a tool returned.
+ public static GatePoint AfterTool { get; } = new(nameof(AfterTool), TimeSpan.FromSeconds(2), HookFailure.Open);
+
+ /// Gets the gate where a tool threw.
+ public static GatePoint AfterToolFailed { get; } = new(nameof(AfterToolFailed), TimeSpan.FromSeconds(2), HookFailure.Open);
+
+ /// Gets the gate where the run has a reply, before the turn is sealed.
+ public static GatePoint AfterRun { get; } = new(nameof(AfterRun), TimeSpan.FromSeconds(2), HookFailure.Open);
+
+ /// Gets every gate point, in gate order.
+ public static IReadOnlyList All { get; } =
+ [
+ BeforeCall, BeforeEntry, BeforeTurn, BeforeRun, BeforeCompaction, BeforeModel, AfterModel,
+ AfterModelFailed, BeforeToolApproval, BeforeTool, AfterTool, AfterToolFailed, AfterRun,
+ ];
+
+ /// Gets the point's name, which is its method without Async .
+ public string Name { get; }
+
+ /// Gets how long one hook may take before it is abandoned.
+ public TimeSpan Deadline { get; }
+
+ /// Gets what a failing hook does unless it overrides .
+ public HookFailure DefaultFailure { get; }
+
+ /// Gets the method this point calls.
+ internal string MethodName { get; }
+
+ ///
+ public override string ToString()
+ {
+ return Name;
+ }
+ }
+}
diff --git a/src/AgentCore.Application/Hooks/Gates/ApprovalGate.cs b/src/AgentCore.Application/Hooks/Gates/ApprovalGate.cs
new file mode 100644
index 00000000..2acbfab6
--- /dev/null
+++ b/src/AgentCore.Application/Hooks/Gates/ApprovalGate.cs
@@ -0,0 +1,59 @@
+namespace AgentCore.Application.Hooks.Gates
+{
+ /// A tool that needs approval was called. A hook may approve or deny it; with no decision a human is asked.
+ public sealed class ApprovalGate : HookGate
+ {
+ internal ApprovalGate(
+ HookScope scope,
+ string toolName,
+ string callId,
+ IReadOnlyDictionary arguments,
+ IDictionary items)
+ : base(scope)
+ {
+ ArgumentNullException.ThrowIfNull(toolName);
+ ArgumentNullException.ThrowIfNull(callId);
+ ArgumentNullException.ThrowIfNull(arguments);
+ ArgumentNullException.ThrowIfNull(items);
+
+ ToolName = toolName;
+ CallId = callId;
+ Arguments = arguments;
+ Items = items;
+ }
+
+ /// Gets the name the model called.
+ public string ToolName { get; }
+
+ /// Gets the id the model gave this call.
+ public string CallId { get; }
+
+ /// Gets the arguments the model sent.
+ public IReadOnlyDictionary Arguments { get; }
+
+ /// Gets the turn's shared state.
+ public IDictionary Items { get; }
+
+ internal bool? Approved { get; private set; }
+
+ internal string? DenyReason { get; private set; }
+
+ /// Lets the tool run without asking a human. Terminal.
+ public void Approve()
+ {
+ Stage(terminal: true, () => Approved = true);
+ }
+
+ /// Refuses the call without asking a human. The model is told . Terminal.
+ /// Why the call was refused.
+ public void Deny(string reason)
+ {
+ ArgumentNullException.ThrowIfNull(reason);
+ Stage(terminal: true, () =>
+ {
+ Approved = false;
+ DenyReason = reason;
+ });
+ }
+ }
+}
diff --git a/src/AgentCore.Application/Hooks/Gates/CallGate.cs b/src/AgentCore.Application/Hooks/Gates/CallGate.cs
new file mode 100644
index 00000000..682d91dc
--- /dev/null
+++ b/src/AgentCore.Application/Hooks/Gates/CallGate.cs
@@ -0,0 +1,77 @@
+namespace AgentCore.Application.Hooks.Gates
+{
+ /// A transport offers a call. A hook accepts it onto a conversation or rejects it.
+ public sealed class CallGate : HookGate
+ {
+ internal const int MaxBriefLength = 1000;
+
+ internal CallGate(
+ HookScope scope,
+ string entry,
+ string callId,
+ string? from,
+ string? to,
+ IReadOnlyDictionary headers,
+ string transport)
+ : base(scope)
+ {
+ ArgumentNullException.ThrowIfNull(entry);
+ ArgumentNullException.ThrowIfNull(callId);
+ ArgumentNullException.ThrowIfNull(headers);
+ ArgumentNullException.ThrowIfNull(transport);
+
+ Entry = entry;
+ CallId = callId;
+ From = from;
+ To = to;
+ Headers = headers;
+ Transport = transport;
+ }
+
+ /// Gets the entry the call's route serves.
+ public string Entry { get; }
+
+ /// Gets the transport's id for this call.
+ public string CallId { get; }
+
+ /// Gets the caller's number or address, when the transport knows it.
+ public string? From { get; }
+
+ /// Gets the number or address that was called, when the transport knows it.
+ public string? To { get; }
+
+ /// Gets the transport's headers, for example SIP headers.
+ public IReadOnlyDictionary Headers { get; }
+
+ /// Gets the kind of transport that offered the call.
+ public string Transport { get; }
+
+ internal string? AcceptedConversationId { get; private set; }
+
+ internal string? Brief { get; private set; }
+
+ internal CallRefusal? Refusal { get; private set; }
+
+ /// Takes the call onto a conversation. Terminal.
+ /// The conversation the call joins. AgentCore opens it or resumes it.
+ /// Text the agent is told about this call. Over 1,000 characters it is cut to its first 1,000.
+ public void Accept(string conversationId, string? brief = null)
+ {
+ ArgumentException.ThrowIfNullOrEmpty(conversationId);
+
+ string? kept = brief is { Length: > MaxBriefLength } ? brief[..MaxBriefLength] : brief;
+ Stage(terminal: true, () =>
+ {
+ AcceptedConversationId = conversationId;
+ Brief = kept;
+ });
+ }
+
+ /// Turns the call away. Each transport maps to its own code. Terminal.
+ /// Why the call is refused.
+ public void Reject(CallRefusal reason)
+ {
+ Stage(terminal: true, () => Refusal = reason);
+ }
+ }
+}
diff --git a/src/AgentCore.Application/Hooks/Gates/CallRefusal.cs b/src/AgentCore.Application/Hooks/Gates/CallRefusal.cs
new file mode 100644
index 00000000..f2c3aa07
--- /dev/null
+++ b/src/AgentCore.Application/Hooks/Gates/CallRefusal.cs
@@ -0,0 +1,18 @@
+namespace AgentCore.Application.Hooks.Gates
+{
+ /// Why a call was turned away. Each transport maps these to its own code.
+ public enum CallRefusal
+ {
+ /// A hook declined the call. What declining means is up to the hook and the host.
+ Declined,
+
+ /// A hook turned the call away as busy. What busy means is up to the hook and the host.
+ Busy,
+
+ ///
+ /// No hook accepted the call: none answered, a hook failed, or a hook missed the gate's deadline. A hook may
+ /// also give it.
+ ///
+ Unavailable,
+ }
+}
diff --git a/src/AgentCore.Application/Hooks/Gates/CompactionGate.cs b/src/AgentCore.Application/Hooks/Gates/CompactionGate.cs
new file mode 100644
index 00000000..b205793c
--- /dev/null
+++ b/src/AgentCore.Application/Hooks/Gates/CompactionGate.cs
@@ -0,0 +1,39 @@
+namespace AgentCore.Application.Hooks.Gates
+{
+ /// AgentCore is about to compact. A hook may cancel the compaction or supply its summary.
+ public sealed class CompactionGate : HookGate
+ {
+ internal CompactionGate(HookScope scope, int messageCount, IDictionary items)
+ : base(scope)
+ {
+ ArgumentNullException.ThrowIfNull(items);
+
+ MessageCount = messageCount;
+ Items = items;
+ }
+
+ /// Gets how many messages the compaction may fold: the stored messages before the newest turn.
+ public int MessageCount { get; }
+
+ /// Gets the turn's shared state.
+ public IDictionary Items { get; }
+
+ internal bool Cancelled { get; private set; }
+
+ internal string? Summary { get; private set; }
+
+ /// Skips this compaction: the conversation keeps the view it had. Terminal.
+ public void Cancel()
+ {
+ Stage(terminal: true, () => Cancelled = true);
+ }
+
+ /// Uses as the summary, and the summarising model is not called. Terminal.
+ /// The summary.
+ public void ReplaceSummary(string text)
+ {
+ ArgumentNullException.ThrowIfNull(text);
+ Stage(terminal: true, () => Summary = text);
+ }
+ }
+}
diff --git a/src/AgentCore.Application/Hooks/Gates/EntryGate.cs b/src/AgentCore.Application/Hooks/Gates/EntryGate.cs
new file mode 100644
index 00000000..21f72dbe
--- /dev/null
+++ b/src/AgentCore.Application/Hooks/Gates/EntryGate.cs
@@ -0,0 +1,63 @@
+using System.Security.Claims;
+
+namespace AgentCore.Application.Hooks.Gates
+{
+ /// A request needs an entry. A hook chooses one or refuses the request.
+ public sealed class EntryGate : HookGate
+ {
+ internal EntryGate(
+ HookScope scope,
+ ClaimsPrincipal? user,
+ IReadOnlyDictionary headers,
+ string route,
+ string? requestedEntry,
+ EntryTransport transport)
+ : base(scope)
+ {
+ ArgumentNullException.ThrowIfNull(headers);
+ ArgumentNullException.ThrowIfNull(route);
+
+ User = user;
+ Headers = headers;
+ Route = route;
+ RequestedEntry = requestedEntry;
+ Transport = transport;
+ }
+
+ /// Gets the authenticated user, or when the request is anonymous.
+ public ClaimsPrincipal? User { get; }
+
+ ///
+ /// Gets the HTTP request's headers, on every route. On a call route that is the handshake or webhook request;
+ /// the call's own SIP headers reach .
+ ///
+ public IReadOnlyDictionary Headers { get; }
+
+ /// Gets the route pattern that matched.
+ public string Route { get; }
+
+ /// Gets the entry the URL named, or when the route has no entry segment.
+ public string? RequestedEntry { get; }
+
+ /// Gets how the request arrived.
+ public EntryTransport Transport { get; }
+
+ internal string? Chosen { get; private set; }
+
+ internal bool Refused { get; private set; }
+
+ /// Serves the request with this entry. Terminal.
+ /// The name of an entry the host serves.
+ public void Choose(string entry)
+ {
+ ArgumentException.ThrowIfNullOrEmpty(entry);
+ Stage(terminal: true, () => Chosen = entry);
+ }
+
+ /// Refuses the request. Terminal.
+ public void Refuse()
+ {
+ Stage(terminal: true, () => Refused = true);
+ }
+ }
+}
diff --git a/src/AgentCore.Application/Hooks/Gates/EntryTransport.cs b/src/AgentCore.Application/Hooks/Gates/EntryTransport.cs
new file mode 100644
index 00000000..1d91fa36
--- /dev/null
+++ b/src/AgentCore.Application/Hooks/Gates/EntryTransport.cs
@@ -0,0 +1,12 @@
+namespace AgentCore.Application.Hooks.Gates
+{
+ /// How a request reached an AgentCore route.
+ public enum EntryTransport
+ {
+ /// An HTTP request.
+ Http,
+
+ /// A phone call.
+ Call,
+ }
+}
diff --git a/src/AgentCore.Application/Hooks/Gates/ModelFailureGate.cs b/src/AgentCore.Application/Hooks/Gates/ModelFailureGate.cs
new file mode 100644
index 00000000..433adfce
--- /dev/null
+++ b/src/AgentCore.Application/Hooks/Gates/ModelFailureGate.cs
@@ -0,0 +1,72 @@
+using Microsoft.Extensions.AI;
+
+namespace AgentCore.Application.Hooks.Gates
+{
+ /// A model round trip threw. A hook may retry it or answer for it.
+ public sealed class ModelFailureGate : HookGate
+ {
+ internal ModelFailureGate(
+ HookScope scope,
+ int round,
+ int attempt,
+ Exception exception,
+ bool canRetry,
+ IDictionary items)
+ : base(scope)
+ {
+ ArgumentNullException.ThrowIfNull(exception);
+ ArgumentNullException.ThrowIfNull(items);
+
+ Round = round;
+ Attempt = attempt;
+ Exception = exception;
+ CanRetry = canRetry;
+ Items = items;
+ }
+
+ /// Gets the zero-based round trip of this run.
+ public int Round { get; }
+
+ /// Gets how many retries this round already had.
+ public int Attempt { get; }
+
+ /// Gets what the round trip threw.
+ public Exception Exception { get; }
+
+ ///
+ /// Gets whether is allowed: false once the caller received part of this round, and on the
+ /// last attempt the retry cap allows.
+ ///
+ public bool CanRetry { get; }
+
+ /// Gets the turn's shared state.
+ public IDictionary Items { get; }
+
+ internal bool Retrying { get; private set; }
+
+ internal ChatResponse? Response { get; private set; }
+
+ /// Calls the model again. Allowed only before the first stream update reached the caller. Terminal.
+ /// is false.
+ public void Retry()
+ {
+ if (!CanRetry)
+ {
+ throw new InvalidOperationException("This round cannot be retried: the caller already received part of it, or it reached the retry cap.");
+ }
+
+ Stage(terminal: true, () => Retrying = true);
+ }
+
+ ///
+ /// Answers the round with this response instead of today's fallback. Terminal. After part of the round reached
+ /// the caller, this answer follows it as its own message, as the fallback does.
+ ///
+ /// The response the round trip returns.
+ public void Respond(ChatResponse response)
+ {
+ ArgumentNullException.ThrowIfNull(response);
+ Stage(terminal: true, () => Response = response);
+ }
+ }
+}
diff --git a/src/AgentCore.Application/Hooks/Gates/ModelGate.cs b/src/AgentCore.Application/Hooks/Gates/ModelGate.cs
new file mode 100644
index 00000000..60500c75
--- /dev/null
+++ b/src/AgentCore.Application/Hooks/Gates/ModelGate.cs
@@ -0,0 +1,68 @@
+using Microsoft.Extensions.AI;
+
+namespace AgentCore.Application.Hooks.Gates
+{
+ /// A model round trip is about to start. A hook may change what the model sees, or answer for it.
+ public sealed class ModelGate : HookGate
+ {
+ internal ModelGate(
+ HookScope scope,
+ int round,
+ IReadOnlyList messages,
+ ChatOptions? options,
+ IDictionary items)
+ : base(scope)
+ {
+ ArgumentNullException.ThrowIfNull(messages);
+ ArgumentNullException.ThrowIfNull(items);
+
+ Round = round;
+ Messages = messages;
+ Options = options;
+ Items = items;
+ }
+
+ /// Gets the zero-based round trip of this run.
+ public int Round { get; }
+
+ /// Gets the messages the model will see, as the hook before this one left them.
+ public IReadOnlyList Messages { get; }
+
+ /// Gets the options of the call, as the hook before this one left them.
+ public ChatOptions? Options { get; }
+
+ /// Gets the turn's shared state.
+ public IDictionary Items { get; }
+
+ internal IReadOnlyList? NewMessages { get; private set; }
+
+ internal ChatOptions? NewOptions { get; private set; }
+
+ internal ChatResponse? Response { get; private set; }
+
+ /// Sends the model these messages instead.
+ /// The messages the model sees.
+ public void ReplaceMessages(IEnumerable messages)
+ {
+ ArgumentNullException.ThrowIfNull(messages);
+ List copy = [.. messages];
+ Stage(terminal: false, () => NewMessages = copy);
+ }
+
+ /// Calls the model with these options instead.
+ /// The options of the call.
+ public void SetOptions(ChatOptions options)
+ {
+ ArgumentNullException.ThrowIfNull(options);
+ Stage(terminal: false, () => NewOptions = options);
+ }
+
+ /// Does not call the model. This response stands in for its answer. Terminal.
+ /// The response the round trip returns.
+ public void Respond(ChatResponse response)
+ {
+ ArgumentNullException.ThrowIfNull(response);
+ Stage(terminal: true, () => Response = response);
+ }
+ }
+}
diff --git a/src/AgentCore.Application/Hooks/Gates/ModelResultGate.cs b/src/AgentCore.Application/Hooks/Gates/ModelResultGate.cs
new file mode 100644
index 00000000..0e58f909
--- /dev/null
+++ b/src/AgentCore.Application/Hooks/Gates/ModelResultGate.cs
@@ -0,0 +1,38 @@
+using Microsoft.Extensions.AI;
+
+namespace AgentCore.Application.Hooks.Gates
+{
+ /// A model round trip returned, before tools run or text leaves. A hook may replace the response.
+ public sealed class ModelResultGate : HookGate
+ {
+ internal ModelResultGate(HookScope scope, int round, ChatResponse response, IDictionary items)
+ : base(scope)
+ {
+ ArgumentNullException.ThrowIfNull(response);
+ ArgumentNullException.ThrowIfNull(items);
+
+ Round = round;
+ Response = response;
+ Items = items;
+ }
+
+ /// Gets the zero-based round trip of this run.
+ public int Round { get; }
+
+ /// Gets the model's response, as the hook before this one left it.
+ public ChatResponse Response { get; }
+
+ /// Gets the turn's shared state.
+ public IDictionary Items { get; }
+
+ internal ChatResponse? Replacement { get; private set; }
+
+ /// Uses this response in place of the model's. Replaced arguments or an injected tool call reach the tool loop.
+ /// The response the round trip returns.
+ public void ReplaceResponse(ChatResponse response)
+ {
+ ArgumentNullException.ThrowIfNull(response);
+ Stage(terminal: false, () => Replacement = response);
+ }
+ }
+}
diff --git a/src/AgentCore.Application/Hooks/Gates/RunEndGate.cs b/src/AgentCore.Application/Hooks/Gates/RunEndGate.cs
new file mode 100644
index 00000000..3de6bb14
--- /dev/null
+++ b/src/AgentCore.Application/Hooks/Gates/RunEndGate.cs
@@ -0,0 +1,54 @@
+namespace AgentCore.Application.Hooks.Gates
+{
+ ///
+ /// A run has a reply, before the turn is sealed. A hook may ask the agent to run again. It does not fire for a
+ /// graph entry, for a nested run, for the run that reaches the loop cap, for a run that ends on a pending approval
+ /// request, or for a run where an earlier loop: until: evaluator already asked to continue.
+ ///
+ public sealed class RunEndGate : HookGate
+ {
+ internal RunEndGate(
+ HookScope scope,
+ string? agentId,
+ string replyText,
+ int iteration,
+ IDictionary items)
+ : base(scope)
+ {
+ ArgumentNullException.ThrowIfNull(replyText);
+ ArgumentNullException.ThrowIfNull(items);
+
+ AgentId = agentId;
+ ReplyText = replyText;
+ Iteration = iteration;
+ Items = items;
+ }
+
+ /// Gets the agent that ran, when known.
+ public string? AgentId { get; }
+
+ /// Gets the run's reply text.
+ public string ReplyText { get; }
+
+ /// Gets how many runs of this turn have completed: 1 after the first run.
+ public int Iteration { get; }
+
+ /// Gets the turn's shared state.
+ public IDictionary Items { get; }
+
+ internal string? Continuation { get; private set; }
+
+ ///
+ /// Runs the agent again with . The next run reads the conversation, this turn's user
+ /// message and earlier runs, then the message. The message is not a user row, and the caller never sees it. Runs
+ /// are capped by the agent's loop: maxRounds: ; a loop: block without maxRounds:
+ /// caps at the framework's default of 10, and an agent with no loop: at 3. Terminal.
+ ///
+ /// What the agent is told for the next run.
+ public void Continue(string message)
+ {
+ ArgumentNullException.ThrowIfNull(message);
+ Stage(terminal: true, () => Continuation = message);
+ }
+ }
+}
diff --git a/src/AgentCore.Application/Hooks/Gates/RunGate.cs b/src/AgentCore.Application/Hooks/Gates/RunGate.cs
new file mode 100644
index 00000000..286d033a
--- /dev/null
+++ b/src/AgentCore.Application/Hooks/Gates/RunGate.cs
@@ -0,0 +1,66 @@
+using Microsoft.Extensions.AI;
+
+namespace AgentCore.Application.Hooks.Gates
+{
+ /// A run's context is being built. A hook may add instructions, messages, or tools.
+ public sealed class RunGate : HookGate
+ {
+ private readonly List _instructions = [];
+
+ private readonly List _messages = [];
+
+ private readonly List _tools = [];
+
+ internal RunGate(HookScope scope, string agentId, bool nested, IDictionary items)
+ : base(scope)
+ {
+ ArgumentNullException.ThrowIfNull(agentId);
+ ArgumentNullException.ThrowIfNull(items);
+
+ AgentId = agentId;
+ Nested = nested;
+ Items = items;
+ }
+
+ /// Gets the agent about to run.
+ public string AgentId { get; }
+
+ /// Gets whether this run belongs to a graph participant or an agent-as-tool child.
+ public bool Nested { get; }
+
+ /// Gets the turn's shared state.
+ public IDictionary Items { get; }
+
+ internal IReadOnlyList Instructions => _instructions;
+
+ internal IReadOnlyList Messages => _messages;
+
+ internal IReadOnlyList Tools => _tools;
+
+ /// Adds text to this run's instructions.
+ /// The instructions to add.
+ public void AddInstructions(string text)
+ {
+ ArgumentNullException.ThrowIfNull(text);
+ Stage(terminal: false, () => _instructions.Add(text));
+ }
+
+ /// Adds messages to this run's context.
+ /// The messages to add.
+ public void AddMessages(IEnumerable messages)
+ {
+ ArgumentNullException.ThrowIfNull(messages);
+ List copy = [.. messages];
+ Stage(terminal: false, () => _messages.AddRange(copy));
+ }
+
+ /// Adds tools to this run.
+ /// The tools to add.
+ public void AddTools(IEnumerable tools)
+ {
+ ArgumentNullException.ThrowIfNull(tools);
+ List copy = [.. tools];
+ Stage(terminal: false, () => _tools.AddRange(copy));
+ }
+ }
+}
diff --git a/src/AgentCore.Application/Hooks/Gates/ToolFailureGate.cs b/src/AgentCore.Application/Hooks/Gates/ToolFailureGate.cs
new file mode 100644
index 00000000..0f198354
--- /dev/null
+++ b/src/AgentCore.Application/Hooks/Gates/ToolFailureGate.cs
@@ -0,0 +1,76 @@
+namespace AgentCore.Application.Hooks.Gates
+{
+ /// A tool threw. A hook may retry the call or answer for it; with no decision AgentCore's own policy applies.
+ public sealed class ToolFailureGate : HookGate
+ {
+ internal ToolFailureGate(
+ HookScope scope,
+ string toolName,
+ string callId,
+ int attempt,
+ Exception exception,
+ bool canRetry,
+ IDictionary items)
+ : base(scope)
+ {
+ ArgumentNullException.ThrowIfNull(toolName);
+ ArgumentNullException.ThrowIfNull(callId);
+ ArgumentNullException.ThrowIfNull(exception);
+ ArgumentNullException.ThrowIfNull(items);
+
+ ToolName = toolName;
+ CallId = callId;
+ Attempt = attempt;
+ Exception = exception;
+ CanRetry = canRetry;
+ Items = items;
+ }
+
+ /// Gets the name the model called.
+ public string ToolName { get; }
+
+ /// Gets the id the model gave this call.
+ public string CallId { get; }
+
+ /// Gets how many retries this call already had.
+ public int Attempt { get; }
+
+ /// Gets what the tool threw.
+ public Exception Exception { get; }
+
+ /// Gets whether is allowed: false on the last attempt the retry cap allows.
+ public bool CanRetry { get; }
+
+ /// Gets the turn's shared state.
+ public IDictionary Items { get; }
+
+ internal bool Retrying { get; private set; }
+
+ internal bool Responded { get; private set; }
+
+ internal object? Result { get; private set; }
+
+ /// Calls the tool again. The model sees the second result. Terminal.
+ /// is false.
+ public void Retry()
+ {
+ if (!CanRetry)
+ {
+ throw new InvalidOperationException("This call reached the retry cap, so it cannot be retried.");
+ }
+
+ Stage(terminal: true, () => Retrying = true);
+ }
+
+ /// Answers for the tool with instead of AgentCore's own failure policy. Terminal.
+ /// The result the model reads.
+ public void Respond(object? result)
+ {
+ Stage(terminal: true, () =>
+ {
+ Responded = true;
+ Result = result;
+ });
+ }
+ }
+}
diff --git a/src/AgentCore.Application/Hooks/Gates/ToolGate.cs b/src/AgentCore.Application/Hooks/Gates/ToolGate.cs
new file mode 100644
index 00000000..fbc45a1f
--- /dev/null
+++ b/src/AgentCore.Application/Hooks/Gates/ToolGate.cs
@@ -0,0 +1,83 @@
+namespace AgentCore.Application.Hooks.Gates
+{
+ /// A tool is about to run. A hook may block it, answer for it, change its arguments, or end the loop after it.
+ public sealed class ToolGate : HookGate
+ {
+ internal ToolGate(
+ HookScope scope,
+ string toolName,
+ string callId,
+ IReadOnlyDictionary arguments,
+ IDictionary items)
+ : base(scope)
+ {
+ ArgumentNullException.ThrowIfNull(toolName);
+ ArgumentNullException.ThrowIfNull(callId);
+ ArgumentNullException.ThrowIfNull(arguments);
+ ArgumentNullException.ThrowIfNull(items);
+
+ ToolName = toolName;
+ CallId = callId;
+ Arguments = arguments;
+ Items = items;
+ }
+
+ /// Gets the name the model called.
+ public string ToolName { get; }
+
+ /// Gets the id the model gave this call.
+ public string CallId { get; }
+
+ /// Gets the arguments, as the model sent them or as the hook before this one replaced them.
+ public IReadOnlyDictionary Arguments { get; }
+
+ /// Gets the turn's shared state. Tools read the same dictionary through ToolCallScope.Items .
+ public IDictionary Items { get; }
+
+ internal bool Blocked { get; private set; }
+
+ internal bool Responded { get; private set; }
+
+ internal object? Result { get; private set; }
+
+ internal IReadOnlyDictionary? NewArguments { get; private set; }
+
+ internal bool EndsLoop { get; private set; }
+
+ /// Does not run the tool. The model reads as the tool's answer. Terminal.
+ /// What the model is told, for example why the call was refused.
+ public void Block(object? resultForModel)
+ {
+ Stage(terminal: true, () =>
+ {
+ Blocked = true;
+ Result = resultForModel;
+ });
+ }
+
+ /// Does not run the tool, and answers for it with . Terminal.
+ /// The tool's answer.
+ public void Respond(object? result)
+ {
+ Stage(terminal: true, () =>
+ {
+ Responded = true;
+ Result = result;
+ });
+ }
+
+ /// Runs the tool with these arguments. The model's own call record keeps what the model sent.
+ /// The arguments the tool gets. A key left out is removed.
+ public void ReplaceArguments(IReadOnlyDictionary arguments)
+ {
+ ArgumentNullException.ThrowIfNull(arguments);
+ Stage(terminal: false, () => NewArguments = arguments);
+ }
+
+ /// Ends the tool loop after this call: the model is not asked again this run.
+ public void EndLoop()
+ {
+ Stage(terminal: false, () => EndsLoop = true);
+ }
+ }
+}
diff --git a/src/AgentCore.Application/Hooks/Gates/ToolResultGate.cs b/src/AgentCore.Application/Hooks/Gates/ToolResultGate.cs
new file mode 100644
index 00000000..68949724
--- /dev/null
+++ b/src/AgentCore.Application/Hooks/Gates/ToolResultGate.cs
@@ -0,0 +1,59 @@
+namespace AgentCore.Application.Hooks.Gates
+{
+ /// A tool returned. A hook may replace its result or end the loop after it.
+ public sealed class ToolResultGate : HookGate
+ {
+ internal ToolResultGate(
+ HookScope scope,
+ string toolName,
+ string callId,
+ object? result,
+ IDictionary items)
+ : base(scope)
+ {
+ ArgumentNullException.ThrowIfNull(toolName);
+ ArgumentNullException.ThrowIfNull(callId);
+ ArgumentNullException.ThrowIfNull(items);
+
+ ToolName = toolName;
+ CallId = callId;
+ Result = result;
+ Items = items;
+ }
+
+ /// Gets the name the model called.
+ public string ToolName { get; }
+
+ /// Gets the id the model gave this call.
+ public string CallId { get; }
+
+ /// Gets the tool's result, as the hook before this one left it.
+ public object? Result { get; }
+
+ /// Gets the turn's shared state.
+ public IDictionary Items { get; }
+
+ internal bool Replaced { get; private set; }
+
+ internal object? NewResult { get; private set; }
+
+ internal bool EndsLoop { get; private set; }
+
+ /// Tells the model instead of what the tool returned.
+ /// The result the model reads.
+ public void ReplaceResult(object? result)
+ {
+ Stage(terminal: false, () =>
+ {
+ Replaced = true;
+ NewResult = result;
+ });
+ }
+
+ /// Ends the tool loop after this call: the model is not asked again this run.
+ public void EndLoop()
+ {
+ Stage(terminal: false, () => EndsLoop = true);
+ }
+ }
+}
diff --git a/src/AgentCore.Application/Hooks/Gates/TurnGate.cs b/src/AgentCore.Application/Hooks/Gates/TurnGate.cs
new file mode 100644
index 00000000..2551267d
--- /dev/null
+++ b/src/AgentCore.Application/Hooks/Gates/TurnGate.cs
@@ -0,0 +1,62 @@
+using AgentCore.Application.Hooks.Notices;
+
+namespace AgentCore.Application.Hooks.Gates
+{
+ /// The user's message arrived, before moderation and the model. A hook may block it, rewrite it, or add context.
+ public sealed class TurnGate : HookGate
+ {
+ private readonly List _addedContext = [];
+
+ internal TurnGate(HookScope scope, string userText, IDictionary items)
+ : base(scope)
+ {
+ ArgumentNullException.ThrowIfNull(userText);
+ ArgumentNullException.ThrowIfNull(items);
+
+ UserText = userText;
+ Items = items;
+ }
+
+ /// Gets the user's text, after any earlier hook's .
+ public string UserText { get; }
+
+ /// Gets the turn's shared state.
+ public IDictionary Items { get; }
+
+ internal string? BlockReply { get; private set; }
+
+ internal string? Input { get; private set; }
+
+ internal IReadOnlyList AddedContext => _addedContext;
+
+ /// Does not run the turn. It is committed with Outcome = Blocked and as the answer. Terminal.
+ /// The text the caller gets.
+ public void Block(string reply)
+ {
+ ArgumentNullException.ThrowIfNull(reply);
+ Stage(terminal: true, () => BlockReply = reply);
+ }
+
+ ///
+ /// Rewrites the user's message. The store, TurnCompleted.UserText and the audit hash all see the new text.
+ /// The rewrite is made in place on the turn's user message, the same
+ /// object the host passed in. Its text becomes ; its other contents (an image, an approval
+ /// answer) are kept, but may be moved after the text. was raised before this gate, so it
+ /// keeps the words as received.
+ ///
+ /// The message the turn runs on.
+ public void ReplaceInput(string text)
+ {
+ ArgumentNullException.ThrowIfNull(text);
+ Stage(terminal: false, () => Input = text);
+ }
+
+ /// Tells the model something for this turn's runs only. It is never stored and is not a user row.
+ /// The context note.
+ public void AddContext(string text)
+ {
+ ArgumentNullException.ThrowIfNull(text);
+ Stage(terminal: false, () => _addedContext.Add(text));
+ }
+ }
+}
diff --git a/src/AgentCore.Application/Hooks/HookFailure.cs b/src/AgentCore.Application/Hooks/HookFailure.cs
new file mode 100644
index 00000000..f86c4ed7
--- /dev/null
+++ b/src/AgentCore.Application/Hooks/HookFailure.cs
@@ -0,0 +1,12 @@
+namespace AgentCore.Application.Hooks
+{
+ /// What a gate does when a hook throws or misses its deadline.
+ public enum HookFailure
+ {
+ /// Act as if the hook did nothing: its staged verbs are dropped and the chain goes on.
+ Open,
+
+ /// Apply the gate's safe verb and stop the chain.
+ Closed,
+ }
+}
diff --git a/src/AgentCore.Application/Hooks/HookGate.cs b/src/AgentCore.Application/Hooks/HookGate.cs
new file mode 100644
index 00000000..aad2db91
--- /dev/null
+++ b/src/AgentCore.Application/Hooks/HookGate.cs
@@ -0,0 +1,71 @@
+namespace AgentCore.Application.Hooks
+{
+ ///
+ /// One hook's view of a gate. It shows the facts as the hooks before this one left them, and takes this hook's
+ /// verbs. A terminal verb ends the chain; a modifying verb changes what the next hook sees.
+ ///
+ public abstract class HookGate
+ {
+ private readonly Lock _verbs = new();
+
+ private bool _sealed;
+
+ private bool _terminal;
+
+ private protected HookGate(HookScope scope)
+ {
+ ArgumentNullException.ThrowIfNull(scope);
+ Scope = scope;
+ }
+
+ /// Gets where and when the gate fires.
+ public HookScope Scope { get; }
+
+ /// Gets whether this view took a terminal verb.
+ internal bool IsTerminal
+ {
+ get
+ {
+ lock (_verbs)
+ {
+ return _terminal;
+ }
+ }
+ }
+
+ /// Closes this view. Every verb after this throws.
+ internal void Seal()
+ {
+ lock (_verbs)
+ {
+ _sealed = true;
+ }
+ }
+
+ /// Records one verb on this view.
+ /// Whether the verb ends the chain.
+ /// Writes the verb's value into the view's staged fields.
+ /// The view is sealed, or it already took a terminal verb.
+ private protected void Stage(bool terminal, Action apply)
+ {
+ ArgumentNullException.ThrowIfNull(apply);
+
+ lock (_verbs)
+ {
+ if (_sealed)
+ {
+ throw new InvalidOperationException(
+ "The gate already moved past this hook, so this verb changes nothing. A hook is abandoned at its gate's deadline.");
+ }
+
+ if (_terminal)
+ {
+ throw new InvalidOperationException("This gate already took a terminal verb, so no verb can follow it.");
+ }
+
+ apply();
+ _terminal = terminal;
+ }
+ }
+ }
+}
diff --git a/src/AgentCore.Application/Hooks/HookNotice.cs b/src/AgentCore.Application/Hooks/HookNotice.cs
new file mode 100644
index 00000000..36f7cebc
--- /dev/null
+++ b/src/AgentCore.Application/Hooks/HookNotice.cs
@@ -0,0 +1,10 @@
+namespace AgentCore.Application.Hooks
+{
+ /// One fact a hook is told about after it happened. AgentCore never waits for a notice.
+ /// Where and when it happened.
+ public abstract record HookNotice(HookScope Scope)
+ {
+ /// Gets the identity of this notice: a version 7 Guid. The audit hook uses it as the row id.
+ public Guid EventId { get; init; }
+ }
+}
diff --git a/src/AgentCore.Application/Hooks/HookScope.cs b/src/AgentCore.Application/Hooks/HookScope.cs
new file mode 100644
index 00000000..25b82405
--- /dev/null
+++ b/src/AgentCore.Application/Hooks/HookScope.cs
@@ -0,0 +1,28 @@
+namespace AgentCore.Application.Hooks
+{
+ /// Where and when one gate or notice happens. Every gate object and every notice carries one.
+ ///
+ /// The conversation, or for a host notice and for the call and entry gates, which run
+ /// before a conversation exists.
+ ///
+ /// The entry the conversation runs, or before one is chosen.
+ /// The zero-based turn, or for a fact about the conversation itself.
+ /// The stage the machine held, empty when the entry declares no policy, or .
+ ///
+ /// The loaded session instance. A version 7 Guid, so it sorts by load time. on a host
+ /// notice and on a gate that runs before a session exists.
+ ///
+ ///
+ /// The notice's place among the notices of its conversation while the conversation stays loaded, from 1.
+ /// Ordering across an unload uses first. Gates carry 0.
+ ///
+ /// When it happened, on the session's clock.
+ public sealed record HookScope(
+ string? ConversationId,
+ string? Entry,
+ int? TurnIndex,
+ string? Stage,
+ Guid SessionId,
+ long Sequence,
+ DateTimeOffset OccurredAt);
+}
diff --git a/src/AgentCore.Application/Hooks/Layers/ApprovalGateLayer.cs b/src/AgentCore.Application/Hooks/Layers/ApprovalGateLayer.cs
new file mode 100644
index 00000000..74fb28b0
--- /dev/null
+++ b/src/AgentCore.Application/Hooks/Layers/ApprovalGateLayer.cs
@@ -0,0 +1,280 @@
+using System.Runtime.CompilerServices;
+using AgentCore.Application.Configuration.Compilation;
+using AgentCore.Application.Hooks.Engine;
+using AgentCore.Application.Hooks.Gates;
+using AgentCore.Application.Hooks.Notices;
+using Microsoft.Agents.AI;
+using Microsoft.Extensions.AI;
+using AgentCore.Application.Runtime.Turn;
+
+namespace AgentCore.Application.Hooks.Layers
+{
+ /// The combined auto-approval rule, and the deny layer outside the approval layer.
+ internal static class ApprovalGateLayer
+ {
+ ///
+ /// The most model rounds of one run whose denials the deny layer answers. A run that only hands back a request MAF
+ /// already queued calls no model and does not count. The next model round that still asks for a denied call ends
+ /// the run as a fault.
+ ///
+ internal const int MaxRounds = 8;
+
+ /// What the model is told when a hook that fails closed broke at BeforeToolApproval.
+ private const string FailedClosedReason = "a hook denied this call.";
+
+ ///
+ /// The one rule UseToolApproval gets: the hook chain first, then the document's patterns.
+ /// approves; asks, or leaves a denial for the deny layer.
+ ///
+ internal static Func> Rule(IReadOnlyList auto, HookRuntime hooks)
+ {
+ return async context =>
+ {
+ FunctionCallContent call = context.FunctionCallContent;
+ TurnInvocation? turn = TurnInvocation.From(context.RunOptions);
+ SessionHooks? session = turn?.Hooks;
+
+ if (session is not null && hooks.Gates.Overrides(GatePoint.BeforeToolApproval))
+ {
+ ApprovalDecision decision = session.ApprovalDecisions.TryGetValue(call.CallId, out ApprovalDecision? known)
+ ? known
+ : await DecideAsync(hooks, session, turn!, call).ConfigureAwait(false);
+
+ if (decision.Approved is { } approved)
+ {
+ return approved;
+ }
+ }
+
+ if (auto.Any(pattern => AgentApproval.Matches(pattern, call.Name)))
+ {
+ if (session is not null)
+ {
+ _ = session.ApprovalDecisions.TryRemove(call.CallId, out _);
+ Changed(session, session.Scope(turn!.TurnIndex, turn.Stage), call, ApprovalState.Approved, ApprovalBy.Rule, reason: null);
+ }
+
+ return true;
+ }
+
+ return false;
+ };
+ }
+
+ /// Adds the deny layer. Registered before UseToolApproval , so it is the outer one.
+ internal static AIAgentBuilder UseDenials(AIAgentBuilder builder)
+ {
+ return builder.Use(
+ runFunc: static (messages, session, options, inner, cancellationToken) => RunAsync(messages, session, options, inner, cancellationToken),
+ runStreamingFunc: static (messages, session, options, inner, cancellationToken) => StreamAsync(messages, session, options, inner, cancellationToken));
+ }
+
+ // Runs the chain once per call. An approval is settled the moment the rule returns true, so only a denial and a
+ // call left undecided are kept, for MAF's next question about the same call.
+ private static async ValueTask DecideAsync(HookRuntime hooks, SessionHooks session, TurnInvocation turn, FunctionCallContent call)
+ {
+ HookScope scope = session.Scope(turn.TurnIndex, turn.Stage);
+ IReadOnlyDictionary arguments = call.Arguments is { } given
+ ? new Dictionary(given, StringComparer.Ordinal)
+ : new Dictionary(StringComparer.Ordinal);
+ IDictionary items = turn.Items ?? new Dictionary(StringComparer.Ordinal);
+
+ // The rule has no CancellationToken; the gate's own deadline still bounds the wait.
+ ApprovalDecision decision = await hooks.Gates.RunAsync(
+ GatePoint.BeforeToolApproval,
+ scope,
+ new ApprovalDecision(Approved: null, Reason: null),
+ _ => new ApprovalGate(scope, call.Name, call.CallId, arguments, items),
+ static (hook, gate, token) => hook.BeforeToolApprovalAsync(gate, token),
+ static (state, gate) => gate.Approved is null ? state : new ApprovalDecision(gate.Approved, gate.DenyReason),
+ static _ => new ApprovalDecision(Approved: false, Reason: FailedClosedReason),
+ session.RaiseFault,
+ CancellationToken.None).ConfigureAwait(false);
+
+ if (decision.Approved is false)
+ {
+ decision = decision with { Reason = decision.Reason ?? FailedClosedReason };
+ Changed(session, scope, call, ApprovalState.Denied, ApprovalBy.Hook, decision.Reason);
+ }
+ else if (decision.Approved is true)
+ {
+ Changed(session, scope, call, ApprovalState.Approved, ApprovalBy.Hook, reason: null);
+ return decision;
+ }
+
+ session.ApprovalDecisions[call.CallId] = decision;
+ return decision;
+ }
+
+ private static void Changed(SessionHooks hooks, HookScope scope, FunctionCallContent call, ApprovalState state, ApprovalBy by, string? reason)
+ {
+ if (hooks.Wants())
+ {
+ _ = hooks.Raise(new ApprovalChanged(scope, call.Name, call.CallId, state, by, reason));
+ }
+ }
+
+ /// The requests among a BeforeToolApproval hook denied and the layer has not answered yet, each with its reason.
+ private static List<(ToolApprovalRequestContent Request, string Reason)> DeniedOf(IEnumerable contents, AgentRunOptions? options)
+ {
+ if (TurnInvocation.From(options)?.Hooks is not { } session)
+ {
+ return [];
+ }
+
+ List<(ToolApprovalRequestContent Request, string Reason)> denied = [];
+ foreach (ToolApprovalRequestContent request in contents.OfType())
+ {
+ if (request.ToolCall is FunctionCallContent call
+ && session.ApprovalDecisions.TryGetValue(call.CallId, out ApprovalDecision? decision)
+ && decision is { Approved: false, Reason: { } reason })
+ {
+ denied.Add((request, reason));
+ }
+ }
+
+ return denied;
+ }
+
+ /// The contents with the denied requests taken out.
+ private static List Without(IList contents, List<(ToolApprovalRequestContent Request, string Reason)> denied)
+ {
+ return [.. contents.Where(content =>
+ content is not ToolApprovalRequestContent request || !denied.Exists(pair => ReferenceEquals(pair.Request, request)))];
+ }
+
+ /// Answers the denials, settling them: the session forgets them and the turn knows their calls were refused.
+ private static ChatMessage Answer(List<(ToolApprovalRequestContent Request, string Reason)> denied, AgentRunOptions? options)
+ {
+ TurnInvocation? turn = TurnInvocation.From(options);
+ foreach ((ToolApprovalRequestContent request, _) in denied)
+ {
+ string callId = ((FunctionCallContent)request.ToolCall).CallId;
+ _ = turn?.Hooks?.ApprovalDecisions.TryRemove(callId, out _);
+ turn?.Results?.NoteDenied(callId);
+ }
+
+ return new(ChatRole.User, [.. denied.Select(static pair => (AIContent)pair.Request.CreateResponse(false, pair.Reason))]);
+ }
+
+ private static int? RoundsSoFar(AgentRunOptions? options) => TurnInvocation.From(options)?.Rounds?.Count;
+
+ // Without a round counter every run counts, which can only end the run sooner.
+ private static bool CalledTheModel(int? before, AgentRunOptions? options) => before is null || RoundsSoFar(options) != before;
+
+ /// The cap is reached with a denial still open: never a person's to answer. Logs, raises the fault, and throws.
+ private static InvalidOperationException Exhausted(List<(ToolApprovalRequestContent Request, string Reason)> denied, AgentRunOptions? options)
+ {
+ string message = $"A hook denied tool calls after {MaxRounds} model rounds of denials in one turn, so the turn ends without asking a person.";
+ if (TurnInvocation.From(options) is { Hooks: { } session } turn)
+ {
+ foreach ((ToolApprovalRequestContent request, _) in denied)
+ {
+ _ = session.ApprovalDecisions.TryRemove(((FunctionCallContent)request.ToolCall).CallId, out _);
+ }
+
+ HookLog.DenialRoundsExhausted(session.Runtime.Logger, session.ConversationId, turn.TurnIndex, MaxRounds);
+ _ = session.Raise(new Fault(session.Scope(turn.TurnIndex, turn.Stage), FaultKind.DenialRoundsExhausted, message, Cause: null));
+ }
+
+ return new InvalidOperationException(message);
+ }
+
+ private static async Task RunAsync(
+ IEnumerable messages, AgentSession? session, AgentRunOptions? options, AIAgent inner, CancellationToken cancellationToken)
+ {
+ session ??= await inner.CreateSessionAsync(cancellationToken).ConfigureAwait(false);
+ IEnumerable input = messages;
+ List all = [];
+ int answered = 0;
+
+ while (true)
+ {
+ int? before = RoundsSoFar(options);
+ AgentResponse response = await inner.RunAsync(input, session, options, cancellationToken).ConfigureAwait(false);
+ List<(ToolApprovalRequestContent Request, string Reason)> denied = DeniedOf(response.Messages.SelectMany(static message => message.Contents), options);
+
+ if (denied.Count == 0)
+ {
+ all.AddRange(response.Messages);
+ response.Messages = all;
+ return response;
+ }
+
+ if (CalledTheModel(before, options) && answered++ == MaxRounds)
+ {
+ throw Exhausted(denied, options);
+ }
+
+ foreach (ChatMessage message in response.Messages)
+ {
+ List kept = Without(message.Contents, denied);
+ if (kept.Count > 0)
+ {
+ all.Add(new ChatMessage(message.Role, kept) { MessageId = message.MessageId, AuthorName = message.AuthorName });
+ }
+ }
+
+ input = [Answer(denied, options)];
+ }
+ }
+
+ // Only updates that carry an approval request are held back; everything else streams through at once.
+ private static async IAsyncEnumerable StreamAsync(
+ IEnumerable messages,
+ AgentSession? session,
+ AgentRunOptions? options,
+ AIAgent inner,
+ [EnumeratorCancellation] CancellationToken cancellationToken)
+ {
+ session ??= await inner.CreateSessionAsync(cancellationToken).ConfigureAwait(false);
+ IEnumerable input = messages;
+ int answered = 0;
+
+ while (true)
+ {
+ int? before = RoundsSoFar(options);
+ List held = [];
+ await foreach (AgentResponseUpdate update in inner.RunStreamingAsync(input, session, options, cancellationToken).ConfigureAwait(false))
+ {
+ if (update.Contents.OfType().Any())
+ {
+ held.Add(update);
+ continue;
+ }
+
+ yield return update;
+ }
+
+ List<(ToolApprovalRequestContent Request, string Reason)> denied = DeniedOf(held.SelectMany(static update => update.Contents), options);
+
+ if (denied.Count == 0)
+ {
+ foreach (AgentResponseUpdate update in held)
+ {
+ yield return update;
+ }
+
+ yield break;
+ }
+
+ if (CalledTheModel(before, options) && answered++ == MaxRounds)
+ {
+ throw Exhausted(denied, options);
+ }
+
+ foreach (AgentResponseUpdate update in held)
+ {
+ List rest = Without(update.Contents, denied);
+ if (rest.Count > 0)
+ {
+ update.Contents = rest;
+ yield return update;
+ }
+ }
+
+ input = [Answer(denied, options)];
+ }
+ }
+ }
+}
diff --git a/src/AgentCore.Application/Hooks/Layers/CallGateChain.cs b/src/AgentCore.Application/Hooks/Layers/CallGateChain.cs
new file mode 100644
index 00000000..2e03b6cc
--- /dev/null
+++ b/src/AgentCore.Application/Hooks/Layers/CallGateChain.cs
@@ -0,0 +1,67 @@
+using AgentCore.Application.Hooks.Engine;
+using AgentCore.Application.Hooks.Gates;
+
+namespace AgentCore.Application.Hooks.Layers
+{
+ /// What a transport offers when a call arrives.
+ /// The entry the call's route serves.
+ /// The transport's id for the call.
+ /// The caller's number or address, when known.
+ /// The number or address that was called, when known.
+ /// The transport's headers, for example SIP headers.
+ /// The kind of transport that offered the call.
+ internal sealed record CallOffer(string Entry, string CallId, string? From, string? To, IReadOnlyDictionary Headers, string Transport);
+
+ /// The call gate's answer: accepted under a conversation id with an optional brief, or refused with a reason.
+ /// Whether the call is taken.
+ /// The conversation the call joins, when accepted.
+ /// Text the agent is told about the call, when accepted.
+ /// Why the call is refused, when it is.
+ internal sealed record CallDecision(bool Accepted, string? ConversationId, string? Brief, CallRefusal? Refusal);
+
+ /// The phone path calls this when a transport offers a call.
+ internal static class CallGateChain
+ {
+ private static readonly CallDecision Undecided = new(Accepted: false, ConversationId: null, Brief: null, Refusal: null);
+
+ /// Runs the chain and returns what the call does.
+ /// The compiled hooks.
+ /// The call on offer.
+ /// The call's token.
+ /// How long each hook may take: providers.conversation.answerSeconds, or for 5 s.
+ internal static async ValueTask DecideAsync(HookRuntime hooks, CallOffer offer, CancellationToken cancellationToken, TimeSpan? deadline = null)
+ {
+ ArgumentNullException.ThrowIfNull(hooks);
+ ArgumentNullException.ThrowIfNull(offer);
+
+ if (!hooks.Gates.Overrides(GatePoint.BeforeCall))
+ {
+ return new CallDecision(Accepted: true, offer.CallId, Brief: null, Refusal: null);
+ }
+
+ HookScope scope = HookScopes.ForGate(conversationId: null, offer.Entry, hooks.Timers.GetUtcNow());
+
+ CallDecision decision = await hooks.Gates.RunAsync(
+ GatePoint.BeforeCall,
+ scope,
+ Undecided,
+ _ => new CallGate(scope, offer.Entry, offer.CallId, offer.From, offer.To, offer.Headers, offer.Transport),
+ static (hook, gate, token) => hook.BeforeCallAsync(gate, token),
+ static (state, gate) =>
+ {
+ if (gate.AcceptedConversationId is { } accepted)
+ {
+ return new CallDecision(Accepted: true, accepted, gate.Brief, Refusal: null);
+ }
+
+ return gate.Refusal is { } refusal ? new CallDecision(Accepted: false, ConversationId: null, Brief: null, refusal) : state;
+ },
+ static _ => new CallDecision(Accepted: false, ConversationId: null, Brief: null, CallRefusal.Unavailable),
+ hooks.RaiseHostFault,
+ cancellationToken,
+ deadline).ConfigureAwait(false);
+
+ return decision == Undecided ? decision with { Refusal = CallRefusal.Unavailable } : decision;
+ }
+ }
+}
diff --git a/src/AgentCore.Application/Hooks/Layers/EntryGateChain.cs b/src/AgentCore.Application/Hooks/Layers/EntryGateChain.cs
new file mode 100644
index 00000000..66bdc30e
--- /dev/null
+++ b/src/AgentCore.Application/Hooks/Layers/EntryGateChain.cs
@@ -0,0 +1,53 @@
+using System.Security.Claims;
+using AgentCore.Application.Hooks.Engine;
+using AgentCore.Application.Hooks.Gates;
+
+namespace AgentCore.Application.Hooks.Layers
+{
+ /// What a request on an AgentCore route offers the entry gate.
+ /// The authenticated user, or when the request is anonymous.
+ /// The HTTP request's headers. A header with several values joins them with a comma.
+ /// The route pattern that matched.
+ /// The entry the URL named, or when the route has no entry segment.
+ /// How the request arrived.
+ internal sealed record EntryOffer(ClaimsPrincipal? User, IReadOnlyDictionary Headers, string Route, string? RequestedEntry, EntryTransport Transport);
+
+ /// Picks the entry a request runs. No decision takes the URL's entry; none there refuses.
+ internal static class EntryGateChain
+ {
+ private sealed record Choice(string? Entry, bool Refused);
+
+ /// Runs the chain and returns the entry the request runs. With no hook overriding BeforeEntry it returns the URL's entry.
+ /// The compiled hooks.
+ /// The request on offer.
+ /// The request's token.
+ /// The entry key, or when a hook refused or no entry is named.
+ internal static async ValueTask ChooseAsync(HookRuntime hooks, EntryOffer offer, CancellationToken cancellationToken)
+ {
+ ArgumentNullException.ThrowIfNull(hooks);
+ ArgumentNullException.ThrowIfNull(offer);
+
+ HookScope scope = HookScopes.ForGate(conversationId: null, offer.RequestedEntry, hooks.Timers.GetUtcNow());
+ Choice choice = await hooks.Gates.RunAsync(
+ GatePoint.BeforeEntry,
+ scope,
+ new Choice(Entry: null, Refused: false),
+ _ => new EntryGate(scope, offer.User, offer.Headers, offer.Route, offer.RequestedEntry, offer.Transport),
+ static (hook, gate, token) => hook.BeforeEntryAsync(gate, token),
+ static (state, gate) =>
+ {
+ if (gate.Chosen is { } chosen)
+ {
+ return new Choice(chosen, Refused: false);
+ }
+
+ return gate.Refused ? new Choice(Entry: null, Refused: true) : state;
+ },
+ static _ => new Choice(Entry: null, Refused: true),
+ hooks.RaiseHostFault,
+ cancellationToken).ConfigureAwait(false);
+
+ return choice.Refused ? null : choice.Entry ?? offer.RequestedEntry;
+ }
+ }
+}
diff --git a/src/AgentCore.Application/Hooks/Layers/GatedSummariser.cs b/src/AgentCore.Application/Hooks/Layers/GatedSummariser.cs
new file mode 100644
index 00000000..d5ca4aa5
--- /dev/null
+++ b/src/AgentCore.Application/Hooks/Layers/GatedSummariser.cs
@@ -0,0 +1,85 @@
+using System.Runtime.CompilerServices;
+using AgentCore.Application.Hooks.Engine;
+using AgentCore.Application.Hooks.Gates;
+using Microsoft.Extensions.AI;
+using AgentCore.Application.Runtime.Turn;
+
+namespace AgentCore.Application.Hooks.Layers
+{
+ ///
+ /// BeforeCompaction at the one point that means "AgentCore is about to compact": the strategy's first request to the
+ /// summariser. The chain runs once; its decision answers every request of this compaction.
+ ///
+ internal sealed class GatedSummariser(IChatClient inner, HookRuntime hooks, TurnInvocation? turn, int messageCount) : DelegatingChatClient(inner)
+ {
+ private sealed record Decision(bool Cancel, string? Summary);
+
+ private Decision? _decision;
+
+ /// Gets whether the strategy asked for a summary at all.
+ internal bool Asked => _decision is not null;
+
+ /// Gets whether a hook cancelled this compaction.
+ internal bool Cancelled => _decision?.Cancel == true;
+
+ public override async Task GetResponseAsync(
+ IEnumerable messages, ChatOptions? options = null, CancellationToken cancellationToken = default)
+ {
+ return await AnswerAsync(cancellationToken).ConfigureAwait(false)
+ ?? await base.GetResponseAsync(messages, options, cancellationToken).ConfigureAwait(false);
+ }
+
+ public override async IAsyncEnumerable GetStreamingResponseAsync(
+ IEnumerable messages, ChatOptions? options = null, [EnumeratorCancellation] CancellationToken cancellationToken = default)
+ {
+ if (await AnswerAsync(cancellationToken).ConfigureAwait(false) is { } answer)
+ {
+ foreach (ChatResponseUpdate update in answer.ToChatResponseUpdates())
+ {
+ yield return update;
+ }
+
+ yield break;
+ }
+
+ await foreach (ChatResponseUpdate update in base.GetStreamingResponseAsync(messages, options, cancellationToken).ConfigureAwait(false))
+ {
+ yield return update;
+ }
+ }
+
+ private async ValueTask AnswerAsync(CancellationToken cancellationToken)
+ {
+ _decision ??= await DecideAsync(cancellationToken).ConfigureAwait(false);
+
+ return _decision switch
+ {
+ { Cancel: true } => new ChatResponse(new ChatMessage(ChatRole.Assistant, string.Empty)),
+ { Summary: { } text } => new ChatResponse(new ChatMessage(ChatRole.Assistant, text)),
+ _ => null,
+ };
+ }
+
+ private async ValueTask DecideAsync(CancellationToken cancellationToken)
+ {
+ if (turn?.Hooks is not { } raiser || !hooks.Gates.Overrides(GatePoint.BeforeCompaction))
+ {
+ return new Decision(Cancel: false, Summary: null);
+ }
+
+ HookScope scope = raiser.Scope(turn.TurnIndex, turn.Stage);
+ IDictionary items = turn.Items ?? new Dictionary(StringComparer.Ordinal);
+
+ return await hooks.Gates.RunAsync(
+ GatePoint.BeforeCompaction,
+ scope,
+ new Decision(Cancel: false, Summary: null),
+ _ => new CompactionGate(scope, messageCount, items),
+ static (hook, gate, token) => hook.BeforeCompactionAsync(gate, token),
+ static (state, gate) => new Decision(state.Cancel || gate.Cancelled, gate.Summary ?? state.Summary),
+ static state => state with { Cancel = true },
+ raiser.RaiseFault,
+ cancellationToken).ConfigureAwait(false);
+ }
+ }
+}
diff --git a/src/AgentCore.Application/Hooks/Layers/ModelHookChatClient.cs b/src/AgentCore.Application/Hooks/Layers/ModelHookChatClient.cs
new file mode 100644
index 00000000..b25f2627
--- /dev/null
+++ b/src/AgentCore.Application/Hooks/Layers/ModelHookChatClient.cs
@@ -0,0 +1,235 @@
+using System.Runtime.CompilerServices;
+using System.Runtime.ExceptionServices;
+using AgentCore.Application.Hooks.Engine;
+using AgentCore.Application.Hooks.Notices;
+using Microsoft.Agents.AI;
+using Microsoft.Extensions.AI;
+using AgentCore.Application.Runtime.ToolCalls;
+using AgentCore.Application.Runtime.Turn;
+
+namespace AgentCore.Application.Hooks.Layers
+{
+ ///
+ /// BeforeModel, AfterModel and AfterModelFailed over each model round trip of an agent, directly inside , so a
+ /// hook sees what the model sees. The function-invoking client sits above, so every round passes here.
+ ///
+ /// The vendor's client.
+ /// The hooks of this compile.
+ internal sealed class ModelHookChatClient(IChatClient inner, HookRuntime hooks) : DelegatingChatClient(inner)
+ {
+ /// The most times one round is retried after a hook's Retry .
+ internal const int MaxRetries = 2;
+
+ private readonly GateRunner _gates = hooks.Gates;
+
+ public override async Task GetResponseAsync(
+ IEnumerable messages, ChatOptions? options = null, CancellationToken cancellationToken = default)
+ {
+ if (Turn() is not { Hooks: { } session } turn)
+ {
+ return await base.GetResponseAsync(messages, options, cancellationToken).ConfigureAwait(false);
+ }
+
+ ModelRound round = new(session, turn);
+ ModelRoundGates.Asked asked = await ModelRoundGates.BeforeAsync(_gates, round, messages, options, cancellationToken).ConfigureAwait(false);
+
+ ChatResponse response;
+ Exception? recovered = null;
+ if (asked.Answer is { } answered)
+ {
+ response = answered;
+ }
+ else
+ {
+ for (int attempt = 0; ; attempt++)
+ {
+ try
+ {
+ response = await base.GetResponseAsync(asked.Messages, asked.Options, cancellationToken).ConfigureAwait(false);
+ break;
+ }
+ catch (Exception failure) when (failure is not OperationCanceledException || !cancellationToken.IsCancellationRequested)
+ {
+ ModelRoundGates.Recovery recovery = await ModelRoundGates
+ .FailedAsync(_gates, round, attempt, failure, canRetry: attempt < MaxRetries, cancellationToken).ConfigureAwait(false);
+
+ if (recovery.Retrying)
+ {
+ continue;
+ }
+
+ if (recovery.Answer is { } substitute)
+ {
+ response = substitute;
+ recovered = failure;
+ break;
+ }
+
+ round.Called(response: null, asked.Options, failure);
+ throw;
+ }
+ }
+ }
+
+ response = await ModelRoundGates.AfterAsync(_gates, round, response, cancellationToken).ConfigureAwait(false);
+ if (asked.Answer is null)
+ {
+ round.Called(response, asked.Options, recovered);
+ }
+
+ return response;
+ }
+
+ public override async IAsyncEnumerable GetStreamingResponseAsync(
+ IEnumerable messages,
+ ChatOptions? options = null,
+ [EnumeratorCancellation] CancellationToken cancellationToken = default)
+ {
+ if (Turn() is not { Hooks: { } session } turn)
+ {
+ await foreach (ChatResponseUpdate update in base.GetStreamingResponseAsync(messages, options, cancellationToken).ConfigureAwait(false))
+ {
+ yield return update;
+ }
+
+ yield break;
+ }
+
+ ModelRound round = new(session, turn);
+ ModelRoundGates.Asked asked = await ModelRoundGates.BeforeAsync(_gates, round, messages, options, cancellationToken).ConfigureAwait(false);
+
+ // A round is buffered only when some hook may replace it.
+ bool buffer = _gates.Overrides(GatePoint.AfterModel);
+ bool collect = buffer || session.Wants();
+ List seen = [];
+ bool yielded = false;
+ Exception? recovered = null;
+
+ if (asked.Answer is { } answered)
+ {
+ seen.AddRange(answered.ToChatResponseUpdates());
+ }
+ else
+ {
+ for (int attempt = 0; ; attempt++)
+ {
+ Exception? failure = null;
+ IAsyncEnumerator? stream = null;
+ try
+ {
+ stream = base.GetStreamingResponseAsync(asked.Messages, asked.Options, cancellationToken).GetAsyncEnumerator(cancellationToken);
+ }
+ catch (Exception thrown) when (thrown is not OperationCanceledException || !cancellationToken.IsCancellationRequested)
+ {
+ failure = thrown;
+ }
+
+ if (stream is not null)
+ {
+ try
+ {
+ while (true)
+ {
+ bool more;
+ try
+ {
+ more = await stream.MoveNextAsync().ConfigureAwait(false);
+ }
+ catch (Exception thrown) when (thrown is not OperationCanceledException || !cancellationToken.IsCancellationRequested)
+ {
+ failure = thrown;
+ break;
+ }
+
+ if (!more)
+ {
+ break;
+ }
+
+ if (collect)
+ {
+ seen.Add(stream.Current);
+ }
+
+ if (!buffer)
+ {
+ // Set just before the yield, so it is true exactly when the caller has something.
+ yielded = true;
+ yield return stream.Current;
+ }
+ }
+ }
+ finally
+ {
+ await stream.DisposeAsync().ConfigureAwait(false);
+ }
+ }
+
+ if (failure is null)
+ {
+ break;
+ }
+
+ ModelRoundGates.Recovery recovery = await ModelRoundGates
+ .FailedAsync(_gates, round, attempt, failure, canRetry: !yielded && attempt < MaxRetries, cancellationToken).ConfigureAwait(false);
+
+ if (recovery.Retrying)
+ {
+ seen.Clear();
+ continue;
+ }
+
+ if (recovery.Answer is { } substitute)
+ {
+ recovered = failure;
+ seen.Clear();
+ seen.AddRange(substitute.ToChatResponseUpdates());
+ if (yielded)
+ {
+ // Without an id of its own the answer would merge into the partial message the caller holds.
+ string messageId = Guid.NewGuid().ToString("N");
+ foreach (ChatResponseUpdate update in seen)
+ {
+ update.MessageId ??= messageId;
+ }
+ }
+
+ if (!buffer)
+ {
+ foreach (ChatResponseUpdate update in seen)
+ {
+ yielded = true;
+ yield return update;
+ }
+ }
+
+ break;
+ }
+
+ round.Called(seen.Count > 0 ? seen.ToChatResponse() : null, asked.Options, failure);
+ ExceptionDispatchInfo.Throw(failure);
+ }
+ }
+
+ if (!buffer && asked.Answer is null)
+ {
+ round.Called(collect ? seen.ToChatResponse() : null, asked.Options, recovered);
+ yield break;
+ }
+
+ ChatResponse collected = seen.ToChatResponse();
+ ChatResponse final = await ModelRoundGates.AfterAsync(_gates, round, collected, cancellationToken).ConfigureAwait(false);
+ if (asked.Answer is null)
+ {
+ round.Called(final, asked.Options, recovered);
+ }
+
+ foreach (ChatResponseUpdate update in ReferenceEquals(final, collected) ? seen : [.. final.ToChatResponseUpdates()])
+ {
+ yield return update;
+ }
+ }
+
+ private static TurnInvocation? Turn() => TurnInvocation.From(AIAgent.CurrentRunContext?.RunOptions);
+ }
+}
diff --git a/src/AgentCore.Application/Hooks/Layers/ModelRound.cs b/src/AgentCore.Application/Hooks/Layers/ModelRound.cs
new file mode 100644
index 00000000..001fd56d
--- /dev/null
+++ b/src/AgentCore.Application/Hooks/Layers/ModelRound.cs
@@ -0,0 +1,56 @@
+using AgentCore.Application.Configuration.Schema;
+using AgentCore.Application.Hooks.Engine;
+using AgentCore.Application.Hooks.Notices;
+using Microsoft.Extensions.AI;
+using AgentCore.Application.Runtime.Turn;
+
+namespace AgentCore.Application.Hooks.Layers
+{
+ /// One model round trip of a turn, as the model layer reports it.
+ internal sealed class ModelRound
+ {
+ private readonly long _started;
+
+ internal ModelRound(SessionHooks hooks, TurnInvocation turn)
+ {
+ Hooks = hooks;
+ Index = turn.Rounds?.Next() ?? 0;
+ Scope = hooks.Scope(turn.TurnIndex, turn.Stage);
+ Items = turn.Items ?? new Dictionary(StringComparer.Ordinal);
+ RefusalReply = turn.RefusalReply ?? AgentCoreConfiguration.DefaultRefusalReply;
+ _started = hooks.Time.GetTimestamp();
+ }
+
+ internal SessionHooks Hooks { get; }
+
+ internal int Index { get; }
+
+ internal HookScope Scope { get; }
+
+ internal IDictionary Items { get; }
+
+ /// Gets what a gate that fails closed answers in the model's place.
+ internal string RefusalReply { get; }
+
+ /// Raises for this round, when some hook wants it.
+ internal void Called(ChatResponse? response, ChatOptions? options, Exception? failure)
+ {
+ if (!Hooks.Wants())
+ {
+ return;
+ }
+
+ UsageDetails? usage = response?.Usage;
+ _ = Hooks.Raise(new ModelCalled(
+ Scope,
+ response?.ModelId ?? options?.ModelId,
+ Index,
+ usage?.InputTokenCount,
+ usage?.OutputTokenCount,
+ usage?.CachedInputTokenCount,
+ Hooks.Time.GetElapsedTime(_started),
+ response?.FinishReason?.Value,
+ failure is null ? null : $"{failure.GetType().Name}: {failure.Message}"));
+ }
+ }
+}
diff --git a/src/AgentCore.Application/Hooks/Layers/ModelRoundGates.cs b/src/AgentCore.Application/Hooks/Layers/ModelRoundGates.cs
new file mode 100644
index 00000000..77adef67
--- /dev/null
+++ b/src/AgentCore.Application/Hooks/Layers/ModelRoundGates.cs
@@ -0,0 +1,70 @@
+using AgentCore.Application.Hooks.Engine;
+using AgentCore.Application.Hooks.Gates;
+using Microsoft.Extensions.AI;
+
+namespace AgentCore.Application.Hooks.Layers
+{
+ /// The three model gate chains (BeforeModel, AfterModel, AfterModelFailed) over one round.
+ internal static class ModelRoundGates
+ {
+ internal static ValueTask BeforeAsync(
+ GateRunner gates, ModelRound round, IEnumerable messages, ChatOptions? options, CancellationToken cancellationToken)
+ {
+ Asked asked = new(messages as IReadOnlyList ?? [.. messages], options, Answer: null);
+ return !gates.Overrides(GatePoint.BeforeModel)
+ ? ValueTask.FromResult(asked)
+ : gates.RunAsync(
+ GatePoint.BeforeModel,
+ round.Scope,
+ asked,
+ state => new ModelGate(round.Scope, round.Index, state.Messages, state.Options, round.Items),
+ static (hook, gate, token) => hook.BeforeModelAsync(gate, token),
+ static (state, gate) => new Asked(gate.NewMessages ?? state.Messages, gate.NewOptions ?? state.Options, gate.Response ?? state.Answer),
+ state => state with { Answer = Refusal(round) },
+ round.Hooks.RaiseFault,
+ cancellationToken);
+ }
+
+ internal static ValueTask AfterAsync(GateRunner gates, ModelRound round, ChatResponse response, CancellationToken cancellationToken)
+ {
+ return !gates.Overrides(GatePoint.AfterModel)
+ ? ValueTask.FromResult(response)
+ : gates.RunAsync(
+ GatePoint.AfterModel,
+ round.Scope,
+ response,
+ state => new ModelResultGate(round.Scope, round.Index, state, round.Items),
+ static (hook, gate, token) => hook.AfterModelAsync(gate, token),
+ static (state, gate) => gate.Replacement ?? state,
+ _ => Refusal(round),
+ round.Hooks.RaiseFault,
+ cancellationToken);
+ }
+
+ internal static ValueTask FailedAsync(
+ GateRunner gates, ModelRound round, int attempt, Exception failure, bool canRetry, CancellationToken cancellationToken)
+ {
+ Recovery none = new(Retrying: false, Answer: null);
+ return !gates.Overrides(GatePoint.AfterModelFailed)
+ ? ValueTask.FromResult(none)
+ : gates.RunAsync(
+ GatePoint.AfterModelFailed,
+ round.Scope,
+ none,
+ _ => new ModelFailureGate(round.Scope, round.Index, attempt, failure, canRetry, round.Items),
+ static (hook, gate, token) => hook.AfterModelFailedAsync(gate, token),
+ static (state, gate) => new Recovery(gate.Retrying, gate.Response ?? state.Answer),
+ static state => state,
+ round.Hooks.RaiseFault,
+ cancellationToken);
+ }
+
+ private static ChatResponse Refusal(ModelRound round) => new(new ChatMessage(ChatRole.Assistant, round.RefusalReply));
+
+ /// What the model is asked after BeforeModel, or the answer a hook gave in its place.
+ internal sealed record Asked(IReadOnlyList Messages, ChatOptions? Options, ChatResponse? Answer);
+
+ /// What AfterModelFailed decided: call the model again, or answer for it.
+ internal sealed record Recovery(bool Retrying, ChatResponse? Answer);
+ }
+}
diff --git a/src/AgentCore.Application/Hooks/Layers/RunEndGateChain.cs b/src/AgentCore.Application/Hooks/Layers/RunEndGateChain.cs
new file mode 100644
index 00000000..91d9f713
--- /dev/null
+++ b/src/AgentCore.Application/Hooks/Layers/RunEndGateChain.cs
@@ -0,0 +1,45 @@
+using AgentCore.Application.Hooks.Engine;
+using AgentCore.Application.Hooks.Gates;
+using AgentCore.Application.Runtime.Turn;
+
+namespace AgentCore.Application.Hooks.Layers
+{
+ /// The run has a reply, before the turn is sealed. The loop evaluator in ApplyLoop calls this.
+ internal static class RunEndGateChain
+ {
+ /// The run cap when the agent declares no loop: .
+ internal const int DefaultMaxIterations = 3;
+
+ /// Runs the chain and returns the message to run again with, or to stop.
+ /// The compiled hooks.
+ /// The turn the run belongs to, or outside a conversation turn.
+ /// The agent that ran, when known.
+ /// The run's reply text.
+ /// How many runs of this turn have completed, starting at 1.
+ /// The turn's token.
+ internal static async ValueTask ContinueAsync(
+ HookRuntime hooks, TurnInvocation? turn, string? agentId, string replyText, int iteration, CancellationToken cancellationToken)
+ {
+ ArgumentNullException.ThrowIfNull(hooks);
+
+ if (turn is not { Nested: false, Hooks: { } raiser })
+ {
+ return null;
+ }
+
+ HookScope scope = raiser.Scope(turn.TurnIndex, turn.Stage);
+ IDictionary items = turn.Items ?? new Dictionary(StringComparer.Ordinal);
+
+ return await hooks.Gates.RunAsync(
+ GatePoint.AfterRun,
+ scope,
+ null,
+ _ => new RunEndGate(scope, agentId, replyText, iteration, items),
+ static (hook, gate, token) => hook.AfterRunAsync(gate, token),
+ static (state, gate) => gate.Continuation ?? state,
+ static state => state,
+ raiser.RaiseFault,
+ cancellationToken).ConfigureAwait(false);
+ }
+ }
+}
diff --git a/src/AgentCore.Application/Hooks/Layers/RunGateProvider.cs b/src/AgentCore.Application/Hooks/Layers/RunGateProvider.cs
new file mode 100644
index 00000000..c53ed6dc
--- /dev/null
+++ b/src/AgentCore.Application/Hooks/Layers/RunGateProvider.cs
@@ -0,0 +1,51 @@
+using AgentCore.Application.Hooks.Engine;
+using AgentCore.Application.Hooks.Gates;
+using AgentCore.Application.Runtime.Turn;
+using Microsoft.Agents.AI;
+using Microsoft.Extensions.AI;
+
+namespace AgentCore.Application.Hooks.Layers
+{
+ /// Once per run, when its context is built. Everything it adds is seen on every round.
+ internal sealed class RunGateProvider(HookRuntime hooks) : AIContextProvider
+ {
+ private sealed record Added(IReadOnlyList Instructions, IReadOnlyList Messages, IReadOnlyList Tools);
+
+ protected override async ValueTask ProvideAIContextAsync(InvokingContext context, CancellationToken cancellationToken = default)
+ {
+ ArgumentNullException.ThrowIfNull(context);
+
+ // The turn's own run is filed under its session, and a graph participant under its own with a nested
+ // copy; an agent-as-tool child is found through the run's options and is nested too.
+ TurnInvocation? filed = TurnRegistry.For(context.Session);
+ TurnInvocation? turn = filed ?? TurnInvocation.From(AIAgent.CurrentRunContext?.RunOptions);
+ if (turn?.Hooks is not { } raiser)
+ {
+ return new AIContext();
+ }
+
+ bool nested = turn.Nested || filed is null;
+ HookScope scope = raiser.Scope(turn.TurnIndex, turn.Stage);
+ IDictionary items = turn.Items ?? new Dictionary(StringComparer.Ordinal);
+ string agentId = context.Agent.Name ?? context.Agent.Id;
+
+ Added added = await hooks.Gates.RunAsync(
+ GatePoint.BeforeRun,
+ scope,
+ new Added([], [], []),
+ _ => new RunGate(scope, agentId, nested, items),
+ static (hook, gate, token) => hook.BeforeRunAsync(gate, token),
+ static (state, gate) => new Added([.. state.Instructions, .. gate.Instructions], [.. state.Messages, .. gate.Messages], [.. state.Tools, .. gate.Tools]),
+ static state => state,
+ raiser.RaiseFault,
+ cancellationToken).ConfigureAwait(false);
+
+ return new AIContext
+ {
+ Instructions = added.Instructions.Count == 0 ? null : string.Join("\n\n", added.Instructions),
+ Messages = added.Messages.Count == 0 ? null : added.Messages,
+ Tools = added.Tools.Count == 0 ? null : added.Tools,
+ };
+ }
+ }
+}
diff --git a/src/AgentCore.Application/Hooks/Layers/ToolCallOutcomes.cs b/src/AgentCore.Application/Hooks/Layers/ToolCallOutcomes.cs
new file mode 100644
index 00000000..dc72fd63
--- /dev/null
+++ b/src/AgentCore.Application/Hooks/Layers/ToolCallOutcomes.cs
@@ -0,0 +1,32 @@
+using AgentCore.Application.Hooks.Notices;
+using Microsoft.Extensions.AI;
+
+namespace AgentCore.Application.Hooks.Layers
+{
+ ///
+ /// Marks a tool call's outcome beside its arguments, where the wrappers that know it (cache, time limit,
+ /// pinned-skill redirect) and the tool middleware that reports it both reach it.
+ ///
+ internal static class ToolCallOutcomes
+ {
+ internal static readonly object CachedKey = new();
+
+ internal static readonly object TimedOutKey = new();
+
+ internal static readonly object RedirectedKey = new();
+
+ internal static void Mark(AIFunctionArguments arguments, object key)
+ {
+ arguments.Context ??= new Dictionary();
+ arguments.Context[key] = true;
+ }
+
+ internal static bool Marked(AIFunctionArguments arguments, object key) =>
+ arguments.Context?.ContainsKey(key) == true;
+
+ internal static ToolOutcome Of(AIFunctionArguments arguments) =>
+ Marked(arguments, TimedOutKey) ? ToolOutcome.TimedOut
+ : Marked(arguments, CachedKey) ? ToolOutcome.Cached
+ : ToolOutcome.Ok;
+ }
+}
diff --git a/src/AgentCore.Application/Hooks/Layers/ToolHookMiddleware.cs b/src/AgentCore.Application/Hooks/Layers/ToolHookMiddleware.cs
new file mode 100644
index 00000000..9ec4b75e
--- /dev/null
+++ b/src/AgentCore.Application/Hooks/Layers/ToolHookMiddleware.cs
@@ -0,0 +1,245 @@
+using System.Globalization;
+using System.Text.Json;
+using AgentCore.Application.Hooks.Engine;
+using AgentCore.Application.Hooks.Gates;
+using AgentCore.Application.Hooks.Notices;
+using AgentCore.Application.Skills;
+using AgentCore.Application.Tools;
+using AgentCore.Domain.Audit;
+using Microsoft.Agents.AI;
+using Microsoft.Extensions.AI;
+using AgentCore.Application.Runtime.Session;
+using AgentCore.Application.Runtime.ToolCalls;
+using AgentCore.Application.Runtime.Turn;
+
+namespace AgentCore.Application.Hooks.Layers
+{
+ ///
+ /// The one function middleware of an agent: BeforeTool, AfterTool and AfterToolFailed, and one per call.
+ /// MAF runs function middleware innermost-first, so AgentCore registers this one and orders the hooks itself.
+ ///
+ internal static class ToolHookMiddleware
+ {
+ /// The most times one call is run again after a hook's Retry .
+ internal const int MaxRetries = 2;
+
+ private static readonly string StoppedAtEnd = string.Create(
+ CultureInfo.InvariantCulture, $"stopped {ConversationEnding.ToolGrace.TotalSeconds:0} s after the conversation ended.");
+
+ internal static AIAgent Apply(AIAgent agent, HookRuntime hooks)
+ {
+ AIAgent gated = new AIAgentBuilder(agent)
+ .Use((_, context, next, cancellationToken) => InvokeAsync(hooks.Gates, context, next, cancellationToken))
+ .Build();
+
+ // MAF 1.21.0's function middleware wraps the ChatClientFactory of the options it is handed, in place.
+ // LoopAgent hands every round the same options, so without a fresh copy per run round k would run
+ // this middleware k times per call.
+ return new AIAgentBuilder(gated)
+ .Use(
+ (messages, session, options, inner, cancellationToken) => inner.RunAsync(messages, session, Fresh(options), cancellationToken),
+ (messages, session, options, inner, cancellationToken) => inner.RunStreamingAsync(messages, session, Fresh(options), cancellationToken))
+ .Build();
+ }
+
+ private static AgentRunOptions? Fresh(AgentRunOptions? options) =>
+ options is ChatClientAgentRunOptions run ? run.Clone() : options;
+
+ private sealed record Before(Dictionary Arguments, bool Blocked, bool Responded, object? Result, bool EndLoop);
+
+ private sealed record After(object? Result, bool EndLoop);
+
+ private sealed record Failed(bool Retrying, bool Responded, object? Result);
+
+ /// What every gate and the notice of one call share.
+ private sealed record Call(
+ GateRunner Gates,
+ SessionHooks Hooks,
+ HookScope Scope,
+ string Name,
+ string CallId,
+ IDictionary Items,
+ long Started);
+
+ private static async ValueTask InvokeAsync(
+ GateRunner gates,
+ FunctionInvocationContext context,
+ Func> next,
+ CancellationToken cancellationToken)
+ {
+ TurnInvocation? turn = TurnInvocation.FiledIn(context.Arguments) ?? TurnInvocation.From(context.Options);
+ if (turn?.Hooks is not { } hooks)
+ {
+ return await next(context, cancellationToken).ConfigureAwait(false);
+ }
+
+ Call call = new(
+ gates,
+ hooks,
+ hooks.Scope(turn.TurnIndex, turn.Stage),
+ context.CallContent.Name,
+ context.CallContent.CallId,
+ turn.Items ?? new Dictionary(StringComparer.Ordinal),
+ hooks.Time.GetTimestamp());
+
+ if (gates.Overrides(GatePoint.BeforeTool))
+ {
+ Dictionary asked = new(context.Arguments, StringComparer.Ordinal);
+ Before before = await BeforeAsync(call, asked, cancellationToken).ConfigureAwait(false);
+ context.Terminate |= before.EndLoop;
+
+ if (before.Blocked || before.Responded)
+ {
+ Called(call, before.Blocked ? ToolOutcome.Blocked : ToolOutcome.Responded, fatal: false, kind: null, failure: null);
+ return before.Result;
+ }
+
+ if (!ReferenceEquals(before.Arguments, asked))
+ {
+ context.Arguments = Replaced(context.Arguments, before.Arguments);
+ }
+ }
+
+ object? result;
+ for (int attempt = 0; ; attempt++)
+ {
+ try
+ {
+ result = await next(context, cancellationToken).ConfigureAwait(false);
+ break;
+ }
+ catch (Exception exception) when (exception is not OperationCanceledException || !cancellationToken.IsCancellationRequested)
+ {
+ Failed failed = await FailedAsync(call, attempt, exception, cancellationToken).ConfigureAwait(false);
+ if (failed.Retrying)
+ {
+ continue;
+ }
+
+ string message = $"{exception.GetType().Name}: {exception.Message}";
+ if (failed.Responded)
+ {
+ Called(call, ToolOutcome.Responded, fatal: false, ToolFailureKind.Faulted, message);
+ return failed.Result;
+ }
+
+ Called(call, ToolOutcome.Failed, AuditingFunctionInvokingChatClient.IsBeyondTheModel(exception), ToolFailureKind.Faulted, message);
+ throw;
+ }
+ catch (OperationCanceledException) when (cancellationToken == turn.ToolStop)
+ {
+ // The end backstop stopped a call that ran: the audit shows it.
+ Called(call, ToolOutcome.Failed, fatal: true, ToolFailureKind.Faulted, StoppedAtEnd);
+ throw;
+ }
+ }
+
+ if (gates.Overrides(GatePoint.AfterTool))
+ {
+ After after = await AfterAsync(call, result, cancellationToken).ConfigureAwait(false);
+ result = after.Result;
+ context.Terminate |= after.EndLoop;
+ }
+
+ ToolOutcome outcome = ToolCallOutcomes.Of(context.Arguments);
+ Called(call, outcome, fatal: false, kind: null, outcome == ToolOutcome.TimedOut ? "the tool did not answer within its time limit." : null);
+ RaiseSkillLoaded(call, context.Arguments, result);
+
+ return result;
+ }
+
+ private static ValueTask BeforeAsync(Call call, Dictionary asked, CancellationToken cancellationToken)
+ {
+ return call.Gates.RunAsync(
+ GatePoint.BeforeTool,
+ call.Scope,
+ new Before(asked, Blocked: false, Responded: false, Result: null, EndLoop: false),
+ state => new ToolGate(call.Scope, call.Name, call.CallId, state.Arguments, call.Items),
+ static (hook, gate, token) => hook.BeforeToolAsync(gate, token),
+ static (state, gate) => state with
+ {
+ // A copy: the hook keeps its own dictionary and may change it after the gate closed.
+ Arguments = gate.NewArguments is { } replaced ? new Dictionary(replaced, StringComparer.Ordinal) : state.Arguments,
+ Blocked = gate.Blocked,
+ Responded = gate.Responded,
+ Result = gate.Blocked || gate.Responded ? gate.Result : state.Result,
+ EndLoop = state.EndLoop || gate.EndsLoop,
+ },
+ state => state with { Blocked = true, Result = ToolErrorResult.Create(call.Name, "a hook refused this call.") },
+ call.Hooks.RaiseFault,
+ cancellationToken);
+ }
+
+ private static async ValueTask FailedAsync(Call call, int attempt, Exception exception, CancellationToken cancellationToken)
+ {
+ if (!call.Gates.Overrides(GatePoint.AfterToolFailed))
+ {
+ return new Failed(Retrying: false, Responded: false, Result: null);
+ }
+
+ return await call.Gates.RunAsync(
+ GatePoint.AfterToolFailed,
+ call.Scope,
+ new Failed(Retrying: false, Responded: false, Result: null),
+ _ => new ToolFailureGate(call.Scope, call.Name, call.CallId, attempt, exception, attempt < MaxRetries, call.Items),
+ static (hook, gate, token) => hook.AfterToolFailedAsync(gate, token),
+ static (state, gate) => new Failed(gate.Retrying, gate.Responded, gate.Responded ? gate.Result : state.Result),
+ static state => state,
+ call.Hooks.RaiseFault,
+ cancellationToken).ConfigureAwait(false);
+ }
+
+ private static ValueTask AfterAsync(Call call, object? result, CancellationToken cancellationToken)
+ {
+ return call.Gates.RunAsync(
+ GatePoint.AfterTool,
+ call.Scope,
+ new After(result, EndLoop: false),
+ state => new ToolResultGate(call.Scope, call.Name, call.CallId, state.Result, call.Items),
+ static (hook, gate, token) => hook.AfterToolAsync(gate, token),
+ static (state, gate) => new After(gate.Replaced ? gate.NewResult : state.Result, state.EndLoop || gate.EndsLoop),
+ state => state with { Result = "a hook withheld this result." },
+ call.Hooks.RaiseFault,
+ cancellationToken);
+ }
+
+ private static void Called(Call call, ToolOutcome outcome, bool fatal, ToolFailureKind? kind, string? failure)
+ {
+ if (call.Hooks.Wants())
+ {
+ _ = call.Hooks.Raise(new ToolCalled(
+ call.Scope, call.Name, call.CallId, call.Hooks.Time.GetElapsedTime(call.Started), outcome, fatal, kind, failure));
+ }
+ }
+
+ // A load_skill the pinned-skill redirect answered, or that MAF answered "not
+ // found", loaded nothing.
+ private static void RaiseSkillLoaded(Call call, AIFunctionArguments arguments, object? result)
+ {
+ if (string.Equals(call.Name, AgentSkillsProvider.LoadSkillToolName, StringComparison.Ordinal)
+ && !ToolCallOutcomes.Marked(arguments, ToolCallOutcomes.RedirectedKey)
+ && PinnedSkillRedirect.ReadName(arguments) is { } skill
+ && !SkillNotFound(skill, result)
+ && call.Hooks.Wants())
+ {
+ _ = call.Hooks.Raise(new SkillLoaded(call.Scope, skill, Pinned: false));
+ }
+ }
+
+ // MAF's load_skill answers an unknown name with this text (SkillsProviderFactoryTests pins it), serialized.
+ private static bool SkillNotFound(string skill, object? result) =>
+ result is JsonElement { ValueKind: JsonValueKind.String } text
+ && string.Equals(text.GetString(), $"Error: Skill '{skill}' not found.", StringComparison.Ordinal);
+
+ // A new object, because the old one wraps the model's own call record, which is stored and sent back.
+ // It shares the old Context: that is where the turn is filed and where the wrappers leave their marks.
+ private static AIFunctionArguments Replaced(AIFunctionArguments arguments, Dictionary replacement)
+ {
+ return new AIFunctionArguments(replacement)
+ {
+ Context = arguments.Context,
+ Services = arguments.Services,
+ };
+ }
+ }
+}
diff --git a/src/AgentCore.Application/Hooks/Layers/TurnGateAgent.cs b/src/AgentCore.Application/Hooks/Layers/TurnGateAgent.cs
new file mode 100644
index 00000000..5769f065
--- /dev/null
+++ b/src/AgentCore.Application/Hooks/Layers/TurnGateAgent.cs
@@ -0,0 +1,97 @@
+using System.Runtime.CompilerServices;
+using AgentCore.Application.Hooks.Engine;
+using AgentCore.Application.Hooks.Gates;
+using Microsoft.Agents.AI;
+using Microsoft.Extensions.AI;
+using AgentCore.Application.Runtime.Turn;
+using AgentCore.Application.Runtime.Turn.Lifecycle;
+
+namespace AgentCore.Application.Hooks.Layers
+{
+ ///
+ /// Runs between the seal and moderation. It runs once per conversation turn, never for a nested run, and before
+ /// moderation and the model see the caller's words. A hook that fails closed blocks the turn with refusalReply .
+ ///
+ internal sealed class TurnGateAgent(AIAgent inner, HookRuntime hooks, string refusalReply) : DelegatingAIAgent(inner)
+ {
+ private sealed record Decision(string Text, string? Block, IReadOnlyList Context);
+
+ protected override async Task RunCoreAsync(
+ IEnumerable messages, AgentSession? session = null, AgentRunOptions? options = null, CancellationToken cancellationToken = default)
+ {
+ if (Filed(options) is null)
+ {
+ return await base.RunCoreAsync(messages, session, options, cancellationToken).ConfigureAwait(false);
+ }
+
+ List updates = [];
+ await foreach (AgentResponseUpdate update in RunCoreStreamingAsync(messages, session, options, cancellationToken).ConfigureAwait(false))
+ {
+ updates.Add(update);
+ }
+
+ return updates.ToAgentResponse();
+ }
+
+ protected override async IAsyncEnumerable RunCoreStreamingAsync(
+ IEnumerable messages,
+ AgentSession? session = null,
+ AgentRunOptions? options = null,
+ [EnumeratorCancellation] CancellationToken cancellationToken = default)
+ {
+ if (Filed(options) is not { } turn)
+ {
+ await foreach (AgentResponseUpdate update in base.RunCoreStreamingAsync(messages, session, options, cancellationToken).ConfigureAwait(false))
+ {
+ yield return update;
+ }
+
+ yield break;
+ }
+
+ ChatMessage user = turn.User!;
+ SessionHooks raiser = turn.Hooks!;
+ HookScope scope = raiser.Scope(turn.TurnIndex, turn.Stage);
+ IDictionary items = turn.Items ?? new Dictionary(StringComparer.Ordinal);
+
+ Decision decision = await hooks.Gates.RunAsync(
+ GatePoint.BeforeTurn,
+ scope,
+ new Decision(user.Text, Block: null, Context: []),
+ state => new TurnGate(scope, state.Text, items),
+ static (hook, gate, token) => hook.BeforeTurnAsync(gate, token),
+ static (state, gate) => new Decision(gate.Input ?? state.Text, gate.BlockReply ?? state.Block, [.. state.Context, .. gate.AddedContext]),
+ state => state with { Block = refusalReply },
+ raiser.RaiseFault,
+ cancellationToken).ConfigureAwait(false);
+
+ if (decision.Block is { } reply)
+ {
+ AgentResponseUpdate blocked = new(ChatRole.Assistant, reply);
+ (blocked.AdditionalProperties ??= []).Add(
+ new TurnDisposition(Moderation: null, FlaggedCategories: null, FallbackCause.None, FallbackFault: null, ModerationReason: null) { Blocked = true });
+ yield return blocked;
+ yield break;
+ }
+
+ if (!string.Equals(decision.Text, user.Text, StringComparison.Ordinal))
+ {
+ // In place: the seal commits this same message object (FiledTurn.User).
+ user.Contents = [new TextContent(decision.Text), .. user.Contents.Where(static content => content is not TextContent)];
+ }
+
+ foreach (string note in decision.Context)
+ {
+ turn.AddedContext?.Enqueue(note);
+ }
+
+ await foreach (AgentResponseUpdate update in base.RunCoreStreamingAsync(messages, session, options, cancellationToken).ConfigureAwait(false))
+ {
+ yield return update;
+ }
+ }
+
+ private static TurnInvocation? Filed(AgentRunOptions? options) =>
+ TurnInvocation.From(options) is { Nested: false, User: not null, Hooks: not null } turn ? turn : null;
+ }
+}
diff --git a/src/AgentCore.Application/Hooks/Notices/ApprovalBy.cs b/src/AgentCore.Application/Hooks/Notices/ApprovalBy.cs
new file mode 100644
index 00000000..b8d114a7
--- /dev/null
+++ b/src/AgentCore.Application/Hooks/Notices/ApprovalBy.cs
@@ -0,0 +1,15 @@
+namespace AgentCore.Application.Hooks.Notices
+{
+ /// Who answered an approval, or who will.
+ public enum ApprovalBy
+ {
+ /// A hook.
+ Hook,
+
+ /// A person.
+ Human,
+
+ /// A rule of the entry.
+ Rule,
+ }
+}
diff --git a/src/AgentCore.Application/Hooks/Notices/ApprovalChanged.cs b/src/AgentCore.Application/Hooks/Notices/ApprovalChanged.cs
new file mode 100644
index 00000000..825a267e
--- /dev/null
+++ b/src/AgentCore.Application/Hooks/Notices/ApprovalChanged.cs
@@ -0,0 +1,18 @@
+namespace AgentCore.Application.Hooks.Notices
+{
+ /// A tool approval was asked or answered.
+ /// Where and when it happened.
+ /// The tool awaiting or holding the approval.
+ /// The function call id (FunctionCallContent.CallId ), for , and alike.
+ /// Where the approval stands.
+ /// Who answered the approval, or who will.
+ /// The reason given, or .
+ public sealed record ApprovalChanged(
+ HookScope Scope,
+ string ToolName,
+ string CallId,
+ ApprovalState State,
+ ApprovalBy By,
+ string? Reason)
+ : HookNotice(Scope);
+}
diff --git a/src/AgentCore.Application/Hooks/Notices/ApprovalState.cs b/src/AgentCore.Application/Hooks/Notices/ApprovalState.cs
new file mode 100644
index 00000000..b6eae953
--- /dev/null
+++ b/src/AgentCore.Application/Hooks/Notices/ApprovalState.cs
@@ -0,0 +1,15 @@
+namespace AgentCore.Application.Hooks.Notices
+{
+ /// Where an approval stands.
+ public enum ApprovalState
+ {
+ /// The approval was requested.
+ Asked,
+
+ /// The call was approved.
+ Approved,
+
+ /// The call was denied.
+ Denied,
+ }
+}
diff --git a/src/AgentCore.Application/Hooks/Notices/CallEnd.cs b/src/AgentCore.Application/Hooks/Notices/CallEnd.cs
new file mode 100644
index 00000000..f18694a9
--- /dev/null
+++ b/src/AgentCore.Application/Hooks/Notices/CallEnd.cs
@@ -0,0 +1,8 @@
+namespace AgentCore.Application.Hooks.Notices
+{
+ /// The call part of a conversation that ended on the phone.
+ /// The transport's own id of the call.
+ /// How long the call lasted, in seconds.
+ /// The transport's word for why the call ended, or .
+ public sealed record CallEnd(string CallId, double Seconds, string? Cause);
+}
diff --git a/src/AgentCore.Application/Hooks/Notices/CallEndReason.cs b/src/AgentCore.Application/Hooks/Notices/CallEndReason.cs
new file mode 100644
index 00000000..c955642f
--- /dev/null
+++ b/src/AgentCore.Application/Hooks/Notices/CallEndReason.cs
@@ -0,0 +1,18 @@
+namespace AgentCore.Application.Hooks.Notices
+{
+ /// How a call left its conversation.
+ public enum CallEndReason
+ {
+ /// The call ended together with its conversation, which raises .
+ Ended,
+
+ /// A newer connection with the same call id took the call over; the conversation goes on under it.
+ Replaced,
+
+ /// Another call took the conversation after it unloaded; that call's conversation is left alone.
+ Lost,
+
+ /// The call left without ending its conversation, which was closed instead.
+ Closed,
+ }
+}
diff --git a/src/AgentCore.Application/Hooks/Notices/CallEnded.cs b/src/AgentCore.Application/Hooks/Notices/CallEnded.cs
new file mode 100644
index 00000000..4ef40086
--- /dev/null
+++ b/src/AgentCore.Application/Hooks/Notices/CallEnded.cs
@@ -0,0 +1,19 @@
+namespace AgentCore.Application.Hooks.Notices
+{
+ ///
+ /// A started call left, once per call, however it left. It never replaces , which
+ /// stays once per conversation; this one also reaches the hooks after that end.
+ ///
+ /// Where and when it happened.
+ /// The transport's own id of the call.
+ /// How long the call lasted, in seconds, up to when it began to leave.
+ /// The transport's word for why the call ended, or .
+ /// How the call left its conversation.
+ public sealed record CallEnded(
+ HookScope Scope,
+ string CallId,
+ double Seconds,
+ string? Cause,
+ CallEndReason Reason)
+ : HookNotice(Scope);
+}
diff --git a/src/AgentCore.Application/Hooks/Notices/CallStarted.cs b/src/AgentCore.Application/Hooks/Notices/CallStarted.cs
new file mode 100644
index 00000000..00834fdd
--- /dev/null
+++ b/src/AgentCore.Application/Hooks/Notices/CallStarted.cs
@@ -0,0 +1,16 @@
+namespace AgentCore.Application.Hooks.Notices
+{
+ /// A call was accepted and its session is open.
+ /// Where and when it happened.
+ /// The transport's own id of the call.
+ /// The caller's number, or .
+ /// The number called, or .
+ /// The kind of transport that carried the call.
+ public sealed record CallStarted(
+ HookScope Scope,
+ string CallId,
+ string? From,
+ string? To,
+ string Transport)
+ : HookNotice(Scope);
+}
diff --git a/src/AgentCore.Application/Hooks/Notices/Compacted.cs b/src/AgentCore.Application/Hooks/Notices/Compacted.cs
new file mode 100644
index 00000000..0cb83407
--- /dev/null
+++ b/src/AgentCore.Application/Hooks/Notices/Compacted.cs
@@ -0,0 +1,14 @@
+namespace AgentCore.Application.Hooks.Notices
+{
+ /// A compaction finished or failed.
+ /// Where and when it happened.
+ /// The message count before.
+ /// The message count after.
+ /// How it ended.
+ public sealed record Compacted(
+ HookScope Scope,
+ int MessagesBefore,
+ int MessagesAfter,
+ CompactionOutcome Outcome)
+ : HookNotice(Scope);
+}
diff --git a/src/AgentCore.Application/Hooks/Notices/CompactionOutcome.cs b/src/AgentCore.Application/Hooks/Notices/CompactionOutcome.cs
new file mode 100644
index 00000000..c2952698
--- /dev/null
+++ b/src/AgentCore.Application/Hooks/Notices/CompactionOutcome.cs
@@ -0,0 +1,18 @@
+namespace AgentCore.Application.Hooks.Notices
+{
+ /// How a compaction ended.
+ public enum CompactionOutcome
+ {
+ /// The history shrank.
+ Compacted,
+
+ /// The history stayed as it was.
+ Unchanged,
+
+ /// The compaction faulted.
+ Failed,
+
+ /// The compaction was cancelled.
+ Cancelled,
+ }
+}
diff --git a/src/AgentCore.Application/Hooks/Notices/ConversationEnded.cs b/src/AgentCore.Application/Hooks/Notices/ConversationEnded.cs
new file mode 100644
index 00000000..fd4d2f0e
--- /dev/null
+++ b/src/AgentCore.Application/Hooks/Notices/ConversationEnded.cs
@@ -0,0 +1,16 @@
+using AgentCore.Domain.Audit;
+
+namespace AgentCore.Application.Hooks.Notices
+{
+ /// The conversation ended. Raised once per conversation.
+ /// Where and when it happened.
+ /// Why it ended.
+ /// The terminal stage the machine stopped in, or null when the conversation ended for another reason.
+ /// The call that carried the conversation, or null. Set by the phone path.
+ public sealed record ConversationEnded(
+ HookScope Scope,
+ ConversationEndReason Reason,
+ string? TerminalStage,
+ CallEnd? Call)
+ : HookNotice(Scope);
+}
diff --git a/src/AgentCore.Application/Hooks/Notices/ConversationOrigin.cs b/src/AgentCore.Application/Hooks/Notices/ConversationOrigin.cs
new file mode 100644
index 00000000..98eaec83
--- /dev/null
+++ b/src/AgentCore.Application/Hooks/Notices/ConversationOrigin.cs
@@ -0,0 +1,21 @@
+namespace AgentCore.Application.Hooks.Notices
+{
+ /// How a session found its conversation.
+ public enum ConversationOrigin
+ {
+ /// The store held nothing.
+ New,
+
+ /// The store held rows or state.
+ Resumed,
+
+ /// This process held the conversation in an earlier session.
+ Reloaded,
+
+ ///
+ /// The session ended or refused a turn before its store was opened, so whether the conversation was new or
+ /// resumed is unknown.
+ ///
+ Unopened,
+ }
+}
diff --git a/src/AgentCore.Application/Hooks/Notices/ConversationStarted.cs b/src/AgentCore.Application/Hooks/Notices/ConversationStarted.cs
new file mode 100644
index 00000000..1ab0ad35
--- /dev/null
+++ b/src/AgentCore.Application/Hooks/Notices/ConversationStarted.cs
@@ -0,0 +1,7 @@
+namespace AgentCore.Application.Hooks.Notices
+{
+ /// The store opened the conversation.
+ /// Where and when it happened.
+ /// How the session found the conversation.
+ public sealed record ConversationStarted(HookScope Scope, ConversationOrigin Origin) : HookNotice(Scope);
+}
diff --git a/src/AgentCore.Application/Hooks/Notices/ConversationUnloaded.cs b/src/AgentCore.Application/Hooks/Notices/ConversationUnloaded.cs
new file mode 100644
index 00000000..cfbfbf8f
--- /dev/null
+++ b/src/AgentCore.Application/Hooks/Notices/ConversationUnloaded.cs
@@ -0,0 +1,7 @@
+namespace AgentCore.Application.Hooks.Notices
+{
+ /// The session left memory by an idle unload or a close; the conversation lives on in the store.
+ /// Where and when it happened.
+ /// Why the session left memory.
+ public sealed record ConversationUnloaded(HookScope Scope, UnloadCause Cause) : HookNotice(Scope);
+}
diff --git a/src/AgentCore.Application/Hooks/Notices/Fault.cs b/src/AgentCore.Application/Hooks/Notices/Fault.cs
new file mode 100644
index 00000000..b9dca25c
--- /dev/null
+++ b/src/AgentCore.Application/Hooks/Notices/Fault.cs
@@ -0,0 +1,9 @@
+namespace AgentCore.Application.Hooks.Notices
+{
+ /// A non-fatal fault happened.
+ /// Where and when it happened.
+ /// Which fault.
+ /// What went wrong.
+ /// The exception behind the failure, for a log's stack trace, or null.
+ public sealed record Fault(HookScope Scope, FaultKind Kind, string Message, Exception? Cause) : HookNotice(Scope);
+}
diff --git a/src/AgentCore.Application/Hooks/Notices/FaultKind.cs b/src/AgentCore.Application/Hooks/Notices/FaultKind.cs
new file mode 100644
index 00000000..9df28584
--- /dev/null
+++ b/src/AgentCore.Application/Hooks/Notices/FaultKind.cs
@@ -0,0 +1,30 @@
+namespace AgentCore.Application.Hooks.Notices
+{
+ /// A non-fatal fault.
+ public enum FaultKind
+ {
+ /// Writing the transcript failed.
+ TranscriptWriteFailed,
+
+ /// Resyncing the transcript failed.
+ TranscriptResyncFailed,
+
+ /// Restoring the conversation state succeeded in part.
+ StateRestorePartial,
+
+ /// Extracting slots from the turn failed.
+ ExtractionFailed,
+
+ /// The conversation's busy mark lapsed while a turn still ran, and another session took it.
+ BusyMarkLost,
+
+ /// A hook threw or ran past its deadline.
+ HookFailed,
+
+ /// Putting or renewing the conversation's busy mark failed in the store.
+ BusyMarkFailed,
+
+ /// A hook kept denying tool calls past the approval layer's round cap, so the turn ended as a fault instead of asking a person.
+ DenialRoundsExhausted,
+ }
+}
diff --git a/src/AgentCore.Application/Hooks/Notices/FilePublished.cs b/src/AgentCore.Application/Hooks/Notices/FilePublished.cs
new file mode 100644
index 00000000..d814bc99
--- /dev/null
+++ b/src/AgentCore.Application/Hooks/Notices/FilePublished.cs
@@ -0,0 +1,16 @@
+namespace AgentCore.Application.Hooks.Notices
+{
+ /// file.publish stored a file.
+ /// Where and when it happened.
+ /// The file's name.
+ /// The file's length in bytes.
+ /// The file's media type.
+ /// ownerId/name of the stored blob.
+ public sealed record FilePublished(
+ HookScope Scope,
+ string Name,
+ long Length,
+ string MediaType,
+ string BlobKey)
+ : HookNotice(Scope);
+}
diff --git a/src/AgentCore.Application/Hooks/Notices/HostStarted.cs b/src/AgentCore.Application/Hooks/Notices/HostStarted.cs
new file mode 100644
index 00000000..ed8d6638
--- /dev/null
+++ b/src/AgentCore.Application/Hooks/Notices/HostStarted.cs
@@ -0,0 +1,8 @@
+namespace AgentCore.Application.Hooks.Notices
+{
+ /// Boot finished: the host is ready to serve.
+ /// Where and when it happened.
+ /// The names of the entries the host serves.
+ /// How many tools the host registered.
+ public sealed record HostStarted(HookScope Scope, IReadOnlyList Entries, int ToolCount) : HookNotice(Scope);
+}
diff --git a/src/AgentCore.Application/Hooks/Notices/HostStopping.cs b/src/AgentCore.Application/Hooks/Notices/HostStopping.cs
new file mode 100644
index 00000000..3315394a
--- /dev/null
+++ b/src/AgentCore.Application/Hooks/Notices/HostStopping.cs
@@ -0,0 +1,6 @@
+namespace AgentCore.Application.Hooks.Notices
+{
+ /// Shutdown began.
+ /// Where and when it happened.
+ public sealed record HostStopping(HookScope Scope) : HookNotice(Scope);
+}
diff --git a/src/AgentCore.Application/Hooks/Notices/InputModerated.cs b/src/AgentCore.Application/Hooks/Notices/InputModerated.cs
new file mode 100644
index 00000000..b927b126
--- /dev/null
+++ b/src/AgentCore.Application/Hooks/Notices/InputModerated.cs
@@ -0,0 +1,14 @@
+namespace AgentCore.Application.Hooks.Notices
+{
+ /// Moderation decided about the caller's words.
+ /// Where and when it happened.
+ /// What moderation decided.
+ /// The endpoint's categories in its own order; empty unless .
+ /// Why there was no verdict when is , otherwise .
+ public sealed record InputModerated(
+ HookScope Scope,
+ InputVerdict Verdict,
+ IReadOnlyList Categories,
+ ModerationUnavailableReason? Reason)
+ : HookNotice(Scope);
+}
diff --git a/src/AgentCore.Application/Hooks/Notices/InputVerdict.cs b/src/AgentCore.Application/Hooks/Notices/InputVerdict.cs
new file mode 100644
index 00000000..c9536591
--- /dev/null
+++ b/src/AgentCore.Application/Hooks/Notices/InputVerdict.cs
@@ -0,0 +1,15 @@
+namespace AgentCore.Application.Hooks.Notices
+{
+ /// What moderation decided about the caller's words.
+ public enum InputVerdict
+ {
+ /// Moderation passed the words.
+ Clean,
+
+ /// Moderation flagged the words.
+ Flagged,
+
+ /// Moderation gave no verdict.
+ Unavailable,
+ }
+}
diff --git a/src/AgentCore.Application/Hooks/Notices/KnowledgeSearched.cs b/src/AgentCore.Application/Hooks/Notices/KnowledgeSearched.cs
new file mode 100644
index 00000000..586c94e6
--- /dev/null
+++ b/src/AgentCore.Application/Hooks/Notices/KnowledgeSearched.cs
@@ -0,0 +1,20 @@
+using AgentCore.Domain.Knowledge;
+
+namespace AgentCore.Application.Hooks.Notices
+{
+ /// A knowledge search finished.
+ /// Where and when it happened.
+ /// The query text. The text is the caller's conversation text: a hook that logs it logs personal data.
+ /// The scope the search ran under, or .
+ /// How many hits came back.
+ /// How long the search took.
+ /// The failure message, or when the search succeeded.
+ public sealed record KnowledgeSearched(
+ HookScope Scope,
+ string Query,
+ KnowledgeScope? SearchScope,
+ int Hits,
+ TimeSpan Latency,
+ string? Failure)
+ : HookNotice(Scope);
+}
diff --git a/src/AgentCore.Application/Hooks/Notices/LatencyMetric.cs b/src/AgentCore.Application/Hooks/Notices/LatencyMetric.cs
new file mode 100644
index 00000000..d755c083
--- /dev/null
+++ b/src/AgentCore.Application/Hooks/Notices/LatencyMetric.cs
@@ -0,0 +1,18 @@
+namespace AgentCore.Application.Hooks.Notices
+{
+ /// The four signed-off voice latency readings.
+ public enum LatencyMetric
+ {
+ /// From the start of one model step to its first text or tool call. One reading per step.
+ TimeToFirstToken,
+
+ /// From the user's final words to the first text of the reply handed to the output.
+ TimeToFirstSpeech,
+
+ /// From the user's final words to the end of the last step of a reply nothing cut short. A cut reply gets no reading.
+ TimeToReplyEnd,
+
+ /// From the transport reporting a barge-in to every speech it interrupted being done.
+ BargeIn,
+ }
+}
diff --git a/src/AgentCore.Application/Hooks/Notices/LineSpoken.cs b/src/AgentCore.Application/Hooks/Notices/LineSpoken.cs
new file mode 100644
index 00000000..f11572a8
--- /dev/null
+++ b/src/AgentCore.Application/Hooks/Notices/LineSpoken.cs
@@ -0,0 +1,16 @@
+namespace AgentCore.Application.Hooks.Notices
+{
+ /// A spoken line closed.
+ /// Where and when it happened.
+ /// Who said it.
+ /// The words said. The text is the caller's conversation text: a hook that logs it logs personal data.
+ /// When the line began.
+ /// When the line ended.
+ public sealed record LineSpoken(
+ HookScope Scope,
+ Speaker Speaker,
+ string Text,
+ DateTimeOffset StartedAt,
+ DateTimeOffset EndedAt)
+ : HookNotice(Scope);
+}
diff --git a/src/AgentCore.Application/Hooks/Notices/ModelCalled.cs b/src/AgentCore.Application/Hooks/Notices/ModelCalled.cs
new file mode 100644
index 00000000..70b2434e
--- /dev/null
+++ b/src/AgentCore.Application/Hooks/Notices/ModelCalled.cs
@@ -0,0 +1,30 @@
+namespace AgentCore.Application.Hooks.Notices
+{
+ ///
+ /// One model round trip ended. A round a hook answered never reached the
+ /// model, so it raises none.
+ ///
+ /// Where and when it happened.
+ /// The model that answered, or when unknown.
+ /// The round within the turn.
+ /// Input tokens, or when the provider reported none.
+ /// Output tokens, or when the provider reported none.
+ /// Cached input tokens, or when the provider reported none.
+ /// How long the round trip took.
+ /// The provider's finish reason, or .
+ ///
+ /// The failure message, or when the model answered. A round an
+ /// hook answered carries the model's failure it answered for.
+ ///
+ public sealed record ModelCalled(
+ HookScope Scope,
+ string? ModelId,
+ int Round,
+ long? InputTokens,
+ long? OutputTokens,
+ long? CachedInputTokens,
+ TimeSpan Latency,
+ string? FinishReason,
+ string? Failure)
+ : HookNotice(Scope);
+}
diff --git a/src/AgentCore.Application/Hooks/Notices/ModerationUnavailableReason.cs b/src/AgentCore.Application/Hooks/Notices/ModerationUnavailableReason.cs
new file mode 100644
index 00000000..226741f5
--- /dev/null
+++ b/src/AgentCore.Application/Hooks/Notices/ModerationUnavailableReason.cs
@@ -0,0 +1,12 @@
+namespace AgentCore.Application.Hooks.Notices
+{
+ /// Why moderation had no verdict.
+ public enum ModerationUnavailableReason
+ {
+ /// The moderation call ran past its limit.
+ TimedOut,
+
+ /// The moderation call threw.
+ Threw,
+ }
+}
diff --git a/src/AgentCore.Application/Hooks/Notices/ReplyCut.cs b/src/AgentCore.Application/Hooks/Notices/ReplyCut.cs
new file mode 100644
index 00000000..4c1375ca
--- /dev/null
+++ b/src/AgentCore.Application/Hooks/Notices/ReplyCut.cs
@@ -0,0 +1,14 @@
+namespace AgentCore.Application.Hooks.Notices
+{
+ /// The caller spoke over the reply: a cut or a recut.
+ /// Where and when it happened.
+ /// The words the caller heard before the cut. The text is the caller's conversation text: a hook that logs it logs personal data.
+ /// How long the reply played, or when unknown.
+ /// The of the notice this cut amends.
+ public sealed record ReplyCut(
+ HookScope Scope,
+ string HeardText,
+ TimeSpan? Played,
+ Guid AmendsEventId)
+ : HookNotice(Scope);
+}
diff --git a/src/AgentCore.Application/Hooks/Notices/ReplyUpdated.cs b/src/AgentCore.Application/Hooks/Notices/ReplyUpdated.cs
new file mode 100644
index 00000000..251bf673
--- /dev/null
+++ b/src/AgentCore.Application/Hooks/Notices/ReplyUpdated.cs
@@ -0,0 +1,8 @@
+namespace AgentCore.Application.Hooks.Notices
+{
+ /// An output update left the turn. Raised only when a hook overrides its handler.
+ /// Where and when it happened.
+ /// The update's text. The text is the caller's conversation text: a hook that logs it logs personal data.
+ /// The update's author, or .
+ public sealed record ReplyUpdated(HookScope Scope, string Text, string? Author) : HookNotice(Scope);
+}
diff --git a/src/AgentCore.Application/Hooks/Notices/RetentionSwept.cs b/src/AgentCore.Application/Hooks/Notices/RetentionSwept.cs
new file mode 100644
index 00000000..8530ee64
--- /dev/null
+++ b/src/AgentCore.Application/Hooks/Notices/RetentionSwept.cs
@@ -0,0 +1,7 @@
+namespace AgentCore.Application.Hooks.Notices
+{
+ /// The hourly retention sweep ran.
+ /// Where and when it happened.
+ /// How many rows the sweep deleted.
+ public sealed record RetentionSwept(HookScope Scope, int Deleted) : HookNotice(Scope);
+}
diff --git a/src/AgentCore.Application/Hooks/Notices/SkillLoaded.cs b/src/AgentCore.Application/Hooks/Notices/SkillLoaded.cs
new file mode 100644
index 00000000..3d66d2e3
--- /dev/null
+++ b/src/AgentCore.Application/Hooks/Notices/SkillLoaded.cs
@@ -0,0 +1,8 @@
+namespace AgentCore.Application.Hooks.Notices
+{
+ /// A skill was pinned or loaded.
+ /// Where and when it happened.
+ /// The skill's name.
+ /// Whether the entry pins the skill, as opposed to the model loading it.
+ public sealed record SkillLoaded(HookScope Scope, string Name, bool Pinned) : HookNotice(Scope);
+}
diff --git a/src/AgentCore.Application/Hooks/Notices/Speaker.cs b/src/AgentCore.Application/Hooks/Notices/Speaker.cs
new file mode 100644
index 00000000..fb8f7e59
--- /dev/null
+++ b/src/AgentCore.Application/Hooks/Notices/Speaker.cs
@@ -0,0 +1,12 @@
+namespace AgentCore.Application.Hooks.Notices
+{
+ /// Who said a spoken line.
+ public enum Speaker
+ {
+ /// The person on the call.
+ Caller,
+
+ /// The agent.
+ Agent,
+ }
+}
diff --git a/src/AgentCore.Application/Hooks/Notices/StageChanged.cs b/src/AgentCore.Application/Hooks/Notices/StageChanged.cs
new file mode 100644
index 00000000..6dbd4b8e
--- /dev/null
+++ b/src/AgentCore.Application/Hooks/Notices/StageChanged.cs
@@ -0,0 +1,8 @@
+namespace AgentCore.Application.Hooks.Notices
+{
+ /// The stage machine moved.
+ /// Where and when it happened.
+ /// The stage before.
+ /// The stage after.
+ public sealed record StageChanged(HookScope Scope, string Before, string After) : HookNotice(Scope);
+}
diff --git a/src/AgentCore.Application/Hooks/Notices/SubagentEnded.cs b/src/AgentCore.Application/Hooks/Notices/SubagentEnded.cs
new file mode 100644
index 00000000..399b1a98
--- /dev/null
+++ b/src/AgentCore.Application/Hooks/Notices/SubagentEnded.cs
@@ -0,0 +1,19 @@
+namespace AgentCore.Application.Hooks.Notices
+{
+ /// An agent-as-tool run ended.
+ /// Where and when it happened.
+ ///
+ /// The function call id of the tool call that started it, or . For an agent an agent-as-tool
+ /// started (depth 2 or more), it is the id of the outermost tool call, the one the turn's own agent made.
+ ///
+ /// The agent that ran as the tool.
+ /// How the run ended.
+ /// How long the run took.
+ public sealed record SubagentEnded(
+ HookScope Scope,
+ string? ParentToolCallId,
+ string AgentId,
+ SubagentOutcome Outcome,
+ TimeSpan Duration)
+ : HookNotice(Scope);
+}
diff --git a/src/AgentCore.Application/Hooks/Notices/SubagentOutcome.cs b/src/AgentCore.Application/Hooks/Notices/SubagentOutcome.cs
new file mode 100644
index 00000000..b86e2553
--- /dev/null
+++ b/src/AgentCore.Application/Hooks/Notices/SubagentOutcome.cs
@@ -0,0 +1,15 @@
+namespace AgentCore.Application.Hooks.Notices
+{
+ /// How an agent-as-tool run ended.
+ public enum SubagentOutcome
+ {
+ /// The sub-agent returned an answer.
+ Answered,
+
+ /// The sub-agent faulted.
+ Faulted,
+
+ /// The sub-agent was cancelled.
+ Cancelled,
+ }
+}
diff --git a/src/AgentCore.Application/Hooks/Notices/SubagentStarted.cs b/src/AgentCore.Application/Hooks/Notices/SubagentStarted.cs
new file mode 100644
index 00000000..3bbcbaff
--- /dev/null
+++ b/src/AgentCore.Application/Hooks/Notices/SubagentStarted.cs
@@ -0,0 +1,11 @@
+namespace AgentCore.Application.Hooks.Notices
+{
+ /// An agent-as-tool run began.
+ /// Where and when it happened.
+ ///
+ /// The function call id of the tool call that started it, or . For an agent an agent-as-tool
+ /// started (depth 2 or more), it is the id of the outermost tool call, the one the turn's own agent made.
+ ///
+ /// The agent that runs as the tool.
+ public sealed record SubagentStarted(HookScope Scope, string? ParentToolCallId, string AgentId) : HookNotice(Scope);
+}
diff --git a/src/AgentCore.Application/Hooks/Notices/ToolCalled.cs b/src/AgentCore.Application/Hooks/Notices/ToolCalled.cs
new file mode 100644
index 00000000..b9e6f121
--- /dev/null
+++ b/src/AgentCore.Application/Hooks/Notices/ToolCalled.cs
@@ -0,0 +1,24 @@
+using AgentCore.Domain.Audit;
+
+namespace AgentCore.Application.Hooks.Notices
+{
+ /// One tool call ended. Raised once per call.
+ /// Where and when it happened.
+ /// The tool's name.
+ /// The function call id.
+ /// How long the call took.
+ /// How it ended.
+ /// Whether the fault was one the model cannot answer, so the turn speaks the fallback. False for every outcome but .
+ /// The kind of failure, or when the call did not fail.
+ /// The failure message, or when the call did not fail.
+ public sealed record ToolCalled(
+ HookScope Scope,
+ string ToolName,
+ string CallId,
+ TimeSpan Duration,
+ ToolOutcome Outcome,
+ bool Fatal,
+ ToolFailureKind? FailureKind,
+ string? Failure)
+ : HookNotice(Scope);
+}
diff --git a/src/AgentCore.Application/Hooks/Notices/ToolOutcome.cs b/src/AgentCore.Application/Hooks/Notices/ToolOutcome.cs
new file mode 100644
index 00000000..577c8a7a
--- /dev/null
+++ b/src/AgentCore.Application/Hooks/Notices/ToolOutcome.cs
@@ -0,0 +1,27 @@
+namespace AgentCore.Application.Hooks.Notices
+{
+ /// How one tool call ended.
+ public enum ToolOutcome
+ {
+ /// The tool returned a result.
+ Ok,
+
+ /// The tool faulted.
+ Failed,
+
+ /// A hook blocked the call.
+ Blocked,
+
+ /// A hook answered in place of the tool.
+ Responded,
+
+ /// The result came from the tool's cache.
+ Cached,
+
+ /// The tool ran past its time limit.
+ TimedOut,
+
+ /// The model called a tool the entry does not declare.
+ Undeclared,
+ }
+}
diff --git a/src/AgentCore.Application/Hooks/Notices/TurnCompleted.cs b/src/AgentCore.Application/Hooks/Notices/TurnCompleted.cs
new file mode 100644
index 00000000..0aacd6b6
--- /dev/null
+++ b/src/AgentCore.Application/Hooks/Notices/TurnCompleted.cs
@@ -0,0 +1,26 @@
+namespace AgentCore.Application.Hooks.Notices
+{
+ /// The turn was sealed.
+ /// Where and when it happened.
+ /// How the turn ended.
+ /// The caller's words. The text is the caller's conversation text: a hook that logs it logs personal data.
+ /// The words the turn's reply rows hold. For a cut turn, the whole text the model produced; carries what was heard. It is the caller's conversation text: a hook that logs it logs personal data.
+ /// The stage when the turn began.
+ /// The stage when the turn ended.
+ /// How long the turn took.
+ /// The reason for , and , or null.
+ /// Whether the fallback came from a tool that spent its retry budget.
+ /// The exception behind the failure, for a log's stack trace, or null.
+ public sealed record TurnCompleted(
+ HookScope Scope,
+ TurnOutcome Outcome,
+ string UserText,
+ string ReplyText,
+ string StageBefore,
+ string StageAfter,
+ TimeSpan Duration,
+ string? Failure,
+ bool FailedInTool,
+ Exception? Cause)
+ : HookNotice(Scope);
+}
diff --git a/src/AgentCore.Application/Hooks/Notices/TurnLatency.cs b/src/AgentCore.Application/Hooks/Notices/TurnLatency.cs
new file mode 100644
index 00000000..c32fe4fd
--- /dev/null
+++ b/src/AgentCore.Application/Hooks/Notices/TurnLatency.cs
@@ -0,0 +1,8 @@
+namespace AgentCore.Application.Hooks.Notices
+{
+ /// A voice turn's timing is known.
+ /// Where and when it happened.
+ /// Which reading it is.
+ /// The measured time.
+ public sealed record TurnLatency(HookScope Scope, LatencyMetric Metric, TimeSpan Value) : HookNotice(Scope);
+}
diff --git a/src/AgentCore.Application/Hooks/Notices/TurnOutcome.cs b/src/AgentCore.Application/Hooks/Notices/TurnOutcome.cs
new file mode 100644
index 00000000..8505906e
--- /dev/null
+++ b/src/AgentCore.Application/Hooks/Notices/TurnOutcome.cs
@@ -0,0 +1,24 @@
+namespace AgentCore.Application.Hooks.Notices
+{
+ /// How a turn ended.
+ public enum TurnOutcome
+ {
+ /// The model answered.
+ Answered,
+
+ /// The turn spoke the fallback reply.
+ Fallback,
+
+ /// The model produced no reply.
+ Empty,
+
+ /// A hook blocked the turn.
+ Blocked,
+
+ /// The turn was cancelled.
+ Cancelled,
+
+ /// The turn faulted.
+ Faulted,
+ }
+}
diff --git a/src/AgentCore.Application/Hooks/Notices/TurnRefusal.cs b/src/AgentCore.Application/Hooks/Notices/TurnRefusal.cs
new file mode 100644
index 00000000..9fae9c38
--- /dev/null
+++ b/src/AgentCore.Application/Hooks/Notices/TurnRefusal.cs
@@ -0,0 +1,27 @@
+namespace AgentCore.Application.Hooks.Notices
+{
+ /// Why a turn was refused and kept none of its words.
+ public enum TurnRefusal
+ {
+ /// Another turn held the conversation past the wait limit.
+ Busy,
+
+ /// The store refused the turn's words: another session saved that turn first.
+ Conflict,
+
+ /// The client left while the turn waited for the conversation.
+ Gone,
+
+ /// The caller let go of the turn before it read the reply, so the model never ran.
+ Dropped,
+
+ /// The conversation had reached a terminal stage.
+ Terminal,
+
+ /// The session was disposed.
+ Disposed,
+
+ /// Another entry holds the conversation.
+ InUse,
+ }
+}
diff --git a/src/AgentCore.Application/Hooks/Notices/TurnRefusalTokens.cs b/src/AgentCore.Application/Hooks/Notices/TurnRefusalTokens.cs
new file mode 100644
index 00000000..d9933aeb
--- /dev/null
+++ b/src/AgentCore.Application/Hooks/Notices/TurnRefusalTokens.cs
@@ -0,0 +1,25 @@
+namespace AgentCore.Application.Hooks.Notices
+{
+ /// The audit token of each , under refusedReason .
+ public static class TurnRefusalTokens
+ {
+ /// Names one refusal reason.
+ /// The reason.
+ /// The token the audit row and the log line carry.
+ /// is outside the closed set.
+ public static string ToToken(TurnRefusal refusal)
+ {
+ return refusal switch
+ {
+ TurnRefusal.Busy => "busy",
+ TurnRefusal.Conflict => "conflict",
+ TurnRefusal.Gone => "gone",
+ TurnRefusal.Dropped => "dropped",
+ TurnRefusal.Terminal => "terminal",
+ TurnRefusal.Disposed => "disposed",
+ TurnRefusal.InUse => "in_use",
+ _ => throw new ArgumentOutOfRangeException(nameof(refusal), refusal, "The refusal is outside the closed set."),
+ };
+ }
+ }
+}
diff --git a/src/AgentCore.Application/Hooks/Notices/TurnRefused.cs b/src/AgentCore.Application/Hooks/Notices/TurnRefused.cs
new file mode 100644
index 00000000..cf12f7a9
--- /dev/null
+++ b/src/AgentCore.Application/Hooks/Notices/TurnRefused.cs
@@ -0,0 +1,12 @@
+namespace AgentCore.Application.Hooks.Notices
+{
+ /// A turn was refused or dropped, so none of its words were kept.
+ /// Where and when it happened. is null when the turn was
+ /// refused before it read the conversation.
+ /// Why. names it for an audit row.
+ ///
+ /// Whether the conversation had already ended. Such a refusal still reaches the hooks, but no audit row follows
+ /// conversation.ended .
+ ///
+ public sealed record TurnRefused(HookScope Scope, TurnRefusal Reason, bool AfterEnd) : HookNotice(Scope);
+}
diff --git a/src/AgentCore.Application/Hooks/Notices/TurnStarted.cs b/src/AgentCore.Application/Hooks/Notices/TurnStarted.cs
new file mode 100644
index 00000000..5e3a2fa9
--- /dev/null
+++ b/src/AgentCore.Application/Hooks/Notices/TurnStarted.cs
@@ -0,0 +1,14 @@
+using AgentCore.Application.Hooks.Gates;
+
+namespace AgentCore.Application.Hooks.Notices
+{
+ /// The turn was admitted and its agent chosen.
+ /// Where and when it happened.
+ /// The agent that runs the turn.
+ ///
+ /// The caller's words as received, before any hook ran: a
+ /// does not change a already raised, so a hook that
+ /// redacts input does not redact this. The text is the caller's conversation text: a hook that logs it logs personal data.
+ ///
+ public sealed record TurnStarted(HookScope Scope, string AgentId, string UserText) : HookNotice(Scope);
+}
diff --git a/src/AgentCore.Application/Hooks/Notices/TurnSuperseded.cs b/src/AgentCore.Application/Hooks/Notices/TurnSuperseded.cs
new file mode 100644
index 00000000..b5075ca8
--- /dev/null
+++ b/src/AgentCore.Application/Hooks/Notices/TurnSuperseded.cs
@@ -0,0 +1,8 @@
+namespace AgentCore.Application.Hooks.Notices
+{
+ /// Edit-and-resend withdrew turns.
+ /// Where and when it happened.
+ /// The first turn index withdrawn.
+ /// The last turn index withdrawn.
+ public sealed record TurnSuperseded(HookScope Scope, int WithdrewFrom, int WithdrewThrough) : HookNotice(Scope);
+}
diff --git a/src/AgentCore.Application/Hooks/Notices/UnloadCause.cs b/src/AgentCore.Application/Hooks/Notices/UnloadCause.cs
new file mode 100644
index 00000000..484c4fa4
--- /dev/null
+++ b/src/AgentCore.Application/Hooks/Notices/UnloadCause.cs
@@ -0,0 +1,12 @@
+namespace AgentCore.Application.Hooks.Notices
+{
+ /// Why a session left memory.
+ public enum UnloadCause
+ {
+ /// The session sat idle past its limit.
+ Idle,
+
+ /// The session was closed.
+ Closed,
+ }
+}
diff --git a/src/AgentCore.Application/Hooks/Notices/VoiceParty.cs b/src/AgentCore.Application/Hooks/Notices/VoiceParty.cs
new file mode 100644
index 00000000..ee65314f
--- /dev/null
+++ b/src/AgentCore.Application/Hooks/Notices/VoiceParty.cs
@@ -0,0 +1,12 @@
+namespace AgentCore.Application.Hooks.Notices
+{
+ /// Whose voice state changed.
+ public enum VoiceParty
+ {
+ /// The caller.
+ User,
+
+ /// The agent.
+ Agent,
+ }
+}
diff --git a/src/AgentCore.Application/Hooks/Notices/VoiceState.cs b/src/AgentCore.Application/Hooks/Notices/VoiceState.cs
new file mode 100644
index 00000000..d0823e93
--- /dev/null
+++ b/src/AgentCore.Application/Hooks/Notices/VoiceState.cs
@@ -0,0 +1,18 @@
+namespace AgentCore.Application.Hooks.Notices
+{
+ /// A voice state.
+ public enum VoiceState
+ {
+ /// Waiting for speech.
+ Listening,
+
+ /// Working out a reply.
+ Thinking,
+
+ /// Speaking.
+ Speaking,
+
+ /// Not on the call.
+ Away,
+ }
+}
diff --git a/src/AgentCore.Application/Hooks/Notices/VoiceStateChanged.cs b/src/AgentCore.Application/Hooks/Notices/VoiceStateChanged.cs
new file mode 100644
index 00000000..5611a3d9
--- /dev/null
+++ b/src/AgentCore.Application/Hooks/Notices/VoiceStateChanged.cs
@@ -0,0 +1,14 @@
+namespace AgentCore.Application.Hooks.Notices
+{
+ /// The user's or the agent's voice state changed.
+ /// Where and when it happened.
+ /// Whose state changed.
+ /// The state before.
+ /// The state after.
+ public sealed record VoiceStateChanged(
+ HookScope Scope,
+ VoiceParty Party,
+ VoiceState Old,
+ VoiceState New)
+ : HookNotice(Scope);
+}
diff --git a/src/AgentCore.Application/Knowledge/ClaimedProbe.cs b/src/AgentCore.Application/Knowledge/ClaimedProbe.cs
index 3022306b..e36711ca 100644
--- a/src/AgentCore.Application/Knowledge/ClaimedProbe.cs
+++ b/src/AgentCore.Application/Knowledge/ClaimedProbe.cs
@@ -1,4 +1,4 @@
-using AgentCore.Application.Runtime;
+using AgentCore.Application.Runtime.Clarification;
namespace AgentCore.Application.Knowledge
{
diff --git a/src/AgentCore.Application/Knowledge/FacetFilteredSearch.cs b/src/AgentCore.Application/Knowledge/FacetFilteredSearch.cs
index 0fc9d7f7..0350ccb3 100644
--- a/src/AgentCore.Application/Knowledge/FacetFilteredSearch.cs
+++ b/src/AgentCore.Application/Knowledge/FacetFilteredSearch.cs
@@ -1,10 +1,10 @@
using System.Text.Json;
using AgentCore.Application.Configuration.Schema;
-using AgentCore.Application.Runtime;
using Microsoft.Extensions.AI;
using Microsoft.Agents.AI;
+using AgentCore.Application.Runtime.Turn;
namespace AgentCore.Application.Knowledge
{
diff --git a/src/AgentCore.Application/Knowledge/KnowledgePrefetchProvider.cs b/src/AgentCore.Application/Knowledge/KnowledgePrefetchProvider.cs
index ecac7d81..f3452d54 100644
--- a/src/AgentCore.Application/Knowledge/KnowledgePrefetchProvider.cs
+++ b/src/AgentCore.Application/Knowledge/KnowledgePrefetchProvider.cs
@@ -1,7 +1,6 @@
using Microsoft.Agents.AI;
using Microsoft.Extensions.Logging;
using AgentCore.Application.Runtime.Turn;
-using AgentCore.Application.Runtime;
namespace AgentCore.Application.Knowledge
{
diff --git a/src/AgentCore.Application/Knowledge/KnowledgeProbe.cs b/src/AgentCore.Application/Knowledge/KnowledgeProbe.cs
index 13543e6b..49a71795 100644
--- a/src/AgentCore.Application/Knowledge/KnowledgeProbe.cs
+++ b/src/AgentCore.Application/Knowledge/KnowledgeProbe.cs
@@ -1,15 +1,15 @@
using System.Diagnostics;
using AgentCore.Application.Configuration.Schema;
using AgentCore.Application.Diagnostics;
-using AgentCore.Application.Runtime;
using AgentCore.Domain.Knowledge;
using Microsoft.Agents.AI;
using Microsoft.Extensions.Logging;
+using AgentCore.Application.Runtime.Clarification;
namespace AgentCore.Application.Knowledge
{
///
- /// §8 steps 3-6: the probe. When a scoped search clears no card, it drops one wildcard-filled facet,
+ /// The probe. When a scoped search clears no card, it drops one wildcard-filled facet,
/// searches again, and names what the wider search holds.
///
internal static class KnowledgeProbe
@@ -30,7 +30,7 @@ internal static class KnowledgeProbe
bool carriesHistory,
CancellationToken cancellationToken)
{
- // K42: a nested tool call runs with the holder stripped from its invocation, so a delegated
+ // A nested tool call runs with the holder stripped from its invocation, so a delegated
// run's own search cannot latch, count or record — but a scoped run still owes the caller
// the notice.
if (clarifications is null || ProbeWiring.From(binding.Knowledge.Clarification) is not { } wiring)
@@ -38,7 +38,7 @@ internal static class KnowledgeProbe
return [KnowledgeNotices.Of(KnowledgeNotices.Empty)];
}
- // §8 step 3. Recomputed fresh on every call in the turn — including one that arrives after the
+ // Recomputed fresh on every call in the turn — including one that arrives after the
// turn's probe has already run — so it must stay cheap, deterministic and repeatable rather
// than mutate anything. Nothing is latched by this exit: a second call that also finds no
// droppable facet simply reaches this same conclusion again.
@@ -61,7 +61,7 @@ internal static class KnowledgeProbe
// that a throw already took away.
try
{
- // §8 step 4: the latch and the increment both belong before the search runs. An increment
+ // The latch and the increment both belong before the search runs. An increment
// placed after it would let a probe that always times out offer the same facet forever; a
// latch placed after it would leave a throwing first call's latch unset, so a second call
// in the same turn would re-run the search and advance probeAsks a second time.
@@ -104,7 +104,7 @@ internal static class KnowledgeProbe
}
}
- /// §8 step 4: the probe's own second search, under the narrowed scope, logged like the main one.
+ /// The probe's own second search, under the narrowed scope, logged like the main one.
private static async Task> SearchAsync(
KnowledgeBinding binding,
KnowledgeScope narrowedScope,
@@ -132,7 +132,7 @@ private static async Task> SearchAsync(
return probeCards;
}
- /// K43: replays the outcome of the one probe this turn already claimed.
+ /// Replays the outcome of the one probe this turn already claimed.
private static async Task> ReplayAsync(
Clarifications.Probe probe,
KnowledgeAmbiguityConfiguration ambiguity,
@@ -146,14 +146,14 @@ private static async Task> SearchAsync(
}
catch (Exception failure) when (!KnowledgeCancellation.ByCaller(failure, cancellationToken))
{
- // The winner's own search already answered for reachability (§8 step 4's own catch says
- // the same thing): a wait that timed out, or a payload the winner failed via Probe.Fail(),
+ // The winner's own search already answered for reachability (the catch around the probe's own
+ // search says the same thing): a wait that timed out, or a payload the winner failed via Probe.Fail(),
// is never grounds to tell this caller the store is unreachable.
return [KnowledgeNotices.Of(KnowledgeNotices.Empty)];
}
}
- ///