← Pi Agents

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.