Skip to content

Commit b495468

Browse files
committed
Refactor sample agent into clear modular files
1 parent 6fc230f commit b495468

File tree

8 files changed

+497
-443
lines changed

8 files changed

+497
-443
lines changed
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
internal static class AgentPrompt
2+
{
3+
public const string Instructions =
4+
"You are a memory agent. " +
5+
"Keep responses concise by default (target <= 120 words, unless user explicitly asks for detail). " +
6+
"Do not dump long blocks of text unless requested. " +
7+
"Use memory tools to persist important facts in markdown files and recall prior event digests. " +
8+
"Prefer: profile.md, long_term_memory.md, projects/{project_name}.md.";
9+
}
Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
using System.ClientModel;
2+
using Azure.AI.OpenAI;
3+
using Azure.Identity;
4+
using Microsoft.Extensions.AI;
5+
using OpenAI;
6+
7+
internal static class LlmClientFactory
8+
{
9+
public static IChatClient Create(SampleConfig config)
10+
{
11+
return config.UseAzureOpenAi
12+
? CreateAzureOpenAiChatClient(config.AzureOpenAiEndpoint!, config.ModelName)
13+
: CreateOpenAiChatClient(config.ModelName);
14+
}
15+
16+
private static IChatClient CreateOpenAiChatClient(string modelName)
17+
{
18+
return new OpenAIClient(RequireEnv("OPENAI_API_KEY"))
19+
.GetChatClient(modelName)
20+
.AsIChatClient();
21+
}
22+
23+
private static IChatClient CreateAzureOpenAiChatClient(string endpoint, string deploymentName)
24+
{
25+
var apiKey = Environment.GetEnvironmentVariable("AZURE_OPENAI_API_KEY");
26+
27+
var client = string.IsNullOrWhiteSpace(apiKey)
28+
? new AzureOpenAIClient(new Uri(endpoint), new DefaultAzureCredential())
29+
: new AzureOpenAIClient(new Uri(endpoint), new ApiKeyCredential(apiKey));
30+
31+
return client.GetChatClient(deploymentName).AsIChatClient();
32+
}
33+
34+
private static string RequireEnv(string name)
35+
{
36+
return Environment.GetEnvironmentVariable(name)
37+
?? throw new InvalidOperationException($"Set environment variable '{name}'.");
38+
}
39+
}
Lines changed: 184 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,184 @@
1+
using System.Text;
2+
using System.Text.Json;
3+
using MemNet.AgentMemory;
4+
using MemNet.Client;
5+
using Microsoft.Extensions.AI;
6+
7+
internal sealed class MemorySessionContext
8+
{
9+
private const string ProfilePath = "profile.md";
10+
private const string LongTermPath = "long_term_memory.md";
11+
private const string ProjectsPrefix = "projects/";
12+
13+
private readonly MemNetClient _client;
14+
private readonly AgentMemory _memory;
15+
private readonly MemNetScope _scope;
16+
private string? _profileContent;
17+
private string? _longTermContent;
18+
private List<string> _projectFiles = new();
19+
private bool _includeSnapshotOnNextTurn = true;
20+
21+
public MemorySessionContext(MemNetClient client, AgentMemory memory, MemNetScope scope)
22+
{
23+
_client = client;
24+
_memory = memory;
25+
_scope = scope;
26+
}
27+
28+
public async Task PrimeAsync(CancellationToken cancellationToken = default)
29+
{
30+
var assembled = await _client.AssembleContextAsync(
31+
_scope,
32+
new AssembleContextRequest(
33+
Files:
34+
[
35+
new AssembleFileRef(ProfilePath),
36+
new AssembleFileRef(LongTermPath)
37+
],
38+
MaxDocs: 4,
39+
MaxCharsTotal: 40_000),
40+
cancellationToken);
41+
42+
_profileContent = FindAssembledFileText(assembled, ProfilePath);
43+
_longTermContent = FindAssembledFileText(assembled, LongTermPath);
44+
45+
var projects = await _memory.MemoryListFilesAsync(_scope, ProjectsPrefix, 200, cancellationToken);
46+
_projectFiles = projects
47+
.Select(x => x.Path)
48+
.OrderBy(x => x, StringComparer.OrdinalIgnoreCase)
49+
.ToList();
50+
_includeSnapshotOnNextTurn = true;
51+
}
52+
53+
public IReadOnlyList<ChatMessage> BuildTurnMessages(string userInput)
54+
{
55+
if (!_includeSnapshotOnNextTurn)
56+
{
57+
return [new ChatMessage(ChatRole.User, userInput)];
58+
}
59+
60+
_includeSnapshotOnNextTurn = false;
61+
return
62+
[
63+
new ChatMessage(
64+
ChatRole.System,
65+
"Use this preloaded memory snapshot as default context. " +
66+
"Avoid redundant memory_load_file calls for these files unless explicitly asked to verify.\n\n" +
67+
BuildSnapshotText()),
68+
new ChatMessage(ChatRole.User, userInput)
69+
];
70+
}
71+
72+
public void MarkFileUpdated(string path, string content)
73+
{
74+
var normalizedPath = NormalizePath(path);
75+
if (normalizedPath.Equals(ProfilePath, StringComparison.OrdinalIgnoreCase))
76+
{
77+
_profileContent = content;
78+
_includeSnapshotOnNextTurn = true;
79+
return;
80+
}
81+
82+
if (normalizedPath.Equals(LongTermPath, StringComparison.OrdinalIgnoreCase))
83+
{
84+
_longTermContent = content;
85+
_includeSnapshotOnNextTurn = true;
86+
return;
87+
}
88+
89+
if (normalizedPath.StartsWith(ProjectsPrefix, StringComparison.OrdinalIgnoreCase))
90+
{
91+
AddProjectFile(normalizedPath);
92+
_includeSnapshotOnNextTurn = true;
93+
}
94+
}
95+
96+
public void UpdateProjectCatalog(IReadOnlyList<MemoryFileListItem> files)
97+
{
98+
_projectFiles = files
99+
.Select(x => NormalizePath(x.Path))
100+
.Where(x => x.StartsWith(ProjectsPrefix, StringComparison.OrdinalIgnoreCase))
101+
.Distinct(StringComparer.OrdinalIgnoreCase)
102+
.OrderBy(x => x, StringComparer.OrdinalIgnoreCase)
103+
.ToList();
104+
_includeSnapshotOnNextTurn = true;
105+
}
106+
107+
private static string? FindAssembledFileText(AssembleContextResponse assembled, string path)
108+
{
109+
var match = assembled.Files.FirstOrDefault(x => x.Path.Equals(path, StringComparison.OrdinalIgnoreCase));
110+
if (match is null)
111+
{
112+
return null;
113+
}
114+
115+
var text = match.Document.Content["text"]?.GetValue<string>();
116+
if (!string.IsNullOrWhiteSpace(text))
117+
{
118+
return text;
119+
}
120+
121+
return JsonSerializer.Serialize(match.Document.Content, new JsonSerializerOptions { WriteIndented = true });
122+
}
123+
124+
private string BuildSnapshotText()
125+
{
126+
var sb = new StringBuilder();
127+
AppendSnapshotSection(sb, ProfilePath, _profileContent);
128+
sb.AppendLine();
129+
AppendSnapshotSection(sb, LongTermPath, _longTermContent);
130+
sb.AppendLine();
131+
AppendProjectCatalogSection(sb, _projectFiles);
132+
return sb.ToString().TrimEnd();
133+
}
134+
135+
private static void AppendSnapshotSection(StringBuilder sb, string path, string? content)
136+
{
137+
sb.AppendLine($"[{path}]");
138+
if (content is null)
139+
{
140+
sb.AppendLine("(not found)");
141+
}
142+
else if (string.IsNullOrWhiteSpace(content))
143+
{
144+
sb.AppendLine("(empty)");
145+
}
146+
else
147+
{
148+
sb.AppendLine(content.TrimEnd());
149+
}
150+
}
151+
152+
private static void AppendProjectCatalogSection(StringBuilder sb, IReadOnlyList<string> projectFiles)
153+
{
154+
sb.AppendLine("[projects/catalog]");
155+
if (projectFiles.Count == 0)
156+
{
157+
sb.AppendLine("(none)");
158+
return;
159+
}
160+
161+
foreach (var projectPath in projectFiles)
162+
{
163+
sb.Append("- ").AppendLine(projectPath);
164+
}
165+
}
166+
167+
private void AddProjectFile(string path)
168+
{
169+
if (_projectFiles.Contains(path, StringComparer.OrdinalIgnoreCase))
170+
{
171+
return;
172+
}
173+
174+
_projectFiles.Add(path);
175+
_projectFiles = _projectFiles
176+
.OrderBy(x => x, StringComparer.OrdinalIgnoreCase)
177+
.ToList();
178+
}
179+
180+
private static string NormalizePath(string path)
181+
{
182+
return path.Replace('\\', '/').Trim().TrimStart('/');
183+
}
184+
}

0 commit comments

Comments
 (0)