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,48 @@
1
+ from pathlib import Path
2
+
3
+ from mycode.tools.ignore import (
4
+ DEFAULT_SAFE_ENV_TEMPLATE_FILE_NAMES,
5
+ DEFAULT_SENSITIVE_FILE_NAMES,
6
+ DEFAULT_SENSITIVE_FILE_SUFFIXES,
7
+ )
8
+
9
+
10
+ GLOB_META_CHARS = frozenset("*?[")
11
+
12
+
13
+ def validate_relative_pattern(pattern: str, *, label: str) -> str | None:
14
+ pattern_path = Path(pattern)
15
+ if pattern_path.is_absolute():
16
+ return f"{label} must be relative: {pattern}"
17
+
18
+ if ".." in pattern_path.parts:
19
+ return f"{label} must not contain '..': {pattern}"
20
+
21
+ return None
22
+
23
+
24
+ def is_explicit_path_pattern(pattern: str) -> bool:
25
+ return not any(char in pattern for char in GLOB_META_CHARS)
26
+
27
+
28
+ def is_sensitive_path_pattern(pattern: str) -> bool:
29
+ name_pattern = Path(pattern).name.casefold()
30
+ if not name_pattern:
31
+ return False
32
+
33
+ if name_pattern in DEFAULT_SAFE_ENV_TEMPLATE_FILE_NAMES:
34
+ return False
35
+
36
+ if name_pattern in DEFAULT_SENSITIVE_FILE_NAMES:
37
+ return True
38
+
39
+ if any(
40
+ name_pattern.startswith(f"{sensitive_name}*")
41
+ for sensitive_name in DEFAULT_SENSITIVE_FILE_NAMES
42
+ ):
43
+ return True
44
+
45
+ if name_pattern.startswith(".env"):
46
+ return True
47
+
48
+ return any(name_pattern.endswith(f"*{suffix}") for suffix in DEFAULT_SENSITIVE_FILE_SUFFIXES)
@@ -0,0 +1,27 @@
1
+ from mycode.permissions import PermissionDecision
2
+
3
+
4
+ def with_permission_metadata(
5
+ decision: PermissionDecision,
6
+ metadata: dict[str, object],
7
+ ) -> PermissionDecision:
8
+ merged_metadata = {**decision.metadata, **metadata}
9
+
10
+ if decision.status == "allow":
11
+ return PermissionDecision.allow(
12
+ message=decision.message,
13
+ metadata=merged_metadata,
14
+ )
15
+
16
+ if decision.status == "ask":
17
+ return PermissionDecision.ask(
18
+ reason=decision.reason,
19
+ message=decision.message,
20
+ metadata=merged_metadata,
21
+ )
22
+
23
+ return PermissionDecision.deny(
24
+ reason=decision.reason,
25
+ message=decision.message,
26
+ metadata=merged_metadata,
27
+ )
@@ -0,0 +1,166 @@
1
+ import os
2
+ import signal
3
+ import subprocess
4
+
5
+
6
+ class ProcessTreeCleanup:
7
+ def __init__(
8
+ self,
9
+ *,
10
+ method: str,
11
+ job_handle: int | None = None,
12
+ process_group_id: int | None = None,
13
+ error: str | None = None,
14
+ ) -> None:
15
+ self.method = method
16
+ self.error = error
17
+ self._job_handle = job_handle
18
+ self._process_group_id = process_group_id
19
+ self._closed = False
20
+ self._success = error is None
21
+
22
+ def close(self) -> bool:
23
+ if self._closed:
24
+ return self._success
25
+
26
+ self._closed = True
27
+ if self._job_handle is not None:
28
+ self._success = _close_windows_job_handle(self._job_handle)
29
+ if not self._success:
30
+ self.error = self.error or "CloseHandle failed for process job."
31
+ return self._success
32
+
33
+ if self._process_group_id is not None:
34
+ self._success = _kill_posix_process_group(self._process_group_id)
35
+ if not self._success:
36
+ self.error = self.error or "Failed to terminate process group."
37
+ return self._success
38
+
39
+ return self._success
40
+
41
+
42
+ def process_tree_popen_kwargs() -> dict[str, object]:
43
+ if os.name == "nt":
44
+ return {}
45
+
46
+ return {"start_new_session": True}
47
+
48
+
49
+ def create_process_tree_cleanup(
50
+ process: subprocess.Popen[str],
51
+ ) -> ProcessTreeCleanup:
52
+ if os.name == "nt":
53
+ return _create_windows_job_cleanup(process)
54
+
55
+ return ProcessTreeCleanup(
56
+ method="posix_process_group",
57
+ process_group_id=process.pid,
58
+ )
59
+
60
+
61
+ def _create_windows_job_cleanup(
62
+ process: subprocess.Popen[str],
63
+ ) -> ProcessTreeCleanup:
64
+ try:
65
+ import ctypes
66
+ from ctypes import wintypes
67
+
68
+ kernel32 = ctypes.WinDLL("kernel32", use_last_error=True)
69
+
70
+ class _IoCounters(ctypes.Structure):
71
+ _fields_ = [
72
+ ("ReadOperationCount", ctypes.c_ulonglong),
73
+ ("WriteOperationCount", ctypes.c_ulonglong),
74
+ ("OtherOperationCount", ctypes.c_ulonglong),
75
+ ("ReadTransferCount", ctypes.c_ulonglong),
76
+ ("WriteTransferCount", ctypes.c_ulonglong),
77
+ ("OtherTransferCount", ctypes.c_ulonglong),
78
+ ]
79
+
80
+ class _JobObjectBasicLimitInformation(ctypes.Structure):
81
+ _fields_ = [
82
+ ("PerProcessUserTimeLimit", ctypes.c_int64),
83
+ ("PerJobUserTimeLimit", ctypes.c_int64),
84
+ ("LimitFlags", wintypes.DWORD),
85
+ ("MinimumWorkingSetSize", ctypes.c_size_t),
86
+ ("MaximumWorkingSetSize", ctypes.c_size_t),
87
+ ("ActiveProcessLimit", wintypes.DWORD),
88
+ ("Affinity", ctypes.c_size_t),
89
+ ("PriorityClass", wintypes.DWORD),
90
+ ("SchedulingClass", wintypes.DWORD),
91
+ ]
92
+
93
+ class _JobObjectExtendedLimitInformation(ctypes.Structure):
94
+ _fields_ = [
95
+ ("BasicLimitInformation", _JobObjectBasicLimitInformation),
96
+ ("IoInfo", _IoCounters),
97
+ ("ProcessMemoryLimit", ctypes.c_size_t),
98
+ ("JobMemoryLimit", ctypes.c_size_t),
99
+ ("PeakProcessMemoryUsed", ctypes.c_size_t),
100
+ ("PeakJobMemoryUsed", ctypes.c_size_t),
101
+ ]
102
+
103
+ kernel32.CreateJobObjectW.argtypes = [wintypes.LPVOID, wintypes.LPCWSTR]
104
+ kernel32.CreateJobObjectW.restype = wintypes.HANDLE
105
+ kernel32.SetInformationJobObject.argtypes = [
106
+ wintypes.HANDLE,
107
+ ctypes.c_int,
108
+ wintypes.LPVOID,
109
+ wintypes.DWORD,
110
+ ]
111
+ kernel32.SetInformationJobObject.restype = wintypes.BOOL
112
+ kernel32.AssignProcessToJobObject.argtypes = [wintypes.HANDLE, wintypes.HANDLE]
113
+ kernel32.AssignProcessToJobObject.restype = wintypes.BOOL
114
+
115
+ job_handle = kernel32.CreateJobObjectW(None, None)
116
+ if not job_handle:
117
+ raise ctypes.WinError(ctypes.get_last_error())
118
+
119
+ limit_info = _JobObjectExtendedLimitInformation()
120
+ limit_info.BasicLimitInformation.LimitFlags = 0x00002000
121
+ if not kernel32.SetInformationJobObject(
122
+ job_handle,
123
+ 9,
124
+ ctypes.byref(limit_info),
125
+ ctypes.sizeof(limit_info),
126
+ ):
127
+ error = ctypes.WinError(ctypes.get_last_error())
128
+ _close_windows_job_handle(int(job_handle))
129
+ raise error
130
+
131
+ process_handle = getattr(process, "_handle")
132
+ if not kernel32.AssignProcessToJobObject(job_handle, process_handle):
133
+ error = ctypes.WinError(ctypes.get_last_error())
134
+ _close_windows_job_handle(int(job_handle))
135
+ raise error
136
+
137
+ return ProcessTreeCleanup(method="windows_job", job_handle=int(job_handle))
138
+ except Exception as error:
139
+ return ProcessTreeCleanup(
140
+ method="windows_job",
141
+ error=f"{type(error).__name__}: {error}",
142
+ )
143
+
144
+
145
+ def _close_windows_job_handle(job_handle: int) -> bool:
146
+ try:
147
+ import ctypes
148
+ from ctypes import wintypes
149
+
150
+ kernel32 = ctypes.WinDLL("kernel32", use_last_error=True)
151
+ kernel32.CloseHandle.argtypes = [wintypes.HANDLE]
152
+ kernel32.CloseHandle.restype = wintypes.BOOL
153
+ return bool(kernel32.CloseHandle(wintypes.HANDLE(job_handle)))
154
+ except Exception:
155
+ return False
156
+
157
+
158
+ def _kill_posix_process_group(process_group_id: int) -> bool:
159
+ try:
160
+ os.killpg(process_group_id, signal.SIGKILL)
161
+ except ProcessLookupError:
162
+ return True
163
+ except OSError:
164
+ return False
165
+
166
+ return True
@@ -0,0 +1,242 @@
1
+ from pathlib import Path
2
+
3
+ from pydantic import Field, field_validator
4
+
5
+ from mycode.permissions import PermissionChecker, PermissionDecision, PermissionRequest
6
+ from mycode.tools.base import PydanticTool, ToolArgs, ToolResult
7
+ from mycode.tools.bounds import clamp_positive_int_upper_bound
8
+ from mycode.tools.ignore import is_sensitive_path
9
+ from mycode.tools.path_permissions import PathPermissionPolicy
10
+ from mycode.tools.text import (
11
+ SUPPORTED_TEXT_ENCODINGS,
12
+ contains_nul_byte,
13
+ decode_text,
14
+ )
15
+ from mycode.tools.workspace import Workspace, WorkspacePathError
16
+
17
+
18
+ SUPPORTED_ENCODINGS = SUPPORTED_TEXT_ENCODINGS
19
+ DEFAULT_MAX_LINES = 200
20
+ MAX_LINES_LIMIT = 1000
21
+
22
+
23
+ class ReadFileArgs(ToolArgs):
24
+ path: str = Field(description="要读取的 workspace 内文件路径。")
25
+ start_line: int = Field(
26
+ default=1,
27
+ ge=1,
28
+ description="从第几行开始读取,行号从 1 开始。",
29
+ )
30
+ max_lines: int = Field(
31
+ default=DEFAULT_MAX_LINES,
32
+ ge=1,
33
+ le=MAX_LINES_LIMIT,
34
+ strict=True,
35
+ description="本次最多读取的行数。",
36
+ )
37
+
38
+ @field_validator("max_lines", mode="before")
39
+ @classmethod
40
+ def clamp_max_lines(cls, value: object) -> object:
41
+ return clamp_positive_int_upper_bound(
42
+ value,
43
+ upper_bound=MAX_LINES_LIMIT,
44
+ )
45
+
46
+
47
+ class ReadFileTool(PydanticTool[ReadFileArgs]):
48
+ name = "read_file"
49
+ description = "Read a text file inside the workspace with line numbers."
50
+ args_model = ReadFileArgs
51
+ capability = "read"
52
+ risk = "low"
53
+ concurrency_safe = True
54
+
55
+ def __init__(self, workspace: Workspace) -> None:
56
+ self.workspace = workspace
57
+
58
+ def build_permission_request(self, args: ReadFileArgs) -> PermissionRequest:
59
+ return PermissionRequest(
60
+ tool_name=self.name,
61
+ capability=self.capability,
62
+ action=self.name,
63
+ target=args.path,
64
+ arguments=args.model_dump(),
65
+ )
66
+
67
+ def check_permission(
68
+ self,
69
+ args: ReadFileArgs,
70
+ permission_checker: PermissionChecker,
71
+ ) -> tuple[PermissionRequest, PermissionDecision]:
72
+ request = self.build_permission_request(args)
73
+ path_decision = PathPermissionPolicy(self.workspace).check_path(
74
+ request,
75
+ args.path,
76
+ )
77
+ if path_decision.status != "allow":
78
+ return request, path_decision
79
+
80
+ decision = permission_checker.check(
81
+ request,
82
+ self.get_permission_profile(),
83
+ )
84
+ if decision.status != "allow":
85
+ return request, decision
86
+
87
+ return request, PermissionDecision.allow(
88
+ message=decision.message,
89
+ metadata={**decision.metadata, **path_decision.metadata},
90
+ )
91
+
92
+ def run_authorized(
93
+ self,
94
+ args: ReadFileArgs,
95
+ decision: PermissionDecision,
96
+ ) -> ToolResult:
97
+ try:
98
+ resolved_path = decision.metadata.get("resolved_path")
99
+ if isinstance(resolved_path, str):
100
+ return self._read_path(args, Path(resolved_path))
101
+
102
+ return self.run_parsed(args)
103
+ except Exception as error:
104
+ return ToolResult.failure(
105
+ error=f"Tool execution failed: {error}",
106
+ metadata={"exception_type": type(error).__name__},
107
+ )
108
+
109
+ def _run(self, args: ReadFileArgs) -> ToolResult:
110
+ try:
111
+ path = self.workspace.resolve_path(args.path)
112
+ except WorkspacePathError as error:
113
+ return ToolResult.failure(
114
+ error=str(error),
115
+ metadata={"path": args.path},
116
+ )
117
+
118
+ if not path.exists():
119
+ return ToolResult.failure(
120
+ error=f"File not found: {args.path}",
121
+ metadata={"path": args.path},
122
+ )
123
+
124
+ if not path.is_file():
125
+ return ToolResult.failure(
126
+ error=f"Path is not a file: {args.path}",
127
+ metadata={"path": args.path},
128
+ )
129
+
130
+ if is_sensitive_path(path, self.workspace.root):
131
+ return ToolResult.failure(
132
+ error=f"Refusing to read sensitive file: {args.path}",
133
+ metadata={"path": args.path, "reason": "sensitive_file"},
134
+ )
135
+
136
+ return self._read_path(args, path)
137
+
138
+ def _read_path(self, args: ReadFileArgs, path: Path) -> ToolResult:
139
+ if not path.exists():
140
+ return ToolResult.failure(
141
+ error=f"File not found: {args.path}",
142
+ metadata={"path": args.path},
143
+ )
144
+
145
+ if not path.is_file():
146
+ return ToolResult.failure(
147
+ error=f"Path is not a file: {args.path}",
148
+ metadata={"path": args.path},
149
+ )
150
+
151
+ raw_content = path.read_bytes()
152
+ if contains_nul_byte(raw_content):
153
+ return ToolResult.failure(
154
+ error=f"File is not a supported text file: {args.path}",
155
+ metadata={"path": args.path, "reason": "nul_byte"},
156
+ )
157
+
158
+ decoded = decode_text(raw_content)
159
+ if decoded is None:
160
+ return ToolResult.failure(
161
+ error=f"File is not a supported text file: {args.path}",
162
+ metadata={
163
+ "path": args.path,
164
+ "supported_encodings": list(SUPPORTED_ENCODINGS),
165
+ },
166
+ )
167
+
168
+ text, encoding = decoded
169
+ lines = text.splitlines()
170
+ total_lines = len(lines)
171
+ start_index = args.start_line - 1
172
+ end_index = min(start_index + args.max_lines, total_lines)
173
+ selected_lines = lines[start_index:end_index]
174
+ end_line = args.start_line + len(selected_lines) - 1
175
+ if not selected_lines:
176
+ end_line = args.start_line - 1
177
+ has_more = end_index < total_lines
178
+ next_start_line = end_line + 1 if has_more else None
179
+ display_path = _display_path(path, self.workspace.root)
180
+ content = _format_read_result(
181
+ path=display_path,
182
+ selected_lines=selected_lines,
183
+ start_line=args.start_line,
184
+ end_line=end_line,
185
+ total_lines=total_lines,
186
+ has_more=has_more,
187
+ next_start_line=next_start_line,
188
+ )
189
+
190
+ return ToolResult.success(
191
+ content=content,
192
+ metadata={
193
+ "path": display_path,
194
+ "encoding": encoding,
195
+ "start_line": args.start_line,
196
+ "end_line": end_line,
197
+ "total_lines": total_lines,
198
+ "has_more": has_more,
199
+ "next_start_line": next_start_line,
200
+ },
201
+ )
202
+
203
+
204
+ def _format_numbered_lines(lines: list[str], *, start_line: int) -> str:
205
+ return "\n".join(
206
+ f"{line_number} | {line}"
207
+ for line_number, line in enumerate(lines, start=start_line)
208
+ )
209
+
210
+
211
+ def _format_read_result(
212
+ *,
213
+ path: str,
214
+ selected_lines: list[str],
215
+ start_line: int,
216
+ end_line: int,
217
+ total_lines: int,
218
+ has_more: bool,
219
+ next_start_line: int | None,
220
+ ) -> str:
221
+ lines_display = (
222
+ f"{start_line}-{end_line}"
223
+ if end_line >= start_line
224
+ else f"none (requested start: {start_line})"
225
+ )
226
+ header = [
227
+ f"File: {path}",
228
+ f"Lines: {lines_display} / {total_lines}",
229
+ f"Has more: {'yes' if has_more else 'no'}",
230
+ ]
231
+ if next_start_line is not None:
232
+ header.append(f"Next start line: {next_start_line}")
233
+ body = _format_numbered_lines(selected_lines, start_line=start_line)
234
+ header_text = "\n".join(header)
235
+ return header_text if not body else f"{header_text}\n\n{body}"
236
+
237
+
238
+ def _display_path(path: Path, root: Path) -> str:
239
+ try:
240
+ return path.relative_to(root).as_posix()
241
+ except ValueError:
242
+ return path.as_posix()
@@ -0,0 +1,93 @@
1
+ from pydantic import Field, field_validator
2
+
3
+ from mycode.skills import ActiveSkillState, SkillPathError, SkillRegistry
4
+ from mycode.tools.base import PydanticTool, ToolArgs, ToolResult
5
+ from mycode.tools.bounds import clamp_positive_int_upper_bound
6
+
7
+
8
+ DEFAULT_MAX_SKILL_RESOURCE_CHARS = 4000
9
+ MAX_SKILL_RESOURCE_CHARS = 20000
10
+
11
+
12
+ class ReadSkillResourceArgs(ToolArgs):
13
+ skill: str = Field(min_length=1)
14
+ path: str = Field(min_length=1)
15
+ offset_chars: int = Field(default=0, ge=0, strict=True)
16
+ max_chars: int = Field(
17
+ default=DEFAULT_MAX_SKILL_RESOURCE_CHARS,
18
+ ge=1,
19
+ le=MAX_SKILL_RESOURCE_CHARS,
20
+ strict=True,
21
+ )
22
+
23
+ @field_validator("max_chars", mode="before")
24
+ @classmethod
25
+ def clamp_max_chars(cls, value: object) -> object:
26
+ return clamp_positive_int_upper_bound(
27
+ value, upper_bound=MAX_SKILL_RESOURCE_CHARS
28
+ )
29
+
30
+
31
+ class ReadSkillResourceTool(PydanticTool[ReadSkillResourceArgs]):
32
+ name = "read_skill_resource"
33
+ description = "从已激活的 Skill 中按边界读取 UTF-8 文本资源。"
34
+ args_model = ReadSkillResourceArgs
35
+ capability = "read"
36
+ risk = "low"
37
+ concurrency_safe = True
38
+
39
+ def __init__(self, registry: SkillRegistry, state: ActiveSkillState) -> None:
40
+ self.registry = registry
41
+ self.state = state
42
+
43
+ def _run(self, args: ReadSkillResourceArgs) -> ToolResult:
44
+ skill = self.registry.get(args.skill)
45
+ metadata: dict[str, object] = {
46
+ "skill_name": args.skill,
47
+ "resource_path": args.path,
48
+ }
49
+ if skill is None or not self.state.is_active(args.skill):
50
+ return ToolResult.failure(
51
+ error=f"Skill is not active: {args.skill}", metadata=metadata
52
+ )
53
+ metadata["skill_source"] = skill.source
54
+ try:
55
+ path = self.registry.resolve_resource(skill, args.path)
56
+ except SkillPathError as error:
57
+ return ToolResult.failure(error=str(error), metadata=metadata)
58
+ if not path.exists():
59
+ return ToolResult.failure(
60
+ error=f"Skill resource not found: {args.path}", metadata=metadata
61
+ )
62
+ if not path.is_file():
63
+ return ToolResult.failure(
64
+ error=f"Skill resource is not a file: {args.path}", metadata=metadata
65
+ )
66
+ try:
67
+ raw = path.read_bytes()
68
+ if b"\x00" in raw:
69
+ raise UnicodeDecodeError("utf-8", raw, 0, 1, "NUL byte")
70
+ text = raw.decode("utf-8")
71
+ except UnicodeDecodeError:
72
+ return ToolResult.failure(
73
+ error=f"Skill resource is not valid UTF-8 text: {args.path}",
74
+ metadata={**metadata, "reason": "unsupported_text"},
75
+ )
76
+ except OSError as error:
77
+ return ToolResult.failure(
78
+ error=f"Cannot read Skill resource: {error}", metadata=metadata
79
+ )
80
+ content = text[args.offset_chars : args.offset_chars + args.max_chars]
81
+ next_offset = args.offset_chars + len(content)
82
+ has_more = next_offset < len(text)
83
+ return ToolResult.success(
84
+ content=content,
85
+ metadata={
86
+ **metadata,
87
+ "offset_chars": args.offset_chars,
88
+ "returned_chars": len(content),
89
+ "total_chars": len(text),
90
+ "has_more": has_more,
91
+ "next_offset_chars": next_offset if has_more else None,
92
+ },
93
+ )