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();
+ }
+}