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
1 change: 1 addition & 0 deletions Directory.Packages.props
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
<PackageVersion Include="Microsoft.Extensions.FileSystemGlobbing" Version="10.0.12" />
<PackageVersion Include="Microsoft.Extensions.Http" Version="10.0.2" />
<PackageVersion Include="Microsoft.Extensions.Http.Resilience" Version="10.2.0" />
<PackageVersion Include="Microsoft.Extensions.Options" Version="10.0.12" />
</ItemGroup>
<ItemGroup>
<PackageVersion Include="Microsoft.Extensions.AI" Version="10.10.0" />
Expand Down
13 changes: 12 additions & 1 deletion src/AgentCore.Application/Conversation/ChatConversationTitler.cs
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
using System.Runtime.CompilerServices;
using System.Text;
using AgentCore.Application.Llm;
using AgentCore.Application.Ports;
using AgentCore.Application.Transcript;
using Microsoft.Extensions.AI;
Expand Down Expand Up @@ -99,10 +100,20 @@ private async IAsyncEnumerable<string> NameAsync(
new ChatMessage(ChatRole.User, Transcript(messages.Take(MaxMessages))),
];

// The title runs outside a turn, so the stamp that tags turn requests never sees it. Adapters
// that route on the conversation id (OpenCode Go refuses a request without one) read this.
ChatOptions options = new()
{
AdditionalProperties = new AdditionalPropertiesDictionary
{
[ChatRequestProperties.ConversationId] = conversationId,
},
};

StringBuilder title = new();

await foreach (ChatResponseUpdate? update in client
.GetStreamingResponseAsync(prompt, options: null, cancellationToken)
.GetStreamingResponseAsync(prompt, options, cancellationToken)
.ConfigureAwait(false))
{
string piece = update.Text;
Expand Down
19 changes: 18 additions & 1 deletion src/AgentCore.Application/Llm/CompositeChatClientFactory.cs
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,8 @@ public sealed class CompositeChatClientFactory : IChatClientFactory, IDisposable

private readonly Dictionary<string, IChatClient> _vendor = new(StringComparer.Ordinal);

private readonly Dictionary<string, int?> _contextWindows = new(StringComparer.Ordinal);

private readonly ConcurrentDictionary<string, IChatClient> _shaped = new(StringComparer.Ordinal);

private LlmProviderConfiguration? _default;
Expand All @@ -34,6 +36,10 @@ private CompositeChatClientFactory()
/// <param name="configuration">The loaded document.</param>
/// <param name="secrets">The chain each adapter resolves its credential through, or <see langword="null"/>.</param>
/// <param name="adapters">The adapters the host registers, one for each vendor it supports.</param>
/// <param name="catalog">
/// Looks up each entry's context window by its <c>kind</c> and <c>model</c>. A document with no
/// catalog answers every <see cref="GetContextWindow"/> call with <see langword="null"/>.
/// </param>
/// <param name="cancellationToken">Cancels the build.</param>
/// <returns>The factory.</returns>
/// <exception cref="ConfigurationLoadException">
Expand All @@ -44,6 +50,7 @@ public static async ValueTask<CompositeChatClientFactory> CreateAsync(
AgentCoreConfiguration configuration,
ISecretResolverPort? secrets,
IReadOnlyList<IChatClientAdapter> adapters,
IModelCatalogPort? catalog = null,
CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(configuration);
Expand All @@ -61,6 +68,7 @@ public static async ValueTask<CompositeChatClientFactory> CreateAsync(
$"providers.llm entry '{entry.As}'",
ConfigurationError.AppendPointer(pointer, "kind"),
"options.UseChatClients(...)");

IChatClientAdapter adapter = VendorAdapterSelector.Select(entry.Kind, adapters, seam);

if (!factory._entries.TryAdd(entry.As, entry))
Expand All @@ -76,6 +84,15 @@ public static async ValueTask<CompositeChatClientFactory> CreateAsync(
.CreateClientAsync(entry, secrets, cancellationToken)
.ConfigureAwait(false);

if (catalog is not null)
{
ModelCatalogEntry? found = await catalog
.LookupAsync(entry.Kind, entry.Model, cancellationToken)
.ConfigureAwait(false);

factory._contextWindows[entry.As] = found?.ContextWindow;
}

factory._default ??= entry;
}

Expand Down Expand Up @@ -134,7 +151,7 @@ public IChatClient GetChatClient(ModelReference? model)
ObjectDisposedException.ThrowIf(Volatile.Read(ref _disposed) == 1, this);

LlmProviderConfiguration entry = Resolve(model);
return _adapters.TryGetValue(entry.As, out IChatClientAdapter? adapter) ? adapter.GetContextWindow(entry) : null;
return _contextWindows.TryGetValue(entry.As, out int? tokens) ? tokens : null;
}

/// <summary>Releases every client the adapters built.</summary>
Expand Down
6 changes: 6 additions & 0 deletions src/AgentCore.Application/Llm/ModelCatalogEntry.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
namespace AgentCore.Application.Llm
{
/// <summary>One model's facts, as <see cref="Ports.IModelCatalogPort"/> reports them.</summary>
/// <param name="ContextWindow">The context window, in tokens.</param>
public sealed record ModelCatalogEntry(int ContextWindow);
}
12 changes: 0 additions & 12 deletions src/AgentCore.Application/Ports/IChatClientAdapter.cs
Original file line number Diff line number Diff line change
Expand Up @@ -26,17 +26,5 @@ ValueTask<IChatClient> CreateClientAsync(
{
return null;
}

/// <summary>Reports the model's context window, in tokens.</summary>
/// <param name="entry">The <c>providers.llm[]</c> entry this adapter serves.</param>
/// <returns>
/// The window in tokens, or <see langword="null"/> when this adapter does not know the model.
/// Compaction is always on, so the compiler refuses an agent whose model answers
/// <see langword="null"/>.
/// </returns>
int? GetContextWindow(LlmProviderConfiguration entry)
{
return null;
}
}
}
18 changes: 18 additions & 0 deletions src/AgentCore.Application/Ports/IModelCatalogPort.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
using AgentCore.Application.Llm;

namespace AgentCore.Application.Ports
{
/// <summary>
/// Looks up one vendor's models by name: whether the name exists, and its context window.
/// </summary>
public interface IModelCatalogPort
{
/// <summary>Looks up one model under one provider.</summary>
/// <param name="provider">The catalog's provider key, such as <c>opencode-go</c>.</param>
/// <param name="model">The model id, exactly as the vendor's own API names it.</param>
/// <param name="cancellationToken">Cancels the lookup.</param>
/// <returns>The entry, or <see langword="null"/> when the provider knows no such model.</returns>
ValueTask<ModelCatalogEntry?> LookupAsync(
string provider, string model, CancellationToken cancellationToken = default);
}
}
20 changes: 18 additions & 2 deletions src/AgentCore.Application/PublicAPI.Unshipped.txt
Original file line number Diff line number Diff line change
Expand Up @@ -1089,7 +1089,6 @@ AgentCore.Application.Ports.IConversationTitler.GenerateAsync(string! conversati
AgentCore.Application.Ports.IConversationTitler.GenerateFromAsync(string! conversationId, System.Collections.Generic.IReadOnlyList<Microsoft.Extensions.AI.ChatMessage!>! messages, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Collections.Generic.IAsyncEnumerable<string!>!
AgentCore.Application.Ports.IChatClientAdapter
AgentCore.Application.Ports.IChatClientAdapter.CreateClientAsync(AgentCore.Application.Configuration.Schema.LlmProviderConfiguration! entry, AgentCore.Application.Ports.ISecretResolverPort? secrets, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.ValueTask<Microsoft.Extensions.AI.IChatClient!>
AgentCore.Application.Ports.IChatClientAdapter.GetContextWindow(AgentCore.Application.Configuration.Schema.LlmProviderConfiguration! entry) -> int?
AgentCore.Application.Ports.IChatClientAdapter.ResolveHostedTool(Microsoft.Extensions.AI.AITool! marker, AgentCore.Application.Configuration.Schema.LlmProviderConfiguration! entry) -> Microsoft.Extensions.AI.AITool?
AgentCore.Application.Ports.IChatClientFactory
AgentCore.Application.Ports.IChatClientFactory.GetChatClient(AgentCore.Application.Configuration.Schema.ModelReference? model) -> Microsoft.Extensions.AI.IChatClient!
Expand Down Expand Up @@ -1959,7 +1958,7 @@ static AgentCore.Application.Knowledge.KnowledgeCitationFormatterFactory.Resolve
static AgentCore.Application.Knowledge.KnowledgePoint.operator !=(AgentCore.Application.Knowledge.KnowledgePoint? left, AgentCore.Application.Knowledge.KnowledgePoint? right) -> bool
static AgentCore.Application.Knowledge.KnowledgePoint.operator ==(AgentCore.Application.Knowledge.KnowledgePoint? left, AgentCore.Application.Knowledge.KnowledgePoint? right) -> bool
static AgentCore.Application.Knowledge.KnowledgeRetrievalPortExtensions.GetService<TService>(this AgentCore.Application.Ports.IKnowledgeRetrievalPort! port, object? serviceKey = null) -> TService?
static AgentCore.Application.Llm.CompositeChatClientFactory.CreateAsync(AgentCore.Application.Configuration.Schema.AgentCoreConfiguration! configuration, AgentCore.Application.Ports.ISecretResolverPort? secrets, System.Collections.Generic.IReadOnlyList<AgentCore.Application.Ports.IChatClientAdapter!>! adapters, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.ValueTask<AgentCore.Application.Llm.CompositeChatClientFactory!>
static AgentCore.Application.Llm.CompositeChatClientFactory.CreateAsync(AgentCore.Application.Configuration.Schema.AgentCoreConfiguration! configuration, AgentCore.Application.Ports.ISecretResolverPort? secrets, System.Collections.Generic.IReadOnlyList<AgentCore.Application.Ports.IChatClientAdapter!>! adapters, AgentCore.Application.Ports.IModelCatalogPort? catalog = null, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.ValueTask<AgentCore.Application.Llm.CompositeChatClientFactory!>
static AgentCore.Application.Providers.VendorAdapterSelector.Select<TAdapter>(string! kind, System.Collections.Generic.IReadOnlyList<TAdapter>! adapters, AgentCore.Application.Providers.VendorSeam seam) -> TAdapter
static AgentCore.Application.Providers.VendorSeam.operator !=(AgentCore.Application.Providers.VendorSeam left, AgentCore.Application.Providers.VendorSeam right) -> bool
static AgentCore.Application.Providers.VendorSeam.operator ==(AgentCore.Application.Providers.VendorSeam left, AgentCore.Application.Providers.VendorSeam right) -> bool
Expand Down Expand Up @@ -2337,3 +2336,20 @@ AgentCore.Application.Runtime.Compaction.CompactionContent.Outcome.get -> string
AgentCore.Application.Runtime.Compaction.CompactionContent.Phase.get -> string!
const AgentCore.Application.Runtime.Compaction.CompactionContent.EndPhase = "end" -> string!
const AgentCore.Application.Runtime.Compaction.CompactionContent.StartPhase = "start" -> string!
AgentCore.Application.Llm.ModelCatalogEntry
AgentCore.Application.Llm.ModelCatalogEntry.<Clone>$() -> AgentCore.Application.Llm.ModelCatalogEntry!
AgentCore.Application.Llm.ModelCatalogEntry.ModelCatalogEntry(int ContextWindow) -> void
AgentCore.Application.Llm.ModelCatalogEntry.ContextWindow.get -> int
AgentCore.Application.Llm.ModelCatalogEntry.ContextWindow.init -> void
AgentCore.Application.Llm.ModelCatalogEntry.Deconstruct(out int ContextWindow) -> void
AgentCore.Application.Llm.ModelCatalogEntry.Equals(AgentCore.Application.Llm.ModelCatalogEntry? other) -> bool
override AgentCore.Application.Llm.ModelCatalogEntry.Equals(object? obj) -> bool
override AgentCore.Application.Llm.ModelCatalogEntry.GetHashCode() -> int
override AgentCore.Application.Llm.ModelCatalogEntry.ToString() -> string!
static AgentCore.Application.Llm.ModelCatalogEntry.operator ==(AgentCore.Application.Llm.ModelCatalogEntry? left, AgentCore.Application.Llm.ModelCatalogEntry? right) -> bool
static AgentCore.Application.Llm.ModelCatalogEntry.operator !=(AgentCore.Application.Llm.ModelCatalogEntry? left, AgentCore.Application.Llm.ModelCatalogEntry? right) -> bool
AgentCore.Application.Ports.IModelCatalogPort
AgentCore.Application.Ports.IModelCatalogPort.LookupAsync(string! provider, string! model, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.ValueTask<AgentCore.Application.Llm.ModelCatalogEntry?>
const AgentCore.Application.Secrets.KnownSecrets.OpenCodeGoApiKeyName = "opencode-go-api-key" -> string!
const AgentCore.Application.Secrets.KnownSecrets.OpenCodeGoApiKeyVariable = "OPENCODE_GO_API_KEY" -> string!
static readonly AgentCore.Application.Secrets.KnownSecrets.OpenCodeGo -> AgentCore.Application.Secrets.SecretName!
9 changes: 9 additions & 0 deletions src/AgentCore.Application/Secrets/KnownSecrets.cs
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,12 @@ public static class KnownSecrets
/// <summary>The standard OpenAI environment variable, read when the chain holds no name.</summary>
public const string OpenAiApiKeyVariable = "OPENAI_API_KEY";

/// <summary>The <c>${secret:name}</c> name the OpenCode Go key resolves under.</summary>
public const string OpenCodeGoApiKeyName = "opencode-go-api-key";

/// <summary>The environment variable OpenCode Go's own docs name, read when the chain holds no name.</summary>
public const string OpenCodeGoApiKeyVariable = "OPENCODE_GO_API_KEY";

/// <summary>The <c>${secret:name}</c> name the Qdrant key resolves under.</summary>
public const string QdrantApiKeyName = "qdrant-api-key";

Expand Down Expand Up @@ -50,6 +56,9 @@ public static class KnownSecrets
/// <summary>The one OpenAI credential, which chat, embedding, and moderation all read.</summary>
public static readonly SecretName OpenAi = new(OpenAiApiKeyName, OpenAiApiKeyVariable);

/// <summary>The OpenCode Go credential, which the chat adapter sends as its bearer token.</summary>
public static readonly SecretName OpenCodeGo = new(OpenCodeGoApiKeyName, OpenCodeGoApiKeyVariable);

/// <summary> The Qdrant API key the vector store sends on every conversation. </summary>
public static readonly SecretName Qdrant = new(QdrantApiKeyName, QdrantApiKeyVariable);

Expand Down
15 changes: 14 additions & 1 deletion src/AgentCore.AspNetCore/DependencyInjection/AgentCoreOptions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,9 @@ public sealed class AgentCoreOptions
/// <summary>Gets the seam that resolves a model reference, or <see langword="null"/>.</summary>
internal Func<AgentCoreStartup, CancellationToken, ValueTask<IChatClientFactory>>? ChatClients { get; private set; }

/// <summary>Gets the catalog every chat adapter's context window is read from, or <see langword="null"/>.</summary>
internal IModelCatalogPort? ModelCatalog { get; private set; }

/// <summary>Gets the embedding vendors the host registered, or <see langword="null"/>.</summary>
internal IReadOnlyList<IEmbeddingGeneratorAdapter>? Embeddings { get; private set; }

Expand Down Expand Up @@ -117,14 +120,24 @@ public AgentCoreOptions UseConversationSessions(Func<string, IConversationSessio
return this;
}

/// <summary>Binds the catalog every chat adapter's context window is read from.</summary>
/// <param name="catalog">Looks up one entry's context window by its <c>kind</c> and <c>model</c>.</param>
/// <returns>These options, so a host chains its conversations.</returns>
public AgentCoreOptions UseModelCatalog(IModelCatalogPort catalog)
{
ArgumentNullException.ThrowIfNull(catalog);
ModelCatalog = catalog;
return this;
}

/// <summary>Binds the vendor adapters, and the document picks one by each entry's <c>kind</c>.</summary>
/// <param name="adapters">One adapter for each vendor this host supports.</param>
/// <returns>These options, so a host chains its conversations.</returns>
public AgentCoreOptions UseChatClients(params IChatClientAdapter[] adapters)
{
ArgumentNullException.ThrowIfNull(adapters);
return UseChatClients(async (startup, cancellationToken) => await CompositeChatClientFactory
.CreateAsync(startup.Configuration, SecretResolver, adapters, cancellationToken)
.CreateAsync(startup.Configuration, SecretResolver, adapters, ModelCatalog, cancellationToken)
.ConfigureAwait(false));
}

Expand Down
1 change: 1 addition & 0 deletions src/AgentCore.AspNetCore/PublicAPI.Unshipped.txt
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ AgentCore.AspNetCore.DependencyInjection.AgentCoreOptions.UseConversationStores(
AgentCore.AspNetCore.DependencyInjection.AgentCoreOptions.UseChatClients(System.Func<AgentCore.AspNetCore.DependencyInjection.AgentCoreStartup!, AgentCore.Application.Ports.IChatClientFactory!>! chatClients) -> AgentCore.AspNetCore.DependencyInjection.AgentCoreOptions!
AgentCore.AspNetCore.DependencyInjection.AgentCoreOptions.UseChatClients(System.Func<AgentCore.AspNetCore.DependencyInjection.AgentCoreStartup!, System.Threading.CancellationToken, System.Threading.Tasks.ValueTask<AgentCore.Application.Ports.IChatClientFactory!>>! chatClients) -> AgentCore.AspNetCore.DependencyInjection.AgentCoreOptions!
AgentCore.AspNetCore.DependencyInjection.AgentCoreOptions.UseChatClients(params AgentCore.Application.Ports.IChatClientAdapter![]! adapters) -> AgentCore.AspNetCore.DependencyInjection.AgentCoreOptions!
AgentCore.AspNetCore.DependencyInjection.AgentCoreOptions.UseModelCatalog(AgentCore.Application.Ports.IModelCatalogPort! catalog) -> AgentCore.AspNetCore.DependencyInjection.AgentCoreOptions!
AgentCore.AspNetCore.DependencyInjection.AgentCoreOptions.UseEmbeddings(params AgentCore.Application.Ports.IEmbeddingGeneratorAdapter![]! adapters) -> AgentCore.AspNetCore.DependencyInjection.AgentCoreOptions!
AgentCore.AspNetCore.DependencyInjection.AgentCoreOptions.UseKnowledgeCitationFormatters(params AgentCore.Application.Knowledge.IKnowledgeCitationFormatter![]! formatters) -> AgentCore.AspNetCore.DependencyInjection.AgentCoreOptions!
AgentCore.AspNetCore.DependencyInjection.AgentCoreOptions.UseKnowledgePointMappers(params AgentCore.Application.Knowledge.IKnowledgePointMapper![]! mappers) -> AgentCore.AspNetCore.DependencyInjection.AgentCoreOptions!
Expand Down
9 changes: 8 additions & 1 deletion src/AgentCore.Hosting/AgentCoreHostBuilderExtensions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,9 @@
using AgentCore.Infrastructure.Embeddings.OpenAI;
using AgentCore.Infrastructure.Evaluation.OpenAiModeration;
using AgentCore.Infrastructure.Knowledge.VectorData.Qdrant;
using AgentCore.Infrastructure.Llm.ModelsDev;
using AgentCore.Infrastructure.Llm.OpenAI;
using AgentCore.Infrastructure.Llm.OpenCodeGo;
using AgentCore.Infrastructure.Secrets;
using AgentCore.Infrastructure.Telemetry.Grafana;
using AgentCore.Infrastructure.Tools;
Expand Down Expand Up @@ -98,8 +100,13 @@ private static void Configure(
new ConfigurationSecretResolver(hostConfiguration),
]);

// Every vendor's models.dev lookup shares one fetch of the catalog.
_ = options.UseModelCatalog(ModelsDevCatalogPort.CreateFromConfiguration(httpClients, hostConfiguration));

// providers.llm[].kind picks the adapter for each entry.
_ = options.UseChatClients(new OpenAiChatClientAdapter());
_ = options.UseChatClients(
new OpenAiChatClientAdapter(),
OpenCodeGoChatClientAdapter.CreateFromConfiguration(httpClients, hostConfiguration));

// providers.embeddings.kind picks the adapter.
_ = options.UseEmbeddings(new OpenAiEmbeddingGeneratorAdapter());
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@
<PackageReference Include="Microsoft.Extensions.FileSystemGlobbing" />
<PackageReference Include="Microsoft.Extensions.Http" />
<PackageReference Include="Microsoft.Extensions.Logging" />
<PackageReference Include="Microsoft.Extensions.Options" />
<PackageReference Include="Qdrant.Client" />
<PackageReference Include="ModelContextProtocol.Core" />
<PackageReference Include="OpenAI" />
Expand Down
Loading
Loading