Schemaauskunft im Datenbank-Werkzeug: "kein Feld" wird eine Feststellung (#676)
- Aktionen describe (alle Spalten einer freigegebenen Tabelle, mit dem Satz, dass nicht Genanntes nicht existiert) und tables (freigegebene Tabellen, vorhanden oder fehlend) - "Unknown column" / "no such table" usw. werden mit dem Vorhandenen beantwortet - MySQL, Postgres, SQL Server, SQLite; nur fuer freigegebene Tabellen - SQLite als Datenbanktyp; die Abfragen schreibt das Werkzeug selbst, PRAGMA bleibt im Agenten-SQL verboten - MongoDB: describe nennt die Felder aus den Dokumenten - describe/tables sind Lesezugriffe (Freigabeschluessel select), Vorpruefung der Tabelle Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
17d90cf952
commit
a02b1996dd
@@ -3,6 +3,7 @@ using System.Data.Common;
|
||||
using System.Text.Json;
|
||||
using ClawdDotNet.Core.Tools;
|
||||
using Microsoft.Data.SqlClient;
|
||||
using Microsoft.Data.Sqlite;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using MongoDB.Bson;
|
||||
using MongoDB.Driver;
|
||||
@@ -26,17 +27,21 @@ public sealed class DatabaseTool : IAgentTool
|
||||
public bool ReadsForeignContent => true;
|
||||
|
||||
public string Description => """
|
||||
Access to a SQL database (MySQL/MariaDB, Postgres, MSSQL) or MongoDB.
|
||||
Access to a SQL database (MySQL/MariaDB, Postgres, MSSQL, SQLite) or MongoDB.
|
||||
|
||||
Before you use a column you have not seen: action "describe" with "table" lists every column of
|
||||
the table - do not guess names. Action "tables" lists the tables you may use and whether they exist.
|
||||
What "describe" does not list does not exist - say so instead of guessing.
|
||||
|
||||
SQL: Send each statement with action "query" and "sql" - both read and write. The operation type
|
||||
(read, write, schema change) is determined from the statement itself. One statement per call,
|
||||
no SQL comments. Only configured allowed tables can be accessed.
|
||||
Table schema: "DESCRIBE <table>" or "SHOW COLUMNS FROM <table>" - verify column names before writing.
|
||||
Write statements return the number of affected rows.
|
||||
Which operations (SELECT, INSERT, UPDATE, ALTER...) you can execute without confirmation is determined
|
||||
by your tool configuration. Other operations will require approval.
|
||||
|
||||
MongoDB: action query (filter), insert (document) in an allowed collection.
|
||||
MongoDB: action query (filter), insert (document) in an allowed collection; describe (collection)
|
||||
lists the fields found in its documents.
|
||||
""";
|
||||
|
||||
public JsonElement InputSchema { get; } = JsonDocument.Parse("""
|
||||
@@ -45,8 +50,12 @@ public sealed class DatabaseTool : IAgentTool
|
||||
"properties": {
|
||||
"action": {
|
||||
"type": "string",
|
||||
"enum": ["query", "insert", "upsert"],
|
||||
"description": "The action to perform"
|
||||
"enum": ["query", "insert", "upsert", "describe", "tables"],
|
||||
"description": "The action to perform. describe: the columns of a table; tables: the tables you may use."
|
||||
},
|
||||
"table": {
|
||||
"type": "string",
|
||||
"description": "describe: the table (SQL) whose columns to list"
|
||||
},
|
||||
"sql": {
|
||||
"type": "string",
|
||||
@@ -83,6 +92,18 @@ public sealed class DatabaseTool : IAgentTool
|
||||
if (string.IsNullOrWhiteSpace(type))
|
||||
return null; // Konfigurationsfehler meldet die Ausfuehrung selbst
|
||||
|
||||
// #676: The schema questions are reads of allowed tables — the table is checked, no SQL is sent.
|
||||
var action = GetString(input, "action");
|
||||
if (action == "tables")
|
||||
return null;
|
||||
if (action == "describe" && type != "mongodb")
|
||||
{
|
||||
var table = GetString(input, "table");
|
||||
if (string.IsNullOrWhiteSpace(table))
|
||||
return "'table' is required for describe.";
|
||||
return IsTableAllowed(table, context) ? null : $"Sicherheitsfehler: Zugriff auf Tabelle '{table}' ist nicht erlaubt.";
|
||||
}
|
||||
|
||||
if (type == "mongodb")
|
||||
{
|
||||
var collection = GetString(input, "collection");
|
||||
@@ -115,7 +136,7 @@ public sealed class DatabaseTool : IAgentTool
|
||||
|
||||
return GetString(input, "action") switch
|
||||
{
|
||||
"query" => "select",
|
||||
"query" or "describe" or "tables" => "select",
|
||||
"insert" => "insert",
|
||||
"upsert" => "update",
|
||||
_ => null
|
||||
@@ -173,6 +194,10 @@ public sealed class DatabaseTool : IAgentTool
|
||||
{
|
||||
return await HandleMongoAsync(action, input, connectionString, context, ct);
|
||||
}
|
||||
if (action is "describe" or "tables")
|
||||
{
|
||||
return await HandleSchemaAsync(type, action, input, connectionString, context, ct);
|
||||
}
|
||||
else
|
||||
{
|
||||
return await HandleSqlAsync(type, action, input, connectionString, context, ct);
|
||||
@@ -199,18 +224,24 @@ public sealed class DatabaseTool : IAgentTool
|
||||
if (Verweigerung(inspection, accessLevel, context.FreigabeGeprueft) is { } verweigert)
|
||||
return ToolResult.Fail(verweigert);
|
||||
|
||||
using DbConnection conn = type switch
|
||||
{
|
||||
"mysql" => new MySqlConnection(connectionString),
|
||||
"postgres" => new NpgsqlConnection(connectionString),
|
||||
"mssql" => new SqlConnection(connectionString),
|
||||
_ => throw new NotSupportedException($"SQL Typ '{type}' wird nicht unterstützt.")
|
||||
};
|
||||
|
||||
using var conn = Connect(type, connectionString);
|
||||
await conn.OpenAsync(ct);
|
||||
using var cmd = conn.CreateCommand();
|
||||
cmd.CommandText = sql;
|
||||
|
||||
try
|
||||
{
|
||||
return await RunAsync(cmd, inspection, ct);
|
||||
}
|
||||
catch (DbException ex) when (SchemaLookup.Classify(ex.Message).Miss != SchemaMiss.None)
|
||||
{
|
||||
// #676: A refusal that knows the next step names it — the columns or tables that exist.
|
||||
return ToolResult.Fail($"Fehler: {ex.Message}\n{await HintAsync(conn, type, ex.Message, inspection, context, ct)}");
|
||||
}
|
||||
}
|
||||
|
||||
private static async Task<ToolResult> RunAsync(DbCommand cmd, SqlInspection inspection, CancellationToken ct)
|
||||
{
|
||||
// #609: Ob gelesen oder geschrieben wird, entscheidet das Statement. Vorher fuehrte
|
||||
// action "query" ein INSERT per ExecuteReader aus und meldete "[]" statt der Zeilenzahl.
|
||||
if (inspection.Operation == SqlOperation.Read)
|
||||
@@ -237,6 +268,90 @@ public sealed class DatabaseTool : IAgentTool
|
||||
}
|
||||
}
|
||||
|
||||
private static DbConnection Connect(string type, string connectionString) => type switch
|
||||
{
|
||||
"mysql" => new MySqlConnection(connectionString),
|
||||
"postgres" => new NpgsqlConnection(connectionString),
|
||||
"mssql" => new SqlConnection(connectionString),
|
||||
"sqlite" => new SqliteConnection(connectionString),
|
||||
_ => throw new NotSupportedException($"SQL Typ '{type}' wird nicht unterstützt.")
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// #676: "describe" — every column of an allowed table; "tables" — the allowed tables and
|
||||
/// whether they exist. Asked of the database, never guessed.
|
||||
/// </summary>
|
||||
private static async Task<ToolResult> HandleSchemaAsync(
|
||||
string type, string action, JsonElement input, string connectionString, AgentToolContext context, CancellationToken ct)
|
||||
{
|
||||
var allowed = GetAllowedTables(context);
|
||||
using var conn = Connect(type, connectionString);
|
||||
await conn.OpenAsync(ct);
|
||||
|
||||
if (action == "tables")
|
||||
{
|
||||
if (allowed.Count == 0)
|
||||
return ToolResult.Ok("No tables are released for you (allowedTables is empty).");
|
||||
var (existing, missing) = SchemaLookup.Split(allowed, await SchemaLookup.TablesAsync(conn, type, ct));
|
||||
return ToolResult.Ok(
|
||||
(existing.Count == 0 ? "None of the tables released for you exists in the database." : $"Tables you may use: {string.Join(", ", existing)}.") +
|
||||
(missing.Count == 0 ? "" : $"\nReleased for you but not in the database: {string.Join(", ", missing)}."));
|
||||
}
|
||||
|
||||
var table = GetString(input, "table");
|
||||
if (string.IsNullOrWhiteSpace(table))
|
||||
return ToolResult.Fail("'table' is required for describe.");
|
||||
if (!IsTableAllowed(table, context))
|
||||
return ToolResult.Fail($"Sicherheitsfehler: Zugriff auf Tabelle '{table}' ist nicht erlaubt. Erlaubt sind: {string.Join(", ", allowed)}.");
|
||||
|
||||
var columns = await SchemaLookup.ColumnsAsync(conn, type, table, ct);
|
||||
if (columns.Count == 0)
|
||||
{
|
||||
var (existing, _) = SchemaLookup.Split(allowed, await SchemaLookup.TablesAsync(conn, type, ct));
|
||||
return ToolResult.Fail($"Table '{table}' does not exist in the database." +
|
||||
(existing.Count == 0 ? "" : $" Tables you may use: {string.Join(", ", existing)}."));
|
||||
}
|
||||
return ToolResult.Ok(SchemaLookup.DescribeTable(SchemaLookup.StripSchema(table), columns));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// #676: After "unknown column" the columns of the tables in the statement; after "unknown
|
||||
/// table" the allowed tables that exist. Only ever allowed tables — the guard let the
|
||||
/// statement through, so every table in it is one. A hint that fails costs the hint, not the answer.
|
||||
/// </summary>
|
||||
private static async Task<string> HintAsync(
|
||||
DbConnection conn, string type, string message, SqlInspection inspection, AgentToolContext context, CancellationToken ct)
|
||||
{
|
||||
try
|
||||
{
|
||||
var (miss, name) = SchemaLookup.Classify(message);
|
||||
if (miss == SchemaMiss.UnknownColumn)
|
||||
{
|
||||
var lines = new List<string>();
|
||||
foreach (var table in inspection.Tables.Select(SchemaLookup.StripSchema).Distinct(StringComparer.OrdinalIgnoreCase))
|
||||
{
|
||||
var columns = await SchemaLookup.ColumnsAsync(conn, type, table, ct);
|
||||
if (columns.Count > 0)
|
||||
lines.Add($"Columns of '{table}': {SchemaLookup.ColumnList(columns)}.");
|
||||
}
|
||||
return lines.Count == 0
|
||||
? "Use action \"describe\" to see the columns."
|
||||
: $"There is no column '{name}'. " + string.Join(" ", lines) + " Use one of these, or say that the field does not exist.";
|
||||
}
|
||||
|
||||
var (existing, _) = SchemaLookup.Split(GetAllowedTables(context), await SchemaLookup.TablesAsync(conn, type, ct));
|
||||
return $"There is no table '{SchemaLookup.StripSchema(name ?? "")}'. " +
|
||||
(existing.Count == 0 ? "None of the tables released for you exists." : $"Tables you may use: {string.Join(", ", existing)}.");
|
||||
}
|
||||
catch (Exception)
|
||||
{
|
||||
return "Use action \"describe\" to see the columns of a table.";
|
||||
}
|
||||
}
|
||||
|
||||
private static bool IsTableAllowed(string table, AgentToolContext context)
|
||||
=> GetAllowedTables(context).Contains(SchemaLookup.StripSchema(table), StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
private async Task<ToolResult> HandleMongoAsync(string action, JsonElement input, string connectionString, AgentToolContext context, CancellationToken ct)
|
||||
{
|
||||
var collectionName = input.TryGetProperty("collection", out var c) ? c.GetString() : null;
|
||||
@@ -253,6 +368,20 @@ public sealed class DatabaseTool : IAgentTool
|
||||
var db = client.GetDatabase(dbName);
|
||||
var collection = db.GetCollection<BsonDocument>(collectionName);
|
||||
|
||||
if (action == "tables")
|
||||
{
|
||||
return ToolResult.Ok($"Collections you may use: {string.Join(", ", GetAllowedTables(context))}.");
|
||||
}
|
||||
if (action == "describe")
|
||||
{
|
||||
// #676: MongoDB has no schema — the fields found in the documents are the next best thing.
|
||||
var sample = await collection.Find(new BsonDocument()).Limit(50).ToListAsync(ct);
|
||||
var fields = sample.SelectMany(d => d.Names).Distinct().ToList();
|
||||
return ToolResult.Ok(sample.Count == 0
|
||||
? $"Collection '{collectionName}' has no documents."
|
||||
: $"Fields found in {sample.Count} document(s) of '{collectionName}': {string.Join(", ", fields)}. " +
|
||||
"A collection has no fixed schema — a field missing here may still exist in older documents.");
|
||||
}
|
||||
if (action == "query")
|
||||
{
|
||||
var filterJson = input.TryGetProperty("filter", out var f) ? f.GetString() : "{}";
|
||||
|
||||
@@ -0,0 +1,151 @@
|
||||
using System.Data.Common;
|
||||
using System.Text;
|
||||
using System.Text.RegularExpressions;
|
||||
|
||||
namespace ClawdDotNet.Tools.Database;
|
||||
|
||||
/// <summary>One column of a table, as the database describes it.</summary>
|
||||
public sealed record ColumnInfo(string Name, string Type, bool Nullable);
|
||||
|
||||
/// <summary>What a failed statement was missing, read from the database's own message.</summary>
|
||||
public enum SchemaMiss
|
||||
{
|
||||
None,
|
||||
UnknownColumn,
|
||||
UnknownTable
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// #676: The schema, asked of the database — so that "there is no field for it" is a finding,
|
||||
/// not a guess.
|
||||
///
|
||||
/// <para>Observed in the test operation (#572): agents guessed column names and got only the
|
||||
/// refusal back ("Unknown column 'zeitpunkt'"). Each guess cost two or three steps, and the
|
||||
/// workaround — column names written into the task text — does not scale. The organisation
|
||||
/// concept goes further (section 5): without a reliable negative finding an escalation is an
|
||||
/// escalation of a guess.</para>
|
||||
///
|
||||
/// <para>Only ever for tables the agent may use. The lookups are written here, with the table
|
||||
/// name as a parameter — the agent's own SQL still passes the guard, where PRAGMA and friends
|
||||
/// stay forbidden.</para>
|
||||
/// </summary>
|
||||
public static class SchemaLookup
|
||||
{
|
||||
public static async Task<IReadOnlyList<ColumnInfo>> ColumnsAsync(DbConnection conn, string type, string table, CancellationToken ct)
|
||||
{
|
||||
using var cmd = conn.CreateCommand();
|
||||
cmd.CommandText = type switch
|
||||
{
|
||||
"mysql" => "SELECT COLUMN_NAME, COLUMN_TYPE, IS_NULLABLE FROM information_schema.COLUMNS " +
|
||||
"WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = @t ORDER BY ORDINAL_POSITION",
|
||||
"postgres" => "SELECT column_name, data_type, is_nullable FROM information_schema.columns " +
|
||||
"WHERE table_schema = ANY (current_schemas(false)) AND table_name = @t ORDER BY ordinal_position",
|
||||
"mssql" => "SELECT COLUMN_NAME, DATA_TYPE, IS_NULLABLE FROM INFORMATION_SCHEMA.COLUMNS " +
|
||||
"WHERE TABLE_NAME = @t ORDER BY ORDINAL_POSITION",
|
||||
"sqlite" => "SELECT name, type, CASE WHEN \"notnull\" = 1 THEN 'NO' ELSE 'YES' END FROM pragma_table_info(@t)",
|
||||
_ => throw new NotSupportedException($"SQL type '{type}' is not supported.")
|
||||
};
|
||||
var parameter = cmd.CreateParameter();
|
||||
parameter.ParameterName = "@t";
|
||||
parameter.Value = StripSchema(table);
|
||||
cmd.Parameters.Add(parameter);
|
||||
|
||||
var columns = new List<ColumnInfo>();
|
||||
await using var reader = await cmd.ExecuteReaderAsync(ct);
|
||||
while (await reader.ReadAsync(ct))
|
||||
{
|
||||
columns.Add(new ColumnInfo(
|
||||
reader.GetString(0),
|
||||
reader.IsDBNull(1) ? "" : reader.GetString(1),
|
||||
!reader.IsDBNull(2) && string.Equals(reader.GetString(2), "YES", StringComparison.OrdinalIgnoreCase)));
|
||||
}
|
||||
return columns;
|
||||
}
|
||||
|
||||
/// <summary>The tables (and views) the database has, in the current database or schema.</summary>
|
||||
public static async Task<IReadOnlyList<string>> TablesAsync(DbConnection conn, string type, CancellationToken ct)
|
||||
{
|
||||
using var cmd = conn.CreateCommand();
|
||||
cmd.CommandText = type switch
|
||||
{
|
||||
"mysql" => "SELECT TABLE_NAME FROM information_schema.TABLES WHERE TABLE_SCHEMA = DATABASE()",
|
||||
"postgres" => "SELECT table_name FROM information_schema.tables WHERE table_schema = ANY (current_schemas(false))",
|
||||
"mssql" => "SELECT TABLE_NAME FROM INFORMATION_SCHEMA.TABLES",
|
||||
"sqlite" => "SELECT name FROM sqlite_master WHERE type IN ('table', 'view') AND name NOT LIKE 'sqlite_%'",
|
||||
_ => throw new NotSupportedException($"SQL type '{type}' is not supported.")
|
||||
};
|
||||
|
||||
var tables = new List<string>();
|
||||
await using var reader = await cmd.ExecuteReaderAsync(ct);
|
||||
while (await reader.ReadAsync(ct))
|
||||
tables.Add(reader.GetString(0));
|
||||
return tables;
|
||||
}
|
||||
|
||||
// ═══════════════════ reading the database's refusal ═══════════════════
|
||||
|
||||
private static readonly Regex[] UnknownColumnPatterns =
|
||||
[
|
||||
new(@"Unknown column '(?<n>[^']+)'", RegexOptions.IgnoreCase), // MySQL / MariaDB
|
||||
new(@"column ""(?<n>[^""]+)""(?: of relation ""[^""]+"")? does not exist", RegexOptions.IgnoreCase), // Postgres
|
||||
new(@"Invalid column name '(?<n>[^']+)'", RegexOptions.IgnoreCase), // SQL Server
|
||||
new(@"no such column: (?<n>[A-Za-z0-9_.$]+)", RegexOptions.IgnoreCase), // SQLite
|
||||
new(@"has no column named (?<n>[A-Za-z0-9_$]+)", RegexOptions.IgnoreCase) // SQLite, INSERT
|
||||
];
|
||||
|
||||
private static readonly Regex[] UnknownTablePatterns =
|
||||
[
|
||||
new(@"Table '(?<n>[^']+)' doesn't exist", RegexOptions.IgnoreCase), // MySQL / MariaDB
|
||||
new(@"relation ""(?<n>[^""]+)"" does not exist", RegexOptions.IgnoreCase), // Postgres
|
||||
new(@"Invalid object name '(?<n>[^']+)'", RegexOptions.IgnoreCase), // SQL Server
|
||||
new(@"no such table: (?<n>[A-Za-z0-9_.$]+)", RegexOptions.IgnoreCase) // SQLite
|
||||
];
|
||||
|
||||
/// <summary>What the message says was missing, and its name as the database wrote it.</summary>
|
||||
public static (SchemaMiss Miss, string? Name) Classify(string? message)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(message))
|
||||
return (SchemaMiss.None, null);
|
||||
foreach (var pattern in UnknownColumnPatterns)
|
||||
if (pattern.Match(message) is { Success: true } m)
|
||||
return (SchemaMiss.UnknownColumn, m.Groups["n"].Value);
|
||||
foreach (var pattern in UnknownTablePatterns)
|
||||
if (pattern.Match(message) is { Success: true } m)
|
||||
return (SchemaMiss.UnknownTable, m.Groups["n"].Value);
|
||||
return (SchemaMiss.None, null);
|
||||
}
|
||||
|
||||
// ═══════════════════ saying it ═══════════════════
|
||||
|
||||
/// <summary>"Columns of 'messwerte' (5): gemessen_um datetime, ..." — and that these are all.</summary>
|
||||
public static string DescribeTable(string table, IReadOnlyList<ColumnInfo> columns)
|
||||
{
|
||||
if (columns.Count == 0)
|
||||
return $"Table '{table}' does not exist in the database (or has no columns).";
|
||||
|
||||
var sb = new StringBuilder($"Columns of '{table}' ({columns.Count}):");
|
||||
foreach (var column in columns)
|
||||
sb.Append($"\n- {column.Name} {column.Type}{(column.Nullable ? "" : " not null")}");
|
||||
sb.Append($"\nThese are all the columns '{table}' has. A field that is not listed does not exist.");
|
||||
return sb.ToString();
|
||||
}
|
||||
|
||||
public static string ColumnList(IReadOnlyList<ColumnInfo> columns)
|
||||
=> string.Join(", ", columns.Select(c => c.Name));
|
||||
|
||||
/// <summary>The allowed tables, split into those that exist and those that do not.</summary>
|
||||
public static (IReadOnlyList<string> Existing, IReadOnlyList<string> Missing) Split(
|
||||
IReadOnlyCollection<string> allowed, IReadOnlyCollection<string> inDatabase)
|
||||
{
|
||||
var present = new HashSet<string>(inDatabase, StringComparer.OrdinalIgnoreCase);
|
||||
return (allowed.Where(present.Contains).ToList(), allowed.Where(t => !present.Contains(t)).ToList());
|
||||
}
|
||||
|
||||
/// <summary>"schema.table" → "table", as the whitelist lists names.</summary>
|
||||
public static string StripSchema(string table)
|
||||
{
|
||||
var name = table.Trim().Trim('`', '"', '[', ']');
|
||||
var dot = name.LastIndexOf('.');
|
||||
return dot >= 0 ? name[(dot + 1)..].Trim('`', '"', '[', ']') : name;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user