Osy#the first language built for agents
Agents firstAgentic appsWorkflowsDurable Execution — built inSecurityTestingThe editorThe UI modelOne program

Reference / Agent

the agent loop (app.Agent, Loop)

app.Agent = new AgentConfig { Loop = <workflow> } · agent X { Loop = <workflow>; }

The workflow an agent's work runs INSIDE. A step of it means "run the agent until it finishes or asks for help" — the workflow supplies durability, the human slot, escalation, retries and the trace, while the agent supplies the turns. Declared once for the app and overridable per agent. Naming a workflow that does not exist is a compile error, because the failure it would otherwise cause is an absence nothing reports.

preview2 examples compiled by CIagentworkflowconfig

Summary#

An agent that answers a question in one turn needs nothing around it. An agent that does work — reads several things, calls tools, pauses for a person, resumes hours later — needs everything a workflow already provides. So the work runs inside one, and you say which:

app.Agent = new AgentConfig { Loop = ProcessTask };    // the app default

The workflow wraps the turn loop; it does not become it. One step of it means "run the agent until it finishes or asks for help".

Signature#

app.Agent = new AgentConfig {
  Loop = <workflow>          // the default loop for every agent in this app
};

agent Escalator {
  Loop = <workflow>;         // …overridden for this one
}

Both take a bare workflow NAME. Omit Loop on an agent and it takes the app default; omit app.Agent too and the agent's work runs with no workflow around it — which is the right answer for an app whose agents only ever answer in the turn.

Description#

The workflow supplies the durability, the agent supplies the turns#

This split is the whole design. The workflow owns:

  • durability — the run survives a restart, a deploy, and hours of waiting;
  • the human slot — when the agent asks a person something, the wait is a modelled fact with an assignee, an opened-at and an SLA clock, not a paused process;
  • escalation and retries — declared, not hand-written;
  • the trace — what happened, in order, queryable afterwards.

The agent owns the turns: call the model, dispatch tools, repeat. That loop stays in one place. If a workflow could express it too there would be two implementations of the same thing, and they would drift.

The loop's subject is the TASK#

A loop workflow runs over the agent's task — the same row the work log is built from — and it declares so:

workflow ProcessTask {
  Tracks  = AgentTask.Status;
  Initial = Running;
  state Running { }
  state Waiting { }              // parked on a person
  terminal success Completed { }
  terminal error   Failed { }
}

Two things follow, and both are the reason for the choice:

  • "What has this agent done, or is waiting for" stays one column to list on. The task's Status is the workflow's state. A loop with a state of its own would make that two questions, and a list view would have to know which of them to ask.
  • The state transition log is the task's. Every move the loop makes is recorded against the piece of work it is about, so the history is where you would look for it.

While a loop drives a task, the workflow OWNS Status and nothing else writes it. The agent's turn loop finishing is not the task finishing — a run parked on a person is still parked, and the task goes on reading Waiting until the workflow moves it. What the platform still records directly is what that run produced: its outcome and when it stopped.

What a loop must at least have#

The platform does not supply the loop. It checks that yours can do the two things a loop is for, and refuses at compile time when it cannot:

the rulewhy it is a rule
Tracks = AgentTask.Statusa loop runs over the agent's own task; tracking something else leaves the task's status written by nothing
a state for every member of AgentTaskStatusa missing member is a state the task can reach and the workflow has nowhere to put

Waiting is the one that matters. Without it an agent that asks a person has nowhere to park — and that failure is an absence: the work runs, nobody is asked, and there is no exception to catch and no log line to find. Said at compile time it is one sentence naming what to add.

Everything else is yours. Who gets notified when the run parks, what the SLA is, how it escalates, how many times it retries — the platform has no opinion, because any opinion it had would be wrong in the apps that disagreed. That is what the Waiting state's enter body is for.

This is also why samples are safe to copy. Start from one, change it into what your app needs — and if the protocol ever demands more, your copy stops building with a sentence naming what to add, rather than quietly going on doing less than it used to.

Not every task gets one#

  • Work a workflow already drives needs no second loop — the task points at the run that is already going, and starting a second would put two state machines on one piece of work.
  • Work started by a chat message, an event or a schedule runs in the declared loop, when the work warrants it. Opening the task starts the run, bound to that task — so <agent>.StartTask(trigger) is how a trigger hands work to the loop, and the loop is what runs the agent.
  • An agent spawned by another agent rides its parent's loop; it starts nothing of its own.

Do not spin durable machinery for a one-turn answer. "What did we spend on travel in Q3" should cost a model call, not a workflow run. Deciding which is which is your app's job — classify the incoming message first, answer the questions, and start the loop only for the work:

var verdict = Triager.Ask(turns).Text;          // cheap: decide whether this is work
if (verdict == "work") {
  juniorLegal.StartTask("…");                   // durable: the loop takes it from here
}

Which loop runs is decided by the task's TYPE#

A loop tracks a task type, and it starts for rows of that type and no other. So an app with more than one kind of agent work declares a task type per kind and a loop per type:

entity PersonalTask : AgentTask { security { allow read when IsAuthenticated; } }

workflow PersonalTaskProcessor {
  Tracks    = PersonalTask.Status;
  Autostart = this.Item.Type != AgentTaskType.Workflow && this.Item.Type != AgentTaskType.Spawn;
  Initial   = Running;
  state Running { }
  state Waiting { }
  terminal success Completed { }
  terminal error   Failed { }
}

Autostart is a plain condition over the task, written directly — the row is in scope as this.Item, so there is no lambda to introduce. It is also where "not every task gets one" is stated in your own words rather than assumed: a task a workflow already drives, or one an agent spawned, is excluded here.

A name that resolves to nothing is refused#

`app.Agent.Loop` names `ProcessTsak`, which is not a declared workflow — add `workflow ProcessTsak { … }`, whose
step runs the agent until it finishes or asks for help. Left unresolved the work would run with no workflow around
it: no durability, no human slot, no trace, and nothing to say so.

This is a compile error rather than a runtime one because the runtime failure would be an absence. Nothing throws; the work simply runs bare, and an app that never asked for a loop looks exactly the same. There is no log line to find, because nothing went wrong — something did not happen.

The same check covers the per-agent override, which is the same mistake in the other place it can be written.

The override is on the agent, and the default is resolved at run time#

An agent that declares no Loop stores none — it is not stamped with the app default at compile time. That is deliberate: changing app.Agent would otherwise leave every previously-compiled agent pointing at the old workflow. An empty Loop means "ask the app", and it is answered each time the work starts.

An agent declaration seeds a template, and the template carries the Loop too — so an instance minted from it later inherits the declaration rather than falling back to the default.

Examples#

A real loop, and the reason loops exist. The agent reviews a claim; when it cannot judge one without the person who filed it, it asks — the run parks in Waiting, and their answer hours later resumes the very same run:

using Osyrin.Agents;

[Principal] entity User {
  [Required, MaxLength(255)] string Email;
  security { allow read when IsAuthenticated; }
}

entity Claim {
  [Required, MaxLength(200)] string Title;
  [Required] User Filer;
  security { allow read, create, update when IsAuthenticated; }
}

/// What the agent asked, and what it was told. The platform parks the run; what the question LOOKS like to the
/// person answering it is yours.
entity Question {
  [Required] Claim Claim;
  [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 and recommend approve or send-back.";
  Prompt    = "You review expense claims.";
  Memory    = Persistent;                       // an agent that retains nothing cannot come back from a park
  Principal = new User { Email = "auditor@example.com" };
  Loop      = ReviewClaim;
}

workflow ReviewClaim {
  Tracks    = ReviewTask.Status;
  Autostart = true;
  Initial   = Running;

  event Answered(string text);

  state Running {
    enter {
      var claim = Claim.Where(c => c.Id == this.Item.EntityId).FirstOrDefault();
      if (claim == null) { goto Failed; }

      var turns = new List<Turn>();
      turns.Add(Turn.Context("Claim: " + claim.Title));
      turns.Add(Turn.User("Review this claim."));

      var reply = Auditor.Ask(turns);
      if (reply.Parked) {                        // it stopped to ask a person
        OpenQuestion(claim, this.Item, reply.Question ?? "");
        goto Waiting;
      }
      goto Completed;
    }
  }

  state Waiting {
    enter { Log.Information("waiting on the filer of {Claim}", this.Item.EntityId); }   // notifying is yours

    subscribe Answered(string text) as Filer {
      Assignee = Claim.Where(c => c.Id == this.Item.EntityId).FirstOrDefault().Filer;
      Finished { Within = TimeSpan.FromDays(2); Unfinished { goto Failed; } }
    }

    on Answered(string text, Slot slot) {
      var reply = Auditor.Answer(this.Item, text);   // their words reach the agent as its question's answer
      CloseQuestion(this.Item, text);
      if (reply.Parked) { goto Waiting; }            // it asked one more thing — ordinary, not an edge case
      goto Completed;
    }
  }

  terminal success Completed { }
  terminal error   Failed { Message = "the review could not be completed"; }
}

void OpenQuestion(Claim claim, ReviewTask task, string text) {
  var q = new Question { Claim = claim, Task = task, Text = text };
  UnitOfWork.Commit();
}

void CloseQuestion(ReviewTask task, string text) {
  var open = Question.Where(q => q.Task == task && q.Answer == null).FirstOrDefault();
  if (open != null) { open.Answer = text; UnitOfWork.Commit(); }
}

/// The trigger. `about:` is what lets the task say which claim it is for.
Guid StartReview(Claim claim) {
  return Auditor.StartTask<ReviewTask>("claim submitted: " + claim.Title, about: claim);
}

/// The person's answer, on its way to the parked run.
void AnswerAuditor(Question question, string text) {
  ReviewClaim.For(question.Task).Filer.Answered(text);
}

Read the three moments in order. StartReview opens the task and returns — the submit gesture does not wait for a model. Running's enter body runs the agent and branches on the park rather than being thrown out of. And on Answered hands the person's words back with Answer, which is the hop the whole mechanism is for: nothing was suspended, so nothing has to be woken.

Everything in Waiting is yours. Who is asked, how they hear about it, how long they get, what happens when they do not reply. The platform's half was making the run parkable at all.

A minimal loop, when you only need the wiring — every state present, no body yet:

using Osyrin.Agents;

[Principal] entity User {
  [Required, MaxLength(100)] string DisplayName;
  security { allow read when IsAuthenticated; allow create when IsAuthenticated || IsAnonymous; }
}

workflow ProcessTask {
  Tracks  = AgentTask.Status;
  Initial = Running;
  state Running { }
  state Waiting { }
  terminal success Completed { }
  terminal error   Failed { }
}

app.Agent = new AgentConfig { Loop = ProcessTask };

agent Triager {
  Purpose   = "Triage incoming tickets.";
  Prompt    = "You triage tickets.";
  Principal = new User { DisplayName = "Triager" };
}

A chat message that turns into work now opens a task, starts a run of ProcessTask bound to it, and the task's Status reads whatever state that run is in.

This one demonstrates the wiring and nothing else — a reader meeting the feature here would learn that a loop is a two-state workflow and come away with no idea why they would want one. The example above it is the feature.

See also#

Related

running an agent from your code

Call an agent you declared the way you would call anything else you declared — by name. You hand it the turns you want…

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…

Workflow.Work&lt;T&gt; (everything outstanding) and its SLA numbers

Every live slot of every run tracking T, whoever holds it — the unfiltered sibling of Workflow.Inbox. Rows carry…

the agent task log (AgentTask)

Every piece of work an agent does is recorded as an `AgentTask` — which agent, what set it going, when it started and…