Summary
One ctx.callAI call can make several provider requests. Under RFC 0032 a host retries a schema violation, retries a truncation with a larger budget (RFC 0033), and may add a natural-language-to-JSON fallback. The pack sees none of this: the result's usage describes one attempt, so a node that budgets by calls or tokens under-counts what the provider was sent. This RFC adds an optional attempts record to the result (request count, summed output tokens, structured-output mode). It also adds an optional per-call retry policy, so a node that already repairs its own output can turn host retries off, or stop the budget from growing on truncation. Both are advertised under aiProviders.callAccounting.
Motivation
The Book AI pack (openwop/bookai) shows an author an estimate before every run and enforces a per-node call budget (maxCalls). Both count ctx.callAI calls. On the reference host (openwop-app), a structured call can make up to three provider requests by default. A truncated reply is retried with maxTokens doubled, and one natural-language fallback may follow. So the provider can receive more requests, and produce more output tokens than the author's maxTokens, than either the estimate or the budget shows.
The pack already validates every reply closed-world and does one error-fed repair of its own. The host's retries are a second, invisible loop around the pack's loop. This is spec surface rather than host choice because the pack author cannot observe the loop (the result's shape is the contract) and cannot control it (the request's shape is the contract). And every host that implements RFC 0032 retries has the same gap.
The reliability events RFC 0032 defines (envelope.retry.*) are on the run event log, not in the result. A node would have to read its own run's log mid-execution to count its attempts, which no pack ABI offers.
Proposal
§A Attempt accounting on the result
A host advertising aiProviders.callAccounting.attempts: true MUST add an attempts record to every ctx.callAI result:
attempts?: {
count: number, // integer ≥ 1: the provider requests this call made, retries and fallbacks included
outputTokens?: number, // integer ≥ 0: output tokens summed over every attempt that reported usage
usageComplete: boolean, // false when any attempt reported no usage (so outputTokens is a floor)
structuredOutput?: 'native' | 'prompt', // only when `responseSchema` was supplied: which mode the FINAL attempt used
}
- A.1
countMUST equal the number of requests the host sent to a provider
for this call. It includes schema-violation retries, truncation retries and a natural-language fallback. A request refused before it reached the provider (a policy denial) is not an attempt.
- A.2
outputTokensMUST be the sum of the output tokens each attempt's
provider response reported. When any attempt reported none, usageComplete MUST be false.
- A.3
structuredOutputisnativewhen the final attempt used the
provider's own structured-output mechanism (a JSON-schema response format, a forced tool call), and prompt when it used a prompt-embedded schema.
- A.4
usagekeeps its current meaning: it describes the attempt whose
output the result carries. Existing readers are unaffected.
- A.5 On replay,
attemptsMUST equal the recorded value. It is part of the
call's recorded outcome, like envelope.retry.exhausted.totalAttempts (RFC 0032 §D).
- A.6 A call that fails (throws) carries no result. The count for a failed
call is already on envelope.retry.exhausted.totalAttempts, so this RFC adds nothing to the error.
§B Per-call retry policy on the request
A host advertising aiProviders.callAccounting.retryPolicy: true MUST honour an optional retry field on the ctx.callAI request:
retry?: {
maxAttempts?: number, // integer 1..16: the most provider requests this call may make
growMaxTokens?: boolean, // default true; false = a truncation retry keeps the request's maxTokens
}
- B.1 The host MUST NOT send more than
maxAttemptsprovider requests for
the call. maxAttempts: 1 turns host retries off. A host MAY make fewer requests than the cap.
- B.2 With
growMaxTokens: false, the host MUST NOT send any attempt with a
maxTokens above the request's maxTokens, or above the host default when the request names none.
- B.3 The policy narrows; it never widens. A
maxAttemptsabove the host's
own ceiling is clamped to that ceiling, not refused. RFC 0032 §B.3 (no retry after a refusal) is unaffected.
- B.4 RFC 0032 events still fire. With
maxAttempts: 1, a failed call emits
envelope.retry.exhausted with totalAttempts: 1, as §B.2 there already requires of a host that does not retry.
- B.5 A malformed
retry(a non-integer, or out of range) MUST be refused
with validation_error and details.field naming the key. It MUST NOT be ignored.
§C Advertisement
// spec/v2/facets/aiProviders.schema.json — properties
+ "callAccounting": {
+ "type": "object",
+ "properties": {
+ "attempts": { "type": "boolean", "description": "RFC 0248 §A — ctx.callAI results carry `attempts`." },
+ "retryPolicy": { "type": "boolean", "description": "RFC 0248 §B — ctx.callAI honours a per-call `retry`." }
+ },
+ "additionalProperties": false
+ }
The two flags are independent. A pack MUST NOT rely on either field unless the flag is true. A host that does not advertise retryPolicy is not bound by §B.5, and an older host ignores an unknown request field.
Examples
Positive, with both flags advertised. The first attempt truncates and the second completes:
{ "data": { "scene": "…" }, "usage": { "outputTokens": 1800 },
"attempts": { "count": 2, "outputTokens": 3800, "usageComplete": true, "structuredOutput": "native" } }
The same call with retry: { maxAttempts: 1 } makes one request, and on truncation throws with envelope.retry.exhausted { totalAttempts: 1 }.
Negative: retry: { maxAttempts: 0 } and retry: { maxAttempts: 2.5 } are both refused with validation_error, details.field: "retry.maxAttempts".
Compatibility
additive (COMPATIBILITY.md §4, "new optional field"):
- both new fields are optional;
usageis unchanged;- a host that does not advertise
callAccountingneither emitsattemptsnor
reads retry;
- a pack that does not send
retrygets today's behaviour.
Conformance
New scenario v2-callai-attempt-accounting. It needs no seam. The fixture conformance-callai-attempt-accounting, listed in discovery fixtures, has one node, conformance.callai.echo-attempts. That node makes one ctx.callAI call through the host's conformance mock, which serves the run's script (one reply per provider request, contract in fixtures.md). It then writes the result's attempts to its output. The suite wrote the script, so it knows what each request returned. Where the host advertises providerUsage, provider.usage (exactly one per provider invocation) corroborates it. The mock clamps each reported outputTokens to that request's maxTokens, which makes budget growth visible. Judges live in lib/callai-attempts-witness.ts.
| Id | Case | Expect |
|---|---|---|
0248.attempts-count | invalid JSON, then valid | attempts.count equals the provider.usage count |
0248.output-tokens-sum | the same run | outputTokens equals the scripted usages summed and the summed provider.usage; usageComplete: true |
0248.usage-incomplete | one reply with usage: false | usageComplete: false |
0248.structured-output | no responseSchema; then a mock declaring native | absent; then "native" |
0248.attempts-replayed | a replay fork of the count run | attempts byte-equal to the source |
0248.max-attempts-cap | maxAttempts: 1, invalid first reply | the call fails; at most one provider.usage; retry-exhausted.totalAttempts: 1 when emitted |
0248.no-budget-growth | growMaxTokens: false, truncated, then a reply that fits only a grown budget | the call does not complete; no provider.usage.outputTokens above maxTokens |
0248.retry-malformed-refused | maxAttempts: 0 | node fails validation_error, details.field: retry.maxAttempts |
Each leg is gated on its callAccounting flag and the fixture, and the replay leg also on replay.modes including replay. A count run that fails because the host does not retry a schema violation is blocked, not judged.
Falsifiability — one row per normative requirement
| Requirement | Observable — what an outside party sees | Who can cause the condition | Verdict |
|---|---|---|---|
| §A.1 count = requests | attempts.count vs the provider.usage count | the suite, through the fixture's script | witnessable-gated |
| §A.2 summed tokens | outputTokens vs the scripted usages and the summed provider.usage | the suite, through the script | witnessable-gated |
§A.2 usageComplete | a scripted no-usage reply vs the echoed flag | the suite, through the script | witnessable-gated |
§A.3 structuredOutput | absent without responseSchema; the mode the script declared | the suite, through the script | witnessable-gated |
| §A.5 replay equality | source vs fork node.completed outputs | the suite (:fork replay) | witnessable-gated |
| §B.1 cap | the call's outcome; the provider.usage count; retry-exhausted.totalAttempts | the suite, through the script | witnessable-gated |
| §B.2 no budget growth | the call's outcome; provider.usage.outputTokens against maxTokens (the mock clamps) | the suite, through the script | witnessable-gated |
| §B.5 malformed refused | node.failed.error.code + details.field | the suite, unaided | witnessable-gated |
Acceptance criteria
- [x]
spec/v2/facets/aiProviders.schema.jsoncarries optionalcallAccounting {attempts, retryPolicy}with the §A and §B rules in its descriptions;spec/v2/declaration.jsonlists the facet. (2026-10-22, suite 2.46.19.) - [x] The fixture
conformance-callai-attempt-accounting, with its contract infixtures.md. (2026-10-22, suite 2.46.19.) - [x] The eight legs in
v2-callai-attempt-accounting.test.ts, with the judges inlib/callai-attempts-witness.ts(self-tests fail each wrong host answer). (2026-10-22, suite 2.46.19.) - [x] CHANGELOG entry.
- [x] A host advertises
callAccountingand passes all eight0248.legs non-vacuously. (2026-10-23: openwop-app, 2.46.19 cutf17ee3753, 22 assertions.)*
Security considerations
attempts carries counts and an enum only, never text from an attempt, so it adds no redaction surface. retry can only reduce the requests a host makes, which cannot raise spend or open a circumvention path. The RFC 0032 rule against retrying a refusal still holds when a cap is higher.
Unresolved questions
- Should
attemptsalso carryinputTokens? Prompt caching makes a per-attempt input sum misleading. Left out until a consumer needs it. - Should
ctx.callAIWithToolsget the same record? Its loop is the pack's, not the host's, so it is out of scope here.
Status history
- 2026-10-19 — Draft.
- 2026-10-22 — Active (comment window waived; STEWARD OVERRIDE of RFC 0147 §A.6, see Updated).
- 2026-10-23 — Accepted (tier-1, openwop-app cut
f17ee3753; §B review deferred by steward exemption).