A Stochastic Step Is a Typed Function
Wrap an agent loop as a typed function: validate a submit-tool result, bound unsuccessful runs, and compose steps while keeping schema validity separate from correctness.
An unconstrained agent may finish with prose, and prose is a fragile interface between software components.
You can pass a paragraph to another function, but branching on its meaning requires interpretation or parsing whose behavior you must define and test. An ordinary agent can stop after deciding it is finished, without producing the structure a downstream function expects. This chapter puts a validated value at that boundary instead.
Chapter 36 fixed this for a single call. This chapter fixes it for a loop. The result is the most reusable idea in the book, and it fits in one function.
The shape
A stochastic step is a function whose input is text and whose output is a value you declared:
step(models, model, system, input: string, schema) -> Promise<Static<schema>>
models and model are how the step crosses the layer boundary: they are the
pi-ai collection and the model the agent core will call. The input is text.
The typed value exists only on the way out.
Within the tools and limits supplied by the caller, the model may look up
evidence, revise its answer or make mistakes. The wrapper returns a value only
after the core validates the arguments of a submit call against schema.
Otherwise the call throws; it never returns unvalidated prose as the typed result.
This is a guarantee about shape, not truth, authorization or successful
completion of every other tool. Expected run failures use StepFailed.kind to
identify the broad cause; configuration errors and unexpected runtime exceptions
can also propagate without being wrapped in StepFailed.
The implementation
import { Agent, type AgentTool } from "@earendil-works/pi-agent-core";
import type { Model, Models, Static, TSchema } from "@earendil-works/pi-ai";
export interface StepOptions {
/** Maximum number of failed submit tool calls before requesting abort. */
maxAttempts?: number;
/** How many model turns to allow in total, whatever they do. Bounds a model that never submits. */
maxTurns?: number;
extraTools?: AgentTool[];
}
// `kind` classifies the wrapper's expected run failures; it is not a retry policy.
// In particular, "provider" also covers an aborted response, which may reflect
// cancellation rather than a transient provider fault.
export type StepFailure = "provider" | "invalid-submissions" | "turn-limit" | "no-submit";
export class StepFailed extends Error {
readonly kind: StepFailure;
constructor(message: string, kind: StepFailure) {
super(message);
this.kind = kind;
}
}
// One stochastic step as a function: text in, a validated value or an error out.
// Only a schema-valid `submit` call can produce a successful return; other run
// endings throw. The core validates arguments before execute() runs.
export async function step<S extends TSchema>(
models: Models,
model: Model<any>,
system: string,
input: string,
schema: S,
options: StepOptions = {},
): Promise<Static<S>> {
const maxAttempts = options.maxAttempts ?? 3;
const maxTurns = options.maxTurns ?? 10;
if (!Number.isSafeInteger(maxAttempts) || maxAttempts < 1) {
throw new RangeError("maxAttempts must be a positive integer");
}
if (!Number.isSafeInteger(maxTurns) || maxTurns < 1) {
throw new RangeError("maxTurns must be a positive integer");
}
if (options.extraTools?.some((tool) => tool.name === "submit")) {
throw new Error('extraTools must not define the reserved tool name "submit"');
}
let result: Static<S> | undefined;
const submit: AgentTool = {
name: "submit",
label: "Submit",
description: "Submit the final answer. This ends the task. Call it exactly once.",
parameters: schema,
async execute(_id, params) {
// The first valid submission is the answer. A later one cannot
// overwrite it, so a model that submits twice cannot retroactively
// change the value this call returns.
if (result === undefined) result = params as Static<S>;
return { content: [{ type: "text", text: "accepted" }], details: undefined, terminate: true };
},
};
const agent = new Agent({
initialState: { systemPrompt: system, model, tools: [submit, ...(options.extraTools ?? [])] },
streamFn: models.streamSimple.bind(models),
});
// A rejected submit call reaches the model as an error tool result, giving it
// an opportunity (not a guarantee) to try again. The counter below increments
// for each failed submit tool call; it does not inspect the error's cause.
// A model that never calls submit needs a separate turn bound.
//
// maxTurns bounds the whole run, so it must fire whether or not a submission
// has been stored. It is not conditional on `result === undefined`, and that
// is the load-bearing detail: submit returns `terminate: true`, but a batch
// only ends the turn when every completed result in it agrees (chapter 17).
// A response carrying submit *and* a non-terminating tool therefore stores a
// result and keeps looping, and a bound that switched off once a result
// existed would stop holding exactly then.
let failures = 0;
let turns = 0;
let hitTurnLimit = false;
agent.subscribe((e) => {
if (e.type === "tool_execution_end" && e.toolName === "submit" && e.isError && ++failures >= maxAttempts) {
agent.abort();
}
if (e.type === "turn_end" && ++turns >= maxTurns) {
hitTurnLimit = true;
agent.abort();
}
});
await agent.prompt(input);
// Policy choice: return the first schema-valid submission even if a later turn
// reaches the limit or fails. This does NOT establish that other tools in the
// batch succeeded, that later model work succeeded, or that the answer is true.
// Applications requiring all-or-nothing completion must use a stricter policy.
if (result !== undefined) return result;
if (failures >= maxAttempts) throw new StepFailed(`no valid submission after ${failures} attempt(s)`, "invalid-submissions");
if (hitTurnLimit) throw new StepFailed(`no submission after ${turns} turn(s)`, "turn-limit");
// A failed request is not a model decision. It ends the run with the error on the final message.
const last = agent.state.messages.findLast((m) => m.role === "assistant");
if (last && (last.stopReason === "error" || last.stopReason === "aborted")) {
throw new StepFailed(`model call failed: ${last.errorMessage ?? last.stopReason}`, "provider");
}
throw new StepFailed(failures ? `no valid submission after ${failures} attempt(s)` : "the model finished without calling submit", failures ? "invalid-submissions" : "no-submit");
}
It is short because the agent core already does most of the work. Three mechanisms are doing the job, and each one is documented.
The only successful return is a validated tool submission. The model is told
that submit ends the task, and its parameter schema is the schema you passed in.
If no valid submission was stored, prose-only completion or an exhausted bound
raises an error. A previously stored valid submission can still be returned after
a later limit, by the wrapper’s explicit policy.
Static<S> is inferred from the same TypeBox definition used at runtime, but the
cast inside execute() still relies on the core’s runtime validation. Matching a
schema gives structural validity, not a guarantee that a judgment is correct.
Arguments are validated before execute() runs. The README says the core
prepares and validates arguments before beforeToolCall and execution. With the
provided schema, a malformed submission receives an error result instead of
reaching the result = params assignment. The core performs structural schema
validation; the host must still enforce application rules such as permitted
citations, authorization, and consistency with evidence.
terminate: true can end the run after the submission. A tool result can
request that the loop skip the next model call. The core honours that signal only
when every finalized result in the batch sets terminate: true. If the model
submits alongside a non-terminating tool, that other tool may execute and the loop
continues; the stored submission is not an atomic commit or a cancellation of
other effects. Without termination, the loop can make another model request after
a successful submission, costing additional tokens.
What the tests establish
The chapter reports fourteen tests against pi-agent-core 1.0.4. The table
summarizes the distinct behaviors they exercise rather than listing every test
one-for-one. Several regression cases came from an independent review of the
first implementation, including a promised turn limit that the earlier code did
not enforce after storing a submission.
| Test | What it shows |
|---|---|
| Valid submission | One request, not two. terminate really did save the follow-up |
| Invalid, then valid | The scripted next response corrected the rejected tool arguments after receiving an error result; this does not measure how often a real model self-corrects |
| Repeatedly invalid | Stopped at maxAttempts, and execute() never saw a value |
Prose, no submit |
StepFailed, not an empty or partial result |
| Two steps | Output of one, serialised, is the next step’s input text |
| A failed request | StepFailed with kind: "provider" and the provider’s message, not “the model finished without calling submit” |
| A model that never submits | Stopped by maxTurns, with kind: "turn-limit". maxAttempts does not bound this, because no invalid submission ever happens |
| submit batched with a non-terminating tool | maxTurns still holds. The submitted value is returned and the request count is exactly maxTurns |
| Mixed batch, then the run fails | The wrapper returns the earlier schema-valid submission despite a later failed model request; this is its chosen acceptance policy, not proof the rest of the work succeeded |
| Submitted twice | The first valid submission wins; a later one cannot overwrite it |
| Invalid in a mixed batch | A single failed submit counts once; an unrelated tool in the same batch does not itself consume an attempt |
| Invalid in mixed batches, exhausted | The attempt budget still ends the run with kind: "invalid-submissions" |
Three ways to spend the same work
A typed step is not one shape. The differences are in who decides the step is over, and those differences are exactly where the bounds go wrong.
| Shape | Who ends it | Typical request count | The failure mode |
|---|---|---|---|
| One request (chapter 36) | the request ends | exactly 1, or it did not work | Returns prose, or an error value. No value at all |
| Bounded loop (this chapter) | submit, or a bound |
1 to maxTurns |
Answers in prose; submits something invalid; submits in a batch with a non-terminating tool |
| Open agent (chapter 37) | the model decides | unbounded without a limit | Keeps working. Or keeps searching. Nothing stops it but you |
flowchart TD
I["input"] --> R["model turn"]
R --> O{"assistant outcome"}
O -->|"prose, failure or abort"| F["no valid submit: StepFailed"]
O -->|"tool calls"| X["execute tool batch"]
X -->|"valid submit; all results terminate"| V["return stored valid value"]
X --> A{"failed submit count reached maxAttempts?"}
A -->|yes| AB["abort"]
A -->|no| B{"turn_end count reached maxTurns?"}
B -->|yes| AB
B -->|no| R
AB --> D{"valid submit already stored?"}
D -->|yes| V
D -->|no| F
The original bug was in the turn-limit guard, which stopped checking the bound
once result existed. The revised guard counts completed turns even after a
submission in a mixed batch. A valid submission can nevertheless be returned
after a later abort or failure; that is an explicit acceptance policy, not an
indication that every intervening operation succeeded. The diagram summarizes
normal control flow rather than showing provider transport retries or deadlines.
The second row of the table is worth a closer look. step() does not itself
retry failed provider requests or guarantee that invalid submissions will be
corrected. The core returns a rejected tool call as an isError result and may
make another model request; the next response might repair the arguments, fail
again, or never call submit. The scripted test demonstrates one successful
repair, not a reliability rate.
The third row exists because a model can repeat a mistake. Without a bound, a model
that keeps submitting the same invalid value would loop until a request limit or a
bill stopped it. step() counts failures from the tool_execution_end events and
calls agent.abort(). The bound is proposed, not documented. It is a pattern,
and the number is yours to choose.
maxAttempts actually counts failed submit tool calls as reported by
tool_execution_end; the listener does not distinguish bad schema arguments
from other failures of that tool. Multiple failed submit calls can each count,
even if issued in one assistant response. A model that repeatedly calls only a
lookup tool does not increment that counter. The first version of this function
had no turn bound: a scripted model that never submitted made 31 requests under
the default maxAttempts of 3. The revised maxTurns counts completed
turn_end events and requests abort at the limit. Neither setting is a wall-clock,
provider-HTTP-retry, tool-duration or token-spend limit; unattended applications
need separate deadlines and cost controls. These are this book’s application
bounds, not automatic guarantees supplied by Pi.
The same review found that a failed request used to surface as “the model
finished without calling submit”, which mistakes a transport or model failure
for a model decision. When no valid submission exists, step() checks the last
assistant message for stopReason: "error" or "aborted" and raises
StepFailed with kind: "provider". That label is deliberately coarse: an
abort can be caused by cancellation rather than a provider outage, and not every
provider failure is transient or safe to retry. The separate retry mechanisms
and policies belong to the caller; chapter 40 considers where they fit.
The bound that was not a bound
The turn counter originally read if (++turns >= maxTurns && result === undefined).
That guard looks harmless and it is not: it means the turn bound stops applying the
moment a submission has been stored.
Chapter 17’s batch rule is what turns that into a real defect. submit returns
terminate: true, but the core honours it only when every finalized result in
the batch agrees. So a model that calls submit and a lookup tool in one response
stores a valid answer and keeps the loop running — and from that point the turn
limit no longer fires at all.
Observed on 1.0.4 against the unfixed code: with maxTurns: 2 and a scripted model
returning submit plus lookup in every turn, step() made seven requests and
returned the submitted value. A limit described in a comment as “how many model
turns to allow in total, whatever they do” was in fact “how many turns to allow
before the first valid submission”.
The fix is to drop result === undefined from the guard, and then to decide what a
bound means once a value exists. Three choices were available:
- Ignore the stored value and throw
turn-limit. Honest about the overrun, but it discards a schema-valid answer the model already committed to. - Keep returning it, and say so. The bound’s job is to stop the loop; whether the value is trustworthy is a separate question the schema already answered.
- Track that both happened, and report a distinct kind.
This chapter takes the second, because it is the one that composes. A caller
retrying on kind: "turn-limit" is asking “did the model fail to answer?”, and a
stored submission means it did not. The ninth row of the table above pins it.
Two further points concern mixed batches. One failed submit call counts once;
an unrelated lookup in the same batch does not count as another failed submission.
That is not a promise of one attempt per batch: two failed submit calls would
generate two matching failure events. Conversely, a valid submission followed by
a provider failure is still returned because result was saved earlier. This
choice may suit a read-only assessment, but an application with side-effecting
extraTools should not treat it as proof that the complete operation succeeded.
Chaining
Because each step has a declared output type, the value you get back is typed, and you pass it to the next step as text, serialised by you:
const triage = await step(models, model, "Triage.", report, Triage);
const action = await step(models, model, "Write the next action.", JSON.stringify(triage), Action);
There is no workflow framework here. Each step returns a schema-validated value
that ordinary code can log, store, check or use for branching. You choose how
to serialize that value into the next prompt; JSON.stringify does not by
itself preserve a runtime schema contract for the receiving model, so the next
step must validate its own output and any critical input assumptions. No
conversation history is automatically shared between these newly constructed
agents. A step still exposes its own system prompt, supplied input and any tool
results produced during that step to its model.
Each call builds its own Agent, and result is a local variable of that call.
Two steps do not share state, so steps that do not depend on each other can run
together:
const [a, b] = await Promise.all([step(/* ... */), step(/* ... */)]);
Proposed, with a caveat: nothing in the documentation promises this is safe for every provider. Rate limits, in particular, are yours to handle.
Tools inside a step
step() takes extraTools. A step that needs to look something up gets the tool
from chapter 37 beside submit:
const out = await step(models, model, system, input, EvidenceAssessment, {
extraTools: [lookupEvidence(corpus)],
});
Now the step is a small agent with a typed successful return. The model may
call lookup_evidence while its run remains within the configured limits. It can
also fail, answer only in prose, or exhaust a bound without submitting anything.
Supplying an extraTools implementation additionally grants that tool’s
capabilities; the schema on submit does not sandbox its effects. This joins
chapter 36’s single-call validation to chapter 37’s
loop, with an application-defined completion policy.
What a type cannot give you
A value that matches the schema is not a value that is true. The schema says
confidence is a number between 0 and 1. It does not say the model should be
confident. It says citations is an array of strings. It does not say the strings
exist in the evidence.
That gap is real, and it is where the next two chapters go. A step makes the shape of an answer a contract, not its factual content. Ordinary code can perform deterministic checks after the step. For example, if the citation contract is that each citation must reproduce an entire evidence item exactly, you can reject unmatched items:
if (!assessment.citations.every((c) => evidence.includes(c))) {
throw new Error("citation not present as an exact evidence item");
}
This does not detect a real evidence item used to support an unrelated claim, and would reject valid citations expressed as IDs or excerpts rather than complete items. Choose a citation contract first, then check both references and their relationship to the asserted conclusion. Chapter 40 considers where these validations belong in a pipeline.
What this is not
It is not a multi-agent system. One agent runs, finishes, and is discarded. The
word “step” is chosen over “agent” because nothing about the composition depends
on the steps being agents. A step might be one complete() call, as in chapter 36.
It might be a loop with tools. It might be a plain function that never calls a
model. Chapter 40 mixes all three.
It is also not guaranteed to succeed. The expected no-submit, turn-limit,
failed-submission and request-failure paths raise StepFailed, but invalid
configuration and unexpected runtime errors can raise other exceptions. The
function may also return an earlier schema-valid submission after later work
failed, by explicit policy. Callers must decide whether to accept that policy,
retry a safe operation, try another model, escalate to a person or stop. A
StepFailed.kind is a useful signal, not sufficient evidence that retry is safe.
What you can and cannot claim
Documented: AgentTool, argument validation before execution, terminate
semantics (including “every finalized tool result in the batch”), abort(), and
throw-to-fail (pi-agent-core README and declarations, 1.0.4).
Observed: the behaviors summarized in the test table were reported under scripted responses against 1.0.4. They exercise the implementation’s handling of selected responses; they do not demonstrate real-model compliance, bounds on wall-clock time or token spend, or safety of side-effecting extra tools. The new configuration guards in this review require corresponding tests in the example suite before publication. Chapter 41 addresses how to measure real-model performance.
Proposed: the step() abstraction, the maxAttempts bound, and running
independent steps in parallel.
Next
You can now build a typed-returning function around an agent. The next
question is about control: where in the loop can you intervene?
step() used an event subscription, agent.subscribe(), to observe completed
tool calls and turns and request an abort. That is not the same as a pre-execution
hook: some decisions must be enforced before a tool has any effect.
Chapter 39 maps every point at which the agent core lets you interfere, and what each one can still change.