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 stoodSignature#
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 / StoppedDescription#
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:
| When | The person's answer reaches the model as | |
|---|---|---|
MayChoose | the 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, Options | what ask_person answered |
About | the same tool — the model is told these decisions are a person's, and asks before it decides one | what ask_person answered |
WhenStuck | a step failed StuckAfter times in a row, and — when the agent consults a model — that model has been asked | advice 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, andWaitingOnsays 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;
StopAgentTaskcalls waiting work off: it endsCancelled, 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:
Doanswers at once withStatus == Waitingand 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, andDoanswers 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#
- What an agent does alone, what it proposes, what it never does — the gate and proposals (Agents kit) — what an agent proposes to a person before it does it
- An agent's proposals and questions in a person's inbox, and an inbox item in an agent's hands (Agents.HumanRequests kit) — questions and proposals as requests in a person's inbox
- Asking for help — an agent that consults a more capable model (Agents kit) — the rung before this one: a more capable model
- Giving an agent work — Do, Ask, Stream, For and Decide (Agents kit) — giving an agent work; whose rights it runs with
- Workflow.Run (start a workflow) — a process that waits
- The receipt and the record — what every piece of an agent's work leaves (Agents kit) — the receipt and the record