my-pi-agent 0.1.0

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 (141) hide show
  1. package/README.md +318 -0
  2. package/package.json +45 -0
  3. package/pyproject.toml +50 -0
  4. package/src/my_agent_core/__init__.py +123 -0
  5. package/src/my_agent_core/agent.py +441 -0
  6. package/src/my_agent_core/background.py +121 -0
  7. package/src/my_agent_core/context.py +505 -0
  8. package/src/my_agent_core/events.py +153 -0
  9. package/src/my_agent_core/extensions/__init__.py +9 -0
  10. package/src/my_agent_core/extensions/core.py +197 -0
  11. package/src/my_agent_core/hooks.py +130 -0
  12. package/src/my_agent_core/loop.py +709 -0
  13. package/src/my_agent_core/main.py +134 -0
  14. package/src/my_agent_core/memory.py +241 -0
  15. package/src/my_agent_core/message_queue.py +110 -0
  16. package/src/my_agent_core/plugins.py +212 -0
  17. package/src/my_agent_core/registry.py +186 -0
  18. package/src/my_agent_core/session/__init__.py +79 -0
  19. package/src/my_agent_core/session/entries.py +197 -0
  20. package/src/my_agent_core/session/jsonl.py +60 -0
  21. package/src/my_agent_core/session/memory.py +137 -0
  22. package/src/my_agent_core/session/session.py +400 -0
  23. package/src/my_agent_core/session/storage.py +245 -0
  24. package/src/my_agent_core/session/store.py +131 -0
  25. package/src/my_agent_core/session/tree.py +86 -0
  26. package/src/my_agent_core/skills.py +149 -0
  27. package/src/my_agent_core/subagent_tasks.py +170 -0
  28. package/src/my_agent_core/subagents.py +148 -0
  29. package/src/my_agent_core/task_store.py +248 -0
  30. package/src/my_agent_core/tool_history.py +189 -0
  31. package/src/my_agent_core/tools/__init__.py +5 -0
  32. package/src/my_agent_core/tools/builtin/__init__.py +5 -0
  33. package/src/my_agent_core/tools/builtin/task.py +30 -0
  34. package/src/my_agent_core/tools/builtin/task_tools.py +215 -0
  35. package/src/my_agent_core/tools/core.py +239 -0
  36. package/src/my_agent_llm/__init__.py +45 -0
  37. package/src/my_agent_llm/auth/__init__.py +46 -0
  38. package/src/my_agent_llm/auth/antigravity.py +209 -0
  39. package/src/my_agent_llm/auth/manager.py +259 -0
  40. package/src/my_agent_llm/auth/quota.py +56 -0
  41. package/src/my_agent_llm/auth/schema.py +94 -0
  42. package/src/my_agent_llm/client.py +116 -0
  43. package/src/my_agent_llm/config.py +17 -0
  44. package/src/my_agent_llm/events.py +84 -0
  45. package/src/my_agent_llm/models.py +195 -0
  46. package/src/my_agent_llm/providers/__init__.py +4 -0
  47. package/src/my_agent_llm/providers/_base.py +94 -0
  48. package/src/my_agent_llm/providers/anthropic.py +298 -0
  49. package/src/my_agent_llm/providers/antigravity.py +480 -0
  50. package/src/my_agent_llm/providers/deepseek.py +196 -0
  51. package/src/my_agent_llm/providers/openai.py +364 -0
  52. package/src/my_agent_llm/providers/registry.py +16 -0
  53. package/src/my_agent_llm/stream.py +218 -0
  54. package/src/my_coding_agent/__init__.py +66 -0
  55. package/src/my_coding_agent/agent.py +208 -0
  56. package/src/my_coding_agent/cli.py +78 -0
  57. package/src/my_coding_agent/file_reference.py +80 -0
  58. package/src/my_coding_agent/macro.py +408 -0
  59. package/src/my_coding_agent/mcp.py +243 -0
  60. package/src/my_coding_agent/mutation_queue.py +37 -0
  61. package/src/my_coding_agent/paths.py +119 -0
  62. package/src/my_coding_agent/permissions.py +84 -0
  63. package/src/my_coding_agent/prompt.py +54 -0
  64. package/src/my_coding_agent/rpc_server.py +2817 -0
  65. package/src/my_coding_agent/settings.py +126 -0
  66. package/src/my_coding_agent/tools/__init__.py +55 -0
  67. package/src/my_coding_agent/tools/base.py +58 -0
  68. package/src/my_coding_agent/tools/bash.py +206 -0
  69. package/src/my_coding_agent/tools/edit.py +226 -0
  70. package/src/my_coding_agent/tools/find.py +118 -0
  71. package/src/my_coding_agent/tools/grep.py +177 -0
  72. package/src/my_coding_agent/tools/ls.py +112 -0
  73. package/src/my_coding_agent/tools/read.py +113 -0
  74. package/src/my_coding_agent/tools/write.py +72 -0
  75. package/tui/README.md +27 -0
  76. package/tui/bin/my-agent.js +98 -0
  77. package/tui/dist/app.d.ts +41 -0
  78. package/tui/dist/app.js +110 -0
  79. package/tui/dist/bridge/event-translator.d.ts +92 -0
  80. package/tui/dist/bridge/event-translator.js +216 -0
  81. package/tui/dist/bridge/kernel-bridge.d.ts +48 -0
  82. package/tui/dist/bridge/kernel-bridge.js +132 -0
  83. package/tui/dist/client.d.ts +63 -0
  84. package/tui/dist/client.js +239 -0
  85. package/tui/dist/components/assistant-message.d.ts +19 -0
  86. package/tui/dist/components/assistant-message.js +90 -0
  87. package/tui/dist/components/compaction-summary-message.d.ts +19 -0
  88. package/tui/dist/components/compaction-summary-message.js +46 -0
  89. package/tui/dist/components/custom-editor.d.ts +18 -0
  90. package/tui/dist/components/custom-editor.js +56 -0
  91. package/tui/dist/components/dynamic-border.d.ts +9 -0
  92. package/tui/dist/components/dynamic-border.js +14 -0
  93. package/tui/dist/components/footer.d.ts +39 -0
  94. package/tui/dist/components/footer.js +199 -0
  95. package/tui/dist/components/header.d.ts +4 -0
  96. package/tui/dist/components/header.js +21 -0
  97. package/tui/dist/components/keys.d.ts +5 -0
  98. package/tui/dist/components/keys.js +12 -0
  99. package/tui/dist/components/login-selector.d.ts +26 -0
  100. package/tui/dist/components/login-selector.js +181 -0
  101. package/tui/dist/components/logout-selector.d.ts +19 -0
  102. package/tui/dist/components/logout-selector.js +88 -0
  103. package/tui/dist/components/model-selector.d.ts +40 -0
  104. package/tui/dist/components/model-selector.js +268 -0
  105. package/tui/dist/components/session-selector.d.ts +54 -0
  106. package/tui/dist/components/session-selector.js +393 -0
  107. package/tui/dist/components/settings-selector.d.ts +24 -0
  108. package/tui/dist/components/settings-selector.js +146 -0
  109. package/tui/dist/components/status-indicator.d.ts +25 -0
  110. package/tui/dist/components/status-indicator.js +60 -0
  111. package/tui/dist/components/theme-selector.d.ts +14 -0
  112. package/tui/dist/components/theme-selector.js +77 -0
  113. package/tui/dist/components/thinking-selector.d.ts +21 -0
  114. package/tui/dist/components/thinking-selector.js +128 -0
  115. package/tui/dist/components/tool-execution.d.ts +31 -0
  116. package/tui/dist/components/tool-execution.js +206 -0
  117. package/tui/dist/components/tree-selector.d.ts +40 -0
  118. package/tui/dist/components/tree-selector.js +173 -0
  119. package/tui/dist/components/user-message-selector.d.ts +21 -0
  120. package/tui/dist/components/user-message-selector.js +103 -0
  121. package/tui/dist/components/user-message.d.ts +5 -0
  122. package/tui/dist/components/user-message.js +15 -0
  123. package/tui/dist/index.d.ts +11 -0
  124. package/tui/dist/index.js +11 -0
  125. package/tui/dist/interactive/chat-viewport.d.ts +19 -0
  126. package/tui/dist/interactive/chat-viewport.js +41 -0
  127. package/tui/dist/interactive/components.d.ts +1 -0
  128. package/tui/dist/interactive/components.js +1 -0
  129. package/tui/dist/interactive/interactive-mode.d.ts +89 -0
  130. package/tui/dist/interactive/interactive-mode.js +1625 -0
  131. package/tui/dist/interactive/theme.d.ts +1 -0
  132. package/tui/dist/interactive/theme.js +1 -0
  133. package/tui/dist/interactive/tui-renderer.d.ts +8 -0
  134. package/tui/dist/interactive/tui-renderer.js +10 -0
  135. package/tui/dist/protocol.d.ts +78 -0
  136. package/tui/dist/protocol.js +1 -0
  137. package/tui/dist/theme/dark.json +54 -0
  138. package/tui/dist/theme/light.json +71 -0
  139. package/tui/dist/theme/theme.d.ts +20 -0
  140. package/tui/dist/theme/theme.js +86 -0
  141. package/tui/package.json +25 -0
@@ -0,0 +1,126 @@
1
+ """Settings: 全局 (~/.my-pi-agent/settings.json) 与项目双层级联配置系统。"""
2
+
3
+ from __future__ import annotations
4
+
5
+ import contextlib
6
+ import json
7
+ from pathlib import Path
8
+ from typing import Any, Literal
9
+
10
+ from pydantic import BaseModel, ConfigDict, Field
11
+
12
+ from my_coding_agent.paths import AgentPaths
13
+
14
+ # 特权字段:仅允许在全局 settings.json 中配置,项目级配置一律忽略
15
+ PRIVILEGED_GLOBAL_KEYS: set[str] = {
16
+ "http_proxy",
17
+ "httpProxy",
18
+ "project_trust",
19
+ "projectTrust",
20
+ }
21
+
22
+
23
+ class CompactionSettings(BaseModel):
24
+ """上下文压缩管线配置参数。"""
25
+
26
+ enabled: bool = True
27
+ reserve_tokens: int = Field(default=16384, alias="reserveTokens")
28
+ keep_recent_tokens: int = Field(default=20000, alias="keepRecentTokens")
29
+
30
+ model_config = ConfigDict(populate_by_name=True)
31
+
32
+
33
+ class Settings(BaseModel):
34
+ """统一管理全局偏好与项目覆盖的强类型配置实体。"""
35
+
36
+ # ── 默认模型与思考深度 ──
37
+ default_provider: str = Field(default="openai", alias="defaultProvider")
38
+ default_model: str = Field(default="deepseek-flash", alias="defaultModel")
39
+ default_thinking_level: Literal["off", "minimal", "low", "medium", "high", "xhigh", "max"] = Field(
40
+ default="off", alias="defaultThinkingLevel"
41
+ )
42
+ model_thinking_levels: dict[str, str] = Field(default_factory=dict, alias="modelThinkingLevels")
43
+
44
+ # ── UI 表现层 ──
45
+ theme: str = "dark"
46
+ quiet_startup: bool = Field(default=False, alias="quietStartup")
47
+ editor_padding_x: int = Field(default=0, alias="editorPaddingX")
48
+ show_hardware_cursor: bool = Field(default=False, alias="showHardwareCursor")
49
+
50
+ # ── 网络与代理 (特权级) ──
51
+ http_proxy: str | None = Field(default=None, alias="httpProxy")
52
+
53
+ # ── 会话与压缩管线 ──
54
+ compaction: CompactionSettings = Field(default_factory=CompactionSettings)
55
+ auto_save_session: bool = Field(default=True, alias="autoSaveSession")
56
+
57
+ # ── 权限与安全 ──
58
+ default_permission_mode: Literal["review", "yolo", "strict"] = Field(
59
+ default="review", alias="defaultPermissionMode"
60
+ )
61
+ project_trust: Literal["ask", "always", "never"] = Field(default="ask", alias="projectTrust")
62
+
63
+ model_config = ConfigDict(populate_by_name=True)
64
+
65
+
66
+ def _deep_merge_dict(base: dict[str, Any], overrides: dict[str, Any]) -> dict[str, Any]:
67
+ """递归合并字典;数组/列表整体替换。"""
68
+ result = dict(base)
69
+ for k, v in overrides.items():
70
+ if k in result and isinstance(result[k], dict) and isinstance(v, dict):
71
+ result[k] = _deep_merge_dict(result[k], v)
72
+ else:
73
+ result[k] = v
74
+ return result
75
+
76
+
77
+ def load_settings(paths: AgentPaths | None = None, cwd: Path | str | None = None) -> Settings:
78
+ """加载并级联合并全局与项目级配置。
79
+
80
+ 优先级:默认内置值 < 全局 settings.json < 项目级 settings.json(受限覆盖)
81
+ """
82
+ active_paths = paths or AgentPaths()
83
+ merged: dict[str, Any] = {}
84
+
85
+ # 1. 全局配置
86
+ if active_paths.settings_path.exists():
87
+ try:
88
+ content = active_paths.settings_path.read_text(encoding="utf-8")
89
+ data = json.loads(content)
90
+ if isinstance(data, dict):
91
+ merged = dict(data)
92
+ except Exception:
93
+ pass
94
+
95
+ # 2. 项目级受控覆盖
96
+ if cwd:
97
+ proj_settings = active_paths.project_settings_path(Path(cwd))
98
+ if proj_settings.exists():
99
+ try:
100
+ content = proj_settings.read_text(encoding="utf-8")
101
+ data = json.loads(content)
102
+ if isinstance(data, dict):
103
+ # 剥离项目级越权特权字段
104
+ safe_data = {k: v for k, v in data.items() if k not in PRIVILEGED_GLOBAL_KEYS}
105
+ merged = _deep_merge_dict(merged, safe_data)
106
+ except Exception:
107
+ pass
108
+
109
+ try:
110
+ return Settings.model_validate(merged)
111
+ except Exception:
112
+ return Settings()
113
+
114
+
115
+ def save_settings(settings: Settings, target_path: Path) -> None:
116
+ """以 0o600 权限与临时文件原子替换保存配置。"""
117
+ resolved_target = Path(target_path).resolve()
118
+ resolved_target.parent.mkdir(parents=True, exist_ok=True)
119
+ tmp_path = resolved_target.with_name(f"{resolved_target.name}.tmp")
120
+ tmp_path.write_text(
121
+ settings.model_dump_json(by_alias=True, indent=2) + "\n",
122
+ encoding="utf-8",
123
+ )
124
+ with contextlib.suppress(Exception):
125
+ tmp_path.chmod(0o600)
126
+ tmp_path.replace(resolved_target)
@@ -0,0 +1,55 @@
1
+ """文件工具包:7 个核心工具工厂(对齐 Pi 原厂 read/write/edit/bash/grep/find/ls)+ build_coding_tools 装配入口。"""
2
+
3
+ from pathlib import Path
4
+ from typing import TYPE_CHECKING
5
+
6
+ from my_agent_core.tools import Tool # pyright: ignore[reportMissingImports]
7
+
8
+ if TYPE_CHECKING:
9
+ from my_agent_core.background import ( # pyright: ignore[reportMissingImports]
10
+ BackgroundRunner,
11
+ )
12
+
13
+ from my_coding_agent.mutation_queue import FileMutationQueue
14
+ from my_coding_agent.tools.base import is_binary_file, resolve_path
15
+ from my_coding_agent.tools.bash import make_bash_tool
16
+ from my_coding_agent.tools.edit import EditBlock, make_edit_tool
17
+ from my_coding_agent.tools.find import make_find_tool
18
+ from my_coding_agent.tools.grep import make_grep_tool
19
+ from my_coding_agent.tools.ls import make_ls_tool
20
+ from my_coding_agent.tools.read import make_read_tool
21
+ from my_coding_agent.tools.write import make_write_tool
22
+
23
+
24
+ def build_coding_tools(
25
+ workspace: str | Path,
26
+ mutation_queue: FileMutationQueue | None = None,
27
+ background_runner: "BackgroundRunner | None" = None,
28
+ ) -> list[Tool]:
29
+ """返回 7 个核心文件工具(read/write/edit/bash/grep/find/ls),各绑定 workspace。"""
30
+ workspace_path = Path(workspace).resolve()
31
+ queue = mutation_queue or FileMutationQueue()
32
+ return [
33
+ make_read_tool(workspace_path),
34
+ make_write_tool(workspace_path, mutation_queue=queue),
35
+ make_edit_tool(workspace_path, mutation_queue=queue),
36
+ make_bash_tool(workspace_path, background_runner=background_runner),
37
+ make_grep_tool(workspace_path),
38
+ make_find_tool(workspace_path),
39
+ make_ls_tool(workspace_path),
40
+ ]
41
+
42
+
43
+ __all__ = [
44
+ "build_coding_tools",
45
+ "resolve_path",
46
+ "is_binary_file",
47
+ "make_read_tool",
48
+ "make_write_tool",
49
+ "make_edit_tool",
50
+ "EditBlock",
51
+ "make_bash_tool",
52
+ "make_grep_tool",
53
+ "make_find_tool",
54
+ "make_ls_tool",
55
+ ]
@@ -0,0 +1,58 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass
4
+ from pathlib import Path
5
+ from typing import Any
6
+
7
+ from my_agent_core.tools import ToolResult # pyright: ignore[reportMissingImports]
8
+
9
+ DEFAULT_MAX_LINES = 2000
10
+ DEFAULT_MAX_BYTES = 50 * 1024 # 50KB
11
+ DEFAULT_IGNORE_DIRS = {
12
+ ".git",
13
+ ".venv",
14
+ "node_modules",
15
+ "__pycache__",
16
+ ".pytest_cache",
17
+ ".ruff_cache",
18
+ }
19
+
20
+
21
+ def resolve_path(workspace: Path, p: str | Path) -> Path:
22
+ raw = Path(p)
23
+ if raw.is_absolute():
24
+ return raw.resolve()
25
+ return (workspace / raw).resolve()
26
+
27
+
28
+ def is_binary_file(path: Path) -> bool:
29
+ try:
30
+ with open(path, "rb") as f:
31
+ chunk = f.read(1024)
32
+ return b"\x00" in chunk
33
+ except Exception:
34
+ return False
35
+
36
+
37
+ @dataclass
38
+ class StringCompatibleToolResult(ToolResult):
39
+ """ToolResult 子类:提供字符串兼容比较、包含、转换操作。
40
+
41
+ 共享基类,供 read/write/edit/bash/grep/find 工具继承,避免重复实现。
42
+ """
43
+
44
+ def __eq__(self, other: Any) -> bool:
45
+ if isinstance(other, str):
46
+ val = self.data if self.data is not None else self.error
47
+ return str(val) == other
48
+ return super().__eq__(other)
49
+
50
+ def __contains__(self, item: Any) -> bool:
51
+ content = self.data if self.data is not None else (self.error or "")
52
+ return str(item) in str(content)
53
+
54
+ def __str__(self) -> str:
55
+ return str(self.data if self.data is not None else self.error)
56
+
57
+ def __repr__(self) -> str:
58
+ return repr(self.data if self.data is not None else self.error)
@@ -0,0 +1,206 @@
1
+ from __future__ import annotations
2
+
3
+ import asyncio
4
+ import contextlib
5
+ import inspect
6
+ import os
7
+ import signal
8
+ import sys
9
+ import tempfile
10
+ from collections.abc import Callable
11
+ from pathlib import Path
12
+ from typing import Any
13
+
14
+ from my_agent_core.background import BackgroundRunner
15
+ from my_agent_core.tools import Tool, tool
16
+
17
+ from my_coding_agent.tools.base import (
18
+ DEFAULT_MAX_BYTES,
19
+ DEFAULT_MAX_LINES,
20
+ StringCompatibleToolResult,
21
+ )
22
+
23
+ BLOCKED_COMMANDS = {
24
+ "rm -rf /",
25
+ "rm -rf /*",
26
+ "mkfs",
27
+ "dd if=/dev/zero",
28
+ ":(){ :|:& };:",
29
+ "shutdown",
30
+ "reboot",
31
+ "init 0",
32
+ }
33
+
34
+
35
+ def _kill_process_tree(pid: int) -> None:
36
+ """递归杀死指定 PID 的进程树,兼容 Windows 与 POSIX。"""
37
+ if sys.platform == "win32":
38
+ import subprocess
39
+
40
+ with contextlib.suppress(Exception):
41
+ subprocess.run(
42
+ ["taskkill", "/F", "/T", "/PID", str(pid)],
43
+ capture_output=True,
44
+ check=False,
45
+ )
46
+ else:
47
+ try:
48
+ pgid = os.getpgid(pid)
49
+ os.killpg(pgid, signal.SIGKILL)
50
+ except Exception:
51
+ with contextlib.suppress(Exception):
52
+ os.kill(pid, signal.SIGKILL)
53
+
54
+
55
+ class BashResult(StringCompatibleToolResult):
56
+ """Bash 工具执行结果:继承 StringCompatibleToolResult。"""
57
+
58
+
59
+ def make_bash_tool(
60
+ workspace: Path | str,
61
+ background_runner: BackgroundRunner | None = None,
62
+ ) -> Tool:
63
+ """创建 bash 工具工厂,绑定 workspace 目录并支持超时终结、日志外溢与后台运行。"""
64
+ workspace = Path(workspace).resolve()
65
+
66
+ @tool(
67
+ name="bash",
68
+ description="Execute a bash/shell command in the workspace with timeout protection and process tree killing.",
69
+ is_parallel_safe=False,
70
+ )
71
+ async def bash(
72
+ command: str,
73
+ timeout: int = 120,
74
+ run_in_background: bool = False,
75
+ on_update: Callable[[Any], None] | None = None,
76
+ ) -> str:
77
+ for blocked in BLOCKED_COMMANDS:
78
+ if blocked in command:
79
+ return f"Error: Blocked dangerous command pattern '{blocked}'."
80
+
81
+ if run_in_background:
82
+ if background_runner is None:
83
+ return "Error: Background task execution not configured on this agent."
84
+ res = background_runner.run_process(command, cwd=workspace)
85
+ task_id = await res if inspect.isawaitable(res) else res
86
+ return f"Background task started with ID: {task_id}"
87
+
88
+ try:
89
+ kwargs: dict[str, Any] = {
90
+ "cwd": str(workspace),
91
+ "stdout": asyncio.subprocess.PIPE,
92
+ "stderr": asyncio.subprocess.STDOUT,
93
+ "stdin": asyncio.subprocess.DEVNULL,
94
+ }
95
+ if sys.platform != "win32":
96
+ kwargs["preexec_fn"] = os.setsid
97
+
98
+ proc = await asyncio.create_subprocess_shell(command, **kwargs)
99
+
100
+ output_chunks: list[str] = []
101
+ loop = asyncio.get_running_loop()
102
+ last_update_time = loop.time()
103
+
104
+ async def _read_stream() -> None:
105
+ nonlocal last_update_time
106
+ if proc.stdout is None:
107
+ return
108
+ while True:
109
+ line_bytes = await proc.stdout.readline()
110
+ if not line_bytes:
111
+ break
112
+ text = line_bytes.decode("utf-8", errors="replace")
113
+ output_chunks.append(text)
114
+ now = loop.time()
115
+ if on_update is not None and (now - last_update_time >= 0.1):
116
+ last_update_time = now
117
+ tail_preview = "".join(output_chunks[-5:]).strip()
118
+ if tail_preview:
119
+ on_update(tail_preview)
120
+
121
+ try:
122
+ await asyncio.wait_for(_read_stream(), timeout=timeout)
123
+ await proc.wait()
124
+ output = "".join(output_chunks)
125
+ except asyncio.TimeoutError:
126
+ if proc.pid:
127
+ _kill_process_tree(proc.pid)
128
+ with contextlib.suppress(Exception):
129
+ await asyncio.wait_for(proc.wait(), timeout=2.0)
130
+ return f"Error: Command timed out after {timeout} seconds: {command}"
131
+ except (asyncio.CancelledError, GeneratorExit):
132
+ if proc.pid:
133
+ _kill_process_tree(proc.pid)
134
+ with contextlib.suppress(Exception):
135
+ await asyncio.wait_for(proc.wait(), timeout=2.0)
136
+ raise
137
+
138
+ exit_code = proc.returncode
139
+
140
+ # 处理尾部截断与外溢至临时日志文件
141
+ lines = output.splitlines()
142
+ total_lines = len(lines)
143
+ encoded = output.encode("utf-8")
144
+ is_overflow = total_lines > DEFAULT_MAX_LINES or len(encoded) > DEFAULT_MAX_BYTES
145
+
146
+ if is_overflow:
147
+ with tempfile.NamedTemporaryFile(
148
+ mode="w",
149
+ prefix="pi-bash-",
150
+ suffix=".log",
151
+ delete=False,
152
+ encoding="utf-8",
153
+ ) as f:
154
+ f.write(output)
155
+ temp_log_path = f.name
156
+
157
+ truncated_lines = lines[-DEFAULT_MAX_LINES:]
158
+ truncated_text = "\n".join(truncated_lines)
159
+ trunc_bytes = truncated_text.encode("utf-8")
160
+ if len(trunc_bytes) > DEFAULT_MAX_BYTES:
161
+ tail_bytes = trunc_bytes[-DEFAULT_MAX_BYTES:]
162
+ nl_pos = tail_bytes.find(b"\n")
163
+ if nl_pos != -1 and nl_pos + 1 < len(tail_bytes):
164
+ tail_bytes = tail_bytes[nl_pos + 1 :]
165
+ truncated_text = tail_bytes.decode("utf-8", errors="ignore")
166
+ truncated_lines = truncated_text.splitlines()
167
+
168
+ output = (
169
+ f"[Output truncated: showing last {len(truncated_lines)} lines of {total_lines}. "
170
+ f"Full output saved to: {temp_log_path}]\n\n{truncated_text}"
171
+ )
172
+
173
+ if exit_code != 0:
174
+ return f"Command failed with exit code {exit_code}:\n{output}"
175
+
176
+ return output or "(Command executed with no output)"
177
+ except Exception as e:
178
+ return f"Error: {e}"
179
+
180
+ orig_execute = bash.execute
181
+
182
+ async def execute(
183
+ args: dict[str, Any] | None = None,
184
+ signal: Any | None = None,
185
+ on_update: Callable[[Any], None] | None = None,
186
+ tool_call_id: str | None = None,
187
+ **kwargs: Any,
188
+ ) -> BashResult:
189
+ call_args = dict(args) if isinstance(args, dict) else {}
190
+ call_args.update(kwargs)
191
+ res = await orig_execute(
192
+ call_args,
193
+ signal=signal,
194
+ on_update=on_update,
195
+ tool_call_id=tool_call_id,
196
+ )
197
+ return BashResult(
198
+ ok=res.ok,
199
+ data=res.data,
200
+ error=res.error,
201
+ meta=res.meta,
202
+ terminate=res.terminate,
203
+ )
204
+
205
+ bash.execute = execute
206
+ return bash
@@ -0,0 +1,226 @@
1
+ from __future__ import annotations
2
+
3
+ import difflib
4
+ import json
5
+ from collections.abc import Callable
6
+ from pathlib import Path
7
+ from typing import Any
8
+
9
+ from my_agent_core.tools import Tool, tool
10
+ from pydantic import BaseModel, ConfigDict, Field
11
+
12
+ from my_coding_agent.mutation_queue import FileMutationQueue
13
+ from my_coding_agent.tools.base import (
14
+ StringCompatibleToolResult,
15
+ is_binary_file,
16
+ resolve_path,
17
+ )
18
+
19
+
20
+ class EditBlock(BaseModel):
21
+ """单个代码编辑块:精确旧文本与替换新文本。"""
22
+
23
+ model_config = ConfigDict(populate_by_name=True)
24
+
25
+ old_text: str = Field(..., alias="oldText", description="Exact original code block to replace")
26
+ new_text: str = Field(..., alias="newText", description="New code block to insert")
27
+
28
+
29
+ class EditResult(StringCompatibleToolResult):
30
+ """Edit 工具执行结果:继承 StringCompatibleToolResult。"""
31
+
32
+
33
+ def make_edit_tool(workspace: Path, mutation_queue: FileMutationQueue | None = None) -> Tool:
34
+ """创建工作区绑定的 edit 工具。
35
+
36
+ - workspace: 工作区根目录路径
37
+ - mutation_queue: 单文件并发互斥锁队列,缺省时自动新建
38
+ """
39
+ workspace = workspace.resolve()
40
+ queue = mutation_queue or FileMutationQueue()
41
+
42
+ @tool(
43
+ name="edit",
44
+ description="Surgically edit file using multi-edit atomic blocks and unified diff generation.",
45
+ is_parallel_safe=True,
46
+ )
47
+ async def edit(
48
+ path: str,
49
+ edits: list[dict] | list[EditBlock] | None = None,
50
+ old_text: str | None = None,
51
+ new_text: str | None = None,
52
+ ) -> str:
53
+ try:
54
+ target = resolve_path(workspace, path)
55
+ if not target.exists():
56
+ return f"Error: File not found: {path}"
57
+ if target.is_dir():
58
+ return f"Error: Path is a directory: {path}"
59
+ if is_binary_file(target):
60
+ return f"Error: Cannot edit binary file: {path}"
61
+
62
+ # 1. 规范化 edits 入参 (对标 Pi 官方 prepareEditArguments 容错)
63
+ raw_edits = edits
64
+ if isinstance(raw_edits, str):
65
+ try:
66
+ parsed = json.loads(raw_edits)
67
+ if isinstance(parsed, (list, dict)):
68
+ raw_edits = parsed
69
+ except Exception:
70
+ pass
71
+ elif isinstance(raw_edits, dict):
72
+ raw_edits = [raw_edits]
73
+
74
+ edit_blocks: list[EditBlock] = []
75
+ if raw_edits is not None and isinstance(raw_edits, list):
76
+ if len(raw_edits) == 0:
77
+ return "Error: No edits provided."
78
+ for item in raw_edits:
79
+ if isinstance(item, EditBlock):
80
+ edit_blocks.append(item)
81
+ elif isinstance(item, dict):
82
+ old_val = item.get("oldText") if "oldText" in item else item.get("old_text")
83
+ new_val = item.get("newText") if "newText" in item else item.get("new_text")
84
+ if old_val is None or new_val is None:
85
+ return (
86
+ "Error: Each edit block must contain 'oldText' (or 'old_text') "
87
+ "and 'newText' (or 'new_text')."
88
+ )
89
+ edit_blocks.append(EditBlock(old_text=str(old_val), new_text=str(new_val)))
90
+ else:
91
+ return f"Error: Invalid edit block type: {type(item).__name__}"
92
+ elif old_text is not None and new_text is not None:
93
+ edit_blocks.append(EditBlock(old_text=old_text, new_text=new_text))
94
+ else:
95
+ return "Error: Either 'edits' or ('old_text' and 'new_text') must be provided."
96
+
97
+ if not edit_blocks:
98
+ return "Error: No edits provided."
99
+
100
+ async with queue.acquire(target):
101
+ raw_bytes = target.read_bytes()
102
+
103
+ # 2. 探测 BOM
104
+ has_bom = raw_bytes.startswith(b"\xef\xbb\xbf")
105
+ clean_bytes = raw_bytes[3:] if has_bom else raw_bytes
106
+ try:
107
+ content = clean_bytes.decode("utf-8")
108
+ except UnicodeDecodeError:
109
+ return f"Error: File is not valid UTF-8: {path}"
110
+
111
+ # 3. 探测换行符并统一归一化为 LF
112
+ is_crlf = "\r\n" in content
113
+ normalized_content = content.replace("\r\n", "\n")
114
+
115
+ # 4. 预检所有 edit block(唯一匹配与非重叠校验)
116
+ matches: list[tuple[int, int, str, str, int]] = []
117
+ for i, b in enumerate(edit_blocks):
118
+ old_norm = b.old_text.replace("\r\n", "\n")
119
+ new_norm = b.new_text.replace("\r\n", "\n")
120
+ if not old_norm:
121
+ return f"Error: 'oldText' cannot be empty (edit #{i + 1})."
122
+
123
+ count = normalized_content.count(old_norm)
124
+ if count == 0:
125
+ lines_count = len(normalized_content.splitlines())
126
+ return (
127
+ f"Error: 'oldText' not found in {path} (edit #{i + 1}). "
128
+ f"The file has {lines_count} lines. Please read the file first to check exact indentation."
129
+ )
130
+ if count > 1:
131
+ return (
132
+ f"Error: 'oldText' matched {count} times in {path} (edit #{i + 1}). "
133
+ f"Please provide more surrounding context lines to ensure a unique match."
134
+ )
135
+
136
+ idx = normalized_content.find(old_norm)
137
+ matches.append((idx, idx + len(old_norm), old_norm, new_norm, i + 1))
138
+
139
+ # 5. 校验区间非重叠
140
+ matches.sort(key=lambda m: (m[0], m[1]))
141
+ for j in range(len(matches) - 1):
142
+ if matches[j][1] > matches[j + 1][0]:
143
+ e1 = matches[j][4]
144
+ e2 = matches[j + 1][4]
145
+ return (
146
+ f"Error: Overlapping edit regions detected between "
147
+ f"edit #{min(e1, e2)} and edit #{max(e1, e2)}."
148
+ )
149
+
150
+ # 6. 逆序替换(Reverse replacement)
151
+ modified_content = normalized_content
152
+ for start, end, _, new_norm, _ in sorted(matches, key=lambda m: m[0], reverse=True):
153
+ modified_content = modified_content[:start] + new_norm + modified_content[end:]
154
+
155
+ # 7. 生成 Unified Diff
156
+ orig_lines = normalized_content.splitlines(keepends=True)
157
+ mod_lines = modified_content.splitlines(keepends=True)
158
+ display_path = path.replace("\\", "/")
159
+ diff = "".join(
160
+ difflib.unified_diff(
161
+ orig_lines,
162
+ mod_lines,
163
+ fromfile=f"a/{display_path}",
164
+ tofile=f"b/{display_path}",
165
+ lineterm="\n",
166
+ )
167
+ )
168
+
169
+ # 8. 还原换行符与 BOM
170
+ final_text = modified_content.replace("\n", "\r\n") if is_crlf else modified_content
171
+ out_bytes = final_text.encode("utf-8")
172
+ if has_bom:
173
+ out_bytes = b"\xef\xbb\xbf" + out_bytes
174
+
175
+ target.write_bytes(out_bytes)
176
+
177
+ return f"Successfully applied {len(edit_blocks)} edit(s) to {path}.\nDiff:\n```diff\n{diff}```"
178
+ except Exception as e:
179
+ return f"Error: {e}"
180
+
181
+ orig_execute = edit.execute
182
+
183
+ async def execute(
184
+ args: dict[str, Any] | None = None,
185
+ signal: Any | None = None,
186
+ on_update: Callable[[Any], None] | None = None,
187
+ tool_call_id: str | None = None,
188
+ **kwargs: Any,
189
+ ) -> EditResult:
190
+ call_args = dict(args) if isinstance(args, dict) else {}
191
+ call_args.update(kwargs)
192
+
193
+ # 规范化 edits 入参 (对标 Pi 官方 prepareEditArguments 容错)
194
+ raw_edits = call_args.get("edits")
195
+ if isinstance(raw_edits, str):
196
+ try:
197
+ parsed = json.loads(raw_edits)
198
+ if isinstance(parsed, (list, dict)):
199
+ raw_edits = parsed
200
+ except Exception:
201
+ pass
202
+ if isinstance(raw_edits, dict):
203
+ raw_edits = [raw_edits]
204
+ if raw_edits is not None:
205
+ call_args["edits"] = raw_edits
206
+
207
+ if "oldText" in call_args and "old_text" not in call_args:
208
+ call_args["old_text"] = call_args.pop("oldText")
209
+ if "newText" in call_args and "new_text" not in call_args:
210
+ call_args["new_text"] = call_args.pop("newText")
211
+ res = await orig_execute(
212
+ call_args,
213
+ signal=signal,
214
+ on_update=on_update,
215
+ tool_call_id=tool_call_id,
216
+ )
217
+ return EditResult(
218
+ ok=res.ok,
219
+ data=res.data,
220
+ error=res.error,
221
+ meta=res.meta,
222
+ terminate=res.terminate,
223
+ )
224
+
225
+ edit.execute = execute
226
+ return edit