2 Commits
Author SHA1 Message Date
RichardandClaude Opus 5.5 da1257485b Merge fix/726-n8n-switch: Vorschlaege nur mit n8n (#726)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-01 20:37:08 +02:00
RichardandClaude Opus 5.5 a17ac2957f 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 <noreply@anthropic.com>
2026-10-01 20:29:19 +02:00
12 changed files with 124 additions and 4 deletions
+4 -1
View File
@@ -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. // #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, 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; engine.Automations = automations;
host.Automations = automations; host.Automations = automations;
automations.Start(); automations.Start();
@@ -9,6 +9,16 @@ using Microsoft.Extensions.Logging;
namespace ClawdDotNet.Core.Automation; namespace ClawdDotNet.Core.Automation;
/// <summary>
/// #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.
/// </summary>
public sealed class AutomationSettings
{
[System.Text.Json.Serialization.JsonPropertyName("enabled")]
public bool Enabled { get; set; }
}
/// <summary>What the replaced tasks cost over the measured days — taken from the receipts, not estimated.</summary> /// <summary>What the replaced tasks cost over the measured days — taken from the receipts, not estimated.</summary>
public sealed record MeasuredSavings(int Days, int Runs, decimal CostUsd, long Tokens) 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 public interface IAutomationProposals
{ {
/// <summary>Whether the instance works with n8n — without it, nothing is proposed.</summary>
bool Enabled { get; }
Task<IReadOnlyList<AutomationProposal>> FindAsync(string subject, CancellationToken ct); Task<IReadOnlyList<AutomationProposal>> FindAsync(string subject, CancellationToken ct);
Task<AutomationOutcome> ProposeAsync(AutomationRequest request, CancellationToken ct); Task<AutomationOutcome> ProposeAsync(AutomationRequest request, CancellationToken ct);
@@ -57,8 +70,13 @@ public sealed class AutomationProposals(
ITaskRepository tasks, ITaskRepository tasks,
TaskboardService? board, TaskboardService? board,
ILogger logger, ILogger logger,
Func<AutomationSettings>? settings = null,
TimeProvider? clock = null) : IAutomationProposals, IAsyncDisposable 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 MaxOpenPerAgent = 3;
public const int MeasureDays = 28; public const int MeasureDays = 28;
@@ -85,6 +103,8 @@ public sealed class AutomationProposals(
public async Task<AutomationOutcome> ProposeAsync(AutomationRequest r, CancellationToken ct) public async Task<AutomationOutcome> ProposeAsync(AutomationRequest r, CancellationToken ct)
{ {
if (!Enabled)
return new(false, DisabledMessage);
if (string.IsNullOrWhiteSpace(r.Purpose) || string.IsNullOrWhiteSpace(r.Trigger)) 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."); return new(false, "'purpose' and 'trigger' are required — what it is for, and what starts it.");
if (r.TaskIds.Count == 0) if (r.TaskIds.Count == 0)
@@ -236,7 +256,7 @@ public sealed class AutomationProposals(
/// </summary> /// </summary>
public async Task<IReadOnlyList<string>> FindCandidatesAsync(CancellationToken ct) public async Task<IReadOnlyList<string>> FindCandidatesAsync(CancellationToken ct)
{ {
if (board is null) if (board is null || !Enabled)
return []; return [];
var since = Now.AddDays(-CandidateDays); var since = Now.AddDays(-CandidateDays);
var proposals = await ListAsync(null, 1000, ct); var proposals = await ListAsync(null, 1000, ct);
@@ -159,6 +159,10 @@ public sealed class InstanceConfig
[JsonPropertyName("fileHistory")] [JsonPropertyName("fileHistory")]
public Files.FileHistorySettings FileHistory { get; set; } = new(); public Files.FileHistorySettings FileHistory { get; set; } = new();
/// <summary>#726: Whether this instance works with n8n — only then do agents propose workflows. Off by default.</summary>
[JsonPropertyName("automation")]
public Automation.AutomationSettings Automation { get; set; } = new();
/// <summary>#197: External MCP servers whose tools the agents may use — set up by a human only.</summary> /// <summary>#197: External MCP servers whose tools the agents may use — set up by a human only.</summary>
[JsonPropertyName("mcpServers")] [JsonPropertyName("mcpServers")]
public List<Mcp.McpServerConfig> McpServers { get; set; } = new(); public List<Mcp.McpServerConfig> McpServers { get; set; } = new();
@@ -698,6 +698,9 @@
"staging.rejected": "Aufruf wurde abgelehnt.", "staging.rejected": "Aufruf wurde abgelehnt.",
"staging.rejectFailed": "Ablehnen fehlgeschlagen: {error}", "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.", "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.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.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.none": "Kein Vorschlag wartet auf dich.",
@@ -698,6 +698,9 @@
"staging.rejected": "Call was rejected.", "staging.rejected": "Call was rejected.",
"staging.rejectFailed": "Rejection failed: {error}", "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.", "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.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.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.none": "No proposal waits for you.",
@@ -86,8 +86,23 @@ export function erstelleMcpServerKarte() {
lade(); 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"), [ return erstelleKarte(t("mcpServers.title"), [
erstelleElement("p", { klasse: "gedaempft text-klein", text: t("mcpServers.hint") }), 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, liste,
erstelleElement("h4", { text: t("mcpServers.add") }), erstelleElement("h4", { text: t("mcpServers.add") }),
erstelleElement("div", { klasse: "formular-raster" }, erstelleElement("div", { klasse: "formular-raster" },
@@ -431,6 +431,13 @@ export function erstelleAutomationsKarte() {
const box = erstelleElement("div", { klasse: "automationen" }); const box = erstelleElement("div", { klasse: "automationen" });
async function lade() { 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 = []; let liste = [];
try { liste = (await api("/staging/automations?status=proposed")) || []; } catch { liste = []; } try { liste = (await api("/staging/automations?status=proposed")) || []; } catch { liste = []; }
if (!liste.length) { if (!liste.length) {
@@ -24,7 +24,20 @@ public sealed class EscalationTool : IAgentTool
{ {
public string Name => CaseDesk.ToolName; public string Name => CaseDesk.ToolName;
public string Description => """ /// <summary>
/// #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.
/// </summary>
public Func<bool>? 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. 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. - 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. - 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; 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: 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. 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. 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) if (context.Automations is not { } automations)
return ToolResult.Fail("Automation proposals are not available in this instance."); 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") if (Get(input, "action") == "automation_find")
{ {
@@ -11,3 +11,6 @@ public sealed record AutomationProposalDto(
string Status, string? Reason, DateTime CreatedAt, DateTime? DecidedAt, long? Answers); string Status, string? Reason, DateTime CreatedAt, DateTime? DecidedAt, long? Answers);
public sealed record AutomationDecisionDto(bool Accept, string? Reason); public sealed record AutomationDecisionDto(bool Accept, string? Reason);
/// <summary>#726: Whether the instance works with n8n.</summary>
public sealed record AutomationSettingsDto(bool Enabled);
@@ -387,6 +387,18 @@ internal static class ApiEndpoints
p.Status, p.Reason, p.CreatedAt, p.DecidedAt, p.Answers)).ToList()); 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) => 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 host.Automations is { } automations && (await automations.ListAsync(null, 1000, ct)).FirstOrDefault(p => p.Id == id) is { } proposal
? Results.Text(proposal.WorkflowJson, "application/json") ? Results.Text(proposal.WorkflowJson, "application/json")
@@ -165,6 +165,22 @@ public sealed class AutomationProposalsTests : IDisposable
varied.ShouldNotBe(same); 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] [Fact]
public void The_signature_folds_repeats_and_leaves_out_bookkeeping() public void The_signature_folds_repeats_and_leaves_out_bookkeeping()
{ {
@@ -0,0 +1,20 @@
using ClawdDotNet.Tools.Taskboard;
using Shouldly;
namespace ClawdDotNet.Tools.Tests.Taskboard;
/// <summary>#726: Agents hear of workflow proposals only in an instance that works with n8n.</summary>
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");
}
}