Osy#betaRun it on Osyrin: osyrin.com

Reference / Agent

What an agent does alone, what it proposes, what it never does — the gate and proposals (Agents kit)

Agent.May => new Permits { Changes, Sends, Spends, ReachesOut, Alone, Propose, ProposeEveryTime, Never } · Skill.May · Skill.VersionOf(action, argumentsJson) · Skill.Names(action, argument, value) · AgentProposal · ApproveAgentProposal(proposal, alwaysLikeThis:) · RejectAgentProposal(proposal, reason) · AllowAlone(agent, tool, skill:, forEverybody:) · RevokeStandingRule(rule, why) · policy WidensAgentAutonomy

An agent's class says what it may do alone, what it must propose to a person first, and what it never does — by what a tool DOES (changes data, sends, spends, reaches outside), as the compiler proved it, and tool by tool. Said nothing, an agent looks things up alone and proposes everything else. A proposal is the exact call: the tool, its arguments, why, and what the agent had looked at. The work waits; a yes runs the tool as whoever said yes — and as the agent: only what both may — for the version of things that was shown; a no gives the agent the reason. A person the app trusts may say "from now on, alone", and take it back.

preview1 example compiled by CIagentskitsecurity

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 on

Signature#

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 · AgentTaskWaiting

Description#

What may an agent do alone?#

What its class says — and, said nothing, only what reads:

A tool that…DefaultSay otherwise with
only reads ([Action(…, Query)])runs alone, always—
creates, changes or deletes the app's dataproposedChanges = Leave.Alone
sends something to somebody ([Sends])proposedSends = Leave.Alone
spends ([Spends])proposedSpends = Leave.Alone
reaches outside the app some other wayproposedReachesOut = 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 needs Osysharp.Agents.HumanRequests, which puts the proposal there; without it the proposal waits for whoever DecidesAgentProposals admits.
  • 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#

Related

An agent asks a person, and waits — the person rung of the ladder of help (Agents kit)

An agent that says so may put one focused question to a person: the model chooses to (a tool it is offered), or work…

An agent's proposals and questions in a person's inbox, and an inbox item in an agent's hands (Agents.HumanRequests kit)

With the Agents kit and the HumanRequests kit both in an app, this bridge is there: what an agent proposes is an…

Correcting an agent, and what it is told from then on — corrections, instructions, instruction sets and rollback (Agents kit)

An agent gets better through people. Somebody who sees a piece of its work corrects it — what was wrong, what right…

Writing an agent — a class with a purpose, skills and an account of its own (Agents kit)

An agent is a class of the `Osysharp.Agents` kit: what it is for, the skills it holds, what it may spend. It runs as an…

A skill — a typed job an agent can be given, its check and its tools (Agents kit)

A skill is a class: what goes in, what comes back, and the app's own functions the agent may use to get there. The…

Giving an agent work — Do, Ask, Stream, For and Decide (Agents kit)

`Do` gives an agent a typed job and answers an `AgentOutcome<TOut>`: the checked result, how it ended, what it cost and…

The receipt and the record — what every piece of an agent's work leaves (Agents kit)

Every piece of work leaves one receipt, `AgentTask` — who asked, the account it ran as, what it cost against its limit…

Requests to people — a question, an approval, a form, and the answer coming back (HumanRequests kit)

The `Osysharp.HumanRequests` kit puts something to a person — a question with the options it may be answered with, an…

[Action] — what an app offers to be done, by name

Mark a function `[Action]` and the app offers it to be done BY NAME — by a rule, a macro, a tool — with everything a…

Agents (a class with a job, its own account, a budget and a record)

An agent is a class of the `Osysharp.Agents` kit: a purpose, the skills it holds and what it may spend. A skill is a…