Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
The table of contents is too big for display.
Diff view
Diff view
  •  
  •  
  •  
22 changes: 16 additions & 6 deletions .claude/rules/voice-components.md
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ flowchart TB
Loop["VoiceConversationLoop"]
UTH["UserTurnHandler"]
Act["VoiceActivity"]
PhoneCall["Calls/PhoneCall<br/>G1, CallStarted, LineSpoken, end, CallEnded"]
end

subgraph Speech["Speech control"]
Expand All @@ -47,6 +48,7 @@ flowchart TB
Hear["ReplyHearing<br/>what caller heard"]
Fill["ToolFillerScope<br/>→ FillerScheduler"]
Met["TurnMetrics"]
VN["VoiceNotices<br/>state → VoiceStateChanged"]
end

subgraph Engine["AgentCore.Application"]
Expand All @@ -60,16 +62,21 @@ flowchart TB
Conn --> Pump & Sender & Loop
Pump --> Reader --> In
In -->|"ConversationInput:<br/>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
Expand All @@ -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
```

Expand All @@ -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
Expand All @@ -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<br/>the speech is done and its barge window closed: a barge report for it; the next final prompt, once the report<br/>that may follow it had its HeardTextWait (a later report changes nothing, and is logged); or DrainAsync, at once<br/>(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<br/>and VoiceActivity.InterruptByAudioActivity → ReplyHearing cuts the engine turn to what was heard
```
32 changes: 27 additions & 5 deletions config/example.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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://<host>/v1/<entry>/call?key=<TELNYX_RELAY_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.
Expand Down
59 changes: 0 additions & 59 deletions src/AgentCore.Application/Audit/AuditConversationObserver.cs

This file was deleted.

39 changes: 25 additions & 14 deletions src/AgentCore.Application/Audit/QueuedAuditSink.cs
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ namespace AgentCore.Application.Audit
/// </summary>
public sealed class QueuedAuditSink : IAuditSinkPort, IAsyncDisposable, IDisposable
{
/// <summary>The number of events the queue holds before it starts dropping.</summary>
/// <summary>The number of events the queue holds before an append waits.</summary>
public const int DefaultCapacity = 10_000;

/// <summary>The most events the writer hands the store in one conversation.</summary>
Expand Down Expand Up @@ -60,7 +60,7 @@ public sealed class QueuedAuditSink : IAuditSinkPort, IAsyncDisposable, IDisposa
/// <see cref="NullLogger.Instance"/>. The library never throws for want of one.
/// </param>
/// <param name="capacity">
/// The number of events the queue holds before it drops, or <see cref="DefaultCapacity"/>.
/// The number of events the queue holds before an append waits, or <see cref="DefaultCapacity"/>.
/// </param>
/// <param name="batchSize">
/// The most events one call to the store carries, or <see cref="DefaultBatchSize"/>.
Expand Down Expand Up @@ -101,33 +101,44 @@ public QueuedAuditSink(
_writer = Task.Run(WriteAsync, CancellationToken.None);
}

/// <summary>
/// Gets or sets what <see cref="FlushAsync"/> 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.
/// </summary>
internal Func<Task>? Upstream { get; set; }

/// <inheritdoc />
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;
}

/// <summary>Waits until every accepted event has been handed to the store.</summary>
/// <param name="cancellationToken">Stops waiting. It cancels no write.</param>
/// <returns>A task that completes when nothing is left in the queue.</returns>
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();
Expand Down Expand Up @@ -249,7 +260,7 @@ await _retry.ExecuteAsync(
}

/// <summary>Writes the one line a dropped event is worth, and never throws.</summary>
/// <param name="auditEvent">The event the queue had no room for.</param>
/// <param name="auditEvent">The event nothing will take: the queue was closed, or the caller gave up.</param>
private void Report(AuditEvent auditEvent)
{
try
Expand Down
Original file line number Diff line number Diff line change
@@ -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;
Expand Down Expand Up @@ -47,45 +50,71 @@ public static bool Matches(string pattern, string toolName)
/// <param name="defaults">The <c>agents.defaults</c> section, or <see langword="null"/>.</param>
/// <param name="item">The agent being compiled.</param>
/// <param name="tools">The tools the agent offers the model, if any.</param>
/// <param name="hooks">The hooks of the compile.</param>
/// <returns>
/// The pending queue and the standing-rule keys when the agent may surface an approval —
/// through <c>auto:</c>, 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 <c>auto:</c>, 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.
/// </returns>
public static IReadOnlyList<string> StateKeysFor(
AgentDefaults? defaults, AgentConfiguration item, IEnumerable<AITool>? tools)
AgentDefaults? defaults, AgentConfiguration item, IEnumerable<AITool>? tools, HookRuntime hooks)
{
ArgumentNullException.ThrowIfNull(item);
ArgumentNullException.ThrowIfNull(hooks);

return Compose(defaults, item).Count == 0
&& tools?.OfType<ApprovalRequiredAIFunction>().Any() != true
return !MayAsk(defaults, item, tools, hooks)
? []
: [PendingApprovalQueue.PendingStateKey, PendingApprovalQueue.StandingStateKey];
: [PendingApprovalQueue.PendingStateKey, PendingApprovalQueue.StandingStateKey, PendingApprovalQueue.HeldStateKey];
}

/// <summary>
/// Bakes one agent's <c>auto:</c> into its pipeline as <c>UseToolApproval</c> rules. No
/// patterns, no layer — the document pays nothing for approval it never declares.
/// Bakes one agent's <c>auto:</c> and the BeforeToolApproval hooks into its pipeline as one <c>UseToolApproval</c> rule,
/// with the deny layer outside it when a hook overrides BeforeToolApproval, <see cref="ApprovalQueueDrain"/> directly outside
/// it, and <see cref="ApprovalAnswerHold"/> innermost whenever the agent can ask. An agent that can ask nothing gets no layer — the document pays nothing for
/// approval it never declares.
/// </summary>
/// <param name="agent">The compiled agent.</param>
/// <param name="defaults">The <c>agents.defaults</c> section, or <see langword="null"/>.</param>
/// <param name="item">The agent being compiled.</param>
/// <param name="tools">The tools the agent offers the model, if any.</param>
/// <param name="hooks">The hooks of the compile.</param>
/// <returns>The approval-wrapped agent, or <paramref name="agent"/> unchanged.</returns>
public static AIAgent Apply(AIAgent agent, AgentDefaults? defaults, AgentConfiguration item)
public static AIAgent Apply(
AIAgent agent, AgentDefaults? defaults, AgentConfiguration item, IEnumerable<AITool>? tools, HookRuntime hooks)
{
ArgumentNullException.ThrowIfNull(agent);
ArgumentNullException.ThrowIfNull(item);
ArgumentNullException.ThrowIfNull(hooks);

IReadOnlyList<string> 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<ToolAutoApprovalRuleContext, ValueTask<bool>> ToRule(string pattern)
private static bool MayAsk(AgentDefaults? defaults, AgentConfiguration item, IEnumerable<AITool>? tools, HookRuntime hooks)
{
return context => ValueTask.FromResult(Matches(pattern, context.FunctionCallContent.Name));
return Compose(defaults, item).Count > 0
|| tools?.OfType<ApprovalRequiredAIFunction>().Any() == true
|| hooks.Gates.Overrides(GatePoint.BeforeRun);
}
}
}
Loading
Loading