#631: Derselbe Vorschlag (gleicher Agent, gleiches Werkzeug, gleiche Argumente unabhaengig von Schluesselreihenfolge) wird nicht erneut angelegt - der Agent erfaehrt die bestehende Nummer. Offene Vorschlaege verfallen nach 24 h (Status Expired). #609: Werkzeuge koennen den Freigabeschluessel aus dem Inhalt bestimmen (IAgentTool.StagingAktion). Database: SELECT/SHOW/DESCRIBE -> query, INSERT/UPDATE/DELETE -> write, DDL -> schema; write und schema sind freigabepflichtig. Ausgefuehrt wird nach Statement-Art, schreibende Statements melden die Zeilenzahl. #579: Database-Vorpruefung (Zugriffsebene, Tabellenfreigabe, SqlGuard) - was das Werkzeug ablehnen wuerde, kommt nicht mehr in die Freigabeliste. #610: SqlGuard ueberspringt IF [NOT] EXISTS und erkennt DESCRIBE/DESC <tabelle>. #632: WebFetch fetch mit filter liefert aus JSON/JSONP nur passende Eintraege; beim Blaettern durch grosses JSON empfiehlt der Hinweis den filter. Version 0.1.10. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -1393,7 +1393,8 @@ public sealed class AgentEngine : IAgentMessageRouter, Staging.IFrozenCallExecut
|
||||
if (_stagingGate is not null)
|
||||
{
|
||||
var intercept = await _stagingGate.InterceptAsync(
|
||||
agentConfig.AgentId, instanceId, runId, toolName, arguments, ct);
|
||||
agentConfig.AgentId, instanceId, runId, toolName, arguments, ct,
|
||||
tool.StagingAktion(input));
|
||||
|
||||
if (intercept.Outcome == Staging.StagingOutcome.Denied)
|
||||
{
|
||||
|
||||
@@ -106,6 +106,38 @@ public sealed class SqliteStagingRepository : IStagingRepository
|
||||
return Convert.ToInt32(await cmd.ExecuteScalarAsync(ct));
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<StagedCall>> ListPendingForAgentAsync(string agentId, string tool, CancellationToken ct)
|
||||
{
|
||||
await using var conn = await _storage.OpenConnectionAsync(ct);
|
||||
using var cmd = conn.CreateCommand();
|
||||
cmd.CommandText = "SELECT " + Columns + " FROM StagedCalls " +
|
||||
"WHERE Status = 'Pending' AND AgentId = @agentId AND Tool = @tool ORDER BY Id";
|
||||
cmd.Parameters.AddWithValue("@agentId", agentId);
|
||||
cmd.Parameters.AddWithValue("@tool", tool);
|
||||
|
||||
var results = new List<StagedCall>();
|
||||
await using var reader = await cmd.ExecuteReaderAsync(ct);
|
||||
while (await reader.ReadAsync(ct))
|
||||
results.Add(Read(reader));
|
||||
return results;
|
||||
}
|
||||
|
||||
public Task<int> ExpirePendingAsync(DateTime createdBefore, DateTime now, CancellationToken ct)
|
||||
=> _storage.WriteAsync(async conn =>
|
||||
{
|
||||
// CreatedAt steht als ISO-8601 in UTC ("O") - der Textvergleich ist ein Zeitvergleich.
|
||||
using var cmd = conn.CreateCommand();
|
||||
cmd.CommandText = """
|
||||
UPDATE StagedCalls
|
||||
SET Status = 'Expired', DecidedAt = @now, DecidedBy = 'system',
|
||||
RejectionReason = 'Verfallen: zu lange unentschieden'
|
||||
WHERE Status = 'Pending' AND CreatedAt < @before
|
||||
""";
|
||||
cmd.Parameters.AddWithValue("@now", Format(now));
|
||||
cmd.Parameters.AddWithValue("@before", Format(createdBefore));
|
||||
return await cmd.ExecuteNonQueryAsync(ct);
|
||||
}, ct);
|
||||
|
||||
// ─── Hilfsfunktionen ───
|
||||
|
||||
private const string Columns =
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Nodes;
|
||||
|
||||
namespace ClawdDotNet.Core.Staging;
|
||||
|
||||
@@ -28,20 +29,42 @@ public readonly record struct StagingInterception(StagingOutcome Outcome, string
|
||||
/// </summary>
|
||||
public sealed class StagingGate
|
||||
{
|
||||
/// <summary>
|
||||
/// Wie lange ein Vorschlag offen bleiben darf (#631). Im Testbetrieb standen nach knapp drei
|
||||
/// Tagen ueber hundert Vorschlaege an; freigegeben wurde am Ende, was laengst ueberholt war.
|
||||
/// </summary>
|
||||
public static readonly TimeSpan StandardVerfall = TimeSpan.FromHours(24);
|
||||
|
||||
private readonly StagingPolicy _policy;
|
||||
private readonly IStagingRepository _repo;
|
||||
private readonly TimeSpan _verfall;
|
||||
private readonly TimeProvider _clock;
|
||||
|
||||
public StagingGate(StagingPolicy policy, IStagingRepository repo)
|
||||
public StagingGate(StagingPolicy policy, IStagingRepository repo, TimeSpan? verfall = null, TimeProvider? clock = null)
|
||||
{
|
||||
_policy = policy;
|
||||
_repo = repo;
|
||||
_verfall = verfall ?? StandardVerfall;
|
||||
_clock = clock ?? TimeProvider.System;
|
||||
}
|
||||
|
||||
/// <summary>Verwirft offene Vorschlaege, die aelter als die Verfallsfrist sind. Gibt die Anzahl zurueck.</summary>
|
||||
public Task<int> ExpireAsync(CancellationToken ct)
|
||||
{
|
||||
var jetzt = _clock.GetUtcNow().UtcDateTime;
|
||||
return _repo.ExpirePendingAsync(jetzt - _verfall, jetzt, ct);
|
||||
}
|
||||
|
||||
/// <param name="action">
|
||||
/// Der Aktionsschluessel, den das Werkzeug aus dem Inhalt des Aufrufs bestimmt hat
|
||||
/// (IAgentTool.StagingAktion). Ohne ihn gilt das <c>action</c>-Argument - das aber waehlt
|
||||
/// das Modell frei (#609).
|
||||
/// </param>
|
||||
public async Task<StagingInterception> InterceptAsync(
|
||||
string agentId, string instanceId, string runId,
|
||||
string tool, string argumentsJson, CancellationToken ct)
|
||||
string tool, string argumentsJson, CancellationToken ct, string? action = null)
|
||||
{
|
||||
var action = ExtractAction(argumentsJson);
|
||||
action ??= ExtractAction(argumentsJson);
|
||||
|
||||
switch (_policy.Decide(tool, action))
|
||||
{
|
||||
@@ -55,6 +78,22 @@ public sealed class StagingGate
|
||||
0);
|
||||
|
||||
case StagingDecision.Approve:
|
||||
await ExpireAsync(ct);
|
||||
|
||||
// #631: Derselbe Vorschlag liegt schon vor - kein zweiter Eintrag. Vorher legte
|
||||
// ein Agent dieselbe Loeschung jede Nacht erneut vor, bis 28 Dateien mehrfach
|
||||
// in der Liste standen.
|
||||
if (await FindeOffenenAsync(agentId, tool, argumentsJson, ct) is { } vorhanden)
|
||||
{
|
||||
return new StagingInterception(
|
||||
StagingOutcome.Staged,
|
||||
$"Dieser Vorschlag liegt bereits als #{vorhanden.Id} zur Freigabe vor " +
|
||||
$"(seit {vorhanden.CreatedAt:yyyy-MM-dd HH:mm} UTC) und wurde nicht erneut angelegt. " +
|
||||
"Lege ihn nicht noch einmal vor; du wirst geweckt, sobald entschieden ist. " +
|
||||
"Fahre mit anderer Arbeit fort oder schließe ab.",
|
||||
vorhanden.Id);
|
||||
}
|
||||
|
||||
var id = await _repo.AppendAsync(new StagedCall
|
||||
{
|
||||
RunId = runId,
|
||||
@@ -78,6 +117,45 @@ public sealed class StagingGate
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<StagedCall?> FindeOffenenAsync(string agentId, string tool, string argumentsJson, CancellationToken ct)
|
||||
{
|
||||
var gesucht = Kanonisch(argumentsJson);
|
||||
foreach (var offen in await _repo.ListPendingForAgentAsync(agentId, tool, ct))
|
||||
{
|
||||
if (Kanonisch(offen.ArgumentsJson) == gesucht)
|
||||
return offen;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Argumente in fester Form: Schluessel sortiert, ohne Leerraum. Das Modell schreibt dieselben
|
||||
/// Argumente mal als {"action","workspace","path"}, mal als {"action","path","workspace"}.
|
||||
/// </summary>
|
||||
internal static string Kanonisch(string argumentsJson)
|
||||
{
|
||||
try
|
||||
{
|
||||
return JsonNode.Parse(argumentsJson) is { } knoten
|
||||
? Sortiert(knoten)?.ToJsonString() ?? ""
|
||||
: "";
|
||||
}
|
||||
catch (JsonException)
|
||||
{
|
||||
return argumentsJson.Trim();
|
||||
}
|
||||
}
|
||||
|
||||
private static JsonNode? Sortiert(JsonNode? knoten) => knoten switch
|
||||
{
|
||||
JsonObject obj => new JsonObject(
|
||||
obj.OrderBy(p => p.Key, StringComparer.Ordinal)
|
||||
.Select(p => KeyValuePair.Create(p.Key, Sortiert(p.Value)))),
|
||||
JsonArray arr => new JsonArray(arr.Select(Sortiert).ToArray()),
|
||||
null => null,
|
||||
_ => JsonNode.Parse(knoten.ToJsonString())
|
||||
};
|
||||
|
||||
/// <summary>Liest das <c>action</c>-Argument, wenn vorhanden — der Aktionsschlüssel der Policy.</summary>
|
||||
public static string? ExtractAction(string argumentsJson)
|
||||
{
|
||||
|
||||
@@ -29,7 +29,13 @@ public enum StagingStatus
|
||||
Failed,
|
||||
|
||||
/// <summary>Abgelehnt.</summary>
|
||||
Rejected
|
||||
Rejected,
|
||||
|
||||
/// <summary>
|
||||
/// Zu lange unentschieden und deshalb verworfen (#631). Eine Freigabe nach Tagen fuehrte
|
||||
/// sonst aus, was laengst ueberholt ist - doppelte Messwerte, laengst geloeschte Dateien.
|
||||
/// </summary>
|
||||
Expired
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -92,4 +98,13 @@ public interface IStagingRepository
|
||||
Task FinalizeAsync(long id, StagingStatus status, string? resultRef, DateTime now, CancellationToken ct);
|
||||
|
||||
Task<int> CountPendingAsync(CancellationToken ct);
|
||||
|
||||
/// <summary>Die offenen Vorschlaege eines Agenten fuer ein Werkzeug - zum Erkennen von Dubletten (#631).</summary>
|
||||
Task<IReadOnlyList<StagedCall>> ListPendingForAgentAsync(string agentId, string tool, CancellationToken ct);
|
||||
|
||||
/// <summary>
|
||||
/// Setzt alle offenen Vorschlaege, die vor <paramref name="createdBefore"/> angelegt wurden,
|
||||
/// auf <see cref="StagingStatus.Expired"/>. Gibt die Anzahl zurueck.
|
||||
/// </summary>
|
||||
Task<int> ExpirePendingAsync(DateTime createdBefore, DateTime now, CancellationToken ct);
|
||||
}
|
||||
|
||||
@@ -45,6 +45,12 @@ public sealed class StagingPolicy
|
||||
["Telegram.send_message"] = StagingDecision.Approve,
|
||||
["Database.insert"] = StagingDecision.Approve,
|
||||
["Database.upsert"] = StagingDecision.Approve,
|
||||
|
||||
// #609: Bei SQL bestimmt das Werkzeug den Schluessel aus dem Statement selbst
|
||||
// (IAgentTool.StagingAktion) - "query" gilt nur noch fuer lesende Statements.
|
||||
// Vorher lief dasselbe INSERT ueber action "query" an der Freigabe vorbei.
|
||||
["Database.write"] = StagingDecision.Approve,
|
||||
["Database.schema"] = StagingDecision.Approve,
|
||||
["FileRW.delete"] = StagingDecision.Approve,
|
||||
["FTP.upload"] = StagingDecision.Approve,
|
||||
["FTP.delete"] = StagingDecision.Approve,
|
||||
|
||||
@@ -43,7 +43,13 @@ public sealed class StagingService
|
||||
_logger = loggerFactory.CreateLogger("ClawdDotNet.Core.Staging");
|
||||
}
|
||||
|
||||
public Task<IReadOnlyList<StagedCall>> ListPendingAsync(CancellationToken ct) => _repo.ListPendingAsync(ct);
|
||||
public async Task<IReadOnlyList<StagedCall>> ListPendingAsync(CancellationToken ct)
|
||||
{
|
||||
// #631: Was zu lange unentschieden liegt, kommt gar nicht erst wieder in die Ansicht.
|
||||
var jetzt = DateTime.UtcNow;
|
||||
await _repo.ExpirePendingAsync(jetzt - StagingGate.StandardVerfall, jetzt, ct);
|
||||
return await _repo.ListPendingAsync(ct);
|
||||
}
|
||||
public Task<StagedCall?> GetAsync(long id, CancellationToken ct) => _repo.GetAsync(id, ct);
|
||||
public Task<int> CountPendingAsync(CancellationToken ct) => _repo.CountPendingAsync(ct);
|
||||
|
||||
|
||||
@@ -57,6 +57,14 @@ public interface IAgentTool
|
||||
/// </summary>
|
||||
string? Preflight(JsonElement input, AgentToolContext context) => null;
|
||||
|
||||
/// <summary>
|
||||
/// Der Aktionsschluessel fuer die Freigaberegel, bestimmt aus dem Inhalt des Aufrufs statt
|
||||
/// aus dem frei waehlbaren <c>action</c>-Argument (#609). Beispiel Database: ein INSERT ist
|
||||
/// "write", auch wenn das Modell action "query" schickt. <c>null</c> = das
|
||||
/// <c>action</c>-Argument gilt.
|
||||
/// </summary>
|
||||
string? StagingAktion(JsonElement input) => null;
|
||||
|
||||
Task<ToolResult> ExecuteAsync(
|
||||
JsonElement input,
|
||||
AgentToolContext context,
|
||||
|
||||
Reference in New Issue
Block a user