Outcomes and completion
Handle every ending without treating generated output as proof of success.
Completed
A clean finish satisfies the turn's content and delivery checks.
kind: "completed" payload + proof + deliverableFailed
A request or execution requirement could not be satisfied.
kind: "failed" cause: { code, message }Truncated
A length-limited response was not recovered.
kind: "truncated" at + continuationCut
Time, liveness, transport interruption, or a post-content provider error ended execution.
kind: "cut" by + retryableStopped
The user requested cancellation.
kind: "stopped" by: "user"
A returned TurnOutcome tells your application how execution ended.
Parts can arrive before that decision, so keep provisional output separate
from the final status you show or persist as a completed answer.
Handle every variant
import type { TurnOutcome } from "@keel-dev/core";
function describeOutcome(outcome: TurnOutcome): string {
switch (outcome.kind) {
case "completed":
return outcome.payload.text || "Completed with structured content";
case "failed":
return `Failed: ${outcome.cause.code}`;
case "truncated":
return `Incomplete output at ${outcome.at}`;
case "cut":
return `Interrupted: ${outcome.by}`;
case "stopped":
return "Stopped by the user";
default: {
const unreachable: never = outcome;
return unreachable;
}
}
}The discriminant gives each branch its own fields. failed carries a cause
and refunded: true; cut carries a reason and retryable: true. These
fields describe runtime policy. They do not perform refunds or automatically
retry a request.
Know what completed proves
A completed turn reached a clean stop and passed its content and delivery
checks. It carries payload, a branded proof, and a deliverable when a
contract was fulfilled.
Read the payload precisely. payload.text is all the turn's text
concatenated in order. payload.parts is the turn's full emitted wire —
text parts (which duplicate payload.text), progress parts, and forwarded
child parts included. Filter for structured content instead of assuming the
array holds only your custom parts:
const structured = outcome.payload.parts.filter((p) => p.kind === "custom");One part never appears there: the framework-reserved outcome part that
hooks.onEvent receives after settlement is a live wire signal, not payload.
The baseline accepts nonempty text or a registered content part. Progress, ephemeral parts, and forwarded child parts do not count as the caller's own substance. Baseline completion does not prove factual accuracy or task quality; plain text claiming success can still be nonempty text.
For a required result shape, declare a deliverable contract. See Completion and deliverables.
Distinguish incomplete endings
| Trigger | Ending |
|---|---|
| Ordinary step grant runs out before a clean finish | failed, cause budget-cut |
Cumulative outputTokens grant exhausted before the model finished | failed, cause budget-cut |
| Required part still missing after allowed nudges | failed, cause deliverable-missing |
| Opening marker remains after a normal stop | failed, cause marker-open |
| Length limit without successful continuation | truncated |
| Time ceiling, liveness watchdog, or post-content stream error | cut, by one of "watchdog" | "timeout" | "stream-error" |
| User cancellation observed by the runtime | stopped |
The outputTokens grant on a turn's budget is enforced: each model request's
output reserve is clamped to what remains, and an exhausted grant settles the
turn failed with budget-cut — never a synthetic completion.
An agent's continuation.max grants bounded extra rounds after a length limit;
the default is zero. Recovery can produce a completed outcome, but a partial
response is not silently relabeled as completed. A truncated outcome names
where it happened: outcome.at is either the part marker left open, or the
literal string "output-budget" when no marker was open.
Handle failures at the right layer
A defect event may be recovered inside a turn, so it does not necessarily
mean the final outcome is failed. Conversely, a useful partial answer can
belong to a cut or truncated turn.
Configuration errors and unexpected application exceptions can reject
runTurn rather than returning one of these variants. Handle those at your
application boundary as well; do not manufacture a completed outcome in a
catch or finally block.
Read the defect code
A failed outcome names its cause. Defects may also appear in events during a turn that later recovers:
| Code | Meaning |
|---|---|
admit-refused | Another root turn owns the thread — the prior turn has not settled. |
ambient-invalid | The ambient's shared core is malformed, or its scope was rejected by the app's defineAmbient declaration. |
envelope-invalid, envelope-too-large | The supplied request fails validation or its size limit. |
render-failure | A block reader, repair, or ambient section threw during context assembly — contained as a typed defect, never a raw exception. |
fit-overflow | The opening assembly or a growing model request exceeds its allocation. |
budget-cut | The step grant or the cumulative output-token grant ended before a clean finish. |
marker-open, marker-rejected | Structured output is incomplete or its payload is invalid. |
empty | No qualifying content was produced. |
chain-exhausted | No model tier produced a usable result — reported with its real cause even during continuation or nudge rounds. |
tool-unknown, tool-refused, tool-input-invalid | A proposed operation fails name, access, or schema checks. |
tool-error, writer-taken | Tool execution or a state byproduct encountered a failure. |
delegation-failed | A child did not return a completed result. |
deliverable-missing | The required part is absent after recovery. |
hook-failure | An output callback threw; the runtime records it and continues. |
This is the complete DefectCode union exported by @keel-dev/core — a
failed outcome always names one of these codes, never a bare string. See
Failure and recovery for which
failures return feedback and which end execution.
Keep retry a deliberate choice
Before restarting work, check whether tools already changed state or external
systems. A failed or cut turn is not a rollback transaction. The retryable
flag does not establish that every previously executed tool is idempotent.
The caller's clientTurnId also does not currently deduplicate requests.
Inspect in Studio
Compare WIRE with the final outcome in TURN RECORDS after a forced length limit, a timeout, and a normal stop. Then inspect any recovery rounds to see why similar-looking partial output led to different endings.
Next: Nested turns.
