#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:
@@ -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 =>
|
||||
{
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 =>
|
||||
{
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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; }
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user