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,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