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
|
@@ -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()
|