Freigaben buendeln statt Weckflut (#643), Freigabemodell ueber Werkzeugrechte (#641)

#643: Entscheidungen wecken den Agenten nur noch bei Ablehnung oder gescheiterter
Ausfuehrung, gesammelt in einer Aufgabe je Agent (5 min Ruhefenster), frisch per
@new:agent statt im fortgefuehrten Chatverlauf. Eine einmalige Aufgabe, deren Lauf
wegen Budget oder Herunterfahren gar nicht stattfand, bleibt faellig (Wiedervorlage
nach 30 min) statt mit gesetztem Marker liegenzubleiben.

#641: Rechte stehen in der Werkzeugkonfiguration des Agenten (erlaubt/verboten).
Erlaubtes laeuft, Nicht-Erlaubtes wird einzeln vorgelegt statt verweigert, Verbotenes
gar nicht erst. "Gleichartiges kuenftig erlauben" erteilt eine widerrufbare
Dauerfreigabe je Agent/Werkzeug/Operation; nach drei gleichartigen Freigaben schlaegt
die Oberflaeche sie vor. Aenderungen an Agenten bleiben ausnahmslos freigabepflichtig.
Database: Operation ist das SQL-Schluesselwort; die eigene Zugriffspruefung greift nur
noch ohne Freigabestelle (AgentToolContext.FreigabeGeprueft). WebUI: Rechte-Tabelle je
Werkzeug, Dauerfreigaben mit Widerruf.

Version 0.1.11.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Richard
2026-09-19 14:37:44 +02:00
co-authored by Claude Opus 5
parent f74e49ba35
commit 2f0361278b
36 changed files with 1687 additions and 137 deletions
+9 -2
View File
@@ -1392,9 +1392,13 @@ public sealed class AgentEngine : IAgentMessageRouter, Staging.IFrozenCallExecut
// ausgeführt. Eine Prompt-Injection kann so nur einen Vorschlag erzeugen.
if (_stagingGate is not null)
{
// #641: Die Rechte stehen in der Werkzeugkonfiguration des Agenten.
var operation = tool.StagingAktion(input) ?? Staging.StagingGate.ExtractAction(arguments);
var intercept = await _stagingGate.InterceptAsync(
agentConfig.AgentId, instanceId, runId, toolName, arguments, ct,
tool.StagingAktion(input));
operation,
Staging.Werkzeugrechte.Aus(context.ToolConfig),
operation is null ? null : tool.Vorgabe(operation, context.ToolConfig));
if (intercept.Outcome == Staging.StagingOutcome.Denied)
{
@@ -1407,6 +1411,8 @@ public sealed class AgentEngine : IAgentMessageRouter, Staging.IFrozenCallExecut
await Audit(AuditStatus.Staged, intercept.Message);
return intercept.Message; // dem Agenten als reguläres Tool-Ergebnis
}
context = context with { FreigabeGeprueft = true };
}
logger.LogDebug("Executing tool {Tool} for agent {AgentId}", toolName, agentConfig.AgentId);
@@ -1576,7 +1582,8 @@ public sealed class AgentEngine : IAgentMessageRouter, Staging.IFrozenCallExecut
? default
: JsonDocument.Parse(argumentsJson).RootElement;
var context = BuildToolContext(config, _instanceId, tool, ct);
// #641: Ein Mensch hat genau diesen Aufruf freigegeben.
var context = BuildToolContext(config, _instanceId, tool, ct) with { FreigabeGeprueft = true };
var result = await agentTool.ExecuteAsync(input, context, ct);
var status = result.Success ? AuditStatus.Ok : AuditStatus.Error;
@@ -0,0 +1,131 @@
using System.Globalization;
using ClawdDotNet.Core.Storage;
using Microsoft.Data.Sqlite;
namespace ClawdDotNet.Core.Staging;
/// <summary>
/// „Gleichartiges künftig erlauben" (#641): Ein Mensch hat eine Operation eines Agenten
/// freigegeben und dabei entschieden, dass dieselbe Operation künftig ohne Rückfrage laufen
/// darf. Gilt je Agent, Werkzeug und Operation — nicht für andere Agenten, nicht für
/// andere Operationen. Widerrufbar; ein Widerruf löscht nichts, er beendet die Geltung.
/// </summary>
public sealed record Dauerfreigabe
{
public long Id { get; init; }
public string AgentId { get; init; } = "";
public string Tool { get; init; } = "";
public string Operation { get; init; } = "";
public string ErteiltVon { get; init; } = "";
public DateTime ErteiltAm { get; init; }
/// <summary>Der Vorschlag, bei dessen Freigabe sie erteilt wurde.</summary>
public long? AnlassId { get; init; }
public DateTime? WiderrufenAm { get; init; }
public string? WiderrufenVon { get; init; }
public bool Gilt => WiderrufenAm is null;
}
public interface IDauerfreigabeRepository
{
/// <summary>Erteilt sie. Gilt schon eine gleiche, bleibt es bei dieser — keine Dublette.</summary>
Task<Dauerfreigabe> ErteilenAsync(Dauerfreigabe freigabe, CancellationToken ct);
/// <summary>Die geltende Dauerfreigabe für genau diese Operation, sonst <c>null</c>.</summary>
Task<Dauerfreigabe?> FindeAsync(string agentId, string tool, string operation, CancellationToken ct);
Task<IReadOnlyList<Dauerfreigabe>> ListAsync(bool mitWiderrufenen, CancellationToken ct);
Task<bool> WiderrufenAsync(long id, string von, DateTime jetzt, CancellationToken ct);
}
public sealed class SqliteDauerfreigabeRepository(SqliteStorage storage) : IDauerfreigabeRepository
{
private const string Spalten =
"Id, AgentId, Tool, Operation, ErteiltVon, ErteiltAm, AnlassId, WiderrufenAm, WiderrufenVon";
public async Task<Dauerfreigabe> ErteilenAsync(Dauerfreigabe freigabe, CancellationToken ct)
{
var op = Werkzeugrechte.Normalisiere(freigabe.Operation);
if (await FindeAsync(freigabe.AgentId, freigabe.Tool, op, ct) is { } vorhanden)
return vorhanden;
var id = await storage.WriteAsync(async conn =>
{
using var cmd = conn.CreateCommand();
cmd.CommandText = """
INSERT INTO Dauerfreigaben (AgentId, Tool, Operation, ErteiltVon, ErteiltAm, AnlassId)
VALUES (@agent, @tool, @op, @von, @am, @anlass);
SELECT last_insert_rowid();
""";
cmd.Parameters.AddWithValue("@agent", freigabe.AgentId);
cmd.Parameters.AddWithValue("@tool", freigabe.Tool);
cmd.Parameters.AddWithValue("@op", op);
cmd.Parameters.AddWithValue("@von", freigabe.ErteiltVon);
cmd.Parameters.AddWithValue("@am", Format(freigabe.ErteiltAm == default ? DateTime.UtcNow : freigabe.ErteiltAm));
cmd.Parameters.AddWithValue("@anlass", (object?)freigabe.AnlassId ?? DBNull.Value);
return Convert.ToInt64(await cmd.ExecuteScalarAsync(ct));
}, ct);
return (await LiesAsync("WHERE Id = @id", ct, ("@id", id))).Single();
}
public async Task<Dauerfreigabe?> FindeAsync(string agentId, string tool, string operation, CancellationToken ct)
=> (await LiesAsync(
"WHERE AgentId = @agent AND Tool = @tool COLLATE NOCASE AND Operation = @op AND WiderrufenAm IS NULL LIMIT 1",
ct, ("@agent", agentId), ("@tool", tool), ("@op", Werkzeugrechte.Normalisiere(operation))))
.FirstOrDefault();
public Task<IReadOnlyList<Dauerfreigabe>> ListAsync(bool mitWiderrufenen, CancellationToken ct)
=> LiesAsync(mitWiderrufenen ? "ORDER BY Id DESC" : "WHERE WiderrufenAm IS NULL ORDER BY Id DESC", ct);
public Task<bool> WiderrufenAsync(long id, string von, DateTime jetzt, CancellationToken ct)
=> storage.WriteAsync(async conn =>
{
using var cmd = conn.CreateCommand();
cmd.CommandText = """
UPDATE Dauerfreigaben SET WiderrufenAm = @am, WiderrufenVon = @von
WHERE Id = @id AND WiderrufenAm IS NULL
""";
cmd.Parameters.AddWithValue("@am", Format(jetzt));
cmd.Parameters.AddWithValue("@von", von);
cmd.Parameters.AddWithValue("@id", id);
return await cmd.ExecuteNonQueryAsync(ct) > 0;
}, ct);
private async Task<IReadOnlyList<Dauerfreigabe>> LiesAsync(
string bedingung, CancellationToken ct, params (string Name, object Wert)[] parameter)
{
await using var conn = await storage.OpenConnectionAsync(ct);
using var cmd = conn.CreateCommand();
cmd.CommandText = $"SELECT {Spalten} FROM Dauerfreigaben {bedingung}";
foreach (var (name, wert) in parameter)
cmd.Parameters.AddWithValue(name, wert);
var liste = new List<Dauerfreigabe>();
await using var reader = await cmd.ExecuteReaderAsync(ct);
while (await reader.ReadAsync(ct))
liste.Add(Lies(reader));
return liste;
}
private static Dauerfreigabe Lies(SqliteDataReader r) => new()
{
Id = r.GetInt64(0),
AgentId = r.GetString(1),
Tool = r.GetString(2),
Operation = r.GetString(3),
ErteiltVon = r.GetString(4),
ErteiltAm = Parse(r.GetString(5)),
AnlassId = r.IsDBNull(6) ? null : r.GetInt64(6),
WiderrufenAm = r.IsDBNull(7) ? null : Parse(r.GetString(7)),
WiderrufenVon = r.IsDBNull(8) ? null : r.GetString(8)
};
private static string Format(DateTime value) => value.ToUniversalTime().ToString("O", CultureInfo.InvariantCulture);
private static DateTime Parse(string value)
=> DateTime.Parse(value, CultureInfo.InvariantCulture, DateTimeStyles.RoundtripKind).ToUniversalTime();
}
@@ -122,6 +122,22 @@ public sealed class SqliteStagingRepository : IStagingRepository
return results;
}
public async Task<int> CountExecutedAsync(string agentId, string tool, string? action, DateTime since, CancellationToken ct)
{
await using var conn = await _storage.OpenConnectionAsync(ct);
using var cmd = conn.CreateCommand();
cmd.CommandText = """
SELECT COUNT(*) FROM StagedCalls
WHERE Status = 'Executed' AND AgentId = @agentId AND Tool = @tool
AND COALESCE(Action, '') = @action COLLATE NOCASE AND DecidedAt >= @since
""";
cmd.Parameters.AddWithValue("@agentId", agentId);
cmd.Parameters.AddWithValue("@tool", tool);
cmd.Parameters.AddWithValue("@action", action ?? "");
cmd.Parameters.AddWithValue("@since", Format(since));
return Convert.ToInt32(await cmd.ExecuteScalarAsync(ct));
}
public Task<int> ExpirePendingAsync(DateTime createdBefore, DateTime now, CancellationToken ct)
=> _storage.WriteAsync(async conn =>
{
+29 -8
View File
@@ -37,13 +37,17 @@ public sealed class StagingGate
private readonly StagingPolicy _policy;
private readonly IStagingRepository _repo;
private readonly IDauerfreigabeRepository? _dauer;
private readonly TimeSpan _verfall;
private readonly TimeProvider _clock;
public StagingGate(StagingPolicy policy, IStagingRepository repo, TimeSpan? verfall = null, TimeProvider? clock = null)
public StagingGate(
StagingPolicy policy, IStagingRepository repo, TimeSpan? verfall = null, TimeProvider? clock = null,
IDauerfreigabeRepository? dauerfreigaben = null)
{
_policy = policy;
_repo = repo;
_dauer = dauerfreigaben;
_verfall = verfall ?? StandardVerfall;
_clock = clock ?? TimeProvider.System;
}
@@ -60,13 +64,30 @@ public sealed class StagingGate
/// (IAgentTool.StagingAktion). Ohne ihn gilt das <c>action</c>-Argument - das aber waehlt
/// das Modell frei (#609).
/// </param>
/// <param name="rechte">#641: <c>erlaubt</c>/<c>verboten</c> aus der Werkzeugkonfiguration des Agenten.</param>
/// <param name="vorgabe">#641: Die Einstufung, die das Werkzeug selbst vorgibt.</param>
public async Task<StagingInterception> InterceptAsync(
string agentId, string instanceId, string runId,
string tool, string argumentsJson, CancellationToken ct, string? action = null)
string tool, string argumentsJson, CancellationToken ct, string? action = null,
Werkzeugrechte? rechte = null, StagingDecision? vorgabe = null)
{
action ??= ExtractAction(argumentsJson);
rechte ??= Werkzeugrechte.Keine;
switch (_policy.Decide(tool, action))
var einstufung = FreigabeRegeln.Einstufen(tool, action, rechte, vorgabe, _policy);
// Eine Dauerfreigabe hebt nur auf, was sonst vorgelegt wuerde - nie ein Verbot und
// nie eine Aenderung an Agenten.
if (einstufung.Entscheidung == StagingDecision.Approve
&& _dauer is not null
&& !string.IsNullOrWhiteSpace(action)
&& !FreigabeRegeln.IstImmerFreigabe(tool, action)
&& await _dauer.FindeAsync(agentId, tool, action, ct) is not null)
{
einstufung = FreigabeRegeln.Einstufen(tool, action, rechte, vorgabe, _policy, dauerfreigabe: true);
}
switch (einstufung.Entscheidung)
{
case StagingDecision.Auto:
return StagingInterception.Proceed;
@@ -74,7 +95,7 @@ public sealed class StagingGate
case StagingDecision.Deny:
return new StagingInterception(
StagingOutcome.Denied,
$"Aktion '{Label(tool, action)}' ist gesperrt (Policy: deny) und wurde nicht ausgeführt.",
$"Aktion '{Label(tool, action)}' ist gesperrt und wurde nicht ausgeführt. {einstufung.Grund}",
0);
case StagingDecision.Approve:
@@ -89,7 +110,7 @@ public sealed class StagingGate
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. " +
"Lege ihn nicht noch einmal vor. " +
"Fahre mit anderer Arbeit fort oder schließe ab.",
vorhanden.Id);
}
@@ -107,9 +128,9 @@ public sealed class StagingGate
return new StagingInterception(
StagingOutcome.Staged,
$"Zur Freigabe vorgelegt (#{id}): '{Label(tool, action)}'. " +
"Die Aktion wird erst nach menschlicher Freigabe ausgeführt; du wirst danach " +
"mit dem Ergebnis geweckt. Fahre mit anderer Arbeit fort oder schließe ab.",
$"Zur Freigabe vorgelegt (#{id}): '{Label(tool, action)}'. {einstufung.Grund} " +
"Die Aktion wird erst nach menschlicher Freigabe ausgeführt. Scheitert sie oder wird sie " +
"abgelehnt, wirst du geweckt. Fahre mit anderer Arbeit fort oder schließe ab.",
id);
default:
@@ -107,4 +107,10 @@ public interface IStagingRepository
/// auf <see cref="StagingStatus.Expired"/>. Gibt die Anzahl zurueck.
/// </summary>
Task<int> ExpirePendingAsync(DateTime createdBefore, DateTime now, CancellationToken ct);
/// <summary>
/// Wie oft dieselbe Operation dieses Agenten seit <paramref name="since"/> freigegeben und
/// ausgefuehrt wurde (#641) - Grundlage fuer den Vorschlag "Gleichartiges kuenftig erlauben".
/// </summary>
Task<int> CountExecutedAsync(string agentId, string tool, string? action, DateTime since, CancellationToken ct);
}
+11 -5
View File
@@ -43,14 +43,20 @@ public sealed class StagingPolicy
{
["Mail.send"] = StagingDecision.Approve,
["Telegram.send_message"] = StagingDecision.Approve,
// #641: Der Schluessel ist die SQL-Operation (IAgentTool.StagingAktion). Database stuft
// zusaetzlich selbst ein (IAgentTool.Vorgabe: alles ausser select vorlegen); die
// Eintraege hier halten dieselbe Linie, falls ein Aufruf ohne Vorgabe ankommt.
["Database.insert"] = StagingDecision.Approve,
["Database.upsert"] = StagingDecision.Approve,
["Database.update"] = StagingDecision.Approve,
["Database.delete"] = StagingDecision.Approve,
["Database.replace"] = StagingDecision.Approve,
["Database.create"] = StagingDecision.Approve,
["Database.alter"] = StagingDecision.Approve,
["Database.drop"] = StagingDecision.Approve,
["Database.truncate"] = 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,
+174 -21
View File
@@ -17,9 +17,21 @@ public readonly record struct StagingResult(StagingResultKind Kind, string Messa
/// Die Freigabe-/Ablehnungs-Seite von A2 — die API, die die Review-Oberfläche aufruft.
///
/// Kein pausierter Lauf: Bei Freigabe wird der <b>eingefrorene</b> Aufruf direkt
/// ausgeführt (Plan-Freeze), das Ergebnis festgehalten und der Agent über einen
/// Folge-Task (A1) mit dem Ergebnis geweckt. Jede Entscheidung wird als Approval-Record
/// ins Audit-Log (A3) geschrieben.
/// ausgeführt (Plan-Freeze) und das Ergebnis festgehalten. Jede Entscheidung wird als
/// Approval-Record ins Audit-Log (A3) geschrieben.
///
/// <para><b>Wecken (#643).</b> Früher legte jede Entscheidung eine eigene Aufgabe
/// <c>@agent</c> an, die den Chatverlauf fortführte: 110 Entscheidungen, 110 Läufe, ein
/// Prompt von 58.000 Tokens und eine bis Mitternacht gesperrte Instanz. Jetzt gilt:</para>
/// <list type="bullet">
/// <item>Geweckt wird nur, wenn es etwas zu tun gibt — nach einer Ablehnung oder einer
/// gescheiterten Ausführung. Eine erfolgreich ausgeführte Freigabe weckt nicht.</item>
/// <item>Alle Entscheidungen zu einem Agenten landen in <b>einer</b> Sammelaufgabe, die
/// erst fällig wird, wenn eine Weile (<see cref="StandardBuendelFenster"/>) nichts mehr
/// dazukam.</item>
/// <item>Der Agent wird frisch geweckt (<c>@new:agent</c>): Der Vorschlag ist eingefroren,
/// den alten Verlauf braucht er dafür nicht.</item>
/// </list>
/// </summary>
public sealed class StagingService
{
@@ -28,19 +40,48 @@ public sealed class StagingService
private readonly TaskboardService _board;
private readonly IAuditRepository? _audit;
private readonly ILogger _logger;
private readonly TimeSpan _fenster;
private readonly TimeProvider _clock;
private readonly IDauerfreigabeRepository? _dauer;
/// <summary>
/// Ab so vielen gleichartigen Freigaben in <see cref="VorschlagsZeitraum"/> schlägt die
/// Oberfläche vor, sie dauerhaft zu erlauben (#641). Übernehmen muss der Mensch.
/// </summary>
public const int VorschlagAb = 3;
public static readonly TimeSpan VorschlagsZeitraum = TimeSpan.FromDays(14);
/// <summary>Ruhezeit nach der letzten Entscheidung, bevor die Sammelaufgabe fällig wird.</summary>
public static readonly TimeSpan StandardBuendelFenster = TimeSpan.FromMinutes(5);
/// <summary>Titel der Sammelaufgabe — an ihm wird eine noch offene wiedererkannt.</summary>
public const string SammelTitel = "Freigabe-Ergebnisse";
/// <summary>Ab so vielen Einträgen wird nur noch die Kurzzeile angehängt.</summary>
private const int AusfuehrlicheEintraege = 20;
/// <summary>Anlegen und Ergänzen der Sammelaufgabe dürfen sich nicht überholen.</summary>
private readonly SemaphoreSlim _sammelSperre = new(1, 1);
public StagingService(
IStagingRepository repo,
IFrozenCallExecutor executor,
TaskboardService board,
ILoggerFactory loggerFactory,
IAuditRepository? audit = null)
IAuditRepository? audit = null,
TimeSpan? buendelFenster = null,
TimeProvider? clock = null,
IDauerfreigabeRepository? dauerfreigaben = null)
{
_repo = repo;
_executor = executor;
_board = board;
_audit = audit;
_logger = loggerFactory.CreateLogger("ClawdDotNet.Core.Staging");
_fenster = buendelFenster ?? StandardBuendelFenster;
_clock = clock ?? TimeProvider.System;
_dauer = dauerfreigaben;
}
public async Task<IReadOnlyList<StagedCall>> ListPendingAsync(CancellationToken ct)
@@ -53,7 +94,44 @@ public sealed class StagingService
public Task<StagedCall?> GetAsync(long id, CancellationToken ct) => _repo.GetAsync(id, ct);
public Task<int> CountPendingAsync(CancellationToken ct) => _repo.CountPendingAsync(ct);
// ─── Dauerfreigaben (#641) ───
public bool KenntDauerfreigaben => _dauer is not null;
/// <summary>Kann für diesen Vorschlag „Gleichartiges künftig erlauben" angeboten werden?</summary>
public static bool IstDauerhaftErlaubbar(StagedCall call)
=> !string.IsNullOrWhiteSpace(call.Action) && !FreigabeRegeln.IstImmerFreigabe(call.Tool, call.Action);
/// <summary>
/// Wie oft dieselbe Operation dieses Agenten zuletzt schon freigegeben wurde — ab
/// <see cref="VorschlagAb"/> schlägt die Oberfläche die Dauerfreigabe vor.
/// </summary>
public async Task<int> BisherFreigegebenAsync(StagedCall call, CancellationToken ct)
=> IstDauerhaftErlaubbar(call)
? await _repo.CountExecutedAsync(call.AgentId, call.Tool, call.Action,
_clock.GetUtcNow().UtcDateTime - VorschlagsZeitraum, ct)
: 0;
public Task<IReadOnlyList<Dauerfreigabe>> ListDauerfreigabenAsync(bool mitWiderrufenen, CancellationToken ct)
=> _dauer?.ListAsync(mitWiderrufenen, ct) ?? Task.FromResult<IReadOnlyList<Dauerfreigabe>>([]);
public async Task<bool> WiderrufenAsync(long id, string von, CancellationToken ct)
{
if (_dauer is null || !await _dauer.WiderrufenAsync(id, von, _clock.GetUtcNow().UtcDateTime, ct))
return false;
_logger.LogInformation("Dauerfreigabe #{Id} widerrufen von {Von}", id, von);
return true;
}
public async Task<StagingResult> ApproveAsync(long id, string decidedBy, CancellationToken ct)
=> await ApproveAsync(id, decidedBy, gleichartigErlauben: false, ct);
/// <param name="gleichartigErlauben">
/// #641: Dieselbe Operation dieses Agenten künftig ohne Rückfrage zulassen. Wirkt nicht bei
/// Änderungen an Agenten — die bleiben einzeln freigabepflichtig.
/// </param>
public async Task<StagingResult> ApproveAsync(long id, string decidedBy, bool gleichartigErlauben, CancellationToken ct)
{
var call = await _repo.GetAsync(id, ct);
if (call is null) return StagingResult.NotFound;
@@ -86,18 +164,31 @@ public sealed class StagingService
await _repo.FinalizeAsync(id, final, Cap(result, 500), DateTime.UtcNow, ct);
var ausgefuehrt = final == StagingStatus.Executed;
if (gleichartigErlauben && _dauer is not null && IstDauerhaftErlaubbar(call))
{
var dauer = await _dauer.ErteilenAsync(new Dauerfreigabe
{
AgentId = call.AgentId,
Tool = call.Tool,
Operation = call.Action!,
ErteiltVon = decidedBy,
ErteiltAm = _clock.GetUtcNow().UtcDateTime,
AnlassId = call.Id
}, ct);
await RecordAuditAsync(call, decidedBy, AuditStatus.Ok,
$"Dauerfreigabe #{dauer.Id} erteilt von {decidedBy}: {Label(call)} für {call.AgentId}", ct);
}
await RecordAuditAsync(call, decidedBy, ausgefuehrt ? AuditStatus.Ok : AuditStatus.Error,
ausgefuehrt
? $"Freigegeben von {decidedBy}"
: $"Freigegeben von {decidedBy}, Ausführung gescheitert: {result}", ct);
// #193: Das Ergebnis stammt aus dem Werkzeug - gerahmt, auch wenn es als Auftrag ankommt.
await WakeAgentAsync(call,
$"Freigabe-Ergebnis: {call.Tool}",
(ausgefuehrt
? $"[Freigabe] Deine vorgelegte Aktion '{Label(call)}' wurde freigegeben und ausgeführt.\n\n"
: $"[Freigabe] Deine vorgelegte Aktion '{Label(call)}' wurde freigegeben, die Ausführung ist aber gescheitert.\n\n") +
$"Ergebnis:\n{Engine.UntrustedContent.Rahmen(call.Tool, result)}\n\nSetze deine Arbeit fort.", ct);
// #643: Nur ein Fehlschlag gibt dem Agenten etwas zu tun. Ein Erfolg reist mit,
// wenn ohnehin eine Sammelaufgabe offen ist.
await SammelnAsync(call,
ausgefuehrt ? "freigegeben und ausgeführt" : "freigegeben, die Ausführung ist aber gescheitert",
$"Ergebnis: {result}", weckt: !ausgefuehrt, ct);
return new StagingResult(
final == StagingStatus.Executed ? StagingResultKind.Executed : StagingResultKind.Failed, result);
@@ -112,33 +203,95 @@ public sealed class StagingService
return StagingResult.AlreadyDecided;
await RecordAuditAsync(call, decidedBy, AuditStatus.Denied, $"Abgelehnt von {decidedBy}: {reason}", ct);
await WakeAgentAsync(call,
$"Freigabe abgelehnt: {call.Tool}",
$"[Ablehnung] Deine vorgelegte Aktion '{Label(call)}' wurde abgelehnt.\n\n" +
$"Grund: {reason}\n\nFühre sie nicht erneut ohne Rücksprache aus.", ct);
await SammelnAsync(call, "abgelehnt", $"Grund: {reason}", weckt: true, ct);
return new StagingResult(StagingResultKind.Rejected, reason);
}
// ─── Hilfsfunktionen ───
private async Task WakeAgentAsync(StagedCall call, string title, string body, CancellationToken ct)
/// <summary>
/// Trägt eine Entscheidung in die offene Sammelaufgabe des Agenten ein und schiebt ihren
/// Termin um das Bündelfenster nach hinten. Gibt es keine offene, entsteht eine — aber
/// nur, wenn die Entscheidung den Agenten wecken soll.
/// </summary>
private async Task SammelnAsync(StagedCall call, string ausgang, string inhalt, bool weckt, CancellationToken ct)
{
var assignee = $"{TaskAssignee.New}:{call.AgentId}";
var termin = new TaskWhen
{
Kind = TaskWhenKind.At,
Value = (_clock.GetUtcNow().UtcDateTime + _fenster).ToString("yyyy-MM-ddTHH:mm:ssZ"),
TimeZone = "UTC"
};
await _sammelSperre.WaitAsync(ct);
try
{
var offen = (await _board.ListAsync(
new TaskQuery { Status = TaskItemStatus.Todo, Assignee = assignee, Limit = 50 }, ct))
.FirstOrDefault(t => t.Title == SammelTitel && t.LastOccurrence is null && t.ClaimToken is null);
if (offen is not null)
{
await _board.UpdateAsync(offen.Id, t => t with
{
Body = t.Body.TrimEnd() + "\n\n" + Eintrag(call, ausgang, inhalt, Anzahl(t.Body)),
When = termin
}, ct);
return;
}
if (!weckt)
return;
await _board.CreateAsync(new TaskItem
{
Title = title,
Body = body,
Title = SammelTitel,
Body = SammelKopf + "\n\n" + Eintrag(call, ausgang, inhalt, 0),
Status = TaskItemStatus.Todo,
Assignee = "@" + call.AgentId,
Assignee = assignee,
When = termin,
Type = TaskItemType.Work
}, ct);
}
catch (Exception ex)
catch (Exception ex) when (ex is not OperationCanceledException)
{
_logger.LogError(ex, "Folge-Task für Vorschlag #{Id} konnte nicht angelegt werden", call.Id);
_logger.LogError(ex, "Sammelaufgabe für Vorschlag #{Id} konnte nicht geschrieben werden", call.Id);
}
finally
{
_sammelSperre.Release();
}
}
internal const string SammelKopf =
"[Freigabe-Ergebnisse] Ein Mensch hat über Aktionen entschieden, die du zur Freigabe "
+ "vorgelegt hattest. Freigegebene Aktionen wurden genau so ausgeführt, wie du sie vorgelegt "
+ "hast, abgelehnte gar nicht.\n"
+ "- Gescheitert: Prüfe die Ursache. Liegt sie bei dir (falscher Name, falsche Spalte, "
+ "fehlende Datei), lege die korrigierte Aktion einmal neu vor.\n"
+ "- Abgelehnt: Nicht erneut vorlegen, solange sich an der Begründung nichts geändert hat.\n"
+ "- Ausgeführt: Nichts zu tun.\n"
+ "Ist nichts zu tun, schließe sofort ab.";
private static int Anzahl(string body)
=> body.Split("\n### #").Length - 1;
/// <summary>
/// #193: Vorschlag und Ergebnis stammen aus Modell und Werkzeug - gerahmt, auch wenn sie
/// wie ein Auftrag klingen. Nach <see cref="AusfuehrlicheEintraege"/> Einträgen nur noch
/// die Kurzzeile, damit auch eine lange Entscheidungsrunde ein kleiner Lauf bleibt.
/// </summary>
private static string Eintrag(StagedCall call, string ausgang, string inhalt, int bisher)
{
var kopf = $"### #{call.Id} {Label(call)}: {ausgang}";
if (bisher >= AusfuehrlicheEintraege)
return kopf;
var vorschlag = string.IsNullOrWhiteSpace(call.Proposal) ? call.ArgumentsJson : call.Proposal;
return kopf + "\n" + Engine.UntrustedContent.Rahmen(call.Tool,
$"Vorschlag: {Cap(vorschlag, 300)}\n{Cap(inhalt, 400)}");
}
private async Task RecordAuditAsync(
@@ -0,0 +1,146 @@
using System.Collections;
using System.Text.Json;
namespace ClawdDotNet.Core.Staging;
/// <summary>
/// Was ein Agent mit einem Werkzeug darf (#641). Steht in der Werkzeugkonfiguration des
/// Agenten — dort, wo auch Verbindung, Tabellen und Verzeichnisse stehen —, nicht in einer
/// globalen Liste.
///
/// <list type="bullet">
/// <item><c>erlaubt</c>: Operationen, die ohne Rückfrage laufen. Ist die Liste gesetzt,
/// wird alles andere einzeln zur Freigabe vorgelegt — nicht stillschweigend
/// verweigert. Darf ein Agent SELECT, INSERT und UPDATE und braucht einmal ein ALTER,
/// entscheidet der Mensch über genau dieses eine ALTER.</item>
/// <item><c>verboten</c>: Operationen, die gar nicht erst vorgelegt werden. Für Agenten,
/// deren Freigabeliste sonst ein Einfallstor wäre (nur lesende Agenten, die Webinhalte
/// verarbeiten).</item>
/// </list>
///
/// <para>Ohne <c>erlaubt</c> gilt, was das Werkzeug selbst vorgibt, sonst die
/// Standardregel — das Verhalten vor #641.</para>
/// </summary>
public sealed record Werkzeugrechte(IReadOnlySet<string>? Erlaubt, IReadOnlySet<string> Verboten)
{
public const string ErlaubtSchluessel = "erlaubt";
public const string VerbotenSchluessel = "verboten";
/// <summary>Steht in <c>erlaubt</c> für „alles, was das Werkzeug kann".</summary>
public const string Alles = "*";
public static Werkzeugrechte Keine { get; } = new(null, new HashSet<string>());
public bool IstKonfiguriert => Erlaubt is not null;
public bool IstErlaubt(string operation)
=> Erlaubt is { } erlaubt && (erlaubt.Contains(Alles) || erlaubt.Contains(Normalisiere(operation)));
public bool IstVerboten(string operation) => Verboten.Contains(Normalisiere(operation));
public static Werkzeugrechte Aus(IReadOnlyDictionary<string, object?>? config)
{
if (config is null)
return Keine;
return new Werkzeugrechte(
config.TryGetValue(ErlaubtSchluessel, out var erlaubt) ? Liste(erlaubt) : null,
config.TryGetValue(VerbotenSchluessel, out var verboten) ? Liste(verboten) ?? Keine.Verboten : Keine.Verboten);
}
public static string Normalisiere(string operation) => operation.Trim().ToLowerInvariant();
/// <summary>
/// Liest eine Liste so, wie sie in der Konfiguration ankommen kann: als JSON-Array
/// (aus der Datei), als Aufzählung (aus der Oberfläche) oder als Komma-Text.
/// </summary>
internal static IReadOnlySet<string>? Liste(object? wert)
{
IEnumerable<string?>? eintraege = wert switch
{
null => null,
JsonElement { ValueKind: JsonValueKind.Array } je => je.EnumerateArray()
.Select(e => e.ValueKind == JsonValueKind.String ? e.GetString() : e.ToString()),
JsonElement { ValueKind: JsonValueKind.String } je => je.GetString()?.Split(','),
JsonElement => null,
string s => s.Split(','),
IEnumerable e => e.Cast<object?>().Select(o => o?.ToString()),
_ => null
};
return eintraege?
.Where(e => !string.IsNullOrWhiteSpace(e))
.Select(e => Normalisiere(e!))
.ToHashSet(StringComparer.Ordinal);
}
}
/// <summary>Wie ein Aufruf eingestuft wurde, und warum — der Grund geht an Agent und Mensch.</summary>
public readonly record struct Freigabeeinstufung(StagingDecision Entscheidung, string Grund);
/// <summary>
/// Die Reihenfolge der Freigabeentscheidung (#641). Rein und ohne Zustand.
///
/// <list type="number">
/// <item>Agenten verändern (Identität, Soul, neuer Agent) — immer Einzelfreigabe. Weder
/// Werkzeugkonfiguration noch Dauerfreigabe heben das auf.</item>
/// <item><c>verboten</c> in der Werkzeugkonfiguration — abgelehnt.</item>
/// <item><c>erlaubt</c> in der Werkzeugkonfiguration — läuft; alles andere wird einzeln
/// vorgelegt (außer eine Dauerfreigabe deckt es).</item>
/// <item>Dauerfreigabe („Gleichartiges künftig erlauben") — läuft.</item>
/// <item>Vorgabe des Werkzeugs, sonst die Standardregel.</item>
/// </list>
/// </summary>
public static class FreigabeRegeln
{
/// <summary>Was einen Agenten verändert. Bleibt ausnahmslos freigabepflichtig.</summary>
public static IReadOnlySet<string> ImmerFreigabe { get; } = new HashSet<string>(StringComparer.OrdinalIgnoreCase)
{
"AgentEditor.update_identity",
"AgentEditor.update_soul",
"AgentEditor.create_agent"
};
public static bool IstImmerFreigabe(string tool, string? operation)
=> !string.IsNullOrWhiteSpace(operation) && ImmerFreigabe.Contains($"{tool}.{operation}");
public static Freigabeeinstufung Einstufen(
string tool, string? operation, Werkzeugrechte rechte,
StagingDecision? werkzeugVorgabe, StagingPolicy policy, bool dauerfreigabe = false)
{
if (IstImmerFreigabe(tool, operation))
return new(StagingDecision.Approve, "Änderungen an Agenten brauchen immer eine Freigabe.");
if (string.IsNullOrWhiteSpace(operation))
return Nach(policy.Decide(tool, null), "der Standardregel");
var op = Werkzeugrechte.Normalisiere(operation);
if (rechte.IstVerboten(op))
return new(StagingDecision.Deny,
$"'{op}' ist für diesen Agenten verboten (Werkzeugkonfiguration '{Werkzeugrechte.VerbotenSchluessel}').");
if (rechte.IstErlaubt(op))
return new(StagingDecision.Auto, $"'{op}' ist erlaubt (Werkzeugkonfiguration).");
if (dauerfreigabe)
return new(StagingDecision.Auto, $"'{op}' ist dauerhaft freigegeben.");
if (rechte.Erlaubt is { } erlaubt)
return new(StagingDecision.Approve,
$"'{op}' gehört nicht zu den erlaubten Operationen ({string.Join(", ", erlaubt.Order())}) " +
"und wird einzeln zur Freigabe vorgelegt.");
if (werkzeugVorgabe is { } vorgabe)
return Nach(vorgabe, "der Vorgabe des Werkzeugs");
return Nach(policy.Decide(tool, op), "der Standardregel");
}
private static Freigabeeinstufung Nach(StagingDecision entscheidung, string quelle) => new(entscheidung, entscheidung switch
{
StagingDecision.Approve => $"Nach {quelle} freigabepflichtig.",
StagingDecision.Deny => $"Nach {quelle} gesperrt.",
_ => $"Nach {quelle} erlaubt."
});
}
@@ -271,6 +271,23 @@ public sealed class SqliteStorage
CREATE INDEX IF NOT EXISTS IX_StagedCalls_Pending
ON StagedCalls (Status, Id);
-- #641: "Gleichartiges kuenftig erlauben" - je Agent, Werkzeug und Operation.
-- Ein Widerruf setzt WiderrufenAm; geloescht wird nichts.
CREATE TABLE IF NOT EXISTS Dauerfreigaben (
Id INTEGER PRIMARY KEY AUTOINCREMENT,
AgentId TEXT NOT NULL,
Tool TEXT NOT NULL,
Operation TEXT NOT NULL,
ErteiltVon TEXT NOT NULL,
ErteiltAm TEXT NOT NULL,
AnlassId INTEGER NULL,
WiderrufenAm TEXT NULL,
WiderrufenVon TEXT NULL
);
CREATE INDEX IF NOT EXISTS IX_Dauerfreigaben_Agent
ON Dauerfreigaben (AgentId, Tool, Operation);
""";
cmd.ExecuteNonQuery();
@@ -49,7 +49,7 @@ public sealed class EngineTaskDispatcher : ITaskDispatcher
_clock = clock ?? TimeProvider.System;
}
public async Task<bool> DispatchAsync(TaskItem task, CancellationToken ct)
public async Task<TaskLauf> DispatchAsync(TaskItem task, CancellationToken ct)
{
// Poll-Tasks (tool_job) ticken ein Tool statt einen Agenten direkt zu starten.
if (task.Type == TaskItemType.ToolJob)
@@ -70,7 +70,7 @@ public sealed class EngineTaskDispatcher : ITaskDispatcher
_logger.LogWarning(
"Aufgabe {TaskId}: Assignee '{Assignee}' nicht auflösbar (frischer Lauf braucht einen eindeutigen Agenten)",
task.Id, task.Assignee);
return false;
return TaskLauf.Gescheitert;
}
result = await _engine.RunAsync(
config, message, _instanceId, ct, source: ChatSource.Task, taskId: task.Id);
@@ -84,7 +84,7 @@ public sealed class EngineTaskDispatcher : ITaskDispatcher
{
_logger.LogWarning(
"Aufgabe {TaskId}: Agent '{AgentId}' nicht gefunden", task.Id, agentId);
return false;
return TaskLauf.Gescheitert;
}
result = await _engine.ChatAsync(
config, message, _instanceId, ct, source: ChatSource.Task, taskId: task.Id);
@@ -92,10 +92,15 @@ public sealed class EngineTaskDispatcher : ITaskDispatcher
}
default:
// @human wird vom Scanner gar nicht erst angestoßen.
return false;
return TaskLauf.Gescheitert;
}
return result.Status == AgentRunStatus.Completed;
return result.Status switch
{
AgentRunStatus.Completed => TaskLauf.Erledigt,
AgentRunStatus.BudgetExceeded or AgentRunStatus.ShuttingDown => TaskLauf.NichtGelaufen,
_ => TaskLauf.Gescheitert
};
}
/// <summary>
@@ -103,14 +108,14 @@ public sealed class EngineTaskDispatcher : ITaskDispatcher
/// wenn der Tick etwas meldet. Der Tick selbst gilt als erfolgreich, auch wenn nichts
/// zu tun war — ein Poll ohne Fund ist kein Fehler.
/// </summary>
private async Task<bool> DispatchToolJobAsync(TaskItem task, CancellationToken ct)
private async Task<TaskLauf> DispatchToolJobAsync(TaskItem task, CancellationToken ct)
{
if (_toolRegistry.Get(task.ToolName) is not IToolJobProvider provider)
{
_logger.LogWarning(
"Tool-Job {TaskId}: Tool '{Tool}' ist kein IToolJobProvider oder nicht registriert",
task.Id, task.ToolName);
return false;
return TaskLauf.Gescheitert;
}
// Zielagent (zum Wecken) und dessen Tool-Konfiguration/Workspace.
@@ -124,14 +129,14 @@ public sealed class EngineTaskDispatcher : ITaskDispatcher
task.JobTypeId, toolConfig, _stateStore, _logger, ct, agentId, config?.WorkspacePath);
if (!result.ShouldWakeAgent || string.IsNullOrWhiteSpace(result.WakeMessage))
return true; // Poll lief, nichts zu wecken
return TaskLauf.Erledigt; // Poll lief, nichts zu wecken
if (config is null)
{
_logger.LogWarning(
"Tool-Job {TaskId} wollte Agent '{AgentId}' wecken, der aber nicht gefunden wurde",
task.Id, agentId);
return true;
return TaskLauf.Erledigt;
}
// Der Provider entscheidet je Tick, ob mit Kontext (ChatAsync) oder zustandslos.
@@ -140,7 +145,7 @@ public sealed class EngineTaskDispatcher : ITaskDispatcher
else
await _engine.RunAsync(config, result.WakeMessage, _instanceId, ct, source: ChatSource.Job, taskId: task.Id);
return true;
return TaskLauf.Erledigt;
}
private static AgentConfig? ResolveFreshAgent(IReadOnlyList<AgentConfig> agents, string agentId)
@@ -44,6 +44,14 @@ public interface ITaskRepository
string id, string claimToken, TaskItemStatus finalStatus,
DateTime now, CancellationToken ct);
/// <summary>
/// Gibt einen Claim zurück, als hätte es ihn nicht gegeben: Status <c>todo</c> und der
/// Marker auf <paramref name="previousOccurrence"/> (#643). Für Läufe, die gar nicht
/// stattfanden. Nur wirksam, solange der Aufrufer den Claim hält.
/// </summary>
Task<bool> ReleaseClaimAsync(
string id, string claimToken, string? previousOccurrence, DateTime now, CancellationToken ct);
/// <summary>Ändert den Status direkt (Tool-Aktionen, Auto-Dispatch, Eskalation).</summary>
Task<bool> SetStatusAsync(string id, TaskItemStatus status, DateTime now, CancellationToken ct);
@@ -162,6 +162,25 @@ public sealed class SqliteTaskRepository : ITaskRepository
return await cmd.ExecuteNonQueryAsync(ct) > 0;
}, ct);
public Task<bool> ReleaseClaimAsync(
string id, string claimToken, string? previousOccurrence, DateTime now, CancellationToken ct)
=> _storage.WriteAsync(async conn =>
{
using var cmd = conn.CreateCommand();
cmd.CommandText = """
UPDATE Tasks
SET Status = 'todo', ClaimToken = NULL, ClaimedAt = NULL,
LastOccurrence = @occ, UpdatedAt = @now
WHERE Id = @id AND ClaimToken = @token
""";
cmd.Parameters.AddWithValue("@occ", (object?)previousOccurrence ?? DBNull.Value);
cmd.Parameters.AddWithValue("@now", Format(now));
cmd.Parameters.AddWithValue("@id", id);
cmd.Parameters.AddWithValue("@token", claimToken);
return await cmd.ExecuteNonQueryAsync(ct) > 0;
}, ct);
public Task<bool> SetStatusAsync(string id, TaskItemStatus status, DateTime now, CancellationToken ct)
=> _storage.WriteAsync(async conn =>
{
+51 -5
View File
@@ -2,14 +2,30 @@ using Microsoft.Extensions.Logging;
namespace ClawdDotNet.Core.Tasks;
/// <summary>Wie ein Dispatch ausging.</summary>
public enum TaskLauf
{
/// <summary>Der Lauf schloss regulär ab.</summary>
Erledigt,
/// <summary>Der Lauf fand statt und scheiterte.</summary>
Gescheitert,
/// <summary>
/// Der Lauf fand gar nicht statt — Budget erschöpft, Instanz im Herunterfahren (#643).
/// Eine einmalige Aufgabe bleibt dann fällig, statt als gelaufen liegenzubleiben.
/// </summary>
NichtGelaufen
}
/// <summary>
/// Führt eine fällige Aufgabe aus. Kapselt, wie ein Assignee zu einem Lauf wird — der
/// Scanner selbst kennt die Engine nicht und bleibt so ohne sie testbar.
/// </summary>
public interface ITaskDispatcher
{
/// <summary>Führt die Aufgabe aus. Gibt zurück, ob der Lauf regulär abschloss.</summary>
Task<bool> DispatchAsync(TaskItem task, CancellationToken ct);
/// <summary>Führt die Aufgabe aus und meldet, wie der Lauf ausging.</summary>
Task<TaskLauf> DispatchAsync(TaskItem task, CancellationToken ct);
}
/// <summary>
@@ -58,6 +74,16 @@ public sealed class TaskScanner : IAsyncDisposable
/// <summary>Wiederkehrende @agent-Aufgaben, deren wachsender Kontext bereits gemeldet wurde (#598).</summary>
private readonly HashSet<string> _warnedContext = [];
/// <summary>
/// #643: Einmalige Aufgaben, deren Lauf nicht stattfand, und ab wann sie wieder dran sind.
/// Ohne diese Pause würde eine wegen Budget abgewiesene Aufgabe jede Minute erneut
/// anlaufen. Nur im Speicher — nach einem Neustart darf sie einmal sofort versuchen.
/// </summary>
private readonly System.Collections.Concurrent.ConcurrentDictionary<string, DateTime> _zurueckgestellt = new();
/// <summary>Pause, bevor eine nicht gelaufene einmalige Aufgabe erneut versucht wird.</summary>
public static readonly TimeSpan Wiedervorlage = TimeSpan.FromMinutes(30);
private readonly CancellationTokenSource _cts = new();
private Task? _loop;
@@ -196,6 +222,9 @@ public sealed class TaskScanner : IAsyncDisposable
_logger.LogWarning("Aufgabe {TaskId} ({Title}): {Hinweis}", task.Id, task.Title, hinweis);
}
if (_zurueckgestellt.TryGetValue(task.Id, out var ab) && now < ab)
continue;
var occ = TaskSchedule.DueOccurrence(task, now);
if (occ is null)
continue;
@@ -257,10 +286,10 @@ public sealed class TaskScanner : IAsyncDisposable
private async Task ProcessCoreAsync(TaskItem task, string token, CancellationToken ct)
{
bool ok;
TaskLauf lauf;
try
{
ok = await _dispatcher.DispatchAsync(task, ct);
lauf = await _dispatcher.DispatchAsync(task, ct);
}
catch (OperationCanceledException) when (ct.IsCancellationRequested)
{
@@ -269,10 +298,27 @@ public sealed class TaskScanner : IAsyncDisposable
catch (Exception ex)
{
_logger.LogError(ex, "Dispatch für Aufgabe {TaskId} ist gescheitert", task.Id);
ok = false;
lauf = TaskLauf.Gescheitert;
}
var now = _clock.GetUtcNow().UtcDateTime;
var ok = lauf == TaskLauf.Erledigt;
// #643: Fand der Lauf einer einmaligen Aufgabe gar nicht statt, gilt ihr Termin als
// nicht verbraucht. Vorher blieb sie mit gesetztem Marker für immer auf todo liegen.
if (lauf == TaskLauf.NichtGelaufen && !task.IsRecurring)
{
if (await _repo.ReleaseClaimAsync(task.Id, token, task.LastOccurrence, now, ct))
{
_zurueckgestellt[task.Id] = now + Wiedervorlage;
_logger.LogInformation(
"Aufgabe {TaskId} lief nicht (Budget/Herunterfahren) — erneut fällig ab {Ab:HH:mm} UTC",
task.Id, now + Wiedervorlage);
}
return;
}
_zurueckgestellt.TryRemove(task.Id, out _);
// Endstatus:
// - Fehlschlag → zurück auf todo (der Marker steht schon beim Claim, derselbe Termin
+11 -1
View File
@@ -24,4 +24,14 @@ public sealed record AgentToolContext(
/// nicht von einer neuen Delegation unterscheiden. Siehe <see cref="DelegationChain"/>.
/// </summary>
DelegationChain? Delegation = null
);
)
{
/// <summary>
/// Die Freigabestelle hat diesen Aufruf geprueft und zugelassen - als erlaubte Operation,
/// per Dauerfreigabe oder als einzeln freigegebener Vorschlag (#641). Ein Werkzeug darf
/// seine eigene Rechtepruefung dann auslassen; sonst wuerde ein freigegebenes ALTER eines
/// Agenten, der nur schreiben darf, erst nach der Freigabe am Werkzeug scheitern.
/// Ohne Freigabestelle bleibt es <c>false</c>, und das Werkzeug prueft selbst.
/// </summary>
public bool FreigabeGeprueft { get; init; }
}
+13
View File
@@ -65,6 +65,19 @@ public interface IAgentTool
/// </summary>
string? StagingAktion(JsonElement input) => null;
/// <summary>
/// Die Operationen, die sich in der Werkzeugkonfiguration eines Agenten erlauben oder
/// verbieten lassen (#641) - dieselben Schluessel, die <see cref="StagingAktion"/> bzw. das
/// <c>action</c>-Argument liefern. Vorgabe: die Werte von <c>action</c> aus dem Schema.
/// </summary>
IReadOnlyList<string> Operationen => WerkzeugOperationen.AusSchema(InputSchema);
/// <summary>
/// Die Einstufung einer Operation, wenn die Werkzeugkonfiguration des Agenten kein
/// <c>erlaubt</c> setzt (#641). <c>null</c> = die Standardregel gilt.
/// </summary>
Staging.StagingDecision? Vorgabe(string operation, IReadOnlyDictionary<string, object?> config) => null;
Task<ToolResult> ExecuteAsync(
JsonElement input,
AgentToolContext context,
@@ -0,0 +1,27 @@
using System.Text.Json;
namespace ClawdDotNet.Core.Tools;
/// <summary>Hilfen rund um die Operationen eines Werkzeugs (#641).</summary>
public static class WerkzeugOperationen
{
/// <summary>Die Werte der <c>action</c>-Aufzaehlung im Eingabeschema, sonst keine.</summary>
public static IReadOnlyList<string> AusSchema(JsonElement schema)
{
if (schema.ValueKind != JsonValueKind.Object
|| !schema.TryGetProperty("properties", out var props)
|| props.ValueKind != JsonValueKind.Object
|| !props.TryGetProperty("action", out var action)
|| action.ValueKind != JsonValueKind.Object
|| !action.TryGetProperty("enum", out var werte)
|| werte.ValueKind != JsonValueKind.Array)
{
return [];
}
return werte.EnumerateArray()
.Where(w => w.ValueKind == JsonValueKind.String)
.Select(w => w.GetString()!)
.ToList();
}
}