Limits and Errors

This chapter is the map for when a prompt runs long or goes wrong. It gathers every limit a run observes in one table, shows what happens when Lua code runs into each one, and gives the full classification of failures: the parse error kinds a file can fail with before it runs, the error kinds pcall sees inside Lua, and the run error kind a failed run reports. It ends with the Host cancel, the one way a run stops without failing. With it, any failure message from any chapter tells you what broke and where to look.

Limits at a glance

Every run observes six limits. These are their defaults:

LimitDefaultApplies toSet by
Round cap24 tool roundseach models.loop callmax_tool_iterations: in the frontmatter, or the Harness
Concurrency limit8 tasks at onceevery task the run admits, fanout arms includedthe Harness, lowered by tasks.concurrency
Response cap16 MiB (16,777,216 bytes)each model replythe Harness
Memory ceiling64 MiB (67,108,864 bytes) of Lua heapeach section VMthe Harness
Log event quota1024 log callseach section VMthe Harness
Receive timeout120 secondseach wait for the next piece of a model replythe Harness

The log byte quota follows from the log event quota: 256 bytes for each call the log event quota allows, so 262,144 bytes by default. No limit counts Lua instructions.

One set of limits applies to the whole run, the H1 pass and every section included. A run uses the defaults unless the Harness that runs it sets its own values, so the limits a given run observes can differ from this table.

From inside a prompt, only the round cap can change. The frontmatter key max_tool_iterations takes a whole number from 1 to 1000 and caps each models.loop call separately (The round cap):

---
name: researcher
description: Researches a topic with tools
promptforge: 0
max_tool_iterations: 50
---

A value outside that range fails the parse with parse error kind Frontmatter and one of these messages, where {raw} is the value as written:

max_tool_iterations must be a positive integer (>= 1), got {raw}
max_tool_iterations must be <= 1000, got {raw}

No frontmatter key sets the other five limits. The concurrency limit is taught with fanout, a prompt can only lower it, with tasks.concurrency (Tasks), and the rest of this chapter covers the memory ceiling, the log quotas, the response cap, and the receive timeout. The web fetch tool has a policy of its own, whose values are also defaults set by the Harness and that a prompt cannot change (The fetch policy).

How failures are reported

The whole prompt file is checked before anything runs. The parser validates the frontmatter, the headings, the fences, and the sections, and compiles every Lua block: the shared library, each block in the H1 body, and each section block. A structural error or a Lua syntax error anywhere in the file, even in the last section, stops the prompt before any section runs.

Failures fall into four families, each with its own vocabulary:

FamilyWhen it happensWhat you see
Parse failurebefore anything runsone of five parse error kinds
Error valueinside Lua, at the call that failedan error value whose kind is one of thirteen lowercase tags
Failed runwhen the file fails to parse, prepare refuses the run, or a failure goes uncaughtone of twelve run error kinds
Cancelled outcomewhen the Host cancelsno error kind at all

A parse failure has exactly one of five parse error kinds: Frontmatter, Structure, Fence, List, or Lua (Parse error kinds).

Inside Lua, a failure is an error value (Catching and inspecting errors), and its kind is always one of lua, internal, cancelled, context_exhausted, empty_model_reply, tool_loop_exhausted, tasks_live, task_not_owned, task_consumed, out_of_scope_tool, unbound_tool, store, or tool. Catch it with pcall and branch on err.kind:

local ok, reply = pcall(models.infer, prose)
if not ok then
  local err = reply
  if err.kind == 'internal' then
    return 'model unavailable: ' .. err.message
  end
  error(err)
end
return reply

Here an internal failure, such as a model call the Harness could not complete, becomes the section's result, and any other kind is raised again unchanged before any other suspending call, so the run ends exactly as it would have without the pcall.

A failed run reports one run error kind naming what failed, from a fixed set of twelve: Parse, Version, Binding, Completion, Tool, Vfs, Determinism, Lua, Quota, ContextExhausted, Internal, and RequirementsUnmet (How a failed run is classified). The Host decides how it shows the run error kind, the message, and, for a parse failure, the location to the person running the prompt.

A run the Host cancels is not a failed run. A running prompt can be stopped at any point, even inside a Lua loop that never waits on the Harness, and the run then ends with the cancelled outcome (Failure and cancellation), a clean stop rather than a failure. The Host triggers the cancel, for example on Ctrl-C. A prompt cannot cancel its own run; it only observes the outcome (Cancelling a run).

Lua block budgets

Each section VM runs its Lua under the memory ceiling and two log quotas. They are applied right after the section VM is built, before any setup step, so they cover every block from its first instruction, the shared library replay and the H1 pass included. Each section VM starts with its own full allowance.

No instruction limit

A Lua block has no instruction-count limit, so long loops are legal. A block runs until it finishes, runs past the memory ceiling, runs out a log quota, or the run is cancelled. This bounded loop simply runs to the end:

---
name: counter
description: Counts to eight million
promptforge: 0
---

# Counter

## Count

```lua
local n = 0
for i = 1, 8000000 do n = n + 1 end
return n
```

Its run result:

8000000

Even an endless loop such as while true do end is legal. Only a Host cancel stops it, within about 10,000 instructions (Cancelling a run).

The memory ceiling

Each section VM may use up to 64 MiB of Lua heap by default, or the Harness's value. An allocation past the memory ceiling is refused as an ordinary Lua error whose message mentions memory. It is not a quota:

  • Caught with pcall, it is an error value of kind lua.
  • Left uncaught, it ends the run with run error kind Lua, or with RequirementsUnmet in the H1 pass, where the Lua error text becomes the requirements notice.
  • The section VM is still torn down normally.

A block that keeps adding large strings to a table forever is the usual way to reach it:

local t, i = {}, 1
while true do
  t[i] = string.rep('x', 16384)
  i = i + 1
end

The log quotas

log records author checkpoints, and each section VM has two quotas for it:

  • The log event quota: 1024 log calls by default, or the Harness's value.
  • The log byte quota: the log event quota times 256, so 262,144 bytes by default, counted in UTF-8 bytes across all of the section VM's log calls. Multibyte text uses it up faster: 256 copies of é cost 512 bytes.

Every log call counts, calls made while the shared library loads included. Each call is checked in this order:

  1. It must have exactly one argument, or it fails with log expects exactly one argument and spends nothing.
  2. It spends one unit of the log event quota.
  3. It is checked against the string type, 256-character, and line-break rules, so a one-argument call that breaks one of them has still spent its unit.
  4. It charges the message's UTF-8 byte length to the log byte quota. A message that would overflow the quota is refused whole, and messages logged before it stay recorded.

A call past either quota raises an error instead of recording a checkpoint. What you see depends on whether you catch it:

QuotaCaught with pcall, kind luaUncaught, run error kind Quota
Log event quotalua log event budget exceededlua log event quota exceeded
Log byte quotalua log cumulative byte budget exceededlua log byte quota exceeded

The uncaught message names no prompt line. Quota stays Quota in the H1 pass too.

This prompt logs more than the default log event quota allows and catches the refusal:

---
name: chatty
description: Logs past the log event quota and catches the refusal
promptforge: 0
---

# Chatty

## Steps

```lua
local ok, err = pcall(function()
  for i = 1, 2000 do
    log('step ' .. i)
  end
end)
if not ok then
  return err.kind .. ': ' .. err.message
end
return 'all steps logged'
```

With the default quota, the 1025th call is refused, and the run result is:

lua: lua log event budget exceeded

Two Harness-set quotas show the other cases. With a log event quota of 4, the log byte quota is 1024 bytes, so three log(string.rep('é', 200)) calls of 400 bytes each fail on the third with the log byte quota refusal. Exactly two checkpoints are recorded, and left uncaught the failure ends the run as Quota before the block's later return runs. With a log event quota of 1, a shared library that calls log('one') and then log('two') fails on the second call, which left uncaught ends the run with lua log event quota exceeded.

Model reply size and wait time

Two limits guard every model call. Both apply to every round in the run, models.infer rounds (nested ones included) and models.loop rounds alike, so an oversized or stalled reply ends that round with an error instead of hanging the run. Only the Harness changes either one.

The response cap

A model reply may be up to 16 MiB (16,777,216 bytes) by default. A reply that would pass the response cap is refused as its bytes arrive, before any decoding, and the cap covers error replies as well as successful ones. The call fails with a malformed-response error whose message names the byte limit after the fixed phrase:

the model backend sent a reply that could not be understood: response stream exceeds the {max_bytes}-byte limit

An error reply over the cap names the limit the same way, as response body exceeds the {cap}-byte limit or response body of {len} bytes exceeds the {cap}-byte limit.

The receive timeout

Every model call has a receive timeout of 120 seconds by default. The call waits at most that long for the reply headers, and then at most that long for each next chunk of the body. Every arriving chunk restarts the wait, and there is no limit on the whole request, so a long reply that keeps streaming is never cut off. A reply that stalls fails the call as a timeout:

the model backend did not answer in time

Both failures are model call failures: kind internal when caught, run error kind Completion when uncaught, and both count as transient, so the run may succeed when run again (Model call and environment failures).

Parse error kinds

A parse failure stops the prompt before any section runs, and every parse failure has exactly one of five parse error kinds. The frontmatter is decoded before the body is checked, so a frontmatter failure is reported ahead of any body failure, and the kind tells you which part of the file to look at:

Parse error kindRaised when
Frontmatterthe frontmatter block at the top of the file is missing, unclosed, or not valid YAML, or the prompt contract rejects a value in it
Structurethe H1 title or the heading tree is invalid, or the file has no promptforge: key when it is run
Fencea lua shared fence is repeated or outside the H1 body, or a fence is left unclosed
Lista list section holds something other than list items, an empty item, or no items
Luaa Lua block does not compile

Common mistakes land in predictable kinds: malformed YAML and an out-of-range max_tool_iterations give Frontmatter, duplicate sibling headings give Structure, an empty list item gives List, and a Lua syntax error gives Lua. Whatever its parse error kind, a parse failure ends the run with run error kind Parse.

Frontmatter

Frontmatter covers the YAML block itself and every value the prompt contract checks (Frontmatter rules and errors). YAML that does not parse gives this message, where {message} is a readable YAML diagnostic that never dumps the raw source:

invalid frontmatter: {message}

A value the contract rejects, such as a malformed Plugin id, an out-of-range max_tool_iterations, or a tool alias that is a reserved name, gives that key's own message.

Structure

Structure covers the H1 title and the heading tree (Sections and nesting):

prompt requires an H1 title
prompt must contain exactly one H1 title
prompt H1 title must not be empty
section `{name}` is an orphan H{level} heading with no parent H{n}
an H{level} section heading must not be empty
duplicate sibling section name `{name}`: first declared at line {first_line}, again at line {line}; sibling section names must be unique

The first three mean the H1 title is missing, appears more than once, or is empty. The orphan message means a heading has no parent one level up, the next means a heading is empty, and the last means two sibling sections share a name.

A file whose frontmatter has no promptforge: key parses, but its run is refused on the first step with this Structure message (The promptforge version):

not a promptforge prompt: no promptforge version

A prompt with an H1 title and no ## sections is valid and runs.

Fence

Fence covers where lua shared fences go and whether fences are closed (Writing a Lua fence):

prompt allows at most one `lua shared` fence
`lua shared` fence is allowed only in H1
{label} fence is not closed
section `{name}` `lua` fence is not closed exactly

List

List covers the contents of a list section (List sections):

section `{name}` is a list section but contains non-list content: {line}
empty bullet item in list section `{name}`
section `{name}` is a list section but has no items

Lua

Every Lua block, whether the shared library, an H1 block, or a section block, is compiled when the file is parsed. A syntax error fails the parse with parse error kind Lua and a message naming the section and block plus the Lua compiler's diagnostic. Error locations in the prompt file shows the message layout.

Finding where a parse failed

A parse failure comes with a location when the parser can point at the problem. The Host reports it beside the message as a path, a line, a column, and a byte span, each when known. Lines are 1-based and count from the top of the file, the frontmatter included, with the opening --- as line 1.

Frontmatter failures

A frontmatter failure, whether the YAML is invalid or the contract rejects a value, gives a 1-based line and a 1-based column. For a Plugin entry on line 5 whose value is not a Plugin id, the failure reports line 5 and column 5, where the value starts after the - list marker. The exceptions are the checks that span entries, which give neither a line nor a column: a name declared both under tools: and under models:, a Plugin declared twice, and a tool slot whose Plugin is declared optional.

A frontmatter failure has no prompt name, because the name comes from the frontmatter itself. Its location path is the placeholder <prompt>, and the Host may label the failure with its own name for the file instead.

Body failures

A Structure, Fence, or List failure holds the prompt's frontmatter name. When the parser can point at the offending region, such as a duplicate sibling section, it also gives the 1-based file line and the 1-based column where that region starts; body failures without a located region give neither. The column counts bytes, so a multibyte UTF-8 character earlier on the line pushes it past the character count. The message is the plain diagnostic with no added prefix, and the name, line, and column are separate details beside it.

This file names two sibling sections S:

---
name: dup
description: Two sibling sections share a name
promptforge: 0
---

# T

## S

First.

## S

Second.

It fails the parse with parse error kind Structure and this message:

duplicate sibling section name `S`: first declared at line 9, again at line 13; sibling section names must be unique

The location path is dup, the line is 13, and the column is 1. The run error kind is Parse.

Lua compile failures

A Lua syntax error's position is inside its message: the Lua compiler's diagnostic passes through word for word, and the separate name, line, and column details are empty. Error locations in the prompt file shows how to read the position.

Failures after the parse

Only parse failures have a prompt location. An Internal failure names an Engine source file and line instead, and every other run error kind reports no location. For the prompt line a runtime Lua error names in its message, see Error locations in the prompt file.

Errors caught in Lua

An error value is the table pcall returns for a failure, with its error kind in err.kind and its text in err.message (Catching and inspecting errors). Most error kinds belong to one feature and are taught with it. Three are families that gather failures from many places: lua, internal, and cancelled.

The lua family

kind == 'lua' covers every failure on the Lua side of a run as one family:

  • a Lua runtime error, your own error and assert calls included
  • a built-in call's own argument error
  • running past the memory ceiling
  • a refused log call
  • a failed {{ }} substitution (Substitution errors)

The message text is what tells them apart. A failed store operation is not in this family: it has its own kind, store, with the failure's reason and fields (Store errors).

The internal family

kind == 'internal' marks a failure the prompt cannot fix:

  • a model call failure: a connection failure, a receive timeout, a backend error status, a malformed reply (an oversized one included), or a disabled gateway
  • the missing-model error, raised when a model round has no model selected (Choosing a section's model)
  • a fault in the Engine or in the Lua runtime's own machinery

An ordinary failed store call is kind store, not internal. Its message text says what failed, and its reason and fields let a prompt branch on the failure mode (Store errors).

The cancelled kind

kind == 'cancelled' covers a Host cancel and a cancelled task. For a Host cancel the message is interrupted by Ctrl-C (Calls waiting during a cancel). For a cancelled task, err.task holds the task id (Cancellation and task lifetimes).

Error kinds and run error kinds

An error kind is what Lua sees at the call. A run error kind is what the Host reports when a failure ends the run. They are separate vocabularies, and one error kind can lead to different run error kinds depending on its source. An uncaught failure ends the run with its kind, message, and fields intact, never flattened to a message string, so the run is classified the same as the original error: an uncaught context_exhausted ends the run as ContextExhausted with a message starting context exhausted: .

err.kindIts own fieldsUncaught, ends the run as
luanoneLua, or Quota for a refused log call
internalnoneCompletion for a model call failure, Binding for the missing-model error, Internal for an Engine fault
cancelledtask, for a cancelled taskthe cancelled outcome for a Host cancel; Lua for a cancelled task's error value raised again right after its wait
context_exhaustedreasonContextExhausted
empty_model_replyfinish_reason, only when the backend gave oneCompletion
tool_loop_exhaustednoneTool
toolnoneTool
out_of_scope_toolnameTool
unbound_toolnameTool
tasks_livetasksLua
task_not_ownedtaskLua
task_consumedtaskLua
storereason, plus path, and anchor and count or ruleVfs

Every other error value has only kind and message. In the H1 pass, some of these failures end the run as RequirementsUnmet instead, as How a failed run is classified explains. An error value raised inside a local tool handler reaches a pcall around the call with its kind kept (Local tools).

Raising a caught error again

When you catch an error value and raise it again with error(err), the run's classification depends on when you raise it. Raised again before any other suspending call, it ends the run exactly as if it had never been caught, with the same run error kind. Raised again later, after another suspending call, the failure is rebuilt from the value's kind:

err.kindRaised again later, ends the run as
tool_loop_exhausted, toolTool
empty_model_replyCompletion
context_exhausted, with its reasonContextExhausted
cancelledthe cancelled outcome, not a failed run
storeVfs
task_not_owned, task_consumed, with their taskLua, in the H1 pass too
out_of_scope_tool, unbound_tool, tasks_live, lua, internalLua, or RequirementsUnmet in the H1 pass

A value missing its kind's fields also ends the run as Lua, or as RequirementsUnmet in the H1 pass.

How a failed run is classified

A failed run reports exactly one run error kind. The kind names what failed, and the message beside it is always the underlying error's own text with its full cause chain; the kind never replaces it. There are twelve run error kinds. A Host cancel is not among them, because a cancelled run has not failed.

Run error kindThe run failed becauseMessage
Parsethe file failed to parse, or has no promptforge: keythe parse failure's own message
Versionpromptforge: declares a major version other than 0unsupported promptforge version: {n} (this build supports major 0)
Bindinga model round had no model selectedmodel binding required for section {section}
Completiona model call failed, or an empty reply went uncaughtthe call's own message, such as the model backend is overloaded (status 503)
Toola tool call failed, was out of scope, or named an unbound tool, or a models.loop call reached the round captool call failure: {message}, or one of the other tool messages below
Vfsa store operation failed and went uncaught, or was caught and raised again, or the handle declares no storethe store failure's own message, such as file not found in store: {path} or store operation failed
Determinismtwo accesses unordered by happens-before touched one store region in conflicting waysstore determinism violation: {detail}
LuaLua failed at run time or returned an unusable valuethe Lua error's message
Quotaa section VM ran out a log quotalua log event quota exceeded or lua log byte quota exceeded
ContextExhaustedthe compactor ran out of the model's context windowcontext exhausted: {reason}
Internalan Engine invariant brokeinternal invariant violated: {message}
RequirementsUnmetprepare refused the run, or the H1 pass failed its hard gatethe requirements notice, or the Lua error text

Nothing reruns a failed run automatically. Model call and environment failures lists the failures worth running again.

Before the run starts

  • Parse: every parse failure, a Lua syntax error included, is reported as a failed run with run error kind Parse, while its parse error kind says which part of the file failed (Parse error kinds). A file with no promptforge: key also ends its run on the first step with Parse.
  • Version: the promptforge: key declares a major version other than 0, and the run ends on its first step (The promptforge version).
  • RequirementsUnmet at prepare: a required Plugin is missing or fails to activate, two declared Plugins conflict, a tool slot names a Plugin that contributed no tools, or a filled model fails a hard requirement, so prepare refuses the run before it starts, and the message is the requirements notice (When a run cannot start).

Model, tool, and input failures

  • Binding: a section sends prose to a model, or calls models.infer without a handle, while neither models.use nor a prompt-wide models.default is in effect (Choosing a section's model). Caught with pcall, the same error is kind internal. The kind also covers a Harness tool whose schema the Harness cannot offer to the model, which nothing in a prompt causes.
  • Completion: a model call fails at the transport, backend, or decode layer and the prompt does not catch it, a disabled gateway included; or an empty_model_reply goes uncaught (Empty and truncated replies).
  • Tool: a dispatched tool fails (Tool failures), a tool call is out of the section's scope (Advertising tools to the model), a call names a tool not bound in the run (Calling tools from Lua), or a models.loop call does not finish within the round cap (The round cap), and the prompt does not catch it.
  • ContextExhausted: the selected compactor runs out of the model's context window and the prompt does not catch it (Compactors and context exhaustion).
  • A failed operator ask is a tool failure: when the Host cannot answer an input.ask() and the prompt does not catch it, the run ends as Tool. Caught with pcall, the same failure is kind tool.

The four Tool messages are these, where {name:?} and the other :? placeholders show the value in double quotes with escapes:

tool call failure: {message}
tool {name:?} is not in this section's scope; in-scope aliases: {in_scope:?}
tool {name:?} is not bound in this run; bound aliases: {bound:?}
tool-call loop did not converge

Lua and quota failures

  • Lua: a section's Lua fails at run time or does not return a usable value. That covers an uncaught runtime error, running past the memory ceiling, a failed substitution, a misused task (an uncaught tasks_live, task_not_owned, or task_consumed, see Task errors), and a block that returns a table (Block and section returns). It holds the same way in a walked section, a call chain, a task, and a fanout arm; in the H1 pass the ordinary Lua errors among them end the run as RequirementsUnmet instead, as the H1 pass hard gate below explains.
  • A failed {{ }} substitution ends the run as Lua with the substitution's own message, or as RequirementsUnmet in the H1 pass. Substitution has no run error kind of its own.
  • Quota: a section VM runs out the log event quota or the log byte quota. Only the two log quotas lead to Quota: running past the memory ceiling is Lua, and no instruction count can run out.

Store and Engine failures

  • An author's own failed store call is an error value of kind store, and ends the run as Vfs when uncaught (Store errors), in the H1 pass too.
  • Determinism: two accesses unordered by happens-before touch the same store region in conflicting ways (Sharing the store across calls and tasks). The run ends on the spot: the store call never returns into Lua, so no pcall can catch it. The message names the path, both chains, and both claim kinds. A conflict in shared library code while it loads ends the run the same way.
  • Vfs: an uncaught store error value ends the run as Vfs, a caught one raised again keeps Vfs, a run whose handle declares no store fails with Vfs, and the Host's store backend failing outside any store call, as the run starts or as the store is opened for the H1 pass, the section walk, or a new task, ends the run as Vfs as well. Its message is the failure's own text, store operation failed for a failure outside any store call.
  • Internal: an Engine invariant broke, a fault in the Engine rather than a mistake in the prompt. Its location names an Engine source file and line.

The H1 pass hard gate

The H1 pass is a hard gate. An uncaught failure there that would otherwise end the run as Lua ends it as RequirementsUnmet instead, with the Lua error text as the requirements notice, and later H1 blocks and every section never run:

---
name: gated
description: Stops before the walk when its gate fails
promptforge: 0
---

# Gated

```lua
assert(false, 'the gate cannot hold')
```

## Work

```lua
return 'never reached'
```

The run fails with run error kind RequirementsUnmet and a message that contains the gate cannot hold, and ## Work never runs. Move the same assert into a block under ## Work and the run fails as Lua instead.

In the H1 pass, these failures become RequirementsUnmet:

  • an error or assert call, or any other runtime fault, in a block in the H1 body
  • a failed substitution
  • running past the memory ceiling
  • an error value raised again after another suspending call that is rebuilt as Lua: kind out_of_scope_tool, unbound_tool, tasks_live, lua, or internal, or a value missing its fields

Everything else keeps its own classification in the H1 pass:

  • Task errors stay Lua: an uncaught tasks_live, task_not_owned, or task_consumed, a delivered cancelled task's error value, and a task_not_owned or task_consumed value raised again later with its task field.
  • Tool failures stay Tool, and Quota, Completion, Binding, and ContextExhausted keep their kinds.
  • A failed store call stays Vfs, and a claims conflict stays Determinism.
  • A Host cancel stays the cancelled outcome.
  • A lua-kind failure while the shared library loads, a failure in the var read-back, and a bad jump target from the H1 pass stay Lua.

Model call and environment failures

A model call that fails on the Harness's side reaches Lua as an error value of kind internal with no extra fields. Its message is one fixed phrase for the kind of failure, and it never includes the reply body, so a hostile or private payload cannot leak into a message or forge a log line:

FailureMessage
connection failure, or an error reply whose body cannot be readthe connection to the model backend failed
receive timeoutthe model backend did not answer in time
status 400 or 413 naming a context limitthe request is larger than the model's context window (status {status})
status 429the model backend is limiting the request rate (status 429), or the model backend says the usage quota is spent (status 429) when the body names a spent quota
status 503 or 529, or a 5xx body naming an overloadthe model backend is overloaded (status {status})
any other status of 500 or higherthe model backend reported a fault of its own (status {status})
status 401 or 403the model backend did not accept the credentials (status {status})
status 400 naming a content policythe model backend refused the request on content policy grounds (status 400)
any other non-success statusthe model backend rejected the request (status {status})
oversized or undecodable replythe model backend sent a reply that could not be understood: {what was wrong}
gateway disabled by the Hostmodel access is turned off or not configured

A models.loop round answered with HTTP 500 raises the model backend reported a fault of its own (status 500), and a 502 whose body holds forged log text still shows only 502. Left uncaught, every one of these ends the run as Completion. A models.loop round that overflows the context window never shows the first status row: the loop hands it to the compactor, which raises context_exhausted under the default policy (Provider rejections).

Catch them like any other error value:

local ok, reply = pcall(models.infer, prose)
if not ok then
  if reply.kind == 'internal' then
    log('model call failed')
    return 'skipped: ' .. reply.message
  end
  error(reply)
end
return reply

A backend answering with status 503 makes this block return skipped: the model backend is overloaded (status 503). The missing-model error is kind internal too, so the block also returns a skipped: result in a section with no model selected.

Environment variables

A Host can build the Harness's model connection from two environment variables. A prompt never reads them, and a missing or invalid value is a setup error that the Host sees before any run starts, so no prompt catches it.

  • PROMPTFORGE_GATEWAY_URL is always required.
  • PROMPTFORGE_GATEWAY_API_KEY is required unless the URL's host is loopback: 127.0.0.1, ::1, or localhost. An empty key counts as unset.

An unset variable fails the setup with missing environment variable: {name}, and one set to a value that is not valid Unicode fails it with environment variable is set but not valid Unicode: {name}.

Failures worth running again

Nothing reruns a failed run or a failed model call automatically. A run that failed on a transient model call problem may succeed when run again, and these count as transient:

  • connection failures and receive timeouts, an unreadable error body included
  • malformed or oversized replies
  • rate limits (status 429 without a spent quota) and overloaded backends
  • backend statuses of 500 or higher

Any other failure is not transient: a context overflow, a spent quota, a refused or rejected request, bad credentials, an empty reply, or a disabled gateway.

Cancelling a run

A Host cancel stops a run from outside, for example when the person running it presses Ctrl-C. A prompt cannot cancel its own run; it only observes the outcome. The run ends with the cancelled outcome, a clean stop rather than a failure, so it has no run error kind.

A cancel reaches running Lua promptly, even a tight endless loop:

---
name: spinner
description: Loops until the host cancels the run
promptforge: 0
---

# Spinner

## Loop

```lua
local n = 0
while true do n = n + 1 end
```

Nothing in this prompt ends the loop, and no limit does either. When the Host cancels, the loop is aborted and the run ends with the cancelled outcome.

How a cancel reaches running Lua

  • Every 10,000 Lua instructions, running Lua checks the run's cancel flag. The check covers each section VM's main code and every block coroutine, so it reaches every block of every section, the H1 pass included. The Engine also checks the flag between steps.
  • Every section VM and every activated Plugin share the same cancel flag.
  • Once the flag is set, the running block fails with the interrupted error: kind cancelled, message interrupted by Ctrl-C whatever the Host's actual trigger was, and no source location. It never appears as an ordinary Lua runtime error, and it wins over any error value the block had raised.
  • The cancel stops every block in the run, not only the first. After a cancel, a block with a bounded loop such as for i = 1, 100000 do end followed by return "done" never returns done.
  • A running models.loop stays cancellable, because the loop runs as Lua inside your block and the check keeps running while it does.

Left uncaught, the interrupted error ends the run with the cancelled outcome.

Calls waiting during a cancel

A cancel also reaches every call that is waiting on the Harness. Each one resumes with the interrupted error, kind cancelled, message interrupted by Ctrl-C:

  • models.infer, and each models.loop round
  • tools.call, input.ask() included, and tool calls the model makes
  • every store call

Calls made inside a local tool handler are ordinary suspending calls and resume the same way (Local tools).

pcall catches the interrupted error at the call like any other error value. With a tool slot search bound:

local ok, out = pcall(tools.call, 'search', { query = args })
if not ok then
  if out.kind == 'cancelled' then
    log('stopped while searching')
  end
  error(out)
end
return out

A cancel during the tool call gives ok == false, out.kind == 'cancelled', and tostring(out) == 'interrupted by Ctrl-C'. A models.loop round cut short the same way raises an error value whose kind is cancelled and whose message is interrupted by Ctrl-C, so tostring(err) gives exactly that message.

Catching a cancel does not keep the run going. Once the Host cancels, running Lua is stopped by the instruction check and the Engine's next step tears every chain down, so the run still ends with the cancelled outcome. Raise the caught value again, as above, rather than trying to continue.

Work in flight

  • A tool call in flight is interrupted rather than waited out, and the run ends promptly with the cancelled outcome, even when the tool itself would never finish.
  • A models.infer round in flight is aborted before its reply lands.
  • A cancel that lands while a block waits on a store.write ends the run with the cancelled outcome the same way.
  • A run cancelled before a fanout begins ends with the cancelled outcome without running any arms.

A stopped round

A Host can also stop the round in flight without cancelling the run, as when the operator stops a reply that is heading the wrong way. A stop reaches the calls waiting on a model, a tool, or a timeout when it lands, and each one resumes with the same interrupted error, kind cancelled, message interrupted by Ctrl-C:

  • models.infer, and each models.loop round
  • tools.call, and tool calls the model makes
  • tasks.join and tasks.join_any with a timeout; the tasks they wait on are not cancelled, and a later wait joins each one the interrupted wait had not yet delivered. A timed tasks.join loses the results of the members it delivered before the stop, so a later wait that names one of them raises task_consumed

A stop reaches only the calls that are waiting when it lands. A stop that lands while no call is waiting changes nothing, so the next call runs as usual.

A stop leaves the run's cancel flag clear, so running Lua goes on, and a caught stop keeps the run going. This block returns stopped when the Host stops its round, and the run completes:

local ok, out = pcall(models.infer, 'Draft the summary.')
if not ok and out.kind == 'cancelled' then
  return 'stopped'
end
return out

Under a cancel, the same block's return 'stopped' changes nothing: the run still ends with the cancelled outcome, so a prompt need not tell a stop from a cancel. Left uncaught, the interrupted error from a stop ends the run with the cancelled outcome too.

A question to the operator stays open through a stop. input.ask(), and the ask tool a model calls, keep waiting for the operator's answer, so a person in the middle of typing never loses the question. A store call is never cut short by a stop, because the Harness answers every store call as the run makes it, so no store call is ever waiting when a stop lands.