keel
Turns

Outcomes and completion

Handle every ending without treating generated output as proof of success.

  1. Completed

    A clean finish satisfies the turn's content and delivery checks.

    kind: "completed"
    payload + proof + deliverable
  2. Failed

    A request or execution requirement could not be satisfied.

    kind: "failed"
    cause: { code, message }
  3. Truncated

    A length-limited response was not recovered.

    kind: "truncated"
    at + continuation
  4. Cut

    Time, liveness, transport interruption, or a post-content provider error ended execution.

    kind: "cut"
    by + retryable
  5. Stopped

    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

TriggerEnding
Ordinary step grant runs out before a clean finishfailed, cause budget-cut
Cumulative outputTokens grant exhausted before the model finishedfailed, cause budget-cut
Required part still missing after allowed nudgesfailed, cause deliverable-missing
Opening marker remains after a normal stopfailed, cause marker-open
Length limit without successful continuationtruncated
Time ceiling, liveness watchdog, or post-content stream errorcut, by one of "watchdog" | "timeout" | "stream-error"
User cancellation observed by the runtimestopped

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:

CodeMeaning
admit-refusedAnother root turn owns the thread — the prior turn has not settled.
ambient-invalidThe ambient's shared core is malformed, or its scope was rejected by the app's defineAmbient declaration.
envelope-invalid, envelope-too-largeThe supplied request fails validation or its size limit.
render-failureA block reader, repair, or ambient section threw during context assembly — contained as a typed defect, never a raw exception.
fit-overflowThe opening assembly or a growing model request exceeds its allocation.
budget-cutThe step grant or the cumulative output-token grant ended before a clean finish.
marker-open, marker-rejectedStructured output is incomplete or its payload is invalid.
emptyNo qualifying content was produced.
chain-exhaustedNo model tier produced a usable result — reported with its real cause even during continuation or nudge rounds.
tool-unknown, tool-refused, tool-input-invalidA proposed operation fails name, access, or schema checks.
tool-error, writer-takenTool execution or a state byproduct encountered a failure.
delegation-failedA child did not return a completed result.
deliverable-missingThe required part is absent after recovery.
hook-failureAn 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.