Merge fix/726-n8n-switch: Vorschlaege nur mit n8n (#726)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -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();
|
||||
|
||||
@@ -9,6 +9,16 @@ using Microsoft.Extensions.Logging;
|
||||
|
||||
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>
|
||||
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
|
||||
{
|
||||
/// <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<AutomationOutcome> ProposeAsync(AutomationRequest request, CancellationToken ct);
|
||||
@@ -57,8 +70,13 @@ public sealed class AutomationProposals(
|
||||
ITaskRepository tasks,
|
||||
TaskboardService? board,
|
||||
ILogger logger,
|
||||
Func<AutomationSettings>? 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<AutomationOutcome> 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(
|
||||
/// </summary>
|
||||
public async Task<IReadOnlyList<string>> 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);
|
||||
|
||||
@@ -159,6 +159,10 @@ public sealed class InstanceConfig
|
||||
[JsonPropertyName("fileHistory")]
|
||||
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>
|
||||
[JsonPropertyName("mcpServers")]
|
||||
public List<Mcp.McpServerConfig> McpServers { get; set; } = new();
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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" },
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -24,7 +24,20 @@ public sealed class EscalationTool : IAgentTool
|
||||
{
|
||||
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.
|
||||
- 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")
|
||||
{
|
||||
|
||||
@@ -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);
|
||||
|
||||
/// <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());
|
||||
});
|
||||
|
||||
// #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")
|
||||
|
||||
@@ -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()
|
||||
{
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user