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,269 @@
|
|
|
1
|
+
from pathlib import Path
|
|
2
|
+
import re
|
|
3
|
+
import shutil
|
|
4
|
+
from typing import Literal, Self
|
|
5
|
+
|
|
6
|
+
from pydantic import Field, field_validator, model_validator
|
|
7
|
+
|
|
8
|
+
from mycode.permissions import PermissionChecker, PermissionDecision, PermissionRequest
|
|
9
|
+
from mycode.tools.base import PydanticTool, ToolArgs, ToolResult
|
|
10
|
+
from mycode.tools.command_executor import CommandExecutionArgs, execute_command
|
|
11
|
+
from mycode.tools.ignore import is_sensitive_path
|
|
12
|
+
from mycode.tools.path_permissions import PathPermissionPolicy
|
|
13
|
+
from mycode.tools.permission_metadata import with_permission_metadata
|
|
14
|
+
from mycode.tools.workspace import Workspace, WorkspacePathError
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
DEFAULT_INSPECT_TIMEOUT_SECONDS = 30.0
|
|
18
|
+
DEFAULT_INSPECT_MAX_OUTPUT_CHARS = 20000
|
|
19
|
+
MAX_INSPECT_OUTPUT_CHARS = 100000
|
|
20
|
+
MAX_INSPECT_PATHS = 20
|
|
21
|
+
SAFE_REF_PATTERN = re.compile(
|
|
22
|
+
r"(?:HEAD|[A-Za-z0-9][A-Za-z0-9._/-]*)(?:[~^][0-9]+)?"
|
|
23
|
+
)
|
|
24
|
+
|
|
25
|
+
InspectChangesAction = Literal["status", "diff"]
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class InspectChangesArgs(ToolArgs):
|
|
29
|
+
action: InspectChangesAction
|
|
30
|
+
paths: list[str] = Field(default_factory=list, max_length=MAX_INSPECT_PATHS)
|
|
31
|
+
staged: bool = False
|
|
32
|
+
base_ref: str | None = Field(default=None, max_length=200)
|
|
33
|
+
max_output_chars: int = Field(
|
|
34
|
+
default=DEFAULT_INSPECT_MAX_OUTPUT_CHARS,
|
|
35
|
+
ge=0,
|
|
36
|
+
le=MAX_INSPECT_OUTPUT_CHARS,
|
|
37
|
+
)
|
|
38
|
+
|
|
39
|
+
@field_validator("paths")
|
|
40
|
+
@classmethod
|
|
41
|
+
def validate_plain_paths(cls, values: list[str]) -> list[str]:
|
|
42
|
+
normalized: list[str] = []
|
|
43
|
+
for value in values:
|
|
44
|
+
path = value.strip()
|
|
45
|
+
if path == "":
|
|
46
|
+
raise ValueError("inspect_changes paths must not be blank.")
|
|
47
|
+
if len(path) > 500:
|
|
48
|
+
raise ValueError("inspect_changes paths must not exceed 500 characters.")
|
|
49
|
+
if path.startswith(":") or any(marker in path for marker in ("*", "?", "[", "]", "\x00")):
|
|
50
|
+
raise ValueError("inspect_changes paths must be literal paths, not pathspecs.")
|
|
51
|
+
normalized.append(path)
|
|
52
|
+
return normalized
|
|
53
|
+
|
|
54
|
+
@field_validator("base_ref")
|
|
55
|
+
@classmethod
|
|
56
|
+
def validate_base_ref(cls, value: str | None) -> str | None:
|
|
57
|
+
if value is None:
|
|
58
|
+
return None
|
|
59
|
+
normalized = value.strip()
|
|
60
|
+
if (
|
|
61
|
+
normalized == ""
|
|
62
|
+
or normalized.startswith("-")
|
|
63
|
+
or SAFE_REF_PATTERN.fullmatch(normalized) is None
|
|
64
|
+
):
|
|
65
|
+
raise ValueError("base_ref must be a plain Git revision, not an option.")
|
|
66
|
+
return normalized
|
|
67
|
+
|
|
68
|
+
@model_validator(mode="after")
|
|
69
|
+
def action_arguments_must_match(self) -> Self:
|
|
70
|
+
if self.action == "status" and (
|
|
71
|
+
self.paths or self.staged or self.base_ref is not None
|
|
72
|
+
):
|
|
73
|
+
raise ValueError(
|
|
74
|
+
"status does not accept paths, staged, or base_ref arguments."
|
|
75
|
+
)
|
|
76
|
+
if self.action == "diff" and not self.paths:
|
|
77
|
+
raise ValueError("diff requires at least one explicit path.")
|
|
78
|
+
return self
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
class InspectChangesTool(PydanticTool[InspectChangesArgs]):
|
|
82
|
+
name = "inspect_changes"
|
|
83
|
+
description = (
|
|
84
|
+
"Inspect Git status or a bounded diff for explicit non-sensitive workspace paths."
|
|
85
|
+
)
|
|
86
|
+
args_model = InspectChangesArgs
|
|
87
|
+
capability = "read"
|
|
88
|
+
risk = "low"
|
|
89
|
+
concurrency_safe = True
|
|
90
|
+
|
|
91
|
+
def __init__(self, workspace: Workspace) -> None:
|
|
92
|
+
self.workspace = workspace
|
|
93
|
+
|
|
94
|
+
def build_permission_request(self, args: InspectChangesArgs) -> PermissionRequest:
|
|
95
|
+
target = "repository status" if args.action == "status" else ", ".join(args.paths)
|
|
96
|
+
return PermissionRequest(
|
|
97
|
+
tool_name=self.name,
|
|
98
|
+
capability=self.capability,
|
|
99
|
+
action=f"{self.name}.{args.action}",
|
|
100
|
+
target=target,
|
|
101
|
+
arguments=args.model_dump(),
|
|
102
|
+
description="Inspect repository changes without arbitrary command execution.",
|
|
103
|
+
)
|
|
104
|
+
|
|
105
|
+
def check_permission(
|
|
106
|
+
self,
|
|
107
|
+
args: InspectChangesArgs,
|
|
108
|
+
permission_checker: PermissionChecker,
|
|
109
|
+
) -> tuple[PermissionRequest, PermissionDecision]:
|
|
110
|
+
request = self.build_permission_request(args)
|
|
111
|
+
resolved_paths: list[str] = []
|
|
112
|
+
for path in args.paths:
|
|
113
|
+
try:
|
|
114
|
+
resolved = self.workspace.resolve_path(path)
|
|
115
|
+
except WorkspacePathError:
|
|
116
|
+
return request, PermissionDecision.deny(
|
|
117
|
+
reason="outside_workspace",
|
|
118
|
+
message=f"inspect_changes path is outside workspace: {path}",
|
|
119
|
+
metadata={
|
|
120
|
+
"path": path,
|
|
121
|
+
"workspace_root": self.workspace.root.as_posix(),
|
|
122
|
+
"inspect_action": args.action,
|
|
123
|
+
},
|
|
124
|
+
)
|
|
125
|
+
if is_sensitive_path(resolved, self.workspace.root):
|
|
126
|
+
return request, PermissionDecision.deny(
|
|
127
|
+
reason="sensitive_path",
|
|
128
|
+
message=f"inspect_changes refuses sensitive path content: {path}",
|
|
129
|
+
metadata={
|
|
130
|
+
"path": path,
|
|
131
|
+
"resolved_path": resolved.as_posix(),
|
|
132
|
+
"workspace_root": self.workspace.root.as_posix(),
|
|
133
|
+
"inspect_action": args.action,
|
|
134
|
+
},
|
|
135
|
+
)
|
|
136
|
+
|
|
137
|
+
path_decision = PathPermissionPolicy(self.workspace).check_path(request, path)
|
|
138
|
+
if path_decision.status != "allow":
|
|
139
|
+
return request, with_permission_metadata(
|
|
140
|
+
path_decision,
|
|
141
|
+
{
|
|
142
|
+
"inspect_action": args.action,
|
|
143
|
+
"resolved_paths": [*resolved_paths, resolved.as_posix()],
|
|
144
|
+
"staged": args.staged,
|
|
145
|
+
"base_ref": args.base_ref,
|
|
146
|
+
},
|
|
147
|
+
)
|
|
148
|
+
resolved_paths.append(resolved.as_posix())
|
|
149
|
+
|
|
150
|
+
decision = permission_checker.check(request, self.get_permission_profile())
|
|
151
|
+
if decision.status != "allow":
|
|
152
|
+
return request, decision
|
|
153
|
+
return request, PermissionDecision.allow(
|
|
154
|
+
message=decision.message,
|
|
155
|
+
metadata={
|
|
156
|
+
**decision.metadata,
|
|
157
|
+
"workspace_root": self.workspace.root.as_posix(),
|
|
158
|
+
"resolved_cwd": self.workspace.root.as_posix(),
|
|
159
|
+
"inspect_action": args.action,
|
|
160
|
+
"resolved_paths": resolved_paths,
|
|
161
|
+
"staged": args.staged,
|
|
162
|
+
"base_ref": args.base_ref,
|
|
163
|
+
},
|
|
164
|
+
)
|
|
165
|
+
|
|
166
|
+
def run_authorized(
|
|
167
|
+
self,
|
|
168
|
+
args: InspectChangesArgs,
|
|
169
|
+
decision: PermissionDecision,
|
|
170
|
+
) -> ToolResult:
|
|
171
|
+
try:
|
|
172
|
+
return self._run_authorized(args, decision)
|
|
173
|
+
except Exception as error:
|
|
174
|
+
return ToolResult.failure(
|
|
175
|
+
error=f"Tool execution failed: {error}",
|
|
176
|
+
metadata={
|
|
177
|
+
**decision.metadata,
|
|
178
|
+
"exception_type": type(error).__name__,
|
|
179
|
+
},
|
|
180
|
+
)
|
|
181
|
+
|
|
182
|
+
def _run_authorized(
|
|
183
|
+
self,
|
|
184
|
+
args: InspectChangesArgs,
|
|
185
|
+
decision: PermissionDecision,
|
|
186
|
+
) -> ToolResult:
|
|
187
|
+
git_executable = shutil.which("git")
|
|
188
|
+
if git_executable is None:
|
|
189
|
+
return ToolResult.failure(
|
|
190
|
+
error="Git executable was not found.",
|
|
191
|
+
metadata={**decision.metadata, "reason": "git_not_found"},
|
|
192
|
+
)
|
|
193
|
+
resolved_git = Path(git_executable).resolve(strict=False)
|
|
194
|
+
if resolved_git.is_relative_to(self.workspace.root):
|
|
195
|
+
return ToolResult.failure(
|
|
196
|
+
error="Refusing to execute a workspace-local Git executable.",
|
|
197
|
+
metadata={
|
|
198
|
+
**decision.metadata,
|
|
199
|
+
"reason": "workspace_local_executable",
|
|
200
|
+
"git_executable": resolved_git.as_posix(),
|
|
201
|
+
},
|
|
202
|
+
)
|
|
203
|
+
|
|
204
|
+
command = self._build_command(args, resolved_git)
|
|
205
|
+
safe_git_flags = [
|
|
206
|
+
"--no-optional-locks",
|
|
207
|
+
"-c core.fsmonitor=false",
|
|
208
|
+
"--no-pager",
|
|
209
|
+
"--ignore-submodules=all",
|
|
210
|
+
]
|
|
211
|
+
if args.action == "diff":
|
|
212
|
+
safe_git_flags.extend(["--no-ext-diff", "--no-textconv"])
|
|
213
|
+
return execute_command(
|
|
214
|
+
args=CommandExecutionArgs(
|
|
215
|
+
command=command,
|
|
216
|
+
timeout_seconds=DEFAULT_INSPECT_TIMEOUT_SECONDS,
|
|
217
|
+
max_output_chars=args.max_output_chars,
|
|
218
|
+
),
|
|
219
|
+
cwd=self.workspace.root,
|
|
220
|
+
permission_metadata={
|
|
221
|
+
**decision.metadata,
|
|
222
|
+
"command": command,
|
|
223
|
+
"safe_git_flags": safe_git_flags,
|
|
224
|
+
},
|
|
225
|
+
permission_status=decision.status,
|
|
226
|
+
)
|
|
227
|
+
|
|
228
|
+
def _run(self, args: InspectChangesArgs) -> ToolResult:
|
|
229
|
+
return ToolResult.failure(
|
|
230
|
+
error="inspect_changes must be run through ToolRegistry.run_tool().",
|
|
231
|
+
metadata={"action": args.action, "reason": "permission_required"},
|
|
232
|
+
)
|
|
233
|
+
|
|
234
|
+
def _build_command(self, args: InspectChangesArgs, git_executable: Path) -> list[str]:
|
|
235
|
+
command = [
|
|
236
|
+
git_executable.as_posix(),
|
|
237
|
+
"--no-optional-locks",
|
|
238
|
+
"-c",
|
|
239
|
+
"core.fsmonitor=false",
|
|
240
|
+
"--no-pager",
|
|
241
|
+
]
|
|
242
|
+
if args.action == "status":
|
|
243
|
+
return [
|
|
244
|
+
*command,
|
|
245
|
+
"status",
|
|
246
|
+
"--short",
|
|
247
|
+
"--branch",
|
|
248
|
+
"--untracked-files=all",
|
|
249
|
+
"--ignore-submodules=all",
|
|
250
|
+
]
|
|
251
|
+
|
|
252
|
+
command.extend(
|
|
253
|
+
[
|
|
254
|
+
"diff",
|
|
255
|
+
"--no-ext-diff",
|
|
256
|
+
"--no-textconv",
|
|
257
|
+
"--ignore-submodules=all",
|
|
258
|
+
]
|
|
259
|
+
)
|
|
260
|
+
if args.staged:
|
|
261
|
+
command.append("--cached")
|
|
262
|
+
if args.base_ref is not None:
|
|
263
|
+
command.append(args.base_ref)
|
|
264
|
+
resolved_paths = [
|
|
265
|
+
self.workspace.resolve_path(path).relative_to(self.workspace.root).as_posix()
|
|
266
|
+
for path in args.paths
|
|
267
|
+
]
|
|
268
|
+
command.extend(["--", *resolved_paths])
|
|
269
|
+
return command
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
from pydantic import Field
|
|
2
|
+
|
|
3
|
+
from mycode.skills import (
|
|
4
|
+
MAX_ACTIVE_SKILLS,
|
|
5
|
+
ActiveSkillLimitError,
|
|
6
|
+
ActiveSkillState,
|
|
7
|
+
SkillNotFoundError,
|
|
8
|
+
SkillRegistry,
|
|
9
|
+
)
|
|
10
|
+
from mycode.tools.base import PydanticTool, ToolArgs, ToolResult
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class LoadSkillArgs(ToolArgs):
|
|
14
|
+
name: str = Field(min_length=1)
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class LoadSkillTool(PydanticTool[LoadSkillArgs]):
|
|
18
|
+
name = "load_skill"
|
|
19
|
+
description = (
|
|
20
|
+
"加载一个与当前任务相关的 Skill。加载后的完整 Skill 指导会从下一轮模型调用"
|
|
21
|
+
"开始可见;如果后续操作依赖该 Skill,请先加载 Skill,再根据加载后的指导继续执行。"
|
|
22
|
+
)
|
|
23
|
+
args_model = LoadSkillArgs
|
|
24
|
+
capability = "control"
|
|
25
|
+
risk = "low"
|
|
26
|
+
|
|
27
|
+
def __init__(self, registry: SkillRegistry, state: ActiveSkillState) -> None:
|
|
28
|
+
self.registry = registry
|
|
29
|
+
self.state = state
|
|
30
|
+
|
|
31
|
+
def _run(self, args: LoadSkillArgs) -> ToolResult:
|
|
32
|
+
try:
|
|
33
|
+
skill = self.registry.require(args.name)
|
|
34
|
+
except SkillNotFoundError:
|
|
35
|
+
return ToolResult.failure(
|
|
36
|
+
error=f"Skill not found: {args.name}",
|
|
37
|
+
metadata={"skill_name": args.name, "already_active": False},
|
|
38
|
+
)
|
|
39
|
+
if self.state.is_active(skill.name):
|
|
40
|
+
return ToolResult.success(
|
|
41
|
+
content=f"Skill '{skill.name}' 已经激活。",
|
|
42
|
+
metadata={
|
|
43
|
+
"skill_name": skill.name,
|
|
44
|
+
"skill_source": skill.source,
|
|
45
|
+
"already_active": True,
|
|
46
|
+
},
|
|
47
|
+
)
|
|
48
|
+
if len(self.state.get_active()) >= MAX_ACTIVE_SKILLS:
|
|
49
|
+
return ToolResult.failure(
|
|
50
|
+
error=f"最多只能同时激活 {MAX_ACTIVE_SKILLS} 个 Skill。",
|
|
51
|
+
metadata={
|
|
52
|
+
"skill_name": skill.name,
|
|
53
|
+
"skill_source": skill.source,
|
|
54
|
+
"already_active": False,
|
|
55
|
+
"active_skill_count": len(self.state.get_active()),
|
|
56
|
+
"max_active_skills": MAX_ACTIVE_SKILLS,
|
|
57
|
+
},
|
|
58
|
+
)
|
|
59
|
+
try:
|
|
60
|
+
instructions = self.registry.load_instructions(skill.name)
|
|
61
|
+
self.state.activate(skill, instructions)
|
|
62
|
+
except ActiveSkillLimitError as error:
|
|
63
|
+
return ToolResult.failure(
|
|
64
|
+
error=str(error),
|
|
65
|
+
metadata={
|
|
66
|
+
"skill_name": skill.name,
|
|
67
|
+
"skill_source": skill.source,
|
|
68
|
+
"already_active": False,
|
|
69
|
+
"active_skill_count": len(self.state.get_active()),
|
|
70
|
+
"max_active_skills": MAX_ACTIVE_SKILLS,
|
|
71
|
+
},
|
|
72
|
+
)
|
|
73
|
+
except ValueError as error:
|
|
74
|
+
return ToolResult.failure(
|
|
75
|
+
error=f"无法加载 Skill '{skill.name}':{error}",
|
|
76
|
+
metadata={
|
|
77
|
+
"skill_name": skill.name,
|
|
78
|
+
"skill_source": skill.source,
|
|
79
|
+
"already_active": False,
|
|
80
|
+
},
|
|
81
|
+
)
|
|
82
|
+
metadata = {
|
|
83
|
+
"skill_name": skill.name,
|
|
84
|
+
"skill_source": skill.source,
|
|
85
|
+
"already_active": False,
|
|
86
|
+
}
|
|
87
|
+
return ToolResult.success(
|
|
88
|
+
content=(
|
|
89
|
+
f"Skill '{skill.name}' 已为当前任务激活。完整指导将从下一轮模型调用开始可见。"
|
|
90
|
+
),
|
|
91
|
+
metadata=metadata,
|
|
92
|
+
)
|
mycode/tools/memory.py
ADDED
|
@@ -0,0 +1,264 @@
|
|
|
1
|
+
from pydantic import field_validator
|
|
2
|
+
|
|
3
|
+
from mycode.memory import (
|
|
4
|
+
MemoryKind,
|
|
5
|
+
MemoryScope,
|
|
6
|
+
MemoryStore,
|
|
7
|
+
SensitiveMemoryError,
|
|
8
|
+
validate_memory_content,
|
|
9
|
+
validate_memory_key,
|
|
10
|
+
)
|
|
11
|
+
from mycode.permissions import PermissionChecker, PermissionDecision, PermissionRequest
|
|
12
|
+
from mycode.tools.base import PydanticTool, ToolArgs, ToolResult
|
|
13
|
+
from mycode.tools.permission_metadata import with_permission_metadata
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class ListMemoriesArgs(ToolArgs):
|
|
17
|
+
scope: MemoryScope | None = None
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class SaveMemoryArgs(ToolArgs):
|
|
21
|
+
scope: MemoryScope
|
|
22
|
+
kind: MemoryKind
|
|
23
|
+
key: str
|
|
24
|
+
content: str
|
|
25
|
+
|
|
26
|
+
@field_validator("key")
|
|
27
|
+
@classmethod
|
|
28
|
+
def validate_key(cls, value: str) -> str:
|
|
29
|
+
return validate_memory_key(value)
|
|
30
|
+
|
|
31
|
+
@field_validator("content")
|
|
32
|
+
@classmethod
|
|
33
|
+
def validate_content(cls, value: str) -> str:
|
|
34
|
+
try:
|
|
35
|
+
return validate_memory_content(value, max_chars=2000)
|
|
36
|
+
except SensitiveMemoryError as error:
|
|
37
|
+
raise ValueError(str(error)) from error
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class DeleteMemoryArgs(ToolArgs):
|
|
41
|
+
scope: MemoryScope
|
|
42
|
+
key: str
|
|
43
|
+
|
|
44
|
+
@field_validator("key")
|
|
45
|
+
@classmethod
|
|
46
|
+
def validate_key(cls, value: str) -> str:
|
|
47
|
+
return validate_memory_key(value)
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
class ListMemoriesTool(PydanticTool[ListMemoriesArgs]):
|
|
51
|
+
name = "list_memories"
|
|
52
|
+
description = "List safe long-term memories for the current user or project."
|
|
53
|
+
args_model = ListMemoriesArgs
|
|
54
|
+
capability = "read"
|
|
55
|
+
risk = "low"
|
|
56
|
+
concurrency_safe = True
|
|
57
|
+
|
|
58
|
+
def __init__(self, store: MemoryStore) -> None:
|
|
59
|
+
self.store = store
|
|
60
|
+
|
|
61
|
+
def _run(self, args: ListMemoriesArgs) -> ToolResult:
|
|
62
|
+
entries = self.store.list_entries(args.scope)
|
|
63
|
+
issues = self.store.list_issues(args.scope)
|
|
64
|
+
lines: list[str] = []
|
|
65
|
+
for entry in entries:
|
|
66
|
+
lines.extend(
|
|
67
|
+
[
|
|
68
|
+
f"[{entry.scope}/{entry.kind}] {entry.key}",
|
|
69
|
+
entry.content,
|
|
70
|
+
"",
|
|
71
|
+
]
|
|
72
|
+
)
|
|
73
|
+
if issues:
|
|
74
|
+
lines.append("WITHHELD_OR_INVALID")
|
|
75
|
+
lines.extend(issue.display for issue in issues)
|
|
76
|
+
content = "\n".join(lines).rstrip()
|
|
77
|
+
if content == "":
|
|
78
|
+
content = "No long-term memories found."
|
|
79
|
+
return ToolResult.success(
|
|
80
|
+
content=content,
|
|
81
|
+
metadata={
|
|
82
|
+
"scope": args.scope or "all",
|
|
83
|
+
"memory_count": len(entries),
|
|
84
|
+
"issue_count": len(issues),
|
|
85
|
+
},
|
|
86
|
+
)
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
class SaveMemoryTool(PydanticTool[SaveMemoryArgs]):
|
|
90
|
+
name = "save_memory"
|
|
91
|
+
description = (
|
|
92
|
+
"Create or correct one user-level or project-level long-term memory. "
|
|
93
|
+
"Only use when the user explicitly asks to remember the information."
|
|
94
|
+
)
|
|
95
|
+
args_model = SaveMemoryArgs
|
|
96
|
+
capability = "write"
|
|
97
|
+
risk = "medium"
|
|
98
|
+
|
|
99
|
+
def __init__(self, store: MemoryStore) -> None:
|
|
100
|
+
self.store = store
|
|
101
|
+
|
|
102
|
+
def build_permission_request(self, args: SaveMemoryArgs) -> PermissionRequest:
|
|
103
|
+
return PermissionRequest(
|
|
104
|
+
tool_name=self.name,
|
|
105
|
+
capability=self.capability,
|
|
106
|
+
action=f"save {args.scope} memory",
|
|
107
|
+
target=str(self.store.path_for_scope(args.scope)),
|
|
108
|
+
arguments={
|
|
109
|
+
"scope": args.scope,
|
|
110
|
+
"kind": args.kind,
|
|
111
|
+
"key": args.key,
|
|
112
|
+
"content_chars": len(args.content),
|
|
113
|
+
},
|
|
114
|
+
description="Save one reviewed long-term memory.",
|
|
115
|
+
)
|
|
116
|
+
|
|
117
|
+
def check_permission(
|
|
118
|
+
self,
|
|
119
|
+
args: SaveMemoryArgs,
|
|
120
|
+
permission_checker: PermissionChecker,
|
|
121
|
+
) -> tuple[PermissionRequest, PermissionDecision]:
|
|
122
|
+
request = self.build_permission_request(args)
|
|
123
|
+
decision = permission_checker.check(request, self.get_permission_profile())
|
|
124
|
+
return request, with_permission_metadata(
|
|
125
|
+
decision,
|
|
126
|
+
{
|
|
127
|
+
"memory_scope": args.scope,
|
|
128
|
+
"memory_kind": args.kind,
|
|
129
|
+
"memory_key": args.key,
|
|
130
|
+
"memory_content": args.content,
|
|
131
|
+
"memory_path": str(self.store.path_for_scope(args.scope)),
|
|
132
|
+
},
|
|
133
|
+
)
|
|
134
|
+
|
|
135
|
+
def run_authorized(
|
|
136
|
+
self,
|
|
137
|
+
args: SaveMemoryArgs,
|
|
138
|
+
decision: PermissionDecision,
|
|
139
|
+
) -> ToolResult:
|
|
140
|
+
try:
|
|
141
|
+
result = self.store.save(
|
|
142
|
+
scope=args.scope,
|
|
143
|
+
kind=args.kind,
|
|
144
|
+
key=args.key,
|
|
145
|
+
content=args.content,
|
|
146
|
+
)
|
|
147
|
+
except Exception as error:
|
|
148
|
+
return ToolResult.failure(
|
|
149
|
+
error=f"Memory write failed: {error}",
|
|
150
|
+
metadata={
|
|
151
|
+
**_safe_permission_metadata(decision),
|
|
152
|
+
"memory_scope": args.scope,
|
|
153
|
+
"memory_kind": args.kind,
|
|
154
|
+
"memory_key": args.key,
|
|
155
|
+
"exception_type": type(error).__name__,
|
|
156
|
+
},
|
|
157
|
+
)
|
|
158
|
+
return ToolResult.success(
|
|
159
|
+
content=(
|
|
160
|
+
f"Memory {result.action}: "
|
|
161
|
+
f"[{args.scope}/{args.kind}] {args.key}"
|
|
162
|
+
),
|
|
163
|
+
metadata={
|
|
164
|
+
**_safe_permission_metadata(decision),
|
|
165
|
+
"memory_scope": args.scope,
|
|
166
|
+
"memory_kind": args.kind,
|
|
167
|
+
"memory_key": args.key,
|
|
168
|
+
"memory_action": result.action,
|
|
169
|
+
"memory_path": str(result.path),
|
|
170
|
+
},
|
|
171
|
+
)
|
|
172
|
+
|
|
173
|
+
def _run(self, args: SaveMemoryArgs) -> ToolResult:
|
|
174
|
+
return ToolResult.failure(
|
|
175
|
+
error="save_memory must be run through ToolRegistry.run_tool().",
|
|
176
|
+
metadata={"memory_key": args.key, "reason": "permission_required"},
|
|
177
|
+
)
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
class DeleteMemoryTool(PydanticTool[DeleteMemoryArgs]):
|
|
181
|
+
name = "delete_memory"
|
|
182
|
+
description = "Delete one user-level or project-level long-term memory by key."
|
|
183
|
+
args_model = DeleteMemoryArgs
|
|
184
|
+
capability = "write"
|
|
185
|
+
risk = "medium"
|
|
186
|
+
|
|
187
|
+
def __init__(self, store: MemoryStore) -> None:
|
|
188
|
+
self.store = store
|
|
189
|
+
|
|
190
|
+
def build_permission_request(self, args: DeleteMemoryArgs) -> PermissionRequest:
|
|
191
|
+
return PermissionRequest(
|
|
192
|
+
tool_name=self.name,
|
|
193
|
+
capability=self.capability,
|
|
194
|
+
action=f"delete {args.scope} memory",
|
|
195
|
+
target=str(self.store.path_for_scope(args.scope)),
|
|
196
|
+
arguments={"scope": args.scope, "key": args.key},
|
|
197
|
+
description="Delete one long-term memory.",
|
|
198
|
+
)
|
|
199
|
+
|
|
200
|
+
def check_permission(
|
|
201
|
+
self,
|
|
202
|
+
args: DeleteMemoryArgs,
|
|
203
|
+
permission_checker: PermissionChecker,
|
|
204
|
+
) -> tuple[PermissionRequest, PermissionDecision]:
|
|
205
|
+
request = self.build_permission_request(args)
|
|
206
|
+
decision = permission_checker.check(request, self.get_permission_profile())
|
|
207
|
+
return request, with_permission_metadata(
|
|
208
|
+
decision,
|
|
209
|
+
{
|
|
210
|
+
"memory_scope": args.scope,
|
|
211
|
+
"memory_key": args.key,
|
|
212
|
+
"memory_path": str(self.store.path_for_scope(args.scope)),
|
|
213
|
+
},
|
|
214
|
+
)
|
|
215
|
+
|
|
216
|
+
def run_authorized(
|
|
217
|
+
self,
|
|
218
|
+
args: DeleteMemoryArgs,
|
|
219
|
+
decision: PermissionDecision,
|
|
220
|
+
) -> ToolResult:
|
|
221
|
+
try:
|
|
222
|
+
deleted = self.store.delete(scope=args.scope, key=args.key)
|
|
223
|
+
except Exception as error:
|
|
224
|
+
return ToolResult.failure(
|
|
225
|
+
error=f"Memory delete failed: {error}",
|
|
226
|
+
metadata={
|
|
227
|
+
**_safe_permission_metadata(decision),
|
|
228
|
+
"memory_scope": args.scope,
|
|
229
|
+
"memory_key": args.key,
|
|
230
|
+
"exception_type": type(error).__name__,
|
|
231
|
+
},
|
|
232
|
+
)
|
|
233
|
+
if not deleted:
|
|
234
|
+
return ToolResult.failure(
|
|
235
|
+
error=f"Memory not found: [{args.scope}] {args.key}",
|
|
236
|
+
metadata={
|
|
237
|
+
**_safe_permission_metadata(decision),
|
|
238
|
+
"memory_scope": args.scope,
|
|
239
|
+
"memory_key": args.key,
|
|
240
|
+
},
|
|
241
|
+
)
|
|
242
|
+
return ToolResult.success(
|
|
243
|
+
content=f"Memory deleted: [{args.scope}] {args.key}",
|
|
244
|
+
metadata={
|
|
245
|
+
**_safe_permission_metadata(decision),
|
|
246
|
+
"memory_scope": args.scope,
|
|
247
|
+
"memory_key": args.key,
|
|
248
|
+
"memory_action": "deleted",
|
|
249
|
+
},
|
|
250
|
+
)
|
|
251
|
+
|
|
252
|
+
def _run(self, args: DeleteMemoryArgs) -> ToolResult:
|
|
253
|
+
return ToolResult.failure(
|
|
254
|
+
error="delete_memory must be run through ToolRegistry.run_tool().",
|
|
255
|
+
metadata={"memory_key": args.key, "reason": "permission_required"},
|
|
256
|
+
)
|
|
257
|
+
|
|
258
|
+
|
|
259
|
+
def _safe_permission_metadata(decision: PermissionDecision) -> dict[str, object]:
|
|
260
|
+
return {
|
|
261
|
+
key: value
|
|
262
|
+
for key, value in decision.metadata.items()
|
|
263
|
+
if key != "memory_content"
|
|
264
|
+
}
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
from dataclasses import dataclass
|
|
2
|
+
from pathlib import Path
|
|
3
|
+
|
|
4
|
+
from mycode.permissions import PermissionDecision, PermissionRequest
|
|
5
|
+
from mycode.tools.ignore import is_ignored_path, is_sensitive_path
|
|
6
|
+
from mycode.tools.workspace import Workspace, WorkspacePathError
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
@dataclass(frozen=True)
|
|
10
|
+
class PathPermissionPolicy:
|
|
11
|
+
workspace: Workspace
|
|
12
|
+
|
|
13
|
+
def check_path(
|
|
14
|
+
self,
|
|
15
|
+
request: PermissionRequest,
|
|
16
|
+
path: str | Path,
|
|
17
|
+
) -> PermissionDecision:
|
|
18
|
+
resolved_path = _resolve_candidate_path(self.workspace, path)
|
|
19
|
+
metadata = _build_metadata(
|
|
20
|
+
request=request,
|
|
21
|
+
path=path,
|
|
22
|
+
resolved_path=resolved_path,
|
|
23
|
+
workspace_root=self.workspace.root,
|
|
24
|
+
)
|
|
25
|
+
|
|
26
|
+
try:
|
|
27
|
+
self.workspace.resolve_path(path)
|
|
28
|
+
except WorkspacePathError:
|
|
29
|
+
return PermissionDecision.ask(
|
|
30
|
+
reason="outside_workspace",
|
|
31
|
+
message=f"Path outside workspace requires confirmation: {path}",
|
|
32
|
+
metadata={**metadata, "path_scope": "outside_workspace"},
|
|
33
|
+
)
|
|
34
|
+
|
|
35
|
+
if is_sensitive_path(resolved_path, self.workspace.root):
|
|
36
|
+
return PermissionDecision.ask(
|
|
37
|
+
reason="sensitive_path",
|
|
38
|
+
message=f"Sensitive path requires confirmation: {path}",
|
|
39
|
+
metadata={**metadata, "path_scope": "sensitive_path"},
|
|
40
|
+
)
|
|
41
|
+
|
|
42
|
+
if is_ignored_path(resolved_path, self.workspace.root):
|
|
43
|
+
return PermissionDecision.ask(
|
|
44
|
+
reason="ignored_path",
|
|
45
|
+
message=f"Ignored path requires confirmation: {path}",
|
|
46
|
+
metadata={**metadata, "path_scope": "ignored_path"},
|
|
47
|
+
)
|
|
48
|
+
|
|
49
|
+
return PermissionDecision.allow(
|
|
50
|
+
message=f"Path inside workspace allowed: {path}",
|
|
51
|
+
metadata={**metadata, "path_scope": "inside_workspace"},
|
|
52
|
+
)
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def _resolve_candidate_path(workspace: Workspace, path: str | Path) -> Path:
|
|
56
|
+
requested_path = Path(path)
|
|
57
|
+
if requested_path.is_absolute():
|
|
58
|
+
return requested_path.resolve(strict=False)
|
|
59
|
+
|
|
60
|
+
return (workspace.root / requested_path).resolve(strict=False)
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def _build_metadata(
|
|
64
|
+
*,
|
|
65
|
+
request: PermissionRequest,
|
|
66
|
+
path: str | Path,
|
|
67
|
+
resolved_path: Path,
|
|
68
|
+
workspace_root: Path,
|
|
69
|
+
) -> dict[str, object]:
|
|
70
|
+
return {
|
|
71
|
+
"tool_name": request.tool_name,
|
|
72
|
+
"capability": request.capability,
|
|
73
|
+
"action": request.action,
|
|
74
|
+
"target": request.target,
|
|
75
|
+
"path": str(path),
|
|
76
|
+
"resolved_path": resolved_path.as_posix(),
|
|
77
|
+
"workspace_root": workspace_root.as_posix(),
|
|
78
|
+
}
|