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
mycode/tools/patterns.py
ADDED
|
@@ -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
|
+
)
|