Osy#betaRun it on Osyrin: osyrin.com

Reference / Agent

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

Agent.People => new AsksPeople { MayChoose, WhenStuck, About, Most } · AgentHelp { From = Person, AskedPerson, Question, Context, OptionsJson, Answer, AnsweredBy } · AnswerAgentQuestion(question, answer) · QuestionsForMe() · AgentOutcome.Waiting · AgentTaskStatus.Waiting · AgentTask.WaitingOn · WorkEnds(task) · StopAgentTask(task, why)

An agent that says so may put one focused question to a person: the model chooses to (a tool it is offered), or work that is stuck does before it gives up — after the model it consults, when it names one. Who is asked is the person the work is for; the agent's sponsor when it is for nobody. The work stops where it stands — its receipt says `Waiting` and on what — and goes on from the same point when the person has answered, a minute or a month later; nothing runs and nothing is spent meanwhile. Inside a workflow the run waits with the work, and `Do` answers the finished result.

preview1 example compiled by CIagentskit

Summary#

public class Estimator : Agent {
  public override AsksPeople People => new AsksPeople {
    MayChoose = true,                                   // the model may ask when it judges it should
    WhenStuck = true,                                   // …and stuck work asks once before it gives up
    About = "Any repair over 200 EUR.",                 // …and these decisions are a person's
  };
}

var outcome = await Agents.Of<Estimator>().Do(new EstimateRepair(), repair);
if (outcome.Waiting) { … }                              // it asked somebody; the receipt says whom
AnswerAgentQuestion(question, "Replace it.");           // …and it goes on from where it stood

Signature#

class Agent { virtual AsksPeople People; }
class AsksPeople {
  bool MayChoose;        // the model is offered `ask_person`
  bool WhenStuck;        // stuck work asks a person once — after the model it consults, when it names one
  string About;          // kinds of decision that are a person's: said to the model, which asks first
  int Most = 3;          // the most questions one piece of work may put
}

entity AgentHelp {       // the same row a consulted model answers — `From` says who was asked
  HelpFrom From;         // Model, Person
  HelpReason Why;        // Chosen, Stuck
  Principal? AskedPerson; string Question; string Context; string OptionsJson;
  HelpStatus Status;     // Waiting, Answered, Unavailable
  string Answer; Principal? AnsweredBy; DateTime? AnsweredAt;
}
AnswerAgentQuestion(AgentHelp question, string answer)
QuestionsForMe() → List<AgentHelp>

enum AgentTaskStatus { Working, Done, Failed, Stopped, Cancelled, Waiting }
entity AgentTask { …; string WaitingOn; }              // "an answer from Noor", "a decision from Noor"
class AgentOutcome<T> { …; bool Waiting; }   class AgentReply { …; bool Waiting; }

await WorkEnds(Guid task) → AgentTaskStatus            // in a workflow: wait for work that waits on a person
// events: AgentAskedForHelp · AgentQuestionClosed · AgentTaskWaiting · AgentTaskFinished / Failed / Stopped

Description#

When does an agent ask a person?#

Only when its class says it may (People). Then in three ways, each a row of AgentHelp with From = Person and a line of the record:

WhenThe person's answer reaches the model as
MayChoosethe model calls the ask_person tool it is offered, with a Question, the Context the person needs and, when the answer is one of a few, Optionswhat ask_person answered
Aboutthe same tool — the model is told these decisions are a person's, and asks before it decides onewhat ask_person answered
WhenStucka step failed StuckAfter times in a row, and — when the agent consults a model — that model has been askedadvice before its next turn

One piece of work puts at most Most questions; past that the model is told so and finishes without.

Who is asked, and where?#

The person the work is for ([[agent-ask#for]]); the agent's sponsor when it is for nobody — a step of a process. With Osysharp.Agents.HumanRequests in the app the question is a request in that person's inbox, with its options as buttons. Without it the app shows QuestionsForMe() wherever it likes and calls AnswerAgentQuestion. The person asked answers; whoever SupervisesAgents may answer for them. A question is answered once.

What happens to the work while it waits?#

It stops where it stands — on a question, or on a [[agent-autonomy#proposal|proposal]]:

  • its receipt is Waiting, and WaitingOn says on what; the record has a line saying so;
  • everything it needs to go on is kept with the receipt; nothing runs, no model is asked, nothing is spent;
  • when the person has answered or decided, it goes on from the same point, with their answer as what its tool answered — as the app, in a run of its own, so nobody needs to be signed in or looking;
  • StopAgentTask calls waiting work off: it ends Cancelled, a proposal still open is withdrawn, and a late yes is refused.

There is no deadline on the wait itself. A request in the inbox has one, with reminders (Requests to people — a question, an approval, a form, and the answer coming back (HumanRequests kit)); when it runs out, the agent is told nobody answered and goes on.

What does the caller get?#

  • A page's action or a plain function: Do answers at once with Status == Waiting and the receipt's id. The end comes as an event — [On] void Done(AgentTaskFinished e) { … } — or is read from the receipt later.
  • A workflow — its body, or anything it calls: await agent.Do(skill, input) waits with the work. The run parks, for as long as the person takes, and Do answers the finished outcome. await WorkEnds(task) is the same wait for work opened in two steps ([[agent-ask#open]]).

Ask and Stream answer a reply whose Status is Waiting; a stream says so with a Waiting signal before its Done.

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 ReadsModelUsage   => IsManager;
policy ManagesModelSpend => IsManager;

entity Person : Principal {
  security { allow read when IsAuthenticated || IsAuthenticator; allow create when IsAuthenticator; }
}

public enum RepairStage { In, Quoted }

/// One bike in for repair.
entity Repair {
  [Required, Unique, MaxLength(40)] string Ticket;
  [Required, MaxLength(200)] string Fault;
  [MaxLength(400)] string Note = "";
  RepairStage Stage = RepairStage.In;
  security { allow read, create, update when IsMechanic || IsManager || IsHelper; }
}

public class Estimate {
  public decimal Hours;
  public string Summary = "";
}

[Action("Write a note on the repair")]
public string NoteRepair(Repair repair, string note) { repair.Note = note; return $"Noted on {repair.Ticket}."; }

public class EstimateRepair : Skill<Repair, Estimate> {
  public override string Description => "Estimate one repair.";
  public override List<ActionInfo> Tools => [Actions.Of(NoteRepair)];
  public override string Brief(Repair r) => $"Ticket {r.Ticket}: {r.Fault}. Its id is {r.Id}.";
}

/// It may ask a person — when the model judges it should, and when it is stuck.
[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()];
  public override Permits May => Permits.ChangesAlone();
  public override AsksPeople People => new AsksPeople { MayChoose = true, WhenStuck = true };
}

/// A repair's quote, as a process: the estimator estimates, and the process goes on with what it found — however
/// long the estimator had to wait for a person.
workflow Quoting {
  Tracks = Repair.Stage; Autostart = false; Initial = In;
  state In {
    enter {
      var outcome = await Agents.Of<Estimator>().Do(new EstimateRepair(), this.Item, about: "Ticket " + this.Item.Ticket);
      this.Item.Note = outcome.Done ? outcome.Result.Summary : "Not estimated: " + outcome.Problem;
      goto Quoted;
    }
  }
  terminal success Quoted { }
}

[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 wheel is buckled" };
  new AgentSeat { Agent = "Estimator", Sponsor = noor };
}
principal Mia  => Person.Single(p => p.Email == "[email protected]");
principal Noor => Person.Single(p => p.Email == "[email protected]");

/// Let the work a person has answered 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_asks_the_person_it_works_for_and_goes_on_a_month_later() {
  TestClock.Set(new DateTime(2031, 3, 3, 9, 0, 0, DateTimeKind.Utc));
  Guid task;
  runas (Mia) {
    var repair = Repair.Single(r => r.Ticket == "T-7");
    var script = AgentScript.Of(
      Says.AskPerson("True the wheel, or replace it?", "The rim is buckled by about a centimetre."),
      Says.HandBack(new Estimate { Hours = 1, Summary = "Replace the wheel." }));
    ScriptedAnswer.Stub((model, request) => script.Answer(model, request));

    var outcome = await Agents.Of<Estimator>().Do(new EstimateRepair(), repair);
    task = outcome.Task;

    // It stopped on her: the receipt says so, and the question is hers to answer.
    Assert.True(outcome.Waiting);
    Assert.Equal("an answer from Mia", AgentTask.Single().WaitingOn);
    var question = QuestionsForMe().Single();
    Assert.Equal("True the wheel, or replace it?", question.Question);
    Assert.Equal("The rim is buckled by about a centimetre.", question.Context);
    Assert.Equal(HelpFrom.Person, question.From);
  }
  // A month goes by. Nothing runs, and the model is not asked again.
  TestClock.Advance(TimeSpan.FromDays(30));
  Background.Settle();
  runas (Mia) {
    Assert.Equal(1, AgentTask.Single().ModelCalls);
    AnswerAgentQuestion(QuestionsForMe().Single(), "Replace it. The rim is cracked too.");
    GoOn(task);
    // It went on from where it stood, with her answer, and finished.
    var receipt = AgentTask.Single();
    Assert.Equal(AgentTaskStatus.Done, receipt.Status);
    Assert.Contains("Replace the wheel.", receipt.Result!);
    Assert.Contains("Mia answered after 30 days.", Trajectory.Said(task));
    // Answered once.
    Assert.Throws<AgentRefused>(() => AnswerAgentQuestion(AgentHelp.Single(), "On second thoughts, true it."));
  }
}

[Test(Shop)]
void A_process_waits_with_the_work_and_goes_on_with_its_result() {
  Guid id;
  runas (Mia) {
    var repair = Repair.Single(r => r.Ticket == "T-7");
    id = repair.Id;
    var script = AgentScript.Of(
      Says.AskPerson("True the wheel, or replace it?"),
      Says.HandBack(new Estimate { Hours = 1, Summary = "True the wheel." }));
    ScriptedAnswer.Stub((model, request) => script.Answer(model, request));
    Workflow.Run(repair);
    Workflow.Settle(repair);
    // The process has not moved: its step is waiting with the estimator's work.
    Assert.Equal(RepairStage.In, Repair.Single(r => r.Id == id).Stage);
  }
  // Nobody is signed in while a process runs, so the estimator asks its sponsor.
  runas (Noor) {
    var question = QuestionsForMe().Single();
    AnswerAgentQuestion(question, "True it.");
    GoOn(question.Task!.Id);
    Workflow.Settle(AgentWorkWait.Single());
  }
  runas (Mia) {
    var repair = Repair.Single(r => r.Id == id);
    Workflow.Settle(repair);
    Assert.Equal(RepairStage.Quoted, repair.Stage);
    Assert.Equal("True the wheel.", repair.Note);
  }
}

[Test(Shop)]
[runas(Mia)]
void Stuck_work_asks_a_person_once_and_waiting_work_can_be_called_off() {
  var repair = Repair.Single(r => r.Ticket == "T-7");
  // Twice it answers in words where a result must be handed back: it is stuck, and has no model to consult.
  var script = AgentScript.Of(Says.Words("An hour, I think."), Says.Words("About an hour."),
                              Says.HandBack(new Estimate { Hours = 1, Summary = "An hour." }));
  ScriptedAnswer.Stub((model, request) => script.Answer(model, request));

  var outcome = await Agents.Of<Estimator>().Do(new EstimateRepair(), repair);

  Assert.True(outcome.Waiting);
  Assert.Equal(HelpReason.Stuck, QuestionsForMe().Single().Why);

  // She calls the work off instead of answering: it ends where it stood.
  StopAgentTask(AgentTask.Single(), "The customer took the bike back.");
  UnitOfWork.Commit();
  GoOn(outcome.Task);
  Assert.Equal(AgentTaskStatus.Cancelled, AgentTask.Single().Status);
  Assert.Equal(2, AgentTask.Single().ModelCalls);
}

See also#

Related

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

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…

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…

Asking for help — an agent that consults a more capable model (Agents kit)

An agent can name one of the app's models to consult. The model doing the work may then ask it one focused question…

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…

Workflow.Run (start a workflow)

Start the workflow bound to an entity's type, on that entity. Bare — `Workflow.Run(order)` — is fire-and-forget: start…

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…