diff --git a/src/ClawdDotNet.App/AppHost.cs b/src/ClawdDotNet.App/AppHost.cs index 09267dd..7e48181 100644 --- a/src/ClawdDotNet.App/AppHost.cs +++ b/src/ClawdDotNet.App/AppHost.cs @@ -144,6 +144,9 @@ public sealed class AppHost : IAsyncDisposable /// #197: The external MCP servers, each registered as a tool. public Core.Mcp.McpConnections? McpServers { get; private set; } + /// #726: Workflows agents propose; a human decides and imports by hand. + public Core.Automation.AutomationProposals? Automations { get; private set; } + /// Modellpreise von OpenRouter - fuer Budget und Kostenprognose (#595). Laedt im Hintergrund. public ModelPricingCatalog? Pricing { get; private init; } @@ -745,6 +748,14 @@ public sealed class AppHost : IAsyncDisposable mcpServers.Start(); host._shutdown.Add(mcpServers.DisposeAsync); + // #726: agents propose workflows; recurring tasks that do the same each time are put to them. + var automations = new Core.Automation.AutomationProposals(storage, audit, taskRepository, board, + loggerFactory.CreateLogger("ClawdDotNet.Core.Automation")); + engine.Automations = automations; + host.Automations = automations; + automations.Start(); + host._shutdown.Add(automations.DisposeAsync); + // #725: what comes in from outside through a trigger becomes a task. host.Triggers = new Core.Triggers.InboundTriggers(storage, board, audit, () => instance.Agents); diff --git a/src/ClawdDotNet.Core/Automation/AutomationProposals.cs b/src/ClawdDotNet.Core/Automation/AutomationProposals.cs new file mode 100644 index 0000000..f0ecf16 --- /dev/null +++ b/src/ClawdDotNet.Core/Automation/AutomationProposals.cs @@ -0,0 +1,393 @@ +using System.Globalization; +using System.Text; +using System.Text.Json; +using ClawdDotNet.Core.Audit; +using ClawdDotNet.Core.Storage; +using ClawdDotNet.Core.Tasks; +using Microsoft.Data.Sqlite; +using Microsoft.Extensions.Logging; + +namespace ClawdDotNet.Core.Automation; + +/// What the replaced tasks cost over the measured days — taken from the receipts, not estimated. +public sealed record MeasuredSavings(int Days, int Runs, decimal CostUsd, long Tokens) +{ + public decimal CostPerMonthUsd => Days == 0 ? 0 : Math.Round(CostUsd * 30m / Days, 2); + public int RunsPerMonth => Days == 0 ? 0 : (int)Math.Round(Runs * 30.0 / Days); +} + +/// #726: An agent's proposal, as the human decides it. +public sealed record AutomationProposal( + long Id, string AgentId, string Purpose, string Trigger, IReadOnlyList TaskIds, IReadOnlyList Services, + string Risks, string TestGuide, string WorkflowJson, string? Justification, string Summary, + IReadOnlyList Nodes, IReadOnlyList Warnings, MeasuredSavings Savings, + string Status, string? Reason, DateTime CreatedAt, DateTime? DecidedAt, string? TaskId, long? Answers); + +/// What an agent hands in. +public sealed record AutomationRequest( + string AgentId, string Purpose, string Trigger, IReadOnlyList TaskIds, IReadOnlyList Services, + string Risks, string TestGuide, string WorkflowJson, string? Justification, long? Answers, string? Argument); + +public sealed record AutomationOutcome(bool Ok, string Message, long? Id = null); + +public interface IAutomationProposals +{ + Task> FindAsync(string subject, CancellationToken ct); + + Task ProposeAsync(AutomationRequest request, CancellationToken ct); +} + +/// +/// #726: Agents propose n8n workflows with reasons; a human decides and imports by hand. +/// Agents never create workflows themselves — a workflow is a program that runs outside every +/// check here, with all of n8n's credentials. Importing by hand is the point: it makes the +/// human look at it instead of clicking "looks fine". +/// +/// Measured, not guessed. The saving is what the replaced tasks' runs cost over the +/// last days. No flood: at most +/// open proposals per agent, and a proposal for the same purpose as an open one is refused. A +/// rejected proposal keeps its reason — a new one for the same purpose must answer it. +/// +/// Looked for, not only waited for. spots recurring +/// tasks whose runs keep doing the same thing and puts them to their agent. +/// +public sealed class AutomationProposals( + SqliteStorage storage, + IAuditRepository audit, + ITaskRepository tasks, + TaskboardService? board, + ILogger logger, + TimeProvider? clock = null) : IAutomationProposals, IAsyncDisposable +{ + public const int MaxOpenPerAgent = 3; + public const int MeasureDays = 28; + + /// Runs in days from which a recurring task is looked at. + public const int CandidateMinRuns = 8; + public const int CandidateDays = 14; + + /// Share of runs with the same sequence of calls that makes a task a candidate. + public const double CandidateSameShare = 0.75; + + private static readonly JsonSerializerOptions Json = new(); + private readonly TimeProvider _clock = clock ?? TimeProvider.System; + private readonly CancellationTokenSource _cts = new(); + private Task? _loop; + + private DateTime Now => _clock.GetUtcNow().UtcDateTime; + + public async Task> FindAsync(string subject, CancellationToken ct) + => (await ListAsync(null, 500, ct)) + .Where(p => TaskSimilarity.Compare(p.Purpose, subject) != TaskTitleMatch.None + || p.Purpose.Contains(subject.Trim(), StringComparison.OrdinalIgnoreCase)) + .Take(10) + .ToList(); + + public async Task ProposeAsync(AutomationRequest r, CancellationToken ct) + { + if (string.IsNullOrWhiteSpace(r.Purpose) || string.IsNullOrWhiteSpace(r.Trigger)) + return new(false, "'purpose' and 'trigger' are required — what it is for, and what starts it."); + if (r.TaskIds.Count == 0) + return new(false, "'tasks' is required — the tasks it would replace. The saving is measured from their runs."); + if (string.IsNullOrWhiteSpace(r.Risks)) + return new(false, "'risks' is required — what can go wrong, and what the workflow sends outside."); + if (string.IsNullOrWhiteSpace(r.TestGuide)) + return new(false, "'test_guide' is required — how the human checks it after importing."); + + foreach (var id in r.TaskIds) + if (await tasks.GetAsync(id, ct) is null) + return new(false, $"There is no task '{id}'."); + + var all = await ListAsync(null, 1000, ct); + var mine = all.Where(p => p.Status == "proposed" && string.Equals(p.AgentId, r.AgentId, StringComparison.OrdinalIgnoreCase)).ToList(); + if (mine.Count >= MaxOpenPerAgent) + return new(false, $"You have {mine.Count} proposals waiting for a human ({string.Join(", ", mine.Select(m => "#" + m.Id))}). Wait for a decision first."); + + if (all.FirstOrDefault(p => p.Status is "proposed" or "accepted" && Same(p, r)) is { } open) + return new(false, $"Proposal #{open.Id} ({open.Status}) is about the same: \"{open.Purpose}\". Do not propose it again."); + if (all.FirstOrDefault(p => p.Status == "rejected" && Same(p, r)) is { } rejected + && (r.Answers != rejected.Id || string.IsNullOrWhiteSpace(r.Argument))) + return new(false, $"Proposal #{rejected.Id} for the same purpose was rejected: \"{rejected.Reason}\". " + + $"A new one must answer that: give 'answers' {rejected.Id} and an 'argument' that is not already there."); + + var review = WorkflowReview.Review(r.WorkflowJson, r.Justification); + if (!review.Ok) + return new(false, "The workflow does not pass the check:\n- " + string.Join("\n- ", review.Problems)); + + var savings = await MeasureAsync(r.TaskIds, ct); + var id2 = await storage.WriteAsync(async conn => + { + using var cmd = conn.CreateCommand(); + cmd.CommandText = """ + INSERT INTO AutomationProposals (AgentId, Purpose, Trigger, TaskIds, Services, Risks, TestGuide, WorkflowJson, + Justification, Summary, Nodes, Warnings, Savings, Status, CreatedAt, Answers, Argument) + VALUES (@agent, @purpose, @trigger, @tasks, @services, @risks, @guide, @workflow, + @justification, @summary, @nodes, @warnings, @savings, 'proposed', @at, @answers, @argument) + RETURNING Id + """; + cmd.Parameters.AddWithValue("@agent", r.AgentId); + cmd.Parameters.AddWithValue("@purpose", r.Purpose.Trim()); + cmd.Parameters.AddWithValue("@trigger", r.Trigger.Trim()); + cmd.Parameters.AddWithValue("@tasks", JsonSerializer.Serialize(r.TaskIds)); + cmd.Parameters.AddWithValue("@services", JsonSerializer.Serialize(r.Services)); + cmd.Parameters.AddWithValue("@risks", r.Risks.Trim()); + cmd.Parameters.AddWithValue("@guide", r.TestGuide.Trim()); + cmd.Parameters.AddWithValue("@workflow", r.WorkflowJson); + cmd.Parameters.AddWithValue("@justification", (object?)r.Justification?.Trim() ?? DBNull.Value); + cmd.Parameters.AddWithValue("@summary", review.Summary); + cmd.Parameters.AddWithValue("@nodes", JsonSerializer.Serialize(review.Nodes, Json)); + cmd.Parameters.AddWithValue("@warnings", JsonSerializer.Serialize(review.Warnings, Json)); + cmd.Parameters.AddWithValue("@savings", JsonSerializer.Serialize(savings, Json)); + cmd.Parameters.AddWithValue("@at", Now.ToString("O")); + cmd.Parameters.AddWithValue("@answers", (object?)r.Answers ?? DBNull.Value); + cmd.Parameters.AddWithValue("@argument", (object?)r.Argument?.Trim() ?? DBNull.Value); + return Convert.ToInt64(await cmd.ExecuteScalarAsync(ct), CultureInfo.InvariantCulture); + }, ct); + + if (board is not null) + { + var task = await board.CreateAsync(new TaskItem + { + Title = $"Automation proposal #{id2}: {r.Purpose.Trim()}", + Body = $"{r.AgentId} proposes an n8n workflow. Decide it in the approvals view; if you accept, import the JSON " + + $"into n8n by hand, check it with the test guide, and release it as an MCP tool.\n\n" + + $"Measured over {savings.Days} days: {savings.Runs} run(s), {savings.CostUsd.ToString("0.00", CultureInfo.InvariantCulture)} USD " + + $"(about {savings.CostPerMonthUsd.ToString("0.00", CultureInfo.InvariantCulture)} USD a month).\n" + + $"What it does: {review.Summary}", + Assignee = TaskAssignee.Human, + Priority = 2 + }, ct); + await storage.WriteAsync(async conn => + { + using var cmd = conn.CreateCommand(); + cmd.CommandText = "UPDATE AutomationProposals SET TaskId = @task WHERE Id = @id"; + cmd.Parameters.AddWithValue("@task", task.Id); + cmd.Parameters.AddWithValue("@id", id2); + return await cmd.ExecuteNonQueryAsync(ct); + }, ct); + } + + logger.LogInformation("Automation proposal #{Id} by {Agent}: {Purpose}", id2, r.AgentId, r.Purpose); + return new(true, $"Proposal #{id2} is with a human. Measured: {savings.RunsPerMonth} run(s) and about " + + $"{savings.CostPerMonthUsd.ToString("0.00", CultureInfo.InvariantCulture)} USD a month. " + + (review.Warnings.Count > 0 ? "Noted for the human: " + string.Join(" ", review.Warnings) : ""), id2); + } + + /// The human's decision. Accepting records it; importing stays with the human, in n8n. + public async Task DecideAsync(long id, bool accept, string reason, CancellationToken ct) + { + if ((await ListAsync("Id = @p", 1, ct, id.ToString(CultureInfo.InvariantCulture))).FirstOrDefault() is not { } proposal) + return new(false, $"There is no proposal #{id}."); + if (proposal.Status != "proposed") + return new(false, $"Proposal #{id} is decided already ({proposal.Status})."); + if (!accept && string.IsNullOrWhiteSpace(reason)) + return new(false, "A rejection needs its reason — it stays with the proposal, so the same one does not come back."); + + await storage.WriteAsync(async conn => + { + using var cmd = conn.CreateCommand(); + cmd.CommandText = "UPDATE AutomationProposals SET Status = @s, Reason = @r, DecidedAt = @at WHERE Id = @id AND Status = 'proposed'"; + cmd.Parameters.AddWithValue("@s", accept ? "accepted" : "rejected"); + cmd.Parameters.AddWithValue("@r", (reason ?? "").Trim()); + cmd.Parameters.AddWithValue("@at", Now.ToString("O")); + cmd.Parameters.AddWithValue("@id", id); + return await cmd.ExecuteNonQueryAsync(ct); + }, ct); + + if (proposal.TaskId is { } taskId && board is not null + && await board.GetAsync(taskId, ct) is { Status: not (TaskItemStatus.Done or TaskItemStatus.Canceled) }) + { + await board.AddCommentAsync(taskId, "human", accept ? "Accepted." + (string.IsNullOrWhiteSpace(reason) ? "" : " " + reason.Trim()) : "Rejected: " + reason.Trim(), ct); + await board.UpdateAsync(taskId, t => t with { Status = accept ? TaskItemStatus.Done : TaskItemStatus.Canceled }, ct); + } + return new(true, accept + ? $"Proposal #{id} accepted. Import the workflow into n8n by hand, test it, release it as an MCP tool — then switch the replaced tasks off." + : $"Proposal #{id} rejected; the reason stays with it."); + } + + public Task> ListAsync(string? status, int limit, CancellationToken ct) + => ListAsync(status is null ? null : "Status = @p", limit, ct, status); + + // ─── measuring ─── + + public async Task MeasureAsync(IReadOnlyList taskIds, CancellationToken ct) + { + var since = Now.AddDays(-MeasureDays); + var runs = 0; + decimal cost = 0; + long tokens = 0; + foreach (var id in taskIds.Distinct(StringComparer.OrdinalIgnoreCase)) + foreach (var receipt in await audit.ListReceiptsForTaskAsync(id, ct)) + if (receipt.OccurredAt >= since) + { + runs++; + cost += receipt.CostIsKnown ? receipt.CostUsd : 0; + tokens += receipt.PromptTokens + receipt.CompletionTokens; + } + return new MeasuredSavings(MeasureDays, runs, cost, tokens); + } + + // ─── candidates ─── + + /// + /// Recurring tasks whose runs keep doing the same thing — the same sequence of calls in most + /// of them — are put to their agent once a month: maybe a workflow does it without a model. + /// Returns the tasks noted in this pass. + /// + public async Task> FindCandidatesAsync(CancellationToken ct) + { + if (board is null) + return []; + var since = Now.AddDays(-CandidateDays); + var proposals = await ListAsync(null, 1000, ct); + var noted = new List(); + var perAgent = new Dictionary(StringComparer.OrdinalIgnoreCase); + + foreach (var task in await tasks.ListAsync(new TaskQuery(), ct)) + { + if (task.When is not { Kind: TaskWhenKind.Every or TaskWhenKind.Cron } || task.Status is TaskItemStatus.Canceled or TaskItemStatus.Archived) + continue; + if (TaskAssignee.AgentId(task.Assignee) is not { Length: > 0 } agent) + continue; + if (proposals.Any(p => p.Status is "proposed" or "accepted" && p.TaskIds.Contains(task.Id, StringComparer.OrdinalIgnoreCase))) + continue; + if (await NotedSinceAsync(task.Id, Now.AddDays(-30), ct) || perAgent.GetValueOrDefault(agent) >= 2) + continue; + + var receipts = (await audit.ListReceiptsForTaskAsync(task.Id, ct)).Where(r => r.OccurredAt >= since).ToList(); + if (receipts.Count < CandidateMinRuns) + continue; + + var signatures = new List(); + foreach (var receipt in receipts) + signatures.Add(Signature(await audit.ListForRunAsync(receipt.RunId, ct))); + var common = signatures.GroupBy(s => s).OrderByDescending(g => g.Count()).First(); + if (common.Key.Length == 0 || (double)common.Count() / signatures.Count < CandidateSameShare) + continue; + + var cost = receipts.Where(r => r.CostIsKnown).Sum(r => r.CostUsd); + await board.CreateAsync(new TaskItem + { + Title = $"Automation candidate: {task.Title}", + Body = $"Task {task.Id} ran {receipts.Count} times in {CandidateDays} days for {cost.ToString("0.00", CultureInfo.InvariantCulture)} USD, " + + $"and {common.Count()} of the runs made the same calls: {common.Key}.\n\n" + + "Consider whether an n8n workflow could do it without a model. If yes: Escalation automation_find (is there a proposal already?), " + + "then automation_propose with the workflow JSON, the tasks it replaces, the risks and a test guide. " + + "If no: close this task and say why — judgement, changing inputs, or decisions a workflow cannot make.", + Assignee = "@" + agent, + Priority = 2 + }, ct); + await NoteAsync(task.Id, ct); + perAgent[agent] = perAgent.GetValueOrDefault(agent) + 1; + noted.Add(task.Id); + } + + if (noted.Count > 0) + logger.LogInformation("Automation candidates put to their agents: {Tasks}", string.Join(", ", noted)); + return noted; + } + + /// The calls of a run in order, repeats folded — "FileRW.read > DirectAPI.get > FileRW.write". + internal static string Signature(IReadOnlyList entries) + { + var steps = new List(); + foreach (var e in entries.Where(e => e.Status != AuditStatus.Started && e.Tool is not ("Taskboard" or "Escalation"))) + { + var step = Inspection.RunInspector.OperationOf(e.Arguments) is { } op ? $"{e.Tool}.{op}" : e.Tool; + if (steps.Count == 0 || steps[^1] != step) + steps.Add(step); + } + return string.Join(" > ", steps); + } + + private static bool Same(AutomationProposal existing, AutomationRequest request) + => TaskSimilarity.Compare(existing.Purpose, request.Purpose) == TaskTitleMatch.Duplicate + || (existing.TaskIds.Count > 0 && existing.TaskIds.OrderBy(t => t).SequenceEqual(request.TaskIds.OrderBy(t => t), StringComparer.OrdinalIgnoreCase)); + + // ─── storage ─── + + private async Task NotedSinceAsync(string taskId, DateTime since, CancellationToken ct) + { + await using var conn = await storage.OpenConnectionAsync(ct); + using var cmd = conn.CreateCommand(); + cmd.CommandText = "SELECT COUNT(*) FROM AutomationCandidates WHERE TaskId = @t AND NotedAt >= @since"; + cmd.Parameters.AddWithValue("@t", taskId); + cmd.Parameters.AddWithValue("@since", since.ToString("O")); + return Convert.ToInt32(await cmd.ExecuteScalarAsync(ct), CultureInfo.InvariantCulture) > 0; + } + + private Task NoteAsync(string taskId, CancellationToken ct) + => storage.WriteAsync(async conn => + { + using var cmd = conn.CreateCommand(); + cmd.CommandText = "INSERT INTO AutomationCandidates (TaskId, NotedAt) VALUES (@t, @at)"; + cmd.Parameters.AddWithValue("@t", taskId); + cmd.Parameters.AddWithValue("@at", Now.ToString("O")); + return await cmd.ExecuteNonQueryAsync(ct); + }, ct); + + private async Task> ListAsync(string? where, int limit, CancellationToken ct, string? value = null) + { + await using var conn = await storage.OpenConnectionAsync(ct); + using var cmd = conn.CreateCommand(); + cmd.CommandText = """ + SELECT Id, AgentId, Purpose, Trigger, TaskIds, Services, Risks, TestGuide, WorkflowJson, Justification, Summary, + Nodes, Warnings, Savings, Status, Reason, CreatedAt, DecidedAt, TaskId, Answers + FROM AutomationProposals + """ + (where is null ? "" : " WHERE " + where) + " ORDER BY Id DESC LIMIT @limit"; + if (value is not null) + cmd.Parameters.AddWithValue("@p", value); + cmd.Parameters.AddWithValue("@limit", limit); + var result = new List(); + await using var r = await cmd.ExecuteReaderAsync(ct); + while (await r.ReadAsync(ct)) + result.Add(new AutomationProposal( + r.GetInt64(0), r.GetString(1), r.GetString(2), r.GetString(3), + JsonSerializer.Deserialize>(r.GetString(4)) ?? [], + JsonSerializer.Deserialize>(r.GetString(5)) ?? [], + r.GetString(6), r.GetString(7), r.GetString(8), Text(r, 9), r.GetString(10), + JsonSerializer.Deserialize>(r.GetString(11), Json) ?? [], + JsonSerializer.Deserialize>(r.GetString(12)) ?? [], + JsonSerializer.Deserialize(r.GetString(13), Json) ?? new MeasuredSavings(0, 0, 0, 0), + r.GetString(14), Text(r, 15), Time(r, 16)!.Value, Time(r, 17), Text(r, 18), r.IsDBNull(19) ? null : r.GetInt64(19))); + return result; + } + + private static string? Text(SqliteDataReader r, int i) => r.IsDBNull(i) ? null : r.GetString(i); + + private static DateTime? Time(SqliteDataReader r, int i) + => r.IsDBNull(i) ? null : DateTime.Parse(r.GetString(i), CultureInfo.InvariantCulture, DateTimeStyles.RoundtripKind); + + // ─── loop ─── + + public void Start() => _loop ??= RunAsync(_cts.Token); + + private async Task RunAsync(CancellationToken ct) + { + try { await Task.Delay(TimeSpan.FromMinutes(10), ct); } + catch (OperationCanceledException) { return; } + + while (!ct.IsCancellationRequested) + { + try { await FindCandidatesAsync(ct); } + catch (Exception ex) when (ex is not OperationCanceledException) + { + logger.LogWarning(ex, "Looking for automation candidates failed"); + } + + try { await Task.Delay(TimeSpan.FromHours(24), ct); } + catch (OperationCanceledException) { return; } + } + } + + public async ValueTask DisposeAsync() + { + await _cts.CancelAsync(); + if (_loop is not null) + { + try { await _loop; } + catch (OperationCanceledException) { } + } + _cts.Dispose(); + } +} diff --git a/src/ClawdDotNet.Core/Automation/WorkflowReview.cs b/src/ClawdDotNet.Core/Automation/WorkflowReview.cs new file mode 100644 index 0000000..95379ee --- /dev/null +++ b/src/ClawdDotNet.Core/Automation/WorkflowReview.cs @@ -0,0 +1,181 @@ +using System.Text.Json; +using System.Text.RegularExpressions; +using ClawdDotNet.Core.Organisation; + +namespace ClawdDotNet.Core.Automation; + +/// One node of a workflow as the review reads it: what it does, and where to. +public sealed record WorkflowNode(string Name, string Type, string Effect, string? Target, bool Dangerous); + +public sealed record WorkflowReviewResult( + bool Ok, + IReadOnlyList Problems, + IReadOnlyList Warnings, + IReadOnlyList Nodes, + IReadOnlyList Targets, + string Summary); + +/// +/// #726: The free check of a proposed n8n workflow before a human sees it — not whether it is +/// valid, but what it DOES: every node with its effect, every address outside, everything that +/// runs code. A workflow is a program that runs outside all our checks, with all of n8n's +/// credentials; the human must see that before importing it by hand. +/// +/// Refused: no JSON, no nodes, an embedded secret (credentials are references in n8n, +/// never values), and nodes that run code or commands without a stated reason. +/// +public static partial class WorkflowReview +{ + public const int MaxBytes = 200 * 1024; + public const int MaxNodes = 200; + + [GeneratedRegex(@"\b(sk-[A-Za-z0-9_-]{16,}|ghp_[A-Za-z0-9]{20,}|xox[bap]-[A-Za-z0-9-]{10,}|AKIA[0-9A-Z]{16})\b")] + private static partial Regex KeyLike(); + + public static WorkflowReviewResult Review(string json, string? justification) + { + var problems = new List(); + var warnings = new List(); + var nodes = new List(); + + if (string.IsNullOrWhiteSpace(json)) + return Fail("No workflow was given."); + if (System.Text.Encoding.UTF8.GetByteCount(json) > MaxBytes) + return Fail($"The workflow is larger than {MaxBytes / 1024} KB."); + + JsonDocument doc; + try { doc = JsonDocument.Parse(json); } + catch (JsonException ex) { return Fail($"The workflow is no JSON: {ex.Message}"); } + + using (doc) + { + if (doc.RootElement.ValueKind != JsonValueKind.Object + || !doc.RootElement.TryGetProperty("nodes", out var list) || list.ValueKind != JsonValueKind.Array || list.GetArrayLength() == 0) + return Fail("The workflow has no nodes — n8n exports it as { \"nodes\": [...], \"connections\": {...} }."); + if (list.GetArrayLength() > MaxNodes) + return Fail($"The workflow has more than {MaxNodes} nodes."); + + foreach (var node in list.EnumerateArray()) + { + var name = Text(node, "name") ?? "(unnamed)"; + var type = Text(node, "type") ?? "(no type)"; + var parameters = node.TryGetProperty("parameters", out var p) && p.ValueKind == JsonValueKind.Object ? p : default; + var (effect, target, dangerous) = Effect(type, parameters); + nodes.Add(new WorkflowNode(name, type, effect, target, dangerous)); + + if (parameters.ValueKind == JsonValueKind.Object) + foreach (var path in Secrets(parameters, "")) + problems.Add($"Node '{name}' carries a secret in {path} — credentials belong into n8n and are only referenced."); + } + } + + var risky = nodes.Where(n => n.Dangerous).ToList(); + if (risky.Count > 0) + { + var list = string.Join(", ", risky.Select(n => $"'{n.Name}' ({n.Effect})")); + if (string.IsNullOrWhiteSpace(justification)) + problems.Add($"{list}: these run code or commands outside every check. Without a reason ('justification') they are refused."); + else + warnings.Add($"{list} — the proposer's reason: {justification.Trim()}"); + } + if (nodes.All(n => n.Effect != "starts the workflow")) + warnings.Add("No trigger node — the workflow would only run by hand."); + + var targets = nodes.Select(n => n.Target).OfType().Distinct(StringComparer.OrdinalIgnoreCase).ToList(); + return new WorkflowReviewResult(problems.Count == 0, problems, warnings, nodes, targets, Summarize(nodes, targets)); + + WorkflowReviewResult Fail(string problem) => new(false, [problem], [], [], [], ""); + } + + /// What a node does, from its type — the last part of e.g. n8n-nodes-base.httpRequest. + internal static (string Effect, string? Target, bool Dangerous) Effect(string type, JsonElement parameters) + { + var kind = type.Contains('.') ? type[(type.LastIndexOf('.') + 1)..] : type; + var k = kind.ToLowerInvariant(); + if (k.EndsWith("trigger", StringComparison.Ordinal) || k is "webhook" or "cron" or "interval" or "start" or "manualtrigger") + return ("starts the workflow", k == "webhook" ? "(webhook in n8n)" : null, false); + if (k is "code" or "function" or "functionitem" or "pythonfunction" or "aitransform") + return ("runs code", null, true); + if (k == "executecommand") + return ("runs a command on the n8n host", null, true); + if (k == "ssh") + return ("runs commands on another machine (SSH)", Host(Param(parameters, "host")), true); + if (k == "httprequest") + return ("calls an address", Host(Param(parameters, "url")), false); + if (k is "emailsend" or "gmail" or "microsoftoutlook" or "sendgrid" or "mailjet" or "mailgun") + return ("sends mail", Param(parameters, "toEmail") ?? Param(parameters, "sendTo") ?? "(recipients in the node)", false); + if (k is "readwritefile" or "writebinaryfile" or "readbinaryfile" or "readbinaryfiles") + return ("reads or writes files on the n8n host", null, false); + if (k is "postgres" or "mysql" or "microsoftsql" or "mongodb" or "redis") + return ("talks to a database", null, false); + if (k is "set" or "if" or "switch" or "merge" or "splitinbatches" or "noop" or "itemlists" or "filter" or "sort" or "limit" or "aggregate" or "datetime" or "stickynote") + return ("shapes the data", null, false); + return ($"uses {kind}", null, false); + } + + private static string? Param(JsonElement parameters, string name) + => parameters.ValueKind == JsonValueKind.Object && parameters.TryGetProperty(name, out var v) && v.ValueKind == JsonValueKind.String + ? v.GetString() + : null; + + /// The host of an address; an n8n expression is computed at run time and named as such. + private static string? Host(string? address) + { + if (string.IsNullOrWhiteSpace(address)) + return null; + if (address.TrimStart().StartsWith('=') || address.Contains("{{")) + return "(address computed at run time)"; + return Uri.TryCreate(address.Trim(), UriKind.Absolute, out var uri) ? uri.Host : address.Trim(); + } + + /// Paths of values that are secrets: by name, or by look (a key or a bearer token). + private static IEnumerable Secrets(JsonElement element, string path) + { + switch (element.ValueKind) + { + case JsonValueKind.Object: + foreach (var property in element.EnumerateObject()) + { + var here = path.Length == 0 ? property.Name : $"{path}.{property.Name}"; + if (property.Value.ValueKind == JsonValueKind.String) + { + var value = property.Value.GetString() ?? ""; + var expression = value.TrimStart().StartsWith('='); + if (!expression && value.Length > 0 + && (OrgTemplates.IsSecretName(property.Name) || value.StartsWith("Bearer ", StringComparison.OrdinalIgnoreCase) || KeyLike().IsMatch(value))) + yield return here; + } + else + foreach (var inner in Secrets(property.Value, here)) + yield return inner; + } + break; + case JsonValueKind.Array: + var i = 0; + foreach (var item in element.EnumerateArray()) + foreach (var inner in Secrets(item, $"{path}[{i++}]")) + yield return inner; + break; + } + } + + private static string Summarize(IReadOnlyList nodes, IReadOnlyList targets) + { + var parts = new List { $"{nodes.Count} node(s)." }; + var starts = nodes.Where(n => n.Effect == "starts the workflow").Select(n => n.Name).ToList(); + if (starts.Count > 0) + parts.Add($"Starts with: {string.Join(", ", starts)}."); + if (targets.Count > 0) + parts.Add($"Reaches outside: {string.Join(", ", targets)}."); + var mail = nodes.Where(n => n.Effect == "sends mail").ToList(); + if (mail.Count > 0) + parts.Add($"Sends mail ({string.Join(", ", mail.Select(m => m.Name))})."); + var code = nodes.Where(n => n.Dangerous).ToList(); + if (code.Count > 0) + parts.Add($"Runs code or commands: {string.Join(", ", code.Select(c => c.Name))}."); + return string.Join(" ", parts); + } + + private static string? Text(JsonElement node, string name) + => node.ValueKind == JsonValueKind.Object && node.TryGetProperty(name, out var v) && v.ValueKind == JsonValueKind.String ? v.GetString() : null; +} diff --git a/src/ClawdDotNet.Core/Engine/AgentEngine.cs b/src/ClawdDotNet.Core/Engine/AgentEngine.cs index 1bcbe8b..01e21e5 100644 --- a/src/ClawdDotNet.Core/Engine/AgentEngine.cs +++ b/src/ClawdDotNet.Core/Engine/AgentEngine.cs @@ -66,6 +66,9 @@ public sealed class AgentEngine : IAgentMessageRouter, Staging.IFrozenCallExecut /// #702: Blind votes on staged calls; set by the host. public Staging.IVotes? Votes { get; set; } + /// #726: Automation proposals; set by the host. + public Automation.IAutomationProposals? Automations { get; set; } + /// /// #717: Special budgets. A run on a task in a funded tree draws on the pot instead of the /// daily limits; without this, every run counts against the day. @@ -1885,6 +1888,7 @@ public sealed class AgentEngine : IAgentMessageRouter, Staging.IFrozenCallExecut Doctrines = Doctrines, SpotChecks = SpotChecks, Votes = Votes, + Automations = Automations, SpecialBudgets = SpecialBudgets, Sandbox = Sandbox, Files = Files, diff --git a/src/ClawdDotNet.Core/Storage/SqliteStorage.cs b/src/ClawdDotNet.Core/Storage/SqliteStorage.cs index 5cc17ec..29a43b7 100644 --- a/src/ClawdDotNet.Core/Storage/SqliteStorage.cs +++ b/src/ClawdDotNet.Core/Storage/SqliteStorage.cs @@ -395,6 +395,36 @@ public sealed class SqliteStorage CREATE INDEX IF NOT EXISTS IX_InboundTriggerEvents_Trigger ON InboundTriggerEvents (TriggerId, ReceivedAt); + -- #726: workflows agents propose, the human decides; and the tasks already put to their agent. + CREATE TABLE IF NOT EXISTS AutomationProposals ( + Id INTEGER PRIMARY KEY AUTOINCREMENT, + AgentId TEXT NOT NULL, + Purpose TEXT NOT NULL, + Trigger TEXT NOT NULL, + TaskIds TEXT NOT NULL, + Services TEXT NOT NULL, + Risks TEXT NOT NULL, + TestGuide TEXT NOT NULL, + WorkflowJson TEXT NOT NULL, + Justification TEXT NULL, + Summary TEXT NOT NULL, + Nodes TEXT NOT NULL, + Warnings TEXT NOT NULL, + Savings TEXT NOT NULL, + Status TEXT NOT NULL, + Reason TEXT NULL, + CreatedAt TEXT NOT NULL, + DecidedAt TEXT NULL, + TaskId TEXT NULL, + Answers INTEGER NULL, + Argument TEXT NULL + ); + + CREATE TABLE IF NOT EXISTS AutomationCandidates ( + TaskId TEXT NOT NULL, + NotedAt TEXT NOT NULL + ); + CREATE TABLE IF NOT EXISTS StagedCalls ( Id INTEGER PRIMARY KEY AUTOINCREMENT, RunId TEXT NOT NULL, diff --git a/src/ClawdDotNet.Core/Tools/AgentToolContext.cs b/src/ClawdDotNet.Core/Tools/AgentToolContext.cs index e30a526..56fec42 100644 --- a/src/ClawdDotNet.Core/Tools/AgentToolContext.cs +++ b/src/ClawdDotNet.Core/Tools/AgentToolContext.cs @@ -61,6 +61,9 @@ public sealed record AgentToolContext( /// #702: The ballots of a voter. null outside the engine. public Staging.IVotes? Votes { get; init; } + /// #726: Automation proposals of agents. null outside the engine. + public Automation.IAutomationProposals? Automations { get; init; } + /// #654: Where code runs — never in the main process. Unavailable under Windows. public Sandbox.ISandboxRunner? Sandbox { get; init; } diff --git a/src/ClawdDotNet.Manager.Web/wwwroot/i18n/de.json b/src/ClawdDotNet.Manager.Web/wwwroot/i18n/de.json index 55a36df..839d0af 100644 --- a/src/ClawdDotNet.Manager.Web/wwwroot/i18n/de.json +++ b/src/ClawdDotNet.Manager.Web/wwwroot/i18n/de.json @@ -698,6 +698,20 @@ "staging.rejected": "Aufruf wurde abgelehnt.", "staging.rejectFailed": "Ablehnen fehlgeschlagen: {error}", "staging.withLead": "Die Leitung {lead} darf dies bis {until} entscheiden, im Rahmen dessen, was ihre Einheit erlaubt. Du kannst vorher entscheiden; danach liegt es allein bei dir.", + "automation.title": "Automatisierungsvorschläge", + "automation.hint": "Agenten schlagen n8n-Workflows für Routine vor, die sie immer wieder gleich erledigen. Lies, was der Workflow tut — jeden Knoten und jede Adresse, die er erreicht. Nimmst du an, importierst du das JSON von Hand in n8n, testest es und gibst es als MCP-Werkzeug frei; einen Knopf, der das für dich tut, gibt es mit Absicht nicht. Eine Ablehnung behält ihren Grund.", + "automation.none": "Kein Vorschlag wartet auf dich.", + "automation.trigger": "Auslöser", + "automation.savings": "Gemessen über {days} Tage: {runs} Lauf/Läufe, {cost} USD — etwa {runsMonth} Läufe und {month} USD im Monat.", + "automation.does": "Was er tut", + "automation.replaces": "Ersetzt Aufgaben", + "automation.services": "Braucht", + "automation.risks": "Risiken", + "automation.testGuide": "Testanleitung", + "automation.download": "JSON herunterladen", + "automation.reason": "Grund (Pflicht beim Ablehnen)", + "automation.accept": "Annehmen", + "automation.reject": "Ablehnen", "staging.voteRunning": "Abstimmung #{id} läuft bis {until}: {yes} ja, {no} nein, {missing} ausstehend. Du kannst vorher entscheiden.", "staging.voteDone": "Abstimmung #{id}: {outcome} — {yes} ja, {no} nein, {missing} fehlen.", "staging.voteRule": "Angenommen ab {approve} Ja, abgelehnt bei höchstens {reject}, mindestens {quorum} Stimmen", diff --git a/src/ClawdDotNet.Manager.Web/wwwroot/i18n/en.json b/src/ClawdDotNet.Manager.Web/wwwroot/i18n/en.json index 70b71b1..41abf46 100644 --- a/src/ClawdDotNet.Manager.Web/wwwroot/i18n/en.json +++ b/src/ClawdDotNet.Manager.Web/wwwroot/i18n/en.json @@ -698,6 +698,20 @@ "staging.rejected": "Call was rejected.", "staging.rejectFailed": "Rejection failed: {error}", "staging.withLead": "Its lead {lead} may decide this until {until}, within the limits its unit grants. You can decide it first; after that it is yours alone.", + "automation.title": "Automation proposals", + "automation.hint": "Agents propose n8n workflows for routine they do the same way again and again. Read what the workflow does — every node and every address it reaches. If you accept, import the JSON into n8n by hand, test it, and release it as an MCP tool; there is deliberately no button that does it for you. A rejection keeps its reason.", + "automation.none": "No proposal waits for you.", + "automation.trigger": "trigger", + "automation.savings": "Measured over {days} days: {runs} run(s), {cost} USD — about {runsMonth} runs and {month} USD a month.", + "automation.does": "What it does", + "automation.replaces": "Replaces tasks", + "automation.services": "Needs", + "automation.risks": "Risks", + "automation.testGuide": "Test guide", + "automation.download": "Download JSON", + "automation.reason": "Reason (required to reject)", + "automation.accept": "Accept", + "automation.reject": "Reject", "staging.voteRunning": "Vote #{id} runs until {until}: {yes} yes, {no} no, {missing} outstanding. You can decide it first.", "staging.voteDone": "Vote #{id}: {outcome} — {yes} yes, {no} no, {missing} missing.", "staging.voteRule": "Approved from {approve} yes, rejected at {reject} or fewer, at least {quorum} votes", diff --git a/src/ClawdDotNet.Manager.Web/wwwroot/views/staging.js b/src/ClawdDotNet.Manager.Web/wwwroot/views/staging.js index d34bdfb..a44b1c7 100644 --- a/src/ClawdDotNet.Manager.Web/wwwroot/views/staging.js +++ b/src/ClawdDotNet.Manager.Web/wwwroot/views/staging.js @@ -77,7 +77,8 @@ export async function rendereStaging() { const listeBereich = erstelleElement("div", { id: "staging-liste-bereich" }); const dauerBereich = erstelleElement("div", { id: "staging-dauer-bereich" }); const leitungBereich = erstelleElement("div", { id: "staging-leitung-bereich" }); - layout.append(kopfZeile, listeBereich, dauerBereich, leitungBereich); + // #726: workflows agents propose — decided here, imported by hand. + layout.append(kopfZeile, listeBereich, erstelleAutomationsKarte(), dauerBereich, leitungBereich); container.append(layout); await ladeFreigaben(); @@ -420,3 +421,76 @@ function oeffneAblehnenDialog(id) { modal.oeffnen(); } + +/** + * #726: Workflows agents propose. The human reads what the workflow does (the review lists every + * node and every address outside), decides, and on acceptance imports the JSON into n8n by hand — + * deliberately no "apply" button: a workflow runs outside every check here. + */ +export function erstelleAutomationsKarte() { + const box = erstelleElement("div", { klasse: "automationen" }); + + async function lade() { + let liste = []; + try { liste = (await api("/staging/automations?status=proposed")) || []; } catch { liste = []; } + if (!liste.length) { + box.replaceChildren(erstelleElement("p", { klasse: "gedaempft text-klein", text: t("automation.none") })); + return; + } + box.replaceChildren(...liste.map(vorschlag)); + } + + function vorschlag(p) { + const grund = erstelleElement("input", { type: "text", placeholder: t("automation.reason") }); + const entscheiden = async (annehmen) => { + try { + const r = await api(`/staging/automations/${p.id}/decide`, { method: "POST", body: { accept: annehmen, reason: grund.value } }); + zeigeMeldung(r?.message ?? t("common.saved"), "erfolg", 8000); + await lade(); + } catch (err) { + zeigeMeldung(err.message, "gefahr", 8000); + } + }; + const herunterladen = async () => { + try { + const json = await api(`/staging/automations/${p.id}/workflow`); + const blob = new Blob([typeof json === "string" ? json : JSON.stringify(json, null, 2)], { type: "application/json" }); + const link = erstelleElement("a", { href: URL.createObjectURL(blob) }); + link.download = `automation-${p.id}.n8n.json`; + link.click(); + setTimeout(() => URL.revokeObjectURL(link.href), 1000); + } catch (err) { + zeigeMeldung(err.message, "gefahr", 8000); + } + }; + + return erstelleElement("div", { klasse: "automation" }, + erstelleElement("strong", { text: `#${p.id} ${p.purpose}` }), + erstelleElement("div", { klasse: "gedaempft text-klein", text: `${p.agentId} · ${formatiereDatumZeit(p.createdAt)} · ${t("automation.trigger")}: ${p.trigger}` }), + erstelleElement("p", { klasse: "text-klein", + text: t("automation.savings", { days: p.days, runs: p.runs, cost: p.costUsd.toFixed(2), month: p.costPerMonthUsd.toFixed(2), runsMonth: p.runsPerMonth }) }), + erstelleElement("p", { klasse: "text-klein", text: `${t("automation.does")}: ${p.summary}` }), + erstelleElement("table", { klasse: "daten-tabelle" }, + erstelleElement("tbody", {}, ...p.nodes.map((n) => erstelleElement("tr", { klasse: n.dangerous ? "org-ausreisser" : "" }, + erstelleElement("td", { text: n.name }), + erstelleElement("td", { text: n.effect }), + erstelleElement("td", { klasse: "gedaempft", text: n.target ?? "" }))))), + ...p.warnings.map((w) => erstelleElement("p", { klasse: "hinweis-box warnung text-klein", text: w })), + erstelleElement("dl", { klasse: "datenliste" }, + erstelleElement("dt", { text: t("automation.replaces") }), erstelleElement("dd", { text: p.taskIds.join(", ") }), + erstelleElement("dt", { text: t("automation.services") }), erstelleElement("dd", { text: p.services.join(", ") || "—" }), + erstelleElement("dt", { text: t("automation.risks") }), erstelleElement("dd", { text: p.risks }), + erstelleElement("dt", { text: t("automation.testGuide") }), erstelleElement("dd", { text: p.testGuide })), + erstelleElement("div", { klasse: "gruppe-horizontal" }, + erstelleElement("button", { type: "button", klasse: "klein", text: t("automation.download"), onclick: herunterladen }), + grund, + erstelleElement("button", { type: "button", klasse: "primaer klein", text: t("automation.accept"), onclick: () => entscheiden(true) }), + erstelleElement("button", { type: "button", klasse: "klein", text: t("automation.reject"), onclick: () => entscheiden(false) }))); + } + + lade(); + return erstelleKarte(t("automation.title"), [ + erstelleElement("p", { klasse: "gedaempft text-klein", text: t("automation.hint") }), + box + ]); +} diff --git a/src/ClawdDotNet.Tools.Taskboard/EscalationTool.cs b/src/ClawdDotNet.Tools.Taskboard/EscalationTool.cs index 16b3646..4f2fcb1 100644 --- a/src/ClawdDotNet.Tools.Taskboard/EscalationTool.cs +++ b/src/ClawdDotNet.Tools.Taskboard/EscalationTool.cs @@ -41,6 +41,7 @@ public sealed class EscalationTool : IAgentTool spot_checks lists the runs picked for you, with what the audit log shows; inspect_run (run) shows one run in full; check_done (check, verdict ok|finding, note) closes a check. propose_measure (agent, measure, reasoning, check) puts a measure to a human: sharpen_task, change_model, adjust_rights, suspend — or security, if you suspect the agent is steered from outside. + Routine you do the same way again and again may not need a model: automation_find (is there a proposal for it, and why was one rejected?), then automation_propose (purpose, trigger, tasks it replaces, services, risks, workflow as n8n JSON, test_guide; justification if it runs code). A human decides and imports it — you never create workflows yourself. As a voter: list_ballots shows the calls you are asked to vote on; cast_vote (vote, choice yes|no, reasoning, evidence) — check against your own data, with source and time. You never see the other votes. """; @@ -50,7 +51,7 @@ public sealed class EscalationTool : IAgentTool "properties": { "action": { "type": "string", - "enum": ["find_decisions", "escalate", "list_cases", "decide", "handle", "forward", "open_conflict", "state_position", "request_budget", "list_approvals", "approve_call", "reject_call", "doctrine_read", "doctrine_set", "spot_checks", "inspect_run", "check_done", "propose_measure", "list_ballots", "cast_vote"] + "enum": ["find_decisions", "escalate", "list_cases", "decide", "handle", "forward", "open_conflict", "state_position", "request_budget", "list_approvals", "approve_call", "reject_call", "doctrine_read", "doctrine_set", "spot_checks", "inspect_run", "check_done", "propose_measure", "list_ballots", "cast_vote", "automation_find", "automation_propose"] }, "amount_usd": { "type": "number", "description": "request_budget: the amount for the task and all its parts, in USD." }, "subject": { "type": "string", "description": "What it is about, in a few words — like a title. escalate, open_conflict, find_decisions." }, @@ -76,7 +77,17 @@ public sealed class EscalationTool : IAgentTool "measure": { "type": "string", "enum": ["sharpen_task", "change_model", "adjust_rights", "suspend", "security"], "description": "propose_measure: what should follow." }, "vote": { "type": "integer", "description": "cast_vote: the vote number (#…)." }, "choice": { "type": "string", "enum": ["yes", "no"], "description": "cast_vote: your vote." }, - "evidence": { "type": "string", "description": "cast_vote: what you checked — source and time. Required: without it a vote is an opinion." } + "evidence": { "type": "string", "description": "cast_vote: what you checked — source and time. Required: without it a vote is an opinion." }, + "purpose": { "type": "string", "description": "automation_propose: what the workflow is for. automation_find: what to look for." }, + "trigger": { "type": "string", "description": "automation_propose: what starts it (mail, schedule, webhook …) and how often it happens." }, + "tasks": { "type": "array", "items": { "type": "string" }, "description": "automation_propose: the task ids it replaces — the saving is measured from their runs." }, + "services": { "type": "array", "items": { "type": "string" }, "description": "automation_propose: the services and credentials it needs — names only, never values." }, + "risks": { "type": "string", "description": "automation_propose: what can go wrong, and what it sends outside." }, + "workflow": { "description": "automation_propose: the n8n workflow as exported JSON ({ nodes, connections }) — object or string. Credentials only as references." }, + "test_guide": { "type": "string", "description": "automation_propose: how the human checks it after importing." }, + "justification": { "type": "string", "description": "automation_propose: why a Code, Execute Command or SSH node is needed. Without it such nodes are refused." }, + "answers": { "type": "integer", "description": "automation_propose: the rejected proposal (#) this one answers." }, + "argument": { "type": "string", "description": "automation_propose: what is new against that rejection's reason." } }, "required": ["action"] } @@ -108,6 +119,10 @@ public sealed class EscalationTool : IAgentTool if (Get(input, "action") is "spot_checks" or "inspect_run" or "check_done" or "propose_measure") return await SpotCheckAsync(input, context, ct); + // #726: Proposing a workflow instead of doing routine by model. + if (Get(input, "action") is "automation_find" or "automation_propose") + return await AutomationAsync(input, context, ct); + // #702: A voter's ballot. if (Get(input, "action") is "list_ballots" or "cast_vote") return await BallotAsync(input, context, ct); @@ -184,7 +199,7 @@ public sealed class EscalationTool : IAgentTool break; default: - return ToolResult.Fail("'action' must be one of find_decisions, escalate, list_cases, decide, handle, forward, open_conflict, state_position, request_budget, list_approvals, approve_call, reject_call, doctrine_read, doctrine_set, spot_checks, inspect_run, check_done, propose_measure, list_ballots, cast_vote."); + return ToolResult.Fail("'action' must be one of find_decisions, escalate, list_cases, decide, handle, forward, open_conflict, state_position, request_budget, list_approvals, approve_call, reject_call, doctrine_read, doctrine_set, spot_checks, inspect_run, check_done, propose_measure, list_ballots, cast_vote, automation_find, automation_propose."); } return outcome.Ok ? ToolResult.Ok(outcome.Message) : ToolResult.Fail(outcome.Message); @@ -336,6 +351,41 @@ public sealed class EscalationTool : IAgentTool } } + private static async Task AutomationAsync(JsonElement input, AgentToolContext context, CancellationToken ct) + { + if (context.Automations is not { } automations) + return ToolResult.Fail("Automation proposals are not available in this instance."); + + if (Get(input, "action") == "automation_find") + { + var subject = Get(input, "purpose") ?? Get(input, "subject") ?? ""; + if (string.IsNullOrWhiteSpace(subject)) + return ToolResult.Fail("'purpose' is required — what to look for."); + var found = await automations.FindAsync(subject, ct); + if (found.Count == 0) + return ToolResult.Ok($"No proposal is about \"{subject.Trim()}\"."); + var sb = new StringBuilder("Proposals about it:\n"); + foreach (var p in found) + sb.AppendLine($"- #{p.Id} by {p.AgentId}: {p.Purpose} — {p.Status}" + (string.IsNullOrWhiteSpace(p.Reason) ? "" : $" (reason: {p.Reason})")); + return ToolResult.Ok(sb.ToString().TrimEnd()); + } + + var workflow = input.TryGetProperty("workflow", out var w) + ? w.ValueKind == JsonValueKind.String ? w.GetString() ?? "" : w.GetRawText() + : ""; + long? answers = input.TryGetProperty("answers", out var a) && a.ValueKind == JsonValueKind.Number && a.TryGetInt64(out var n) ? n : null; + var outcome = await automations.ProposeAsync(new Core.Automation.AutomationRequest( + context.AgentId, Get(input, "purpose") ?? "", Get(input, "trigger") ?? "", + List(input, "tasks"), List(input, "services"), Get(input, "risks") ?? "", Get(input, "test_guide") ?? "", + workflow, Get(input, "justification"), answers, Get(input, "argument")), ct); + return outcome.Ok ? ToolResult.Ok(outcome.Message) : ToolResult.Fail(outcome.Message); + } + + private static IReadOnlyList List(JsonElement input, string name) + => input.TryGetProperty(name, out var v) && v.ValueKind == JsonValueKind.Array + ? v.EnumerateArray().Where(e => e.ValueKind == JsonValueKind.String).Select(e => e.GetString()!.Trim()).Where(s => s.Length > 0).ToList() + : []; + private static async Task BallotAsync(JsonElement input, AgentToolContext context, CancellationToken ct) { if (context.Votes is not { } votes) diff --git a/src/ClawdDotNet.Web/Contracts/AutomationDtos.cs b/src/ClawdDotNet.Web/Contracts/AutomationDtos.cs new file mode 100644 index 0000000..82a238f --- /dev/null +++ b/src/ClawdDotNet.Web/Contracts/AutomationDtos.cs @@ -0,0 +1,13 @@ +namespace ClawdDotNet.Web.Contracts; + +/// #726: A node of a proposed workflow, as the review read it. +public sealed record WorkflowNodeDto(string Name, string Type, string Effect, string? Target, bool Dangerous); + +/// #726: An agent's workflow proposal with its measured saving and the review. +public sealed record AutomationProposalDto( + long Id, string AgentId, string Purpose, string Trigger, IReadOnlyList TaskIds, IReadOnlyList Services, + string Risks, string TestGuide, string? Justification, string Summary, IReadOnlyList Nodes, + IReadOnlyList Warnings, int Runs, decimal CostUsd, decimal CostPerMonthUsd, int RunsPerMonth, int Days, + string Status, string? Reason, DateTime CreatedAt, DateTime? DecidedAt, long? Answers); + +public sealed record AutomationDecisionDto(bool Accept, string? Reason); diff --git a/src/ClawdDotNet.Web/Endpoints/ApiEndpoints.cs b/src/ClawdDotNet.Web/Endpoints/ApiEndpoints.cs index ad9cd47..95290a9 100644 --- a/src/ClawdDotNet.Web/Endpoints/ApiEndpoints.cs +++ b/src/ClawdDotNet.Web/Endpoints/ApiEndpoints.cs @@ -375,6 +375,31 @@ internal static class ApiEndpoints result.Kind is Core.Staging.StagingResultKind.Executed, result.Message)); }); + // #726: Workflows agents propose — the human decides here and imports by hand in n8n. + staging.MapGet("/automations", async (AppHost host, CancellationToken ct, string? status) => + { + if (host.Automations is not { } automations) + return Results.Ok(Array.Empty()); + return Results.Ok((await automations.ListAsync(status, 50, ct)).Select(p => new AutomationProposalDto( + p.Id, p.AgentId, p.Purpose, p.Trigger, p.TaskIds, p.Services, p.Risks, p.TestGuide, p.Justification, p.Summary, + p.Nodes.Select(x => new WorkflowNodeDto(x.Name, x.Type, x.Effect, x.Target, x.Dangerous)).ToList(), + p.Warnings, p.Savings.Runs, p.Savings.CostUsd, p.Savings.CostPerMonthUsd, p.Savings.RunsPerMonth, p.Savings.Days, + p.Status, p.Reason, p.CreatedAt, p.DecidedAt, p.Answers)).ToList()); + }); + + staging.MapGet("/automations/{id:long}/workflow", async (AppHost host, long id, CancellationToken ct) => + host.Automations is { } automations && (await automations.ListAsync(null, 1000, ct)).FirstOrDefault(p => p.Id == id) is { } proposal + ? Results.Text(proposal.WorkflowJson, "application/json") + : Results.NotFound(new OutcomeDto(false, $"No proposal #{id}."))); + + staging.MapPost("/automations/{id:long}/decide", async (AppHost host, long id, AutomationDecisionDto input, CancellationToken ct) => + { + if (host.Automations is not { } automations) + return Results.BadRequest(new OutcomeDto(false, "Automation proposals are not available in this instance.")); + var outcome = await automations.DecideAsync(id, input.Accept, input.Reason ?? "", ct); + return outcome.Ok ? Results.Ok(new OutcomeDto(true, outcome.Message)) : Results.BadRequest(new OutcomeDto(false, outcome.Message)); + }); + // #687: How often each lead approved what it was given — a lead that approves everything does nothing. staging.MapGet("/leads", async (AppHost host, CancellationToken ct, int days = 30) => { diff --git a/tests/ClawdDotNet.Core.Tests/Automation/AutomationProposalsTests.cs b/tests/ClawdDotNet.Core.Tests/Automation/AutomationProposalsTests.cs new file mode 100644 index 0000000..23902b4 --- /dev/null +++ b/tests/ClawdDotNet.Core.Tests/Automation/AutomationProposalsTests.cs @@ -0,0 +1,176 @@ +using ClawdDotNet.Core.Audit; +using ClawdDotNet.Core.Automation; +using ClawdDotNet.Core.Storage; +using ClawdDotNet.Core.Tasks; +using Microsoft.Extensions.Logging.Abstractions; +using Shouldly; + +namespace ClawdDotNet.Core.Tests.Automation; + +/// +/// #726: Agents propose workflows, measured and checked for free; the human decides and imports +/// by hand. No flood, and a rejected proposal does not come back without a new argument. +/// +public sealed class AutomationProposalsTests : IDisposable +{ + private readonly string _directory = Path.Combine(Path.GetTempPath(), "clawd-tests", "auto-" + Guid.NewGuid().ToString("N")[..8]); + private readonly SqliteAuditRepository _audit; + private readonly SqliteTaskRepository _tasks; + private readonly TaskboardService _board; + private readonly AutomationProposals _proposals; + + private const string Workflow = """ + {"nodes":[ + {"name":"Every morning","type":"n8n-nodes-base.scheduleTrigger","parameters":{}}, + {"name":"Get rates","type":"n8n-nodes-base.httpRequest","parameters":{"url":"https://api.rates.example/eur","headers":{"x":"={{ $env.TOKEN }}"}}}, + {"name":"Mail","type":"n8n-nodes-base.emailSend","parameters":{"toEmail":"desk@example.org"},"credentials":{"smtp":{"id":"1","name":"Office SMTP"}}} + ],"connections":{}} + """; + + public AutomationProposalsTests() + { + var storage = new SqliteStorage(Path.Combine(_directory, "state.db")); + _audit = new SqliteAuditRepository(storage); + _tasks = new SqliteTaskRepository(storage); + _board = new TaskboardService(_tasks, Path.Combine(_directory, "tasks")); + _proposals = new AutomationProposals(storage, _audit, _tasks, _board, NullLogger.Instance); + } + + public void Dispose() + { + SqliteStorage.ReleasePooledConnections(_directory); + try { Directory.Delete(_directory, recursive: true); } + catch { /* cleanup is secondary */ } + } + + // ─── the review ─── + + [Fact] + public void The_review_says_what_the_workflow_does_and_where_it_reaches() + { + var review = WorkflowReview.Review(Workflow, null); + + review.Ok.ShouldBeTrue(string.Join(" ", review.Problems)); + review.Nodes.Select(n => n.Effect).ShouldBe(["starts the workflow", "calls an address", "sends mail"]); + review.Targets.ShouldBe(["api.rates.example", "desk@example.org"]); + review.Summary.ShouldContain("Reaches outside: api.rates.example"); + } + + [Fact] + public void Code_needs_a_reason_and_a_secret_is_never_carried() + { + const string code = """{"nodes":[{"name":"Calc","type":"n8n-nodes-base.code","parameters":{"jsCode":"return 1"}}]}"""; + + WorkflowReview.Review(code, null).Problems.ShouldContain(p => p.Contains("Without a reason")); + var reasoned = WorkflowReview.Review(code, "Rounds prices the same way the shop does."); + reasoned.Ok.ShouldBeTrue(); + reasoned.Warnings.ShouldContain(w => w.Contains("Rounds prices")); + + WorkflowReview.Review("""{"nodes":[{"name":"Api","type":"n8n-nodes-base.httpRequest","parameters":{"url":"https://x.example","apiKey":"k-123"}}]}""", null) + .Problems.ShouldContain(p => p.Contains("parameters") || p.Contains("apiKey")); + WorkflowReview.Review("""{"nodes":[{"name":"Api","type":"n8n-nodes-base.httpRequest","parameters":{"headerValue":"Bearer abc"}}]}""", null) + .Ok.ShouldBeFalse("a bearer token by its look"); + WorkflowReview.Review("not json", null).Ok.ShouldBeFalse(); + WorkflowReview.Review("""{"nodes":[]}""", null).Ok.ShouldBeFalse(); + } + + // ─── proposing ─── + + private async Task RecurringTask(int runs, string agent = "desk", bool same = true) + { + var task = await _board.CreateAsync(new TaskItem + { + Title = "Morning exchange rates", + Assignee = "@" + agent, + When = new TaskWhen { Kind = TaskWhenKind.Cron, Value = "0 7 * * *", TimeZone = "Europe/Berlin" } + }, default); + for (var i = 0; i < runs; i++) + { + var run = $"run-{task.Id}-{i}"; + await _audit.AppendAsync(new AuditEntry { RunId = run, AgentId = agent, Tool = "WebFetch", Arguments = "{\"action\":\"fetch\"}", Status = AuditStatus.Ok, OccurredAt = DateTime.UtcNow.AddDays(-i) }, default); + await _audit.AppendAsync(new AuditEntry { RunId = run, AgentId = agent, Tool = same || i % 2 == 0 ? "Mail" : "FileRW", Arguments = "{\"action\":\"send\"}", Status = AuditStatus.Ok, OccurredAt = DateTime.UtcNow.AddDays(-i) }, default); + await _audit.RecordReceiptAsync(new RunReceipt { RunId = run, AgentId = agent, TaskId = task.Id, Status = "Completed", CostUsd = 0.05m, CostIsKnown = true, PromptTokens = 1000, OccurredAt = DateTime.UtcNow.AddDays(-i) }, default); + } + return task.Id; + } + + private AutomationRequest Request(string taskId, string purpose = "Send the morning exchange rates", long? answers = null, string? argument = null) + => new("desk", purpose, "every morning at 7", [taskId], ["Office SMTP"], "Sends rates to the desk only.", "Run once by hand, check the mail.", Workflow, null, answers, argument); + + [Fact] + public async Task A_proposal_carries_its_measured_saving_and_goes_to_a_human() + { + var taskId = await RecurringTask(10); + + var outcome = await _proposals.ProposeAsync(Request(taskId), default); + + outcome.Ok.ShouldBeTrue(outcome.Message); + var proposal = (await _proposals.ListAsync("proposed", 10, default)).Single(); + proposal.Savings.Runs.ShouldBe(10); + proposal.Savings.CostUsd.ShouldBe(0.50m); + proposal.Summary.ShouldContain("api.rates.example"); + (await _board.ListAsync(new TaskQuery { Assignee = TaskAssignee.Human }, default)).ShouldContain(t => t.Title.Contains($"#{proposal.Id}")); + } + + [Fact] + public async Task The_same_purpose_is_not_proposed_twice_and_a_rejection_must_be_answered() + { + var taskId = await RecurringTask(3); + var first = await _proposals.ProposeAsync(Request(taskId), default); + + (await _proposals.ProposeAsync(Request(taskId, "Send the exchange rates every morning"), default)).Message.ShouldContain($"#{first.Id}"); + + (await _proposals.DecideAsync(first.Id!.Value, false, "", default)).Ok.ShouldBeFalse("a rejection needs its reason"); + (await _proposals.DecideAsync(first.Id!.Value, false, "Rates change format often — the agent copes, a workflow breaks.", default)).Ok.ShouldBeTrue(); + + (await _proposals.ProposeAsync(Request(taskId), default)).Message.ShouldContain("must answer"); + (await _proposals.ProposeAsync(Request(taskId, answers: first.Id, argument: "The rates API now has a versioned, stable format."), default)) + .Ok.ShouldBeTrue(); + (await _proposals.FindAsync("exchange rates", default)).Count.ShouldBe(2); + } + + [Fact] + public async Task An_agent_has_at_most_three_open_proposals() + { + for (var i = 0; i < 3; i++) + (await _proposals.ProposeAsync(Request(await RecurringTask(1), $"Purpose number {i} quite different {Guid.NewGuid():N}"), default)).Ok.ShouldBeTrue(); + + (await _proposals.ProposeAsync(Request(await RecurringTask(1), "A fourth unrelated thing entirely"), default)).Message.ShouldContain("Wait for a decision"); + } + + [Fact] + public async Task A_proposal_without_the_required_parts_or_a_broken_workflow_is_refused() + { + var taskId = await RecurringTask(1); + + (await _proposals.ProposeAsync(Request(taskId) with { TaskIds = [] }, default)).Message.ShouldContain("'tasks'"); + (await _proposals.ProposeAsync(Request("t-nope"), default)).Message.ShouldContain("no task"); + (await _proposals.ProposeAsync(Request(taskId) with { WorkflowJson = """{"nodes":[{"name":"x","type":"n8n-nodes-base.executeCommand"}]}""" }, default)) + .Message.ShouldContain("does not pass the check"); + } + + // ─── candidates ─── + + [Fact] + public async Task A_recurring_task_that_does_the_same_each_time_is_put_to_its_agent_once() + { + var same = await RecurringTask(9, "desk"); + var varied = await RecurringTask(9, "other", same: false); + + var noted = await _proposals.FindCandidatesAsync(default); + + noted.ShouldBe([same], "half the runs of the other task differ"); + (await _board.ListAsync(new TaskQuery { Assignee = "@desk" }, default)).ShouldContain(t => t.Title.StartsWith("Automation candidate")); + (await _proposals.FindCandidatesAsync(default)).ShouldBeEmpty("once a month, not every day"); + varied.ShouldNotBe(same); + } + + [Fact] + public void The_signature_folds_repeats_and_leaves_out_bookkeeping() + { + AuditEntry E(string tool, string op) => new() { Tool = tool, Arguments = $"{{\"action\":\"{op}\"}}", Status = AuditStatus.Ok }; + + AutomationProposals.Signature([E("FileRW", "read"), E("FileRW", "read"), E("Taskboard", "complete"), E("Mail", "send")]) + .ShouldBe("FileRW.read > Mail.send"); + } +}