Experimental Research fork — APIs and behaviour may change without notice. Read disclaimer →

Status: Layer 1 implemented, Layers 2-3 spec Date: 2026-02-25 (spec), 2026-03-03 (Layer 1 implemented)


Problem

Token counts and costs are extracted from LLM API responses by the LiteLLM provider but discarded in the agent loop. The infrastructure exists at both ends — LLMResponse carries prompt_tokens, completion_tokens, total_tokens, cost_usd, and ToolCallLogger has record_turn_usage() ready to persist them — but nothing connects the two. process_direct() returns a bare str, so callers (CLI, eval runner, daemon) have no visibility into resource consumption.

There's also a Tracer class at picobot/agent/trace.py with per-turn TurnTrace dataclass (prompt_tokens, completion_tokens, cost_usd, llm_latency_ms, per-tool-call timing) and JSONL output to ~/.picobot/data/traces/traces.jsonl — but it's imported nowhere. This is a third piece of disconnected plumbing that should be wired alongside the ToolCallLogger.

This blocks three things: 1. Eval framework — can't measure tokens/cost per task without metrics from the loop 2. Session cost monitoring — users can't see what a session cost 3. Agent/sub-agent budgets — can't enforce token limits on tasks or sub-agents


Current State

What works

Component Location Status
LiteLLM extracts prompt_tokens, completion_tokens, cost_usd providers/litellm_provider.py:98-130 Working
LLMResponse dataclass carries token/cost fields providers/base.py:18-28 Working
ToolCallLogger.record_turn_usage() persists to JSONL workspace/tracker.py:54-71 Exists, never called
ToolCallLogger.session_totals() returns cumulative counts workspace/tracker.py:73-80 Exists, never called
ToolCallLogger.write_session_end() includes token summary workspace/tracker.py:82-96 Writes zeros (no data fed in)
TokenBudget soft compression at 80% of context window agent/budget.py:40-47 Working, but uses chars/4 heuristic

What's fixed (Layer 1 — implemented 2026-03-03)

Fix Location Details
process_direct() calls record_turn_usage() after each LLM call agent/loop.py:493 Token data flows to logger
process_direct() returns AgentResult with full metrics agent/result.py + agent/loop.py:632 Callers get tokens, cost, iterations, elapsed time
Sub-agent tokens tracked and accumulated agent/subagent.py _subagent_tokens Included in AgentResult.total_tokens and exposed via .subagent_tokens

What's still open

Gap Location Impact
TokenBudget ignores real token counts from API agent/budget.py:19-38 Compression based on guess, not reality
Streaming responses don't capture usage agent/loop.py _call_llm_stream path Tokens unavailable in stream mode
No per-task or per-agent budget limits — Can't constrain resource use

Design

Three layers, each building on the previous.

Layer 1: Wire Token Tracking (prerequisite for everything)

Connect the existing plumbing so token data flows from LLM responses through to the log and callers.

1a. Record turn usage in the loop — DONE

Implemented at agent/loop.py:493. After each _call_llm() call, record_turn_usage() feeds real token counts to the logger:

response = await self._call_llm(messages, _log_event if on_event else None)
tc_logger.record_turn_usage(
    response.prompt_tokens, response.completion_tokens,
    response.total_tokens, response.cost_usd,
)

Streaming gap: _call_llm_stream() still returns zero tokens. Options remain: - Option B (recommended): Parse the final stream chunk for usage data (LiteLLM stream_options={"include_usage": True}). Zero overhead, provider-dependent. - Option C (fallback): Fall back to chars/4 estimate for streamed responses.

1b. Enrich process_direct() return type — DONE

Implemented at agent/result.py. process_direct() returns AgentResult:

@dataclass
class AgentResult:
    response: str
    prompt_tokens: int = 0
    completion_tokens: int = 0
    total_tokens: int = 0
    cost_usd: float = 0.0
    tool_calls: int = 0
    failed_tool_calls: int = 0
    iterations: int = 0
    elapsed_seconds: float = 0.0
    tool_call_log: list[dict] = field(default_factory=list)
    subagent_tokens: int = 0

__str__ returns self.response for backwards compatibility. Populated from tc_logger.session_totals() + wall-clock timer + subagents._subagent_tokens at the end of process_direct() (loop.py:630-643).

1c. Feed real counts into TokenBudget

After each LLM call, update a running total of actual tokens consumed. Use this to inform compression decisions instead of (or alongside) the chars/4 heuristic.

# In the loop, after each LLM call:
self.budget.record_actual(response.total_tokens)

# In TokenBudget:
def record_actual(self, tokens: int) -> None:
    self._actual_total += tokens

def should_compress(self, messages):
    # Use actual count if available, fall back to estimate
    estimated = self._actual_total or self.estimate_tokens(messages)
    return estimated > int(self.budget * 0.8)

Note: _actual_total tracks cumulative tokens sent to the API across all turns, not the current message list size. The chars/4 estimate of the current message list is still useful for deciding when to compress — actual API token counts tell you what you've spent, but the message list estimate tells you what the next call will cost. Keep both. Use the estimate for compression decisions, actual counts for reporting.

Layer 2: Session Cost Monitoring

Surface token/cost data to users during and after sessions.

2a. Session summary on exit

When a session ends (CLI exit, daemon session close), print a summary:

Session: 14 turns, 23 tool calls
Tokens: 45,231 in / 12,847 out (58,078 total)
Cost: $0.087

Data source: tc_logger.session_totals() (already accumulated if Layer 1 is wired).

2b. JSONL log enrichment

write_session_end() already writes token totals — once Layer 1 feeds real data in, the JSONL logs become a complete cost record with no additional work. Structure already defined:

{
  "type": "session_end",
  "tokens": {"prompt": N, "completion": N, "total": N, "cost_usd": X},
  "turns": N,
  "tool_calls": N
}

2c. Cost dashboard (optional, lower priority)

Extend the TUI dashboard or add a CLI command (picobot cost [--since 7d]) that reads JSONL logs and aggregates: - Cost per session - Cost per day/week - Cost by model - Running total

This is a read-only view over existing log files — no new data collection needed once Layer 1 is in place.

Layer 3: Budget Enforcement

Allow setting token/cost limits on agents and sub-agents, with configurable enforcement (warn, soft-stop, hard-stop).

3a. Config schema additions

class BudgetConfig(BaseModel):
    """Token/cost budget for an agent."""
    max_tokens: int | None = None       # Total tokens (in+out) before action
    max_cost_usd: float | None = None   # Dollar cost before action
    action: str = "warn"                # "warn" | "stop" | "none"

class AgentDefaults(BaseModel):
    # ... existing fields ...
    budget: BudgetConfig = Field(default_factory=BudgetConfig)

class SubagentConfig(BaseModel):
    """Sub-agent specific limits."""
    max_iterations: int = 15            # Already exists (hardcoded)
    budget: BudgetConfig = Field(default_factory=BudgetConfig)

3b. Budget checking in the loop

After each LLM call in process_direct(), check against the budget:

totals = tc_logger.session_totals()
if self.budget_config.max_tokens and totals["total_tokens"] > self.budget_config.max_tokens:
    if self.budget_config.action == "stop":
        final_content = f"Token budget exceeded ({totals['total_tokens']} / {self.budget_config.max_tokens}). Stopping."
        break
    elif self.budget_config.action == "warn":
        logger.warning(f"Token budget exceeded: {totals['total_tokens']} / {self.budget_config.max_tokens}")
        # Continue but warn — agent sees this in next system message

Same pattern for max_cost_usd.

3c. Sub-agent budgets — PARTIALLY DONE

Token tracking (done): Sub-agent provider.chat() responses are accumulated in SubagentManager._subagent_tokens. This total is added to AgentResult.total_tokens and exposed separately via AgentResult.subagent_tokens. The eval runner and CLI get honest totals including sub-agent work.

Budget enforcement (not yet done):

  1. Pass a BudgetConfig to SubagentManager.spawn() — caller specifies per-task limits
  2. Inside _run_subagent(), create a local ToolCallLogger and check budget after each LLM call
  3. Default sub-agent budget: inherit from parent config's subagent.budget, overridable per-spawn
  4. Surface sub-agent cost in the registry entry so parent sees what each sub-agent spent
# Future: per-sub-agent cost in registry entry
{
    "id": "abc123",
    "status": "completed",
    "result": "...",
    "tokens": {"prompt": N, "completion": N, "total": N, "cost_usd": X}
}

3d. Eval framework integration

The eval runner uses budgets differently from interactive sessions:

  • Observe, don't enforce: Default for evals is action: "none" — record tokens/cost but don't stop the agent. The eval framework compares actual usage against expected budgets in post-processing.
  • Hard limits for runaway prevention: Set a generous max (e.g., 500k tokens) as a safety net to kill tasks that enter infinite loops, not as a capability test.
  • Budget as a metric, not a gate: The eval results JSON includes token/cost data per task. Whether the agent was "efficient" is a separate analysis from whether it succeeded.

Implementation Order

Step Layer Effort Status
Wire record_turn_usage() in loop 1a ~10 lines Done
AgentResult return type 1b ~50 lines + caller updates Done
Sub-agent token tracking 3c ~20 lines Done
Streaming token capture 1a ~20 lines Open
Real counts in TokenBudget 1c ~15 lines Open
Session summary on exit 2a ~15 lines Open
Cost CLI command 2c ~100 lines Open
BudgetConfig in schema 3a ~20 lines Open
Budget checking in loop 3b ~30 lines Open
Sub-agent budget enforcement 3c ~30 lines Open

Layer 1 (1a + 1b) is done. Sub-agent token tracking (3c partial) is done. Remaining items are incremental.


Callers Affected by AgentResult Change

Caller File Usage Status
CLI agent command cli/commands.py print(result) Works via __str__
CLI message flag cli/commands.py print(result) Works via __str__
Daemon message handler daemon/server.py Returns result as string Works via __str__
Sub-agent result storage agent/subagent.py Calls provider.chat() directly N/A (doesn't use process_direct)
Eval runner eval/runner.py Accesses all metric fields Primary consumer — total_tokens, iterations, elapsed_seconds, subagent_tokens

The __str__ fallback means no caller broke during the migration. The eval runner is the primary consumer of metrics.