Undo every turn

An agent turn can run several tools before it finishes — and fail after any of them. AgentKit wraps each turn in exactly one undo transaction: provide an UndoTransactionProvider and the framework begins a transaction when the turn starts, records what ran, commits when the turn succeeds, and rolls back when it fails. Your executors never see any of this — undo is recorded around them, not threaded through them.

Provide an undo provider

Two small types: a provider that starts a transaction per turn, and the transaction that receives entries, then a commit or a rollback.

actor TimelineHistory {
    private(set) var committedTurns: [[String]] = []
    private var open: [String] = []

    func record(_ step: String) { open.append(step) }

    func commitTurn() {
        committedTurns.append(open)
        open = []
    }

    func revertTurn() {
        // walk `open` in reverse and undo each step in your model
        open = []
    }
}

struct TimelineUndoProvider: UndoTransactionProvider {
    let history: TimelineHistory

    func beginTransaction(label: String) -> any UndoTransaction {
        TimelineUndoTransaction(history: history)
    }
}

struct TimelineUndoTransaction: UndoTransaction {
    let history: TimelineHistory

    func addEntry(_ entry: UndoEntry) async {
        await history.record(entry.description)
    }

    func commit() async { await history.commitTurn() }
    func rollback() async { await history.revertTurn() }
}
let history = TimelineHistory()
let agent = try runtime.makeAgent(
    provider: .anthropic(apiKey: key),
    role: role,
    undoProvider: TimelineUndoProvider(history: history)
)

TimelineHistory stands in for your own undo machinery — an UndoManager, a command stack, a database savepoint. The transaction is the bridge.

What gets recorded

Every tool call that returns a successful result adds one UndoEntry to the turn's transaction, automatically. An entry carries three fields:

Field Content
toolCallId the id of the call that ran
toolName the domain-qualified tool id, e.g. timeline.trim_clip
description a human-readable label, e.g. Executed timeline.trim_clip

Denied, failed, and conflicted calls record nothing — only work that actually happened is undoable. Successful read-only calls are recorded too; filter on toolName if your undo stack only wants mutations.

Commit on success, roll back on failure

When the turn ends cleanly, the transaction commits. When the turn fails for any reason — an executor throws, a run limit trips, the turn is cancelled — the transaction rolls back, including entries already recorded earlier in the same turn.

The transaction covers tool side-effects only. Conversation history is not transactional — whatever the turn appended before the failure stays in the transcript, and nothing is appended after it.

On-device Apple Foundation Models run tools inside the provider and take their undo transaction at provider construction — see on-device with Apple.

Detect stale writes with revisions

Collaborative and document editors have a second problem: the state a tool call was planned against may be gone by the time it runs. Provide a RevisionProvider and the session reads currentRevision before each tool call, then hands it to your executor as revision:.

struct NotesRevisionProvider: RevisionProvider {
    let store: NotesStore
    var currentRevision: UInt64 { get async { await store.revision } }
}
let agent = try runtime.makeAgent(
    provider: .anthropic(apiKey: key),
    role: role,
    revisionProvider: NotesRevisionProvider(store: store)
)

NotesStore stands in for whatever owns your document state and bumps a revision counter on every change.

Return a conflict, not an error

When your executor sees that the handed-in revision is stale, return .conflict instead of failing:

func execute(_ call: ToolCall, revision: UInt64?) async throws -> ToolOutcome {
    let actual = await store.revision
    guard revision == actual else {
        return .conflict(ConflictPayload(
            expectedRevision: revision,
            actualRevision: actual,
            message: "Note changed underneath this edit: expected revision \(revision ?? 0), store is at \(actual).",
            stateDeltaSummary: "Paragraph 2 was rewritten by another editor."
        ))
    }
    try await store.apply(call)
    return .success(ToolResultPayload(content: [.text("Edit applied.")]))
}

A conflict is feedback, not a failure — the turn continues. The model receives the conflict as the call's outcome, rendered as:

Conflict: <message>
State delta: <stateDeltaSummary>

so it can retry with current state in the same turn. Put anything the model needs — the revision numbers, what changed — into message and stateDeltaSummary; the expectedRevision and actualRevision fields ride the payload for your own logging and UI. A conflict also invalidates cached context sources, and a freshly built Current state: section follows the rendered conflict when sources are attached — the retry reads reality, not the cache.

Next