1818from __future__ import annotations
1919
2020import inspect
21+ import threading
2122from dataclasses import dataclass , field
2223from typing import TYPE_CHECKING , Callable
2324
2425from datalab .aiassistant .providers .base import (
2526 AssistantMessage ,
2627 ChatMessage ,
2728 LLMProvider ,
29+ TokenUsage ,
30+ sum_usage ,
2831)
2932from datalab .aiassistant .tools .registry import Tool , ToolRegistry , ToolResult
3033
@@ -234,15 +237,27 @@ class TurnResult:
234237 assistant_message: Final assistant text (after all tool calls).
235238 tool_executions: Tool calls made during the turn.
236239 cancelled: True if the user cancelled at a confirmation prompt.
240+ aborted: True if :meth:`AIController.abort` interrupted the loop.
241+ turn_usage: Cumulative token usage for this single turn (sum of
242+ every provider round-trip in the loop). ``None`` when no
243+ round-trip reported usage.
237244 """
238245
239246 assistant_message : str
240247 tool_executions : list [tuple [str , dict , ToolResult ]] = field (default_factory = list )
241248 cancelled : bool = False
249+ aborted : bool = False
250+ turn_usage : TokenUsage | None = None
251+
252+
253+ class AIAbortError (RuntimeError ):
254+ """Raised inside the controller loop when :meth:`AIController.abort` is
255+ called. Mirrors :class:`AbortError` in DataLab-Web."""
242256
243257
244258ConfirmCallback = Callable [[Tool , dict ], bool ]
245259ExecuteCallback = Callable [[str , dict ], ToolResult ]
260+ UsageCallback = Callable [[TokenUsage , TokenUsage ], None ]
246261
247262
248263class AIController :
@@ -271,19 +286,27 @@ def __init__(
271286 max_iterations : int = 8 ,
272287 auto_approve_readonly : bool = True ,
273288 execute_callback : ExecuteCallback | None = None ,
289+ usage_callback : UsageCallback | None = None ,
274290 ) -> None :
275291 self .provider = provider
276292 self .registry = registry
277293 self .proxy = proxy
278294 self .mainwindow = mainwindow
279295 self .confirm_callback = confirm_callback
280296 self .execute_callback = execute_callback
297+ self .usage_callback = usage_callback
281298 self .system_prompt = system_prompt or build_default_system_prompt ()
282299 self .max_iterations = int (max_iterations )
283300 self .auto_approve_readonly = bool (auto_approve_readonly )
284301 self .history : list [ChatMessage ] = [
285302 ChatMessage (role = "system" , content = self .system_prompt )
286303 ]
304+ # Cumulative token usage across the whole conversation. Reset on
305+ # :meth:`reset` and :meth:`load_messages`.
306+ self ._cumulative_usage : TokenUsage = TokenUsage ()
307+ # Set while :meth:`send` is in flight to support :meth:`abort`.
308+ self ._abort_event = threading .Event ()
309+ self ._running = False
287310
288311 @classmethod
289312 def with_default_prompt (
@@ -296,6 +319,7 @@ def with_default_prompt(
296319 max_iterations : int = 8 ,
297320 auto_approve_readonly : bool = True ,
298321 execute_callback : ExecuteCallback | None = None ,
322+ usage_callback : UsageCallback | None = None ,
299323 ) -> AIController :
300324 """Build a controller whose system prompt matches ``registry``."""
301325 tool_names = {schema ["name" ] for schema in registry .list_schemas ()}
@@ -309,30 +333,74 @@ def with_default_prompt(
309333 max_iterations = max_iterations ,
310334 auto_approve_readonly = auto_approve_readonly ,
311335 execute_callback = execute_callback ,
336+ usage_callback = usage_callback ,
312337 )
313338
314339 def reset (self ) -> None :
315340 """Clear the conversation history (keep the system prompt)."""
316341 self .history = [ChatMessage (role = "system" , content = self .system_prompt )]
342+ self ._cumulative_usage = TokenUsage ()
317343
318- def load_messages (self , messages : list [ChatMessage ]) -> None :
344+ def load_messages (
345+ self ,
346+ messages : list [ChatMessage ],
347+ initial_usage : TokenUsage | None = None ,
348+ ) -> None :
319349 """Replace the current history with ``messages``.
320350
321351 The system prompt is always reset to the controller's current
322352 ``system_prompt`` (which is auto-generated and may have changed
323353 since the conversation was persisted). Any system messages from
324354 ``messages`` are dropped.
355+
356+ Args:
357+ messages: Messages to restore (system messages are dropped).
358+ initial_usage: Cumulative token usage to seed the running
359+ counter with — typically the value persisted alongside the
360+ conversation. ``None`` resets it to zero.
325361 """
326362 self .history = [ChatMessage (role = "system" , content = self .system_prompt )]
327363 for msg in messages :
328364 if msg .role == "system" :
329365 continue
330366 self .history .append (msg )
367+ self ._cumulative_usage = (
368+ TokenUsage (
369+ prompt_tokens = initial_usage .prompt_tokens ,
370+ completion_tokens = initial_usage .completion_tokens ,
371+ total_tokens = initial_usage .total_tokens ,
372+ )
373+ if initial_usage is not None
374+ else TokenUsage ()
375+ )
331376
332377 def get_messages (self ) -> list [ChatMessage ]:
333378 """Return the conversation messages, excluding the system prompt."""
334379 return [msg for msg in self .history if msg .role != "system" ]
335380
381+ def get_usage (self ) -> TokenUsage :
382+ """Snapshot of the cumulative token usage across the conversation."""
383+ return TokenUsage (
384+ prompt_tokens = self ._cumulative_usage .prompt_tokens ,
385+ completion_tokens = self ._cumulative_usage .completion_tokens ,
386+ total_tokens = self ._cumulative_usage .total_tokens ,
387+ )
388+
389+ def abort (self ) -> None :
390+ """Request cancellation of the in-flight :meth:`send` call.
391+
392+ Idempotent — a no-op when the controller is idle. The abort takes
393+ effect at the next safe point in the loop (between provider calls
394+ / between tool calls). The currently-running provider HTTP request
395+ is **not** interrupted; the abort is observed once it returns.
396+ """
397+ self ._abort_event .set ()
398+
399+ @property
400+ def is_running (self ) -> bool :
401+ """True while a :meth:`send` call is in flight."""
402+ return self ._running
403+
336404 def send (self , user_message : str ) -> TurnResult :
337405 """Send a user message and run the tool-call loop.
338406
@@ -343,20 +411,40 @@ def send(self, user_message: str) -> TurnResult:
343411 responses" is never violated in the persisted history — a corrupt
344412 partial turn would otherwise poison every subsequent ``send()``.
345413 The user message is preserved in the GUI input history regardless.
414+
415+ When :meth:`abort` is called while the loop is in flight, the call
416+ returns a :class:`TurnResult` with ``aborted=True`` (the partial
417+ transcript is rolled back so the persisted history stays valid).
346418 """
347419 snapshot_len = len (self .history )
420+ self ._abort_event .clear ()
421+ self ._running = True
348422 try :
349423 return self ._send_inner (user_message )
424+ except AIAbortError :
425+ del self .history [snapshot_len :]
426+ return TurnResult (
427+ assistant_message = "" ,
428+ aborted = True ,
429+ )
350430 except BaseException :
351431 del self .history [snapshot_len :]
352432 raise
433+ finally :
434+ self ._running = False
435+
436+ def _check_abort (self ) -> None :
437+ if self ._abort_event .is_set ():
438+ raise AIAbortError ()
353439
354440 def _send_inner (self , user_message : str ) -> TurnResult :
355441 self .history .append (ChatMessage (role = "user" , content = user_message ))
356442 executions : list [tuple [str , dict , ToolResult ]] = []
357443 tools_schema = self .registry .list_schemas ()
444+ turn_usage : TokenUsage = TokenUsage ()
358445
359446 for _iteration in range (self .max_iterations ):
447+ self ._check_abort ()
360448 response : AssistantMessage = self .provider .chat (
361449 self .history , tools = tools_schema
362450 )
@@ -367,11 +455,28 @@ def _send_inner(self, user_message: str) -> TurnResult:
367455 tool_calls = list (response .tool_calls ),
368456 )
369457 )
458+ if response .usage is not None :
459+ self ._cumulative_usage = sum_usage (
460+ self ._cumulative_usage , response .usage
461+ )
462+ turn_usage = sum_usage (turn_usage , response .usage )
463+ if self .usage_callback is not None :
464+ try :
465+ self .usage_callback (response .usage , self .get_usage ())
466+ # pylint: disable-next=broad-exception-caught
467+ except BaseException : # noqa: BLE001
468+ # The callback is a UI hook — never let it break
469+ # the conversation loop.
470+ pass
471+ self ._check_abort ()
370472 if not response .tool_calls :
371473 return TurnResult (
372- assistant_message = response .content , tool_executions = executions
474+ assistant_message = response .content ,
475+ tool_executions = executions ,
476+ turn_usage = turn_usage if response .usage is not None else None ,
373477 )
374478 for call_index , call in enumerate (response .tool_calls ):
479+ self ._check_abort ()
375480 try :
376481 tool = self .registry .get (call .name )
377482 except KeyError as exc :
@@ -401,6 +506,7 @@ def _send_inner(self, user_message: str) -> TurnResult:
401506 assistant_message = response .content ,
402507 tool_executions = executions ,
403508 cancelled = True ,
509+ turn_usage = turn_usage ,
404510 )
405511 if self .execute_callback is None :
406512 result = self .registry .call (
@@ -430,4 +536,5 @@ def _send_inner(self, user_message: str) -> TurnResult:
430536 f"iterations ({ self .max_iterations } )."
431537 ),
432538 tool_executions = executions ,
539+ turn_usage = turn_usage ,
433540 )
0 commit comments