Skip to content

Commit 5ea5074

Browse files
authored
Merge pull request #117 from isartor-ai/feat-176-rag
feat(agents): knowledge retrieval (RAG) tool for agents (#176)
2 parents a7856d6 + 8590403 commit 5ea5074

6 files changed

Lines changed: 264 additions & 0 deletions

File tree

src/Autofac.Agents/DependencyInjection.cs

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,9 @@ public static IServiceCollection AddAutofacAgents(
3131
services.AddScoped<IAgentTool, GitHubPostReviewTool>();
3232
services.AddScoped<IAgentTool, CicdTriggerDeployTool>();
3333
services.AddScoped<IAgentTool, SandboxExecutionTool>();
34+
services.Configure<Knowledge.KnowledgeOptions>(configuration.GetSection(Knowledge.KnowledgeOptions.Section));
35+
services.AddSingleton<Knowledge.IKnowledgeRetriever, Knowledge.LexicalKnowledgeRetriever>();
36+
services.AddScoped<IAgentTool, KnowledgeSearchTool>();
3437
services.AddScoped<IAgentHookHandler, InternalPolicyHookHandler>();
3538
services.AddScoped<IAgentHookHandler, TemplateHookHandler>();
3639
services.AddScoped<IAgentHookGateway, HookGateway>();
Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,91 @@
1+
using Microsoft.Extensions.Options;
2+
3+
namespace Autofac.Agents.Knowledge;
4+
5+
public sealed record KnowledgeDocument(string Source, string Text);
6+
7+
public sealed record KnowledgeSnippet(string Source, string Text, double Score);
8+
9+
/// <summary>
10+
/// Retrieves context relevant to a query, with provenance, for agents to ground on (#176).
11+
/// The backing store is pluggable — a lexical corpus today, a vector/embeddings store later.
12+
/// </summary>
13+
public interface IKnowledgeRetriever
14+
{
15+
IReadOnlyList<KnowledgeSnippet> Search(string query, int topK);
16+
}
17+
18+
public sealed class KnowledgeOptions
19+
{
20+
public const string Section = "Knowledge";
21+
22+
/// <summary>In-memory corpus the default lexical retriever searches.</summary>
23+
public List<KnowledgeDocumentOptions> Documents { get; set; } = [];
24+
25+
public int DefaultTopK { get; set; } = 3;
26+
}
27+
28+
public sealed class KnowledgeDocumentOptions
29+
{
30+
public string Source { get; set; } = string.Empty;
31+
32+
public string Text { get; set; } = string.Empty;
33+
}
34+
35+
/// <summary>
36+
/// Default retriever: deterministic term-overlap scoring over a configured in-memory corpus.
37+
/// Dependency-free so RAG works out of the box; a pgvector/embeddings retriever can replace it
38+
/// behind <see cref="IKnowledgeRetriever"/> without touching the agent-facing tool.
39+
/// </summary>
40+
public sealed class LexicalKnowledgeRetriever : IKnowledgeRetriever
41+
{
42+
private static readonly char[] Separators =
43+
[' ', '\t', '\n', '\r', '.', ',', ';', ':', '!', '?', '(', ')', '[', ']', '{', '}', '"', '\'', '/', '\\', '-'];
44+
45+
private readonly IReadOnlyList<KnowledgeDocument> _corpus;
46+
47+
public LexicalKnowledgeRetriever(IOptions<KnowledgeOptions> options)
48+
: this((options.Value.Documents ?? []).Select(d => new KnowledgeDocument(d.Source, d.Text)))
49+
{
50+
}
51+
52+
public LexicalKnowledgeRetriever(IEnumerable<KnowledgeDocument> corpus)
53+
{
54+
_corpus = corpus.Where(d => !string.IsNullOrWhiteSpace(d.Text)).ToArray();
55+
}
56+
57+
public IReadOnlyList<KnowledgeSnippet> Search(string query, int topK)
58+
{
59+
var queryTerms = Tokenize(query).Distinct().ToArray();
60+
if (queryTerms.Length == 0 || _corpus.Count == 0 || topK <= 0)
61+
{
62+
return [];
63+
}
64+
65+
return _corpus
66+
.Select(doc => new { doc, score = Score(queryTerms, Tokenize(doc.Text)) })
67+
.Where(scored => scored.score > 0)
68+
.OrderByDescending(scored => scored.score)
69+
.Take(topK)
70+
.Select(scored => new KnowledgeSnippet(scored.doc.Source, scored.doc.Text, scored.score))
71+
.ToArray();
72+
}
73+
74+
private static double Score(IReadOnlyCollection<string> queryTerms, IEnumerable<string> docTerms)
75+
{
76+
var docSet = new HashSet<string>(docTerms);
77+
if (docSet.Count == 0)
78+
{
79+
return 0;
80+
}
81+
82+
var matches = queryTerms.Count(docSet.Contains);
83+
return (double)matches / queryTerms.Count; // fraction of query terms present in the doc
84+
}
85+
86+
private static IEnumerable<string> Tokenize(string? text) =>
87+
(text ?? string.Empty)
88+
.ToLowerInvariant()
89+
.Split(Separators, StringSplitOptions.RemoveEmptyEntries)
90+
.Where(token => token.Length >= 2);
91+
}
Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,81 @@
1+
using System.Globalization;
2+
using System.Text;
3+
using Autofac.Agents.Knowledge;
4+
using Autofac.Domain.AgentRuntime;
5+
6+
namespace Autofac.Agents.Tools;
7+
8+
/// <summary>
9+
/// Policy-gated retrieval tool (#176): an agent searches the knowledge base and gets back
10+
/// ranked snippets with source citations, recorded like any other tool invocation. Runs
11+
/// through the Tool Gateway, so it is subject to the same allow/deny and policy decisions.
12+
/// </summary>
13+
public sealed class KnowledgeSearchTool : IAgentTool, IToolSchemaProvider
14+
{
15+
private const int DefaultTopK = 3;
16+
private const int MaxTopK = 10;
17+
18+
private readonly IKnowledgeRetriever _retriever;
19+
20+
public KnowledgeSearchTool(IKnowledgeRetriever retriever)
21+
{
22+
_retriever = retriever;
23+
}
24+
25+
public string Name => "knowledge.search";
26+
27+
public string Category => AgentToolCategories.Knowledge;
28+
29+
public IReadOnlyList<ToolSchemaParameter> GetParameters() =>
30+
[
31+
new("query", "string", "The question or query to retrieve relevant context for.", Required: true),
32+
new("top_k", "integer", "Maximum number of snippets to return (1-10, default 3).", Required: false),
33+
];
34+
35+
public void Validate(IReadOnlyDictionary<string, string> input)
36+
{
37+
if (!input.TryGetValue("query", out var query) || string.IsNullOrWhiteSpace(query))
38+
{
39+
throw new InvalidOperationException("Tool input is missing required field 'query'.");
40+
}
41+
}
42+
43+
public Task<AgentToolExecutionResult> ExecuteAsync(
44+
AgentToolExecutionContext context,
45+
IReadOnlyDictionary<string, string> input,
46+
CancellationToken cancellationToken)
47+
{
48+
var query = input["query"];
49+
var topK = DefaultTopK;
50+
if (input.TryGetValue("top_k", out var rawTopK)
51+
&& int.TryParse(rawTopK, NumberStyles.Integer, CultureInfo.InvariantCulture, out var parsed))
52+
{
53+
topK = Math.Clamp(parsed, 1, MaxTopK);
54+
}
55+
56+
var snippets = _retriever.Search(query, topK);
57+
if (snippets.Count == 0)
58+
{
59+
return Task.FromResult(new AgentToolExecutionResult(
60+
Succeeded: true,
61+
Output: "No relevant knowledge was found.",
62+
FailureReason: null));
63+
}
64+
65+
var builder = new StringBuilder();
66+
builder.AppendLine(CultureInfo.InvariantCulture, $"Found {snippets.Count} relevant snippet(s):");
67+
var index = 1;
68+
foreach (var snippet in snippets)
69+
{
70+
builder.AppendLine(CultureInfo.InvariantCulture, $"[{index}] source: {snippet.Source} (score {snippet.Score:0.00})");
71+
builder.AppendLine(snippet.Text);
72+
builder.AppendLine();
73+
index++;
74+
}
75+
76+
return Task.FromResult(new AgentToolExecutionResult(
77+
Succeeded: true,
78+
Output: builder.ToString().TrimEnd(),
79+
FailureReason: null));
80+
}
81+
}

src/Autofac.Domain/AgentRuntime/AgentRuntimeContract.cs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -72,6 +72,7 @@ public static class AgentToolCategories
7272
public const string Integration = "integration";
7373
public const string Mcp = "mcp";
7474
public const string SubAgent = "sub-agent";
75+
public const string Knowledge = "knowledge";
7576
}
7677

7778
public sealed record AgentMcpServerContract
Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
using Autofac.Agents.Knowledge;
2+
3+
namespace Autofac.Agents.Tests;
4+
5+
public sealed class KnowledgeRetrieverTests
6+
{
7+
private static LexicalKnowledgeRetriever Retriever() => new(
8+
[
9+
new KnowledgeDocument("deploy.md", "How to deploy the service to production using Helm and Kubernetes."),
10+
new KnowledgeDocument("auth.md", "Authentication uses OIDC JWT bearer tokens and role mapping."),
11+
new KnowledgeDocument("empty.md", string.Empty),
12+
]);
13+
14+
[Fact]
15+
public void Search_RanksRelevantDocumentHighest()
16+
{
17+
var results = Retriever().Search("how do I deploy with kubernetes", topK: 2);
18+
19+
Assert.NotEmpty(results);
20+
Assert.Equal("deploy.md", results[0].Source);
21+
}
22+
23+
[Fact]
24+
public void Search_RespectsTopK()
25+
{
26+
var results = Retriever().Search("deploy kubernetes authentication oidc", topK: 1);
27+
Assert.Single(results);
28+
}
29+
30+
[Fact]
31+
public void Search_EmptyQueryOrCorpus_ReturnsEmpty()
32+
{
33+
Assert.Empty(Retriever().Search(string.Empty, 3));
34+
Assert.Empty(new LexicalKnowledgeRetriever(Array.Empty<KnowledgeDocument>()).Search("deploy", 3));
35+
}
36+
37+
[Fact]
38+
public void Search_IrrelevantQuery_ReturnsEmpty()
39+
{
40+
Assert.Empty(Retriever().Search("xylophone zebra", 3));
41+
}
42+
}
Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
using Autofac.Agents.Knowledge;
2+
using Autofac.Agents.Tools;
3+
4+
namespace Autofac.Agents.Tests;
5+
6+
public sealed class KnowledgeSearchToolTests
7+
{
8+
private static AgentToolExecutionContext Context() =>
9+
new("run", "step", "agent", "knowledge.search", null, "general", "tag", 1);
10+
11+
private static KnowledgeSearchTool Tool() => new(new LexicalKnowledgeRetriever(
12+
[
13+
new KnowledgeDocument("deploy.md", "Deploy with Helm to Kubernetes."),
14+
]));
15+
16+
[Fact]
17+
public async Task ExecuteAsync_ReturnsSnippetsWithCitations()
18+
{
19+
var result = await Tool().ExecuteAsync(
20+
Context(),
21+
new Dictionary<string, string> { ["query"] = "deploy kubernetes" },
22+
CancellationToken.None);
23+
24+
Assert.True(result.Succeeded);
25+
Assert.Contains("deploy.md", result.Output);
26+
Assert.Contains("Deploy with Helm", result.Output!);
27+
}
28+
29+
[Fact]
30+
public void Validate_MissingQuery_Throws()
31+
{
32+
Assert.Throws<InvalidOperationException>(() => Tool().Validate(new Dictionary<string, string>()));
33+
}
34+
35+
[Fact]
36+
public async Task ExecuteAsync_NoMatch_ReturnsNoResultsMessage()
37+
{
38+
var result = await Tool().ExecuteAsync(
39+
Context(),
40+
new Dictionary<string, string> { ["query"] = "xylophone" },
41+
CancellationToken.None);
42+
43+
Assert.True(result.Succeeded);
44+
Assert.Contains("No relevant knowledge", result.Output!);
45+
}
46+
}

0 commit comments

Comments
 (0)