repopilot-agent 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (48) hide show
  1. repopilot/__init__.py +3 -0
  2. repopilot/__main__.py +3 -0
  3. repopilot/agent/__init__.py +0 -0
  4. repopilot/agent/compact.py +192 -0
  5. repopilot/agent/context.py +236 -0
  6. repopilot/agent/cost.py +119 -0
  7. repopilot/agent/loop.py +387 -0
  8. repopilot/agent/parser.py +152 -0
  9. repopilot/cli.py +396 -0
  10. repopilot/code_index/__init__.py +8 -0
  11. repopilot/code_index/ignore.py +121 -0
  12. repopilot/code_index/repo_map.py +81 -0
  13. repopilot/code_index/symbol_index.py +177 -0
  14. repopilot/config.py +235 -0
  15. repopilot/hooks/__init__.py +9 -0
  16. repopilot/hooks/builtin.py +57 -0
  17. repopilot/hooks/manager.py +102 -0
  18. repopilot/llm/__init__.py +10 -0
  19. repopilot/llm/circuit_breaker.py +76 -0
  20. repopilot/llm/service.py +224 -0
  21. repopilot/llm/stream_handler.py +54 -0
  22. repopilot/logging_setup.py +49 -0
  23. repopilot/memory/__init__.py +131 -0
  24. repopilot/permission/__init__.py +21 -0
  25. repopilot/permission/approver.py +108 -0
  26. repopilot/permission/engine.py +201 -0
  27. repopilot/permission/patterns.py +325 -0
  28. repopilot/repl.py +522 -0
  29. repopilot/sandbox/__init__.py +6 -0
  30. repopilot/sandbox/base.py +158 -0
  31. repopilot/sandbox/docker_sandbox.py +389 -0
  32. repopilot/sandbox/local_sandbox.py +278 -0
  33. repopilot/session/__init__.py +3 -0
  34. repopilot/session/store.py +292 -0
  35. repopilot/tools/__init__.py +34 -0
  36. repopilot/tools/base.py +69 -0
  37. repopilot/tools/exec_tools.py +193 -0
  38. repopilot/tools/file_tools.py +170 -0
  39. repopilot/tools/meta_tools.py +41 -0
  40. repopilot/tools/registry.py +104 -0
  41. repopilot/tools/result.py +61 -0
  42. repopilot/tools/search_tools.py +221 -0
  43. repopilot_agent-0.1.0.dist-info/METADATA +272 -0
  44. repopilot_agent-0.1.0.dist-info/RECORD +48 -0
  45. repopilot_agent-0.1.0.dist-info/WHEEL +5 -0
  46. repopilot_agent-0.1.0.dist-info/entry_points.txt +2 -0
  47. repopilot_agent-0.1.0.dist-info/licenses/LICENSE +21 -0
  48. repopilot_agent-0.1.0.dist-info/top_level.txt +1 -0
repopilot/__init__.py ADDED
@@ -0,0 +1,3 @@
1
+ """RepoPilot — Local-first code agent."""
2
+ from __future__ import annotations
3
+ __version__ = "0.1.0"
repopilot/__main__.py ADDED
@@ -0,0 +1,3 @@
1
+ """Allow running RepoPilot with: python -m repopilot"""
2
+ from repopilot.cli import app
3
+ app()
File without changes
@@ -0,0 +1,192 @@
1
+ """Three-level context compaction for long agent sessions.
2
+
3
+ Levels:
4
+ - tool_compact: Rule-based truncation of tool outputs (no LLM call)
5
+ - micro_compact: Use fast LLM to summarize oldest 3-5 steps into 1-2 sentences
6
+ - auto_compact: Use fast LLM to summarize everything except recent K steps into
7
+ a structured summary (completed actions / key findings / file locations / unresolved issues)
8
+
9
+ Token counting uses the approximation len(text) // 4 (works for mixed Chinese/English/code).
10
+ When real token counts are available from LLM usage responses, call update_actual_usage()
11
+ to calibrate.
12
+ """
13
+ from __future__ import annotations
14
+
15
+ from dataclasses import dataclass, field
16
+ from typing import Any, Optional, Protocol, TYPE_CHECKING
17
+
18
+ if TYPE_CHECKING:
19
+ pass
20
+
21
+ CHARS_PER_TOKEN = 4
22
+
23
+
24
+ def estimate_tokens(text: str) -> int:
25
+ """Rough token estimate: ~4 chars per token for code/English/mixed."""
26
+ if not text:
27
+ return 0
28
+ return max(1, len(text) // CHARS_PER_TOKEN)
29
+
30
+
31
+ def tool_compact(text: str, max_chars: int = 12000, max_lines: int = 200) -> str:
32
+ """Rule-based truncation for tool output. No LLM call.
33
+
34
+ - If output fits, return as-is.
35
+ - Otherwise keep head + tail with a truncation notice in between.
36
+ - Always cap total lines to max_lines.
37
+ """
38
+ if not text:
39
+ return ""
40
+ if len(text) <= max_chars and text.count("\n") <= max_lines:
41
+ return text
42
+
43
+ lines = text.split("\n")
44
+ if len(lines) > max_lines:
45
+ head = lines[: max_lines // 2]
46
+ tail = lines[-max_lines // 4 :]
47
+ omitted_lines = len(lines) - len(head) - len(tail)
48
+ text = "\n".join(head) + f"\n\n... ({omitted_lines} lines omitted due to output limit) ...\n\n" + "\n".join(tail)
49
+
50
+ if len(text) > max_chars:
51
+ head_chars = max_chars * 2 // 3
52
+ tail_chars = max_chars // 6
53
+ omitted = len(text) - head_chars - tail_chars
54
+ text = text[:head_chars] + f"\n\n... ({omitted} chars omitted) ...\n\n" + text[-tail_chars:]
55
+
56
+ return text
57
+
58
+
59
+ @dataclass
60
+ class CompactResult:
61
+ """Result of a compaction operation."""
62
+ summary: str
63
+ steps_compacted: int
64
+ tokens_saved: int
65
+
66
+
67
+ class LLMProtocol(Protocol):
68
+ """Protocol for LLM service (avoids circular import)."""
69
+ def chat_fast(self, messages: list[dict], **kwargs) -> str: ...
70
+
71
+
72
+ MICRO_COMPACT_PROMPT = """\
73
+ You are summarizing an old segment of a coding agent's conversation history.
74
+ Below are {n_steps} steps (user messages, assistant responses, and tool results).
75
+ Summarize them in 1-2 concise sentences, preserving:
76
+ - What was attempted
77
+ - Key outcomes or errors
78
+ - Any file paths or symbols that matter
79
+
80
+ Keep the summary under 80 words. Do NOT add speculation. Output only the summary.
81
+
82
+ Steps to summarize:
83
+ {steps_text}
84
+ """
85
+
86
+ AUTO_COMPACT_PROMPT = """\
87
+ You are compressing a long coding agent conversation to fit within the context window.
88
+ Below is the conversation history (excluding the most recent {keep_recent} steps which are kept verbatim).
89
+ Produce a structured summary with exactly these sections:
90
+
91
+ ## Completed Actions
92
+ - List concrete actions taken (files read/written/edited, commands run, tests executed)
93
+
94
+ ## Key Findings
95
+ - Important discoveries: bugs found, code patterns identified, test failures observed
96
+
97
+ ## File Locations
98
+ - Files and line numbers that were relevant (e.g., src/main.py:42)
99
+
100
+ ## Unresolved
101
+ - Issues not yet addressed, errors still failing, next steps needed
102
+
103
+ Keep each bullet point under 20 words. Be specific (use actual filenames/symbols from the text).
104
+ Do NOT include information not present in the conversation. Total summary should be under 400 words.
105
+
106
+ Conversation to summarize:
107
+ {steps_text}
108
+ """
109
+
110
+
111
+ def _format_steps_for_summary(steps: list[dict]) -> str:
112
+ """Format a list of step dicts into text for LLM summarization."""
113
+ parts = []
114
+ for i, step in enumerate(steps, 1):
115
+ role = step.get("role", step.get("type", "unknown"))
116
+ content = step.get("content", step.get("text", ""))
117
+ if isinstance(content, list):
118
+ content_parts = []
119
+ for c in content:
120
+ if isinstance(c, dict):
121
+ content_parts.append(c.get("text", c.get("content", str(c))))
122
+ else:
123
+ content_parts.append(str(c))
124
+ content = "\n".join(content_parts)
125
+ parts.append(f"[{i}] {role}: {content}")
126
+ return "\n\n".join(parts)
127
+
128
+
129
+ def micro_compact(steps: list[dict], llm: Any) -> CompactResult:
130
+ """Use fast LLM to summarize the oldest 3-5 steps into 1-2 sentences.
131
+
132
+ `steps` is a list of message dicts (role/content pairs).
133
+ `llm` must have a `chat_fast(messages) -> str` method.
134
+ """
135
+ if not steps:
136
+ return CompactResult(summary="", steps_compacted=0, tokens_saved=0)
137
+
138
+ steps_text = _format_steps_for_summary(steps)
139
+ original_tokens = estimate_tokens(steps_text)
140
+
141
+ prompt = MICRO_COMPACT_PROMPT.format(n_steps=len(steps), steps_text=steps_text)
142
+ try:
143
+ from repopilot.llm.service import Tier
144
+ resp = llm.chat([
145
+ {"role": "system", "content": "You are a concise summarizer."},
146
+ {"role": "user", "content": prompt},
147
+ ], tier=Tier.FAST)
148
+ summary = resp.content
149
+ except Exception:
150
+ summary = f"[{len(steps)} earlier steps summarized but LLM unavailable]"
151
+
152
+ summary_tokens = estimate_tokens(summary)
153
+ return CompactResult(
154
+ summary=summary.strip(),
155
+ steps_compacted=len(steps),
156
+ tokens_saved=max(0, original_tokens - summary_tokens),
157
+ )
158
+
159
+
160
+ def auto_compact(steps: list[dict], llm: Any, keep_recent: int = 10) -> CompactResult:
161
+ """Use fast LLM to produce a structured summary of older steps.
162
+
163
+ Steps[-keep_recent:] are NOT included (caller keeps those verbatim).
164
+ Everything in steps[:-keep_recent] is summarized.
165
+ """
166
+ if not steps:
167
+ return CompactResult(summary="", steps_compacted=0, tokens_saved=0)
168
+
169
+ to_compact = steps[:-keep_recent] if len(steps) > keep_recent else steps
170
+ if not to_compact:
171
+ return CompactResult(summary="", steps_compacted=0, tokens_saved=0)
172
+
173
+ steps_text = _format_steps_for_summary(to_compact)
174
+ original_tokens = estimate_tokens(steps_text)
175
+
176
+ prompt = AUTO_COMPACT_PROMPT.format(keep_recent=keep_recent, steps_text=steps_text)
177
+ try:
178
+ from repopilot.llm.service import Tier
179
+ resp = llm.chat([
180
+ {"role": "system", "content": "You are a precise engineering summarizer."},
181
+ {"role": "user", "content": prompt},
182
+ ], tier=Tier.FAST)
183
+ summary = resp.content
184
+ except Exception:
185
+ summary = f"## Summary\n[{len(to_compact)} earlier steps - LLM unavailable for detailed summary]"
186
+
187
+ summary_tokens = estimate_tokens(summary)
188
+ return CompactResult(
189
+ summary=summary.strip(),
190
+ steps_compacted=len(to_compact),
191
+ tokens_saved=max(0, original_tokens - summary_tokens),
192
+ )
@@ -0,0 +1,236 @@
1
+ """Context Manager: builds LLM message arrays with layered context and automatic compaction.
2
+
3
+ Message assembly order (top to bottom = first to last in messages array):
4
+ 1. System prompt (L0): fixed instructions ~800 tokens
5
+ 2. Project context (L0.5): REPOPILOT.md content (if exists), repo map, memory, injected skills
6
+ 3. Plan segment: current plan from planner
7
+ 4. Compacted summary (L2): result of micro/auto compact (when triggered)
8
+ 5. Recent steps (L1): verbatim recent messages, fit within remaining budget
9
+ 6. Pending tool result: last tool output (already tool-compacted)
10
+
11
+ Token budget:
12
+ - system + project context ~5000 tokens
13
+ - plan + summary ~1000-2000 tokens
14
+ - recent steps fill the remainder up to budget_tokens
15
+ - tool-compact always applied to tool outputs before storing
16
+
17
+ When token_usage_ratio() exceeds thresholds:
18
+ - 0.75+: trigger micro_compact (summarize oldest 3-5 steps)
19
+ - 0.90+: trigger auto_compact (summarize all but last K steps)
20
+ """
21
+ from __future__ import annotations
22
+
23
+ from dataclasses import dataclass, field
24
+ from typing import Any, Optional
25
+
26
+ from repopilot.agent.compact import (
27
+ estimate_tokens,
28
+ tool_compact,
29
+ micro_compact,
30
+ auto_compact,
31
+ CompactResult,
32
+ )
33
+
34
+ # Default allocation (tokens)
35
+ SYSTEM_RESERVE = 1000
36
+ PROJECT_RESERVE = 5000
37
+ PLAN_RESERVE = 1000
38
+ SUMMARY_RESERVE = 2000
39
+ RECENT_STEPS_KEEP = 10
40
+ MICRO_COMPACT_THRESHOLD = 0.75
41
+ AUTO_COMPACT_THRESHOLD = 0.90
42
+ TOOL_OUTPUT_MAX_CHARS = 12000
43
+ TOOL_OUTPUT_MAX_LINES = 200
44
+
45
+
46
+ @dataclass
47
+ class Step:
48
+ """A single conversation turn or tool interaction."""
49
+ role: str # "user" | "assistant" | "tool" | "plan" | "observation"
50
+ content: str
51
+ tool_calls: Optional[list[dict]] = None
52
+ tool_call_id: Optional[str] = None
53
+ step_type: str = "message" # "message" | "tool_result" | "plan" | "observation"
54
+ tokens: int = 0
55
+
56
+ def __post_init__(self):
57
+ if self.tokens == 0:
58
+ self.tokens = estimate_tokens(self.content) + (
59
+ 30 * (len(self.tool_calls) if self.tool_calls else 0)
60
+ )
61
+
62
+ def to_message(self) -> dict:
63
+ """Convert to OpenAI-style message dict."""
64
+ msg: dict[str, Any] = {"role": self.role, "content": self.content}
65
+ if self.tool_calls:
66
+ msg["tool_calls"] = self.tool_calls
67
+ if self.tool_call_id:
68
+ msg["tool_call_id"] = self.tool_call_id
69
+ return msg
70
+
71
+
72
+ @dataclass
73
+ class ContextManager:
74
+ """Manages conversation context with layered assembly and automatic compaction."""
75
+
76
+ budget_tokens: int = 120000
77
+ system_prompt: str = ""
78
+ repo_map_str: str = ""
79
+ memory_str: str = ""
80
+ injected_skills: str = ""
81
+ plan: str = ""
82
+ summary: str = ""
83
+ steps: list[Step] = field(default_factory=list)
84
+ pending_tool_result: Optional[Step] = None
85
+ _actual_usage_calibration: float = 1.0
86
+
87
+ def set_plan(self, plan: str) -> None:
88
+ self.plan = plan
89
+
90
+ def add_observation(self, text: str) -> None:
91
+ self.steps.append(Step(role="system", content=text, step_type="observation"))
92
+
93
+ def add_assistant(self, text: str, tool_calls: Optional[list[dict]] = None) -> None:
94
+ content = text or ""
95
+ self.steps.append(Step(role="assistant", content=content, tool_calls=tool_calls))
96
+
97
+ def add_user(self, text: str) -> None:
98
+ self.steps.append(Step(role="user", content=text))
99
+
100
+ def add_tool_result(self, tool_call_id: str, content: str, is_error: bool = False) -> None:
101
+ compacted = tool_compact(content, TOOL_OUTPUT_MAX_CHARS, TOOL_OUTPUT_MAX_LINES)
102
+ prefix = "Error: " if is_error else ""
103
+ self.steps.append(Step(
104
+ role="tool",
105
+ content=prefix + compacted,
106
+ tool_call_id=tool_call_id,
107
+ step_type="tool_result",
108
+ ))
109
+
110
+ def inject_skill_prompt(self, text: str) -> None:
111
+ if text and text not in self.injected_skills:
112
+ self.injected_skills += ("\n" + text if self.injected_skills else text)
113
+
114
+ def recent_steps(self, n: int = 20) -> list[dict]:
115
+ return [s.to_message() for s in self.steps[-n:]]
116
+
117
+ def token_usage_ratio(self) -> float:
118
+ used = self._current_total_tokens()
119
+ return used / self.budget_tokens if self.budget_tokens > 0 else 1.0
120
+
121
+ def _system_tokens(self) -> int:
122
+ return estimate_tokens(self.system_prompt) + 200 # small overhead for message structure
123
+
124
+ def _project_tokens(self) -> int:
125
+ parts = [self.repo_map_str, self.memory_str, self.injected_skills]
126
+ return sum(estimate_tokens(p) for p in parts)
127
+
128
+ def _plan_tokens(self) -> int:
129
+ return estimate_tokens(self.plan) if self.plan else 0
130
+
131
+ def _summary_tokens(self) -> int:
132
+ return estimate_tokens(self.summary) if self.summary else 0
133
+
134
+ def _current_total_tokens(self) -> int:
135
+ steps_tokens = sum(s.tokens for s in self.steps)
136
+ return (self._system_tokens() + self._project_tokens() +
137
+ self._plan_tokens() + self._summary_tokens() + steps_tokens)
138
+
139
+ def build_messages(self, task: Optional[str] = None) -> list[dict]:
140
+ """Assemble the full message array for the LLM.
141
+
142
+ Order: system → project context → plan → summary → recent steps → pending tool → (optional task).
143
+ """
144
+ messages: list[dict] = []
145
+
146
+ # L0: system prompt
147
+ system_parts = []
148
+ if self.system_prompt:
149
+ system_parts.append(self.system_prompt)
150
+ messages.append({"role": "system", "content": "\n\n".join(system_parts) if system_parts else "You are a helpful coding assistant."})
151
+
152
+ # L0.5: project context
153
+ project_parts = []
154
+ if self.repo_map_str:
155
+ project_parts.append(f"## Repository Structure\n{self.repo_map_str}")
156
+ if self.memory_str:
157
+ project_parts.append(f"## Memory / Notes\n{self.memory_str}")
158
+ if self.injected_skills:
159
+ project_parts.append(f"## Available Skills\n{self.injected_skills}")
160
+ if project_parts:
161
+ messages.append({"role": "system", "content": "\n\n".join(project_parts)})
162
+
163
+ # Plan
164
+ if self.plan:
165
+ messages.append({"role": "system", "content": f"## Current Plan\n{self.plan}"})
166
+
167
+ # L2: compacted summary
168
+ if self.summary:
169
+ messages.append({"role": "system", "content": f"## Earlier Work Summary\n{self.summary}"})
170
+
171
+ # L1: recent steps (all steps fit within budget; compaction handles trimming)
172
+ remaining_budget = self.budget_tokens - (
173
+ self._system_tokens() + self._project_tokens() +
174
+ self._plan_tokens() + self._summary_tokens()
175
+ )
176
+ steps_to_include = self._select_steps_for_budget(remaining_budget)
177
+ for step in steps_to_include:
178
+ messages.append(step.to_message())
179
+
180
+ # Pending tool result
181
+ if self.pending_tool_result:
182
+ messages.append(self.pending_tool_result.to_message())
183
+
184
+ # Task as final user message
185
+ if task:
186
+ messages.append({"role": "user", "content": task})
187
+
188
+ return messages
189
+
190
+ def _select_steps_for_budget(self, budget: int) -> list[Step]:
191
+ """Select steps to include, preferring the most recent ones."""
192
+ if budget <= 0:
193
+ budget = 4000
194
+ included: list[Step] = []
195
+ used = 0
196
+ for step in reversed(self.steps):
197
+ if used + step.tokens > budget and included:
198
+ break
199
+ included.insert(0, step)
200
+ used += step.tokens
201
+ return included
202
+
203
+ def needs_compaction(self) -> Optional[str]:
204
+ """Return 'micro', 'auto', or None based on current token ratio."""
205
+ ratio = self.token_usage_ratio()
206
+ if ratio >= AUTO_COMPACT_THRESHOLD:
207
+ return "auto"
208
+ if ratio >= MICRO_COMPACT_THRESHOLD:
209
+ return "micro"
210
+ return None
211
+
212
+ def compact(self, level: str, llm: Any) -> CompactResult:
213
+ """Trigger a compaction at the given level ('micro' or 'auto')."""
214
+ if level == "micro":
215
+ to_compact = self.steps[:5] if len(self.steps) > RECENT_STEPS_KEEP else self.steps[:3]
216
+ result = micro_compact([s.to_message() for s in to_compact], llm)
217
+ if result.summary:
218
+ self.summary = (self.summary + "\n\n" if self.summary else "") + result.summary
219
+ self.steps = self.steps[len(to_compact):]
220
+ return result
221
+ elif level == "auto":
222
+ keep_recent = RECENT_STEPS_KEEP
223
+ steps_as_msgs = [s.to_message() for s in self.steps]
224
+ result = auto_compact(steps_as_msgs, llm, keep_recent=keep_recent)
225
+ if result.summary:
226
+ self.summary = result.summary
227
+ self.steps = self.steps[-keep_recent:] if len(self.steps) > keep_recent else self.steps
228
+ return result
229
+ else:
230
+ return CompactResult(summary="", steps_compacted=0, tokens_saved=0)
231
+
232
+ def update_actual_usage(self, prompt_tokens: int) -> None:
233
+ """Calibrate token estimates with real usage data from API response."""
234
+ estimated = self._current_total_tokens()
235
+ if estimated > 0:
236
+ self._actual_usage_calibration = prompt_tokens / estimated
@@ -0,0 +1,119 @@
1
+ """Cost tracker — accumulates token usage and estimated cost across an agent session."""
2
+ from __future__ import annotations
3
+ from dataclasses import dataclass, field
4
+ from typing import Optional
5
+
6
+
7
+ # Fallback per-token prices (USD per 1M tokens) when litellm.model_cost is not available.
8
+ # These are approximate list prices as of 2025 for common models.
9
+ _FALLBACK_PRICES = {
10
+ # Volcengine ARK / Doubao
11
+ "doubao-seed-evolving": {"input": 0.30, "output": 0.60},
12
+ "doubao-seed-2-1-turbo-260628": {"input": 0.15, "output": 0.30},
13
+ "doubao-seed-2-1-pro-260628": {"input": 0.50, "output": 2.00},
14
+ "doubao-seed-1-6-flash-250828": {"input": 0.03, "output": 0.06},
15
+ "doubao-seed-2-0-code-preview-260215": {"input": 0.20, "output": 0.60},
16
+ "doubao-seed-2-0-mini-260428": {"input": 0.05, "output": 0.10},
17
+ # Zhipu
18
+ "glm-4-7-251222": {"input": 0.50, "output": 1.50},
19
+ "glm-5-2-260617": {"input": 0.60, "output": 2.00},
20
+ # DeepSeek
21
+ "deepseek-v3-2-251201": {"input": 0.14, "output": 0.28},
22
+ }
23
+
24
+
25
+ def _lookup_price(model: str) -> tuple[float, float]:
26
+ """Return (input_price_per_1M, output_price_per_1M) for model, in USD.
27
+
28
+ Tries litellm.model_cost first; falls back to _FALLBACK_PRICES by
29
+ stripping provider prefix. Returns (0, 0) if unknown.
30
+ """
31
+ try:
32
+ import litellm
33
+ info = litellm.model_cost.get(model) or litellm.model_cost.get(model.split("/")[-1])
34
+ if info:
35
+ return (
36
+ float(info.get("input_cost_per_token", 0)) * 1_000_000,
37
+ float(info.get("output_cost_per_token", 0)) * 1_000_000,
38
+ )
39
+ except Exception:
40
+ pass
41
+ # Strip provider prefix ("openai/doubao-..." -> "doubao-...")
42
+ short = model.split("/")[-1]
43
+ if short in _FALLBACK_PRICES:
44
+ p = _FALLBACK_PRICES[short]
45
+ return p["input"], p["output"]
46
+ return 0.0, 0.0
47
+
48
+
49
+ @dataclass
50
+ class CostTracker:
51
+ """Accumulates token usage, cost estimates, and tool timing for one session."""
52
+
53
+ total_prompt_tokens: int = 0
54
+ total_completion_tokens: int = 0
55
+ total_cost_usd: float = 0.0
56
+ tool_calls: int = 0
57
+ tool_duration_ms: int = 0
58
+ llm_calls: int = 0
59
+ _per_model: dict[str, dict] = field(default_factory=dict, repr=False)
60
+
61
+ def on_llm_call(self, usage: dict, model: str) -> None:
62
+ """Record token usage from one LLM response."""
63
+ prompt_tok = int(usage.get("prompt_tokens", 0) or 0)
64
+ completion_tok = int(usage.get("completion_tokens", 0) or 0)
65
+ self.total_prompt_tokens += prompt_tok
66
+ self.total_completion_tokens += completion_tok
67
+ self.llm_calls += 1
68
+
69
+ in_price, out_price = _lookup_price(model)
70
+ call_cost = (prompt_tok / 1_000_000 * in_price +
71
+ completion_tok / 1_000_000 * out_price)
72
+ self.total_cost_usd += call_cost
73
+
74
+ # Per-model breakdown
75
+ if model not in self._per_model:
76
+ self._per_model[model] = {"prompt": 0, "completion": 0, "cost": 0.0, "calls": 0}
77
+ self._per_model[model]["prompt"] += prompt_tok
78
+ self._per_model[model]["completion"] += completion_tok
79
+ self._per_model[model]["cost"] += call_cost
80
+ self._per_model[model]["calls"] += 1
81
+
82
+ def on_tool_call(self, tool_name: str, duration_ms: int) -> None:
83
+ """Record a tool execution."""
84
+ self.tool_calls += 1
85
+ self.tool_duration_ms += max(0, duration_ms)
86
+
87
+ def summary(self) -> dict:
88
+ """Return a summary dict suitable for display / logging."""
89
+ return {
90
+ "llm_calls": self.llm_calls,
91
+ "total_tokens": self.total_prompt_tokens + self.total_completion_tokens,
92
+ "prompt_tokens": self.total_prompt_tokens,
93
+ "completion_tokens": self.total_completion_tokens,
94
+ "estimated_cost_usd": round(self.total_cost_usd, 6),
95
+ "tool_calls": self.tool_calls,
96
+ "tool_duration_s": round(self.tool_duration_ms / 1000, 2),
97
+ "per_model": self._per_model,
98
+ }
99
+
100
+ def reset(self) -> None:
101
+ """Reset all counters to zero."""
102
+ self.total_prompt_tokens = 0
103
+ self.total_completion_tokens = 0
104
+ self.total_cost_usd = 0.0
105
+ self.tool_calls = 0
106
+ self.tool_duration_ms = 0
107
+ self.llm_calls = 0
108
+ self._per_model.clear()
109
+
110
+ def format_summary(self) -> str:
111
+ """Human-readable one-line summary."""
112
+ s = self.summary()
113
+ cost_str = f"${s['estimated_cost_usd']:.4f}" if s["estimated_cost_usd"] > 0 else "(price data unavailable)"
114
+ return (
115
+ f"LLM calls: {s['llm_calls']} "
116
+ f"Tokens: {s['prompt_tokens']}in + {s['completion_tokens']}out "
117
+ f"Cost: {cost_str} "
118
+ f"Tools: {s['tool_calls']} calls ({s['tool_duration_s']}s)"
119
+ )