Debug Foundation Models Sessions With Instruments
The first Foundation Models bug you hit usually looks like a bad answer.
The second one is more annoying: the answer is fine, but it takes too long. Or the model calls the same search tool three times. Or a prompt that worked yesterday starts failing after the transcript grows. Or the app switches between sessions and you cannot tell which part of the agentic workflow actually burned the time.
WWDC26 session 243, “Debug and profile agentic app experiences with Instruments”, is about that layer of work. Apple describes an enhanced Foundation Models Instrument in Xcode for inspecting prompts, latency, control flow across multiple LanguageModelSession instances, tools, and profiles in iOS 27-era app workflows. The exact labels and columns can move during the Xcode 27 beta cycle, so this post treats the UI names as beta-shaped. The workflow is the stable part: label the feature, record a focused trace, read the session and tool timeline, then reduce the work the model has to do.
We will build a small two-step SwiftUI feature:
- Search local notes and tasks for context.
- Ask Foundation Models to turn that context into a short daily plan.
Then we will profile it like app code instead of treating the model as a black box.
Build the local data boundary
Start with ordinary app data. For this example, the assistant can search local notes and tasks. The model should not see the whole database. It should ask for a small slice of context through a tool.
import Foundation
struct PlannerItem: Identifiable, Sendable {
enum Kind: String, Sendable {
case note
case task
}
let id: UUID
var kind: Kind
var title: String
var body: String
var project: String?
var dueDate: Date?
}
actor PlannerIndex {
private var items: [PlannerItem] = []
func replaceItems(_ newItems: [PlannerItem]) {
items = newItems
}
func search(query: String, limit: Int) -> [PlannerItem] {
let trimmedQuery = query.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmedQuery.isEmpty else { return [] }
let matches = items.filter { item in
item.title.localizedCaseInsensitiveContains(trimmedQuery) ||
item.body.localizedCaseInsensitiveContains(trimmedQuery) ||
item.project?.localizedCaseInsensitiveContains(trimmedQuery) == true
}
return Array(matches.prefix(limit))
}
}
Nothing here belongs to Foundation Models yet. That is intentional. If the trace later shows that searches are slow, duplicated, or too broad, you can fix the index without touching the view or the model instructions.
Add a small search tool
Now expose one read-only capability. The tool receives a short query and a small limit, then returns compact records.
import Foundation
import FoundationModels
struct SearchPlannerItemsTool: Tool {
let name = "searchPlannerItems"
let description = "Search the user's local notes and tasks for planning context."
let index: PlannerIndex
@Generable
struct Arguments {
@Guide(description: "A short search phrase for notes and tasks")
var query: String
@Guide(description: "The maximum number of matching items to return", .range(1...5))
var limit: Int
}
func call(arguments: Arguments) async throws -> String {
let query = arguments.query.trimmingCharacters(in: .whitespacesAndNewlines)
guard !query.isEmpty else {
return "No results. The search query was empty."
}
let limit = min(max(arguments.limit, 1), 5)
let matches = await index.search(query: query, limit: limit)
guard !matches.isEmpty else {
return "No notes or tasks matched \"\(query)\"."
}
return matches
.map { item in
"""
Kind: \(item.kind.rawValue)
Title: \(item.title)
Project: \(item.project ?? "None")
Due: \(item.dueDate?.formatted(date: .abbreviated, time: .omitted) ?? "None")
Summary: \(item.body.prefix(240))
"""
}
.joined(separator: "\n\n")
}
}
That prefix(240) is not just about speed. It is also about debuggability. A Foundation Models trace can show prompts, tool calls, and tool outputs. If the tool returns complete note bodies, client names, medical details, internal tickets, or access tokens, that data may be present in the trace you capture while debugging.
Treat tool output as diagnostic surface area. Return the smallest useful fields, redact secrets before they cross the tool boundary, and avoid putting raw user documents into prompts just because it is easy.
Create a two-step session flow
For the feature, the user types something like “plan my launch prep”. The app first lets the model search for context, then asks a second session to write a compact plan from that context.
You could do this in one session. Splitting it into two sessions is useful here because it makes the control flow obvious in Instruments: one step gathers context, one step composes the answer. In a larger app, this is also where you might change instructions, tools, or profiles. This post is not about dynamic profiles; it is about making those handoffs visible enough to debug.
import Foundation
import FoundationModels
@Generable
struct DayPlan: Sendable {
@Guide(description: "A one-line title for the plan")
var title: String
@Guide(description: "Three practical next actions", .count(3))
var actions: [String]
@Guide(description: "One risk or unknown to check before starting")
var risk: String
}
struct PlanningResult: Sendable {
var context: String
var plan: DayPlan
}
actor DayPlannerAgent {
private let languageModel = SystemLanguageModel.default
private let index: PlannerIndex
init(index: PlannerIndex) {
self.index = index
}
func planDay(for userRequest: String) async throws -> PlanningResult {
let request = userRequest.trimmingCharacters(in: .whitespacesAndNewlines)
guard !request.isEmpty else {
throw CancellationError()
}
let contextSession = LanguageModelSession(
tools: [
SearchPlannerItemsTool(index: index)
],
instructions: """
Find only the notes and tasks needed to plan the user's request.
Use searchPlannerItems at most twice.
Prefer short search phrases.
Return a compact context summary, not a final plan.
"""
)
let contextResponse = try await contextSession.respond(
to: """
User request:
\(request)
Search for the smallest useful set of local notes and tasks.
""",
options: GenerationOptions(
temperature: 0.1,
maximumResponseTokens: 300
)
)
let planSession = LanguageModelSession(
instructions: """
Turn planning context into a short daily plan.
Do not invent notes, tasks, dates, or project names.
If the context is thin, say what the user should check.
"""
)
let planResponse = try await planSession.respond(
to: """
User request:
\(request)
Planning context:
\(contextResponse.content)
""",
generating: DayPlan.self,
options: GenerationOptions(
temperature: 0.2,
maximumResponseTokens: 250
)
)
return PlanningResult(
context: contextResponse.content,
plan: planResponse.content
)
}
}
There are two important debugging choices in that code.
The first is the hard limit in the context instructions: “Use searchPlannerItems at most twice.” That does not replace validation in the tool, and it does not guarantee the model will make the best choice, but it gives the trace a clear expected shape. If Instruments shows five tool calls, you know the control flow is wrong before you even judge the final answer.
The second is the small response budget. If the trace shows slow total latency, do not immediately blame the model. Check whether you asked for 1,500 tokens when the UI only needs three actions. Use concise instructions, @Guide, and structured output to shape the answer; use maximumResponseTokens as a guardrail with enough headroom, then verify the trace and decoding behavior.
Use it from SwiftUI
The view model owns user state and calls the agent. The SwiftUI view stays boring.
import Foundation
import FoundationModels
import Observation
@MainActor
@Observable
final class DayPlannerModel {
var request = ""
var result: PlanningResult?
var isPlanning = false
var errorMessage: String?
private let languageModel = SystemLanguageModel.default
private let agent: DayPlannerAgent
init(index: PlannerIndex) {
agent = DayPlannerAgent(index: index)
}
var canPlan: Bool {
!request.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty &&
!isPlanning &&
isModelAvailable
}
var isModelAvailable: Bool {
if case .available = languageModel.availability {
return true
}
return false
}
func plan() async {
let currentRequest = request.trimmingCharacters(in: .whitespacesAndNewlines)
guard !currentRequest.isEmpty else { return }
switch languageModel.availability {
case .available:
break
case .unavailable(let reason):
result = nil
errorMessage = unavailableMessage(for: reason)
return
}
isPlanning = true
result = nil
errorMessage = nil
do {
result = try await agent.planDay(for: currentRequest)
} catch LanguageModelError.contextSizeExceeded {
errorMessage = "This plan has too much context. Try a narrower request."
} catch is LanguageModelSession.ToolCallError {
errorMessage = "The local notes and tasks could not be searched."
} catch {
errorMessage = error.localizedDescription
}
isPlanning = false
}
private func unavailableMessage(
for reason: SystemLanguageModel.Availability.UnavailableReason
) -> String {
switch reason {
case .deviceNotEligible:
"This device does not support Apple Intelligence."
case .appleIntelligenceNotEnabled:
"Apple Intelligence is not enabled."
case .modelNotReady:
"The language model is not ready yet."
@unknown default:
"The language model is not available right now."
}
}
}
And the view:
import SwiftUI
struct DayPlannerView: View {
@State private var model: DayPlannerModel
init(index: PlannerIndex) {
_model = State(initialValue: DayPlannerModel(index: index))
}
var body: some View {
Form {
TextField("What are you planning?", text: $model.request)
Button("Plan Day") {
Task {
await model.plan()
}
}
.disabled(!model.canPlan)
if model.isPlanning {
ProgressView()
}
if let errorMessage = model.errorMessage {
Text(errorMessage)
.foregroundStyle(.secondary)
}
if let result = model.result {
Section(result.plan.title) {
ForEach(result.plan.actions, id: \.self) { action in
Text(action)
}
Text(result.plan.risk)
.foregroundStyle(.secondary)
}
}
}
.navigationTitle("Planner")
}
}
This is enough code to produce real traces. Now make the traces readable.
Add app trace labels without adding sensitive data
The Foundation Models Instrument can show the framework-level work: prompts, generated responses, tool calls, control flow, and latency. Your app should add landmarks around the user action so you can line that framework work up with the product interaction.
Use signposts for the workflow, but do not log raw prompt text. Log low-cardinality metadata: character counts, result counts, selected mode, whether a cache was used, or a stable feature name. This does not prevent the Foundation Models Instrument from capturing prompts and responses; it only keeps your app-owned labels from adding more sensitive data.
import Observation
import os.signpost
private let plannerLog = OSLog(
subsystem: "com.example.Planner",
category: "PointsOfInterest"
)
@MainActor
@Observable
final class InstrumentedDayPlannerModel {
var request = ""
var result: PlanningResult?
var isPlanning = false
var errorMessage: String?
private let agent: DayPlannerAgent
init(index: PlannerIndex) {
agent = DayPlannerAgent(index: index)
}
func plan() async {
let currentRequest = request.trimmingCharacters(in: .whitespacesAndNewlines)
guard !currentRequest.isEmpty else { return }
let signpostID = OSSignpostID(log: plannerLog)
os_signpost(
.begin,
log: plannerLog,
name: "PlanDay",
signpostID: signpostID,
"requestLength=%d",
currentRequest.count
)
isPlanning = true
result = nil
errorMessage = nil
do {
result = try await agent.planDay(for: currentRequest)
} catch {
errorMessage = error.localizedDescription
}
isPlanning = false
os_signpost(
.end,
log: plannerLog,
name: "PlanDay",
signpostID: signpostID
)
}
}
That gives Instruments a product-level interval named PlanDay without copying the user’s request into the system log. In real code, keep the availability checks and specific error handling from the first view model; this snippet is only showing the extra signpost boundary. If you need deeper landmarks, add intervals for GatherPlanningContext and ComposePlanningAnswer, but keep the payloads boring. Counts and enum-like labels are easier to compare across runs than full strings.
Record the trace
In the current Xcode 27 beta cycle, the broad workflow is:
- Build a debug or profiling configuration that uses representative local data.
- Open Instruments from Xcode’s profiling flow.
- Choose a template or instrument set that includes the Foundation Models Instrument.
- Add Points of Interest or OS signpost visibility if it is not already present.
- Start recording, perform exactly one planning request, wait for the answer, then stop.
- Repeat with the same request after each change so the run comparison is meaningful.
Do not record your entire dogfooding session and then hope the interesting part is obvious. Agentic traces get noisy because one user action can include multiple sessions, model calls, tool invocations, and profile changes. A single focused interaction is easier to reason about.
When you inspect the trace, I would start with these questions rather than a specific table name:
- How much prompt and transcript context went into each session?
- How long did the user wait before the first visible model output or response milestone?
- How quickly did output arrive after generation started?
- How much total latency came from tool calls versus model generation?
- Did the model call the expected tool with the expected arguments?
- Did it call a tool repeatedly with near-identical arguments?
- Did the feature cross a session, model, or profile boundary at the time you expected?
- Did any prompt, transcript entry, or tool result contain data you would not want in a shared trace?
The WWDC26 session and Apple’s runtime-performance documentation call out this kind of prompt, latency, and control-flow inspection. During the beta, let your installed Xcode seed be the source of truth for the exact pane names, metric labels, and available columns.
Read latency as a workflow
For a user, “the model was slow” is one number: how long until the screen felt done. For a developer, it is several numbers.
Time to first token is when the model begins producing output. In a streaming UI, that can map to the first visible text. With non-streaming respond, user-visible latency is closer to total request duration. If time to first token is high, look at prompt size, transcript size, model/profile selection, and any tool work that must finish before generation can begin.
Generation rate is what happens once output starts. Some seeds may expose this directly, while others make you infer it from response duration and generated-token counts. If generation rate looks fine but the user still waits too long, the response may simply be too large. Tighten the output type, use guides that ask for less text, adjust maximumResponseTokens with enough headroom, or split a long response into a quick preview followed by optional detail.
Total latency is the whole interval from tap to completed UI state. This includes your app code, tool calls, session setup, profile handoffs, generation, parsing, and UI updates. Your PlanDay signpost should surround the same span the user experiences.
Tool latency is different from tool count. One slow search can be worse than three cheap searches. The trace should tell you whether the model is waiting on your local index, your file hydration path, or some other app-owned dependency.
Model or profile handoff time matters in multi-step features. If the trace shows a boundary between the context session and the plan session, ask whether the split is buying enough quality or observability to justify the cost. Sometimes two sessions are clearer. Sometimes one session with tighter instructions is faster and good enough.
Fix prompt bloat
Here is a deliberately bad version of the planning call:
func planDayBadly(
for userRequest: String,
allItems: [PlannerItem]
) async throws -> DayPlan {
let dumpedItems = allItems
.map { item in
"""
ID: \(item.id)
Kind: \(item.kind.rawValue)
Title: \(item.title)
Project: \(item.project ?? "None")
Due: \(item.dueDate?.description ?? "None")
Body:
\(item.body)
"""
}
.joined(separator: "\n\n---\n\n")
let session = LanguageModelSession(
instructions: "Plan the user's day from their notes and tasks."
)
let response = try await session.respond(
to: """
User request:
\(userRequest)
All notes and tasks:
\(dumpedItems)
""",
generating: DayPlan.self
)
return response.content
}
A trace should make the problem visible. The prompt is huge, the transcript grows quickly, time to first token or response duration suffers, and the captured prompt may contain everything in the user’s notes. Even if the answer is good, the implementation is not.
Fix it by moving selection into the tool, limiting what the tool returns, and asking for a compact typed output:
func planDayWithBoundedContext(
for userRequest: String,
index: PlannerIndex
) async throws -> DayPlan {
let contextSession = LanguageModelSession(
tools: [
SearchPlannerItemsTool(index: index)
],
instructions: """
Search only when local notes or tasks would change the answer.
Use searchPlannerItems at most twice.
Summarize the useful context in 120 words or fewer.
"""
)
let context = try await contextSession.respond(
to: "User request: \(userRequest)",
options: GenerationOptions(
temperature: 0.1,
maximumResponseTokens: 180
)
)
let planSession = LanguageModelSession(
instructions: """
Create a short plan from the context.
Use only facts present in the context.
"""
)
let plan = try await planSession.respond(
to: """
User request:
\(userRequest)
Context:
\(context.content)
""",
generating: DayPlan.self,
options: GenerationOptions(
temperature: 0.2,
maximumResponseTokens: 220
)
)
return plan.content
}
After the fix, record the same user request again. You want to see a smaller prompt, a shorter transcript, fewer or more purposeful tool calls, and lower total latency. If output quality drops, the fix was too aggressive. Add back the specific fields the plan needs, not the whole database.
Fix repeated tool-call loops
The other classic trace is a loop. The model calls searchPlannerItems for “launch”, then “launch prep”, then “launch tasks”, then “launch notes”, and keeps getting overlapping results.
Sometimes that is an instruction problem:
let session = LanguageModelSession(
tools: [
SearchPlannerItemsTool(index: index)
],
instructions: """
Search the user's notes and tasks until you have enough context.
Then write a plan.
"""
)
“Until you have enough” sounds reasonable to a person, but it gives the model no stopping rule. In a trace, it can show up as repeated tool calls with similar arguments and little new information.
Make the stopping rule explicit:
let session = LanguageModelSession(
tools: [
SearchPlannerItemsTool(index: index)
],
instructions: """
You may call searchPlannerItems at most twice.
Stop searching after a result that includes at least one task and one note.
If results are thin, write the plan with an explicit "Check this first" risk.
Do not repeat a search query with only minor wording changes.
"""
)
Still protect the tool boundary. The model is allowed to make poor choices. Your Swift code should make repeated or expensive choices cheap and bounded. Create a fresh PlannerSearchBudget per user request or session, not one global budget shared across unrelated planner runs.
actor PlannerSearchBudget {
private var normalizedQueries: Set<String> = []
private var remainingCalls = 2
func shouldRun(query: String) -> Bool {
let normalized = query
.lowercased()
.trimmingCharacters(in: .whitespacesAndNewlines)
guard !normalized.isEmpty,
remainingCalls > 0,
!normalizedQueries.contains(normalized)
else {
return false
}
normalizedQueries.insert(normalized)
remainingCalls -= 1
return true
}
}
struct BudgetedSearchPlannerItemsTool: Tool {
let name = "searchPlannerItems"
let description = "Search the user's local notes and tasks for planning context."
let index: PlannerIndex
let budget: PlannerSearchBudget
@Generable
struct Arguments {
@Guide(description: "A short search phrase for notes and tasks")
var query: String
@Guide(description: "The maximum number of matching items to return", .range(1...5))
var limit: Int
}
func call(arguments: Arguments) async throws -> String {
let query = arguments.query.trimmingCharacters(in: .whitespacesAndNewlines)
guard !query.isEmpty else {
return "No results. The search query was empty."
}
guard await budget.shouldRun(query: query) else {
return "Search skipped. The search budget is exhausted or the query was repeated."
}
let limit = min(max(arguments.limit, 1), 5)
let matches = await index.search(query: query, limit: limit)
guard !matches.isEmpty else {
return "No notes or tasks matched \"\(query)\"."
}
return matches
.prefix(limit)
.map { "\($0.kind.rawValue): \($0.title) - \($0.body.prefix(180))" }
.joined(separator: "\n")
}
}
That tool result is intentionally plain. It tells the model why no more search results are coming, and the trace will show repeated attempts turning into skipped tool results. If calls continue, fix the instructions, retrieval, or feature design. The budget keeps one bad run from turning into expensive repeated work, but it is not a substitute for a well-shaped agent loop.
Debug wrong tool choices
Wrong tool choices are easier to diagnose when each tool has a narrow name and description.
If the model calls searchPlannerItems for a request like “make this plan sound calmer”, the trace is telling you that the tool looked too generally useful or the instructions did not say when to avoid it. Change the session instructions before you add another tool.
let session = LanguageModelSession(
tools: [
SearchPlannerItemsTool(index: index)
],
instructions: """
Use searchPlannerItems only when the answer depends on local notes or tasks.
Do not use tools for rewriting, tone changes, grammar fixes, or summarizing text
already present in the user's request.
"""
)
If the model picks the right tool but bad arguments, improve the argument guides and validate in Swift. For search, that might mean asking for noun phrases instead of full questions:
@Generable
struct Arguments {
@Guide(description: "Two to five words, usually a project name or task phrase")
var query: String
@Guide(description: "The maximum number of matching items to return", .range(1...5))
var limit: Int
}
Then compare traces. You are looking for fewer low-value calls, more stable query strings, and less context returned per answer.
Watch transcript growth
Long-lived sessions are convenient because they preserve context. They also accumulate transcripts. That can become a latency problem, a context-window problem, and a privacy problem.
For a planning feature, I usually prefer a fresh session per explicit planning request unless the UI is truly conversational. If you do keep a session alive, periodically ask what needs to remain in the transcript. The answer is rarely “everything.”
Bad shape:
final class ChattyPlanner {
private let session = LanguageModelSession(
tools: [
SearchPlannerItemsTool(index: PlannerIndex())
],
instructions: "Help the user plan their day."
)
func handleEveryEdit(_ draft: String) async throws -> String {
let response = try await session.respond(
to: "The user edited the text field to: \(draft)"
)
return response.content
}
}
Every edit becomes transcript history. Instruments should make that obvious after a few interactions.
Better shape:
func planOnlyOnSubmit(
_ submittedRequest: String,
index: PlannerIndex
) async throws -> PlanningResult {
let agent = DayPlannerAgent(index: index)
return try await agent.planDay(for: submittedRequest)
}
If the product needs a draft preview while the user types, debounce it and use deterministic app logic where possible. Do not turn every keystroke into a permanent model conversation.
Keep sensitive data out of traces
The Foundation Models Instrument is a debugging tool, which means it can expose debugging data. Assume Foundation Models recordings store prompts and responses unencrypted; tool arguments, tool output, and transcript details may also be visible. Treat .trace files as sensitive data.
Do not put these in prompts or tool output unless they are absolutely required for the feature and appropriate for your debugging process:
- Access tokens, refresh tokens, API keys, cookies, or signed URLs.
- Full database rows when a title and short summary would work.
- User secrets, health data, financial details, private messages, or precise locations.
- Internal employee notes, unreleased product names, or customer support histories.
- Stable identifiers that let someone join the trace back to production records.
Prefer short summaries, local redaction, and opaque temporary labels. For example, Task A, Project B, and 3 matching notes are often enough to debug control flow and latency. If you must capture a realistic trace with sensitive data, treat the .trace file like sensitive user data: store it carefully, share it narrowly, and delete it when you are done.
A practical profiling checklist
Use this loop when a Foundation Models feature feels slow or erratic:
- Add a Points of Interest signpost around the user-visible action.
- Record one focused Instruments run with the Foundation Models Instrument.
- Find the sessions created by that user action.
- Check prompt size and transcript growth before judging model speed.
- Compare time to first token or first visible output, generation rate or response duration, and total tap-to-result latency.
- Inspect each tool call: name, arguments, output size, latency, and repeat behavior.
- Look for session, model, or profile handoffs that happen earlier or later than expected.
- Trim tool output and prompt context, then record the same request again.
- Add tool budgets or stopping rules where the trace shows repeated low-value calls.
- Review captured prompt and tool data before sharing the trace.
That checklist keeps the work grounded. A model answer can be subjective. A trace is concrete: this prompt had this much context, called this tool with these arguments, waited this long, crossed this handoff, and returned this much output.
Beta and source note
This post is based on Apple’s WWDC26 session 243, “Debug and profile agentic app experiences with Instruments”, Apple’s Foundation Models documentation, and the beta-era Xcode 27 direction available on June 30, 2026. Confirm exact SDK availability, symbol names, Instruments templates, metric labels, and UI names against the Xcode seed installed on your machine.
Further reading:
- WWDC26 Debug and profile agentic app experiences with Instruments: https://developer.apple.com/videos/play/wwdc2026/243/
- Apple documentation: https://developer.apple.com/documentation/FoundationModels/analyzing-the-runtime-performance-of-your-foundation-models-app
The main idea is not beta-specific: agentic Foundation Models features are normal app workflows. Give them narrow tools, small prompts, clear stopping rules, and trace labels that do not leak user data. Then use Instruments to prove which part of the workflow is actually slow or wrong.
Official Apple sources: