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,672 @@
|
|
|
1
|
+
from collections.abc import Callable
|
|
2
|
+
import codecs
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
import hashlib
|
|
5
|
+
import json
|
|
6
|
+
import os
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
import stat
|
|
9
|
+
import tempfile
|
|
10
|
+
|
|
11
|
+
from pydantic import Field, field_validator
|
|
12
|
+
|
|
13
|
+
from mycode.context.tool_result_format import (
|
|
14
|
+
TOOL_RESULT_METADATA_MARKER,
|
|
15
|
+
parse_tool_result_content,
|
|
16
|
+
safe_tool_metadata,
|
|
17
|
+
)
|
|
18
|
+
from mycode.conversation import Conversation
|
|
19
|
+
from mycode.messages import Message
|
|
20
|
+
from mycode.tools.base import PydanticTool, ToolArgs, ToolResult
|
|
21
|
+
from mycode.tools.bounds import clamp_positive_int_upper_bound
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
EXTERNALIZED_TOOL_RESULT_MARKER = "[tool result externalized]"
|
|
25
|
+
ARTIFACT_EXTERNALIZATION_FAILURE_MARKER = (
|
|
26
|
+
"[tool result unavailable: artifact externalization failed]"
|
|
27
|
+
)
|
|
28
|
+
DEFAULT_ARTIFACT_READ_CHARS = 4000
|
|
29
|
+
MAX_ARTIFACT_READ_CHARS = 8000
|
|
30
|
+
ARTIFACT_IO_CHUNK_BYTES = 64 * 1024
|
|
31
|
+
ARTIFACT_IO_CHUNK_CHARS = 64 * 1024
|
|
32
|
+
MAX_READABLE_ARTIFACT_BYTES = 64 * 1024 * 1024
|
|
33
|
+
MAX_ARTIFACT_REFERENCE_METADATA_CHARS = 2400
|
|
34
|
+
|
|
35
|
+
ArtifactExternalizationFailureHandler = Callable[
|
|
36
|
+
[str, str, str, Exception],
|
|
37
|
+
str,
|
|
38
|
+
]
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
class _ArtifactTooLargeError(RuntimeError):
|
|
42
|
+
pass
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
@dataclass(frozen=True)
|
|
46
|
+
class _ArtifactTextScan:
|
|
47
|
+
digest: str
|
|
48
|
+
total_bytes: int
|
|
49
|
+
total_chars: int
|
|
50
|
+
selected_content: str
|
|
51
|
+
end_offset_chars: int
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
@dataclass(frozen=True)
|
|
55
|
+
class ToolResultArtifactStore:
|
|
56
|
+
"""Content-addressed storage for large, model-visible tool results."""
|
|
57
|
+
|
|
58
|
+
root: Path
|
|
59
|
+
threshold_chars: int
|
|
60
|
+
|
|
61
|
+
def __post_init__(self) -> None:
|
|
62
|
+
if self.threshold_chars < 1:
|
|
63
|
+
raise ValueError("threshold_chars must be at least 1.")
|
|
64
|
+
object.__setattr__(self, "root", self.root.resolve(strict=False))
|
|
65
|
+
|
|
66
|
+
def externalize(
|
|
67
|
+
self,
|
|
68
|
+
*,
|
|
69
|
+
tool_name: str,
|
|
70
|
+
tool_call_id: str,
|
|
71
|
+
content: str,
|
|
72
|
+
) -> str:
|
|
73
|
+
if len(content) <= self.threshold_chars:
|
|
74
|
+
return content
|
|
75
|
+
if _is_valid_artifact_reference(
|
|
76
|
+
self.root,
|
|
77
|
+
tool_name=tool_name,
|
|
78
|
+
tool_call_id=tool_call_id,
|
|
79
|
+
content=content,
|
|
80
|
+
) or _is_valid_artifact_failure_reference(
|
|
81
|
+
tool_name=tool_name,
|
|
82
|
+
tool_call_id=tool_call_id,
|
|
83
|
+
content=content,
|
|
84
|
+
):
|
|
85
|
+
return content
|
|
86
|
+
|
|
87
|
+
digest = _sha256_text(content)
|
|
88
|
+
artifact_path = self.root / f"{digest}.txt"
|
|
89
|
+
self._write_once(artifact_path, content, digest=digest)
|
|
90
|
+
|
|
91
|
+
parsed = parse_tool_result_content(content)
|
|
92
|
+
reference_metadata: dict[str, object] = {
|
|
93
|
+
"artifact_path": artifact_path.as_posix(),
|
|
94
|
+
"artifact_sha256": digest,
|
|
95
|
+
"context_externalized": True,
|
|
96
|
+
"original_chars": len(content),
|
|
97
|
+
"tool_call_id": tool_call_id,
|
|
98
|
+
"tool_name": tool_name,
|
|
99
|
+
}
|
|
100
|
+
if parsed.result_preview:
|
|
101
|
+
preview_key = (
|
|
102
|
+
"error_preview" if parsed.status == "ERROR" else "result_preview"
|
|
103
|
+
)
|
|
104
|
+
reference_metadata[preview_key] = parsed.result_preview
|
|
105
|
+
reference_metadata.update(
|
|
106
|
+
_bounded_optional_metadata(
|
|
107
|
+
reference_metadata,
|
|
108
|
+
safe_tool_metadata(parsed.metadata),
|
|
109
|
+
)
|
|
110
|
+
)
|
|
111
|
+
|
|
112
|
+
return (
|
|
113
|
+
f"{parsed.status}\n"
|
|
114
|
+
f"{EXTERNALIZED_TOOL_RESULT_MARKER}\n"
|
|
115
|
+
f"tool_name: {tool_name}\n"
|
|
116
|
+
f"artifact_path: {artifact_path.as_posix()}\n"
|
|
117
|
+
f"original_chars: {len(content)}\n"
|
|
118
|
+
f"sha256: {digest}\n"
|
|
119
|
+
f"{TOOL_RESULT_METADATA_MARKER}"
|
|
120
|
+
f"{json.dumps(reference_metadata, ensure_ascii=False, sort_keys=True)}"
|
|
121
|
+
)
|
|
122
|
+
|
|
123
|
+
def rehydrate(self, *, tool_name: str, tool_call_id: str, content: str) -> str:
|
|
124
|
+
"""Read a validated reference in this store; never trust a model-supplied path."""
|
|
125
|
+
info = artifact_reference_info(
|
|
126
|
+
tool_name=tool_name, tool_call_id=tool_call_id, content=content,
|
|
127
|
+
)
|
|
128
|
+
if info is None:
|
|
129
|
+
raise ValueError("Invalid artifact reference.")
|
|
130
|
+
path, digest, original_chars = info
|
|
131
|
+
if not path.is_absolute() or path.parent != self.root or path.name != f"{digest}.txt":
|
|
132
|
+
raise ValueError("Artifact reference is outside this store.")
|
|
133
|
+
# Check the lexical path before resolving, including junctions in parents.
|
|
134
|
+
if any(_is_link_or_reparse_point(part) for part in (path, *path.parents)):
|
|
135
|
+
raise ValueError("Artifact reference traverses a link or reparse point.")
|
|
136
|
+
if path.resolve(strict=True).parent != self.root or not path.is_file():
|
|
137
|
+
raise ValueError("Artifact is not a regular file in this store.")
|
|
138
|
+
if original_chars > MAX_READABLE_ARTIFACT_BYTES:
|
|
139
|
+
raise _ArtifactTooLargeError("Artifact is too large to rehydrate.")
|
|
140
|
+
scan = _scan_utf8_artifact(
|
|
141
|
+
path, offset_chars=0, max_chars=original_chars,
|
|
142
|
+
max_bytes=MAX_READABLE_ARTIFACT_BYTES,
|
|
143
|
+
)
|
|
144
|
+
if scan.digest != digest or scan.total_chars != original_chars:
|
|
145
|
+
raise ValueError("Artifact integrity check failed.")
|
|
146
|
+
return scan.selected_content
|
|
147
|
+
|
|
148
|
+
def externalize_conversation(
|
|
149
|
+
self,
|
|
150
|
+
conversation: Conversation,
|
|
151
|
+
*,
|
|
152
|
+
on_failure: ArtifactExternalizationFailureHandler | None = None,
|
|
153
|
+
) -> Conversation:
|
|
154
|
+
tool_names: dict[str, str] = {}
|
|
155
|
+
externalized_messages: list[Message] = []
|
|
156
|
+
for message in conversation.get_messages():
|
|
157
|
+
if message.role == "assistant":
|
|
158
|
+
for tool_call in message.tool_calls:
|
|
159
|
+
tool_names[tool_call.id] = tool_call.name
|
|
160
|
+
externalized_messages.append(message)
|
|
161
|
+
continue
|
|
162
|
+
|
|
163
|
+
if message.role != "tool" or message.tool_call_id is None:
|
|
164
|
+
externalized_messages.append(message)
|
|
165
|
+
continue
|
|
166
|
+
|
|
167
|
+
tool_name = tool_names.get(message.tool_call_id, "unknown")
|
|
168
|
+
try:
|
|
169
|
+
externalized_content = self.externalize(
|
|
170
|
+
tool_name=tool_name,
|
|
171
|
+
tool_call_id=message.tool_call_id,
|
|
172
|
+
content=message.content,
|
|
173
|
+
)
|
|
174
|
+
except Exception as error:
|
|
175
|
+
if on_failure is None:
|
|
176
|
+
raise
|
|
177
|
+
externalized_content = on_failure(
|
|
178
|
+
tool_name,
|
|
179
|
+
message.tool_call_id,
|
|
180
|
+
message.content,
|
|
181
|
+
error,
|
|
182
|
+
)
|
|
183
|
+
externalized_messages.append(
|
|
184
|
+
Message(
|
|
185
|
+
role="tool",
|
|
186
|
+
content=externalized_content,
|
|
187
|
+
tool_call_id=message.tool_call_id,
|
|
188
|
+
)
|
|
189
|
+
)
|
|
190
|
+
|
|
191
|
+
return Conversation.from_messages(externalized_messages)
|
|
192
|
+
|
|
193
|
+
def _write_once(self, path: Path, content: str, *, digest: str) -> None:
|
|
194
|
+
self.root.mkdir(parents=True, exist_ok=True)
|
|
195
|
+
if os.path.lexists(path):
|
|
196
|
+
if _is_link_or_reparse_point(path) or not path.is_file():
|
|
197
|
+
raise RuntimeError(
|
|
198
|
+
"Existing artifact path is not a regular file."
|
|
199
|
+
)
|
|
200
|
+
existing_digest, _ = _sha256_file(path)
|
|
201
|
+
if existing_digest != digest:
|
|
202
|
+
raise RuntimeError(
|
|
203
|
+
"Existing artifact content does not match its content hash."
|
|
204
|
+
)
|
|
205
|
+
return
|
|
206
|
+
|
|
207
|
+
descriptor, temporary_name = tempfile.mkstemp(
|
|
208
|
+
prefix=".artifact-",
|
|
209
|
+
suffix=".tmp",
|
|
210
|
+
dir=self.root,
|
|
211
|
+
)
|
|
212
|
+
temporary_path = Path(temporary_name)
|
|
213
|
+
try:
|
|
214
|
+
with os.fdopen(descriptor, "w", encoding="utf-8", newline="") as stream:
|
|
215
|
+
for start in range(0, len(content), ARTIFACT_IO_CHUNK_CHARS):
|
|
216
|
+
stream.write(content[start : start + ARTIFACT_IO_CHUNK_CHARS])
|
|
217
|
+
stream.flush()
|
|
218
|
+
os.fsync(stream.fileno())
|
|
219
|
+
temporary_path.replace(path)
|
|
220
|
+
finally:
|
|
221
|
+
temporary_path.unlink(missing_ok=True)
|
|
222
|
+
|
|
223
|
+
|
|
224
|
+
class ReadArtifactArgs(ToolArgs):
|
|
225
|
+
artifact_path: str
|
|
226
|
+
offset_chars: int = Field(default=0, ge=0)
|
|
227
|
+
max_chars: int = Field(
|
|
228
|
+
default=DEFAULT_ARTIFACT_READ_CHARS,
|
|
229
|
+
ge=1,
|
|
230
|
+
le=MAX_ARTIFACT_READ_CHARS,
|
|
231
|
+
strict=True,
|
|
232
|
+
)
|
|
233
|
+
|
|
234
|
+
@field_validator("max_chars", mode="before")
|
|
235
|
+
@classmethod
|
|
236
|
+
def clamp_max_chars(cls, value: object) -> object:
|
|
237
|
+
return clamp_positive_int_upper_bound(
|
|
238
|
+
value,
|
|
239
|
+
upper_bound=MAX_ARTIFACT_READ_CHARS,
|
|
240
|
+
)
|
|
241
|
+
|
|
242
|
+
|
|
243
|
+
class ReadArtifactTool(PydanticTool[ReadArtifactArgs]):
|
|
244
|
+
name = "read_artifact"
|
|
245
|
+
description = (
|
|
246
|
+
"Read a bounded text slice from a tool-result artifact referenced in "
|
|
247
|
+
"the current conversation."
|
|
248
|
+
)
|
|
249
|
+
args_model = ReadArtifactArgs
|
|
250
|
+
capability = "read"
|
|
251
|
+
risk = "low"
|
|
252
|
+
concurrency_safe = True
|
|
253
|
+
|
|
254
|
+
def __init__(self, artifact_root: Path) -> None:
|
|
255
|
+
self.artifact_root = artifact_root.resolve(strict=False)
|
|
256
|
+
|
|
257
|
+
def _run(self, args: ReadArtifactArgs) -> ToolResult:
|
|
258
|
+
raw_path = Path(args.artifact_path)
|
|
259
|
+
if not raw_path.is_absolute():
|
|
260
|
+
return ToolResult.failure(
|
|
261
|
+
error="Artifact path must be absolute.",
|
|
262
|
+
metadata={"reason": "artifact_path_not_absolute"},
|
|
263
|
+
)
|
|
264
|
+
|
|
265
|
+
try:
|
|
266
|
+
if os.path.lexists(raw_path) and _is_link_or_reparse_point(raw_path):
|
|
267
|
+
return ToolResult.failure(
|
|
268
|
+
error="Artifact path must not be a link or reparse point.",
|
|
269
|
+
metadata={"reason": "artifact_path_linked"},
|
|
270
|
+
)
|
|
271
|
+
resolved_path = raw_path.resolve(strict=False)
|
|
272
|
+
except OSError:
|
|
273
|
+
return ToolResult.failure(
|
|
274
|
+
error="Artifact path could not be resolved.",
|
|
275
|
+
metadata={"reason": "artifact_path_unavailable"},
|
|
276
|
+
)
|
|
277
|
+
if not resolved_path.is_relative_to(self.artifact_root):
|
|
278
|
+
return ToolResult.failure(
|
|
279
|
+
error="Artifact path is outside the current session artifact root.",
|
|
280
|
+
metadata={"reason": "artifact_path_outside_root"},
|
|
281
|
+
)
|
|
282
|
+
if (
|
|
283
|
+
resolved_path.parent != self.artifact_root
|
|
284
|
+
or resolved_path.suffix != ".txt"
|
|
285
|
+
or len(resolved_path.stem) != 64
|
|
286
|
+
or any(character not in "0123456789abcdef" for character in resolved_path.stem)
|
|
287
|
+
):
|
|
288
|
+
return ToolResult.failure(
|
|
289
|
+
error="Artifact path is not a valid content-addressed artifact.",
|
|
290
|
+
metadata={"reason": "artifact_path_invalid"},
|
|
291
|
+
)
|
|
292
|
+
if not resolved_path.exists():
|
|
293
|
+
return ToolResult.failure(
|
|
294
|
+
error=f"Artifact not found: {args.artifact_path}",
|
|
295
|
+
metadata={"reason": "artifact_not_found"},
|
|
296
|
+
)
|
|
297
|
+
if not resolved_path.is_file():
|
|
298
|
+
return ToolResult.failure(
|
|
299
|
+
error=f"Artifact path is not a file: {args.artifact_path}",
|
|
300
|
+
metadata={"reason": "artifact_not_file"},
|
|
301
|
+
)
|
|
302
|
+
|
|
303
|
+
try:
|
|
304
|
+
artifact_size = resolved_path.stat().st_size
|
|
305
|
+
except OSError:
|
|
306
|
+
return ToolResult.failure(
|
|
307
|
+
error="Artifact metadata could not be read.",
|
|
308
|
+
metadata={"reason": "artifact_read_failed"},
|
|
309
|
+
)
|
|
310
|
+
if artifact_size > MAX_READABLE_ARTIFACT_BYTES:
|
|
311
|
+
return ToolResult.failure(
|
|
312
|
+
error="Artifact is too large to read safely.",
|
|
313
|
+
metadata={
|
|
314
|
+
"reason": "artifact_too_large",
|
|
315
|
+
"artifact_bytes": artifact_size,
|
|
316
|
+
"max_artifact_bytes": MAX_READABLE_ARTIFACT_BYTES,
|
|
317
|
+
},
|
|
318
|
+
)
|
|
319
|
+
|
|
320
|
+
try:
|
|
321
|
+
scan = _scan_utf8_artifact(
|
|
322
|
+
resolved_path,
|
|
323
|
+
offset_chars=args.offset_chars,
|
|
324
|
+
max_chars=args.max_chars,
|
|
325
|
+
max_bytes=MAX_READABLE_ARTIFACT_BYTES,
|
|
326
|
+
)
|
|
327
|
+
except _ArtifactTooLargeError:
|
|
328
|
+
return ToolResult.failure(
|
|
329
|
+
error="Artifact is too large to read safely.",
|
|
330
|
+
metadata={
|
|
331
|
+
"reason": "artifact_too_large",
|
|
332
|
+
"max_artifact_bytes": MAX_READABLE_ARTIFACT_BYTES,
|
|
333
|
+
},
|
|
334
|
+
)
|
|
335
|
+
except UnicodeDecodeError:
|
|
336
|
+
return ToolResult.failure(
|
|
337
|
+
error="Artifact is not valid UTF-8 text.",
|
|
338
|
+
metadata={"reason": "artifact_encoding_invalid"},
|
|
339
|
+
)
|
|
340
|
+
except OSError:
|
|
341
|
+
return ToolResult.failure(
|
|
342
|
+
error="Artifact content could not be read.",
|
|
343
|
+
metadata={"reason": "artifact_read_failed"},
|
|
344
|
+
)
|
|
345
|
+
|
|
346
|
+
if scan.digest != resolved_path.stem:
|
|
347
|
+
return ToolResult.failure(
|
|
348
|
+
error="Artifact content does not match its content hash.",
|
|
349
|
+
metadata={"reason": "artifact_hash_mismatch"},
|
|
350
|
+
)
|
|
351
|
+
return ToolResult.success(
|
|
352
|
+
content=scan.selected_content,
|
|
353
|
+
metadata={
|
|
354
|
+
"artifact_path": resolved_path.as_posix(),
|
|
355
|
+
"offset_chars": args.offset_chars,
|
|
356
|
+
"end_offset_chars": scan.end_offset_chars,
|
|
357
|
+
"max_chars": args.max_chars,
|
|
358
|
+
"total_bytes": scan.total_bytes,
|
|
359
|
+
"total_chars": scan.total_chars,
|
|
360
|
+
"truncated": scan.end_offset_chars < scan.total_chars,
|
|
361
|
+
},
|
|
362
|
+
)
|
|
363
|
+
|
|
364
|
+
|
|
365
|
+
def artifact_externalization_failure_content(
|
|
366
|
+
*,
|
|
367
|
+
tool_name: str,
|
|
368
|
+
tool_call_id: str,
|
|
369
|
+
original_content: str,
|
|
370
|
+
reason: str,
|
|
371
|
+
) -> str:
|
|
372
|
+
parsed = parse_tool_result_content(original_content)
|
|
373
|
+
safe_reason = _validate_reason_code(reason)
|
|
374
|
+
metadata = {
|
|
375
|
+
"artifact_externalization_failed": True,
|
|
376
|
+
"context_externalized": False,
|
|
377
|
+
"original_chars": len(original_content),
|
|
378
|
+
"reason": safe_reason,
|
|
379
|
+
"tool_call_id": tool_call_id,
|
|
380
|
+
"tool_name": tool_name,
|
|
381
|
+
}
|
|
382
|
+
return (
|
|
383
|
+
f"{parsed.status}\n"
|
|
384
|
+
f"{ARTIFACT_EXTERNALIZATION_FAILURE_MARKER}\n"
|
|
385
|
+
f"tool_name: {tool_name}\n"
|
|
386
|
+
f"original_chars: {len(original_content)}\n"
|
|
387
|
+
f"reason: {safe_reason}\n"
|
|
388
|
+
f"{TOOL_RESULT_METADATA_MARKER}"
|
|
389
|
+
f"{json.dumps(metadata, ensure_ascii=False, sort_keys=True)}"
|
|
390
|
+
)
|
|
391
|
+
|
|
392
|
+
|
|
393
|
+
def artifact_failure_reason(error: Exception) -> str:
|
|
394
|
+
if isinstance(error, PermissionError):
|
|
395
|
+
return "permission_denied"
|
|
396
|
+
if isinstance(error, FileNotFoundError):
|
|
397
|
+
return "path_unavailable"
|
|
398
|
+
if isinstance(error, UnicodeError):
|
|
399
|
+
return "encoding_error"
|
|
400
|
+
if isinstance(error, OSError):
|
|
401
|
+
return "artifact_io_error"
|
|
402
|
+
if isinstance(error, RuntimeError):
|
|
403
|
+
return "artifact_integrity_error"
|
|
404
|
+
return "artifact_externalization_error"
|
|
405
|
+
|
|
406
|
+
|
|
407
|
+
def _bounded_optional_metadata(
|
|
408
|
+
required: dict[str, object],
|
|
409
|
+
optional: dict[str, object],
|
|
410
|
+
) -> dict[str, object]:
|
|
411
|
+
selected: dict[str, object] = {}
|
|
412
|
+
omitted_count = 0
|
|
413
|
+
for key in sorted(optional):
|
|
414
|
+
if key in required:
|
|
415
|
+
continue
|
|
416
|
+
candidate = {**required, **selected, key: optional[key]}
|
|
417
|
+
serialized = json.dumps(
|
|
418
|
+
candidate,
|
|
419
|
+
ensure_ascii=False,
|
|
420
|
+
sort_keys=True,
|
|
421
|
+
default=str,
|
|
422
|
+
)
|
|
423
|
+
if len(serialized) > MAX_ARTIFACT_REFERENCE_METADATA_CHARS:
|
|
424
|
+
omitted_count += 1
|
|
425
|
+
continue
|
|
426
|
+
selected[key] = optional[key]
|
|
427
|
+
|
|
428
|
+
if omitted_count:
|
|
429
|
+
selected["metadata_omitted_count"] = omitted_count
|
|
430
|
+
return selected
|
|
431
|
+
|
|
432
|
+
|
|
433
|
+
def _is_link_or_reparse_point(path: Path) -> bool:
|
|
434
|
+
details = path.lstat()
|
|
435
|
+
if stat.S_ISLNK(details.st_mode):
|
|
436
|
+
return True
|
|
437
|
+
file_attributes = getattr(details, "st_file_attributes", 0)
|
|
438
|
+
reparse_attribute = getattr(stat, "FILE_ATTRIBUTE_REPARSE_POINT", 0)
|
|
439
|
+
return bool(file_attributes & reparse_attribute)
|
|
440
|
+
|
|
441
|
+
|
|
442
|
+
def _sha256_file(path: Path) -> tuple[str, int]:
|
|
443
|
+
digest = hashlib.sha256()
|
|
444
|
+
total_bytes = 0
|
|
445
|
+
with path.open("rb") as stream:
|
|
446
|
+
while chunk := stream.read(ARTIFACT_IO_CHUNK_BYTES):
|
|
447
|
+
total_bytes += len(chunk)
|
|
448
|
+
digest.update(chunk)
|
|
449
|
+
return digest.hexdigest(), total_bytes
|
|
450
|
+
|
|
451
|
+
|
|
452
|
+
def _sha256_text(content: str) -> str:
|
|
453
|
+
digest = hashlib.sha256()
|
|
454
|
+
for start in range(0, len(content), ARTIFACT_IO_CHUNK_CHARS):
|
|
455
|
+
digest.update(
|
|
456
|
+
content[start : start + ARTIFACT_IO_CHUNK_CHARS].encode("utf-8")
|
|
457
|
+
)
|
|
458
|
+
return digest.hexdigest()
|
|
459
|
+
|
|
460
|
+
|
|
461
|
+
def _scan_utf8_artifact(
|
|
462
|
+
path: Path,
|
|
463
|
+
*,
|
|
464
|
+
offset_chars: int,
|
|
465
|
+
max_chars: int,
|
|
466
|
+
max_bytes: int | None = None,
|
|
467
|
+
) -> _ArtifactTextScan:
|
|
468
|
+
if offset_chars < 0:
|
|
469
|
+
raise ValueError("offset_chars must not be negative.")
|
|
470
|
+
if max_chars < 0:
|
|
471
|
+
raise ValueError("max_chars must not be negative.")
|
|
472
|
+
if max_bytes is not None and max_bytes < 1:
|
|
473
|
+
raise ValueError("max_bytes must be positive.")
|
|
474
|
+
|
|
475
|
+
digest = hashlib.sha256()
|
|
476
|
+
decoder = codecs.getincrementaldecoder("utf-8")(errors="strict")
|
|
477
|
+
total_bytes = 0
|
|
478
|
+
total_chars = 0
|
|
479
|
+
selected_parts: list[str] = []
|
|
480
|
+
requested_end = offset_chars + max_chars
|
|
481
|
+
|
|
482
|
+
def consume_text(text: str) -> None:
|
|
483
|
+
nonlocal total_chars
|
|
484
|
+
if not text:
|
|
485
|
+
return
|
|
486
|
+
chunk_start = total_chars
|
|
487
|
+
chunk_end = chunk_start + len(text)
|
|
488
|
+
if (
|
|
489
|
+
max_chars > 0
|
|
490
|
+
and chunk_end > offset_chars
|
|
491
|
+
and chunk_start < requested_end
|
|
492
|
+
):
|
|
493
|
+
selected_start = max(offset_chars, chunk_start) - chunk_start
|
|
494
|
+
selected_end = min(requested_end, chunk_end) - chunk_start
|
|
495
|
+
selected_parts.append(text[selected_start:selected_end])
|
|
496
|
+
total_chars = chunk_end
|
|
497
|
+
|
|
498
|
+
with path.open("rb") as stream:
|
|
499
|
+
if (
|
|
500
|
+
max_bytes is not None
|
|
501
|
+
and os.fstat(stream.fileno()).st_size > max_bytes
|
|
502
|
+
):
|
|
503
|
+
raise _ArtifactTooLargeError
|
|
504
|
+
while raw_chunk := stream.read(ARTIFACT_IO_CHUNK_BYTES):
|
|
505
|
+
total_bytes += len(raw_chunk)
|
|
506
|
+
if max_bytes is not None and total_bytes > max_bytes:
|
|
507
|
+
raise _ArtifactTooLargeError
|
|
508
|
+
digest.update(raw_chunk)
|
|
509
|
+
consume_text(decoder.decode(raw_chunk, final=False))
|
|
510
|
+
consume_text(decoder.decode(b"", final=True))
|
|
511
|
+
|
|
512
|
+
return _ArtifactTextScan(
|
|
513
|
+
digest=digest.hexdigest(),
|
|
514
|
+
total_bytes=total_bytes,
|
|
515
|
+
total_chars=total_chars,
|
|
516
|
+
selected_content="".join(selected_parts),
|
|
517
|
+
end_offset_chars=min(total_chars, requested_end),
|
|
518
|
+
)
|
|
519
|
+
|
|
520
|
+
|
|
521
|
+
def artifact_reference_info(
|
|
522
|
+
*,
|
|
523
|
+
tool_name: str,
|
|
524
|
+
tool_call_id: str,
|
|
525
|
+
content: str,
|
|
526
|
+
) -> tuple[Path, str, int] | None:
|
|
527
|
+
body, separator, metadata_text = content.partition(TOOL_RESULT_METADATA_MARKER)
|
|
528
|
+
if (
|
|
529
|
+
separator == ""
|
|
530
|
+
or len(metadata_text) > MAX_ARTIFACT_REFERENCE_METADATA_CHARS
|
|
531
|
+
):
|
|
532
|
+
return None
|
|
533
|
+
lines = body.splitlines()
|
|
534
|
+
if len(lines) != 6:
|
|
535
|
+
return None
|
|
536
|
+
status, marker, tool_line, path_line, chars_line, digest_line = lines
|
|
537
|
+
if status not in {"OK", "ERROR"} or marker != EXTERNALIZED_TOOL_RESULT_MARKER:
|
|
538
|
+
return None
|
|
539
|
+
if tool_line != f"tool_name: {tool_name}":
|
|
540
|
+
return None
|
|
541
|
+
if not path_line.startswith("artifact_path: "):
|
|
542
|
+
return None
|
|
543
|
+
if not chars_line.startswith("original_chars: "):
|
|
544
|
+
return None
|
|
545
|
+
if not digest_line.startswith("sha256: "):
|
|
546
|
+
return None
|
|
547
|
+
|
|
548
|
+
artifact_path_text = path_line.removeprefix("artifact_path: ")
|
|
549
|
+
digest = digest_line.removeprefix("sha256: ")
|
|
550
|
+
try:
|
|
551
|
+
original_chars = int(chars_line.removeprefix("original_chars: "))
|
|
552
|
+
metadata = json.loads(metadata_text)
|
|
553
|
+
except (ValueError, json.JSONDecodeError):
|
|
554
|
+
return None
|
|
555
|
+
if (
|
|
556
|
+
original_chars < 0
|
|
557
|
+
or len(digest) != 64
|
|
558
|
+
or any(character not in "0123456789abcdef" for character in digest)
|
|
559
|
+
or not isinstance(metadata, dict)
|
|
560
|
+
):
|
|
561
|
+
return None
|
|
562
|
+
required_metadata = {
|
|
563
|
+
"artifact_path": artifact_path_text,
|
|
564
|
+
"artifact_sha256": digest,
|
|
565
|
+
"context_externalized": True,
|
|
566
|
+
"original_chars": original_chars,
|
|
567
|
+
"tool_call_id": tool_call_id,
|
|
568
|
+
"tool_name": tool_name,
|
|
569
|
+
}
|
|
570
|
+
if any(metadata.get(key) != value for key, value in required_metadata.items()):
|
|
571
|
+
return None
|
|
572
|
+
|
|
573
|
+
return Path(artifact_path_text), digest, original_chars
|
|
574
|
+
|
|
575
|
+
|
|
576
|
+
def _is_valid_artifact_reference(
|
|
577
|
+
root: Path,
|
|
578
|
+
*,
|
|
579
|
+
tool_name: str,
|
|
580
|
+
tool_call_id: str,
|
|
581
|
+
content: str,
|
|
582
|
+
) -> bool:
|
|
583
|
+
info = artifact_reference_info(
|
|
584
|
+
tool_name=tool_name, tool_call_id=tool_call_id, content=content,
|
|
585
|
+
)
|
|
586
|
+
if info is None:
|
|
587
|
+
return False
|
|
588
|
+
artifact_path, digest, original_chars = info
|
|
589
|
+
resolved_root = root.resolve(strict=False)
|
|
590
|
+
resolved_path = artifact_path.resolve(strict=False)
|
|
591
|
+
if (
|
|
592
|
+
not artifact_path.is_absolute()
|
|
593
|
+
or resolved_path.parent != resolved_root
|
|
594
|
+
or resolved_path.name != f"{digest}.txt"
|
|
595
|
+
or not resolved_path.is_file()
|
|
596
|
+
):
|
|
597
|
+
return False
|
|
598
|
+
try:
|
|
599
|
+
if _is_link_or_reparse_point(resolved_path):
|
|
600
|
+
return False
|
|
601
|
+
scan = _scan_utf8_artifact(
|
|
602
|
+
resolved_path,
|
|
603
|
+
offset_chars=0,
|
|
604
|
+
max_chars=0,
|
|
605
|
+
)
|
|
606
|
+
except (OSError, UnicodeError):
|
|
607
|
+
return False
|
|
608
|
+
return (
|
|
609
|
+
scan.digest == digest
|
|
610
|
+
and scan.total_chars == original_chars
|
|
611
|
+
)
|
|
612
|
+
|
|
613
|
+
|
|
614
|
+
def _is_valid_artifact_failure_reference(
|
|
615
|
+
*,
|
|
616
|
+
tool_name: str,
|
|
617
|
+
tool_call_id: str,
|
|
618
|
+
content: str,
|
|
619
|
+
) -> bool:
|
|
620
|
+
body, separator, metadata_text = content.partition(TOOL_RESULT_METADATA_MARKER)
|
|
621
|
+
if (
|
|
622
|
+
separator == ""
|
|
623
|
+
or len(metadata_text) > MAX_ARTIFACT_REFERENCE_METADATA_CHARS
|
|
624
|
+
):
|
|
625
|
+
return False
|
|
626
|
+
lines = body.splitlines()
|
|
627
|
+
if len(lines) != 5:
|
|
628
|
+
return False
|
|
629
|
+
status, marker, tool_line, chars_line, reason_line = lines
|
|
630
|
+
if (
|
|
631
|
+
status not in {"OK", "ERROR"}
|
|
632
|
+
or marker != ARTIFACT_EXTERNALIZATION_FAILURE_MARKER
|
|
633
|
+
or tool_line != f"tool_name: {tool_name}"
|
|
634
|
+
or not chars_line.startswith("original_chars: ")
|
|
635
|
+
or not reason_line.startswith("reason: ")
|
|
636
|
+
):
|
|
637
|
+
return False
|
|
638
|
+
reason = reason_line.removeprefix("reason: ")
|
|
639
|
+
try:
|
|
640
|
+
original_chars = int(chars_line.removeprefix("original_chars: "))
|
|
641
|
+
metadata = json.loads(metadata_text)
|
|
642
|
+
safe_reason = _validate_reason_code(reason)
|
|
643
|
+
except (ValueError, json.JSONDecodeError):
|
|
644
|
+
return False
|
|
645
|
+
if original_chars < 0 or not isinstance(metadata, dict):
|
|
646
|
+
return False
|
|
647
|
+
required_metadata = {
|
|
648
|
+
"artifact_externalization_failed": True,
|
|
649
|
+
"context_externalized": False,
|
|
650
|
+
"original_chars": original_chars,
|
|
651
|
+
"reason": safe_reason,
|
|
652
|
+
"tool_call_id": tool_call_id,
|
|
653
|
+
"tool_name": tool_name,
|
|
654
|
+
}
|
|
655
|
+
return (
|
|
656
|
+
reason == safe_reason
|
|
657
|
+
and len(metadata) == len(required_metadata)
|
|
658
|
+
and all(
|
|
659
|
+
metadata.get(key) == value
|
|
660
|
+
for key, value in required_metadata.items()
|
|
661
|
+
)
|
|
662
|
+
)
|
|
663
|
+
|
|
664
|
+
|
|
665
|
+
def _validate_reason_code(reason: str) -> str:
|
|
666
|
+
if (
|
|
667
|
+
reason == ""
|
|
668
|
+
or len(reason) > 100
|
|
669
|
+
or any(character not in "abcdefghijklmnopqrstuvwxyz0123456789_" for character in reason)
|
|
670
|
+
):
|
|
671
|
+
raise ValueError("Artifact failure reason must be a short lowercase code.")
|
|
672
|
+
return reason
|