Example

Return a schema-valid value from a bounded model loop

Drive step() on pi-agent-core 1.0.4 with a scripted provider: a valid submit ends the run in one request, and every other exit is a StepFailed kind.

A stochastic step is a typed function: text in, a value that matches your TypeBox schema out, and no third outcome. The model can only finish by calling a submit tool whose parameters are that schema, so prose cannot leak past the seam. This page runs the canonical step.ts on pi-agent-core 1.0.4 with a scripted provider.

Run it

The only way out is the submit tool; terminate: true is the hint that ends the loop (excerpt of the canonical step.ts, full file linked below):

	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 };
		},
	};

Arguments are validated before execute() runs, so it only sees valid values. The bounds are the book’s pattern, not Pi’s contract: maxAttempts counts invalid submissions, maxTurns counts turns in total:

	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();
		}
	});

After await agent.prompt(input) there is no third outcome: a stored submission is returned, otherwise a StepFailed is thrown with a kind — invalid-submissions, turn-limit, provider (a failed request), or no-submit.

Expected outcome

Every row is observed: step.test.ts (Pi 1.0.4, scripted responses) pins each one; the last four exist because a first version got them wrong.

Scripted model does Result
submits a valid value once returns it after 1 request; terminate saved the follow-up
submits invalid, then valid the invalid call returned as an error and the model repaired it
keeps submitting invalid values stops at maxAttempts; execute() never saw a non-valid value
answers in prose, no submit StepFailed kind no-submit — never a silent empty value
(two steps) output of one, serialised, is the next step’s input
uses a lookup tool, then submits allowed; it must still end in a valid submit
gets a failed request StepFailed kind provider — a rate limit is not a model decision
never submits stopped by maxTurns (kind turn-limit); maxAttempts never moves

The final row is where the first version broke. A batch ends early only when every completed result agrees, so submit plus a non-terminating lookup stores a value and keeps looping — a guard reading && result === undefined switched off then. The fixed guard drops that clause, still returns the committed value, and maxTurns holds.

Mechanism and limitations

Documented: AgentTool, argument validation before execution, terminate semantics (“every finalized tool result in the batch”), abort(), and throw-to-fail (pi-agent-core README and declarations, 1.0.4). Observed: step.test.ts drives the real step() through an Agent and asserts every row above, including that an invalid submission in a mixed batch costs one attempt. Proposed: the step() abstraction and the bound numbers.

A schema-valid value is not a true one: the schema constrains the shape, not the content. This is a faux-provider run — nothing about how often a real model submits a valid value first try.

Full source and test .

Understand this example

Copy this prompt into your AI tool. No code runs here.

Apply this example

Copy this prompt into your AI tool. No code runs here.