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
- Guard the dangerous calls — stop a call before it needs undoing.
- Bound the turn — the limit trips that trigger a rollback.