변경 목적: - 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
168 lines
5.9 KiB
C#
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);
|
|
}
|
|
}
|