Summary#
An agent working on something real will sometimes reach a point only a person can settle. A charge nobody can explain from the record. A judgement that is somebody's to make. The two obvious ways out are both bad: guessing produces a confident wrong answer, and failing throws away the work already done.
So it asks. The run parks, the question goes in front of a person, and when they answer, the work continues from exactly where it stopped.
var reply = Auditor.Ask(turns);
if (reply.Parked) {
// `reply.Question` is what it wants to know. Put it in front of somebody.
goto Waiting;
}…and later, when they have replied:
var reply = Auditor.Answer(this.Item, "A client dinner for four — I paid for all.");Signature#
<agent>.Answer(task, text) // → AgentReplytask | the task whose run is parked — inside a loop that is this.Item. Must be an AgentTask or one of your types deriving from it |
text | what the person said, verbatim. It reaches the agent as the answer to the question it asked |
| returns | an AgentReply, exactly as Ask does — because a resumed run can park again |
Description#
Asking comes from the loop, and is declared nowhere#
An agent running inside a loop can ask a person. An agent that is not, cannot — the verb is not offered to it at all.
That is not a restriction, it is the only honest answer. Parking means "stop here and come back when somebody
replies", and coming back is the loop's doing: a loop is checked at compile time to declare a Waiting state, so an
agent inside one always has somewhere to park. An agent with no loop has nowhere. Letting it park anyway would
produce work that waits forever — and on every screen, work waiting forever looks exactly like work waiting patiently
for a person.
⚑ So the capability follows from the structure rather than from a flag. There is nothing to switch on, nothing to forget, and no way to end up with an agent that can ask but cannot be answered.
Nothing is suspended, and nothing needs to be#
This is the part worth understanding, because it explains every other rule on this page.
An agent's turn loop is ordinary code — call the model, run the tools it asks for, repeat. It cannot be frozen across the hours a person takes to reply, and no amount of machinery would change that.
It does not have to be. An agent's state is its conversation, and that is already durable:
- The agent calls the ask verb. That turn is written down with no result — an unanswered question is exactly what a parked run looks like.
- Everything stops. The task reads
Waiting; the workflow holds the wait as aSlotwith an assignee, an opened-at and an SLA clock. - The person answers. Their words are written as that call's result.
- A fresh run replays the conversation — the original instruction, the agent's own question, and now its answer — and the model carries on as though it had called a very slow tool.
Nothing was suspended, so nothing has to be woken. This is also why an agent that keeps no conversation cannot do any of it, and why that is refused at the ask rather than discovered at the answer.
An agent that retains nothing is refused when it asks#
Asking requires Memory = Persistent on the agent. Its history is its continuation, so an agent with none has
nothing to come back to.
The refusal happens the moment it tries to ask, not later when somebody answers — because a run that parks and can never be resumed is a dead end, and a dead end is indistinguishable from patience on every screen you would look at. The message names the one line that fixes it.
Parking again is ordinary#
Answer returns an AgentReply, and it can come back parked. An answer that raises one more question is what
conversations do.
Branch on it, or you will move to a terminal state over work that is still waiting on somebody:
on Answered(string text, Slot slot) {
var reply = Auditor.Answer(this.Item, text);
if (reply.Parked) { goto Waiting; } // it asked one more thing
goto Completed;
}What the platform owns, and what you own#
| The platform | that the run can park at all; that the wait is a Slot with an assignee, an opened-at and a clock; that the answer arrives as the question's result; that the same task and the same conversation carry on |
| You | who is asked · how they hear about it · what the question looks like on screen · how long they get · what happens if they never reply |
The second column is not an oversight. Any opinion the platform held about who to notify would be wrong in every app
that disagreed — so the Waiting state's enter body is yours, and so is whatever row you write to put the question
in front of somebody.
Answering goes through the slot#
Deposit the answer into the workflow slot rather than calling Answer from a page. The slot is what decides whether
this caller may answer, and what records that they did and how long they took — which is what makes "it waited 15
hours for Sam" a question with an answer instead of a shrug.
void AnswerAuditor(Question question, string text) {
ReviewClaim.For(question.Task).Filer.Answered(text);
}The workflow's own on Answered arm is what calls Answer. Depositing is server-side because the authority check
is: a browser cannot decide who is allowed to answer.
What the person is shown#
reply.Question is what the agent asked, in its own words. It is handed back rather than dropped because it is the
whole content of the work item — an inbox that knows an agent is waiting but not what it wants is not an inbox.
Write it somewhere your own screens can read. What that row looks like is yours; the demo keeps a Question row
against the claim, and renders it at the top of the page the person is already on.
Examples#
The smallest app in which an agent can ask a person. Everything here exists to make the two marked lines reachable — the agent's work runs inside a loop, so it may ask; and the answer goes in through the slot:
using Osyrin.Agents;
[Principal] entity User {
[Required, MaxLength(255)] string Email;
security { allow read when IsAuthenticated; }
}
entity Claim {
[Required, MaxLength(200)] string Title;
security { allow read, create, update when IsAuthenticated; }
}
/// What the agent asked. The platform parks the run; this row is what a person actually sees.
entity Question {
[Required] ReviewTask Task;
[Required, MaxLength(2000)] string Text;
[MaxLength(2000)] string? Answer;
security { allow read, create, update when IsAuthenticated; }
}
entity ReviewTask : AgentTask {
security { allow read when IsAuthenticated; }
}
agent Auditor {
Purpose = "Review a claim.";
Prompt = "You review expense claims.";
Memory = Persistent; // required: an agent that retains nothing cannot come back
Principal = new User { Email = "auditor@example.com" };
Loop = ReviewClaim; // …and this is what lets it ask at all
}
workflow ReviewClaim {
Tracks = ReviewTask.Status;
Autostart = true;
Initial = Running;
event Answered(string text);
state Running {
enter {
var turns = new List<Turn>();
turns.Add(Turn.User("Review this claim."));
var reply = Auditor.Ask(turns);
if (reply.Parked) { // ← IT ASKED SOMEBODY
Ask(this.Item, reply.Question ?? "");
goto Waiting;
}
goto Completed;
}
}
state Waiting {
subscribe Answered(string text) as Filer {
Candidates = u => u.Email != "";
Finished { Within = TimeSpan.FromDays(2); Unfinished { goto Failed; } }
}
on Answered(string text, Slot slot) {
var reply = Auditor.Answer(this.Item, text); // ← AND THIS IS THE ANSWER REACHING IT
Close(this.Item, text);
if (reply.Parked) { goto Waiting; }
goto Completed;
}
}
terminal success Completed { }
terminal error Failed { Message = "the review could not be completed"; }
}
void Ask(ReviewTask task, string text) {
var q = new Question { Task = task, Text = text };
UnitOfWork.Commit();
}
void Close(ReviewTask task, string text) {
var open = Question.Where(q => q.Task == task && q.Answer == null).FirstOrDefault();
if (open != null) { open.Answer = text; UnitOfWork.Commit(); }
}
/// What your page calls when the person replies.
void AnswerAuditor(Guid questionId, string text) {
var question = Question.Where(q => q.Id == questionId).FirstOrDefault();
ReviewClaim.For(question.Task).Filer.Answered(text);
}The same example with the surrounding app — a real claim, a filer, and the trigger that starts the work — is on [[agent-loop#examples|the loop page]].
See also#
- the agent loop (app.Agent, Loop) — the workflow the work runs inside, and what makes asking possible
- running an agent from your code — running a declared agent, and the
AgentReplyboth verbs answer - Workflow.Work<T> (everything outstanding) and its SLA numbers — slots, assignees and SLA clocks: what a wait is made of
- the agent task log (AgentTask) — the task that stays open while the run is parked