Summary#
[Actor(Roles = [ShopRole.Helper])]
public class Estimator : Agent {
public override Permits May => new Permits {
Alone = [Actions.Of(NoteRepair)], // it writes its note by itself
Never = [Actions.Of(ScrapRepair)], // and it never scraps a repair
}; // everything else that changes anything is PROPOSED
}
var outcome = await Agents.Of<Estimator>().Do(new EstimateRepair(), repair);
if (outcome.Waiting) { … } // it stopped on a person; the answer comes as an event
ApproveAgentProposal(proposal); // …runs it now, as whoever approves
RejectAgentProposal(proposal, "Why not."); // …the agent is told, and goes onSignature#
class Agent { virtual Permits May; } // the agent's ceiling
class SkillBase { virtual Permits? May; // stricter for this skill, never looser
virtual string VersionOf(string action, string argumentsJson); // what a yes covers
virtual string Names(string action, string argument, string value); } // an id → the record's name
class Permits {
Leave Changes, Sends, Spends, ReachesOut; // by what a tool does — each Propose until you say
List<ActionInfo> Alone, Propose, ProposeEveryTime, Never; // tool by tool; the strictest wins
static Permits Everything(); static Permits ChangesAlone();
}
enum Leave { Alone, Propose, ProposeEveryTime, Never }
entity AgentProposal {
AgentTask Task; string Agent; string Skill; string Action; string Label; string Does;
string ArgumentsJson; string ShownJson; string Why; string EvidenceJson;
string SubjectVersion; string Fingerprint;
Principal? DecideBy; // who it is put to
ProposalStatus Status; // Waiting, Done, Failed, Rejected, Stale, Withdrawn
Principal? DecidedBy; string Reason; string? ResultJson; string Problem;
}
ApproveAgentProposal(AgentProposal proposal, bool alwaysLikeThis = false) → ProposalDecision
RejectAgentProposal(AgentProposal proposal, string reason) → ProposalDecision
ProposalsForMe() → List<AgentProposal>
AllowAlone(string agent, ActionInfo tool, string skill = "", bool forEverybody = false) → AgentStandingRule
RevokeStandingRule(AgentStandingRule rule, string why = "")
virtual policy WidensAgentAutonomy => false; // who may say "from now on, alone"
// events: AgentProposed · AgentProposalDecided · AgentTaskWaitingDescription#
What may an agent do alone?#
What its class says — and, said nothing, only what reads:
| A tool that… | Default | Say otherwise with |
|---|---|---|
only reads ([Action(…, Query)]) | runs alone, always | — |
| creates, changes or deletes the app's data | proposed | Changes = Leave.Alone |
sends something to somebody ([Sends]) | proposed | Sends = Leave.Alone |
spends ([Spends]) | proposed | Spends = Leave.Alone |
| reaches outside the app some other way | proposed | ReachesOut = Leave.Alone |
What a tool does is what the compiler proved of the action, so nothing is declared safer than
it is. A tool named in Alone, Propose, ProposeEveryTime or Never is treated as that list says whatever it
does, and a tool in two lists gets the stricter. Permits.ChangesAlone() is the common middle: the app's own data
alone; anything that leaves it proposed. Permits.Everything() proposes nothing.
A skill's May can only be stricter for that skill: the stricter of the agent's and the skill's
decides, tool by tool. Skills.Catalog() says the result per tool (SkillToolInfo.Leave).
What is a proposal?#
The call the agent wants to make, kept exactly: the tool and its arguments, what it does, why — what the model said as it asked for the tool — and what it had looked at: each tool it ran before, with what came back. It is put to the person the work is for; to the agent's sponsor when the work is for nobody (a process).
The work stops where it stands. Do answers an outcome whose Status is Waiting; the receipt says the same and
what it waits on; nothing runs and no model is asked meanwhile. The wait costs nothing and has no deadline of its
own. See [[agent-people#waiting]] for how the work — and a process that gave it — goes on.
With Osysharp.Agents.HumanRequests in the app the proposal is an approval request in that
person's inbox; without it, the app shows ProposalsForMe() wherever it likes and calls the two functions below.
Can a skill send its proposals to somebody else?#
Yes — "refunds go to Finance". A skill answers DecidedBy(action) for a tool, with a queue or with one person:
public class SettleTerms : Skill<Supplier, Vetting> {
public override List<ActionInfo> Tools => [Actions.Of(LookUp), Actions.Of(AgreeTerm)];
// What this skill proposes for a payment term waits in the Finance queue.
public override ProposalRoute? DecidedBy(string action) =>
action == Actions.Of(AgreeTerm).Name ? ProposalRoute.ToQueue("Finance") : null;
}ProposalRoute.ToQueue("Finance")— a work queue, by its name: whoever works that queue decides. A queue is how a role with several holders is asked, and a restricted queue keeps the request to its members (The WorkQueues kit — queues, who holds what, and the service levels promised on any item). It needsOsysharp.Agents.HumanRequests, which puts the proposal there; without it the proposal waits for whoeverDecidesAgentProposalsadmits.ProposalRoute.ToPerson(account)— one person: the holder of a role the app looks up.null— the default: the person the work is for; the sponsor when it is for nobody.
It changes who is asked and nothing else. The person the work is for does not decide a proposal that was sent
elsewhere — asking an agent for a refund is not approving one — neither on the proposal nor from the queue, if they
work it too. Whoever DecidesAgentProposals admits still may. A tool the agent never runs is still never proposed;
a yes still runs the tool as whoever says it, with what they may do; and the receipt says what it waits for: "a
decision from the Finance queue". AgentProposal.Routed, DecideQueue and RouteWords(proposal) say where one
went, and Skills.Catalog() says it of each tool (DecidedByQueue, DecidedByOnePerson).
The last example below sends a price to the manager. A queue is shown end to end by
kits/agents/tests/routing.test.osy and kits/agents-human-requests/tests/routing.test.osy.
What does approving do?#
ApproveAgentProposal runs the tool now, as the agent on behalf of whoever approved: it does only what
both may, by the app's ordinary security {} rules. So a yes from somebody who may not do the thing does not do it —
they are told so, and so is the agent. It is the same intersection as work the agent does alone for a person
([[agent-ask#for]]); the person is simply the one who said yes.
A yes is to what was shown. The proposal carries a fingerprint — the agent, the skill and its version, the tool,
its arguments, the version of what it would act on, which the skill says with VersionOf (an order's revision,
a document's hash), and the [[agent-teaching#sets|instruction set]] the agent was on as it proposed. When that reads
differently as the person decides, nothing runs: the proposal is Stale, the person is told, and the agent is told
to look again — told, when it is the instruction set that changed, what it is told now.
The proposal is for whoever it is put to, the person the work is for — unless its skill [[#route|sent it
elsewhere]] — and whoever DecidesAgentProposals admits (whoever supervises, until the app says more). It is decided
once.
RejectAgentProposal needs a reason. The agent is given it as what its tool answered, and goes on — to propose
something else, or to finish without.
What can never be approved?#
A tool in Never is not offered to the model at all. Asked for anyway, it does not run and is not proposed, so
there is nothing for anybody to approve; AllowAlone refuses it; and a proposal whose tool the declaration has
since ruled out does not run whoever says yes. The same holds for a tool that is not one of the work's tools.
"Approve all like this"?#
ApproveAgentProposal(proposal, alwaysLikeThis: true) — or AllowAlone(agent, tool) — makes a standing rule:
from its next step the agent runs that tool without proposing it, in the work it does for whoever said so. It is
for whoever the app's WidensAgentAutonomy admits; forEverybody: true, for work done for anybody, is for whoever
supervises the agents. It widens nothing past the declaration: a tool the agent never runs, or proposes every
time (ProposeEveryTime), takes no rule — and the tool still runs as the agent and never past the person the work
is for. RevokeStandingRule takes it back; the agent proposes again from its next step. A rule counts its own use
(Used, LastUsedAt): each time a tool ran alone because of it.
Examples#
app Workshop {
use Osysharp.Agents;
use Osysharp.Agents.Testing;
use Osysharp.Llm;
use Osysharp.Identity;
use Osysharp.Observability;
use Osysharp.Workflow;
model "model/**/*.osy";
}
using Osysharp.Agents;
using Osysharp.Agents.Testing;
using Osysharp.Identity;
using Osysharp.Llm;
using Osysharp.Workflow;
app.Llm = new LlmSetup {
Models = [ new LlmModel { Name = "Everyday", Client = new ScriptedModel { Name = "Everyday" }, Model = "scripted-everyday",
Price = new LlmPrice { Input = 1, Output = 5 }, MaxOutputTokens = 1000 } ],
Default = "Everyday",
};
app.Agents = new AgentsSetup { Agents = [new Estimator()], TaskBudget = 0.05m };
[Role] enum ShopRole { Authenticator, Mechanic, Manager, Helper }
partial entity RoleGrant { security { allow read when IsAuthenticated; } }
policy IsMechanic => RoleGrant.Any(g => g.Member == user && g.Role == ShopRole.Mechanic);
policy IsManager => RoleGrant.Any(g => g.Member == user && g.Role == ShopRole.Manager);
policy IsHelper => RoleGrant.Any(g => g.Member == user && g.Role == ShopRole.Helper);
policy UsesAgents => IsMechanic || IsManager;
policy SupervisesAgents => IsManager;
policy AuditsAgents => IsManager;
policy WidensAgentAutonomy => IsManager; // who may say "from now on, alone"
policy ReadsModelUsage => IsManager;
policy ManagesModelSpend => IsManager;
entity Person : Principal {
security { allow read when IsAuthenticated || IsAuthenticator; allow create when IsAuthenticator; }
}
/// One bike in for repair.
entity Repair {
[Required, Unique, MaxLength(40)] string Ticket;
[Required, MaxLength(200)] string Fault;
[MaxLength(400)] string Note = "";
/// What the customer is charged. The manager sets it — and so may the estimator's own account, which, working for
/// a person, does it only when that person may too.
decimal Price;
/// Counts up with every change to the fault: what an approval of a price is an approval FOR.
int Revision = 1;
bool Scrapped = false;
security {
allow read, update when IsMechanic || IsManager || IsHelper;
allow create when IsMechanic || IsManager;
deny update Price when !IsManager && !IsHelper;
}
}
public class Estimate {
public decimal Hours;
public string Summary = "";
}
[Action("Look up the parts a fault needs", Query)]
public List<string> PartsFor(string fault) { return fault.Contains("brake") ? ["brake pads", "cable"] : []; }
[Action("Write a note on the repair")]
public string NoteRepair(Repair repair, string note) { repair.Note = note; return $"Noted on {repair.Ticket}."; }
[Action("Set the price")]
public string SetPrice(Repair repair, decimal price) { repair.Price = price; return $"{repair.Ticket} costs {price}."; }
[Action("Scrap the repair")]
public string ScrapRepair(Repair repair) { repair.Scrapped = true; return "Scrapped."; }
Repair? RepairIn(string argumentsJson) {
var values = JsonSerializer.Deserialize<Dictionary<string, Json>>(argumentsJson);
if (!values.ContainsKey("repair")) { return null; }
var id = Guid.Parse(values["repair"].ToString().Replace("\"", ""));
return Repair.FirstOrDefault(r => r.Id == id);
}
public class EstimateRepair : Skill<Repair, Estimate> {
public override string Description => "Estimate one repair, and price it.";
public override List<ActionInfo> Tools => [Actions.Of(PartsFor), Actions.Of(NoteRepair), Actions.Of(SetPrice), Actions.Of(ScrapRepair)];
public override string Brief(Repair r) => $"Ticket {r.Ticket}: {r.Fault}. Its id is {r.Id}.";
// A yes to a price covers the repair as it was described: described again, it is asked again.
public override string VersionOf(string action, string argumentsJson) {
var repair = RepairIn(argumentsJson);
return repair == null ? "" : "r" + repair!.Revision.ToString();
}
// A person reads the ticket, not its id.
public override string Names(string action, string argument, string value) {
if (argument != "repair") { return value; }
var repair = Repair.FirstOrDefault(r => r.Id == Guid.Parse(value));
return repair == null ? value : "Ticket " + repair!.Ticket;
}
}
/// It writes its note by itself, never scraps a repair, and proposes the rest — the price.
[Actor(Roles = [ShopRole.Helper])]
public class Estimator : Agent {
public static Person Principal => new Person { Email = "[email protected]", DisplayName = "Estimator" };
public override string Purpose => "You estimate bicycle repairs for a small workshop.";
public override List<SkillBase> Skills => [new EstimateRepair(), new PriceFleetRepair()];
public override Permits May => new Permits { Alone = [Actions.Of(NoteRepair)], Never = [Actions.Of(ScrapRepair)] };
}
/// ⭐ Prices a fleet customer's repair — and what it proposes for a price is the manager's to decide, whoever asked
/// for the estimate.
public class PriceFleetRepair : Skill<Repair, Estimate> {
public override string Description => "Price one repair for a fleet customer.";
public override List<ActionInfo> Tools => [Actions.Of(PartsFor), Actions.Of(SetPrice)];
public override string Brief(Repair r) => $"Ticket {r.Ticket}: {r.Fault}. Its id is {r.Id}.";
public override ProposalRoute? DecidedBy(string action) =>
action == Actions.Of(SetPrice).Name ? ProposalRoute.ToPerson(TheManager()) : null;
}
/// The manager, looked up by the role the workshop gives her.
Principal TheManager() { return RoleGrant.First(g => g.Role == ShopRole.Manager).Member; }
[TestFixture]
void Shop() {
var mia = new Person { Email = "[email protected]", DisplayName = "Mia" };
var noor = new Person { Email = "[email protected]", DisplayName = "Noor" };
new RoleGrant { Member = mia, Role = ShopRole.Mechanic };
new RoleGrant { Member = noor, Role = ShopRole.Manager };
new Repair { Ticket = "T-7", Fault = "rear brake does not bite" };
new AgentSeat { Agent = "Estimator", Sponsor = noor };
}
principal Mia => Person.Single(p => p.Email == "[email protected]");
principal Noor => Person.Single(p => p.Email == "[email protected]");
string RepairArgs(Repair r, string rest) { return "{\"repair\":\"" + r.Id.ToString() + "\"" + rest + "}"; }
AgentScript NoteThenPrice(Repair repair) {
return AgentScript.Of(
Says.Tool(Actions.Of(NoteRepair), RepairArgs(repair, ",\"note\":\"Pads and a cable.\"")),
Says.Tool("Pads, a cable and an hour of work.", Actions.Of(SetPrice), RepairArgs(repair, ",\"price\":45")),
Says.HandBack(new Estimate { Hours = 1, Summary = "Pads and a cable." }));
}
/// Let the work a person has decided go on: its wait's own run is settled.
void GoOn(Guid task) { Workflow.Settle(AgentWait.Where(w => w.Task.Id == task).OrderByDescending(w => w.Since).First()); }
[Test(Shop)]
void It_notes_alone_proposes_the_price_and_a_yes_runs_it_as_whoever_said_yes() {
Guid task;
// The mechanic asks for an estimate. The note is written; the price is PROPOSED, and the work waits.
runas (Mia) {
var repair = Repair.Single(r => r.Ticket == "T-7");
var script = NoteThenPrice(repair);
ScriptedAnswer.Stub((model, request) => script.Answer(model, request));
var outcome = await Agents.Of<Estimator>().Do(new EstimateRepair(), repair);
task = outcome.Task;
Assert.True(outcome.Waiting);
Assert.Equal("Pads and a cable.", Repair.Single(r => r.Ticket == "T-7").Note);
Assert.Equal(0, Repair.Single(r => r.Ticket == "T-7").Price);
var proposal = ProposalsForMe().Single();
Assert.Equal("Set the price", proposal.Label);
Assert.Equal("Pads, a cable and an hour of work.", proposal.Why);
Assert.Equal("[{\"Name\":\"repair\",\"Value\":\"Ticket T-7\"},{\"Name\":\"price\",\"Value\":\"45\"}]", proposal.ShownJson);
Assert.Contains("Write a note on the repair", proposal.EvidenceJson);
Assert.Equal("r1", proposal.SubjectVersion);
// Her yes runs it as her — and a mechanic may not set a price. It is not done, and she is told.
var decision = ApproveAgentProposal(proposal);
Assert.Equal(ProposalStatus.Failed, decision.Status);
Assert.Equal(0, Repair.Single(r => r.Ticket == "T-7").Price);
GoOn(task);
Assert.Equal(AgentTaskStatus.Done, AgentTask.Single().Status);
}
// The same proposal, decided by the manager: it runs as her, and the price is set.
runas (Mia) {
var repair = Repair.Single(r => r.Ticket == "T-7");
task = (await Agents.Of<Estimator>().Do(new EstimateRepair(), repair)).Task;
}
runas (Noor) {
var proposal = AgentProposal.Single(p => p.Status == ProposalStatus.Waiting);
Assert.Equal(ProposalStatus.Done, ApproveAgentProposal(proposal).Status);
Assert.Equal(45, Repair.Single(r => r.Ticket == "T-7").Price);
GoOn(task);
Assert.Contains("Noor approved SetPrice, and it ran as them.", Trajectory.Said(task));
}
}
[Test(Shop)]
[runas(Noor)]
void A_no_gives_the_agent_the_reason_and_a_yes_does_not_cover_what_changed() {
var repair = Repair.Single(r => r.Ticket == "T-7");
var script = NoteThenPrice(repair);
ScriptedAnswer.Stub((model, request) => script.Answer(model, request));
var first = await Agents.Of<Estimator>().Do(new EstimateRepair(), repair);
Assert.Throws<ValidationException>(() => RejectAgentProposal(ProposalsForMe().Single(), ""));
RejectAgentProposal(ProposalsForMe().Single(), "Quote the cable separately.");
GoOn(first.Task);
Assert.Contains("Noor said no to SetPrice: Quote the cable separately.", Trajectory.Said(first.Task));
Assert.Equal(0, Repair.Single(r => r.Ticket == "T-7").Price);
// Proposed again — and the fault is described anew before she decides. Her yes was to the repair as it was.
var second = await Agents.Of<Estimator>().Do(new EstimateRepair(), repair);
repair.Fault = "rear brake does not bite; the rim is bent";
repair.Revision = 2;
UnitOfWork.Commit();
var decision = ApproveAgentProposal(ProposalsForMe().Single());
Assert.Equal(ProposalStatus.Stale, decision.Status);
Assert.Equal(0, Repair.Single(r => r.Ticket == "T-7").Price);
}
[Test(Shop)]
[runas(Noor)]
void From_now_on_alone_and_what_it_never_does() {
var repair = Repair.Single(r => r.Ticket == "T-7");
var script = NoteThenPrice(repair);
ScriptedAnswer.Stub((model, request) => script.Answer(model, request));
// "Yes — and from now on, do this alone."
var first = await Agents.Of<Estimator>().Do(new EstimateRepair(), repair);
ApproveAgentProposal(ProposalsForMe().Single(), alwaysLikeThis: true);
GoOn(first.Task);
var rule = AgentStandingRule.Single();
Assert.Equal("SetPrice", rule.Action);
// The next estimate she asks for sets its price without asking.
var second = await Agents.Of<Estimator>().Do(new EstimateRepair(), repair);
Assert.True(second.Done);
Assert.Equal(1, AgentProposal.Count());
// She takes it back: it proposes again.
RevokeStandingRule(rule, "Prices are mine again.");
UnitOfWork.Commit();
Assert.True((await Agents.Of<Estimator>().Do(new EstimateRepair(), repair)).Waiting);
// What it never does takes no rule, and is not offered to the model at all.
Assert.Throws<AgentRefused>(() => AllowAlone("Estimator", Actions.Of(ScrapRepair)));
var tools = Skills.Find("Estimator", "EstimateRepair")!.Tools;
Assert.Equal(["Alone", "Alone", "Propose", "Never"], tools.Select(t => t.Leave.Name).ToList());
}
[Test(Shop)]
void A_price_the_skill_sends_to_the_manager_is_not_decided_by_who_asked_for_it() {
Guid task;
runas (Mia) {
var repair = Repair.Single(r => r.Ticket == "T-7");
var script = AgentScript.Of(
Says.Tool("A fleet rate: pads, a cable and an hour.", Actions.Of(SetPrice), RepairArgs(repair, ",\"price\":38")),
Says.HandBack(new Estimate { Hours = 1, Summary = "Fleet rate." }));
ScriptedAnswer.Stub((model, request) => script.Answer(model, request));
var outcome = await Agents.Of<Estimator>().Do(new PriceFleetRepair(), repair);
task = outcome.Task;
// It waits on the manager, not on Mia, and the receipt says so.
var proposal = AgentProposal.Single();
Assert.True(proposal.Routed);
Assert.Equal(Noor.Id, proposal.DecideBy!.Id);
Assert.Equal("a decision from Noor", AgentTask.Single(t => t.Id == task).WaitingOn);
// Mia asked for the work; the decision is not hers.
var refused = Assert.Throws<AgentRefused>(() => ApproveAgentProposal(AgentProposal.Single()));
Assert.Equal("This proposal is decided by Noor: the skill that proposed it says so. The work is for you; the decision is not yours.", refused.Message);
Assert.Equal(0, Repair.Single(r => r.Ticket == "T-7").Price);
}
runas (Noor) {
Assert.Equal(ProposalStatus.Done, ApproveAgentProposal(AgentProposal.Single()).Status);
Assert.Equal(38, Repair.Single(r => r.Ticket == "T-7").Price);
GoOn(task);
Assert.Equal(AgentTaskStatus.Done, AgentTask.Single(t => t.Id == task).Status);
}
}See also#
- An agent asks a person, and waits — the person rung of the ladder of help (Agents kit) — asking a person a question, and how work — and a process — waits
- An agent's proposals and questions in a person's inbox, and an inbox item in an agent's hands (Agents.HumanRequests kit) — proposals and questions as requests in a person's inbox
- Writing an agent — a class with a purpose, skills and an account of its own (Agents kit) — the agent class, its account and who is in charge of it
- A skill — a typed job an agent can be given, its check and its tools (Agents kit) — a typed job and its tools
- Giving an agent work — Do, Ask, Stream, For and Decide (Agents kit) — giving an agent work; whose rights it runs with
- [Action] — what an app offers to be done, by name — an action, and what the compiler proves it does
- The receipt and the record — what every piece of an agent's work leaves (Agents kit) — the receipt and the record