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.
- repopilot/__init__.py +3 -0
- repopilot/__main__.py +3 -0
- repopilot/agent/__init__.py +0 -0
- repopilot/agent/compact.py +192 -0
- repopilot/agent/context.py +236 -0
- repopilot/agent/cost.py +119 -0
- repopilot/agent/loop.py +387 -0
- repopilot/agent/parser.py +152 -0
- repopilot/cli.py +396 -0
- repopilot/code_index/__init__.py +8 -0
- repopilot/code_index/ignore.py +121 -0
- repopilot/code_index/repo_map.py +81 -0
- repopilot/code_index/symbol_index.py +177 -0
- repopilot/config.py +235 -0
- repopilot/hooks/__init__.py +9 -0
- repopilot/hooks/builtin.py +57 -0
- repopilot/hooks/manager.py +102 -0
- repopilot/llm/__init__.py +10 -0
- repopilot/llm/circuit_breaker.py +76 -0
- repopilot/llm/service.py +224 -0
- repopilot/llm/stream_handler.py +54 -0
- repopilot/logging_setup.py +49 -0
- repopilot/memory/__init__.py +131 -0
- repopilot/permission/__init__.py +21 -0
- repopilot/permission/approver.py +108 -0
- repopilot/permission/engine.py +201 -0
- repopilot/permission/patterns.py +325 -0
- repopilot/repl.py +522 -0
- repopilot/sandbox/__init__.py +6 -0
- repopilot/sandbox/base.py +158 -0
- repopilot/sandbox/docker_sandbox.py +389 -0
- repopilot/sandbox/local_sandbox.py +278 -0
- repopilot/session/__init__.py +3 -0
- repopilot/session/store.py +292 -0
- repopilot/tools/__init__.py +34 -0
- repopilot/tools/base.py +69 -0
- repopilot/tools/exec_tools.py +193 -0
- repopilot/tools/file_tools.py +170 -0
- repopilot/tools/meta_tools.py +41 -0
- repopilot/tools/registry.py +104 -0
- repopilot/tools/result.py +61 -0
- repopilot/tools/search_tools.py +221 -0
- repopilot_agent-0.1.0.dist-info/METADATA +272 -0
- repopilot_agent-0.1.0.dist-info/RECORD +48 -0
- repopilot_agent-0.1.0.dist-info/WHEEL +5 -0
- repopilot_agent-0.1.0.dist-info/entry_points.txt +2 -0
- repopilot_agent-0.1.0.dist-info/licenses/LICENSE +21 -0
- repopilot_agent-0.1.0.dist-info/top_level.txt +1 -0
repopilot/__init__.py
ADDED
repopilot/__main__.py
ADDED
|
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
|
repopilot/agent/cost.py
ADDED
|
@@ -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
|
+
)
|