Arya
Software Engineer / AI & ML
Software Engineer / AI & ML






SystemLanguageModel.default gives you an on-device model with a context window of about 4096 tokens. Every tool you hand a LanguageModelSession costs tokens just to describe, its name, its description, and the schema for its arguments. In this app, giving one session all seven tools (bean corpus search, owned-bean lookup, brew advice, a taste quiz, web search, nearby cafes, and a choice-offering tool) cost 1785 tokens before the conversation even started. That's nearly half the budget gone to tool schemas the model wasn't going to use on most turns.Specialty enum with four cases, beans, cupboard, shopping, and general as the fallback.unsure), it falls through to general, which holds every tool. A wrong guess that lands on beans when the user actually wanted shopping costs a missing tool and a bad answer. A guess that lands on unsure costs some tokens but keeps the app working, falling back to the full generalist rather than confidently answering with the wrong specialist.ChatManager rebuilds the model session with just that specialty's tools, and every subsequent tool-calling turn only pays for what it actually needs.coreInstructions covers what every specialist needs (the app is moka-pot only, cite real beans, say when unsure). Each specialty then adds only the paragraphs about the tools it actually holds.general specialist gets the full persona, every briefing concatenated. A specialist gets the core plus one briefing. The bigger saving is that a specialist is never told how to use a tool it doesn't hold.cupboard specialist gets its own compareToReference path and the beans specialist keeps corpus search. The overlap is a design decision, not an oversight. A misrouted comparison would cost a wrong answer, which is worse than a few extra tokens of tool description.Tool in this framework is four things, a name, a description, an Arguments type, and a call(arguments:) method. The name and description are the only information the model has when it decides whether a tool is relevant to a prompt, and a vague description is the reason i found that make a model skips a tool it should have used.Arguments is itself @Generable, so the model doesn't hand you a string to parse, it fills in a typed struct, and @Guide narrows what it's allowed to produce. BeanCorpusTool's arguments look like this..range(1...5) isn't documentation, it's a constraint the model can't violate. The framework enforces it during generation, not after.tokenCount(for:) against a tool array before you ever call it.@Generable struct, Apple's macro for typed, schema-constrained generation, rather than a free-text blob. BeanCorpusTool is a representative example.noMatchesInIndex means the corpus was searched and came up empty, a real answer. indexUnavailable means the search never ran. Those are different facts, and the app's instructions tell the model to treat them differently. "Any status ending in Unavailable, plus indexStale, means the search did not run. Say it is unavailable, never that nothing was found." Without a typed status, a small on-device model is far more likely to blur "I found nothing" and "I couldn't check" into the same sentence.general fallback exists precisely to bound that risk, but it means the generalist path (full tool set, full instructions) still has to be correct and still has to fit, so specialization doesn't remove the original budget problem, it just makes the common case cheaper.DynamicProfile, DynamicInstructions, historyTransform, and @SessionProperty. A profile's tools and instructions re-evaluate before every request, so a specialty switch stops being a session rebuild and becomes a property change, with no KV-cache reset and no lost context.nonisolated enum Specialty: String, CaseIterable, Sendable { case beans, cupboard, shopping, general }
func tools(for specialty: Specialty) -> [any Tool] { switch specialty { case .beans: [corpusTool, quizTool, choicesTool] case .cupboard: [ownedTool, adviceTool, choicesTool] case .shopping: [webSearchTool, nearbyPlacesTool] case .general: [corpusTool, ownedTool, quizTool, adviceTool, choicesTool, webSearchTool, nearbyPlacesTool] } }
@Generable enum RouteChoice { case beans case cupboard case shopping case unsure } func route(_ question: String) async -> Specialty { let session = LanguageModelSession(instructions: Self.routerInstructions) guard let choice = try? await session.respond(to: question, generating: RouteChoice.self).content else { return .general } return choice.specialty }
func makeSession(for specialty: Specialty = .general, recap: String? = nil) -> LanguageModelSession { let tools = tools(for: specialty) return LanguageModelSession( tools: tools, instructions: Instructions { specialty.instructions if let recap { recap } } ) }
var instructions: String { switch self { case .general: CoffeeAgent.personaInstructions default: CoffeeAgent.coreInstructions + "\n\n" + briefing } }
@Generable struct Arguments { @Guide(description: "Island, growing region, flavor note, processing method, or roast level") var query: String @Guide(description: "How many beans to return", .range(1...5)) var limit: Int }
@Generable enum BeanSearchStatus { case matchesFound, noMatchesInIndex, indexUnavailable, indexStale } @Generable struct BeanSearchOutcome { var status: BeanSearchStatus var beans: [BeanHit] }