AX Agent 메모리 구조 2차 강화: 계층형 메모리 관리 도구 확장
Some checks failed
Release Gate / gate (push) Has been cancelled

- memory 도구에 save_scope, delete_scope 액션을 추가해 managed/user/project/local 메모리 파일을 직접 저장 및 삭제할 수 있게 확장함

- search, list 액션이 학습 메모리뿐 아니라 계층형 메모리 문서도 함께 보여주도록 개선함

- AgentMemoryService에 계층형 메모리 파일 쓰기/삭제 경로와 append/remove 로직을 추가해 메모리 계층을 실제로 관리 가능한 상태로 전환함

- 검증: dotnet build src/AxCopilot/AxCopilot.csproj -c Release -v minimal -p:OutputPath=bin\\verify\\ -p:IntermediateOutputPath=obj\\verify\\ (경고 0 / 오류 0)
This commit is contained in:
2026-04-06 23:46:23 +09:00
parent 13cd1e54ed
commit 80682552f4
4 changed files with 173 additions and 15 deletions

View File

@@ -14,18 +14,22 @@ public class MemoryTool : IAgentTool
public string Description =>
"프로젝트 규칙, 사용자 선호도, 학습 내용을 저장하고 검색합니다.\n" +
"대화 간 지속되는 메모리로, 새 대화에서도 이전에 학습한 내용을 활용할 수 있습니다.\n" +
"- action=\"save\": 메모리 저장 (type, content 필수)\n" +
"- action=\"save\": 학습 메모리 저장 (type, content 필수)\n" +
"- action=\"save_scope\": 계층형 메모리 파일에 저장 (scope, content 필수)\n" +
"- action=\"search\": 관련 메모리 검색 (query 필수)\n" +
"- action=\"list\": 현재 메모리 전체 목록\n" +
"- action=\"delete\": 메모리 삭제 (id 필수)\n" +
"- action=\"list\": 현재 메모리 전체 목록 + 계층형 메모리 파일 목록\n" +
"- action=\"delete\": 학습 메모리 삭제 (id 필수)\n" +
"- action=\"delete_scope\": 계층형 메모리 파일에서 삭제 (scope, query 필수)\n" +
"scope 종류: managed | user | project | local\n" +
"type 종류: rule(프로젝트 규칙), preference(사용자 선호), fact(사실), correction(실수 교정)";
public ToolParameterSchema Parameters => new()
{
Properties = new()
{
["action"] = new() { Type = "string", Description = "save | search | list | delete" },
["action"] = new() { Type = "string", Description = "save | save_scope | search | list | delete | delete_scope" },
["type"] = new() { Type = "string", Description = "메모리 유형: rule | preference | fact | correction. save 시 필수." },
["scope"] = new() { Type = "string", Description = "계층형 메모리 대상: managed | user | project | local. save_scope/delete_scope 시 필수." },
["content"] = new() { Type = "string", Description = "저장할 내용. save 시 필수." },
["query"] = new() { Type = "string", Description = "검색 쿼리. search 시 필수." },
["id"] = new() { Type = "string", Description = "메모리 ID. delete 시 필수." },
@@ -44,6 +48,8 @@ public class MemoryTool : IAgentTool
if (memoryService == null)
return Task.FromResult(ToolResult.Fail("메모리 서비스를 사용할 수 없습니다."));
memoryService.Load(context.WorkFolder);
if (!args.TryGetProperty("action", out var actionEl))
return Task.FromResult(ToolResult.Fail("action이 필요합니다."));
var action = actionEl.GetString() ?? "";
@@ -51,10 +57,12 @@ public class MemoryTool : IAgentTool
return Task.FromResult(action switch
{
"save" => ExecuteSave(args, memoryService, context),
"save_scope" => ExecuteSaveScope(args, memoryService, context),
"search" => ExecuteSearch(args, memoryService),
"list" => ExecuteList(memoryService),
"delete" => ExecuteDelete(args, memoryService),
_ => ToolResult.Fail($"알 수 없는 액션: {action}. save | search | list | delete 중 선택하세요."),
"delete_scope" => ExecuteDeleteScope(args, memoryService, context),
_ => ToolResult.Fail($"알 수 없는 액션: {action}. save | save_scope | search | list | delete | delete_scope 중 선택하세요."),
});
}
@@ -82,29 +90,58 @@ public class MemoryTool : IAgentTool
return ToolResult.Fail("query가 필요합니다.");
var results = svc.GetRelevant(query, 10);
if (results.Count == 0)
var docs = svc.InstructionDocuments
.Where(d => d.Content.Contains(query, StringComparison.OrdinalIgnoreCase)
|| d.Path.Contains(query, StringComparison.OrdinalIgnoreCase))
.Take(8)
.ToList();
if (results.Count == 0 && docs.Count == 0)
return ToolResult.Ok("관련 메모리가 없습니다.");
var sb = new StringBuilder();
sb.AppendLine($"관련 메모리 {results.Count}개:");
foreach (var e in results)
sb.AppendLine($" [{e.Type}] {e.Content} (사용 {e.UseCount}회, ID: {e.Id})");
if (docs.Count > 0)
{
sb.AppendLine($"계층형 메모리 {docs.Count}개:");
foreach (var doc in docs)
sb.AppendLine($" [{doc.Label}] {doc.Path}");
sb.AppendLine();
}
if (results.Count > 0)
{
sb.AppendLine($"학습 메모리 {results.Count}개:");
foreach (var e in results)
sb.AppendLine($" [{e.Type}] {e.Content} (사용 {e.UseCount}회, ID: {e.Id})");
}
return ToolResult.Ok(sb.ToString());
}
private static ToolResult ExecuteList(AgentMemoryService svc)
{
var all = svc.All;
if (all.Count == 0)
var docs = svc.InstructionDocuments;
if (all.Count == 0 && docs.Count == 0)
return ToolResult.Ok("저장된 메모리가 없습니다.");
var sb = new StringBuilder();
sb.AppendLine($"전체 메모리 {all.Count}개:");
foreach (var group in all.GroupBy(e => e.Type))
if (docs.Count > 0)
{
sb.AppendLine($"\n[{group.Key}]");
foreach (var e in group.OrderByDescending(e => e.UseCount))
sb.AppendLine($" • {e.Content} (사용 {e.UseCount}회, ID: {e.Id})");
sb.AppendLine($"계층형 메모리 파일 {docs.Count}개:");
foreach (var doc in docs)
sb.AppendLine($" • [{doc.Label}] {doc.Path}");
sb.AppendLine();
}
if (all.Count > 0)
{
sb.AppendLine($"학습 메모리 {all.Count}개:");
foreach (var group in all.GroupBy(e => e.Type))
{
sb.AppendLine($"\n[{group.Key}]");
foreach (var e in group.OrderByDescending(e => e.UseCount))
sb.AppendLine($" • {e.Content} (사용 {e.UseCount}회, ID: {e.Id})");
}
}
return ToolResult.Ok(sb.ToString());
}
@@ -119,4 +156,34 @@ public class MemoryTool : IAgentTool
? ToolResult.Ok($"메모리 삭제됨 (ID: {id})")
: ToolResult.Fail($"해당 ID의 메모리를 찾을 수 없습니다: {id}");
}
private static ToolResult ExecuteSaveScope(JsonElement args, AgentMemoryService svc, AgentContext context)
{
var scope = args.TryGetProperty("scope", out var s) ? s.GetString() ?? "" : "";
var content = args.TryGetProperty("content", out var c) ? c.GetString() ?? "" : "";
if (string.IsNullOrWhiteSpace(scope))
return ToolResult.Fail("scope가 필요합니다. managed | user | project | local 중 선택하세요.");
if (string.IsNullOrWhiteSpace(content))
return ToolResult.Fail("content가 필요합니다.");
var result = svc.SaveInstruction(scope, content, context.WorkFolder);
return result.Path.Length == 0
? ToolResult.Fail(result.Message)
: ToolResult.Ok($"{result.Message}\n경로: {result.Path}");
}
private static ToolResult ExecuteDeleteScope(JsonElement args, AgentMemoryService svc, AgentContext context)
{
var scope = args.TryGetProperty("scope", out var s) ? s.GetString() ?? "" : "";
var query = args.TryGetProperty("query", out var q) ? q.GetString() ?? "" : "";
if (string.IsNullOrWhiteSpace(scope))
return ToolResult.Fail("scope가 필요합니다. managed | user | project | local 중 선택하세요.");
if (string.IsNullOrWhiteSpace(query))
return ToolResult.Fail("query가 필요합니다.");
var result = svc.DeleteInstruction(scope, query, context.WorkFolder);
return result.Changed
? ToolResult.Ok($"{result.Message}\n경로: {result.Path}")
: ToolResult.Fail(result.Message);
}
}

View File

@@ -45,6 +45,84 @@ public class AgentMemoryService
get { lock (_lock) return _instructionDocuments.ToList(); }
}
public string? GetWritableInstructionPath(string scope, string? workFolder)
{
scope = (scope ?? "").Trim().ToLowerInvariant();
return scope switch
{
"managed" => Path.Combine(ManagedMemoryDir, MemoryFileName),
"user" => Path.Combine(MemoryDir, MemoryFileName),
"project" => string.IsNullOrWhiteSpace(workFolder) ? null : Path.Combine(Path.GetFullPath(workFolder), MemoryFileName),
"local" => string.IsNullOrWhiteSpace(workFolder) ? null : Path.Combine(Path.GetFullPath(workFolder), LocalMemoryFileName),
_ => null
};
}
public (bool Changed, string Path, string Message) SaveInstruction(string scope, string content, string? workFolder)
{
lock (_lock)
{
var path = GetWritableInstructionPath(scope, workFolder);
if (string.IsNullOrWhiteSpace(path))
return (false, "", "해당 scope에 저장할 경로를 결정할 수 없습니다.");
try
{
Directory.CreateDirectory(Path.GetDirectoryName(path)!);
var existing = File.Exists(path) ? File.ReadAllText(path) : "";
if (existing.Contains(content, StringComparison.OrdinalIgnoreCase))
return (false, path, "이미 같은 내용이 메모리 파일에 있습니다.");
var sb = new StringBuilder();
if (!string.IsNullOrWhiteSpace(existing))
{
sb.Append(existing.TrimEnd());
sb.AppendLine();
sb.AppendLine();
}
sb.AppendLine($"- {content.Trim()}");
File.WriteAllText(path, sb.ToString());
Load(workFolder);
return (true, path, "메모리 파일에 지시를 추가했습니다.");
}
catch (Exception ex)
{
LogService.Warn($"메모리 파일 저장 실패 ({path}): {ex.Message}");
return (false, path, $"메모리 파일 저장 실패: {ex.Message}");
}
}
}
public (bool Changed, string Path, string Message) DeleteInstruction(string scope, string query, string? workFolder)
{
lock (_lock)
{
var path = GetWritableInstructionPath(scope, workFolder);
if (string.IsNullOrWhiteSpace(path))
return (false, "", "해당 scope에 저장된 메모리 파일 경로를 찾을 수 없습니다.");
if (!File.Exists(path))
return (false, path, "메모리 파일이 아직 없습니다.");
try
{
var lines = File.ReadAllLines(path).ToList();
var filtered = lines.Where(line => !line.Contains(query, StringComparison.OrdinalIgnoreCase)).ToList();
if (filtered.Count == lines.Count)
return (false, path, "삭제할 일치 항목을 찾지 못했습니다.");
File.WriteAllLines(path, filtered);
Load(workFolder);
return (true, path, "메모리 파일에서 일치 항목을 삭제했습니다.");
}
catch (Exception ex)
{
LogService.Warn($"메모리 파일 삭제 실패 ({path}): {ex.Message}");
return (false, path, $"메모리 파일 삭제 실패: {ex.Message}");
}
}
}
/// <summary>작업 폴더별 메모리 + 전역 메모리를 로드합니다.</summary>
public void Load(string? workFolder)
{