Einheiten, Stufe 2: das Adressbuch durchsetzen und jedem Agenten seinen Platz sagen (#683)
Durchgesetzt vom Code, nicht nur im Prompt beschrieben: - Nachricht und Spawn an einen Agenten ausserhalb des Adressbuchs werden vor jedem Modellaufruf abgelehnt; die Antwort nennt den offenen Weg (die eigene Leitung, sonst @human). - list_agents zeigt nur noch das Adressbuch. - task_create und task_update pruefen die Zuweisung an einen Agenten gegen dasselbe Adressbuch; sonst liesse es sich ueber eine Aufgabe umgehen. - Die Delegationstiefe folgt der Tiefe des Baums (Tiefe + 1), der eingestellte Wert bleibt Untergrenze. Unterrichtet werden, wie das Konzept verlangt: - OrgBriefing erzeugt einen Abschnitt "# Organisation" im Systemprompt: Einheit mit Pfad und Ziel, seit wann (mit Hinweis, wenn frisch), die effektive Leitung oder "@human", als Leitung die Untereinheiten und neue Mitglieder, und wen man sonst erreicht. Er ist immer wahr, weil er aus dem Baum kommt; ein Umzug aendert ihn von selbst. - In einem laufenden Chat wird nur dieser Abschnitt ersetzt; der uebrige Systemprompt bleibt, wie er war (#193). - Ohne Einheiten gibt es keinen Abschnitt und keine Einschraenkung. Bestehende Instanzen behalten ihren Prompt und ihren Prompt-Cache. AppHost setzt den Organisationsbaum als Provider (frisch je Frage, eine Aenderung gilt ab dem naechsten Lauf) und protokolliert Probleme der Struktur beim Start. Tests: AddressBookEnforcementTests (10), AssignmentReachTests (4). 1.317 gruen. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
d014a20c38
commit
626fc8d593
@@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// #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.
|
||||
/// </summary>
|
||||
public sealed class AddressBookEnforcementTests
|
||||
{
|
||||
/// <summary>marketing (lead: marketing-lead) › amazon (lead: amazon-lead, ads); accounting: bookkeeper.</summary>
|
||||
private static (EngineFixture Fixture, List<UnitConfig> Units) Shop()
|
||||
{
|
||||
var fixture = new EngineFixture();
|
||||
var units = new List<UnitConfig>
|
||||
{
|
||||
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<UnitConfig>();
|
||||
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.");
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// #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.
|
||||
/// </summary>
|
||||
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<ToolResult> Run(string agentId, string json)
|
||||
{
|
||||
var context = new AgentToolContext(
|
||||
agentId, "inst", new Dictionary<string, object?>(), 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();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user