From a17ac2957f2a10c5c50490c6b3d8435c7fa484ef Mon Sep 17 00:00:00 2001 From: Richard Date: Thu, 1 Oct 2026 20:29:19 +0200 Subject: [PATCH] Automatisierungsvorschlaege nur mit n8n-Anbindung (#726) An instance without n8n has no use for workflow proposals. New instance setting automation.enabled ("this instance works with n8n"), off by default: - off: automation_find/automation_propose refuse, no candidates are put to agents, and the escalation tool does not mention proposals at all; - the switch sits in the settings card for external MCP servers; the approvals view says proposals are off instead of standing empty. Co-Authored-By: Claude Opus 5.5 --- src/ClawdDotNet.App/AppHost.cs | 5 ++++- .../Automation/AutomationProposals.cs | 22 ++++++++++++++++++- src/ClawdDotNet.Core/Config/InstanceConfig.cs | 4 ++++ .../wwwroot/i18n/de.json | 3 +++ .../wwwroot/i18n/en.json | 3 +++ .../wwwroot/views/mcp-servers.js | 15 +++++++++++++ .../wwwroot/views/staging.js | 7 ++++++ .../EscalationTool.cs | 18 +++++++++++++-- .../Contracts/AutomationDtos.cs | 3 +++ src/ClawdDotNet.Web/Endpoints/ApiEndpoints.cs | 12 ++++++++++ .../Automation/AutomationProposalsTests.cs | 16 ++++++++++++++ .../Taskboard/AutomationHintTests.cs | 20 +++++++++++++++++ 12 files changed, 124 insertions(+), 4 deletions(-) create mode 100644 tests/ClawdDotNet.Tools.Tests/Taskboard/AutomationHintTests.cs diff --git a/src/ClawdDotNet.App/AppHost.cs b/src/ClawdDotNet.App/AppHost.cs index 7e48181..91ecff2 100644 --- a/src/ClawdDotNet.App/AppHost.cs +++ b/src/ClawdDotNet.App/AppHost.cs @@ -750,7 +750,10 @@ public sealed class AppHost : IAsyncDisposable // #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")); + loggerFactory.CreateLogger("ClawdDotNet.Core.Automation"), () => instance.Automation); + // The escalation tool only mentions proposals to agents of an instance that works with n8n. + if (tools.Get(Core.Cases.CaseDesk.ToolName) is Tools.Taskboard.EscalationTool escalation) + escalation.AutomationEnabled = () => instance.Automation.Enabled; engine.Automations = automations; host.Automations = automations; automations.Start(); diff --git a/src/ClawdDotNet.Core/Automation/AutomationProposals.cs b/src/ClawdDotNet.Core/Automation/AutomationProposals.cs index f0ecf16..24961e6 100644 --- a/src/ClawdDotNet.Core/Automation/AutomationProposals.cs +++ b/src/ClawdDotNet.Core/Automation/AutomationProposals.cs @@ -9,6 +9,16 @@ using Microsoft.Extensions.Logging; namespace ClawdDotNet.Core.Automation; +/// +/// #726: Whether the instance works with n8n. Off by default: an instance without n8n has no +/// use for workflow proposals, and every proposal and candidate costs runs and attention. +/// +public sealed class AutomationSettings +{ + [System.Text.Json.Serialization.JsonPropertyName("enabled")] + public bool Enabled { get; set; } +} + /// 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) { @@ -32,6 +42,9 @@ public sealed record AutomationOutcome(bool Ok, string Message, long? Id = null) public interface IAutomationProposals { + /// Whether the instance works with n8n — without it, nothing is proposed. + bool Enabled { get; } + Task> FindAsync(string subject, CancellationToken ct); Task ProposeAsync(AutomationRequest request, CancellationToken ct); @@ -57,8 +70,13 @@ public sealed class AutomationProposals( ITaskRepository tasks, TaskboardService? board, ILogger logger, + Func? settings = null, TimeProvider? clock = null) : IAutomationProposals, IAsyncDisposable { + public bool Enabled => settings?.Invoke().Enabled ?? true; + + public const string DisabledMessage = "This instance does not work with n8n — workflow proposals are switched off."; + public const int MaxOpenPerAgent = 3; public const int MeasureDays = 28; @@ -85,6 +103,8 @@ public sealed class AutomationProposals( public async Task ProposeAsync(AutomationRequest r, CancellationToken ct) { + if (!Enabled) + return new(false, DisabledMessage); 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) @@ -236,7 +256,7 @@ public sealed class AutomationProposals( /// public async Task> FindCandidatesAsync(CancellationToken ct) { - if (board is null) + if (board is null || !Enabled) return []; var since = Now.AddDays(-CandidateDays); var proposals = await ListAsync(null, 1000, ct); diff --git a/src/ClawdDotNet.Core/Config/InstanceConfig.cs b/src/ClawdDotNet.Core/Config/InstanceConfig.cs index dcac7f7..3691162 100644 --- a/src/ClawdDotNet.Core/Config/InstanceConfig.cs +++ b/src/ClawdDotNet.Core/Config/InstanceConfig.cs @@ -159,6 +159,10 @@ public sealed class InstanceConfig [JsonPropertyName("fileHistory")] public Files.FileHistorySettings FileHistory { get; set; } = new(); + /// #726: Whether this instance works with n8n — only then do agents propose workflows. Off by default. + [JsonPropertyName("automation")] + public Automation.AutomationSettings Automation { get; set; } = new(); + /// #197: External MCP servers whose tools the agents may use — set up by a human only. [JsonPropertyName("mcpServers")] public List McpServers { get; set; } = new(); diff --git a/src/ClawdDotNet.Manager.Web/wwwroot/i18n/de.json b/src/ClawdDotNet.Manager.Web/wwwroot/i18n/de.json index 839d0af..944853a 100644 --- a/src/ClawdDotNet.Manager.Web/wwwroot/i18n/de.json +++ b/src/ClawdDotNet.Manager.Web/wwwroot/i18n/de.json @@ -698,6 +698,9 @@ "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.disabled": "Diese Instanz arbeitet nicht mit n8n, deshalb schlagen Agenten keine Workflows vor. Schalte die n8n-Anbindung in den Einstellungen (Externe MCP-Server) ein, wenn du n8n betreibst.", + "mcpServers.n8n": "Diese Instanz arbeitet mit n8n", + "mcpServers.n8nHint": "Nur dann schlagen Agenten Workflows für wiederkehrende Routine vor, und nur dann werden ihnen solche Aufgaben vorgelegt. Aus: keine Vorschläge, keine Kandidaten.", "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.", diff --git a/src/ClawdDotNet.Manager.Web/wwwroot/i18n/en.json b/src/ClawdDotNet.Manager.Web/wwwroot/i18n/en.json index 41abf46..fdb4a5b 100644 --- a/src/ClawdDotNet.Manager.Web/wwwroot/i18n/en.json +++ b/src/ClawdDotNet.Manager.Web/wwwroot/i18n/en.json @@ -698,6 +698,9 @@ "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.disabled": "This instance does not work with n8n, so agents propose no workflows. Switch the n8n connection on in the settings (external MCP servers) if you run n8n.", + "mcpServers.n8n": "This instance works with n8n", + "mcpServers.n8nHint": "Only then do agents propose workflows for recurring routine, and only then are such tasks put to them. Off: no proposals, no candidates.", "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.", diff --git a/src/ClawdDotNet.Manager.Web/wwwroot/views/mcp-servers.js b/src/ClawdDotNet.Manager.Web/wwwroot/views/mcp-servers.js index e5114a4..6588c09 100644 --- a/src/ClawdDotNet.Manager.Web/wwwroot/views/mcp-servers.js +++ b/src/ClawdDotNet.Manager.Web/wwwroot/views/mcp-servers.js @@ -86,8 +86,23 @@ export function erstelleMcpServerKarte() { lade(); + // #726: whether the instance works with n8n — only then do agents propose workflows. + const n8n = erstelleElement("input", { type: "checkbox" }); + api("/staging/automations/settings").then((s) => { n8n.checked = !!s?.enabled; }).catch(() => {}); + n8n.addEventListener("change", async () => { + try { + const r = await api("/staging/automations/settings", { method: "PUT", body: { enabled: n8n.checked } }); + zeigeMeldung(r?.message ?? t("common.saved"), "erfolg", 6000); + } catch (err) { + n8n.checked = !n8n.checked; + zeigeMeldung(err.message, "gefahr", 8000); + } + }); + return erstelleKarte(t("mcpServers.title"), [ erstelleElement("p", { klasse: "gedaempft text-klein", text: t("mcpServers.hint") }), + erstelleElement("label", { klasse: "gruppe-horizontal" }, n8n, erstelleElement("span", { text: t("mcpServers.n8n") })), + erstelleElement("p", { klasse: "gedaempft text-klein", text: t("mcpServers.n8nHint") }), liste, erstelleElement("h4", { text: t("mcpServers.add") }), erstelleElement("div", { klasse: "formular-raster" }, diff --git a/src/ClawdDotNet.Manager.Web/wwwroot/views/staging.js b/src/ClawdDotNet.Manager.Web/wwwroot/views/staging.js index a44b1c7..00d0c59 100644 --- a/src/ClawdDotNet.Manager.Web/wwwroot/views/staging.js +++ b/src/ClawdDotNet.Manager.Web/wwwroot/views/staging.js @@ -431,6 +431,13 @@ export function erstelleAutomationsKarte() { const box = erstelleElement("div", { klasse: "automationen" }); async function lade() { + // Without n8n there are no proposals — the card says so instead of standing empty. + let einstellung = null; + try { einstellung = await api("/staging/automations/settings"); } catch { einstellung = null; } + if (!einstellung?.enabled) { + box.replaceChildren(erstelleElement("p", { klasse: "gedaempft text-klein", text: t("automation.disabled") })); + return; + } let liste = []; try { liste = (await api("/staging/automations?status=proposed")) || []; } catch { liste = []; } if (!liste.length) { diff --git a/src/ClawdDotNet.Tools.Taskboard/EscalationTool.cs b/src/ClawdDotNet.Tools.Taskboard/EscalationTool.cs index 4f2fcb1..4d5eca5 100644 --- a/src/ClawdDotNet.Tools.Taskboard/EscalationTool.cs +++ b/src/ClawdDotNet.Tools.Taskboard/EscalationTool.cs @@ -24,7 +24,20 @@ public sealed class EscalationTool : IAgentTool { public string Name => CaseDesk.ToolName; - public string Description => """ + /// + /// #726: Whether the instance works with n8n. Only then are agents told about workflow + /// proposals — an instance without n8n has no use for them. Set by the host. + /// + public Func? AutomationEnabled { get; set; } + + private const string AutomationHint = + "\nRoutine 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."; + + public string Description => AutomationEnabled?.Invoke() == true ? BaseDescription + AutomationHint : BaseDescription; + + private const string BaseDescription = """ Escalate what you cannot decide, and decide what is escalated to you. - find_decisions: decisions that bind you and answer a question. escalate shows them anyway. - escalate: you cannot get on and it is not yours to decide. Goes to your lead (or a human). Name the task you are stuck on; it waits for the answer. @@ -41,7 +54,6 @@ 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. """; @@ -355,6 +367,8 @@ public sealed class EscalationTool : IAgentTool { if (context.Automations is not { } automations) return ToolResult.Fail("Automation proposals are not available in this instance."); + if (!automations.Enabled) + return ToolResult.Fail(Core.Automation.AutomationProposals.DisabledMessage); if (Get(input, "action") == "automation_find") { diff --git a/src/ClawdDotNet.Web/Contracts/AutomationDtos.cs b/src/ClawdDotNet.Web/Contracts/AutomationDtos.cs index 82a238f..d9f1e29 100644 --- a/src/ClawdDotNet.Web/Contracts/AutomationDtos.cs +++ b/src/ClawdDotNet.Web/Contracts/AutomationDtos.cs @@ -11,3 +11,6 @@ public sealed record AutomationProposalDto( string Status, string? Reason, DateTime CreatedAt, DateTime? DecidedAt, long? Answers); public sealed record AutomationDecisionDto(bool Accept, string? Reason); + +/// #726: Whether the instance works with n8n. +public sealed record AutomationSettingsDto(bool Enabled); diff --git a/src/ClawdDotNet.Web/Endpoints/ApiEndpoints.cs b/src/ClawdDotNet.Web/Endpoints/ApiEndpoints.cs index 95290a9..917b257 100644 --- a/src/ClawdDotNet.Web/Endpoints/ApiEndpoints.cs +++ b/src/ClawdDotNet.Web/Endpoints/ApiEndpoints.cs @@ -387,6 +387,18 @@ internal static class ApiEndpoints p.Status, p.Reason, p.CreatedAt, p.DecidedAt, p.Answers)).ToList()); }); + // #726: whether this instance works with n8n — only then do agents propose workflows. + staging.MapGet("/automations/settings", (AppHost host) => Results.Ok(new AutomationSettingsDto(host.Instance.Automation.Enabled))); + + staging.MapPut("/automations/settings", (AppHost host, AutomationSettingsDto input) => + { + host.Instance.Automation.Enabled = input.Enabled; + host.SaveInstance(new InstanceChange(InstanceChangeKind.Settings)); + return Results.Ok(new OutcomeDto(true, input.Enabled + ? "n8n connection on: agents may propose workflows, and recurring routine is put to them." + : "n8n connection off: no workflow proposals, no candidates.")); + }); + 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") diff --git a/tests/ClawdDotNet.Core.Tests/Automation/AutomationProposalsTests.cs b/tests/ClawdDotNet.Core.Tests/Automation/AutomationProposalsTests.cs index 23902b4..3b2950f 100644 --- a/tests/ClawdDotNet.Core.Tests/Automation/AutomationProposalsTests.cs +++ b/tests/ClawdDotNet.Core.Tests/Automation/AutomationProposalsTests.cs @@ -165,6 +165,22 @@ public sealed class AutomationProposalsTests : IDisposable varied.ShouldNotBe(same); } + [Fact] + public async Task Without_n8n_nothing_is_proposed_and_no_candidate_is_put_to_anyone() + { + var settings = new AutomationSettings { Enabled = false }; + var storage = new SqliteStorage(Path.Combine(_directory, "off.db")); + var off = new AutomationProposals(storage, _audit, _tasks, _board, NullLogger.Instance, () => settings); + var taskId = await RecurringTask(9); + + off.Enabled.ShouldBeFalse(); + (await off.ProposeAsync(Request(taskId), default)).Message.ShouldContain("does not work with n8n"); + (await off.FindCandidatesAsync(default)).ShouldBeEmpty(); + + settings.Enabled = true; + (await off.ProposeAsync(Request(taskId), default)).Ok.ShouldBeTrue(); + } + [Fact] public void The_signature_folds_repeats_and_leaves_out_bookkeeping() { diff --git a/tests/ClawdDotNet.Tools.Tests/Taskboard/AutomationHintTests.cs b/tests/ClawdDotNet.Tools.Tests/Taskboard/AutomationHintTests.cs new file mode 100644 index 0000000..f207999 --- /dev/null +++ b/tests/ClawdDotNet.Tools.Tests/Taskboard/AutomationHintTests.cs @@ -0,0 +1,20 @@ +using ClawdDotNet.Tools.Taskboard; +using Shouldly; + +namespace ClawdDotNet.Tools.Tests.Taskboard; + +/// #726: Agents hear of workflow proposals only in an instance that works with n8n. +public sealed class AutomationHintTests +{ + [Fact] + public void The_hint_follows_the_n8n_switch() + { + var enabled = false; + var tool = new EscalationTool { AutomationEnabled = () => enabled }; + + tool.Description.ShouldNotContain("automation_propose"); + enabled = true; + tool.Description.ShouldContain("automation_propose"); + new EscalationTool().Description.ShouldNotContain("automation_propose", customMessage: "without the host saying so, nothing is proposed"); + } +}