Files
AX-Copilot-Codex/src/AxCopilot/Services/Agent/HashAnchor.cs
lacvet 8cb08576d5 AX Agent 도구·스킬 정합성 재구성 및 실행 품질 보강
변경 목적:
- AX Agent의 도구 이름, 내부 설정, 스킬 정책, 실행 루프 사이의 불일치를 줄이고 전체 동작 품질을 높인다.
- claw-code 수준의 일관된 동작 품질을 참고하되 AX 구조에 맞는 고유한 카탈로그·정규화 레이어로 재구성한다.

핵심 수정사항:
- 도구 canonical id, legacy alias, 탭 노출, 설정 카테고리, read-only 분류를 중앙 카탈로그로 통합했다.
- ToolRegistry, AgentLoopService, 병렬 실행 분류, 권한 처리, 훅 처리, 스킬 allowed-tools 해석이 같은 이름 체계를 사용하도록 정리했다.
- Agent 설정/일반 설정/도움말의 도구 카드와 훅 편집기, 스킬 설명을 현재 런타임 구조에 맞게 갱신했다.
- 컨텍스트 압축, intent gate, spawn agents, session learning, model prompt adapter, workspace context 관련 변경과 테스트 추가를 함께 반영했다.
- 문서 이력과 비교/로드맵 문서를 최신 상태로 갱신했다.

검증 결과:
- dotnet build src/AxCopilot/AxCopilot.csproj -c Release -v minimal -p:OutputPath=bin\verify_toolcat\ -p:IntermediateOutputPath=obj\verify_toolcat\ : 경고 0 / 오류 0
- dotnet test src/AxCopilot.Tests/AxCopilot.Tests.csproj -c Release -v minimal --filter AgentToolCatalogTests -p:OutputPath=bin\verify_toolcat_tests\ -p:IntermediateOutputPath=obj\verify_toolcat_tests\ : 통과 8
2026-04-14 17:52:46 +09:00

168 lines
5.9 KiB
C#

using System.IO.Hashing;
using System.Text;
namespace AxCopilot.Services.Agent;
/// <summary>
/// Hash-anchored edits 인프라.
/// 파일 읽기 시 각 라인에 2글자 해시 앵커를 부여하고,
/// 편집 시 해시 앵커로 대상 라인을 정확히 식별 + 스테일 감지.
/// </summary>
internal static class HashAnchor
{
// oh-my-openagent 호환 알파벳 (16자 → 2글자 조합 = 256가지)
private const string Alphabet = "ZPMQVRWSNKTXJBYH";
/// <summary>
/// 라인 내용 + 라인 번호(1-based)로부터 2글자 해시 앵커를 생성합니다.
/// </summary>
public static string ComputeAnchor(string lineContent, int lineNumber)
{
// 정규화: CR 제거 + 후행 공백 제거
var normalized = lineContent.TrimEnd('\r').TrimEnd();
// 빈 줄/공백만 있는 줄 → 라인번호를 시드로 사용 (충돌 감소)
uint hash;
if (IsBlankOrWhitespace(normalized))
{
Span<byte> numBuf = stackalloc byte[4];
System.Buffers.Binary.BinaryPrimitives.WriteInt32LittleEndian(numBuf, lineNumber);
hash = XxHash32(numBuf);
}
else
{
var bytes = Encoding.UTF8.GetBytes(normalized);
hash = XxHash32(bytes);
}
// 8비트로 축소 → 알파벳 2글자로 인코딩
var reduced = (byte)(hash ^ (hash >> 8) ^ (hash >> 16) ^ (hash >> 24));
var hi = Alphabet[(reduced >> 4) & 0x0F];
var lo = Alphabet[reduced & 0x0F];
return $"{hi}{lo}";
}
/// <summary>
/// 파일 전체 라인에 대해 해시 앵커를 배열로 반환합니다.
/// anchors[i]는 lines[i]의 앵커 (0-based).
/// </summary>
public static string[] ComputeAnchors(string[] lines)
{
var anchors = new string[lines.Length];
for (int i = 0; i < lines.Length; i++)
anchors[i] = ComputeAnchor(lines[i], i + 1);
return anchors;
}
/// <summary>
/// "LINENUM#HASH" 형식의 위치 문자열을 파싱합니다.
/// 예: "11#VK" → lineNumber=11, anchor="VK"
/// </summary>
public static bool TryParsePosition(string pos, out int lineNumber, out string anchor)
{
lineNumber = 0;
anchor = "";
if (string.IsNullOrWhiteSpace(pos))
return false;
var hashIdx = pos.IndexOf('#');
if (hashIdx < 1 || hashIdx >= pos.Length - 1)
return false;
if (!int.TryParse(pos.AsSpan(0, hashIdx), out lineNumber) || lineNumber < 1)
return false;
anchor = pos[(hashIdx + 1)..].Trim();
return anchor.Length == 2;
}
/// <summary>
/// 앵커가 현재 파일 라인과 일치하는지 검증합니다.
/// </summary>
public static bool Validate(string lineContent, int lineNumber, string expectedAnchor)
{
var actual = ComputeAnchor(lineContent, lineNumber);
return string.Equals(actual, expectedAnchor, StringComparison.Ordinal);
}
/// <summary>
/// 해시 앵커가 포함된 파일 읽기 출력을 생성합니다.
/// 형식: "LINENUM#HASH| content"
/// </summary>
public static string FormatLine(string lineContent, int lineNumber, string anchor)
{
return $"{lineNumber}#{anchor}| {lineContent.TrimEnd('\r')}";
}
/// <summary>
/// 해시 앵커가 포함된 파일 전체 출력을 생성합니다.
/// </summary>
public static string FormatLines(string[] lines, string[] anchors, int startIdx, int endIdx)
{
var sb = new StringBuilder((endIdx - startIdx) * 80);
for (int i = startIdx; i < endIdx && i < lines.Length; i++)
{
var lineNum = i + 1;
sb.Append(lineNum);
sb.Append('#');
sb.Append(anchors[i]);
sb.Append("| ");
sb.AppendLine(lines[i].TrimEnd('\r'));
}
return sb.ToString();
}
/// <summary>
/// 여러 앵커 위치를 검증하고, 불일치가 있으면 상세 에러를 반환합니다.
/// </summary>
public static (bool AllValid, string? ErrorDetail) ValidatePositions(
string[] lines, List<(int LineNumber, string Anchor)> positions)
{
var mismatches = new List<string>();
foreach (var (lineNum, expectedAnchor) in positions)
{
if (lineNum < 1 || lineNum > lines.Length)
{
mismatches.Add($" Line {lineNum}: out of range (file has {lines.Length} lines)");
continue;
}
var actual = ComputeAnchor(lines[lineNum - 1], lineNum);
if (!string.Equals(actual, expectedAnchor, StringComparison.Ordinal))
{
var preview = lines[lineNum - 1].TrimEnd('\r');
if (preview.Length > 80) preview = preview[..80] + "...";
mismatches.Add($" Line {lineNum}: expected #{expectedAnchor}, got #{actual} — \"{preview}\"");
}
}
if (mismatches.Count == 0)
return (true, null);
var detail = $"Hash anchor mismatch — file was modified since last read. Re-read the file to get fresh anchors.\n" +
string.Join("\n", mismatches);
return (false, detail);
}
// ════════════════════════════════════════════
// 내부 유틸
// ════════════════════════════════════════════
private static bool IsBlankOrWhitespace(string s)
{
foreach (var c in s)
{
if (c != ' ' && c != '\t')
return false;
}
return true;
}
private static uint XxHash32(ReadOnlySpan<byte> data)
{
// System.IO.Hashing 사용
return System.IO.Hashing.XxHash32.HashToUInt32(data);
}
}