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.
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.