Skip to content

Commit b3c3b92

Browse files
committed
Add token usage tracking and markdown export for conversations
1 parent 38734a0 commit b3c3b92

11 files changed

Lines changed: 772 additions & 8 deletions

File tree

datalab/aiassistant/controller.py

Lines changed: 109 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -18,13 +18,16 @@
1818
from __future__ import annotations
1919

2020
import inspect
21+
import threading
2122
from dataclasses import dataclass, field
2223
from typing import TYPE_CHECKING, Callable
2324

2425
from datalab.aiassistant.providers.base import (
2526
AssistantMessage,
2627
ChatMessage,
2728
LLMProvider,
29+
TokenUsage,
30+
sum_usage,
2831
)
2932
from 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

244258
ConfirmCallback = Callable[[Tool, dict], bool]
245259
ExecuteCallback = Callable[[str, dict], ToolResult]
260+
UsageCallback = Callable[[TokenUsage, TokenUsage], None]
246261

247262

248263
class 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
)

datalab/aiassistant/conversation.py

Lines changed: 30 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@
1919
from dataclasses import dataclass, field
2020
from typing import Any
2121

22-
from datalab.aiassistant.providers.base import ChatMessage, ToolCall
22+
from datalab.aiassistant.providers.base import ChatMessage, TokenUsage, ToolCall
2323

2424

2525
def _now_iso() -> str:
@@ -86,13 +86,16 @@ class Conversation:
8686
created_at: ISO-8601 creation timestamp.
8787
updated_at: ISO-8601 last-modification timestamp.
8888
messages: Conversation messages (excluding the system prompt).
89+
usage: Cumulative token usage across the conversation. ``None``
90+
for conversations saved before token tracking was added.
8991
"""
9092

9193
id: str
9294
title: str = ""
9395
created_at: str = ""
9496
updated_at: str = ""
9597
messages: list[ChatMessage] = field(default_factory=list)
98+
usage: TokenUsage | None = None
9699

97100
@classmethod
98101
def new(cls) -> Conversation:
@@ -102,23 +105,28 @@ def new(cls) -> Conversation:
102105

103106
def to_dict(self) -> dict[str, Any]:
104107
"""Serialise to a JSON-friendly dict."""
105-
return {
108+
out: dict[str, Any] = {
106109
"id": self.id,
107110
"title": self.title,
108111
"created_at": self.created_at,
109112
"updated_at": self.updated_at,
110113
"messages": [_message_to_dict(m) for m in self.messages],
111114
}
115+
if self.usage is not None:
116+
out["usage"] = self.usage.to_dict()
117+
return out
112118

113119
@classmethod
114120
def from_dict(cls, data: dict[str, Any]) -> Conversation:
115121
"""Rebuild a :class:`Conversation` from its serialised dict form."""
122+
usage_data = data.get("usage")
116123
return cls(
117124
id=data.get("id", _make_id()),
118125
title=data.get("title", ""),
119126
created_at=data.get("created_at", _now_iso()),
120127
updated_at=data.get("updated_at", _now_iso()),
121128
messages=[_message_from_dict(m) for m in data.get("messages", [])],
129+
usage=TokenUsage.from_dict(usage_data) if usage_data else None,
122130
)
123131

124132

@@ -196,6 +204,26 @@ def delete(self, conv_id: str) -> None:
196204
except FileNotFoundError:
197205
pass
198206

207+
def rename(self, conv_id: str, title: str) -> None:
208+
"""Update the title of a stored conversation in place.
209+
210+
Does **not** bump ``updated_at`` so the history listing keeps its
211+
chronological order — a rename is metadata housekeeping, not new
212+
content. Silently no-ops when the conversation no longer exists.
213+
Mirrors :func:`renameConversation` in DataLab-Web.
214+
"""
215+
try:
216+
conv = self.load(conv_id)
217+
except (OSError, ValueError):
218+
return
219+
conv.title = title
220+
path = self._path(conv_id)
221+
tmp = path + ".tmp"
222+
os.makedirs(self.directory, exist_ok=True)
223+
with open(tmp, "w", encoding="utf-8") as file:
224+
json.dump(conv.to_dict(), file, ensure_ascii=False, indent=2)
225+
os.replace(tmp, path)
226+
199227
def _prune(self) -> None:
200228
items = self.list()
201229
for info in items[self.max_conversations :]:

0 commit comments

Comments
 (0)