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,199 @@
1
+ """Shared tool protocol grouping and compact result formatting (no storage policy)."""
2
+
3
+ from dataclasses import dataclass
4
+ import json
5
+
6
+ from mycode.messages import Message
7
+
8
+
9
+ TOOL_RESULT_METADATA_MARKER = "\n\nMETADATA\n"
10
+ COMPRESSED_TOOL_RESULT_MARKER = "[tool result compressed]"
11
+ LARGE_TOOL_METADATA_KEYS = {"stdout", "stderr"}
12
+ MAX_METADATA_STRING_CHARS = 200
13
+ MAX_TOOL_RESULT_PREVIEW_CHARS = 200
14
+
15
+
16
+ def _group_non_system_messages(
17
+ messages: tuple[Message, ...],
18
+ ) -> list[tuple[Message, ...]]:
19
+ groups: list[tuple[Message, ...]] = []
20
+ index = 0
21
+ while index < len(messages):
22
+ message = messages[index]
23
+ if message.role in {"system", "tool"}:
24
+ index += 1
25
+ continue
26
+
27
+ if message.role == "assistant" and message.tool_calls:
28
+ expected_tool_call_ids = [
29
+ tool_call.id for tool_call in message.tool_calls
30
+ ]
31
+ tool_results: list[Message] = []
32
+ next_index = index + 1
33
+ while (
34
+ next_index < len(messages)
35
+ and messages[next_index].role == "tool"
36
+ ):
37
+ tool_results.append(messages[next_index])
38
+ next_index += 1
39
+
40
+ result_ids = [message.tool_call_id for message in tool_results]
41
+ chain_is_complete = (
42
+ len(expected_tool_call_ids) == len(set(expected_tool_call_ids))
43
+ and len(result_ids) == len(expected_tool_call_ids)
44
+ and len(result_ids) == len(set(result_ids))
45
+ and set(result_ids) == set(expected_tool_call_ids)
46
+ )
47
+ if chain_is_complete:
48
+ groups.append((message, *tool_results))
49
+
50
+ index = next_index
51
+ continue
52
+
53
+ groups.append((message,))
54
+ index += 1
55
+
56
+ return groups
57
+
58
+
59
+ def _flatten_groups(groups: list[tuple[Message, ...]]) -> tuple[Message, ...]:
60
+ return tuple(message for group in groups for message in group)
61
+
62
+
63
+ def _group_has_tool_result(group: tuple[Message, ...]) -> bool:
64
+ return any(message.role == "tool" for message in group)
65
+
66
+
67
+ def _tool_names_by_id(group: tuple[Message, ...]) -> dict[str, str]:
68
+ tool_names: dict[str, str] = {}
69
+ for message in group:
70
+ if message.role != "assistant":
71
+ continue
72
+
73
+ for tool_call in message.tool_calls:
74
+ tool_names[tool_call.id] = tool_call.name
75
+
76
+ return tool_names
77
+
78
+
79
+ def _compress_tool_result(
80
+ message: Message,
81
+ threshold_chars: int,
82
+ tool_names_by_id: dict[str, str],
83
+ ) -> Message:
84
+ if message.role != "tool":
85
+ return message
86
+
87
+ if len(message.content) <= threshold_chars:
88
+ return message
89
+
90
+ parsed = parse_tool_result_content(message.content)
91
+ if parsed.metadata.get("context_compressed") is True:
92
+ return message
93
+ tool_name = tool_names_by_id.get(message.tool_call_id or "", "unknown")
94
+ metadata = safe_tool_metadata(parsed.metadata)
95
+ original_chars = len(message.content)
96
+ if parsed.metadata.get("context_externalized") is True:
97
+ original_chars = parsed.metadata.get("original_chars", original_chars)
98
+ metadata.pop("context_externalized", None)
99
+ for key in ("artifact_path", "artifact_sha256"):
100
+ if key in parsed.metadata:
101
+ metadata[key] = parsed.metadata[key]
102
+ if parsed.result_preview and not parsed.metadata.get("context_externalized"):
103
+ preview_key = "error_preview" if parsed.status == "ERROR" else "result_preview"
104
+ metadata[preview_key] = parsed.result_preview
105
+ metadata.update(
106
+ {
107
+ "context_compressed": True,
108
+ "original_chars": original_chars,
109
+ "tool_name": tool_name,
110
+ "tool_call_id": message.tool_call_id,
111
+ }
112
+ )
113
+ compressed_content = (
114
+ f"{parsed.status}\n"
115
+ f"{COMPRESSED_TOOL_RESULT_MARKER}\n"
116
+ f"tool_name: {tool_name}\n"
117
+ f"original_chars: {original_chars}\n"
118
+ f"{TOOL_RESULT_METADATA_MARKER}"
119
+ f"{json.dumps(metadata, ensure_ascii=False, sort_keys=True, default=str)}"
120
+ )
121
+
122
+ return Message(
123
+ role="tool",
124
+ content=compressed_content,
125
+ tool_call_id=message.tool_call_id,
126
+ )
127
+
128
+
129
+ @dataclass(frozen=True)
130
+ class ParsedToolResultContent:
131
+ status: str
132
+ metadata: dict[str, object]
133
+ result_preview: str
134
+
135
+
136
+ def parse_tool_result_content(content: str) -> ParsedToolResultContent:
137
+ body, _separator, metadata_text = content.partition(TOOL_RESULT_METADATA_MARKER)
138
+ body_lines = body.splitlines()
139
+ first_line = body_lines[0] if body_lines else "UNKNOWN"
140
+ status = first_line if first_line in {"OK", "ERROR"} else "UNKNOWN"
141
+ result_body = "\n".join(body_lines[1:]) if status != "UNKNOWN" else body
142
+ result_preview = _truncate_tool_result_preview(result_body.strip())
143
+
144
+ try:
145
+ metadata = json.loads(metadata_text) if metadata_text else {}
146
+ except json.JSONDecodeError:
147
+ metadata = {}
148
+
149
+ if not isinstance(metadata, dict):
150
+ metadata = {}
151
+
152
+ return ParsedToolResultContent(
153
+ status=status,
154
+ metadata=metadata,
155
+ result_preview=result_preview,
156
+ )
157
+
158
+
159
+ def _truncate_tool_result_preview(content: str) -> str:
160
+ if len(content) <= MAX_TOOL_RESULT_PREVIEW_CHARS:
161
+ return content
162
+
163
+ return f"{content[:MAX_TOOL_RESULT_PREVIEW_CHARS]}..."
164
+
165
+
166
+ def safe_tool_metadata(metadata: dict[str, object]) -> dict[str, object]:
167
+ safe_metadata: dict[str, object] = {}
168
+ for key, value in metadata.items():
169
+ if key in LARGE_TOOL_METADATA_KEYS:
170
+ safe_metadata[f"{key}_omitted"] = True
171
+ continue
172
+
173
+ safe_metadata[key] = _safe_metadata_value(value)
174
+
175
+ return safe_metadata
176
+
177
+
178
+ def _safe_metadata_value(value: object) -> object:
179
+ if isinstance(value, str) and len(value) > MAX_METADATA_STRING_CHARS:
180
+ return f"{value[:MAX_METADATA_STRING_CHARS]}..."
181
+
182
+ if isinstance(value, list):
183
+ return [_safe_metadata_value(item) for item in value[:20]]
184
+
185
+ if isinstance(value, dict):
186
+ return {
187
+ str(key): _safe_metadata_value(item)
188
+ for key, item in list(value.items())[:20]
189
+ }
190
+
191
+ return value
192
+
193
+
194
+ def _count_compressed_tool_results(messages: tuple[Message, ...]) -> int:
195
+ return sum(
196
+ 1
197
+ for message in messages
198
+ if message.role == "tool" and COMPRESSED_TOOL_RESULT_MARKER in message.content
199
+ )
@@ -0,0 +1,261 @@
1
+ """Request-local tool result projection and atomic precision degradation."""
2
+
3
+ from collections.abc import Iterator
4
+ from dataclasses import dataclass, field, replace
5
+ from typing import TYPE_CHECKING
6
+
7
+ from mycode.context.artifacts import (
8
+ ArtifactExternalizationFailureHandler,
9
+ EXTERNALIZED_TOOL_RESULT_MARKER,
10
+ ToolResultArtifactStore,
11
+ artifact_externalization_failure_content,
12
+ artifact_failure_reason,
13
+ artifact_reference_info,
14
+ )
15
+ from mycode.conversation import Conversation
16
+ from mycode.messages import Message
17
+ from mycode.context.tool_result_format import (
18
+ COMPRESSED_TOOL_RESULT_MARKER,
19
+ _compress_tool_result,
20
+ _group_non_system_messages,
21
+ _group_has_tool_result,
22
+ _tool_names_by_id,
23
+ )
24
+
25
+ if TYPE_CHECKING:
26
+ from mycode.context.budget import ContextBudget
27
+
28
+ ToolGroup = tuple[Message, ...]
29
+
30
+
31
+ @dataclass(frozen=True)
32
+ class TurnLocalFullGroup:
33
+ """One freshly persisted batch, handed off to the next context build only."""
34
+
35
+ assistant: Message
36
+ # call id -> (persisted reference, original full content); no disk validation
37
+ # is needed for these originals supplied by the just-completed execution.
38
+ results: dict[str, tuple[str, str]]
39
+ externalized_count: int = 0
40
+
41
+ def content_for(self, assistant: Message, result: Message) -> str | None:
42
+ if assistant is not self.assistant:
43
+ return None
44
+ entry = self.results.get(result.tool_call_id or "")
45
+ if entry is None or entry[0] != result.content:
46
+ return None
47
+ return entry[1]
48
+
49
+
50
+ @dataclass(frozen=True)
51
+ class ToolResultRetentionStats:
52
+ full_groups: int = 0
53
+ artifact_groups: int = 0
54
+ metadata_groups: int = 0
55
+ budget_downgraded_groups: int = 0
56
+ rehydration_failures: int = 0
57
+ turn_local_full_groups: int = 0
58
+ artifact_rehydrated_groups: int = 0
59
+ artifact_rehydrate_count: int = 0
60
+ artifact_externalized_count: int = 0
61
+
62
+
63
+ @dataclass
64
+ class RetentionProjection:
65
+ conversation: Conversation
66
+ # Assistant object identity survives Compact's raw tail; call ids may repeat.
67
+ # Alternatives contain canonical refs, never a second cache of full payloads.
68
+ artifact_groups: dict[int, ToolGroup] = field(default_factory=dict)
69
+ rehydration_failures: int = 0
70
+ turn_local_full_groups: int = 0
71
+ artifact_rehydrated_groups: int = 0
72
+ artifact_rehydrate_count: int = 0
73
+ artifact_externalized_count: int = 0
74
+
75
+
76
+ @dataclass(frozen=True)
77
+ class ToolResultRetentionPolicy:
78
+ budget: "ContextBudget"
79
+ artifact_store: ToolResultArtifactStore | None = None
80
+ on_externalization_failure: ArtifactExternalizationFailureHandler | None = None
81
+
82
+ def project(
83
+ self, conversation: Conversation, *,
84
+ turn_local_full_group: TurnLocalFullGroup | None = None,
85
+ ) -> RetentionProjection:
86
+ projection = RetentionProjection(
87
+ conversation,
88
+ artifact_externalized_count=(
89
+ 0
90
+ if turn_local_full_group is None
91
+ else turn_local_full_group.externalized_count
92
+ ),
93
+ )
94
+ groups = [
95
+ group for group in _group_non_system_messages(tuple(conversation.get_messages()))
96
+ if _group_has_tool_result(group)
97
+ ]
98
+ keep = self.budget.recent_tool_result_groups_to_keep
99
+ recent = {id(g[0]) for g in groups[-keep:]} if keep else set()
100
+ replacements: dict[int, Message] = {}
101
+ for group in groups:
102
+ names = _tool_names_by_id(group)
103
+ canonical_messages = []
104
+ for message in group:
105
+ canonical_message = self._canonical(message, names)
106
+ if (
107
+ canonical_message.content != message.content
108
+ and canonical_message.role == "tool"
109
+ and artifact_reference_info(
110
+ tool_name=names.get(
111
+ canonical_message.tool_call_id or "",
112
+ "unknown",
113
+ ),
114
+ tool_call_id=canonical_message.tool_call_id,
115
+ content=canonical_message.content,
116
+ )
117
+ is not None
118
+ ):
119
+ projection.artifact_externalized_count += 1
120
+ canonical_messages.append(canonical_message)
121
+ canonical = tuple(canonical_messages)
122
+ projected = canonical
123
+ if self.artifact_store is not None and id(group[0]) in recent:
124
+ restored = []
125
+ failures = 0
126
+ turn_local_hits = 0
127
+ rehydrate_attempts = 0
128
+ for message in canonical:
129
+ if message.role != "tool" or not self._is_reference(message, names):
130
+ restored.append(message)
131
+ continue
132
+ try:
133
+ content = (
134
+ turn_local_full_group.content_for(group[0], message)
135
+ if turn_local_full_group is not None and group is groups[-1]
136
+ else None
137
+ )
138
+ if content is None:
139
+ rehydrate_attempts += 1
140
+ projection.artifact_rehydrate_count += 1
141
+ content = self.artifact_store.rehydrate(
142
+ tool_name=names[message.tool_call_id],
143
+ tool_call_id=message.tool_call_id,
144
+ content=message.content,
145
+ )
146
+ else:
147
+ turn_local_hits += 1
148
+ restored.append(replace(message, content=content))
149
+ except Exception:
150
+ failures += 1
151
+ projection.rehydration_failures += failures
152
+ # Failure also applies atomically: don't mix successfully restored
153
+ # siblings with an unavailable reference in the same batch.
154
+ if not failures:
155
+ projected = tuple(restored)
156
+ if projected != canonical and turn_local_hits:
157
+ projection.turn_local_full_groups += 1
158
+ if projected != canonical and rehydrate_attempts:
159
+ projection.artifact_rehydrated_groups += 1
160
+ if projected != canonical:
161
+ projection.artifact_groups[id(group[0])] = canonical
162
+ replacements.update({id(old): new for old, new in zip(group, projected)})
163
+ messages = [replacements.get(id(m), m) for m in conversation.get_messages()]
164
+ if any(a is not b for a, b in zip(messages, conversation.get_messages())):
165
+ projection.conversation = Conversation.from_messages(messages)
166
+ return projection
167
+
168
+ def _canonical(self, message: Message, names: dict[str, str]) -> Message:
169
+ if message.role != "tool" or self.artifact_store is None:
170
+ return message
171
+ # Structural recognition is independent of availability. A missing file
172
+ # must not turn its reference into a newly externalized artifact-of-ref.
173
+ if self._is_reference(message, names):
174
+ return message
175
+ name = names.get(message.tool_call_id or "", "unknown")
176
+ try:
177
+ content = self.artifact_store.externalize(
178
+ tool_name=name, tool_call_id=message.tool_call_id, content=message.content,
179
+ )
180
+ except Exception as error:
181
+ if self.on_externalization_failure is not None:
182
+ content = self.on_externalization_failure(
183
+ name, message.tool_call_id, message.content, error,
184
+ )
185
+ else:
186
+ content = artifact_externalization_failure_content(
187
+ tool_name=name, tool_call_id=message.tool_call_id,
188
+ original_content=message.content, reason=artifact_failure_reason(error),
189
+ )
190
+ return message if content == message.content else replace(message, content=content)
191
+
192
+ @staticmethod
193
+ def _is_reference(message: Message, names: dict[str, str]) -> bool:
194
+ if artifact_reference_info(
195
+ tool_name=names.get(message.tool_call_id or "", "unknown"),
196
+ tool_call_id=message.tool_call_id, content=message.content,
197
+ ) is not None:
198
+ return True
199
+ # Damaged references also stay references: rehydrate rejects them and
200
+ # the whole batch falls back instead of externalizing the ref text.
201
+ lines = message.content.split("\n", 2)
202
+ return len(lines) > 1 and lines[1] == EXTERNALIZED_TOOL_RESULT_MARKER
203
+
204
+ def artifact_candidates(
205
+ self, groups: list[ToolGroup], projection: RetentionProjection,
206
+ ) -> Iterator[tuple[int, ToolGroup]]:
207
+ for index, group in enumerate(groups):
208
+ alternative = projection.artifact_groups.get(id(group[0]))
209
+ if alternative is not None and alternative != group:
210
+ yield index, alternative
211
+
212
+ def metadata_candidates(
213
+ self, groups: list[ToolGroup], *, include_recent: bool = True,
214
+ projection: RetentionProjection | None = None,
215
+ ) -> Iterator[tuple[int, ToolGroup]]:
216
+ indexes = [i for i, group in enumerate(groups) if _group_has_tool_result(group)]
217
+ keep = self.budget.recent_tool_result_groups_to_keep
218
+ if not include_recent and keep:
219
+ indexes = indexes[:-keep]
220
+ for index in indexes:
221
+ group = groups[index]
222
+ # A ref may cost more than a small restored Full and be skipped by
223
+ # Budget. Still retain its locator when producing Metadata.
224
+ source = (
225
+ group if projection is None
226
+ else projection.artifact_groups.get(id(group[0]), group)
227
+ )
228
+ names = _tool_names_by_id(group)
229
+ threshold = (
230
+ 0 if include_recent else self.budget.tool_result_compression_threshold_chars
231
+ )
232
+ compressed = tuple(
233
+ _compress_tool_result(message, threshold, names) for message in source
234
+ )
235
+ if compressed != group:
236
+ yield index, compressed
237
+
238
+ @staticmethod
239
+ def stats(
240
+ groups: list[ToolGroup], projection: RetentionProjection, downgraded: set[int],
241
+ ) -> ToolResultRetentionStats:
242
+ counts = {"full": 0, "artifact": 0, "metadata": 0}
243
+ for group in groups:
244
+ results = [m.content for m in group if m.role == "tool"]
245
+ if not results:
246
+ continue
247
+ if any(COMPRESSED_TOOL_RESULT_MARKER in content for content in results):
248
+ counts["metadata"] += 1
249
+ elif any(EXTERNALIZED_TOOL_RESULT_MARKER in content for content in results):
250
+ counts["artifact"] += 1
251
+ else:
252
+ counts["full"] += 1
253
+ return ToolResultRetentionStats(
254
+ full_groups=counts["full"], artifact_groups=counts["artifact"],
255
+ metadata_groups=counts["metadata"], budget_downgraded_groups=len(downgraded),
256
+ rehydration_failures=projection.rehydration_failures,
257
+ turn_local_full_groups=projection.turn_local_full_groups,
258
+ artifact_rehydrated_groups=projection.artifact_rehydrated_groups,
259
+ artifact_rehydrate_count=projection.artifact_rehydrate_count,
260
+ artifact_externalized_count=projection.artifact_externalized_count,
261
+ )
mycode/conversation.py ADDED
@@ -0,0 +1,78 @@
1
+ from collections.abc import Callable
2
+ from dataclasses import dataclass, field
3
+
4
+ from mycode.agent.events import AgentToolCall
5
+ from mycode.messages import Message
6
+ from mycode.reasoning import ReasoningState
7
+
8
+
9
+ @dataclass
10
+ class Conversation:
11
+ _messages: list[Message] = field(default_factory=list)
12
+ _on_message_added: Callable[[Message], None] | None = field(
13
+ default=None,
14
+ repr=False,
15
+ compare=False,
16
+ )
17
+
18
+ @classmethod
19
+ def from_messages(
20
+ cls,
21
+ messages: list[Message],
22
+ *,
23
+ on_message_added: Callable[[Message], None] | None = None,
24
+ ) -> "Conversation":
25
+ return cls(
26
+ _messages=list(messages),
27
+ _on_message_added=on_message_added,
28
+ )
29
+
30
+ def add_message(self, message: Message) -> None:
31
+ if self._on_message_added is not None:
32
+ self._on_message_added(message)
33
+ self._messages.append(message)
34
+
35
+ def add_system_message(self, content: str) -> None:
36
+ self.add_message(Message(role="system", content=content))
37
+
38
+ def add_user_message(self, content: str) -> None:
39
+ self.add_message(Message(role="user", content=content))
40
+
41
+ def add_assistant_message(self, content: str) -> None:
42
+ self.add_message(Message(role="assistant", content=content))
43
+
44
+ def add_assistant_tool_calls(
45
+ self,
46
+ content: str,
47
+ tool_calls: list[AgentToolCall],
48
+ *,
49
+ reasoning_content: str | None = None,
50
+ reasoning_state: ReasoningState = "absent",
51
+ ) -> None:
52
+ self.add_message(
53
+ Message(
54
+ role="assistant",
55
+ content=content,
56
+ tool_calls=tuple(tool_calls),
57
+ reasoning_content=reasoning_content,
58
+ reasoning_state=reasoning_state,
59
+ )
60
+ )
61
+
62
+ def add_tool_result_message(self, tool_call_id: str, content: str) -> None:
63
+ self.add_message(
64
+ Message(
65
+ role="tool",
66
+ content=content,
67
+ tool_call_id=tool_call_id,
68
+ )
69
+ )
70
+
71
+ def get_messages(self) -> list[Message]:
72
+ return list(self._messages)
73
+
74
+ def to_model_messages(self) -> list[dict[str, object]]:
75
+ return [message.to_model_dict() for message in self._messages]
76
+
77
+ def clear(self) -> None:
78
+ self._messages.clear()