Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
61 changes: 42 additions & 19 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,11 +13,16 @@
- **16 个内置工具**:读文件、写文件、局部替换、执行 shell、列目录、glob 搜索、代码搜索、网页搜索、抓取网页、询问用户、待办管理、多文件读取、批量补丁、符号搜索、定义跳转、引用查找。
- **安全策略**:写操作、危险 shell 命令需要用户确认;禁止访问工作目录外路径。
- **历史持久化**:会话消息和待办事项自动保存到 SQLite,支持跨会话恢复。
- **双模型后端**:默认 Kimi,支持 OpenAI 兼容接口切换。
- **OpenAI 兼容后端**:默认配置为 Kimi,也可切换到其他云端或本地兼容接口。
- **多语言文件工具,Python 结构化索引**:文件读写和文本搜索适用于多种语言;符号搜索、定义跳转和引用查找目前仅解析 Python。

## 安装

```bash
# 运行环境
pip install -e .

# 仅开发者需要测试、格式化和类型检查依赖
pip install -e ".[dev]"
```

Expand Down Expand Up @@ -63,7 +68,7 @@ coding-agent> 写一个 hello.py,内容是 print("hello"),然后运行它
| `/agent [list\|<role>]` | 列出或切换角色 |
| `/mcp` | MCP 服务器状态(实验性) |
| `/reload` | 重新加载配置与角色 |
| `/yolo on\|off\|status` | 切换危险操作确认模式 |
| `/yolo on\|off\|status` | 切换危险操作确认模式;开启时需再次输入 `YOLO` |
| `exit` / `quit` | 退出 |

## 代码质量
Expand Down Expand Up @@ -97,7 +102,8 @@ model = "kimi-for-coding"
base_url = "https://api.kimi.com/coding/v1"
api_key = ""
max_steps_per_turn = 100
max_retries_per_step = 3
max_total_tokens_per_turn = 100000
max_retries_per_step = 5

[security]
confirm_dangerous = true
Expand All @@ -115,9 +121,30 @@ max_messages = 20
- `CODING_AGENT_LLM_MODEL`
- `CODING_AGENT_LLM_API_KEY`
- `CODING_AGENT_LLM_BASE_URL`
- `CODING_AGENT_LLM_MAX_TOTAL_TOKENS_PER_TURN`
- `CODING_AGENT_HISTORY_DB`
- `CODING_AGENT_HISTORY_KEEP`(默认保留最近 200 个会话;设为 `0` 关闭清理)
- `CODING_AGENT_BACKUP_KEEP_DAYS`(默认 30 天;设为 `0` 关闭清理)
- `CODING_AGENT_CONFIG`

### 数据与隐私

- 默认配置连接 Kimi 云端。用户消息、模型上下文,以及模型请求读取后返回的代码或工具输出,会发送到 `base_url` 指向的服务。处理敏感仓库前,请先确认服务方的数据政策,或改用可信的 OpenAI 兼容本地端点。
- API key 应通过 `CODING_AGENT_LLM_API_KEY` 或已被 Git 忽略的 `.env` 提供,不要提交到 `config.toml`。项目不会加密配置文件。
- 历史数据库和撤销备份是本机明文文件,但会以仅当前用户可读的权限创建。可设置 `history.enabled = false` 停止保存消息;旧会话默认只保留最近 200 个,备份默认保留 30 天。
- `safety.log` 只记录工具名、参数字段名、安全分类和成功状态,不记录参数值、命令、文件内容或工具输出。
- `fetch_url` 会拒绝 localhost、私网/链路本地 IP、含凭据 URL 和非 HTTP(S) 协议;实际抓取由 Kimi 服务执行,因此 DNS 重绑定和重定向防护仍依赖上游服务。

本地兼容端点示例:

```toml
[llm]
provider = "local"
model = "your-local-model"
base_url = "http://127.0.0.1:8000/v1"
api_key = ""
```

### 使用 `.env` 文件(推荐)

在工作目录下创建 `.env` 文件:
Expand Down Expand Up @@ -179,29 +206,25 @@ python -m twine upload dist/*

## SWE-bench-lite 基准测试

我们在 [SWE-bench-lite](https://www.swebench.com/) 的 20 个任务上对比了三种执行模式,统一使用 `deepseek-v4-flash` 模型,coding-agent 与 Claude Code 用内置 `SWEBenchEvaluator` 评估,SWE-agent 用 `DockerEvaluator` 评估:
仓库曾在 SWE-bench-lite 的 20 个任务上做过探索性对比。该样本、执行环境和 harness 均不足以支持跨系统排名,因此目前不发布可引用的解决率;下面只保留复现方法和已知限制。

- **direct**:coding-agent 的零 IPC in-process 单 agent 模式
- **Claude Code**:通过 `cc-switch` 代理到本地端点的 Claude Code v2.1.187
- **SWE-agent**:v0.7.0,本地 persistent bash 环境

### 结果(20 task)
### 历史结果状态

> ⚠️ **以下数值为历史运行结果,已失效,待合规重跑后更新。** 它们存在两个已知问题,不能作为当前真实水平参考:
> 1. **数据泄露(已修)**:早期 `runner.py::_build_goal_description` 向 agent 泄露了 `FAIL_TO_PASS` 测试名,相当于给出验收标准,违反 SWE-bench 盲改合规。已移除。
> 2. **SWE-agent 环境已修但未取得可靠分数**:历史运行中 SWE-agent 20 个任务全部 `exit code 1`(启动即崩),原因是其 conda 环境 `swe_agent_py311` 的 numpy(1.24)/pandas(3.0) 版本冲突。numpy 已升级修复,SWE-agent 现可正常启动并能产出正确 patch(单任务冒烟验证)。但 SWE-agent 交互式 bash 模式极慢(单任务 275+ 次 API 调用),默认 1200s 超时内常未跑完验证步骤就被 kill,导致 patch 未被收集、判为未解决。曾报告的 7/20 是更早期旧值,不代表当前配置。完整 20 任务需调大 timeout(预计 6h+)才能取得可靠分数,尚未执行。

| 系统 | 历史值 | 状态 |
|---|---|---|
| coding-agent direct | 16/20 | 含 fail_to_pass 泄露,待合规重跑 |
| Claude Code | 14/20 | 待合规重跑 |
| SWE-agent | 7/20(旧值)/ 0/20(超时)| 环境已修,能解题但超时,待调 timeout 重跑 |
> ⚠️ **历史数值已撤下,待使用同一公开 harness、固定环境、完整任务集并多次重复后更新。** 旧实验存在以下问题:
> 1. **数据泄露(已修)**:早期 runner 向 agent 暴露了 `FAIL_TO_PASS` 测试名,并且不同系统对 `hints_text` 的可见性不一致。当前三种模式都只接收 issue 标题和正文,隐藏测试仅供评估器使用。
> 2. **对比条件不一致**:工具集、执行环境、超时和评估器不同,结果不能作为公平的系统间比较。
> 3. **样本过小且未重复**:20 个任务的单次结果统计波动很大。
> 4. **模型标签不规范**:`deepseek-v4-flash` 是当时本地代理使用的自定义别名,不代表 DeepSeek 官方公开型号。复现时必须记录实际 provider、模型版本和端点配置。

### 关键优化

direct 模式从 12/20 提升到 16/20,主要得益于
历史探索中采用过以下实现调整;这里不再把它们与已撤下的分数绑定

1. **shell 安全策略放宽**:SWE-bench 场景下通过 `CODING_AGENT_SWEBENCH_FORCE=1` 允许 `cd && pytest`、`python -c`、`python -m pytest` 等验证命令执行(safety.py 将 `python -m pytest/py_compile/compileall` 归类为 HARMLESS)
1. **评测运行器显式授权**:只有受信任的 SWE-bench runner 实例能调用危险 shell 的授权入口;环境变量不能关闭普通用户的安全检查,forbidden 命令始终拒绝
2. **Prompt 收紧**:强制最小改动、禁止安装依赖/修改配置、要求验证后再结束。
3. **合规修正**:移除 goal description 中的 `FAIL_TO_PASS` 测试名泄露,agent 只看 issue 描述,验收测试由评估 harness 在不可见情况下运行。

Expand Down Expand Up @@ -337,11 +360,11 @@ coding-agent/
- 根据 `config.llm.stream` 选择 `_run_turn_stream()` 或 `_run_turn_non_stream()`。
- 将 LLM 返回的 `AssistantResponse` 保存为 `assistant` 消息。
- 如果存在 `tool_calls`,逐个调用 `_execute_tool_call()`,结果保存为 `tool` 消息并再次请求 LLM。
- 工具失败(非用户拒绝/禁止)时自动重试 1 次。
- 达到 `max_steps_per_turn` 上限后停止并提示用户。
- 只有无副作用的本地读取/搜索工具失败时自动重试 1 次;shell、写操作和网络请求不自动重试
- 达到 `max_steps_per_turn` 或 `max_total_tokens_per_turn` 上限后停止并提示用户。
- **历史加载 `_load_history()`**:从 SQLite 恢复最近消息,并清洗不完整的 `assistant(tool_calls)` 以及 `tool_call_id` 为空或不匹配的脏 tool 消息。
- **会话管理**:`/sessions`、`/switch`、`/rename`、`/delete` 基于 `HistoryManager` 实现;新会话自动用第一条用户消息前 30 字生成标题。
- **撤销 `/undo`**:写操作前备份原文件到 `~/.coding-agent/backups/<session_id>/<timestamp>/`,`/undo` 恢复最近一次备份。
- **撤销 `/undo`**:写操作前备份原文件到 `~/.coding-agent/backups/<session_id>/<timestamp>/`,`/undo` 恢复最近一次备份;默认自动清理 30 天前的备份
- **Git 状态**:启动时与 `/git` 命令通过 `git status --short` 和 `git branch --show-current` 展示当前分支与未提交文件。

### LLM 调用层(`agent/llm/`)
Expand Down
14 changes: 14 additions & 0 deletions agent/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ class LLMConfig(BaseModel):
timeout: float | None = 300.0
stream_read_timeout: float | None = 120.0
max_steps_per_turn: int = 100
max_total_tokens_per_turn: int = 100_000
max_retries_per_step: int = 5
system_prompt: str | None = None

Expand Down Expand Up @@ -48,6 +49,13 @@ def _validate_max_retries_per_step(cls, v: int) -> int:
raise ValueError("max_retries_per_step must be >= 0")
return v

@field_validator("max_total_tokens_per_turn")
@classmethod
def _validate_max_total_tokens_per_turn(cls, v: int) -> int:
if v < 1:
raise ValueError("max_total_tokens_per_turn must be >= 1")
return v


class SecurityConfig(BaseModel):
confirm_dangerous: bool = True
Expand Down Expand Up @@ -155,6 +163,12 @@ def _env_override_data() -> dict[str, Any]:
stream = os.getenv("CODING_AGENT_LLM_STREAM")
if stream is not None:
overrides.setdefault("llm", {})["stream"] = stream.lower() in ("1", "true", "yes")
token_budget = os.getenv("CODING_AGENT_LLM_MAX_TOTAL_TOKENS_PER_TURN")
if token_budget:
try:
overrides.setdefault("llm", {})["max_total_tokens_per_turn"] = int(token_budget)
except ValueError:
pass
db_path = os.getenv("CODING_AGENT_HISTORY_DB")
if db_path:
overrides.setdefault("history", {})["db_path"] = db_path
Expand Down
16 changes: 15 additions & 1 deletion agent/direct_agent.py
Original file line number Diff line number Diff line change
Expand Up @@ -46,10 +46,12 @@ def __init__(
allowed_tools: list[str] | None = None,
log_path: str | Path | None = None,
conda_env: str | None = None,
allow_dangerous_shell: bool = False,
):
self.llm = llm
self.workspace = Path(workspace).resolve()
self.conda_env = conda_env
self.allow_dangerous_shell = allow_dangerous_shell
# Build tool list
all_tools = TOOL_REGISTRY
if allowed_tools is None:
Expand All @@ -65,6 +67,8 @@ def __init__(
if self.log_path:
self.log_path.parent.mkdir(parents=True, exist_ok=True)
# Start fresh log file for each run.
self.log_path.touch(mode=0o600, exist_ok=True)
self.log_path.chmod(0o600)
self.log_path.write_text("", encoding="utf-8")

def _build_system_prompt(self, base: str) -> str:
Expand Down Expand Up @@ -170,7 +174,13 @@ def run(self, goal_description: str, max_steps: int = 50) -> str:
}
)

total_tokens = 0
max_tokens = self.llm.config.max_total_tokens_per_turn
for step in range(1, max_steps + 1):
if step > 1 and total_tokens >= max_tokens:
message = f"Reached token budget ({max_tokens}) without final answer."
self._log_event({"type": "token_budget_reached", "max_tokens": max_tokens})
return message
messages = self._compact_messages(messages, max_turns=20)
logger.info("step %d/%d: calling LLM", step, max_steps)
try:
Expand All @@ -185,6 +195,7 @@ def run(self, goal_description: str, max_steps: int = 50) -> str:
}
)
return f"LLM error at step {step}: {exc}"
total_tokens += response.usage.total_tokens

self._log_event(
{
Expand Down Expand Up @@ -244,7 +255,10 @@ def run(self, goal_description: str, max_steps: int = 50) -> str:
if call.name == "str_replace_file":
call, result = self._ensure_file_read_before_edit(call, ctx, messages)
else:
result = tool.execute(call.arguments, ctx)
if call.name == "execute_shell" and self.allow_dangerous_shell:
result = tool.execute_forced(call.arguments, ctx)
else:
result = tool.execute(call.arguments, ctx)
if call.name == "read_file" and result.success:
path = call.arguments.get("path")
if path:
Expand Down
4 changes: 4 additions & 0 deletions agent/history.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,10 @@ class HistoryManager:
def __init__(self, db_path: str | None = None):
self.db_path = Path(db_path or DEFAULT_DB_PATH).expanduser()
self.db_path.parent.mkdir(parents=True, exist_ok=True)
if self.db_path.parent == Path.home() / ".coding-agent":
self.db_path.parent.chmod(0o700)
self.db_path.touch(mode=0o600, exist_ok=True)
self.db_path.chmod(0o600)
self._init_db()

def _connect(self) -> sqlite3.Connection:
Expand Down
34 changes: 31 additions & 3 deletions agent/llm/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,23 @@

logger = logging.getLogger("agent.llm.client")

_NON_RETRYABLE_ERROR_MARKERS = (
"insufficient_quota",
"quota has been exhausted",
"invalid_api_key",
"authentication_error",
)


def _is_non_retryable_api_error(exc: Exception) -> bool:
"""Return True for failures that waiting and retrying cannot repair."""
status_code = getattr(exc, "status_code", None)
if status_code in (401, 403):
return True
body = getattr(exc, "body", None)
text = f"{body} {exc}".lower()
return any(marker in text for marker in _NON_RETRYABLE_ERROR_MARKERS)


class LLMClient:
"""封装 OpenAI 兼容接口的 LLM 客户端,支持重试与流式输出。"""
Expand Down Expand Up @@ -45,6 +62,9 @@ def _build_client(self) -> OpenAI:
base_url=self.config.base_url,
default_headers=headers,
timeout=timeout,
# Retry policy is implemented below so quota/auth failures can be
# classified correctly and are not retried inside the SDK first.
max_retries=0,
)

def _prepare_messages(self, messages: list[Message]) -> list[dict[str, Any]]:
Expand Down Expand Up @@ -127,7 +147,9 @@ def chat(
kwargs = self._build_kwargs(messages, tools, temperature, stream=False)
last_error: Exception | None = None
max_attempts = self.config.max_retries_per_step + 1
attempts_made = 0
for attempt in range(max_attempts):
attempts_made = attempt + 1
try:
response = self._client.chat.completions.create(**kwargs)
return parse_assistant_response(response)
Expand All @@ -140,14 +162,16 @@ def chat(
RemoteProtocolError,
) as exc:
last_error = exc
if _is_non_retryable_api_error(exc):
break
if attempt < self.config.max_retries_per_step:
delay = min(2**attempt + random.random(), 60)
time.sleep(delay)
continue
break

logger.error("LLM request failed after %s attempts: %s", max_attempts, last_error)
raise LLMError(f"LLM request failed after {max_attempts} attempts: {last_error}")
logger.error("LLM request failed after %s attempts: %s", attempts_made, last_error)
raise LLMError(f"LLM request failed after {attempts_made} attempts: {last_error}")

def chat_stream(
self,
Expand All @@ -168,8 +192,10 @@ def chat_stream(
kwargs = self._build_kwargs(messages, tools, temperature, stream=True)
last_error: Exception | None = None
max_attempts = self.config.max_retries_per_step + 1
attempts_made = 0

for attempt in range(max_attempts):
attempts_made = attempt + 1
try:
stream = self._client.chat.completions.create(**kwargs)
yield from self._parse_stream(stream)
Expand All @@ -183,13 +209,15 @@ def chat_stream(
RemoteProtocolError,
) as exc:
last_error = exc
if _is_non_retryable_api_error(exc):
break
if attempt < self.config.max_retries_per_step:
delay = min(2**attempt + random.random(), 60)
time.sleep(delay)
continue
break

raise LLMError(f"LLM request failed after {max_attempts} attempts: {last_error}")
raise LLMError(f"LLM request failed after {attempts_made} attempts: {last_error}")

def _parse_stream(self, stream: Any) -> Generator[str | AssistantResponse, None, None]:
"""解析 OpenAI 流式响应。"""
Expand Down
Loading
Loading