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.
Files changed (121) hide show
  1. mycode/__init__.py +0 -0
  2. mycode/adapters/__init__.py +21 -0
  3. mycode/adapters/jsonl.py +692 -0
  4. mycode/agent/__init__.py +25 -0
  5. mycode/agent/events.py +111 -0
  6. mycode/agent/outcome.py +103 -0
  7. mycode/agent/progress.py +373 -0
  8. mycode/agent/runner.py +1481 -0
  9. mycode/application/__init__.py +38 -0
  10. mycode/application/agent_session.py +367 -0
  11. mycode/application/events.py +59 -0
  12. mycode/application/runtime.py +211 -0
  13. mycode/application/sessions.py +180 -0
  14. mycode/cli.py +840 -0
  15. mycode/config.py +355 -0
  16. mycode/context/__init__.py +1 -0
  17. mycode/context/artifacts.py +672 -0
  18. mycode/context/budget.py +752 -0
  19. mycode/context/builder.py +112 -0
  20. mycode/context/compact.py +795 -0
  21. mycode/context/tool_result_format.py +199 -0
  22. mycode/context/tool_result_retention.py +261 -0
  23. mycode/conversation.py +78 -0
  24. mycode/error_handling.py +481 -0
  25. mycode/event_format.py +147 -0
  26. mycode/instructions.py +285 -0
  27. mycode/llm.py +771 -0
  28. mycode/mcp/__init__.py +41 -0
  29. mycode/mcp/client.py +44 -0
  30. mycode/mcp/config.py +207 -0
  31. mycode/mcp/errors.py +302 -0
  32. mycode/mcp/manager.py +339 -0
  33. mycode/mcp/models.py +20 -0
  34. mycode/mcp/result_adapter.py +58 -0
  35. mycode/mcp/tool_adapter.py +145 -0
  36. mycode/mcp/trust.py +313 -0
  37. mycode/memory.py +570 -0
  38. mycode/memory_context.py +245 -0
  39. mycode/messages.py +63 -0
  40. mycode/observability.py +28 -0
  41. mycode/permissions.py +262 -0
  42. mycode/persistence/__init__.py +1 -0
  43. mycode/persistence/filesystem.py +291 -0
  44. mycode/persistence/project_storage.py +208 -0
  45. mycode/persistence/session_lock.py +138 -0
  46. mycode/persistence/session_store.py +503 -0
  47. mycode/presentation/__init__.py +1 -0
  48. mycode/presentation/cli/__init__.py +14 -0
  49. mycode/presentation/cli/confirmer.py +116 -0
  50. mycode/presentation/cli/mcp_trust.py +61 -0
  51. mycode/presentation/cli/presenter.py +320 -0
  52. mycode/presentation/cli/session_menu.py +146 -0
  53. mycode/presentation/cli/subagent_observer.py +124 -0
  54. mycode/presentation/command_format.py +90 -0
  55. mycode/presentation/commands.py +95 -0
  56. mycode/presentation/tui/__init__.py +6 -0
  57. mycode/presentation/tui/app.py +1351 -0
  58. mycode/presentation/tui/interactions.py +253 -0
  59. mycode/presentation/tui/presenter.py +266 -0
  60. mycode/presentation/tui/screens.py +305 -0
  61. mycode/presentation/tui/widgets.py +214 -0
  62. mycode/project.py +22 -0
  63. mycode/prompts.py +181 -0
  64. mycode/reasoning.py +40 -0
  65. mycode/session.py +86 -0
  66. mycode/skills/__init__.py +27 -0
  67. mycode/skills/builtin/database-recovery/SKILL.md +138 -0
  68. mycode/skills/builtin/database-recovery/references/sqlite.md +235 -0
  69. mycode/skills/registry.py +295 -0
  70. mycode/skills/state.py +68 -0
  71. mycode/subagents/__init__.py +1 -0
  72. mycode/subagents/audit.py +212 -0
  73. mycode/subagents/concurrency.py +124 -0
  74. mycode/subagents/contracts.py +421 -0
  75. mycode/subagents/delegate.py +80 -0
  76. mycode/subagents/delegation.py +128 -0
  77. mycode/subagents/lifecycle.py +86 -0
  78. mycode/subagents/limits.py +7 -0
  79. mycode/subagents/observability.py +150 -0
  80. mycode/subagents/persistence.py +152 -0
  81. mycode/subagents/profiles.py +184 -0
  82. mycode/subagents/prompts.py +67 -0
  83. mycode/subagents/results.py +178 -0
  84. mycode/subagents/runtime.py +528 -0
  85. mycode/subagents/snapshots.py +211 -0
  86. mycode/subagents/tool_batch.py +260 -0
  87. mycode/tools/__init__.py +81 -0
  88. mycode/tools/base.py +222 -0
  89. mycode/tools/bounds.py +14 -0
  90. mycode/tools/command_executor.py +167 -0
  91. mycode/tools/command_output.py +166 -0
  92. mycode/tools/command_risk.py +596 -0
  93. mycode/tools/defaults.py +59 -0
  94. mycode/tools/edit_file.py +524 -0
  95. mycode/tools/file_mutation.py +30 -0
  96. mycode/tools/glob.py +247 -0
  97. mycode/tools/grep.py +324 -0
  98. mycode/tools/ignore.py +122 -0
  99. mycode/tools/inspect_changes.py +269 -0
  100. mycode/tools/load_skill.py +92 -0
  101. mycode/tools/memory.py +264 -0
  102. mycode/tools/path_permissions.py +78 -0
  103. mycode/tools/patterns.py +48 -0
  104. mycode/tools/permission_metadata.py +27 -0
  105. mycode/tools/process_tree.py +166 -0
  106. mycode/tools/read_file.py +242 -0
  107. mycode/tools/read_skill_resource.py +93 -0
  108. mycode/tools/registry.py +279 -0
  109. mycode/tools/run_command.py +237 -0
  110. mycode/tools/run_skill_script.py +206 -0
  111. mycode/tools/run_validation.py +107 -0
  112. mycode/tools/submit_result.py +93 -0
  113. mycode/tools/text.py +15 -0
  114. mycode/tools/validation_command.py +377 -0
  115. mycode/tools/workspace.py +33 -0
  116. mycode/tools/write_file.py +169 -0
  117. mycode_coding_agent-0.1.0.dist-info/METADATA +244 -0
  118. mycode_coding_agent-0.1.0.dist-info/RECORD +121 -0
  119. mycode_coding_agent-0.1.0.dist-info/WHEEL +4 -0
  120. mycode_coding_agent-0.1.0.dist-info/entry_points.txt +2 -0
  121. mycode_coding_agent-0.1.0.dist-info/licenses/LICENSE +21 -0
@@ -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]