3636 sort_model_ids ,
3737)
3838from ava .session import (
39+ AttemptTiming ,
3940 DriveError ,
4041 EventSink ,
4142 InboxMessage ,
4546 TurnEnd ,
4647 TurnEndReason ,
4748 TurnStart ,
49+ Usage ,
4850)
4951from ava .session .codec import validate_step_claimed_record
52+ from ava .session .compaction import estimate_context_tokens
53+ from ava .session .context_report import ContextReport , context_report
5054from ava .session .log import Log , OpenMode
5155
5256
@@ -107,8 +111,21 @@ def reopen(
107111 agent ._state .drive_state .restore_paused ()
108112 return agent
109113
110- def close (self ) -> None :
111- self ._state .close ()
114+ async def aclose (self ) -> None :
115+ """Close the provider and durable state. The agent must be idle."""
116+ state = self ._state
117+ if state .drive_state .status not in (Status .idle , Status .paused ):
118+ raise AvaError (ErrorKind .invalid_argument , "cannot close the agent while it is running" )
119+ try :
120+ await state .provider .aclose ()
121+ finally :
122+ state .close ()
123+
124+ async def __aenter__ (self ) -> Agent :
125+ return self
126+
127+ async def __aexit__ (self , * _ : object ) -> None :
128+ await self .aclose ()
112129
113130 # ---- observation ----------------------------------------------------------------------
114131
@@ -224,7 +241,7 @@ async def model_choices(self) -> ModelChoices:
224241 available = False
225242 finally :
226243 state .model_catalog_operations -= 1
227- current = state . pending_model or state . provider . selection .model
244+ current = self . current_selection () .model
228245 models = [* listed , current , * state .provider .model_aliases .values ()]
229246 sort_model_ids (models )
230247 deduplicated = list (dict .fromkeys (models ))
@@ -235,21 +252,94 @@ async def model_choices(self) -> ModelChoices:
235252 def select_model (self , model : str ) -> None :
236253 if not model :
237254 raise AvaError (ErrorKind .invalid_argument , "model id must not be empty" )
238- self . _state . pending_model = model
239- self ._state .pending_effort = None
255+ current = self . current_selection ()
256+ self ._state .pending_selection = Selection ( current . provider , model , None )
240257 self ._state .model_revision += 1
241258
242259 def current_selection (self ) -> Selection :
243- provider = self ._state .provider
244- selection = Selection (
245- provider .selection .provider , provider .selection .model , provider .selection .effort
260+ pending = self ._state .pending_selection
261+ selected = pending or self ._state .provider .selection
262+ return Selection (selected .provider , selected .model , selected .effort )
263+
264+ def current_capabilities (self ) -> ModelCapabilities :
265+ """Capabilities for the pending or active model selection."""
266+ return self ._state .provider .capabilities (self .current_selection ().model )
267+
268+ def context_report (self , * , prepare : bool = False ) -> ContextReport :
269+ """Describe the provider-neutral context without exposing runtime state."""
270+ if prepare :
271+ self .prepare ()
272+ state = self ._state
273+ return context_report (
274+ state .session ,
275+ state .provider .context_window ,
276+ state .compaction_options .threshold_percent ,
277+ )
278+
279+ def status_snapshot (self ) -> dict [str , object ]:
280+ """Return the stable frontend status contract from agent-owned state."""
281+ state = self ._state
282+ selected = self .current_selection ()
283+ report = self .context_report ()
284+ used_tokens = (
285+ report .estimated_tokens
286+ if report .measured_input_tokens is None
287+ else estimate_context_tokens (state .session )
288+ )
289+ capabilities = state .provider .capabilities (selected .model )
290+ context_window = capabilities .context_window_tokens
291+ if context_window is None and selected .model == state .provider .selection .model :
292+ context_window = state .provider .context_window
293+ context_window = context_window or 0
294+ remaining = (
295+ round (max (0 , context_window - used_tokens ) * 100 / context_window )
296+ if context_window
297+ else None
298+ )
299+
300+ input_tokens : int | None = None
301+ output_tokens : int | None = None
302+ cached_read = 0
303+ cache_reported = False
304+ ttft_ms : int | None = None
305+ for event in state .session .events :
306+ match event .payload :
307+ case Usage (
308+ input = input ,
309+ cached_read = cached_read_tokens ,
310+ cache_write = cache_write ,
311+ output = output ,
312+ ):
313+ input_parts = (input , cached_read_tokens , cache_write )
314+ if any (value is not None for value in input_parts ):
315+ input_tokens = (input_tokens or 0 ) + sum (value or 0 for value in input_parts )
316+ if output is not None :
317+ output_tokens = (output_tokens or 0 ) + output
318+ if cached_read_tokens is not None :
319+ cached_read += cached_read_tokens
320+ cache_reported = True
321+ case AttemptTiming (ttft_ms = latest_ttft ):
322+ # The newest attempt describes the latency the user most recently felt.
323+ ttft_ms = latest_ttft
324+
325+ cache_hit_percent = (
326+ cached_read * 100 // input_tokens if cache_reported and input_tokens else None
246327 )
247- if self ._state .pending_model is not None :
248- selection .model = self ._state .pending_model
249- selection .effort = None
250- if self ._state .pending_effort is not None :
251- selection .effort = self ._state .pending_effort
252- return selection
328+ return {
329+ "status" : self .status .value ,
330+ "turn_open" : self .turn_open ,
331+ "cwd" : str (self .cwd ),
332+ "provider" : selected .provider ,
333+ "model" : selected .model ,
334+ "effort" : selected .effort ,
335+ "context_used_tokens" : used_tokens ,
336+ "context_window_tokens" : context_window ,
337+ "context_remaining_percent" : remaining ,
338+ "input_tokens" : input_tokens ,
339+ "output_tokens" : output_tokens ,
340+ "cache_hit_percent" : cache_hit_percent ,
341+ "ttft_ms" : ttft_ms ,
342+ }
253343
254344 async def cycle_effort (self ) -> str :
255345 state = self ._state
@@ -280,7 +370,7 @@ async def cycle_effort(self) -> str:
280370 except ValueError :
281371 index = - 1
282372 next_effort = efforts [0 ] if index < 0 or index + 1 >= len (efforts ) else efforts [index + 1 ]
283- state .pending_effort = next_effort
373+ state .pending_selection = Selection ( selected . provider , selected . model , next_effort )
284374 return next_effort
285375
286376 def select_effort (self , effort : str | None ) -> None :
@@ -295,9 +385,10 @@ def select_effort(self, effort: str | None) -> None:
295385 f"the current model does not advertise reasoning effort '{ effort } '; "
296386 f"choose one of { ', ' .join (capabilities .effort_values ) or 'none' } " ,
297387 )
298- self ._state .pending_effort = effort
388+ selected = self .current_selection ()
389+ self ._state .pending_selection = Selection (selected .provider , selected .model , effort )
299390
300- def reload_credentials (
391+ async def reload_credentials (
301392 self , auth_requirement : AuthRequirement = AuthRequirement .required
302393 ) -> None :
303394 state = self ._state
@@ -307,7 +398,11 @@ def reload_credentials(
307398 or state .model_catalog_operations
308399 ):
309400 raise AvaError (ErrorKind .invalid_argument , "cannot reload credentials while busy" )
310- state .provider = provider_from_environment (None , state .provider .selection , auth_requirement )
401+ old_provider = state .provider
402+ new_provider = provider_from_environment (None , old_provider .selection , auth_requirement )
403+ state .provider = new_provider
404+ if new_provider is not old_provider :
405+ await old_provider .aclose ()
311406
312407 # ---- the driver ----------------------------------------------------------------------
313408
@@ -410,13 +505,15 @@ async def drive(self) -> None:
410505 owns = False
411506 return
412507 if not drive .close_turn ():
413- error = AvaError (
508+ close_error = AvaError (
414509 ErrorKind .internal ,
415510 "cannot close a turn while tool results remain outstanding" ,
416511 )
417- await self ._finish_failed_turn (turn_number , TurnEndReason .completed , error )
512+ await self ._finish_failed_turn (
513+ turn_number , TurnEndReason .completed , close_error
514+ )
418515 owns = False
419- raise error
516+ raise close_error
420517 elapsed_ms = int ((time .monotonic () - turn_started ) * 1000 )
421518 state .append (
422519 TurnEnd (
0 commit comments