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
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions Directory.Packages.props
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,11 @@
<ItemGroup>
<PackageVersion Include="ModelContextProtocol.Core" Version="2.2.0" />
</ItemGroup>
<ItemGroup>
<PackageVersion Include="AGUI.Abstractions" Version="1.0.0" />
<PackageVersion Include="AGUI.Formatting" Version="1.0.0" />
<PackageVersion Include="AGUI.Server" Version="1.0.0" />
</ItemGroup>
<ItemGroup>
<PackageVersion Include="OpenAI" Version="2.13.0" />
</ItemGroup>
Expand Down
6 changes: 3 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -54,8 +54,8 @@ app.MapAgentCoreHost();
app.Run();
```

`AddAgentCoreHost` loads the document (`config/example.yaml` unless `AgentCore__ConfigurationPath` names another) and binds every vendor seam. `MapAgentCoreHost` serves the health check, the OpenAI-compatible Responses endpoint, and the conversation socket.
Every entry under `entries:` answers on `POST /v1/{entry}/responses` and on the conversation socket at `/v1/{entry}/call`.
`AddAgentCoreHost` loads the document (`config/example.yaml` unless `AgentCore__ConfigurationPath` names another) and binds every vendor seam. `MapAgentCoreHost` serves the health check, the OpenAI-compatible Responses endpoint, the conversation socket, and the AG-UI endpoint.
Every entry under `entries:` answers on `POST /v1/{entry}/responses`, on the conversation socket at `/v1/{entry}/call`, and on AG-UI at `POST /agui/{entry}`.
An OpenAI SDK reaches one entry through its base URL alone: `base_url = https://host/v1/phone`.
A document it cannot find is a startup failure, never a silent default.

Expand Down Expand Up @@ -84,7 +84,7 @@ Assistant: How can I help you today!
| `AgentCore.Domain` | Pure domain records. Zero dependencies. |
| `AgentCore.Application` | Orchestration, configuration compilation, and every port interface. |
| `AgentCore.Infrastructure` | Outbound adapters: OpenAI, Zilliz, Telnyx call control, Postgres, B2, Git. |
| `AgentCore.AspNetCore` | Inbound adapters as `Map*` extensions: Telnyx Conversation Relay, Telnyx webhooks, Responses. |
| `AgentCore.AspNetCore` | Inbound adapters as `Map*` extensions: Telnyx Conversation Relay, Telnyx webhooks, Responses, AG-UI. |
| `AgentCore.Hosting` | The batteries-included host: every vendor seam bound and every route mapped, in two calls. |

They share one version and ship as a set. Mixing versions across them is unsupported.
Expand Down
4 changes: 3 additions & 1 deletion src/AgentCore.Application/Blobs/BlobRef.cs
Original file line number Diff line number Diff line change
Expand Up @@ -5,9 +5,11 @@ namespace AgentCore.Application.Blobs
/// <param name="Name">The name it is stored under.</param>
/// <param name="MediaType">The IANA media type, such as <c>image/png</c>.</param>
/// <param name="Length">How many bytes it holds.</param>
/// <param name="DownloadName">The file name a link serves it under, or <see langword="null"/> for <see cref="Name"/>.</param>
public sealed record BlobRef(
string OwnerId,
string Name,
string MediaType,
long Length);
long Length,
string? DownloadName = null);
}
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,10 @@ public AgentCompilationContext(IChatClientFactory chatClients)
/// <see cref="Runtime.Session.ConversationSessionFactory"/> hears notices but runs no gate.
/// </summary>
public IReadOnlyList<AgentHook>? Hooks { get; init; }
/// <summary>
/// Gets or sets the store inbound caller files are filed in, or <see langword="null"/> when the document names none.
/// </summary>
public IBlobStore? Blobs { get; init; }

/// <summary>
/// Gets or sets the compaction stages every agent runs.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -60,7 +60,7 @@ public static List<AIContextProvider> Build(
+ "adapter's table.");
}

providers.Add(new ReaderContentFilterProvider());
providers.Add(new ReaderContentFilterProvider(context.Blobs));

CompactionStages stages = context.Compaction ?? CompactionStrategyFactory.Create(context.ChatClients.GetChatClient(model), contextWindow);
providers.AddRange(CompactionStrategyFactory.BuildProviders(stages, history, walk.Hooks));
Expand Down
39 changes: 39 additions & 0 deletions src/AgentCore.Application/Conversation/PendingApprovals.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
using AgentCore.Application.Runtime.Harness;
using AgentCore.Domain;

namespace AgentCore.Application.Conversation
{
/// <summary>
/// The tool calls a stored conversation still waits on a human answer for, for host history
/// routes to re-arm approvals after a reload. The source is the provider state the asking turn
/// filed: the pending queue minus the answers held or already collected.
/// </summary>
public static class PendingApprovals
{
/// <summary>Reads what a stored conversation still waits on.</summary>
/// <param name="stored">The conversation, as the store holds it.</param>
/// <returns>
/// One entry per open request, oldest first, shaped like <see cref="TurnResult.Approvals"/>;
/// empty when nothing waits on the caller.
/// </returns>
public static IReadOnlyList<PendingApproval> Read(StoredConversation stored)
{
ArgumentNullException.ThrowIfNull(stored);

return Read(stored.Conversation);
}

/// <summary>Reads what a conversation row still waits on.</summary>
/// <param name="record">The conversation's row, carrying the state its last turn filed.</param>
/// <returns>
/// One entry per open request, oldest first, shaped like <see cref="TurnResult.Approvals"/>;
/// empty when nothing waits on the caller.
/// </returns>
public static IReadOnlyList<PendingApproval> Read(ConversationRecord record)
{
ArgumentNullException.ThrowIfNull(record);

return record.State?.Providers is { } providers ? PendingApprovalQueue.OpenOf(providers) : [];
}
}
}
9 changes: 9 additions & 0 deletions src/AgentCore.Application/Hooks/AgentHook.cs
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,15 @@ public virtual ValueTask BeforeEntryAsync(EntryGate gate, CancellationToken canc
return default;
}

/// <summary>A request names a conversation, before AgentCore opens it or runs a turn on it. Refuse with <see cref="ConversationGate.Refuse"/>.</summary>
/// <param name="gate">The facts and the verbs of this gate.</param>
/// <param name="cancellationToken">Cancelled at the gate's deadline.</param>
/// <returns>A task that completes when the hook decided.</returns>
public virtual ValueTask BeforeConversationAsync(ConversationGate gate, CancellationToken cancellationToken)
{
return default;
}

/// <summary>The user's message arrives, before moderation and the model.</summary>
/// <param name="gate">The facts and the verbs of this gate.</param>
/// <param name="cancellationToken">Cancelled at the gate's deadline.</param>
Expand Down
7 changes: 5 additions & 2 deletions src/AgentCore.Application/Hooks/GatePoint.cs
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
namespace AgentCore.Application.Hooks
{
/// <summary>
/// One of the 13 points where AgentCore awaits its hooks. The point, not the hook, fixes the deadline and the
/// One of the 14 points where AgentCore awaits its hooks. The point, not the hook, fixes the deadline and the
/// default failure policy.
/// </summary>
public sealed class GatePoint
Expand All @@ -20,6 +20,9 @@ private GatePoint(string name, TimeSpan deadline, HookFailure defaultFailure)
/// <summary>Gets the gate where a request needs an entry. Fails closed: the request is refused.</summary>
public static GatePoint BeforeEntry { get; } = new(nameof(BeforeEntry), TimeSpan.FromSeconds(2), HookFailure.Closed);

/// <summary>Gets the gate where a request names a conversation. Fails closed: the request is refused.</summary>
public static GatePoint BeforeConversation { get; } = new(nameof(BeforeConversation), TimeSpan.FromSeconds(2), HookFailure.Closed);

/// <summary>Gets the gate where the user's message arrives, before moderation and the model.</summary>
public static GatePoint BeforeTurn { get; } = new(nameof(BeforeTurn), TimeSpan.FromSeconds(2), HookFailure.Open);

Expand Down Expand Up @@ -56,7 +59,7 @@ private GatePoint(string name, TimeSpan deadline, HookFailure defaultFailure)
/// <summary>Gets every gate point, in gate order.</summary>
public static IReadOnlyList<GatePoint> All { get; } =
[
BeforeCall, BeforeEntry, BeforeTurn, BeforeRun, BeforeCompaction, BeforeModel, AfterModel,
BeforeCall, BeforeEntry, BeforeConversation, BeforeTurn, BeforeRun, BeforeCompaction, BeforeModel, AfterModel,
AfterModelFailed, BeforeToolApproval, BeforeTool, AfterTool, AfterToolFailed, AfterRun,
];

Expand Down
68 changes: 68 additions & 0 deletions src/AgentCore.Application/Hooks/Gates/ConversationGate.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
using System.Security.Claims;

namespace AgentCore.Application.Hooks.Gates
{
/// <summary>
/// A request names a conversation, before AgentCore opens it or runs a turn on it. A hook refuses a caller who may
/// not touch the conversation. Every turn on an HTTP route passes this gate, an approval answer too.
/// </summary>
public sealed class ConversationGate : HookGate
{
internal ConversationGate(
HookScope scope,
ClaimsPrincipal? user,
IReadOnlyDictionary<string, string> headers,
string route,
string entry,
string conversationId,
EntryTransport transport,
bool resuming)
: base(scope)
{
ArgumentNullException.ThrowIfNull(headers);
ArgumentNullException.ThrowIfNull(route);
ArgumentNullException.ThrowIfNull(entry);
ArgumentNullException.ThrowIfNull(conversationId);

User = user;
Headers = headers;
Route = route;
Entry = entry;
ConversationId = conversationId;
Transport = transport;
Resuming = resuming;
}

/// <summary>Gets the authenticated user, or <see langword="null"/> when the request is anonymous.</summary>
public ClaimsPrincipal? User { get; }

/// <summary>Gets the HTTP request's headers. A header with several values joins them with a comma.</summary>
public IReadOnlyDictionary<string, string> Headers { get; }

/// <summary>Gets the route pattern that matched.</summary>
public string Route { get; }

/// <summary>Gets the entry the request runs.</summary>
public string Entry { get; }

/// <summary>Gets the conversation the request names. It may be anything the caller sent.</summary>
public string ConversationId { get; }

/// <summary>Gets how the request arrived.</summary>
public EntryTransport Transport { get; }

/// <summary>Gets whether the request answers an approval instead of sending words.</summary>
public bool Resuming { get; }

internal bool Refused { get; private set; }

/// <summary>
/// Refuses the request. Terminal. The caller gets the same answer as for a conversation that does not exist, so a
/// caller cannot learn which ids name somebody else's conversation.
/// </summary>
public void Refuse()
{
Stage(terminal: true, () => Refused = true);
}
}
}
3 changes: 3 additions & 0 deletions src/AgentCore.Application/Hooks/Gates/EntryTransport.cs
Original file line number Diff line number Diff line change
Expand Up @@ -8,5 +8,8 @@ public enum EntryTransport

/// <summary>A phone call.</summary>
Call,

/// <summary>An HTTP request on the AG-UI route.</summary>
AgUi,
}
}
58 changes: 58 additions & 0 deletions src/AgentCore.Application/Hooks/Layers/ConversationGateChain.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
using System.Security.Claims;
using AgentCore.Application.Hooks.Engine;
using AgentCore.Application.Hooks.Gates;

namespace AgentCore.Application.Hooks.Layers
{
/// <summary>What a request offers the conversation gate.</summary>
/// <param name="User">The authenticated user, or <see langword="null"/> when the request is anonymous.</param>
/// <param name="Headers">The HTTP request's headers.</param>
/// <param name="Route">The route pattern that matched.</param>
/// <param name="Entry">The entry the request runs.</param>
/// <param name="ConversationId">The conversation the request names.</param>
/// <param name="Transport">How the request arrived.</param>
/// <param name="Resuming">Whether the request answers an approval.</param>
internal sealed record ConversationOffer(
ClaimsPrincipal? User,
IReadOnlyDictionary<string, string> Headers,
string Route,
string Entry,
string ConversationId,
EntryTransport Transport,
bool Resuming);

/// <summary>Asks the hooks whether a request may touch the conversation it names. No hook means yes.</summary>
internal static class ConversationGateChain
{
/// <summary>Runs the chain.</summary>
/// <param name="hooks">The compiled hooks.</param>
/// <param name="offer">The request on offer.</param>
/// <param name="cancellationToken">The request's token.</param>
/// <returns><see langword="true"/> when no hook refused and none failed closed.</returns>
internal static async ValueTask<bool> AllowAsync(HookRuntime hooks, ConversationOffer offer, CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(hooks);
ArgumentNullException.ThrowIfNull(offer);

if (!hooks.Gates.Overrides(GatePoint.BeforeConversation))
{
return true;
}

HookScope scope = HookScopes.ForGate(offer.ConversationId, offer.Entry, hooks.Timers.GetUtcNow());

bool refused = await hooks.Gates.RunAsync(
GatePoint.BeforeConversation,
scope,
false,
_ => new ConversationGate(scope, offer.User, offer.Headers, offer.Route, offer.Entry, offer.ConversationId, offer.Transport, offer.Resuming),
static (hook, gate, token) => hook.BeforeConversationAsync(gate, token),
static (state, gate) => state || gate.Refused,
static _ => true,
hooks.RaiseHostFault,
cancellationToken).ConfigureAwait(false);

return !refused;
}
}
}
Loading
Loading