Context Propagation in Android AI Agents: Resolving Device State Before Action Dispatch
An AI agent on a phone has access to something a server-side agent doesn’t: the device itself. Calendar events, saved locations, recent contacts, active notifications — all of it is locally available before any network call is made. The question isn’t whether to use that context. It’s how to resolv

An AI agent on a phone has access to something a server-side agent doesn’t: the device itself. Calendar events, saved locations, recent contacts, active notifications — all of it is locally available before any network call is made.
The question isn’t whether to use that context. It’s how to resolve it into action parameters without asking the user for information the phone already has, and without silently filling in details the phone only partially knows.
This post covers the context propagation layer in StareBrain — how device state gets resolved into action parameters, how confidence thresholds decide when to auto-fill vs. suggest vs. ask, and what happens when resolution fails.
When a user says “book a slot for my dentist appointment,” the agent needs to resolve:
Some of these can be resolved from device state with high confidence. Others can be suggested. Others need to be asked explicitly. Collapsing all of them into a single “ask the user for everything” flow is safe but slow. Auto-filling everything without surfacing uncertainty is fast but fragile.
The resolution layer sits between intent parsing and action dispatch, and its job is to decide which parameters fall into which bucket.
`data class DeviceContext ( val upcomingEvents: List < CalendarEvent >, val savedLocations: List < SavedLocation >, val recentContacts: List < Contact >, val recentApps: List < String >, val activeNotifications: List < NotificationSummary > ) data class CalendarEvent ( val eventId: String, val title: String, val startTime: Instant, val location: String?, val attendees: List < String > ) data class SavedLocation ( val label: String, val address: String, val coordinates: Pair < Double, Double > ) ` Enter fullscreen mode Exit fullscreen mode The context is captured at intent-parse time — a snapshot of device state at the moment the user expressed the intent. This matters: if resolution runs against live state at dispatch time, a contact added or an event modified after the user spoke could produce parameters the user never approved.
Each parameter resolution produces a confidence level that determines what happens next:
`enum class ResolutionConfidence { HIGH, MEDIUM, LOW, UNRESOLVABLE } data class ParameterValue ( val value: Any?, val confidence: ResolutionConfidence, val source: ContextSource ) sealed class ContextSource { object DeviceCalendar: ContextSource () object SavedLocations: ContextSource () object RecentContacts: ContextSource () object UserProvided: ContextSource () object Inferred: ContextSource () } fun ParameterValue. shouldAutoFill (): Boolean = confidence == ResolutionConfidence. HIGH fun ParameterValue. shouldSuggest (): Boolean = confidence == ResolutionConfidence. MEDIUM fun ParameterValue. shouldAsk (): Boolean = confidence == ResolutionConfidence.
LOW || confidence == ResolutionConfidence. UNRESOLVABLE` Enter fullscreen mode Exit fullscreen mode HIGH → auto-fill silently. The device has this unambiguously (default calendar, a location saved under an exact label match).
MEDIUM → surface as a suggestion. The device has a candidate but it’s not certain (a location match on partial label, a contact with a common name).
LOW / UNRESOLVABLE → ask the user explicitly before proceeding.
`class ContextResolver ( private val calendarProvider: CalendarProvider, private val locationStore: LocationStore, private val contactStore: ContactStore ) { fun resolve ( intent: ActionIntent, context: DeviceContext ): Map < String, ParameterValue > { return intent. requiredParameters. associate { param -> param. name to resolveParameter ( param, context ) } } private fun resolveParameter ( param: Parameter, context: DeviceContext ): ParameterValue { return when ( param ) { is Parameter. Location -> resolveLocation ( param, context ) is Parameter. Contact -> resolveContact ( param, context ) is Parameter.
TimeSlot -> resolveTimeSlot ( param, context ) is Parameter. Calendar -> resolveCalendar ( param, context ) } } private fun resolveLocation ( param: Parameter. Location, context: DeviceContext ): ParameterValue { val exactMatch = context. savedLocations. firstOrNull { it. label. equals ( param. hint, ignoreCase = true ) } if ( exactMatch!= null ) { return ParameterValue ( value = exactMatch, confidence = ResolutionConfidence.
HIGH, source = ContextSource. SavedLocations ) } val partialMatch = context. savedLocations. firstOrNull { it. label. contains ( param. hint, ignoreCase = true ) } if ( partialMatch!= null ) { return ParameterValue ( value = partialMatch, confidence = ResolutionConfidence. MEDIUM, source = ContextSource.
SavedLocations ) } return ParameterValue ( value = null, confidence = ResolutionConfidence. UNRESOLVABLE, source = ContextSource. SavedLocations ) } } ` Enter fullscreen mode Exit fullscreen mode Building the Agent Context The resolved parameters get packaged into an AgentContext that travels with the action through to dispatch:
`data class AgentContext ( val actionIntent: ActionIntent, val deviceContext: DeviceContext, val resolvedParameters: Map < String, ParameterValue >, val capturedAt: Instant ) fun AgentContext. pendingParameters (): List < String > { return resolvedParameters. filter { ( _, value ) -> value. shouldAsk () }. map { ( name, _ ) -> name } } fun AgentContext. suggestedParameters (): Map < String, ParameterValue > { return resolvedParameters. filter { ( _, value ) -> value. shouldSuggest () } } fun AgentContext. autoFilledParameters (): Map < String, Any?> { return resolvedParameters. filter { ( _, value ) -> value. shouldAutoFill () }. mapValues { ( _, value ) -> value. value } } ` Enter fullscreen mode Exit fullscreen mode Before the action proceeds to the confirmation step, the agent checks pendingParameters(). If anything is unresolvable, the user is asked. If there are suggestions, they’re surfaced with the option to confirm or override.
Only HIGH confidence parameters skip the user entirely.
The capturedAt timestamp on AgentContext is the snapshot boundary. Resolution runs once, against the device state at intent time. If the user takes three minutes to confirm the action, the context doesn’t re-resolve — the parameters they’re approving are the ones they were shown.
This is intentional. Re-resolving at confirmation time could change the suggested contact or location between when the user read the suggestion and when they tapped confirm, which is a different version of the stale-plan problem at the resolution layer rather than the dispatch layer.
The resolution snapshot is the contract. The dispatch layer’s job (covered in earlier posts) is to verify that the external world still matches that contract at execution time.
The resolver separates three categories of knowledge: what the device knows for certain, what it can reasonably guess, and what it genuinely doesn’t have. Surfacing that distinction explicitly — rather than flattening it into “fill what you can and ask for the rest” — keeps the user in the loop on the decisions that affect them and out of the loop on the ones that don’t.