Files
AX-Copilot-Codex/src/AxCopilot/Services/Agent/SqlTool.cs
lacvet 33c1db4dae
Some checks failed
Release Gate / gate (push) Has been cancelled
에이전트 선택적 탐색 구조 개선과 경고 정리 반영
- claude-code 선택적 탐색 흐름을 참고해 Cowork/Code 시스템 프롬프트에서 folder_map 상시 선행 지시를 완화하고 glob/grep 기반 좁은 탐색을 우선하도록 조정함

- FolderMapTool 기본 depth를 2로, include_files 기본값을 false로 낮추고 MultiReadTool 최대 파일 수를 8개로 줄여 초기 과탐색 폭을 보수적으로 조정함

- AgentLoopExplorationPolicy partial을 추가해 탐색 범위 분류, broad-scan corrective hint, exploration_breadth 성능 로그를 연결함

- AgentLoopService에 탐색 범위 가이드 주입과 실행 중 탐색 폭 추적을 추가하고, 좁은 질문에서 반복적인 folder_map/대량 multi_read를 교정하도록 정리함

- DocxToHtmlConverter nullable 경고를 수정해 Release 빌드 경고 0 / 오류 0 기준을 다시 충족함

- README와 docs/DEVELOPMENT.md에 2026-04-09 10:36 (KST) 기준 개발 이력을 반영함
2026-04-09 14:27:59 +09:00

211 lines
7.9 KiB
C#

using System.IO;
using System.Text;
using System.Text.Json;
using Microsoft.Data.Sqlite;
namespace AxCopilot.Services.Agent;
/// <summary>
/// SQLite 데이터베이스 쿼리 실행 도구.
/// 로컬 .db/.sqlite 파일에 대해 SELECT/INSERT/UPDATE/DELETE 쿼리를 실행합니다.
/// </summary>
public class SqlTool : IAgentTool
{
public string Name => "sql_tool";
public string Description =>
"Execute SQL queries on local SQLite database files. Actions: " +
"'query' — run SELECT query and return results as table; " +
"'execute' — run INSERT/UPDATE/DELETE and return affected rows; " +
"'schema' — show database schema (tables, columns, types); " +
"'tables' — list all tables in the database.";
public ToolParameterSchema Parameters => new()
{
Properties = new()
{
["action"] = new()
{
Type = "string",
Description = "Action to perform",
Enum = ["query", "execute", "schema", "tables"],
},
["db_path"] = new()
{
Type = "string",
Description = "Path to SQLite database file (.db, .sqlite, .sqlite3)",
},
["sql"] = new()
{
Type = "string",
Description = "SQL query to execute (for query/execute actions)",
},
["max_rows"] = new()
{
Type = "string",
Description = "Maximum rows to return (default: 100, max: 1000)",
},
},
Required = ["action", "db_path"],
};
public Task<ToolResult> ExecuteAsync(JsonElement args, AgentContext context, CancellationToken ct = default)
{
var action = args.GetProperty("action").SafeGetString() ?? "";
var dbPath = args.GetProperty("db_path").SafeGetString() ?? "";
if (!Path.IsPathRooted(dbPath))
dbPath = Path.Combine(context.WorkFolder, dbPath);
if (!File.Exists(dbPath))
return Task.FromResult(ToolResult.Fail($"Database file not found: {dbPath}"));
try
{
var connStr = $"Data Source={dbPath};Mode=ReadOnly";
// execute 액션은 ReadWrite 필요
if (action == "execute")
connStr = $"Data Source={dbPath}";
using var conn = new SqliteConnection(connStr);
conn.Open();
return Task.FromResult(action switch
{
"query" => QueryAction(conn, args),
"execute" => ExecuteAction(conn, args),
"schema" => SchemaAction(conn),
"tables" => TablesAction(conn),
_ => ToolResult.Fail($"Unknown action: {action}"),
});
}
catch (Exception ex)
{
return Task.FromResult(ToolResult.Fail($"SQL 오류: {ex.Message}"));
}
}
private static ToolResult QueryAction(SqliteConnection conn, JsonElement args)
{
if (!args.SafeTryGetProperty("sql", out var sqlProp))
return ToolResult.Fail("'sql' parameter is required for query action");
var sql = sqlProp.SafeGetString() ?? "";
// SELECT만 허용
if (!sql.TrimStart().StartsWith("SELECT", StringComparison.OrdinalIgnoreCase) &&
!sql.TrimStart().StartsWith("WITH", StringComparison.OrdinalIgnoreCase) &&
!sql.TrimStart().StartsWith("PRAGMA", StringComparison.OrdinalIgnoreCase))
return ToolResult.Fail("Query action only allows SELECT/WITH/PRAGMA statements. Use 'execute' for modifications.");
var maxRows = args.SafeTryGetProperty("max_rows", out var mr)
? Math.Min(mr.SafeGetInt32(100), 1000) : 100;
using var cmd = conn.CreateCommand();
cmd.CommandText = sql;
using var reader = cmd.ExecuteReader();
var sb = new StringBuilder();
var colCount = reader.FieldCount;
// 헤더
var colNames = new string[colCount];
for (var i = 0; i < colCount; i++)
colNames[i] = reader.GetName(i);
sb.AppendLine(string.Join(" | ", colNames));
sb.AppendLine(new string('-', colNames.Sum(c => c.Length + 3)));
// 행
var rowCount = 0;
while (reader.Read() && rowCount < maxRows)
{
var values = new string[colCount];
for (var i = 0; i < colCount; i++)
values[i] = reader.IsDBNull(i) ? "NULL" : reader.GetValue(i)?.ToString() ?? "";
sb.AppendLine(string.Join(" | ", values));
rowCount++;
}
if (rowCount == 0)
return ToolResult.Ok("Query returned 0 rows.");
var result = sb.ToString();
if (result.Length > 8000) result = result[..8000] + "\n... (truncated)";
return ToolResult.Ok($"Rows: {rowCount}" + (rowCount >= maxRows ? $" (limited to {maxRows})" : "") + $"\n\n{result}");
}
private static ToolResult ExecuteAction(SqliteConnection conn, JsonElement args)
{
if (!args.SafeTryGetProperty("sql", out var sqlProp))
return ToolResult.Fail("'sql' parameter is required for execute action");
var sql = sqlProp.SafeGetString() ?? "";
// DDL/DML만 허용 (DROP DATABASE 등 위험 명령 차단)
var trimmed = sql.TrimStart().ToUpperInvariant();
if (trimmed.StartsWith("DROP DATABASE") || trimmed.StartsWith("ATTACH") || trimmed.StartsWith("DETACH"))
return ToolResult.Fail("Security: DROP DATABASE, ATTACH, DETACH are not allowed.");
using var cmd = conn.CreateCommand();
cmd.CommandText = sql;
var affected = cmd.ExecuteNonQuery();
return ToolResult.Ok($"✓ {affected} row(s) affected");
}
private static ToolResult SchemaAction(SqliteConnection conn)
{
var sb = new StringBuilder();
using var cmd = conn.CreateCommand();
cmd.CommandText = "SELECT name FROM sqlite_master WHERE type='table' ORDER BY name";
using var reader = cmd.ExecuteReader();
var tables = new List<string>();
while (reader.Read()) tables.Add(reader.GetString(0));
reader.Close();
foreach (var table in tables)
{
sb.AppendLine($"## {table}");
using var pragmaCmd = conn.CreateCommand();
pragmaCmd.CommandText = $"PRAGMA table_info(\"{table}\")";
using var pragmaReader = pragmaCmd.ExecuteReader();
sb.AppendLine($"{"#",-4} {"Name",-25} {"Type",-15} {"NotNull",-8} {"Default",-15} {"PK"}");
while (pragmaReader.Read())
{
sb.AppendLine($"{pragmaReader.GetInt32(0),-4} " +
$"{pragmaReader.GetString(1),-25} " +
$"{pragmaReader.GetString(2),-15} " +
$"{(pragmaReader.GetInt32(3) == 1 ? "YES" : ""),-8} " +
$"{(pragmaReader.IsDBNull(4) ? "" : pragmaReader.GetString(4)),-15} " +
$"{(pragmaReader.GetInt32(5) > 0 ? "PK" : "")}");
}
pragmaReader.Close();
sb.AppendLine();
}
return ToolResult.Ok(sb.ToString());
}
private static ToolResult TablesAction(SqliteConnection conn)
{
using var cmd = conn.CreateCommand();
cmd.CommandText = @"
SELECT m.name, m.type,
(SELECT count(*) FROM pragma_table_info(m.name)) as col_count
FROM sqlite_master m
WHERE m.type IN ('table','view')
ORDER BY m.type, m.name";
using var reader = cmd.ExecuteReader();
var sb = new StringBuilder();
sb.AppendLine($"{"Name",-30} {"Type",-8} {"Columns"}");
sb.AppendLine(new string('-', 50));
var count = 0;
while (reader.Read())
{
sb.AppendLine($"{reader.GetString(0),-30} {reader.GetString(1),-8} {reader.GetInt32(2)}");
count++;
}
return ToolResult.Ok($"Found {count} tables/views:\n\n{sb}");
}
}