mycode-coding-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.
- mycode/__init__.py +0 -0
- mycode/adapters/__init__.py +21 -0
- mycode/adapters/jsonl.py +692 -0
- mycode/agent/__init__.py +25 -0
- mycode/agent/events.py +111 -0
- mycode/agent/outcome.py +103 -0
- mycode/agent/progress.py +373 -0
- mycode/agent/runner.py +1481 -0
- mycode/application/__init__.py +38 -0
- mycode/application/agent_session.py +367 -0
- mycode/application/events.py +59 -0
- mycode/application/runtime.py +211 -0
- mycode/application/sessions.py +180 -0
- mycode/cli.py +840 -0
- mycode/config.py +355 -0
- mycode/context/__init__.py +1 -0
- mycode/context/artifacts.py +672 -0
- mycode/context/budget.py +752 -0
- mycode/context/builder.py +112 -0
- mycode/context/compact.py +795 -0
- mycode/context/tool_result_format.py +199 -0
- mycode/context/tool_result_retention.py +261 -0
- mycode/conversation.py +78 -0
- mycode/error_handling.py +481 -0
- mycode/event_format.py +147 -0
- mycode/instructions.py +285 -0
- mycode/llm.py +771 -0
- mycode/mcp/__init__.py +41 -0
- mycode/mcp/client.py +44 -0
- mycode/mcp/config.py +207 -0
- mycode/mcp/errors.py +302 -0
- mycode/mcp/manager.py +339 -0
- mycode/mcp/models.py +20 -0
- mycode/mcp/result_adapter.py +58 -0
- mycode/mcp/tool_adapter.py +145 -0
- mycode/mcp/trust.py +313 -0
- mycode/memory.py +570 -0
- mycode/memory_context.py +245 -0
- mycode/messages.py +63 -0
- mycode/observability.py +28 -0
- mycode/permissions.py +262 -0
- mycode/persistence/__init__.py +1 -0
- mycode/persistence/filesystem.py +291 -0
- mycode/persistence/project_storage.py +208 -0
- mycode/persistence/session_lock.py +138 -0
- mycode/persistence/session_store.py +503 -0
- mycode/presentation/__init__.py +1 -0
- mycode/presentation/cli/__init__.py +14 -0
- mycode/presentation/cli/confirmer.py +116 -0
- mycode/presentation/cli/mcp_trust.py +61 -0
- mycode/presentation/cli/presenter.py +320 -0
- mycode/presentation/cli/session_menu.py +146 -0
- mycode/presentation/cli/subagent_observer.py +124 -0
- mycode/presentation/command_format.py +90 -0
- mycode/presentation/commands.py +95 -0
- mycode/presentation/tui/__init__.py +6 -0
- mycode/presentation/tui/app.py +1351 -0
- mycode/presentation/tui/interactions.py +253 -0
- mycode/presentation/tui/presenter.py +266 -0
- mycode/presentation/tui/screens.py +305 -0
- mycode/presentation/tui/widgets.py +214 -0
- mycode/project.py +22 -0
- mycode/prompts.py +181 -0
- mycode/reasoning.py +40 -0
- mycode/session.py +86 -0
- mycode/skills/__init__.py +27 -0
- mycode/skills/builtin/database-recovery/SKILL.md +138 -0
- mycode/skills/builtin/database-recovery/references/sqlite.md +235 -0
- mycode/skills/registry.py +295 -0
- mycode/skills/state.py +68 -0
- mycode/subagents/__init__.py +1 -0
- mycode/subagents/audit.py +212 -0
- mycode/subagents/concurrency.py +124 -0
- mycode/subagents/contracts.py +421 -0
- mycode/subagents/delegate.py +80 -0
- mycode/subagents/delegation.py +128 -0
- mycode/subagents/lifecycle.py +86 -0
- mycode/subagents/limits.py +7 -0
- mycode/subagents/observability.py +150 -0
- mycode/subagents/persistence.py +152 -0
- mycode/subagents/profiles.py +184 -0
- mycode/subagents/prompts.py +67 -0
- mycode/subagents/results.py +178 -0
- mycode/subagents/runtime.py +528 -0
- mycode/subagents/snapshots.py +211 -0
- mycode/subagents/tool_batch.py +260 -0
- mycode/tools/__init__.py +81 -0
- mycode/tools/base.py +222 -0
- mycode/tools/bounds.py +14 -0
- mycode/tools/command_executor.py +167 -0
- mycode/tools/command_output.py +166 -0
- mycode/tools/command_risk.py +596 -0
- mycode/tools/defaults.py +59 -0
- mycode/tools/edit_file.py +524 -0
- mycode/tools/file_mutation.py +30 -0
- mycode/tools/glob.py +247 -0
- mycode/tools/grep.py +324 -0
- mycode/tools/ignore.py +122 -0
- mycode/tools/inspect_changes.py +269 -0
- mycode/tools/load_skill.py +92 -0
- mycode/tools/memory.py +264 -0
- mycode/tools/path_permissions.py +78 -0
- mycode/tools/patterns.py +48 -0
- mycode/tools/permission_metadata.py +27 -0
- mycode/tools/process_tree.py +166 -0
- mycode/tools/read_file.py +242 -0
- mycode/tools/read_skill_resource.py +93 -0
- mycode/tools/registry.py +279 -0
- mycode/tools/run_command.py +237 -0
- mycode/tools/run_skill_script.py +206 -0
- mycode/tools/run_validation.py +107 -0
- mycode/tools/submit_result.py +93 -0
- mycode/tools/text.py +15 -0
- mycode/tools/validation_command.py +377 -0
- mycode/tools/workspace.py +33 -0
- mycode/tools/write_file.py +169 -0
- mycode_coding_agent-0.1.0.dist-info/METADATA +244 -0
- mycode_coding_agent-0.1.0.dist-info/RECORD +121 -0
- mycode_coding_agent-0.1.0.dist-info/WHEEL +4 -0
- mycode_coding_agent-0.1.0.dist-info/entry_points.txt +2 -0
- mycode_coding_agent-0.1.0.dist-info/licenses/LICENSE +21 -0
mycode/context/budget.py
ADDED
|
@@ -0,0 +1,752 @@
|
|
|
1
|
+
from dataclasses import dataclass, field, replace
|
|
2
|
+
import json
|
|
3
|
+
import math
|
|
4
|
+
|
|
5
|
+
from mycode.conversation import Conversation
|
|
6
|
+
from mycode.messages import Message
|
|
7
|
+
from mycode.context.tool_result_format import (
|
|
8
|
+
COMPRESSED_TOOL_RESULT_MARKER, TOOL_RESULT_METADATA_MARKER,
|
|
9
|
+
ParsedToolResultContent, parse_tool_result_content, safe_tool_metadata,
|
|
10
|
+
_group_non_system_messages, _flatten_groups, _count_compressed_tool_results,
|
|
11
|
+
)
|
|
12
|
+
from mycode.context.tool_result_retention import (
|
|
13
|
+
ToolResultRetentionPolicy, RetentionProjection, ToolResultRetentionStats,
|
|
14
|
+
)
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
DEFAULT_CONTEXT_WINDOW_TOKENS = 128000
|
|
18
|
+
DEFAULT_RESERVED_OUTPUT_TOKENS = 8192
|
|
19
|
+
DEFAULT_CONTEXT_SAFETY_MARGIN_TOKENS = 4096
|
|
20
|
+
DEFAULT_TOOL_RESULT_COMPRESSION_THRESHOLD_CHARS = 4000
|
|
21
|
+
DEFAULT_RECENT_TOOL_RESULT_GROUPS_TO_KEEP = 1
|
|
22
|
+
DEFAULT_ASCII_TOKENS_PER_CHAR = 0.5
|
|
23
|
+
DEFAULT_MIXED_TOKENS_PER_CHAR = 0.8
|
|
24
|
+
DEFAULT_NON_ASCII_TOKENS_PER_CHAR = 1.2
|
|
25
|
+
DEFAULT_CALIBRATION_SAFETY_FACTOR = 1.1
|
|
26
|
+
DEFAULT_MAX_CALIBRATION_SAMPLES = 20
|
|
27
|
+
@dataclass(frozen=True)
|
|
28
|
+
class ContextBudget:
|
|
29
|
+
"""Model context budget measured in tokens."""
|
|
30
|
+
|
|
31
|
+
context_window_tokens: int = DEFAULT_CONTEXT_WINDOW_TOKENS
|
|
32
|
+
reserved_output_tokens: int = DEFAULT_RESERVED_OUTPUT_TOKENS
|
|
33
|
+
safety_margin_tokens: int = DEFAULT_CONTEXT_SAFETY_MARGIN_TOKENS
|
|
34
|
+
tool_result_compression_threshold_chars: int = (
|
|
35
|
+
DEFAULT_TOOL_RESULT_COMPRESSION_THRESHOLD_CHARS
|
|
36
|
+
)
|
|
37
|
+
recent_tool_result_groups_to_keep: int = DEFAULT_RECENT_TOOL_RESULT_GROUPS_TO_KEEP
|
|
38
|
+
|
|
39
|
+
def __post_init__(self) -> None:
|
|
40
|
+
if self.context_window_tokens < 1:
|
|
41
|
+
raise ValueError("context_window_tokens must be at least 1.")
|
|
42
|
+
if self.reserved_output_tokens < 0:
|
|
43
|
+
raise ValueError("reserved_output_tokens must be at least 0.")
|
|
44
|
+
if self.safety_margin_tokens < 0:
|
|
45
|
+
raise ValueError("safety_margin_tokens must be at least 0.")
|
|
46
|
+
if self.max_input_tokens < 1:
|
|
47
|
+
raise ValueError(
|
|
48
|
+
"reserved_output_tokens and safety_margin_tokens must leave at "
|
|
49
|
+
"least 1 input token."
|
|
50
|
+
)
|
|
51
|
+
if self.tool_result_compression_threshold_chars < 1:
|
|
52
|
+
raise ValueError(
|
|
53
|
+
"tool_result_compression_threshold_chars must be at least 1."
|
|
54
|
+
)
|
|
55
|
+
if self.recent_tool_result_groups_to_keep < 0:
|
|
56
|
+
raise ValueError("recent_tool_result_groups_to_keep must be at least 0.")
|
|
57
|
+
|
|
58
|
+
@property
|
|
59
|
+
def max_input_tokens(self) -> int:
|
|
60
|
+
return (
|
|
61
|
+
self.context_window_tokens
|
|
62
|
+
- self.reserved_output_tokens
|
|
63
|
+
- self.safety_margin_tokens
|
|
64
|
+
)
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
@dataclass(frozen=True)
|
|
68
|
+
class TokenUsage:
|
|
69
|
+
prompt_tokens: int
|
|
70
|
+
completion_tokens: int
|
|
71
|
+
total_tokens: int
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
@dataclass
|
|
75
|
+
class TokenEstimator:
|
|
76
|
+
"""Estimate input tokens and calibrate from provider-reported prompt usage."""
|
|
77
|
+
|
|
78
|
+
ascii_tokens_per_char: float = DEFAULT_ASCII_TOKENS_PER_CHAR
|
|
79
|
+
mixed_tokens_per_char: float = DEFAULT_MIXED_TOKENS_PER_CHAR
|
|
80
|
+
non_ascii_tokens_per_char: float = DEFAULT_NON_ASCII_TOKENS_PER_CHAR
|
|
81
|
+
calibration_safety_factor: float = DEFAULT_CALIBRATION_SAFETY_FACTOR
|
|
82
|
+
max_samples_per_profile: int = DEFAULT_MAX_CALIBRATION_SAMPLES
|
|
83
|
+
_samples: dict[str, list[float]] = field(default_factory=dict, init=False)
|
|
84
|
+
|
|
85
|
+
def __post_init__(self) -> None:
|
|
86
|
+
for name, value in (
|
|
87
|
+
("ascii_tokens_per_char", self.ascii_tokens_per_char),
|
|
88
|
+
("mixed_tokens_per_char", self.mixed_tokens_per_char),
|
|
89
|
+
("non_ascii_tokens_per_char", self.non_ascii_tokens_per_char),
|
|
90
|
+
("calibration_safety_factor", self.calibration_safety_factor),
|
|
91
|
+
):
|
|
92
|
+
if value <= 0:
|
|
93
|
+
raise ValueError(f"{name} must be greater than 0.")
|
|
94
|
+
if self.max_samples_per_profile < 1:
|
|
95
|
+
raise ValueError("max_samples_per_profile must be at least 1.")
|
|
96
|
+
|
|
97
|
+
def estimate(self, *, total_chars: int, non_ascii_chars: int) -> "TokenEstimate":
|
|
98
|
+
profile = _text_profile(total_chars, non_ascii_chars)
|
|
99
|
+
samples = self._samples.get(profile, [])
|
|
100
|
+
if samples:
|
|
101
|
+
coefficient = _percentile(samples, 0.9) * self.calibration_safety_factor
|
|
102
|
+
source = "calibrated"
|
|
103
|
+
else:
|
|
104
|
+
coefficient = self._default_coefficient(profile)
|
|
105
|
+
source = "default"
|
|
106
|
+
|
|
107
|
+
return TokenEstimate(
|
|
108
|
+
estimated_tokens=math.ceil(total_chars * coefficient),
|
|
109
|
+
tokens_per_char=coefficient,
|
|
110
|
+
profile=profile,
|
|
111
|
+
source=source,
|
|
112
|
+
)
|
|
113
|
+
|
|
114
|
+
def observe(self, estimate: "ConversationEstimate", usage: TokenUsage | None) -> None:
|
|
115
|
+
if usage is None or usage.prompt_tokens < 1 or estimate.total_chars < 1:
|
|
116
|
+
return
|
|
117
|
+
|
|
118
|
+
samples = self._samples.setdefault(estimate.token_profile, [])
|
|
119
|
+
samples.append(usage.prompt_tokens / estimate.total_chars)
|
|
120
|
+
del samples[: -self.max_samples_per_profile]
|
|
121
|
+
|
|
122
|
+
def _default_coefficient(self, profile: str) -> float:
|
|
123
|
+
if profile == "ascii":
|
|
124
|
+
return self.ascii_tokens_per_char
|
|
125
|
+
if profile == "mixed":
|
|
126
|
+
return self.mixed_tokens_per_char
|
|
127
|
+
return self.non_ascii_tokens_per_char
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
@dataclass(frozen=True)
|
|
131
|
+
class TokenEstimate:
|
|
132
|
+
estimated_tokens: int
|
|
133
|
+
tokens_per_char: float
|
|
134
|
+
profile: str
|
|
135
|
+
source: str
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
@dataclass(frozen=True)
|
|
139
|
+
class MessageEstimate:
|
|
140
|
+
"""Per-message estimate in Python characters, not UTF-8 bytes or model tokens."""
|
|
141
|
+
|
|
142
|
+
role: str
|
|
143
|
+
content_chars: int
|
|
144
|
+
reasoning_chars: int = 0
|
|
145
|
+
tool_call_chars: int = 0
|
|
146
|
+
tool_call_id_chars: int = 0
|
|
147
|
+
serialization_overhead_chars: int = 0
|
|
148
|
+
|
|
149
|
+
@property
|
|
150
|
+
def total_chars(self) -> int:
|
|
151
|
+
return (
|
|
152
|
+
len(self.role)
|
|
153
|
+
+ self.content_chars
|
|
154
|
+
+ self.reasoning_chars
|
|
155
|
+
+ self.tool_call_chars
|
|
156
|
+
+ self.tool_call_id_chars
|
|
157
|
+
+ self.serialization_overhead_chars
|
|
158
|
+
)
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
@dataclass(frozen=True)
|
|
162
|
+
class ConversationEstimate:
|
|
163
|
+
message_count: int
|
|
164
|
+
message_chars: int
|
|
165
|
+
estimated_input_tokens: int
|
|
166
|
+
max_input_tokens: int
|
|
167
|
+
tokens_per_char: float
|
|
168
|
+
token_profile: str
|
|
169
|
+
token_estimate_source: str
|
|
170
|
+
message_estimates: tuple[MessageEstimate, ...]
|
|
171
|
+
tool_schema_chars: int = 0
|
|
172
|
+
message_list_overhead_chars: int = 0
|
|
173
|
+
|
|
174
|
+
@property
|
|
175
|
+
def total_chars(self) -> int:
|
|
176
|
+
return self.message_chars + self.tool_schema_chars
|
|
177
|
+
|
|
178
|
+
@property
|
|
179
|
+
def over_budget(self) -> bool:
|
|
180
|
+
return self.estimated_input_tokens > self.max_input_tokens
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
@dataclass(frozen=True)
|
|
184
|
+
class ModelContext:
|
|
185
|
+
messages: tuple[Message, ...]
|
|
186
|
+
estimate: ConversationEstimate
|
|
187
|
+
original_message_count: int
|
|
188
|
+
compressed_tool_result_count: int = 0
|
|
189
|
+
memory_stats: "MemoryContextStats | None" = None
|
|
190
|
+
compact_stats: "CompactContextStats | None" = None
|
|
191
|
+
retention_stats: ToolResultRetentionStats | None = None
|
|
192
|
+
|
|
193
|
+
@property
|
|
194
|
+
def selected_message_count(self) -> int:
|
|
195
|
+
return len(self.messages)
|
|
196
|
+
|
|
197
|
+
@property
|
|
198
|
+
def source_message_count(self) -> int:
|
|
199
|
+
"""Canonical message count represented by this model-visible view."""
|
|
200
|
+
if (
|
|
201
|
+
self.compact_stats is None
|
|
202
|
+
or self.compact_stats.compacted_message_count == 0
|
|
203
|
+
or not self.compact_stats.summary_visible
|
|
204
|
+
):
|
|
205
|
+
return self.original_message_count
|
|
206
|
+
|
|
207
|
+
# The visible compact-summary message replaces the covered canonical
|
|
208
|
+
# messages, so add the covered messages and remove that one summary.
|
|
209
|
+
return (
|
|
210
|
+
self.original_message_count
|
|
211
|
+
+ self.compact_stats.compacted_message_count
|
|
212
|
+
- 1
|
|
213
|
+
)
|
|
214
|
+
|
|
215
|
+
@property
|
|
216
|
+
def trimmed_message_count(self) -> int:
|
|
217
|
+
return self.original_message_count - self.selected_message_count
|
|
218
|
+
|
|
219
|
+
@property
|
|
220
|
+
def trimmed(self) -> bool:
|
|
221
|
+
return self.trimmed_message_count > 0
|
|
222
|
+
|
|
223
|
+
|
|
224
|
+
@dataclass(frozen=True)
|
|
225
|
+
class MemoryContextStats:
|
|
226
|
+
"""Content-free observability for one long-term-memory recall."""
|
|
227
|
+
|
|
228
|
+
safe_entry_count: int = 0
|
|
229
|
+
relevant_entry_count: int = 0
|
|
230
|
+
selected_entry_count: int = 0
|
|
231
|
+
included_entry_count: int = 0
|
|
232
|
+
estimated_tokens: int = 0
|
|
233
|
+
irrelevant_entry_count: int = 0
|
|
234
|
+
conflict_count: int = 0
|
|
235
|
+
budget_omitted_count: int = 0
|
|
236
|
+
issue_count: int = 0
|
|
237
|
+
scopes: tuple[str, ...] = ()
|
|
238
|
+
|
|
239
|
+
def __post_init__(self) -> None:
|
|
240
|
+
numeric_values = (
|
|
241
|
+
self.safe_entry_count,
|
|
242
|
+
self.relevant_entry_count,
|
|
243
|
+
self.selected_entry_count,
|
|
244
|
+
self.included_entry_count,
|
|
245
|
+
self.estimated_tokens,
|
|
246
|
+
self.irrelevant_entry_count,
|
|
247
|
+
self.conflict_count,
|
|
248
|
+
self.budget_omitted_count,
|
|
249
|
+
self.issue_count,
|
|
250
|
+
)
|
|
251
|
+
if any(value < 0 for value in numeric_values):
|
|
252
|
+
raise ValueError("Memory context statistics must not be negative.")
|
|
253
|
+
if self.included_entry_count > self.selected_entry_count:
|
|
254
|
+
raise ValueError(
|
|
255
|
+
"included_entry_count must not exceed selected_entry_count."
|
|
256
|
+
)
|
|
257
|
+
|
|
258
|
+
def with_included_entries(self, count: int) -> "MemoryContextStats":
|
|
259
|
+
return replace(self, included_entry_count=count)
|
|
260
|
+
|
|
261
|
+
|
|
262
|
+
@dataclass(frozen=True)
|
|
263
|
+
class CompactContextStats:
|
|
264
|
+
"""Content-free observability for one conversation Compact decision."""
|
|
265
|
+
|
|
266
|
+
status: str
|
|
267
|
+
boundary_id: str | None = None
|
|
268
|
+
compacted_message_count: int = 0
|
|
269
|
+
covered_turn_count: int = 0
|
|
270
|
+
consecutive_failure_count: int = 0
|
|
271
|
+
retry_after_message_count: int = 0
|
|
272
|
+
circuit_open: bool = False
|
|
273
|
+
summary_visible: bool = False
|
|
274
|
+
|
|
275
|
+
def __post_init__(self) -> None:
|
|
276
|
+
numeric_values = (
|
|
277
|
+
self.compacted_message_count,
|
|
278
|
+
self.covered_turn_count,
|
|
279
|
+
self.consecutive_failure_count,
|
|
280
|
+
self.retry_after_message_count,
|
|
281
|
+
)
|
|
282
|
+
if any(value < 0 for value in numeric_values):
|
|
283
|
+
raise ValueError("Compact context statistics must not be negative.")
|
|
284
|
+
if self.boundary_id is None and (
|
|
285
|
+
self.compacted_message_count > 0 or self.covered_turn_count > 0
|
|
286
|
+
):
|
|
287
|
+
raise ValueError(
|
|
288
|
+
"Compact counts require a persisted or in-memory boundary id."
|
|
289
|
+
)
|
|
290
|
+
if self.summary_visible and self.boundary_id is None:
|
|
291
|
+
raise ValueError(
|
|
292
|
+
"A visible Compact summary requires a boundary id."
|
|
293
|
+
)
|
|
294
|
+
|
|
295
|
+
|
|
296
|
+
class ContextBudgetExceededError(RuntimeError):
|
|
297
|
+
def __init__(self, context: ModelContext) -> None:
|
|
298
|
+
self.context = context
|
|
299
|
+
super().__init__(
|
|
300
|
+
"Model context exceeds the configured input budget: "
|
|
301
|
+
f"tokens={context.estimate.estimated_input_tokens}/"
|
|
302
|
+
f"{context.estimate.max_input_tokens}."
|
|
303
|
+
)
|
|
304
|
+
|
|
305
|
+
|
|
306
|
+
def estimate_message(message: Message) -> MessageEstimate:
|
|
307
|
+
model_dict = message.to_model_dict()
|
|
308
|
+
content_chars = len(message.content)
|
|
309
|
+
reasoning_chars = len(message.reasoning_content or "")
|
|
310
|
+
tool_call_chars = _estimate_tool_calls(message)
|
|
311
|
+
tool_call_id_chars = len(message.tool_call_id or "")
|
|
312
|
+
component_chars = (
|
|
313
|
+
len(message.role)
|
|
314
|
+
+ content_chars
|
|
315
|
+
+ reasoning_chars
|
|
316
|
+
+ tool_call_chars
|
|
317
|
+
+ tool_call_id_chars
|
|
318
|
+
)
|
|
319
|
+
serialized_chars = len(json.dumps(model_dict, ensure_ascii=False))
|
|
320
|
+
|
|
321
|
+
return MessageEstimate(
|
|
322
|
+
role=message.role,
|
|
323
|
+
content_chars=content_chars,
|
|
324
|
+
reasoning_chars=reasoning_chars,
|
|
325
|
+
tool_call_chars=tool_call_chars,
|
|
326
|
+
tool_call_id_chars=tool_call_id_chars,
|
|
327
|
+
serialization_overhead_chars=serialized_chars - component_chars,
|
|
328
|
+
)
|
|
329
|
+
|
|
330
|
+
|
|
331
|
+
def estimate_conversation(
|
|
332
|
+
conversation: Conversation,
|
|
333
|
+
budget: ContextBudget | None = None,
|
|
334
|
+
tools: list[dict[str, object]] | None = None,
|
|
335
|
+
token_estimator: TokenEstimator | None = None,
|
|
336
|
+
) -> ConversationEstimate:
|
|
337
|
+
active_budget = ContextBudget() if budget is None else budget
|
|
338
|
+
active_estimator = TokenEstimator() if token_estimator is None else token_estimator
|
|
339
|
+
message_estimates = tuple(
|
|
340
|
+
estimate_message(message) for message in conversation.get_messages()
|
|
341
|
+
)
|
|
342
|
+
serialized_message_text = json.dumps(
|
|
343
|
+
conversation.to_model_messages(), ensure_ascii=False
|
|
344
|
+
)
|
|
345
|
+
serialized_messages = len(serialized_message_text)
|
|
346
|
+
estimated_message_chars = sum(
|
|
347
|
+
estimate.total_chars for estimate in message_estimates
|
|
348
|
+
)
|
|
349
|
+
message_list_overhead_chars = serialized_messages - estimated_message_chars
|
|
350
|
+
|
|
351
|
+
serialized_tool_text = _serialize_tool_schemas(tools)
|
|
352
|
+
total_text = serialized_message_text + serialized_tool_text
|
|
353
|
+
token_estimate = active_estimator.estimate(
|
|
354
|
+
total_chars=len(total_text),
|
|
355
|
+
non_ascii_chars=_count_non_ascii(total_text),
|
|
356
|
+
)
|
|
357
|
+
|
|
358
|
+
return ConversationEstimate(
|
|
359
|
+
message_count=len(message_estimates),
|
|
360
|
+
message_chars=serialized_messages,
|
|
361
|
+
estimated_input_tokens=token_estimate.estimated_tokens,
|
|
362
|
+
max_input_tokens=active_budget.max_input_tokens,
|
|
363
|
+
tokens_per_char=token_estimate.tokens_per_char,
|
|
364
|
+
token_profile=token_estimate.profile,
|
|
365
|
+
token_estimate_source=token_estimate.source,
|
|
366
|
+
message_estimates=message_estimates,
|
|
367
|
+
tool_schema_chars=len(serialized_tool_text),
|
|
368
|
+
message_list_overhead_chars=message_list_overhead_chars,
|
|
369
|
+
)
|
|
370
|
+
|
|
371
|
+
|
|
372
|
+
def build_model_context(
|
|
373
|
+
conversation: Conversation,
|
|
374
|
+
budget: ContextBudget | None = None,
|
|
375
|
+
tools: list[dict[str, object]] | None = None,
|
|
376
|
+
token_estimator: TokenEstimator | None = None,
|
|
377
|
+
*,
|
|
378
|
+
memory_message: Message | None = None,
|
|
379
|
+
memory_stats: MemoryContextStats | None = None,
|
|
380
|
+
) -> ModelContext:
|
|
381
|
+
"""Convenience entry for budgeting history with optional memory, without Compact."""
|
|
382
|
+
messages = tuple(conversation.get_messages())
|
|
383
|
+
if memory_message is not None:
|
|
384
|
+
messages = (*messages, memory_message)
|
|
385
|
+
return budget_model_context(
|
|
386
|
+
messages,
|
|
387
|
+
budget,
|
|
388
|
+
tools=tools,
|
|
389
|
+
token_estimator=token_estimator,
|
|
390
|
+
memory_message=memory_message,
|
|
391
|
+
memory_stats=memory_stats,
|
|
392
|
+
)
|
|
393
|
+
|
|
394
|
+
|
|
395
|
+
def budget_model_context(
|
|
396
|
+
messages: tuple[Message, ...],
|
|
397
|
+
budget: ContextBudget | None = None,
|
|
398
|
+
tools: list[dict[str, object]] | None = None,
|
|
399
|
+
token_estimator: TokenEstimator | None = None,
|
|
400
|
+
*,
|
|
401
|
+
memory_message: Message | None = None,
|
|
402
|
+
memory_stats: MemoryContextStats | None = None,
|
|
403
|
+
retention_policy: ToolResultRetentionPolicy | None = None,
|
|
404
|
+
retention_projection: RetentionProjection | None = None,
|
|
405
|
+
) -> ModelContext:
|
|
406
|
+
"""Apply the final budget to an assembled request; never revisit upstream views.
|
|
407
|
+
|
|
408
|
+
Optional memory must already be in messages. Agent requests lower tool-group
|
|
409
|
+
precision, omit memory, then trim history. Calls without a retention projection
|
|
410
|
+
retain the existing Chat/standalone trimming and memory-omission behavior.
|
|
411
|
+
"""
|
|
412
|
+
active_budget = ContextBudget() if budget is None else budget
|
|
413
|
+
active_estimator = TokenEstimator() if token_estimator is None else token_estimator
|
|
414
|
+
if memory_message is not None and memory_message.role != "system":
|
|
415
|
+
raise ValueError("memory_message must use the system role.")
|
|
416
|
+
if memory_message is not None and not any(
|
|
417
|
+
message is memory_message for message in messages
|
|
418
|
+
):
|
|
419
|
+
raise ValueError("memory_message must be present in the assembled messages.")
|
|
420
|
+
if memory_message is None and memory_stats is not None:
|
|
421
|
+
if memory_stats.selected_entry_count > 0:
|
|
422
|
+
raise ValueError(
|
|
423
|
+
"memory_stats cannot report selected entries without a memory_message."
|
|
424
|
+
)
|
|
425
|
+
|
|
426
|
+
if retention_policy is not None:
|
|
427
|
+
if retention_projection is None:
|
|
428
|
+
raise ValueError("Retention budget requires the request's projection.")
|
|
429
|
+
return _budget_retained_context(
|
|
430
|
+
messages, active_budget, active_estimator, tools,
|
|
431
|
+
memory_message, memory_stats, retention_policy, retention_projection,
|
|
432
|
+
)
|
|
433
|
+
|
|
434
|
+
context = _build_model_context_from_messages(
|
|
435
|
+
messages,
|
|
436
|
+
active_budget,
|
|
437
|
+
tools=tools,
|
|
438
|
+
token_estimator=active_estimator,
|
|
439
|
+
)
|
|
440
|
+
if memory_message is not None and context.estimate.over_budget:
|
|
441
|
+
context = _build_model_context_from_messages(
|
|
442
|
+
tuple(message for message in messages if message is not memory_message),
|
|
443
|
+
active_budget,
|
|
444
|
+
tools=tools,
|
|
445
|
+
token_estimator=active_estimator,
|
|
446
|
+
)
|
|
447
|
+
if memory_stats is not None:
|
|
448
|
+
memory_stats = memory_stats.with_included_entries(0)
|
|
449
|
+
elif memory_stats is not None:
|
|
450
|
+
memory_stats = memory_stats.with_included_entries(
|
|
451
|
+
memory_stats.selected_entry_count
|
|
452
|
+
)
|
|
453
|
+
|
|
454
|
+
return replace(context, memory_stats=memory_stats)
|
|
455
|
+
|
|
456
|
+
|
|
457
|
+
def _budget_retained_context(
|
|
458
|
+
original_messages: tuple[Message, ...],
|
|
459
|
+
budget: ContextBudget,
|
|
460
|
+
token_estimator: TokenEstimator,
|
|
461
|
+
tools: list[dict[str, object]] | None,
|
|
462
|
+
memory_message: Message | None,
|
|
463
|
+
memory_stats: MemoryContextStats | None,
|
|
464
|
+
policy: ToolResultRetentionPolicy,
|
|
465
|
+
projection: RetentionProjection,
|
|
466
|
+
) -> ModelContext:
|
|
467
|
+
"""Only lower precision on this assembled candidate; never repeat projection/Compact."""
|
|
468
|
+
systems = tuple(m for m in original_messages if m.role == "system")
|
|
469
|
+
groups = _group_non_system_messages(original_messages)
|
|
470
|
+
downgraded: set[int] = set()
|
|
471
|
+
|
|
472
|
+
def estimate(candidate: list[tuple[Message, ...]]) -> ConversationEstimate:
|
|
473
|
+
return estimate_conversation(
|
|
474
|
+
Conversation.from_messages(list(systems + _flatten_groups(candidate))),
|
|
475
|
+
budget, tools=tools, token_estimator=token_estimator,
|
|
476
|
+
)
|
|
477
|
+
|
|
478
|
+
current = estimate(groups)
|
|
479
|
+
for candidates in (
|
|
480
|
+
policy.artifact_candidates(groups, projection),
|
|
481
|
+
policy.metadata_candidates(groups, projection=projection),
|
|
482
|
+
):
|
|
483
|
+
if not current.over_budget:
|
|
484
|
+
break
|
|
485
|
+
for index, replacement in candidates:
|
|
486
|
+
candidate = list(groups)
|
|
487
|
+
candidate[index] = replacement
|
|
488
|
+
next_estimate = estimate(candidate)
|
|
489
|
+
# Lower precision must also lower cost (small refs can exceed Full).
|
|
490
|
+
if next_estimate.estimated_input_tokens >= current.estimated_input_tokens:
|
|
491
|
+
continue
|
|
492
|
+
groups[index] = replacement
|
|
493
|
+
downgraded.add(id(replacement[0]))
|
|
494
|
+
current = next_estimate
|
|
495
|
+
if not current.over_budget:
|
|
496
|
+
break
|
|
497
|
+
|
|
498
|
+
if current.over_budget and memory_message is not None:
|
|
499
|
+
systems = tuple(m for m in systems if m is not memory_message)
|
|
500
|
+
if memory_stats is not None:
|
|
501
|
+
memory_stats = memory_stats.with_included_entries(0)
|
|
502
|
+
current = estimate(groups)
|
|
503
|
+
elif memory_stats is not None:
|
|
504
|
+
memory_stats = memory_stats.with_included_entries(memory_stats.selected_entry_count)
|
|
505
|
+
|
|
506
|
+
# Keep current user intent as well as the latest protocol group. System,
|
|
507
|
+
# Compact summary and runtime guidance remain protected in `systems`.
|
|
508
|
+
protected = {id(groups[-1][0])} if groups else set()
|
|
509
|
+
# Request-only user prompts (e.g. max-turn finalization) must not replace
|
|
510
|
+
# the canonical task request as the protected current user intent.
|
|
511
|
+
for message in reversed(projection.conversation.get_messages()):
|
|
512
|
+
if message.role == "user":
|
|
513
|
+
protected.add(id(message))
|
|
514
|
+
break
|
|
515
|
+
for group in reversed(groups):
|
|
516
|
+
if group[0].role == "user":
|
|
517
|
+
protected.add(id(group[0]))
|
|
518
|
+
break
|
|
519
|
+
while current.over_budget:
|
|
520
|
+
removable = next((i for i, g in enumerate(groups) if id(g[0]) not in protected), None)
|
|
521
|
+
if removable is None:
|
|
522
|
+
break
|
|
523
|
+
del groups[removable]
|
|
524
|
+
current = estimate(groups)
|
|
525
|
+
messages = systems + _flatten_groups(groups)
|
|
526
|
+
return ModelContext(
|
|
527
|
+
messages=messages, estimate=current, original_message_count=len(original_messages),
|
|
528
|
+
compressed_tool_result_count=_count_compressed_tool_results(messages),
|
|
529
|
+
memory_stats=memory_stats, retention_stats=policy.stats(groups, projection, downgraded),
|
|
530
|
+
)
|
|
531
|
+
|
|
532
|
+
|
|
533
|
+
def _build_model_context_from_messages(
|
|
534
|
+
original_messages: tuple[Message, ...],
|
|
535
|
+
budget: ContextBudget,
|
|
536
|
+
*,
|
|
537
|
+
tools: list[dict[str, object]] | None,
|
|
538
|
+
token_estimator: TokenEstimator,
|
|
539
|
+
) -> ModelContext:
|
|
540
|
+
system_messages = tuple(
|
|
541
|
+
message for message in original_messages if message.role == "system"
|
|
542
|
+
)
|
|
543
|
+
non_system_groups = _group_non_system_messages(original_messages)
|
|
544
|
+
valid_messages = system_messages + _flatten_groups(non_system_groups)
|
|
545
|
+
full_estimate = estimate_conversation(
|
|
546
|
+
Conversation.from_messages(list(valid_messages)),
|
|
547
|
+
budget,
|
|
548
|
+
tools=tools,
|
|
549
|
+
token_estimator=token_estimator,
|
|
550
|
+
)
|
|
551
|
+
if not full_estimate.over_budget:
|
|
552
|
+
return ModelContext(
|
|
553
|
+
messages=valid_messages,
|
|
554
|
+
estimate=full_estimate,
|
|
555
|
+
original_message_count=len(original_messages),
|
|
556
|
+
)
|
|
557
|
+
|
|
558
|
+
policy = ToolResultRetentionPolicy(budget)
|
|
559
|
+
for index, group in policy.metadata_candidates(non_system_groups, include_recent=False):
|
|
560
|
+
non_system_groups[index] = group
|
|
561
|
+
compressed_messages = system_messages + _flatten_groups(non_system_groups)
|
|
562
|
+
compressed_estimate = estimate_conversation(
|
|
563
|
+
Conversation.from_messages(list(compressed_messages)),
|
|
564
|
+
budget,
|
|
565
|
+
tools=tools,
|
|
566
|
+
token_estimator=token_estimator,
|
|
567
|
+
)
|
|
568
|
+
if not compressed_estimate.over_budget:
|
|
569
|
+
return ModelContext(
|
|
570
|
+
messages=compressed_messages,
|
|
571
|
+
estimate=compressed_estimate,
|
|
572
|
+
original_message_count=len(original_messages),
|
|
573
|
+
compressed_tool_result_count=_count_compressed_tool_results(
|
|
574
|
+
compressed_messages
|
|
575
|
+
),
|
|
576
|
+
)
|
|
577
|
+
|
|
578
|
+
selected_groups: list[tuple[Message, ...]] = []
|
|
579
|
+
|
|
580
|
+
for group in reversed(non_system_groups):
|
|
581
|
+
candidate_groups = [group, *selected_groups]
|
|
582
|
+
candidate_messages = system_messages + _flatten_groups(candidate_groups)
|
|
583
|
+
candidate_estimate = estimate_conversation(
|
|
584
|
+
Conversation.from_messages(list(candidate_messages)),
|
|
585
|
+
budget,
|
|
586
|
+
tools=tools,
|
|
587
|
+
token_estimator=token_estimator,
|
|
588
|
+
)
|
|
589
|
+
if candidate_estimate.over_budget and selected_groups:
|
|
590
|
+
break
|
|
591
|
+
|
|
592
|
+
selected_groups.insert(0, group)
|
|
593
|
+
|
|
594
|
+
selected_messages = system_messages + _flatten_groups(selected_groups)
|
|
595
|
+
selected_estimate = estimate_conversation(
|
|
596
|
+
Conversation.from_messages(list(selected_messages)),
|
|
597
|
+
budget,
|
|
598
|
+
tools=tools,
|
|
599
|
+
token_estimator=token_estimator,
|
|
600
|
+
)
|
|
601
|
+
|
|
602
|
+
return ModelContext(
|
|
603
|
+
messages=selected_messages,
|
|
604
|
+
estimate=selected_estimate,
|
|
605
|
+
original_message_count=len(original_messages),
|
|
606
|
+
compressed_tool_result_count=_count_compressed_tool_results(selected_messages),
|
|
607
|
+
)
|
|
608
|
+
|
|
609
|
+
|
|
610
|
+
def model_context_needs_notice(context: ModelContext) -> bool:
|
|
611
|
+
memory_needs_notice = context.memory_stats is not None and (
|
|
612
|
+
context.memory_stats.included_entry_count
|
|
613
|
+
!= context.memory_stats.selected_entry_count
|
|
614
|
+
or context.memory_stats.budget_omitted_count > 0
|
|
615
|
+
or context.memory_stats.conflict_count > 0
|
|
616
|
+
or context.memory_stats.issue_count > 0
|
|
617
|
+
)
|
|
618
|
+
return (
|
|
619
|
+
context.trimmed
|
|
620
|
+
or (context.retention_stats is not None and (
|
|
621
|
+
context.retention_stats.budget_downgraded_groups > 0
|
|
622
|
+
or context.retention_stats.rehydration_failures > 0
|
|
623
|
+
))
|
|
624
|
+
or context.compressed_tool_result_count > 0
|
|
625
|
+
or (
|
|
626
|
+
context.compact_stats is not None
|
|
627
|
+
and (
|
|
628
|
+
context.compact_stats.compacted_message_count > 0
|
|
629
|
+
or context.compact_stats.status
|
|
630
|
+
in {
|
|
631
|
+
"failed",
|
|
632
|
+
"cooldown",
|
|
633
|
+
"circuit_open",
|
|
634
|
+
"invalid_boundary",
|
|
635
|
+
}
|
|
636
|
+
)
|
|
637
|
+
)
|
|
638
|
+
or context.estimate.over_budget
|
|
639
|
+
or memory_needs_notice
|
|
640
|
+
)
|
|
641
|
+
|
|
642
|
+
|
|
643
|
+
def format_model_context_stats(
|
|
644
|
+
context: ModelContext,
|
|
645
|
+
*,
|
|
646
|
+
previous_prompt_tokens: int | None = None,
|
|
647
|
+
) -> str:
|
|
648
|
+
estimated_tokens = context.estimate.estimated_input_tokens
|
|
649
|
+
max_input_tokens = context.estimate.max_input_tokens
|
|
650
|
+
used_percent = estimated_tokens / max_input_tokens * 100
|
|
651
|
+
left_percent = max(0.0, 100.0 - used_percent)
|
|
652
|
+
source = (
|
|
653
|
+
"calibrated estimate"
|
|
654
|
+
if context.estimate.token_estimate_source == "calibrated"
|
|
655
|
+
else "conservative estimate, calibration pending"
|
|
656
|
+
)
|
|
657
|
+
summary = (
|
|
658
|
+
f"~{estimated_tokens:,} / {max_input_tokens:,} tokens "
|
|
659
|
+
f"({used_percent:.1f}% used, {left_percent:.1f}% left, {source}), "
|
|
660
|
+
f"messages={context.selected_message_count}/{context.source_message_count}"
|
|
661
|
+
)
|
|
662
|
+
if context.compact_stats is not None:
|
|
663
|
+
compact = context.compact_stats
|
|
664
|
+
if compact.boundary_id is not None and compact.summary_visible:
|
|
665
|
+
summary += (
|
|
666
|
+
f", compact={compact.compacted_message_count} messages/"
|
|
667
|
+
f"{compact.covered_turn_count} turns"
|
|
668
|
+
f"@{compact.boundary_id[:8]}"
|
|
669
|
+
)
|
|
670
|
+
if compact.status in {
|
|
671
|
+
"failed",
|
|
672
|
+
"cooldown",
|
|
673
|
+
"circuit_open",
|
|
674
|
+
"invalid_boundary",
|
|
675
|
+
}:
|
|
676
|
+
summary += (
|
|
677
|
+
f", compact_status={compact.status}, "
|
|
678
|
+
f"compact_failures={compact.consecutive_failure_count}, "
|
|
679
|
+
f"compact_retry_after_messages="
|
|
680
|
+
f"{compact.retry_after_message_count}"
|
|
681
|
+
)
|
|
682
|
+
if context.memory_stats is not None:
|
|
683
|
+
memory = context.memory_stats
|
|
684
|
+
summary += (
|
|
685
|
+
f", memory={memory.included_entry_count} injected/"
|
|
686
|
+
f"{memory.selected_entry_count} selected/"
|
|
687
|
+
f"{memory.relevant_entry_count} relevant/"
|
|
688
|
+
f"{memory.safe_entry_count} safe "
|
|
689
|
+
f"(~{memory.estimated_tokens:,} tokens)"
|
|
690
|
+
)
|
|
691
|
+
if memory.scopes:
|
|
692
|
+
summary += f", memory_scopes={'+'.join(memory.scopes)}"
|
|
693
|
+
if memory.irrelevant_entry_count > 0:
|
|
694
|
+
summary += f", memory_irrelevant={memory.irrelevant_entry_count}"
|
|
695
|
+
if memory.conflict_count > 0:
|
|
696
|
+
summary += f", memory_conflicts={memory.conflict_count}"
|
|
697
|
+
if memory.budget_omitted_count > 0:
|
|
698
|
+
summary += f", memory_budget_omitted={memory.budget_omitted_count}"
|
|
699
|
+
if memory.issue_count > 0:
|
|
700
|
+
summary += f", memory_warnings={memory.issue_count}"
|
|
701
|
+
if context.retention_stats is not None:
|
|
702
|
+
retention = context.retention_stats
|
|
703
|
+
summary += (
|
|
704
|
+
f", tool_groups={retention.full_groups} full/"
|
|
705
|
+
f"{retention.artifact_groups} artifact/{retention.metadata_groups} metadata"
|
|
706
|
+
f", downgraded={retention.budget_downgraded_groups}"
|
|
707
|
+
f", rehydration_failures={retention.rehydration_failures}"
|
|
708
|
+
)
|
|
709
|
+
if previous_prompt_tokens is not None:
|
|
710
|
+
summary += f", previous_actual_input={previous_prompt_tokens:,} tokens"
|
|
711
|
+
if not model_context_needs_notice(context):
|
|
712
|
+
return summary
|
|
713
|
+
|
|
714
|
+
return (
|
|
715
|
+
f"{summary}, trimmed={context.trimmed_message_count}, "
|
|
716
|
+
f"compressed={context.compressed_tool_result_count}, "
|
|
717
|
+
f"over_budget={context.estimate.over_budget}"
|
|
718
|
+
)
|
|
719
|
+
|
|
720
|
+
|
|
721
|
+
def _estimate_tool_calls(message: Message) -> int:
|
|
722
|
+
model_tool_calls = message.to_model_dict().get("tool_calls")
|
|
723
|
+
if not isinstance(model_tool_calls, list):
|
|
724
|
+
return 0
|
|
725
|
+
|
|
726
|
+
return len(json.dumps(model_tool_calls, ensure_ascii=False))
|
|
727
|
+
|
|
728
|
+
|
|
729
|
+
def _serialize_tool_schemas(tools: list[dict[str, object]] | None) -> str:
|
|
730
|
+
if not tools:
|
|
731
|
+
return ""
|
|
732
|
+
|
|
733
|
+
openai_tools = [{"type": "function", "function": dict(tool)} for tool in tools]
|
|
734
|
+
return json.dumps(openai_tools, ensure_ascii=False)
|
|
735
|
+
|
|
736
|
+
|
|
737
|
+
def _count_non_ascii(content: str) -> int:
|
|
738
|
+
return sum(1 for character in content if ord(character) > 127)
|
|
739
|
+
|
|
740
|
+
|
|
741
|
+
def _text_profile(total_chars: int, non_ascii_chars: int) -> str:
|
|
742
|
+
if total_chars < 1 or non_ascii_chars / total_chars < 0.1:
|
|
743
|
+
return "ascii"
|
|
744
|
+
if non_ascii_chars / total_chars < 0.5:
|
|
745
|
+
return "mixed"
|
|
746
|
+
return "non_ascii"
|
|
747
|
+
|
|
748
|
+
|
|
749
|
+
def _percentile(values: list[float], percentile: float) -> float:
|
|
750
|
+
ordered = sorted(values)
|
|
751
|
+
index = max(0, math.ceil(len(ordered) * percentile) - 1)
|
|
752
|
+
return ordered[index]
|