Entscheidungsinstanz an der Spitze: Mensch, Agent oder Mensch zuerst mit Frist (#690)
- cases.top in instance.json: Human | Agent | HumanFirst, agentId, humanFirstHours - TopRole: der entscheidende Agent steht in keiner Einheit, liest keine Fremdinhalte und hat nur Taskboard, Escalation, Memory, FileRW, AgentComm; geprueft beim Setzen, bei jeder Zustellung und vor jeder Entscheidung - CaseDesk: was bisher an der Spitze beim Menschen landete, geht nach Einstellung an den Agenten; Tagesdeckel und Frist gehen weiter an den Menschen. handle verwehrt, forward geht an den Menschen. Mensch zuerst: Uebergabe nach Ablauf, eigene Frist. Der Mensch kann einen Fall des Agenten selbst entscheiden. - Beschluesse des Agenten: ByTopAgent, zum Lesen vorgelegt; jeder Beschluss laesst sich mit Begruendung zuruecknehmen und bindet dann niemanden mehr - Briefing fuer den Agenten an der Spitze, auch ohne Einheiten - OrgAdministration.SetTop mit Audit; Werkzeugsperre und Verschiebesperre - WebUI Vorgaenge: Reiter Spitze, Lesen/Zuruecknehmen, Zaehler; i18n en/de - Tests: TopRoleTests (15), OrgAdministrationTests (+2) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
e22edb418a
commit
e8964e3c74
@@ -1,4 +1,5 @@
|
||||
using ClawdDotNet.App.Services;
|
||||
using ClawdDotNet.Core.Cases;
|
||||
using ClawdDotNet.Core.Config;
|
||||
using Shouldly;
|
||||
|
||||
@@ -249,4 +250,47 @@ public sealed class OrgAdministrationTests : IDisposable
|
||||
Find("newbie").Budget.DailyCostUsd.ShouldBe(7);
|
||||
Find("newbie").UnitId.ShouldBe("amazon");
|
||||
}
|
||||
|
||||
// ═══════════════════ the top (#690) ═══════════════════
|
||||
|
||||
private void MakeDecider(string id)
|
||||
{
|
||||
_test.Host.AgentAdmin.SetToolEnabled(id, "WebFetch", false).Ok.ShouldBeTrue();
|
||||
_test.Host.AgentAdmin.SetToolEnabled(id, "Database", false).Ok.ShouldBeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Only_an_agent_that_fits_can_take_the_top()
|
||||
{
|
||||
BuildMarketing();
|
||||
|
||||
Org.SetTop(TopMode.Agent, null).Ok.ShouldBeFalse("an agent at the top needs a name");
|
||||
Org.SetTop(TopMode.Agent, "bookkeeper").Message.ShouldContain("Database");
|
||||
Org.SetTop(TopMode.Agent, "marketing-lead").Message.ShouldContain("belongs to a unit");
|
||||
|
||||
MakeDecider("bookkeeper");
|
||||
var set = Org.SetTop(TopMode.HumanFirst, "bookkeeper", 3, "decisions at night");
|
||||
|
||||
set.Ok.ShouldBeTrue(set.Message);
|
||||
var top = _test.Host.Instance.Cases.Top;
|
||||
top.Mode.ShouldBe(TopMode.HumanFirst);
|
||||
top.AgentId.ShouldBe("bookkeeper");
|
||||
top.HumanFirstHours.ShouldBe(3);
|
||||
|
||||
Org.SetTop(TopMode.Human, null).Ok.ShouldBeTrue();
|
||||
_test.Host.Instance.Cases.Top.AgentId.ShouldBeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void The_deciding_agent_cannot_be_given_outside_tools_nor_moved_into_a_unit()
|
||||
{
|
||||
BuildMarketing();
|
||||
MakeDecider("bookkeeper");
|
||||
Org.SetTop(TopMode.Agent, "bookkeeper").Ok.ShouldBeTrue();
|
||||
|
||||
_test.Host.AgentAdmin.SetToolEnabled("bookkeeper", "Mail", true).Message.ShouldContain("deciding agent");
|
||||
_test.Host.AgentAdmin.SetToolEnabled("bookkeeper", "Memory", true).Ok.ShouldBeTrue();
|
||||
Org.MoveAgent("bookkeeper", "marketing", "reorg").Ok.ShouldBeFalse();
|
||||
Find("bookkeeper").UnitId.ShouldBeNull();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,290 @@
|
||||
using ClawdDotNet.Core.Cases;
|
||||
using ClawdDotNet.Core.Config;
|
||||
using ClawdDotNet.Core.Organisation;
|
||||
using ClawdDotNet.Core.Storage;
|
||||
using ClawdDotNet.Core.Tasks;
|
||||
using ClawdDotNet.Core.Tests.Infrastructure;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Shouldly;
|
||||
|
||||
namespace ClawdDotNet.Core.Tests.Cases;
|
||||
|
||||
/// <summary>
|
||||
/// #690: The deciding instance at the top — a human, an agent, or the human first with the
|
||||
/// agent behind. The agent decides and does not carry out, its decisions are presented to the
|
||||
/// human, and the deadline holds for it too.
|
||||
/// </summary>
|
||||
public sealed class TopRoleTests : IDisposable
|
||||
{
|
||||
private readonly string _directory = Path.Combine(Path.GetTempPath(), "clawd-tests", "top-" + Guid.NewGuid().ToString("N")[..8]);
|
||||
private readonly SqliteTaskRepository _tasks;
|
||||
private readonly SqliteCaseRepository _cases;
|
||||
private readonly TaskboardService _board;
|
||||
private readonly FakeTimeProvider _clock = new(new DateTimeOffset(2027, 3, 10, 12, 0, 0, TimeSpan.Zero));
|
||||
private readonly CaseSettings _settings = new() { DeadlineHours = 4 };
|
||||
private readonly List<CaseRecord> _called = [];
|
||||
private readonly List<AgentConfig> _agents =
|
||||
[
|
||||
new() { AgentId = "ceo", Tools = { ["Taskboard"] = new(), ["Memory"] = new() } },
|
||||
new() { AgentId = "buy-lead", UnitId = "purchasing" },
|
||||
new() { AgentId = "buyer", UnitId = "purchasing" },
|
||||
new() { AgentId = "dev-lead", UnitId = "devops" }
|
||||
];
|
||||
private readonly CaseDesk _desk;
|
||||
|
||||
/// <summary>purchasing (buy-lead; buyer), devops (dev-lead) — and ceo above both, in no unit.</summary>
|
||||
private OrgChart Org => new(
|
||||
[
|
||||
new UnitConfig { Id = "purchasing", Name = "Purchasing", LeaderAgentId = "buy-lead" },
|
||||
new UnitConfig { Id = "devops", Name = "DevOps", LeaderAgentId = "dev-lead" }
|
||||
],
|
||||
_agents);
|
||||
|
||||
public TopRoleTests()
|
||||
{
|
||||
var storage = new SqliteStorage(Path.Combine(_directory, "state.db"));
|
||||
_tasks = new SqliteTaskRepository(storage);
|
||||
_cases = new SqliteCaseRepository(storage);
|
||||
_board = new TaskboardService(_tasks, Path.Combine(_directory, "tasks"));
|
||||
_desk = new CaseDesk(_cases, _board, () => Org, () => _settings, NullLogger.Instance, _clock,
|
||||
(record, _) => { _called.Add(record); return Task.CompletedTask; });
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
SqliteStorage.ReleasePooledConnections(_directory);
|
||||
try { Directory.Delete(_directory, recursive: true); }
|
||||
catch { /* cleaning up is secondary */ }
|
||||
}
|
||||
|
||||
private void Top(TopMode mode, string? agent = "ceo", double hours = 2)
|
||||
=> _settings.Top = new TopSettings { Mode = mode, AgentId = agent, HumanFirstHours = hours };
|
||||
|
||||
private async Task<CaseRecord> EscalateFromTheLastLead()
|
||||
{
|
||||
var outcome = await _desk.EscalateAsync(new EscalationRequest("buy-lead", AgentTrust.Standard, "Budget for a second warehouse",
|
||||
"Do we rent a second warehouse for the season?", "Compared offers.", "A yes or no.", null, "run-1"), default);
|
||||
outcome.Ok.ShouldBeTrue(outcome.Message);
|
||||
return outcome.Case!;
|
||||
}
|
||||
|
||||
private void Advance(TimeSpan by) => _clock.Advance(by);
|
||||
|
||||
// ═══════════════════ who holds it ═══════════════════
|
||||
|
||||
[Fact]
|
||||
public async Task By_default_a_human_decides_at_the_top()
|
||||
{
|
||||
var record = await EscalateFromTheLastLead();
|
||||
|
||||
record.HeldByHuman.ShouldBeTrue();
|
||||
_desk.TopAgentId.ShouldBeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task With_an_agent_at_the_top_the_case_goes_to_it_with_its_own_instructions()
|
||||
{
|
||||
Top(TopMode.Agent);
|
||||
|
||||
var record = await EscalateFromTheLastLead();
|
||||
|
||||
record.Holder.ShouldBe("ceo");
|
||||
var inbox = (await _tasks.GetAsync(record.HolderTaskId, default))!;
|
||||
inbox.Assignee.ShouldBe("@ceo");
|
||||
inbox.Body.ShouldContain("reached the top, and you decide it");
|
||||
inbox.Body.ShouldNotContain("handle (note)", customMessage: "the top decides, it does not carry out");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Below_the_top_nothing_changes()
|
||||
{
|
||||
Top(TopMode.Agent);
|
||||
|
||||
var outcome = await _desk.EscalateAsync(new EscalationRequest("buyer", AgentTrust.Standard, "Supplier terms",
|
||||
"Which payment terms do we accept?", "", "", null, null), default);
|
||||
|
||||
outcome.Case!.Holder.ShouldBe("buy-lead");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task A_decision_at_the_top_binds_everyone_and_is_presented_to_the_human()
|
||||
{
|
||||
Top(TopMode.Agent);
|
||||
var record = await EscalateFromTheLastLead();
|
||||
|
||||
var decided = await _desk.DecideAsync(record.Id, "ceo", "No second warehouse this season.",
|
||||
"The offers cost more than the expected sales margin.", "run-9", default);
|
||||
|
||||
decided.Ok.ShouldBeTrue(decided.Message);
|
||||
decided.Message.ShouldContain("presented to a human");
|
||||
decided.Decision!.ByTopAgent.ShouldBeTrue();
|
||||
decided.Decision.ScopeUnitId.ShouldBeNull("the top decides for the whole instance");
|
||||
decided.Decision.AwaitsReview.ShouldBeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task The_top_agent_decides_but_does_not_handle_and_passes_on_only_to_a_human()
|
||||
{
|
||||
Top(TopMode.Agent);
|
||||
var record = await EscalateFromTheLastLead();
|
||||
|
||||
(await _desk.HandleAsync(record.Id, "ceo", "I rented it.", null, default)).Message.ShouldContain("you do not carry out");
|
||||
|
||||
var forwarded = await _desk.ForwardAsync(record.Id, "ceo", AgentTrust.Standard, "This is a question of money, not of fact.", null, default);
|
||||
forwarded.Ok.ShouldBeTrue(forwarded.Message);
|
||||
forwarded.Case!.HeldByHuman.ShouldBeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task A_human_can_take_a_case_from_the_top_agent()
|
||||
{
|
||||
Top(TopMode.Agent);
|
||||
var record = await EscalateFromTheLastLead();
|
||||
|
||||
var decided = await _desk.DecideAsync(record.Id, CaseRules.Human, "Yes, rent it.", "Last season we ran out of space.", null, default);
|
||||
|
||||
decided.Ok.ShouldBeTrue(decided.Message);
|
||||
decided.Decision!.ByTopAgent.ShouldBeFalse();
|
||||
(await _tasks.GetAsync(record.HolderTaskId, default))!.Status.ShouldBe(TaskItemStatus.Done);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task A_conflict_of_the_top_leads_is_delivered_to_the_top_agent()
|
||||
{
|
||||
Top(TopMode.Agent);
|
||||
var opened = await _desk.OpenConflictAsync(new ConflictRequest("buy-lead", AgentTrust.Standard, "dev-lead",
|
||||
"Delivery date field", "We need a field for the delivery date.", null, null), default);
|
||||
opened.Ok.ShouldBeTrue(opened.Message);
|
||||
|
||||
var stated = await _desk.StatePositionAsync(opened.Case!.Id, "dev-lead", AgentTrust.Standard, "The note field is enough.", null, default);
|
||||
|
||||
stated.Case!.Holder.ShouldBe("ceo");
|
||||
}
|
||||
|
||||
// ═══════════════════ fitting the role ═══════════════════
|
||||
|
||||
[Fact]
|
||||
public async Task An_agent_with_a_tool_that_acts_outside_is_passed_over()
|
||||
{
|
||||
_agents[0].Tools["Mail"] = new();
|
||||
Top(TopMode.Agent);
|
||||
|
||||
var record = await EscalateFromTheLastLead();
|
||||
|
||||
record.HeldByHuman.ShouldBeTrue();
|
||||
record.Trail.Last().Text.ShouldContain("Mail");
|
||||
_desk.TopAgentId.ShouldBeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void The_deciding_agent_stands_outside_the_units_and_holds_no_outside_tools()
|
||||
{
|
||||
TopRole.Unfit(_agents[0], "ceo").ShouldBeNull();
|
||||
TopRole.Unfit(_agents[1], "buy-lead")!.ShouldContain("belongs to a unit");
|
||||
TopRole.Unfit(new AgentConfig { AgentId = "x", Tools = { ["Database"] = new() } }, "x")!.ShouldContain("Database");
|
||||
TopRole.Unfit(new AgentConfig { AgentId = "y", TreatAsForeignReader = true }, "y")!.ShouldContain("outside");
|
||||
TopRole.Unfit(null, "ghost")!.ShouldContain("no agent");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task An_agent_that_stopped_fitting_does_not_decide()
|
||||
{
|
||||
Top(TopMode.Agent);
|
||||
var record = await EscalateFromTheLastLead();
|
||||
_agents[0].Tools["Mail"] = new();
|
||||
|
||||
(await _desk.DecideAsync(record.Id, "ceo", "No.", "Too expensive.", null, default)).Message.ShouldContain("cannot decide at the top");
|
||||
}
|
||||
|
||||
// ═══════════════════ human first ═══════════════════
|
||||
|
||||
[Fact]
|
||||
public async Task Human_first_hands_to_the_agent_after_the_human_had_time_and_the_deadline_still_holds()
|
||||
{
|
||||
Top(TopMode.HumanFirst, hours: 1);
|
||||
var record = await EscalateFromTheLastLead();
|
||||
record.HeldByHuman.ShouldBeTrue();
|
||||
record.TopAgentAt.ShouldNotBeNull();
|
||||
var humanTask = record.HolderTaskId;
|
||||
|
||||
Advance(TimeSpan.FromMinutes(59));
|
||||
await _desk.CheckAsync(_clock.GetUtcNow().UtcDateTime, default);
|
||||
(await _cases.GetAsync(record.Id, default))!.HeldByHuman.ShouldBeTrue("the human still has time");
|
||||
|
||||
Advance(TimeSpan.FromMinutes(2));
|
||||
await _desk.CheckAsync(_clock.GetUtcNow().UtcDateTime, default);
|
||||
var handed = (await _cases.GetAsync(record.Id, default))!;
|
||||
handed.Holder.ShouldBe("ceo");
|
||||
handed.TopAgentAt.ShouldBeNull();
|
||||
handed.DeadlineUtc.ShouldBe(_clock.GetUtcNow().UtcDateTime.AddHours(4), "the agent gets a deadline of its own");
|
||||
(await _tasks.GetAsync(humanTask, default))!.Status.ShouldBe(TaskItemStatus.Canceled);
|
||||
|
||||
Advance(TimeSpan.FromHours(4));
|
||||
await _desk.CheckAsync(_clock.GetUtcNow().UtcDateTime, default);
|
||||
var called = (await _cases.GetAsync(record.Id, default))!;
|
||||
called.HeldByHuman.ShouldBeTrue("the deadline holds for the top agent too");
|
||||
_called.Select(c => c.Id).ShouldContain(record.Id);
|
||||
(await _tasks.GetAsync(handed.HolderTaskId, default))!.Status.ShouldBe(TaskItemStatus.Canceled);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Human_first_ends_when_the_human_decides()
|
||||
{
|
||||
Top(TopMode.HumanFirst, hours: 1);
|
||||
var record = await EscalateFromTheLastLead();
|
||||
|
||||
(await _desk.DecideAsync(record.Id, CaseRules.Human, "Yes.", "Space ran out last year.", null, default)).Ok.ShouldBeTrue();
|
||||
Advance(TimeSpan.FromHours(2));
|
||||
await _desk.CheckAsync(_clock.GetUtcNow().UtcDateTime, default);
|
||||
|
||||
(await _cases.GetAsync(record.Id, default))!.Status.ShouldBe(CaseStatus.Decided);
|
||||
}
|
||||
|
||||
// ═══════════════════ reading and withdrawing ═══════════════════
|
||||
|
||||
[Fact]
|
||||
public async Task A_withdrawn_decision_binds_nobody_and_the_one_who_asked_is_told()
|
||||
{
|
||||
Top(TopMode.Agent);
|
||||
var record = await EscalateFromTheLastLead();
|
||||
var decision = (await _desk.DecideAsync(record.Id, "ceo", "No second warehouse this season.", "Too expensive.", null, default)).Decision!;
|
||||
(await _desk.FindDecisionsAsync("buyer", "Budget for a second warehouse", null, default)).ShouldNotBeEmpty();
|
||||
|
||||
(await _desk.RevokeDecisionAsync(decision.Id, "", default)).Ok.ShouldBeFalse("a withdrawal needs its reason");
|
||||
var revoked = await _desk.RevokeDecisionAsync(decision.Id, "Sales doubled; we need the space.", default);
|
||||
|
||||
revoked.Ok.ShouldBeTrue(revoked.Message);
|
||||
(await _desk.FindDecisionsAsync("buyer", "Budget for a second warehouse", null, default)).ShouldBeEmpty();
|
||||
var stored = (await _cases.GetDecisionAsync(decision.Id, default))!;
|
||||
stored.RevokedReason.ShouldBe("Sales doubled; we need the space.");
|
||||
stored.AwaitsReview.ShouldBeFalse();
|
||||
(await _board.ListAsync(new TaskQuery { Assignee = "@buy-lead" }, default))
|
||||
.ShouldContain(t => t.Title.StartsWith($"Decision {decision.Id} withdrawn"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Reading_a_decision_takes_it_off_the_list()
|
||||
{
|
||||
Top(TopMode.Agent);
|
||||
var record = await EscalateFromTheLastLead();
|
||||
var decision = (await _desk.DecideAsync(record.Id, "ceo", "No.", "Too expensive.", null, default)).Decision!;
|
||||
|
||||
(await _desk.ReviewDecisionAsync(decision.Id, default)).Ok.ShouldBeTrue();
|
||||
|
||||
(await _cases.GetDecisionAsync(decision.Id, default))!.AwaitsReview.ShouldBeFalse();
|
||||
}
|
||||
|
||||
// ═══════════════════ the briefing ═══════════════════
|
||||
|
||||
[Fact]
|
||||
public void The_top_agent_is_told_what_it_may_not_do_with_or_without_units()
|
||||
{
|
||||
var withUnits = OrgBriefing.For(Org, "ceo", DateTime.UtcNow, atTop: true)!;
|
||||
withUnits.ShouldContain("you do not carry them out");
|
||||
withUnits.ShouldContain("Purchasing (lead: buy-lead)");
|
||||
|
||||
var flat = new OrgChart([], [new AgentConfig { AgentId = "ceo" }, new AgentConfig { AgentId = "worker" }]);
|
||||
OrgBriefing.For(flat, "ceo", DateTime.UtcNow, atTop: true)!.ShouldContain("You do not change the structure");
|
||||
OrgBriefing.For(flat, "worker", DateTime.UtcNow).ShouldBeNull("without units the others keep their prompt");
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user