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
198 changes: 190 additions & 8 deletions README.md

Large diffs are not rendered by default.

102 changes: 102 additions & 0 deletions src/AgentExperience.Abstractions/ExperienceCandidateSource.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
namespace AgentExperience.Abstractions;

/// <summary>
/// Port for finding Experience Records that could apply to a task, matched on task text. It is a
/// read-only search seam kept deliberately separate from <see cref="IExperienceRecordStore"/>: the
/// store persists and reads canonical records by identity or scope, while this port answers "which
/// stored records look relevant to this text?" and nothing else.
/// </summary>
/// <remarks>
/// <para>
/// The same trust boundary applies as to <see cref="IExperienceRecordStore"/>: every call takes a
/// host-established <see cref="AuthorizationContext"/>, a request scope outside it is
/// <see cref="ExperienceStoreOutcome.Denied"/> before any storage access, and scope matching is exact
/// (ordinal, case-sensitive, <see langword="null"/> matches only <see langword="null"/>). Expected
/// conditions return typed results; infrastructure failures throw
/// <see cref="ExperienceStoreException"/>; caller cancellation surfaces as an unwrapped
/// <see cref="OperationCanceledException"/>.
/// </para>
/// <para>
/// An implementation decides <em>nothing</em> about eligibility beyond what the query asks for: it
/// applies the scope, the requested statuses, and the minimum confidence, matches the text, and
/// returns each match with a normalized relevance. Which statuses are eligible, whether a record has
/// expired, whether its environment is compatible, and how candidates are ranked are all Core's
/// decisions, made over what this port returns.
/// </para>
/// </remarks>
public interface IExperienceCandidateSource
{
/// <summary>
/// Finds records within exactly <see cref="ExperienceCandidateQuery.Scope"/> whose indexed task
/// text matches <see cref="ExperienceCandidateQuery.TaskText"/>, whose
/// <see cref="ExperienceRecord.Status"/> is one of
/// <see cref="ExperienceCandidateQuery.EligibleStatuses"/>, and whose
/// <see cref="ExperienceRecord.ReuseConfidence"/> is at least
/// <see cref="ExperienceCandidateQuery.MinimumConfidence"/>. At most
/// <see cref="ExperienceCandidateQuery.Limit"/> records are returned, the strongest text matches
/// first.
/// </summary>
/// <param name="authorization">What the host has established the caller may do.</param>
/// <param name="query">The scoped search. Never treated as authority.</param>
/// <param name="cancellationToken">Cancels the operation.</param>
/// <returns><see cref="ExperienceStoreOutcome.Found"/> (possibly with no candidates), <see cref="ExperienceStoreOutcome.Invalid"/>, or <see cref="ExperienceStoreOutcome.Denied"/>.</returns>
Task<ExperienceCandidateSearchResult> SearchAsync(
AuthorizationContext authorization,
ExperienceCandidateQuery query,
CancellationToken cancellationToken);
}

/// <summary>
/// A scoped, text-matched search for reusable Experience Records.
/// </summary>
/// <param name="Scope">The exact scope to search within. Never treated as authority.</param>
/// <param name="TaskText">The task text to match against. Must be non-blank and at most <see cref="MaxTaskTextLength"/> characters.</param>
/// <param name="EligibleStatuses">The statuses a record must be in to be returned. Must be non-empty and contain only defined values; the caller decides which statuses are eligible.</param>
/// <param name="MinimumConfidence">The smallest <see cref="ExperienceRecord.ReuseConfidence"/> a record may have and still be returned, in [0, 1].</param>
/// <param name="Limit">Maximum number of candidates to return, from <see cref="MinLimit"/> to <see cref="MaxLimit"/>. Defaults to <see cref="DefaultLimit"/>.</param>
public sealed record ExperienceCandidateQuery(
Scope Scope,
string TaskText,
IReadOnlyList<ExperienceStatus> EligibleStatuses,
double MinimumConfidence,
int Limit = ExperienceCandidateQuery.DefaultLimit)
{
/// <summary>
/// The longest permitted <see cref="TaskText"/>. A task description is a sentence or a paragraph;
/// bounding it here keeps an accidental multi-megabyte payload a typed
/// <see cref="ExperienceStoreOutcome.Invalid"/> rather than something the text-search parser chokes
/// on deep inside the database.
/// </summary>
public const int MaxTaskTextLength = 4096;

/// <summary>The smallest permitted <see cref="Limit"/>.</summary>
public const int MinLimit = 1;

/// <summary>The largest permitted <see cref="Limit"/>.</summary>
public const int MaxLimit = 200;

/// <summary>The <see cref="Limit"/> used when none is specified.</summary>
public const int DefaultLimit = 50;
}

/// <summary>
/// One record a search matched, with how strongly its indexed text matched the query.
/// </summary>
/// <param name="Record">The matching record, read back in full.</param>
/// <param name="Relevance">
/// How strongly the record's indexed text matched, normalized to [0, 1] by the implementation, where
/// 0 is no measurable match and 1 is the strongest the implementation can report. Comparable only
/// between candidates from the same search.
/// </param>
public sealed record ExperienceCandidate(ExperienceRecord Record, double Relevance);

/// <summary>
/// The result of <see cref="IExperienceCandidateSource.SearchAsync"/>.
/// </summary>
/// <param name="Outcome">What happened.</param>
/// <param name="Candidates">The matching candidates, strongest match first, when <see cref="Outcome"/> is <see cref="ExperienceStoreOutcome.Found"/>; otherwise empty.</param>
/// <param name="Errors">Every validation error when <see cref="Outcome"/> is <see cref="ExperienceStoreOutcome.Invalid"/>; otherwise empty.</param>
public sealed record ExperienceCandidateSearchResult(
ExperienceStoreOutcome Outcome,
IReadOnlyList<ExperienceCandidate> Candidates,
IReadOnlyList<StoreValidationError> Errors);
5 changes: 4 additions & 1 deletion src/AgentExperience.Core/AgentExperience.Core.csproj
Original file line number Diff line number Diff line change
@@ -1,11 +1,14 @@
<Project Sdk="Microsoft.NET.Sdk">

<PropertyGroup>
<Description>AgentExperience.NET's first production Core package: the default sanitization pipeline (per-Kind allowlists, secret-field classification, recursive traversal, fail-closed rejection) built on AgentExperience.Abstractions' ISanitizer port. No dependency on MAF, EF Core, PostgreSQL, model providers, or OpenTelemetry -- only the BCL, Abstractions, and Microsoft.Extensions.Compliance.Redaction (AD-1).</Description>
<Description>AgentExperience.NET's first production Core package: the default sanitization pipeline (per-Kind allowlists, secret-field classification, recursive traversal, fail-closed rejection) built on AgentExperience.Abstractions' ISanitizer port. No dependency on MAF, EF Core, PostgreSQL, model providers, or OpenTelemetry -- only the BCL, Abstractions, Microsoft.Extensions.Compliance.Redaction, and Microsoft.Extensions.DependencyInjection.Abstractions (AD-1).</Description>
</PropertyGroup>

<ItemGroup>
<PackageReference Include="Microsoft.Extensions.Compliance.Redaction" Version="10.9.0" />
<!-- DI abstractions only (no container, no hosting): lets Core ship its own AddAgentExperienceCore
registration extension. Abstractions stays BCL-only. -->
<PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" Version="[10.0.11]" />
</ItemGroup>

<ItemGroup>
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,126 @@
using AgentExperience.Abstractions;
using AgentExperience.Core.Capture;
using AgentExperience.Core.Finalization;
using AgentExperience.Core.Lifecycle;
using AgentExperience.Core.Reflections;
using AgentExperience.Core.Retrieval;
using AgentExperience.Core.Sanitization;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.DependencyInjection.Extensions;

namespace AgentExperience.Core.DependencyInjection;

/// <summary>
/// Registers AgentExperience.NET's Core services in a <see cref="IServiceCollection"/>. Core owns
/// its own registration so a host never has to know which concrete types implement which port; the
/// storage adapter registers its own in the same way (see
/// <c>AddAgentExperiencePostgresStore</c>), and <c>AgentExperience.Abstractions</c> stays BCL-only.
/// </summary>
public static class AgentExperienceCoreServiceCollectionExtensions
{
/// <summary>
/// Registers the sanitizer, the in-memory capture service, the default reflector, the lifecycle
/// service, and the finalization service as singletons.
/// </summary>
/// <remarks>
/// <para>
/// Every registration uses <c>TryAdd</c>, so a host that has already registered its own
/// <see cref="ISanitizer"/>, <see cref="IExperienceCaptureService"/>, or
/// <see cref="IExperienceReflector"/> keeps it.
/// </para>
/// <para>
/// <see cref="ExperienceLifecycleService"/> and <see cref="ExperienceFinalizationService"/> both
/// need an <see cref="IExperienceRecordStore"/>, which Core does not implement: register a
/// storage adapter (for example <c>AddAgentExperiencePostgresStore</c>) as well, or resolving
/// them fails.
/// </para>
/// <para>
/// No sanitization policy or capture limit is invented here: both are host decisions with real
/// security and memory consequences, so both are required arguments.
/// </para>
/// </remarks>
/// <param name="services">The service collection to add to.</param>
/// <param name="sanitizationOptions">The per-<c>Kind</c> sanitization policy the default sanitizer applies.</param>
/// <param name="captureLimits">The limits in-memory capture enforces.</param>
/// <returns><paramref name="services"/>, for chaining.</returns>
/// <exception cref="ArgumentNullException">Any argument is <see langword="null"/>.</exception>
public static IServiceCollection AddAgentExperienceCore(
this IServiceCollection services,
SanitizationOptions sanitizationOptions,
CaptureLimits captureLimits)
{
ArgumentNullException.ThrowIfNull(services);
ArgumentNullException.ThrowIfNull(sanitizationOptions);
ArgumentNullException.ThrowIfNull(captureLimits);

// The arguments are captured by the factories rather than resolved back out of the container.
// Re-resolving them would let a SanitizationOptions or CaptureLimits the host registered earlier
// silently replace the caller's, so the sanitizer would run a policy nobody passed to it.
services.TryAddSingleton(sanitizationOptions);
services.TryAddSingleton(captureLimits);
services.TryAddSingleton<ISanitizer>(_ => new DefaultSanitizer(sanitizationOptions));
services.TryAddSingleton<IExperienceCaptureService>(provider => new InMemoryExperienceCaptureService(
provider.GetRequiredService<ISanitizer>(),
captureLimits));
services.TryAddSingleton<IExperienceReflector, DefaultExperienceReflector>();
services.TryAddSingleton<ExperienceLifecycleService>();
services.TryAddSingleton<ExperienceFinalizationService>();

return services;
}

/// <summary>
/// Registers <see cref="ExperienceRetrievalService"/> as a singleton, together with the
/// <see cref="RetrievalPolicy"/> and <see cref="RankingWeights"/> it runs under.
/// </summary>
/// <remarks>
/// <para>
/// Retrieval is registered separately from <see cref="AddAgentExperienceCore"/> because it needs
/// an <see cref="IExperienceCandidateSource"/>, which Core does not implement: register a storage
/// adapter's search as well (for example <c>AddAgentExperiencePostgresCandidateSource</c>), or
/// resolving the service fails.
/// </para>
/// <para>
/// Unlike sanitization policy and capture limits, retrieval has documented defaults
/// (<see cref="RetrievalPolicy.Default"/> and <see cref="RankingWeights.Default"/>), so both
/// arguments are optional. Passing an invalid policy or weighting is impossible: both throw at
/// construction, before this call. The <see cref="TimeProvider"/> the timeout, expiry, and recency
/// are measured with is <see cref="TimeProvider.System"/> unless the host registered its own
/// first.
/// </para>
/// <para>
/// Every registration uses <c>TryAdd</c>, so a host that registered its own policy, weights,
/// clock, or service keeps it.
/// </para>
/// </remarks>
/// <param name="services">The service collection to add to.</param>
/// <param name="policy">The retrieval bounds and thresholds. Defaults to <see cref="RetrievalPolicy.Default"/>.</param>
/// <param name="weights">The ranking weights. Defaults to <see cref="RankingWeights.Default"/>.</param>
/// <returns><paramref name="services"/>, for chaining.</returns>
/// <exception cref="ArgumentNullException"><paramref name="services"/> is <see langword="null"/>.</exception>
public static IServiceCollection AddAgentExperienceRetrieval(
this IServiceCollection services,
RetrievalPolicy? policy = null,
RankingWeights? weights = null)
{
ArgumentNullException.ThrowIfNull(services);

var effectivePolicy = policy ?? RetrievalPolicy.Default;
var effectiveWeights = weights ?? RankingWeights.Default;

services.TryAddSingleton(effectivePolicy);
services.TryAddSingleton(effectiveWeights);
services.TryAddSingleton(TimeProvider.System);

// The caller's own policy and weights are captured rather than resolved back out of the
// container, for the same reason the sanitizer's options are: a RetrievalPolicy the host
// registered earlier must not silently replace the one passed here.
services.TryAddSingleton(provider => new ExperienceRetrievalService(
provider.GetRequiredService<IExperienceCandidateSource>(),
effectivePolicy,
effectiveWeights,
provider.GetRequiredService<TimeProvider>()));

return services;
}
}
Loading
Loading