diff --git a/src/ClawdDotNet.App/AppHost.cs b/src/ClawdDotNet.App/AppHost.cs index 1cd4d9a..1139ee5 100644 --- a/src/ClawdDotNet.App/AppHost.cs +++ b/src/ClawdDotNet.App/AppHost.cs @@ -565,6 +565,12 @@ public sealed class AppHost : IAsyncDisposable return string.IsNullOrWhiteSpace(agent?.AgentDir) ? null : agent.AgentDir; }); + // #683: The organisation tree, fresh from the configuration on every question, so a + // change made through the administration applies to the next run without a restart. + engine.OrganisationProvider = () => Core.Organisation.OrgChart.From(instance); + foreach (var problem in Core.Organisation.OrgChart.From(instance).Problems()) + logger.LogWarning("Organisation: {Problem}", problem); + engine.LoadPersistedChats(); var (scanner, staging, board) = BuildTaskboard( diff --git a/src/ClawdDotNet.Core/Engine/AgentEngine.cs b/src/ClawdDotNet.Core/Engine/AgentEngine.cs index 496f81a..fb378cf 100644 --- a/src/ClawdDotNet.Core/Engine/AgentEngine.cs +++ b/src/ClawdDotNet.Core/Engine/AgentEngine.cs @@ -44,6 +44,21 @@ public sealed class AgentEngine : IAgentMessageRouter, Staging.IFrozenCallExecut /// #711: Who is working right now and who reached whom — for the team overview. public Activity.AgentActivity Activity { get; } = new(); + /// + /// #683: The organisation tree, built fresh from the configuration on each call. Without + /// it, the instance has no structure and every agent reaches every other, as before. + /// + public Func? OrganisationProvider { get; set; } + + private Organisation.OrgChart CurrentOrganisation() + => OrganisationProvider?.Invoke() ?? new Organisation.OrgChart([], _agentConfigProvider?.Invoke() ?? []); + + /// The agent's system prompt with its organisation section (#683). + private string SystemPromptFor(AgentConfig agentConfig, bool hasTools) + => Organisation.OrgBriefing.WithSection( + UntrustedContent.MitRegel(agentConfig.FullSystemPrompt, hasTools), + Organisation.OrgBriefing.For(CurrentOrganisation(), agentConfig.AgentId, _clock.GetUtcNow().UtcDateTime)); + /// /// Wie viele Weitergaben eine Delegationskette höchstens haben darf (B8). /// A→B ist die Tiefe 1, A→B→C die Tiefe 2. @@ -226,7 +241,8 @@ public sealed class AgentEngine : IAgentMessageRouter, Staging.IFrozenCallExecut var messages = new List(); // #193: Die Regel zu gerahmten Werkzeugergebnissen gehoert zum festen Teil. - var systemPrompt = UntrustedContent.MitRegel(agentConfig.FullSystemPrompt, tools.Count > 0); + // #683: dazu der Abschnitt zur Organisation. + var systemPrompt = SystemPromptFor(agentConfig, tools.Count > 0); if (!string.IsNullOrWhiteSpace(systemPrompt)) messages.Add(ChatMessage.System(systemPrompt)); @@ -556,11 +572,22 @@ public sealed class AgentEngine : IAgentMessageRouter, Staging.IFrozenCallExecut messages = new List(); // #193 - siehe RunCoreAsync. Bestehende Verlaeufe behalten ihren // Systemprompt; die Regel kommt mit dem naechsten neuen Kontext. - var systemPrompt = UntrustedContent.MitRegel(agentConfig.FullSystemPrompt, tools.Count > 0); + var systemPrompt = SystemPromptFor(agentConfig, tools.Count > 0); if (!string.IsNullOrWhiteSpace(systemPrompt)) messages.Add(ChatMessage.System(systemPrompt)); _chatContexts[agentConfig.AgentId] = messages; } + else if (messages.Count > 0 && messages[0].Role == "system") + { + // #683: Nur der Organisationsabschnitt folgt der Struktur — nach einem + // Umzug weiss der Agent sonst im laufenden Chat nichts davon. Der Rest + // des bestehenden Systemprompts bleibt, wie er war (#193). + var section = Organisation.OrgBriefing.For( + CurrentOrganisation(), agentConfig.AgentId, _clock.GetUtcNow().UtcDateTime); + var refreshed = Organisation.OrgBriefing.WithSection(messages[0].Content ?? "", section); + if (refreshed != messages[0].Content) + messages[0] = ChatMessage.System(refreshed); + } } // Routing-Hinweis: Dem Agenten mitteilen, woher die Nachricht kommt @@ -1052,19 +1079,41 @@ public sealed class AgentEngine : IAgentMessageRouter, Staging.IFrozenCallExecut $"Agent '{toAgentId}' wartet in dieser Kette bereits auf eine Antwort und " + "kann keinen zweiten Auftrag annehmen, ohne sich selbst zu blockieren."; - if (callerChain.Depth >= MaxDelegationDepth) + // #683: Die Grenze folgt der Tiefe des Baums; der eingestellte Wert bleibt Untergrenze. + var maxDepth = CurrentOrganisation().DelegationDepth(MaxDelegationDepth); + if (callerChain.Depth >= maxDepth) return $"Delegationstiefe erschöpft: {callerChain} hat die Grenze von " + - $"{MaxDelegationDepth} Weitergaben erreicht. Erledige den Rest selbst " + + $"{maxDepth} Weitergaben erreicht. Erledige den Rest selbst " + "oder gib die Aufgabe an den Auftraggeber zurück."; return null; } + /// + /// #683: The address book, enforced — not merely described in the prompt. A refused + /// contact is an answer to the caller, naming the way that is open: its lead. + /// + private string? CheckAddressBook(string fromAgentId, string toAgentId) + { + var org = CurrentOrganisation(); + if (org.CanAddress(fromAgentId, toAgentId)) + return null; + + var lead = org.EffectiveLeaderOf(fromAgentId); + return $"'{toAgentId}' is not in your address book: from your position in the organisation you " + + "cannot reach this agent directly. " + + (lead is not null + ? $"Hand the matter to your lead '{lead.AgentId}', who can pass it on." + : "Create a task for @human if it needs someone outside your reach."); + } + public IReadOnlyList ListAgents(string callerAgentId) { var configs = _agentConfigProvider?.Invoke() ?? []; + // #683: Only the agent's address book — the cut of the organisation. + var reachable = CurrentOrganisation().AddressBook(callerAgentId); return configs - .Where(a => a.AgentId != callerAgentId) + .Where(a => a.AgentId != callerAgentId && reachable.Contains(a.AgentId)) .Select(a => new AgentInfo(a.AgentId, a.DisplayName, a.Description, a.Model, IsRunning(a.AgentId))) .ToList(); } @@ -1097,6 +1146,9 @@ public sealed class AgentEngine : IAgentMessageRouter, Staging.IFrozenCallExecut if (targetConfig is null) return new AgentMessageResult(false, null, $"Agent '{toAgentId}' not found."); + if (CheckAddressBook(fromAgentId, toAgentId) is { } outOfReach) + return new AgentMessageResult(false, null, outOfReach); + if (CheckDelegation(chain, fromAgentId, toAgentId) is { } refused) return new AgentMessageResult(false, null, refused); @@ -1149,6 +1201,9 @@ public sealed class AgentEngine : IAgentMessageRouter, Staging.IFrozenCallExecut // Auch der Spawn zählt: Er startet einen vollen Lauf und kann seinerseits // delegieren. Ohne diese Prüfung wäre die Kette über spawn zu umgehen. + if (CheckAddressBook(fromAgentId, targetAgentId) is { } outOfReach) + return new AgentSpawnResult(false, targetAgentId, null, outOfReach); + if (CheckDelegation(chain, fromAgentId, targetAgentId) is { } refused) return new AgentSpawnResult(false, targetAgentId, null, refused); @@ -1606,7 +1661,10 @@ public sealed class AgentEngine : IAgentMessageRouter, Staging.IFrozenCallExecut this, _memoryRepository, _taskRepository, - chain ?? DelegationChain.Root(agentConfig.AgentId)); + chain ?? DelegationChain.Root(agentConfig.AgentId)) + { + Organisation = CurrentOrganisation() + }; } /// diff --git a/src/ClawdDotNet.Core/Organisation/OrgBriefing.cs b/src/ClawdDotNet.Core/Organisation/OrgBriefing.cs new file mode 100644 index 0000000..5eda4f2 --- /dev/null +++ b/src/ClawdDotNet.Core/Organisation/OrgBriefing.cs @@ -0,0 +1,124 @@ +using System.Text; + +namespace ClawdDotNet.Core.Organisation; + +/// +/// #683: The "Organisation" section of an agent's system prompt — its unit, its lead, whom it +/// can reach, and what changed recently. +/// +/// Why the code writes it and not the agent's own texts. The concept demands +/// that agents are told when they move, and warns: an agent that has to infer its lead from +/// a prompt escalates to the wrong place. A section generated from the tree is always true — +/// a move changes it by itself, for the agent that moved and for both leads. +/// +/// Without units there is no section, so instances without a structure keep +/// exactly the prompt they had — and the prompt cache that goes with it. +/// +public static class OrgBriefing +{ + /// Starts the section; also how an existing chat context finds it to replace it. + public const string Heading = "# Organisation"; + + /// How long a move or a new colleague is pointed out. + public static readonly TimeSpan RecentChange = TimeSpan.FromDays(7); + + private const int MaxDescription = 120; + + public static string? For(OrgChart org, string agentId, DateTime nowUtc) + { + if (!org.HasUnits || org.Agent(agentId) is not { } agent) + return null; + + var sb = new StringBuilder(); + sb.AppendLine(Heading); + + var unit = org.UnitOf(agentId); + if (unit is null) + { + sb.AppendLine("You are on the top level of this instance, above all units."); + } + else + { + sb.Append($"You belong to the unit \"{unit.Name}\" ({org.PathOf(unit.Id)})"); + if (agent.UnitSinceUtc is { } since) + { + sb.Append($", since {since:yyyy-MM-dd}"); + if (nowUtc - since < RecentChange) + sb.Append(" — this is recent: your lead, your colleagues and possibly your tools changed"); + } + sb.AppendLine("."); + + if (!string.IsNullOrWhiteSpace(unit.Goal)) + sb.AppendLine($"Goal of your unit: {unit.Goal.Trim()}"); + } + + var lead = org.EffectiveLeaderOf(agentId); + if (lead is not null) + sb.AppendLine($"Your lead: {Name(lead)}. Questions, problems and anything outside your unit go to your lead."); + else + sb.AppendLine("There is no lead agent above you. What you cannot decide goes to a human: create a task for @human."); + + if (org.LedBy(agentId) is { } led) + { + sb.AppendLine($"You lead the unit \"{led.Name}\". Delegate within it, and hand what goes beyond it to your own lead."); + var subUnits = org.Children(led.Id); + if (subUnits.Count > 0) + { + sb.AppendLine("Its sub-units: " + string.Join("; ", subUnits.Select(u => + org.LeaderOf(u.Id) is { } l ? $"{u.Name} (lead: {l.AgentId})" : $"{u.Name} (no lead — its members report to you)")) + "."); + } + + var joined = org.MembersOf(led.Id) + .Where(m => m.AgentId != agentId && m.UnitSinceUtc is { } s && nowUtc - s < RecentChange) + .ToList(); + if (joined.Count > 0) + sb.AppendLine("New in your unit: " + string.Join(", ", joined.Select(m => $"{m.AgentId} since {m.UnitSinceUtc:yyyy-MM-dd}")) + "."); + } + + var contacts = org.AddressBook(agentId) + .Where(id => lead is null || !string.Equals(id, lead.AgentId, StringComparison.OrdinalIgnoreCase)) + .Select(id => org.Agent(id)!) + .OrderBy(a => a.AgentId, StringComparer.OrdinalIgnoreCase) + .ToList(); + + if (contacts.Count > 0) + { + sb.AppendLine("You can also reach:"); + foreach (var contact in contacts) + sb.AppendLine($"- {Name(contact)}{Describe(contact.Description)}"); + } + + sb.Append("Agents not named here cannot be reached from your position — go through your lead."); + return sb.ToString(); + } + + /// + /// Replaces the organisation section of a system prompt, appends it, or removes it — + /// leaving everything before it untouched. Existing chat contexts keep their system + /// prompt otherwise (#193); only this section follows the structure. + /// + public static string WithSection(string systemPrompt, string? section) + { + var start = systemPrompt.IndexOf("\n\n" + Heading + "\n", StringComparison.Ordinal); + var basePrompt = start >= 0 ? systemPrompt[..start] : systemPrompt; + if (start < 0 && systemPrompt.StartsWith(Heading + "\n", StringComparison.Ordinal)) + basePrompt = ""; + + if (string.IsNullOrWhiteSpace(section)) + return basePrompt; + return basePrompt.Length == 0 ? section : basePrompt + "\n\n" + section; + } + + private static string Name(Config.AgentConfig agent) + => string.IsNullOrWhiteSpace(agent.DisplayName) || agent.DisplayName == agent.AgentId + ? agent.AgentId + : $"{agent.DisplayName} ({agent.AgentId})"; + + private static string Describe(string? description) + { + if (string.IsNullOrWhiteSpace(description)) + return ""; + var line = description.ReplaceLineEndings(" ").Trim(); + return " — " + (line.Length > MaxDescription ? line[..MaxDescription] + "…" : line); + } +} diff --git a/src/ClawdDotNet.Core/Tools/AgentToolContext.cs b/src/ClawdDotNet.Core/Tools/AgentToolContext.cs index a0d53e4..3198e27 100644 --- a/src/ClawdDotNet.Core/Tools/AgentToolContext.cs +++ b/src/ClawdDotNet.Core/Tools/AgentToolContext.cs @@ -34,4 +34,11 @@ public sealed record AgentToolContext( /// Ohne Freigabestelle bleibt es false, und das Werkzeug prueft selbst. /// public bool FreigabeGeprueft { get; init; } + + /// + /// #683: The organisation at the time of the call. Tools that let an agent address + /// another — a task assignment, for instance — check the address book here, so it cannot + /// be walked around. null in tests and outside the engine: no structure, no limit. + /// + public Organisation.OrgChart? Organisation { get; init; } } diff --git a/src/ClawdDotNet.Tools.Taskboard/TaskboardTool.cs b/src/ClawdDotNet.Tools.Taskboard/TaskboardTool.cs index e0c71b3..631e773 100644 --- a/src/ClawdDotNet.Tools.Taskboard/TaskboardTool.cs +++ b/src/ClawdDotNet.Tools.Taskboard/TaskboardTool.cs @@ -151,6 +151,12 @@ public sealed class TaskboardTool : IAgentTool if (string.IsNullOrWhiteSpace(action)) return ToolResult.Fail("'action' is required."); + // #683: A task for another agent is a way of reaching it — the same address book + // applies as for a message, otherwise it could be walked around. + if (action is "task_create" or "task_update" + && CheckAssignee(GetString(input, "assignee"), context) is { } outOfReach) + return ToolResult.Fail(outOfReach); + try { return action switch @@ -170,6 +176,26 @@ public sealed class TaskboardTool : IAgentTool } } + private static string? CheckAssignee(string? assignee, AgentToolContext context) + { + if (context.Organisation is not { HasUnits: true } org || string.IsNullOrWhiteSpace(assignee)) + return null; + if (TaskAssignee.KindOf(assignee) == TaskAssigneeKind.Human) + return null; + + var target = TaskAssignee.AgentId(assignee); + if (target.Length == 0 || string.Equals(target, context.AgentId, StringComparison.OrdinalIgnoreCase)) + return null; + if (org.CanAddress(context.AgentId, target)) + return null; + + var lead = org.EffectiveLeaderOf(context.AgentId); + return $"'{target}' is not in your address book, so you cannot assign tasks to it. " + + (lead is not null + ? $"Assign it to your lead '{lead.AgentId}', who can pass it on." + : "Assign it to @human."); + } + // ─── Aktionen ─── /// diff --git a/tests/ClawdDotNet.Core.Tests/Organisation/AddressBookEnforcementTests.cs b/tests/ClawdDotNet.Core.Tests/Organisation/AddressBookEnforcementTests.cs new file mode 100644 index 0000000..bdc7830 --- /dev/null +++ b/tests/ClawdDotNet.Core.Tests/Organisation/AddressBookEnforcementTests.cs @@ -0,0 +1,178 @@ +using ClawdDotNet.Core.Config; +using ClawdDotNet.Core.Organisation; +using ClawdDotNet.Core.Tests.Infrastructure; +using ClawdDotNet.Core.Tools; +using Shouldly; + +namespace ClawdDotNet.Core.Tests.Organisation; + +/// +/// #683: The address book is enforced by the engine — not merely described in a prompt that +/// a model may or may not follow. And every agent is told where it stands. +/// +public sealed class AddressBookEnforcementTests +{ + /// marketing (lead: marketing-lead) › amazon (lead: amazon-lead, ads); accounting: bookkeeper. + private static (EngineFixture Fixture, List Units) Shop() + { + var fixture = new EngineFixture(); + var units = new List + { + new() { Id = "marketing", Name = "Marketing", LeaderAgentId = "marketing-lead" }, + new() { Id = "amazon", Name = "Amazon", ParentId = "marketing", LeaderAgentId = "amazon-lead", Goal = "Sell on Amazon." }, + new() { Id = "accounting", Name = "Accounting" } + }; + + foreach (var (id, unit) in new[] + { + ("marketing-lead", "marketing"), ("amazon-lead", "amazon"), ("ads", "amazon"), ("bookkeeper", "accounting") + }) + fixture.AddAgent(id).UnitId = unit; + + fixture.Engine.OrganisationProvider = () => new OrgChart(units, fixture.Agents); + return (fixture, units); + } + + // ═══════════════════ messages and spawns ═══════════════════ + + [Fact] + public async Task A_message_outside_the_address_book_is_refused_with_the_way_that_is_open() + { + var (shop, _) = Shop(); + + var result = await shop.Engine.SendMessageAsync("ads", "bookkeeper", "Kannst du zahlen?", "inst", null, default); + + result.Delivered.ShouldBeFalse(); + result.Error.ShouldNotBeNull().ShouldContain("not in your address book"); + result.Error.ShouldContain("amazon-lead", Case.Sensitive, "the way that is open: the own lead"); + shop.Client.CallCount.ShouldBe(0, "refused before any model call"); + } + + [Fact] + public async Task A_message_to_the_own_lead_goes_through() + { + var (shop, _) = Shop(); + shop.Client.RespondsWithText("Verstanden."); + + var result = await shop.Engine.SendMessageAsync("ads", "amazon-lead", "Frage", "inst", null, default); + + result.Delivered.ShouldBeTrue(result.Error); + } + + [Fact] + public async Task A_spawn_is_held_to_the_same_address_book() + { + var (shop, _) = Shop(); + + var result = await shop.Engine.SpawnAgentAsync("ads", "bookkeeper", "Buche das", "inst", null, default); + + result.Started.ShouldBeFalse(); + result.Error.ShouldNotBeNull().ShouldContain("not in your address book"); + } + + [Fact] + public void The_agent_list_shows_only_the_address_book() + { + var (shop, _) = Shop(); + + shop.Engine.ListAgents("ads").Select(a => a.AgentId).ShouldBe(["amazon-lead"]); + } + + [Fact] + public void Without_units_the_agent_list_is_unchanged() + { + var fixture = new EngineFixture(); + fixture.AddAgent("a"); + fixture.AddAgent("b"); + fixture.AddAgent("c"); + + fixture.Engine.ListAgents("a").Select(x => x.AgentId).ShouldBe(["b", "c"], ignoreOrder: true); + } + + // ═══════════════════ delegation depth ═══════════════════ + + [Fact] + public async Task The_delegation_limit_follows_the_depth_of_the_tree() + { + // Five levels deep: with the old fixed limit of 3, the bottom could not be reached. + var fixture = new EngineFixture(); + var units = new List(); + string? parent = null; + for (var level = 1; level <= 5; level++) + { + units.Add(new UnitConfig { Id = $"u{level}", Name = $"U{level}", ParentId = parent, LeaderAgentId = $"lead{level}" }); + fixture.AddAgent($"lead{level}").UnitId = $"u{level}"; + parent = $"u{level}"; + } + fixture.Engine.OrganisationProvider = () => new OrgChart(units, fixture.Agents); + fixture.Client.RespondsWithText("ok"); + + // lead1 → lead2 → lead3 → lead4 → lead5: the fourth hand-over. + var chain = DelegationChain.Root("lead1").Append("lead2").Append("lead3").Append("lead4"); + var result = await fixture.Engine.SendMessageAsync("lead4", "lead5", "weiter", "inst", chain, default); + + result.Delivered.ShouldBeTrue(result.Error); + } + + // ═══════════════════ the organisation section ═══════════════════ + + [Fact] + public async Task Every_run_tells_the_agent_its_unit_and_its_lead() + { + var (shop, _) = Shop(); + shop.Client.RespondsWithText("ok"); + + await shop.Engine.RunAsync(shop.Agents.First(a => a.AgentId == "ads"), "Los", "inst", default); + + var system = shop.Client.ReceivedRequests[0].Messages[0].Content!; + system.ShouldContain(OrgBriefing.Heading); + system.ShouldContain("\"Amazon\" (Marketing / Amazon)"); + system.ShouldContain("Your lead: amazon-lead"); + system.ShouldContain("Goal of your unit: Sell on Amazon."); + system.ShouldNotContain("bookkeeper", Case.Sensitive, "not in the address book — not in the prompt either"); + } + + [Fact] + public async Task A_move_reaches_a_running_chat_context() + { + var (shop, units) = Shop(); + var ads = shop.Agents.First(a => a.AgentId == "ads"); + shop.Client.RespondsWithText("ok").RespondsWithText("ok"); + + await shop.Engine.ChatAsync(ads, "Hallo", "inst", default); + shop.Client.ReceivedRequests[0].Messages[0].Content!.ShouldContain("Your lead: amazon-lead"); + + // Moved to accounting, which has no lead: the next run knows it. + ads.UnitId = "accounting"; + ads.UnitSinceUtc = DateTime.UtcNow; + await shop.Engine.ChatAsync(ads, "Und jetzt?", "inst", default); + + var system = shop.Client.ReceivedRequests[1].Messages[0].Content!; + system.ShouldContain("\"Accounting\""); + system.ShouldContain("this is recent"); + system.ShouldContain("There is no lead agent above you"); + system.ShouldStartWith("Du bist ads.", Case.Sensitive, "the rest of the prompt is untouched"); + } + + [Fact] + public async Task Without_units_the_prompt_has_no_organisation_section() + { + var fixture = new EngineFixture(); + var agent = fixture.AddAgent("solo"); + fixture.Client.RespondsWithText("ok"); + + await fixture.Engine.RunAsync(agent, "Los", "inst", default); + + fixture.Client.ReceivedRequests[0].Messages[0].Content!.ShouldNotContain(OrgBriefing.Heading); + } + + [Fact] + public void The_section_is_replaced_not_stacked() + { + var once = OrgBriefing.WithSection("Base.", "# Organisation\nA"); + var twice = OrgBriefing.WithSection(once, "# Organisation\nB"); + + twice.ShouldBe("Base.\n\n# Organisation\nB"); + OrgBriefing.WithSection(twice, null).ShouldBe("Base."); + } +} diff --git a/tests/ClawdDotNet.Tools.Tests/Taskboard/AssignmentReachTests.cs b/tests/ClawdDotNet.Tools.Tests/Taskboard/AssignmentReachTests.cs new file mode 100644 index 0000000..02f6162 --- /dev/null +++ b/tests/ClawdDotNet.Tools.Tests/Taskboard/AssignmentReachTests.cs @@ -0,0 +1,98 @@ +using System.Text.Json; +using ClawdDotNet.Core.Config; +using ClawdDotNet.Core.Organisation; +using ClawdDotNet.Core.Storage; +using ClawdDotNet.Core.Tasks; +using ClawdDotNet.Core.Tools; +using ClawdDotNet.Tools.Taskboard; +using ClawdDotNet.Tools.Tests.FileRW; +using Microsoft.Extensions.Logging.Abstractions; +using Shouldly; + +namespace ClawdDotNet.Tools.Tests.Taskboard; + +/// +/// #683: A task for another agent is a way of reaching it. The address book that holds for +/// messages holds here too — otherwise it could be walked around with task_create. +/// +public sealed class AssignmentReachTests : IDisposable +{ + private readonly string _directory; + private readonly SqliteStorage _storage; + private readonly SqliteTaskRepository _repo; + private readonly TaskboardTool _tool = new(); + + private readonly OrgChart _org = new( + [ + new UnitConfig { Id = "amazon", Name = "Amazon", LeaderAgentId = "amazon-lead" }, + new UnitConfig { Id = "accounting", Name = "Accounting", LeaderAgentId = "accounting-lead" } + ], + [ + new AgentConfig { AgentId = "amazon-lead", UnitId = "amazon" }, + new AgentConfig { AgentId = "ads", UnitId = "amazon" }, + new AgentConfig { AgentId = "accounting-lead", UnitId = "accounting" }, + new AgentConfig { AgentId = "bookkeeper", UnitId = "accounting" } + ]); + + public AssignmentReachTests() + { + _directory = Path.Combine(Path.GetTempPath(), "clawd-tests", Guid.NewGuid().ToString("N")); + _storage = new SqliteStorage(Path.Combine(_directory, "state.db")); + _repo = new SqliteTaskRepository(_storage); + } + + public void Dispose() + { + Microsoft.Data.Sqlite.SqliteConnection.ClearAllPools(); + try { Directory.Delete(_directory, recursive: true); } + catch { /* cleanup is secondary */ } + } + + private Task Run(string agentId, string json) + { + var context = new AgentToolContext( + agentId, "inst", new Dictionary(), new NullStateStore(), NullLogger.Instance, + CancellationToken.None, SharedWorkspacePath: Path.Combine(_directory, "SharedWorkspace"), Tasks: _repo) + { + Organisation = _org + }; + return _tool.ExecuteAsync(JsonDocument.Parse(json).RootElement, context, CancellationToken.None); + } + + [Fact] + public async Task A_task_for_an_agent_outside_the_address_book_is_refused() + { + var result = await Run("ads", """{"action":"task_create","title":"Rechnung buchen","assignee":"@bookkeeper"}"""); + + result.Success.ShouldBeFalse(); + result.ErrorMessage.ShouldNotBeNull().ShouldContain("not in your address book"); + result.ErrorMessage.ShouldContain("amazon-lead"); + } + + [Fact] + public async Task A_fresh_run_of_an_agent_outside_is_refused_too() + { + var result = await Run("ads", """{"action":"task_create","title":"Rechnung buchen","assignee":"@new:bookkeeper"}"""); + + result.Success.ShouldBeFalse(); + } + + [Fact] + public async Task A_task_for_the_own_lead_or_the_human_goes_through() + { + (await Run("ads", """{"action":"task_create","title":"Frage an die Leitung","assignee":"@amazon-lead"}""")).Success.ShouldBeTrue(); + (await Run("ads", """{"action":"task_create","title":"Frage an den Menschen","assignee":"@human"}""")).Success.ShouldBeTrue(); + (await Run("ads", """{"action":"task_create","title":"Eigene Aufgabe","assignee":"@ads"}""")).Success.ShouldBeTrue(); + } + + [Fact] + public async Task Reassigning_is_held_to_the_same_rule() + { + var created = await Run("ads", """{"action":"task_create","title":"Eigene Aufgabe","assignee":"@ads"}"""); + var id = created.Content!.Split(' ', ':', '\n').First(w => w.StartsWith("t-")); + + var result = await Run("ads", $$"""{"action":"task_update","id":"{{id}}","assignee":"@bookkeeper"}"""); + + result.Success.ShouldBeFalse(); + } +}