refactor(providers): define typed usage contract
This commit is contained in:
@@ -9,7 +9,7 @@ from typing import Any
|
||||
|
||||
from loguru import logger
|
||||
|
||||
from nanobot.providers.base import LLMResponse, ToolCallRequest
|
||||
from nanobot.providers.base import LLMResponse, LLMUsage, ToolCallRequest
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
@@ -19,7 +19,7 @@ class AgentHookContext:
|
||||
iteration: int
|
||||
messages: list[dict[str, Any]]
|
||||
response: LLMResponse | None = None
|
||||
usage: dict[str, int] = field(default_factory=dict)
|
||||
usage: LLMUsage | None = None
|
||||
tool_calls: list[ToolCallRequest] = field(default_factory=list)
|
||||
tool_results: list[Any] = field(default_factory=list)
|
||||
tool_events: list[dict[str, str]] = field(default_factory=list)
|
||||
@@ -39,7 +39,7 @@ class AgentRunHookContext:
|
||||
messages: list[dict[str, Any]]
|
||||
final_content: str | None = None
|
||||
tools_used: list[str] = field(default_factory=list)
|
||||
usage: dict[str, int] = field(default_factory=dict)
|
||||
usage: LLMUsage | None = None
|
||||
stop_reason: str | None = None
|
||||
error: str | None = None
|
||||
tool_events: list[dict[str, str]] = field(default_factory=list)
|
||||
@@ -284,7 +284,7 @@ class SDKCaptureHook(AgentHook):
|
||||
super().__init__()
|
||||
self.tools_used: list[str] = []
|
||||
self.messages: list[dict[str, Any]] = []
|
||||
self.usage: dict[str, int] = {}
|
||||
self.usage: LLMUsage | None = None
|
||||
self.stop_reason: str | None = None
|
||||
self.error: str | None = None
|
||||
self.tool_events: list[dict[str, str]] = []
|
||||
@@ -294,7 +294,7 @@ class SDKCaptureHook(AgentHook):
|
||||
for call in context.tool_calls:
|
||||
self.tools_used.append(call.name)
|
||||
self.messages = list(context.messages)
|
||||
self.usage = dict(context.usage)
|
||||
self.usage = context.usage
|
||||
self.stop_reason = context.stop_reason
|
||||
self.error = context.error
|
||||
self.tool_events = list(context.tool_events)
|
||||
@@ -302,7 +302,7 @@ class SDKCaptureHook(AgentHook):
|
||||
async def after_run(self, context: AgentRunHookContext) -> None:
|
||||
self.tools_used = list(context.tools_used)
|
||||
self.messages = list(context.messages)
|
||||
self.usage = dict(context.usage)
|
||||
self.usage = context.usage
|
||||
self.stop_reason = context.stop_reason
|
||||
self.error = context.error
|
||||
self.tool_events = list(context.tool_events)
|
||||
|
||||
@@ -49,7 +49,7 @@ from nanobot.bus.queue import MessageBus
|
||||
from nanobot.bus.runtime_events import RuntimeEventBus
|
||||
from nanobot.command import CommandContext, CommandRouter, register_builtin_commands
|
||||
from nanobot.config.schema import AgentDefaults, ModelPresetConfig
|
||||
from nanobot.providers.base import LLMProvider, ProviderConversationState
|
||||
from nanobot.providers.base import LLMProvider, LLMUsage, ProviderConversationState
|
||||
from nanobot.providers.factory import ProviderSnapshot
|
||||
from nanobot.runtime_context import (
|
||||
RUNTIME_CONTEXT_HISTORY_META,
|
||||
@@ -167,7 +167,7 @@ class TurnContext:
|
||||
turn_wall_started_at: float = field(default_factory=time.time)
|
||||
visible_run_started_at: float | None = None
|
||||
turn_latency_ms: int | None = None
|
||||
usage: dict[str, int] = field(default_factory=dict)
|
||||
usage: LLMUsage | None = None
|
||||
|
||||
def require_runtime(self) -> LLMRuntime:
|
||||
"""Return the runtime established by the BUILD stage."""
|
||||
@@ -203,7 +203,7 @@ class AgentLoop:
|
||||
return self.tools.tool_names
|
||||
|
||||
@property
|
||||
def last_usage(self) -> Mapping[str, int]:
|
||||
def last_usage(self) -> LLMUsage | None:
|
||||
"""Latest aggregate usage exposed through the runtime-control snapshot."""
|
||||
return self._last_usage
|
||||
|
||||
@@ -378,7 +378,7 @@ class AgentLoop:
|
||||
default_restrict_to_workspace=restrict_to_workspace,
|
||||
)
|
||||
self._start_time = time.time()
|
||||
self._last_usage: dict[str, int] = {}
|
||||
self._last_usage: LLMUsage | None = None
|
||||
self._extra_hooks: list[AgentHook] = hooks or []
|
||||
self._hook_factories: list[AgentTurnHookFactory] = hook_factories or []
|
||||
|
||||
@@ -2031,7 +2031,7 @@ class AgentLoop:
|
||||
ctx.all_messages = all_msgs
|
||||
ctx.stop_reason = stop_reason
|
||||
ctx.had_injections = had_injections
|
||||
ctx.usage = dict(self._last_usage)
|
||||
ctx.usage = self._last_usage
|
||||
ctx.delivery.record_usage(ctx.usage)
|
||||
if ctx.kind is TurnKind.USER:
|
||||
await turn_continuation.maybe_continue_turn(ctx)
|
||||
@@ -2058,8 +2058,8 @@ class AgentLoop:
|
||||
else ctx.turn_wall_started_at
|
||||
)
|
||||
ctx.turn_latency_ms = max(0, int((time.time() - latency_started_at) * 1000))
|
||||
if ctx.usage and not ctx.ephemeral:
|
||||
session.metadata["_last_usage"] = dict(ctx.usage)
|
||||
if ctx.usage is not None and not ctx.ephemeral:
|
||||
session.metadata["_last_usage"] = ctx.usage.to_dict()
|
||||
self._save_turn(
|
||||
session, ctx.all_messages, ctx.save_skip,
|
||||
turn_latency_ms=ctx.turn_latency_ms,
|
||||
|
||||
@@ -210,12 +210,14 @@ class AgentProgressHook(AgentHook):
|
||||
tool_hint=False,
|
||||
tool_events=tool_events,
|
||||
)
|
||||
u = context.usage or {}
|
||||
u = context.usage
|
||||
logger.debug(
|
||||
"LLM usage: prompt={} completion={} cached={}",
|
||||
u.get("prompt_tokens", 0),
|
||||
u.get("completion_tokens", 0),
|
||||
u.get("cached_tokens", 0),
|
||||
"LLM usage: input={} output={} cache_read={} cache_write={} source={}",
|
||||
u.input_tokens if u else 0,
|
||||
u.output_tokens if u else 0,
|
||||
u.cache_read_tokens if u else None,
|
||||
u.cache_write_tokens if u else None,
|
||||
u.source if u else "missing",
|
||||
)
|
||||
|
||||
def finalize_content(self, context: AgentHookContext, content: str | None) -> str | None:
|
||||
|
||||
+41
-78
@@ -23,6 +23,7 @@ from nanobot.agent.tools.registry import ToolRegistry, is_tool_error_result
|
||||
from nanobot.providers.base import (
|
||||
LLMProvider,
|
||||
LLMResponse,
|
||||
LLMUsage,
|
||||
ProviderCallContext,
|
||||
ProviderConversationState,
|
||||
ToolCallRequest,
|
||||
@@ -126,7 +127,7 @@ class AgentRunResult:
|
||||
final_content: str | None
|
||||
messages: list[dict[str, Any]]
|
||||
tools_used: list[str] = field(default_factory=list)
|
||||
usage: dict[str, int] = field(default_factory=dict)
|
||||
usage: LLMUsage | None = None
|
||||
stop_reason: str = "completed"
|
||||
error: str | None = None
|
||||
tool_events: list[dict[str, str]] = field(default_factory=list)
|
||||
@@ -412,7 +413,7 @@ class AgentRunner:
|
||||
context.messages = deepcopy(result.messages)
|
||||
context.final_content = result.final_content
|
||||
context.tools_used = list(result.tools_used)
|
||||
context.usage = dict(result.usage)
|
||||
context.usage = result.usage
|
||||
context.stop_reason = result.stop_reason
|
||||
context.error = result.error
|
||||
context.tool_events = deepcopy(result.tool_events)
|
||||
@@ -443,7 +444,7 @@ class AgentRunner:
|
||||
) -> AgentRunResult:
|
||||
final_content: str | None = None
|
||||
tools_used: list[str] = []
|
||||
usage = {"prompt_tokens": 0, "completion_tokens": 0}
|
||||
usage: LLMUsage | None = None
|
||||
error: str | None = None
|
||||
stop_reason = "completed"
|
||||
tool_events: list[dict[str, str]] = []
|
||||
@@ -519,8 +520,8 @@ class AgentRunner:
|
||||
)
|
||||
response.content = cleaned_content
|
||||
raw_usage = self._usage_or_estimate(spec, messages_for_model, response)
|
||||
context.usage = dict(raw_usage)
|
||||
self._accumulate_usage(usage, raw_usage)
|
||||
context.usage = raw_usage
|
||||
usage = self._merge_usage(usage, raw_usage)
|
||||
if reasoning_text and not context.streamed_reasoning:
|
||||
await hook.emit_reasoning(reasoning_text)
|
||||
await hook.emit_reasoning_end()
|
||||
@@ -683,10 +684,10 @@ class AgentRunner:
|
||||
conversation_state=conversation_state,
|
||||
)
|
||||
retry_usage = self._usage_or_estimate(spec, retry_messages, response)
|
||||
self._accumulate_usage(usage, retry_usage)
|
||||
usage = self._merge_usage(usage, retry_usage)
|
||||
raw_usage = self._merge_usage(raw_usage, retry_usage)
|
||||
context.response = response
|
||||
context.usage = dict(raw_usage)
|
||||
context.usage = raw_usage
|
||||
context.tool_calls = list(response.tool_calls)
|
||||
original_content = response.content
|
||||
clean = hook.finalize_content(context, response.content)
|
||||
@@ -859,7 +860,7 @@ class AgentRunner:
|
||||
had_injections = True
|
||||
terminal_content = None
|
||||
if spec.finalize_on_max_iterations:
|
||||
terminal_content = await self._try_finalize_after_max_iterations(
|
||||
terminal_content, usage = await self._try_finalize_after_max_iterations(
|
||||
spec,
|
||||
hook,
|
||||
messages,
|
||||
@@ -1236,9 +1237,9 @@ class AgentRunner:
|
||||
spec: AgentRunSpec,
|
||||
hook: AgentHook,
|
||||
messages: list[dict[str, Any]],
|
||||
usage: dict[str, int],
|
||||
usage: LLMUsage | None,
|
||||
conversation_state: ProviderConversationStateController,
|
||||
) -> str | None:
|
||||
) -> tuple[str | None, LLMUsage | None]:
|
||||
retry_messages = self._budget_exhausted_finalization_messages(messages)
|
||||
try:
|
||||
response = await self._request_no_tools(
|
||||
@@ -1253,10 +1254,10 @@ class AgentRunner:
|
||||
"Budget-exhausted finalization failed for {}; using fallback",
|
||||
spec.session_key or "default",
|
||||
)
|
||||
return None
|
||||
return None, usage
|
||||
|
||||
raw_usage = self._usage_or_estimate(spec, retry_messages, response)
|
||||
self._accumulate_usage(usage, raw_usage)
|
||||
usage = self._merge_usage(usage, raw_usage)
|
||||
if response.finish_reason == "error" or response.has_tool_calls:
|
||||
logger.warning(
|
||||
"Budget-exhausted finalization returned finish_reason='{}' "
|
||||
@@ -1265,19 +1266,19 @@ class AgentRunner:
|
||||
len(response.tool_calls),
|
||||
spec.session_key or "default",
|
||||
)
|
||||
return None
|
||||
return None, usage
|
||||
|
||||
context = AgentHookContext(
|
||||
iteration=spec.max_iterations,
|
||||
messages=messages,
|
||||
response=response,
|
||||
usage=dict(raw_usage),
|
||||
usage=raw_usage,
|
||||
session_key=spec.session_key,
|
||||
)
|
||||
clean = hook.finalize_content(context, response.content)
|
||||
if is_blank_text(clean):
|
||||
return None
|
||||
return clean
|
||||
return None, usage
|
||||
return clean, usage
|
||||
|
||||
async def _request_no_tools(
|
||||
self,
|
||||
@@ -1349,31 +1350,24 @@ class AgentRunner:
|
||||
spec: AgentRunSpec,
|
||||
messages: list[dict[str, Any]],
|
||||
response: LLMResponse,
|
||||
) -> dict[str, int]:
|
||||
usage = self._usage_dict(response.usage)
|
||||
total = self._usage_total(usage)
|
||||
if total > 0:
|
||||
usage["total_tokens"] = total
|
||||
usage.setdefault("provider_tokens", total)
|
||||
elif response.finish_reason == "error":
|
||||
return {}
|
||||
else:
|
||||
) -> LLMUsage | None:
|
||||
usage = response.usage
|
||||
if response.finish_reason == "error":
|
||||
if usage is None or usage.total_tokens == 0:
|
||||
usage = LLMUsage.empty_request()
|
||||
elif usage is None or usage.total_tokens == 0:
|
||||
usage = self._estimate_response_usage(spec, messages, response)
|
||||
completion = usage.get("completion_tokens", 0)
|
||||
if response.generation_ms is not None and completion > 0:
|
||||
usage["generation_ms"] = response.generation_ms
|
||||
usage["measured_completion_tokens"] = completion
|
||||
if response.ttft_ms is not None:
|
||||
usage["ttft_ms"] = response.ttft_ms
|
||||
usage["timed_requests"] = 1
|
||||
return usage
|
||||
return usage.with_timing(
|
||||
generation_ms=response.generation_ms,
|
||||
ttft_ms=response.ttft_ms,
|
||||
)
|
||||
|
||||
def _estimate_response_usage(
|
||||
self,
|
||||
spec: AgentRunSpec,
|
||||
messages: list[dict[str, Any]],
|
||||
response: LLMResponse,
|
||||
) -> dict[str, int]:
|
||||
) -> LLMUsage:
|
||||
try:
|
||||
tools = spec.tools.get_definitions()
|
||||
except Exception:
|
||||
@@ -1391,52 +1385,21 @@ class AgentRunner:
|
||||
thinking_blocks=response.thinking_blocks,
|
||||
)
|
||||
completion_tokens = estimate_message_tokens(assistant_message)
|
||||
total_tokens = max(0, prompt_tokens) + max(0, completion_tokens)
|
||||
if total_tokens <= 0:
|
||||
return {}
|
||||
return {
|
||||
"prompt_tokens": max(0, prompt_tokens),
|
||||
"completion_tokens": max(0, completion_tokens),
|
||||
"total_tokens": total_tokens,
|
||||
"estimated_tokens": total_tokens,
|
||||
}
|
||||
return LLMUsage.estimated(
|
||||
input_tokens=max(0, prompt_tokens),
|
||||
output_tokens=max(0, completion_tokens),
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _usage_dict(usage: dict[str, Any] | None) -> dict[str, int]:
|
||||
if not usage:
|
||||
return {}
|
||||
result: dict[str, int] = {}
|
||||
for key, value in usage.items():
|
||||
try:
|
||||
result[key] = int(value or 0)
|
||||
except (TypeError, ValueError):
|
||||
continue
|
||||
return result
|
||||
|
||||
@staticmethod
|
||||
def _usage_total(usage: dict[str, int]) -> int:
|
||||
return max(0, usage.get("total_tokens", 0) or (
|
||||
usage.get("prompt_tokens", 0) + usage.get("completion_tokens", 0)
|
||||
))
|
||||
|
||||
@staticmethod
|
||||
def _merge_usage(left: dict[str, int], right: dict[str, int]) -> dict[str, int]:
|
||||
merged = dict(left)
|
||||
for key, value in right.items():
|
||||
merged[key] = merged.get(key, 0) + value
|
||||
return merged
|
||||
|
||||
@staticmethod
|
||||
def _accumulate_usage(total: dict[str, int], request: dict[str, int]) -> None:
|
||||
"""Fold one model request into the current turn's usage."""
|
||||
total["request_count"] = total.get("request_count", 0) + 1
|
||||
prompt_tokens = request.get("prompt_tokens")
|
||||
if prompt_tokens is not None and prompt_tokens >= 0:
|
||||
total["context_tokens"] = prompt_tokens
|
||||
for key, value in request.items():
|
||||
if key in {"context_tokens", "request_count"} or value < 0:
|
||||
continue
|
||||
total[key] = total.get(key, 0) + value
|
||||
def _merge_usage(
|
||||
left: LLMUsage | None,
|
||||
right: LLMUsage | None,
|
||||
) -> LLMUsage | None:
|
||||
if left is None:
|
||||
return right
|
||||
if right is None:
|
||||
return left
|
||||
return left + right
|
||||
|
||||
async def _execute_tools(
|
||||
self,
|
||||
|
||||
@@ -28,7 +28,7 @@ from nanobot.agent.tools.registry import ToolRegistry
|
||||
from nanobot.bus.events import InboundMessage
|
||||
from nanobot.bus.queue import MessageBus
|
||||
from nanobot.config.schema import AgentDefaults, ToolsConfig
|
||||
from nanobot.providers.base import LLMProvider
|
||||
from nanobot.providers.base import LLMProvider, LLMUsage
|
||||
from nanobot.security.workspace_access import (
|
||||
WorkspaceScope,
|
||||
bind_workspace_scope,
|
||||
@@ -56,7 +56,7 @@ class SubagentStatus:
|
||||
phase: str = "initializing" # initializing | awaiting_tools | tools_completed | final_response | done | error
|
||||
iteration: int = 0
|
||||
tool_events: list[dict[str, str]] = field(default_factory=list)
|
||||
usage: dict[str, int] = field(default_factory=dict)
|
||||
usage: LLMUsage | None = None
|
||||
stop_reason: str | None = None
|
||||
error: str | None = None
|
||||
|
||||
@@ -82,7 +82,7 @@ class _SubagentHook(AgentHook):
|
||||
return
|
||||
self._status.iteration = context.iteration
|
||||
self._status.tool_events = list(context.tool_events)
|
||||
self._status.usage = dict(context.usage)
|
||||
self._status.usage = context.usage
|
||||
if context.error:
|
||||
self._status.error = str(context.error)
|
||||
|
||||
|
||||
@@ -12,6 +12,7 @@ if TYPE_CHECKING:
|
||||
from nanobot.agent.tools.shell import ExecToolConfig
|
||||
from nanobot.agent.tools.web import WebToolsConfig
|
||||
from nanobot.config.schema import ModelPresetConfig
|
||||
from nanobot.providers.base import LLMUsage
|
||||
from nanobot.utils.llm_runtime import LLMRuntime
|
||||
|
||||
|
||||
@@ -65,7 +66,7 @@ class RuntimeSnapshot:
|
||||
web_config: dict[str, object]
|
||||
exec_config: dict[str, object]
|
||||
subagent_statuses: dict[str, dict[str, object]]
|
||||
last_usage: dict[str, int]
|
||||
last_usage: Mapping[str, JsonScalar]
|
||||
scratchpad: dict[str, JsonValue]
|
||||
|
||||
def as_mapping(self) -> Mapping[str, object]:
|
||||
@@ -151,7 +152,7 @@ class _RuntimeControlTarget(Protocol):
|
||||
def tool_names(self) -> list[str]: ...
|
||||
|
||||
@property
|
||||
def last_usage(self) -> Mapping[str, int]: ...
|
||||
def last_usage(self) -> LLMUsage | None: ...
|
||||
|
||||
def set_runtime_model(self, model: str) -> LLMRuntime: ...
|
||||
|
||||
@@ -190,7 +191,7 @@ class AgentRuntimeControl:
|
||||
web_config=_snapshot_web_config(target.web_config),
|
||||
exec_config=_snapshot_exec_config(target.exec_config),
|
||||
subagent_statuses=_snapshot_subagent_statuses(target.subagents),
|
||||
last_usage=dict(target.last_usage),
|
||||
last_usage=target.last_usage.to_dict() if target.last_usage is not None else {},
|
||||
scratchpad=_snapshot_json_mapping(self.__scratchpad),
|
||||
)
|
||||
|
||||
@@ -297,7 +298,7 @@ def _snapshot_subagent_status(status: SubagentStatus) -> dict[str, object]:
|
||||
"phase": status.phase,
|
||||
"iteration": status.iteration,
|
||||
"tool_events": [dict(event) for event in status.tool_events],
|
||||
"usage": dict(status.usage),
|
||||
"usage": status.usage.to_dict() if status.usage is not None else None,
|
||||
"stop_reason": status.stop_reason,
|
||||
"error": status.error,
|
||||
}
|
||||
|
||||
@@ -150,7 +150,7 @@ class MyTool(Tool):
|
||||
"Actions: check, set.\n"
|
||||
"- check (no key): full config overview — start here.\n"
|
||||
"- check (key): drill into a value. Dot-paths allowed "
|
||||
"(e.g. '_last_usage.prompt_tokens', 'web_config.enable').\n"
|
||||
"(e.g. '_last_usage.input_tokens', 'web_config.enable').\n"
|
||||
"- set (key, value): change config or store notes in your scratchpad. "
|
||||
"Scratchpad keys persist across turns but not restarts.\n"
|
||||
"Key values: _current_iteration (current progress), "
|
||||
|
||||
@@ -4,7 +4,7 @@ from __future__ import annotations
|
||||
|
||||
import dataclasses
|
||||
import time
|
||||
from collections.abc import Awaitable, Callable, Mapping
|
||||
from collections.abc import Awaitable, Callable
|
||||
from dataclasses import dataclass, field
|
||||
from typing import TYPE_CHECKING, Any, cast
|
||||
|
||||
@@ -19,6 +19,7 @@ from nanobot.bus.outbound_events import (
|
||||
from nanobot.bus.progress import build_bus_progress_callback
|
||||
from nanobot.bus.queue import MessageBus
|
||||
from nanobot.bus.runtime_events import RuntimeEventBus, RuntimeEventPublisher
|
||||
from nanobot.providers.base import LLMUsage
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from nanobot.utils.llm_runtime import LLMRuntime
|
||||
@@ -203,7 +204,7 @@ class TurnDelivery:
|
||||
def record_latency(self, latency_ms: int | None) -> None:
|
||||
self.runtime_event_publisher.record_turn_latency(self.session_key, latency_ms)
|
||||
|
||||
def record_usage(self, usage: Mapping[str, int]) -> None:
|
||||
def record_usage(self, usage: LLMUsage | None) -> None:
|
||||
self.runtime_event_publisher.record_turn_usage(self.session_key, usage)
|
||||
|
||||
def background_response(
|
||||
|
||||
Reference in New Issue
Block a user