nju-coding-agent-harness 0.1.0__tar.gz
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.
- nju_coding_agent_harness-0.1.0/PKG-INFO +11 -0
- nju_coding_agent_harness-0.1.0/README.md +245 -0
- nju_coding_agent_harness-0.1.0/harness/__init__.py +0 -0
- nju_coding_agent_harness-0.1.0/harness/agent.py +334 -0
- nju_coding_agent_harness-0.1.0/harness/config.py +45 -0
- nju_coding_agent_harness-0.1.0/harness/credentials.py +86 -0
- nju_coding_agent_harness-0.1.0/harness/fake_llm.py +34 -0
- nju_coding_agent_harness-0.1.0/harness/guardrails.py +46 -0
- nju_coding_agent_harness-0.1.0/harness/hooks.py +57 -0
- nju_coding_agent_harness-0.1.0/harness/llm.py +135 -0
- nju_coding_agent_harness-0.1.0/harness/main.py +400 -0
- nju_coding_agent_harness-0.1.0/harness/mcp.py +253 -0
- nju_coding_agent_harness-0.1.0/harness/memory.py +125 -0
- nju_coding_agent_harness-0.1.0/harness/policy.py +101 -0
- nju_coding_agent_harness-0.1.0/harness/registry.py +322 -0
- nju_coding_agent_harness-0.1.0/harness/sandbox.py +147 -0
- nju_coding_agent_harness-0.1.0/harness/state.py +56 -0
- nju_coding_agent_harness-0.1.0/harness/tests/__init__.py +0 -0
- nju_coding_agent_harness-0.1.0/harness/tests/conftest.py +14 -0
- nju_coding_agent_harness-0.1.0/harness/tests/fixtures/fake_mcp_server.py +64 -0
- nju_coding_agent_harness-0.1.0/harness/tests/mechanism_demo/__init__.py +1 -0
- nju_coding_agent_harness-0.1.0/harness/tests/mechanism_demo/demo_1_guardrail_deny.py +65 -0
- nju_coding_agent_harness-0.1.0/harness/tests/mechanism_demo/demo_2_feedback_change.py +85 -0
- nju_coding_agent_harness-0.1.0/harness/tests/mechanism_demo/demo_3_hitl_trace.py +68 -0
- nju_coding_agent_harness-0.1.0/harness/tests/test_acceptance_matrix.py +434 -0
- nju_coding_agent_harness-0.1.0/harness/tests/test_agent_context.py +101 -0
- nju_coding_agent_harness-0.1.0/harness/tests/test_agent_core.py +99 -0
- nju_coding_agent_harness-0.1.0/harness/tests/test_agent_end.py +53 -0
- nju_coding_agent_harness-0.1.0/harness/tests/test_agent_feedback.py +59 -0
- nju_coding_agent_harness-0.1.0/harness/tests/test_config.py +20 -0
- nju_coding_agent_harness-0.1.0/harness/tests/test_credentials.py +75 -0
- nju_coding_agent_harness-0.1.0/harness/tests/test_docs.py +41 -0
- nju_coding_agent_harness-0.1.0/harness/tests/test_guardrails.py +24 -0
- nju_coding_agent_harness-0.1.0/harness/tests/test_hooks.py +33 -0
- nju_coding_agent_harness-0.1.0/harness/tests/test_llm.py +98 -0
- nju_coding_agent_harness-0.1.0/harness/tests/test_mcp.py +136 -0
- nju_coding_agent_harness-0.1.0/harness/tests/test_mechanism_demo.py +31 -0
- nju_coding_agent_harness-0.1.0/harness/tests/test_memory.py +49 -0
- nju_coding_agent_harness-0.1.0/harness/tests/test_perf_smoke.py +40 -0
- nju_coding_agent_harness-0.1.0/harness/tests/test_policy.py +64 -0
- nju_coding_agent_harness-0.1.0/harness/tests/test_registry.py +172 -0
- nju_coding_agent_harness-0.1.0/harness/tests/test_repl.py +320 -0
- nju_coding_agent_harness-0.1.0/harness/tests/test_sandbox.py +96 -0
- nju_coding_agent_harness-0.1.0/harness/tests/test_security_scan.py +29 -0
- nju_coding_agent_harness-0.1.0/harness/tests/test_skeleton.py +4 -0
- nju_coding_agent_harness-0.1.0/harness/tests/test_state.py +42 -0
- nju_coding_agent_harness-0.1.0/harness/tests/test_tools_ask.py +53 -0
- nju_coding_agent_harness-0.1.0/harness/tests/test_tools_bash.py +49 -0
- nju_coding_agent_harness-0.1.0/harness/tests/test_tools_files.py +52 -0
- nju_coding_agent_harness-0.1.0/harness/tests/test_tools_memory.py +58 -0
- nju_coding_agent_harness-0.1.0/harness/tests/test_tools_notes.py +25 -0
- nju_coding_agent_harness-0.1.0/harness/tests/test_tools_skills.py +59 -0
- nju_coding_agent_harness-0.1.0/harness/tests/test_tools_subagent.py +146 -0
- nju_coding_agent_harness-0.1.0/harness/tests/test_tools_web.py +48 -0
- nju_coding_agent_harness-0.1.0/harness/tools/__init__.py +0 -0
- nju_coding_agent_harness-0.1.0/harness/tools/ask.py +46 -0
- nju_coding_agent_harness-0.1.0/harness/tools/bash.py +38 -0
- nju_coding_agent_harness-0.1.0/harness/tools/files.py +125 -0
- nju_coding_agent_harness-0.1.0/harness/tools/memory.py +91 -0
- nju_coding_agent_harness-0.1.0/harness/tools/notes.py +56 -0
- nju_coding_agent_harness-0.1.0/harness/tools/search.py +133 -0
- nju_coding_agent_harness-0.1.0/harness/tools/skills.py +133 -0
- nju_coding_agent_harness-0.1.0/harness/tools/subagent.py +110 -0
- nju_coding_agent_harness-0.1.0/harness/tools/web.py +56 -0
- nju_coding_agent_harness-0.1.0/harness/transcript.py +32 -0
- nju_coding_agent_harness-0.1.0/nju_coding_agent_harness.egg-info/PKG-INFO +11 -0
- nju_coding_agent_harness-0.1.0/nju_coding_agent_harness.egg-info/SOURCES.txt +70 -0
- nju_coding_agent_harness-0.1.0/nju_coding_agent_harness.egg-info/dependency_links.txt +1 -0
- nju_coding_agent_harness-0.1.0/nju_coding_agent_harness.egg-info/requires.txt +8 -0
- nju_coding_agent_harness-0.1.0/nju_coding_agent_harness.egg-info/top_level.txt +1 -0
- nju_coding_agent_harness-0.1.0/pyproject.toml +22 -0
- nju_coding_agent_harness-0.1.0/setup.cfg +4 -0
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: nju-coding-agent-harness
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Requires-Python: >=3.11
|
|
5
|
+
Requires-Dist: openai
|
|
6
|
+
Requires-Dist: requests
|
|
7
|
+
Requires-Dist: mcp
|
|
8
|
+
Requires-Dist: keyring
|
|
9
|
+
Requires-Dist: httpx
|
|
10
|
+
Provides-Extra: dev
|
|
11
|
+
Requires-Dist: pytest; extra == "dev"
|
|
@@ -0,0 +1,245 @@
|
|
|
1
|
+
# Coding Agent Harness
|
|
2
|
+
|
|
3
|
+
一个最小但真实的编码智能体框架(Python 3.11+,Windows/Linux),用 DeepSeek 模型
|
|
4
|
+
(OpenAI 兼容协议)自主完成编码任务,并把**护栏 / 沙箱 / 记忆 / 上下文工程 /
|
|
5
|
+
人机协同 HITL / 反馈闭环**六维度做成透明、可测试的组件。
|
|
6
|
+
|
|
7
|
+
设计规格见 `docs/superpowers/specs/SPEC.md`(下文以 `§x.y` 引用章节号);
|
|
8
|
+
每个组件验证了什么理论,见 `docs/COMPONENTS.md`。
|
|
9
|
+
|
|
10
|
+
## 架构一页图
|
|
11
|
+
|
|
12
|
+
```
|
|
13
|
+
┌──────────────────────────── 用户(终端) ────────────────────────────┐
|
|
14
|
+
│ python -m harness.main │
|
|
15
|
+
│ main.py REPL:首次输入即任务 / /命令 / Ctrl+C 菜单 / ask 编号菜单 │
|
|
16
|
+
└───────────────┬──────────────────────────────┬─────────────────────┘
|
|
17
|
+
│ 任务 / 回答 │ 流式文本 / 步数统计
|
|
18
|
+
▼ │
|
|
19
|
+
┌──────────────────────────────────────────────▼─────────────────────┐
|
|
20
|
+
│ Agent(agent.py)— 每次任务:检索记忆 → 迭代循环 → 收尾整合 │
|
|
21
|
+
│ 每回合:LLM 调用(llm.py,流式、工具调用解析) │
|
|
22
|
+
│ └─ 工具流水线(每次工具调用,严格排序,§5.2): │
|
|
23
|
+
│ 护栏判定(guardrails.py) → 状态机(state.py) → 钩子(hooks.py) │
|
|
24
|
+
│ → 执行(sandbox.py) → 钩子 → 结果回灌上下文 │
|
|
25
|
+
│ 其中 ask 判定 → HITL 菜单(main.py) → 自适应策略(policy.py) │
|
|
26
|
+
└───────┬───────────────────────────┬───────────────┬───────────────┘
|
|
27
|
+
│ │ │
|
|
28
|
+
┌───────▼─────────┐ ┌─────────────▼───────┐ ┌───▼──────────────┐
|
|
29
|
+
│ 注册表 registry │ │ 支撑服务 │ │ 持久化(工作区内) │
|
|
30
|
+
│ 内置工具 tools/ │ │ Policy 自适应策略 │ │ memory/*.md 记忆 │
|
|
31
|
+
│ MCP 工具 mcp.py │ │ MemoryStore(TF-IDF) │ │ skills/*/SKILL.md│
|
|
32
|
+
│ 技能 tools/skills│ │ HookBus 转录钩子 │ │ transcripts/*.json│
|
|
33
|
+
│ 子智能体 subagent │ │ StateMachine 状态机 │ │ .env(可选) │
|
|
34
|
+
└─────────────────┘ └─────────────────────┘ └──────────────────┘
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
## 快速开始
|
|
38
|
+
|
|
39
|
+
1. **安装**(PyPI 分发,任选其一):
|
|
40
|
+
|
|
41
|
+
```bash
|
|
42
|
+
pip install nju-coding-agent-harness # PyPI 正式发布
|
|
43
|
+
pip install -e ".[dev]" # 源码分发(项目根目录)
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
2. **配置 API Key**:首次运行时会自动进入配置向导(`getpass` 隐藏输入,
|
|
47
|
+
保存到系统凭据库);也可以在 REPL 内用 `/key set` 随时配置(见下文
|
|
48
|
+
"凭据安全")。
|
|
49
|
+
|
|
50
|
+
3. **运行**:
|
|
51
|
+
|
|
52
|
+
```bash
|
|
53
|
+
python -m harness.main
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
提示符 `> ` 下直接输入任务(例如"修复 main.py 里的 bug");**首次输入
|
|
57
|
+
(即使以 `/` 开头)一律视为任务**。REPL 顶部 `Ctrl+C` 干净退出并触发
|
|
58
|
+
SessionEnd 钩子;任务运行中 `Ctrl+C` 弹出暂停菜单(resume / abort)。
|
|
59
|
+
|
|
60
|
+
## 凭据安全(§4.2 / §7.1)
|
|
61
|
+
|
|
62
|
+
- **来源优先级**:keyring(Windows Credential Manager,服务名
|
|
63
|
+
`coding-agent-harness`)→ 环境变量 / `.env` 文件 → 首次运行向导。
|
|
64
|
+
- **首选 keyring**:操作系统加密存储,凭据不出本机。`/key set` 交互录入
|
|
65
|
+
(隐藏输入)后写入 keyring;`/key clear` 删除;`/key status` 只回显
|
|
66
|
+
"是否已配置 / 来源 / 验证时间",**绝不回显明文**。
|
|
67
|
+
- **`.env` 明文风险**:`.env` 文件是本地明文(`DEEPSEEK_API_KEY=...`),
|
|
68
|
+
且其中的值会进入进程环境、对同一用户的其他进程可见;不要提交进 Git
|
|
69
|
+
(仓库已通过 `.gitignore` 排除 `.env`,凭据扫描测试
|
|
70
|
+
`harness/tests/test_security_scan.py` 会检查 key 不落入源码/历史/转录)。
|
|
71
|
+
使用 `.env` 是备选方案,请确保文件权限收紧。
|
|
72
|
+
- **兜底安全**:key 永不写入日志、转录、记忆或策略文件。
|
|
73
|
+
|
|
74
|
+
## 代理配置(GitHub / DeepSeek,Windows)
|
|
75
|
+
|
|
76
|
+
- **DeepSeek API**:默认直连 `https://api.deepseek.com`(HTTPS)。需要
|
|
77
|
+
代理时,为 `openai`/`httpx` 设置环境变量即可:
|
|
78
|
+
|
|
79
|
+
```powershell
|
|
80
|
+
$env:HTTPS_PROXY = "http://127.0.0.1:7890"
|
|
81
|
+
$env:HTTP_PROXY = "http://127.0.0.1:7890"
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
(PowerShell 中设置环境变量仅对当前会话有效,不会进入 shell history;
|
|
85
|
+
请勿用 `export` 方式写入 key,那会进入 shell history。)
|
|
86
|
+
- **GitHub**(克隆仓库、拉取技能时):给 git 配代理
|
|
87
|
+
|
|
88
|
+
```bash
|
|
89
|
+
git config --global http.proxy http://127.0.0.1:7890
|
|
90
|
+
git config --global https.proxy http://127.0.0.1:7890
|
|
91
|
+
```
|
|
92
|
+
|
|
93
|
+
- **Windows 注意**:命令执行基于系统 shell;若在工作区使用 PowerShell
|
|
94
|
+
脚本或路径含空格/中文,注意引号与编码(项目文件统一 UTF-8)。
|
|
95
|
+
|
|
96
|
+
## 配置(harness/config.py)
|
|
97
|
+
|
|
98
|
+
`Config` dataclass 定义全部配置字段(`Config.load(path)` 支持 TOML
|
|
99
|
+
配置文件加载;REPL 启动时使用默认值):
|
|
100
|
+
|
|
101
|
+
| 字段 | 默认值 | 含义 |
|
|
102
|
+
|---|---|---|
|
|
103
|
+
| `model` | `deepseek-chat` | 模型名 |
|
|
104
|
+
| `base_url` | `https://api.deepseek.com` | OpenAI 兼容端点 |
|
|
105
|
+
| `max_steps` | `50` | 每任务步数上限 |
|
|
106
|
+
| `failure_budget` | `3` | 同类工具连续失败预算(§3.6) |
|
|
107
|
+
| `tool_timeout` | `30` | 工具执行超时(秒) |
|
|
108
|
+
| `memory_top_k` | `2` | 任务启动时检索注入的记忆块数(§3.3) |
|
|
109
|
+
| `max_budget_tokens` | `6000` | 上下文预算,超出先压缩(§3.3) |
|
|
110
|
+
| `compression_keep_turns` | `10` | 压缩保留的最近回合数 |
|
|
111
|
+
| `compression_max_rounds` | `3` | 单任务压缩轮数上限 |
|
|
112
|
+
| `max_output_bytes` | `51200` | 工具输出截断上限 |
|
|
113
|
+
| `workspace` | 当前目录 | 工作区(文件/记忆/技能/转录根) |
|
|
114
|
+
| `mcp_servers` | `[]` | MCP 服务器列表(§5.3) |
|
|
115
|
+
|
|
116
|
+
## REPL 命令表
|
|
117
|
+
|
|
118
|
+
实现于 `harness/main.py`(`/help` 输出与之对应):
|
|
119
|
+
|
|
120
|
+
| 命令 | 行为 |
|
|
121
|
+
|---|---|
|
|
122
|
+
| `/exit` | 退出 REPL(返回 0) |
|
|
123
|
+
| `/reset` | 重置会话(重建 Agent,清空上下文与状态);失败打印"重置失败" |
|
|
124
|
+
| `/skills` | 列出工作区 `skills/` 下含 `SKILL.md` 的技能 |
|
|
125
|
+
| `/rules` | 显示策略规则表:`pattern -> action (source)` |
|
|
126
|
+
| `/rules drop skill:<name>` | 移除指定技能注入的规则 |
|
|
127
|
+
| `/key set` | 交互录入 API Key 并保存到 keyring,随后重新初始化会话 |
|
|
128
|
+
| `/key status` | 显示配置状态(是否已配置 / 来源 / 验证时间) |
|
|
129
|
+
| `/key clear` | 清除 keyring 中的 API Key |
|
|
130
|
+
| `/memory` | 列出工作区 `memory/` 下的记忆文件 |
|
|
131
|
+
| `/help` | 命令摘要 |
|
|
132
|
+
|
|
133
|
+
交互行为(与实现一致):
|
|
134
|
+
|
|
135
|
+
- **首次输入即任务**:第一个非空输入(包括以 `/` 开头的字符串)作为任务执行。
|
|
136
|
+
- **任务中 Ctrl+C**:状态机 `interrupt → paused`,弹出编号菜单
|
|
137
|
+
`1. resume / 2. abort`;EOF 视为 abort。选择 abort → `terminated`;
|
|
138
|
+
选择 resume → 从暂停处继续(仅允许恢复一次)。
|
|
139
|
+
- **REPL 顶层 Ctrl+C / EOF**:打印换行、触发 SessionEnd 钩子(写转录)后退出。
|
|
140
|
+
- **护栏 ask**:打印 `? 问题` + 编号选项,输入非编号数字重试。
|
|
141
|
+
- **任务收尾**:逐条回显工具调用 `→ name: args`(失败为 `⊘ name: error`),
|
|
142
|
+
最后打印 `[step N/max | ~T tok]` 步数与近似 token 统计。
|
|
143
|
+
- **流式输出**:模型文本通过 `on_text` 实时打印,无整轮缓冲(§3.1)。
|
|
144
|
+
- **API 失败**(密钥错误 / 限流 / 网络):打印明确提示,**会话保持存活**,
|
|
145
|
+
可继续输入任务或 `/key set`。
|
|
146
|
+
|
|
147
|
+
## 六维度组件(§3)
|
|
148
|
+
|
|
149
|
+
| 维度 | 组件 | 验证要点(详见 COMPONENTS.md) |
|
|
150
|
+
|---|---|---|
|
|
151
|
+
| 治理护栏 | `guardrails.py` / `policy.py` / `state.py`(§3.4 §11.2 §11.4) | 护栏先行、ask 无超时放行、自适应升降级 |
|
|
152
|
+
| 安全沙箱 | `sandbox.py`(§11.3) | 超时/截断、docker 隔离;**local 非隔离(见下)** |
|
|
153
|
+
| 记忆 | `memory.py`(§3.3) | 纯标准库 TF-IDF、top-k 注入、收尾整合 |
|
|
154
|
+
| 上下文工程 | `agent.py`(预算/压缩)+ `llm.py`(§3.1 §3.3) | 超预算先压缩、保留最近 N 回合、轮数上限 |
|
|
155
|
+
| 人机协同 HITL | `main.py` / `state.py` / `tools/ask.py`(§11.4) | interrupt→paused、awaiting_user 双来源 |
|
|
156
|
+
| 反馈闭环 | `agent.py`(失败预算)(§3.6) | 连续失败 3 次停止、错误回灌可修正 |
|
|
157
|
+
|
|
158
|
+
配套能力:工具注册表 `registry.py`、内置工具 `tools/`(bash/files/search/web/
|
|
159
|
+
notes/memory/skills/subagent/ask)、MCP 客户端 `mcp.py`、钩子 `hooks.py`、
|
|
160
|
+
转录 `transcript.py`、凭据 `credentials.py`、LLM 客户端 `llm.py`。
|
|
161
|
+
|
|
162
|
+
## 沙箱非隔离性声明(重要,§11.3)
|
|
163
|
+
|
|
164
|
+
**`LocalSandbox`(默认后端)不是安全边界**——它在宿主上直接以子进程执行
|
|
165
|
+
shell 命令,隔离由**护栏**(危险模式 deny / ask)与**路径包含性检查**
|
|
166
|
+
(工作区外路径 deny)承担第一道防线。若需要真正的进程/文件系统隔离,
|
|
167
|
+
请配置 **DockerSandbox** 后端。
|
|
168
|
+
|
|
169
|
+
## Docker 后端(可选,默认 local)
|
|
170
|
+
|
|
171
|
+
- 通过配置切换沙箱后端;docker 后端用 `docker run --rm` 执行 bash,默认
|
|
172
|
+
`--network=none`(无网络),以 `-v <workspace>:/workspace` 挂载工作区,
|
|
173
|
+
容器内无法破坏宿主文件系统、读不到宿主凭据。
|
|
174
|
+
- 镜像默认 `python:3.11-slim`,需预装工具链(python/git 等)。
|
|
175
|
+
- 未安装 Docker 或 daemon 未运行时快速报错,可回退 local。
|
|
176
|
+
- Windows:需 Docker Desktop 并保持运行;挂载路径请使用绝对路径。
|
|
177
|
+
|
|
178
|
+
## 测试
|
|
179
|
+
|
|
180
|
+
[](https://github.com/clementineyyy/Coding-Agent-Harness/actions/workflows/ci.yml)
|
|
181
|
+
|
|
182
|
+
**一键运行**(GitHub Actions CI 与本地使用同一命令):
|
|
183
|
+
|
|
184
|
+
```bash
|
|
185
|
+
make test
|
|
186
|
+
```
|
|
187
|
+
|
|
188
|
+
- `make test` 自动完成:`.venv` 不存在则创建 → `pip install -e ".[dev]"`
|
|
189
|
+
→ `pytest harness/tests -q`(Windows 用 `.venv\Scripts\python.exe`,
|
|
190
|
+
POSIX 用 `.venv/bin/python`,Makefile 内通过 `$(OS)` 自动判断)。
|
|
191
|
+
- **Windows 无 make** 时的等价命令:
|
|
192
|
+
|
|
193
|
+
```powershell
|
|
194
|
+
python -m venv .venv
|
|
195
|
+
.venv\Scripts\python.exe -m pip install -e ".[dev]"
|
|
196
|
+
.venv\Scripts\python.exe -m pytest harness/tests -q
|
|
197
|
+
```
|
|
198
|
+
|
|
199
|
+
- 只安装不跑测试:`make install`。
|
|
200
|
+
|
|
201
|
+
### 机制演示(§A.4-D 对齐)
|
|
202
|
+
|
|
203
|
+
`make demo` 顺序运行三个机制演示脚本(全部退出码 0 即通过):
|
|
204
|
+
|
|
205
|
+
1. **demo_1_guardrail_deny** — 护栏拦截危险动作:FakeLLM 请求
|
|
206
|
+
`rm -rf` / 网络访问等危险动作时,护栏在**执行前**拒绝(deny),
|
|
207
|
+
绝不让危险命令进入沙箱执行;
|
|
208
|
+
2. **demo_2_feedback_change** — 反馈闭环:工具失败的错误信息回灌
|
|
209
|
+
上下文后,模型的**下一步动作发生改变**(重试 → 换用替代命令);
|
|
210
|
+
3. **demo_3_hitl_trace** — HITL 状态机全轨迹确定性复现:
|
|
211
|
+
`ask → awaiting_user → 执行 → completed` 完整状态迁移。
|
|
212
|
+
|
|
213
|
+
其中 **demo ③(HITL 状态机全轨迹确定性复现)对应 §A.4-D"主要贡献"
|
|
214
|
+
清单中的重点维度**:以确定性方式复现人机协同的关键状态序列,作为
|
|
215
|
+
该贡献的可运行证据(配合 `test_mechanism_demo.py` 的自动化包装)。
|
|
216
|
+
|
|
217
|
+
### 离线确定性测试
|
|
218
|
+
|
|
219
|
+
- 全部测试**无网络依赖、不访问真实 LLM**:由 FakeLLM 客户端与
|
|
220
|
+
`httpx.MockTransport` 驱动,可离线、确定性复现;MCP 测试使用手写的
|
|
221
|
+
假 stdio 服务器子进程,不联网。
|
|
222
|
+
- 主要测试文件:
|
|
223
|
+
`test_agent_core.py` / `test_agent_feedback.py` / `test_agent_context.py` /
|
|
224
|
+
`test_agent_end.py`(六维度组件单测)、`test_repl.py`(REPL 行为)、
|
|
225
|
+
`test_acceptance_matrix.py`(验收矩阵,逐条对应 §9)、
|
|
226
|
+
`test_mechanism_demo.py`(机制演示包装,对应上文 demo ①②③)、
|
|
227
|
+
`test_llm.py`(LLM 客户端,httpx MockTransport)。
|
|
228
|
+
- 其余覆盖:护栏/状态机/策略/记忆/沙箱/钩子/MCP/凭据扫描
|
|
229
|
+
(`test_security_scan.py`)/性能冒烟(`test_perf_smoke.py`)/文档一致性
|
|
230
|
+
(`test_docs.py`)。
|
|
231
|
+
|
|
232
|
+
## 已知实现偏差(与 spec 附录的差异)
|
|
233
|
+
|
|
234
|
+
1. 类型定义集中在 `harness/registry.py`(`Context` / `Tool` / `ToolResult`),
|
|
235
|
+
无独立 `types.py`(`AgentResult` 在 `agent.py`)。
|
|
236
|
+
2. MCP 客户端为手写行式 JSON-RPC 2.0(stdio/url),而非官方 `mcp` SDK
|
|
237
|
+
(依赖已安装但未使用)——零网络依赖、可确定性测试(§4.3)。
|
|
238
|
+
3. 参数校验为手写轻量 schema 校验(`registry.validate_args`),未引入
|
|
239
|
+
`jsonschema` 依赖(§3.2)。
|
|
240
|
+
|
|
241
|
+
## 已知限制
|
|
242
|
+
|
|
243
|
+
- 平台:Windows 10+ / Linux,Python 3.11+;需 DeepSeek 账号与网络连通。
|
|
244
|
+
- `mcp_servers` 的 stdio/url 服务器需用户自备;连接失败自动停用,不影响其余。
|
|
245
|
+
- Docker 沙箱后端需 Docker Desktop(可选)。
|
|
File without changes
|
|
@@ -0,0 +1,334 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
from dataclasses import dataclass, field
|
|
5
|
+
from datetime import datetime
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import TYPE_CHECKING, Any, Callable
|
|
8
|
+
|
|
9
|
+
from harness.guardrails import evaluate
|
|
10
|
+
from harness.registry import (
|
|
11
|
+
Context,
|
|
12
|
+
ToolResult,
|
|
13
|
+
build_request_tools,
|
|
14
|
+
validate_args,
|
|
15
|
+
)
|
|
16
|
+
|
|
17
|
+
if TYPE_CHECKING:
|
|
18
|
+
from harness.config import Config
|
|
19
|
+
from harness.fake_llm import FakeLLM
|
|
20
|
+
from harness.hooks import HookBus
|
|
21
|
+
from harness.llm import LLM
|
|
22
|
+
from harness.memory import MemoryStore
|
|
23
|
+
from harness.policy import Policy
|
|
24
|
+
from harness.sandbox import Sandbox
|
|
25
|
+
from harness.state import StateMachine
|
|
26
|
+
|
|
27
|
+
SYSTEM_PROMPT = (
|
|
28
|
+
"你是编码代理助手。可调用工具完成任务:"
|
|
29
|
+
"先规划,再执行,最后给出最终答案。"
|
|
30
|
+
)
|
|
31
|
+
|
|
32
|
+
_ASK_OPTIONS = ["y", "n", "always_allow", "never_allow"]
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
@dataclass
|
|
36
|
+
class AgentResult:
|
|
37
|
+
text: str = ""
|
|
38
|
+
steps_used: int = 0
|
|
39
|
+
tool_results: list[dict] = field(default_factory=list)
|
|
40
|
+
policy_changes: list[dict] = field(default_factory=list)
|
|
41
|
+
messages: list[dict] = field(default_factory=list)
|
|
42
|
+
failed_sequence: int = 0
|
|
43
|
+
transcript_path: str | None = None
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
class Agent:
|
|
47
|
+
def __init__(
|
|
48
|
+
self,
|
|
49
|
+
llm: LLM,
|
|
50
|
+
registry: dict[str, Any],
|
|
51
|
+
sandbox: Sandbox,
|
|
52
|
+
hooks: HookBus,
|
|
53
|
+
policy: Policy,
|
|
54
|
+
state: StateMachine,
|
|
55
|
+
memory: MemoryStore | None,
|
|
56
|
+
config: Config,
|
|
57
|
+
ask_callback: Callable | None = None,
|
|
58
|
+
on_text: Callable[[str], None] | None = None,
|
|
59
|
+
):
|
|
60
|
+
self.llm = llm
|
|
61
|
+
self.registry = registry
|
|
62
|
+
self.sandbox = sandbox
|
|
63
|
+
self.hooks = hooks
|
|
64
|
+
self.policy = policy
|
|
65
|
+
self.state = state
|
|
66
|
+
self.memory = memory
|
|
67
|
+
self.config = config
|
|
68
|
+
self.ask_callback = ask_callback
|
|
69
|
+
self.on_text = on_text
|
|
70
|
+
self._compress_calls = 0
|
|
71
|
+
self.warnings: list[str] = []
|
|
72
|
+
self._tool_calls: list[dict] = []
|
|
73
|
+
|
|
74
|
+
def context_for_tool(self) -> Context:
|
|
75
|
+
return Context(
|
|
76
|
+
workspace=self.config.workspace,
|
|
77
|
+
sandbox=self.sandbox,
|
|
78
|
+
hooks=self.hooks,
|
|
79
|
+
policy=self.policy,
|
|
80
|
+
state=self.state,
|
|
81
|
+
memory=self.memory,
|
|
82
|
+
config=self.config,
|
|
83
|
+
ask_callback=self.ask_callback,
|
|
84
|
+
llm=self.llm,
|
|
85
|
+
registry=self.registry,
|
|
86
|
+
)
|
|
87
|
+
|
|
88
|
+
def pipeline(self, call: dict, ctx: Context) -> ToolResult:
|
|
89
|
+
name = call["name"]
|
|
90
|
+
args = call["arguments"]
|
|
91
|
+
verdict = evaluate(self.policy.rules, name, args)
|
|
92
|
+
if verdict.action == "deny":
|
|
93
|
+
return ToolResult(status="error", error=f"guardrail denied: {verdict.reason}")
|
|
94
|
+
if verdict.action == "ask":
|
|
95
|
+
self.state.fire("approval_needed", "guardrail")
|
|
96
|
+
answer = self._ask(verdict.matched_rule, verdict.reason)
|
|
97
|
+
self.policy.apply_answer(verdict.matched_rule, answer)
|
|
98
|
+
if self.state.state == "awaiting_user":
|
|
99
|
+
self.state.fire("user_answered", "user")
|
|
100
|
+
if answer in ("n", "never_allow"):
|
|
101
|
+
return ToolResult(status="error", error=f"guardrail denied: {verdict.reason}")
|
|
102
|
+
if self.state.state == "awaiting_user":
|
|
103
|
+
self.state.fire("user_answered", "user")
|
|
104
|
+
args, ok = self.hooks.pre_tool_use(name, args)
|
|
105
|
+
if not ok:
|
|
106
|
+
return ToolResult(status="error", error=f"pre_tool_use hook rejected {name}")
|
|
107
|
+
self.state.fire("tool_requested", "loop")
|
|
108
|
+
tool = self.registry.get(name)
|
|
109
|
+
if tool is None:
|
|
110
|
+
result = ToolResult(status="error", error=f"unknown tool: {name}")
|
|
111
|
+
else:
|
|
112
|
+
err = validate_args(tool.parameters, args, self.config.workspace)
|
|
113
|
+
if err is not None:
|
|
114
|
+
result = ToolResult(status="error", error=f"参数错误: {err}")
|
|
115
|
+
else:
|
|
116
|
+
try:
|
|
117
|
+
result = tool.handler(args, ctx)
|
|
118
|
+
except Exception as exc:
|
|
119
|
+
result = ToolResult(
|
|
120
|
+
status="error",
|
|
121
|
+
error=f"工具 {name} 异常:{type(exc).__name__}: {exc}",
|
|
122
|
+
)
|
|
123
|
+
self.state.fire("tool_finished", "loop")
|
|
124
|
+
self.hooks.post_tool_use(name, args, result)
|
|
125
|
+
return result
|
|
126
|
+
|
|
127
|
+
def _emit_text(self, text: str) -> None:
|
|
128
|
+
if text and self.on_text is not None:
|
|
129
|
+
self.on_text(text)
|
|
130
|
+
|
|
131
|
+
def _ask(self, rule, reason: str) -> str:
|
|
132
|
+
question = f"是否允许执行该操作?\n规则: {rule.pattern}\n原因: {reason}"
|
|
133
|
+
if self.ask_callback is None:
|
|
134
|
+
return "n"
|
|
135
|
+
try:
|
|
136
|
+
answer = self.ask_callback(question, list(_ASK_OPTIONS))
|
|
137
|
+
except Exception:
|
|
138
|
+
return "n"
|
|
139
|
+
if answer not in _ASK_OPTIONS:
|
|
140
|
+
return "n"
|
|
141
|
+
return answer
|
|
142
|
+
|
|
143
|
+
def _check_budget(self, messages: list[dict]) -> bool:
|
|
144
|
+
total = sum(len(m.get("content", "")) / 4 for m in messages)
|
|
145
|
+
return total > self.config.max_budget_tokens
|
|
146
|
+
|
|
147
|
+
def _compress(self, messages: list[dict]) -> list[dict]:
|
|
148
|
+
keep = self.config.compression_keep_turns
|
|
149
|
+
oldest = messages[:-keep]
|
|
150
|
+
if not oldest:
|
|
151
|
+
return messages
|
|
152
|
+
self._compress_calls += 1
|
|
153
|
+
if self._compress_calls > self.config.compression_max_rounds:
|
|
154
|
+
return self._drop_oldest(messages, keep)
|
|
155
|
+
try:
|
|
156
|
+
summary = self.llm.complete(
|
|
157
|
+
[
|
|
158
|
+
{
|
|
159
|
+
"role": "system",
|
|
160
|
+
"content": (
|
|
161
|
+
"请将以下较早回合总结为简洁摘要,"
|
|
162
|
+
"保留关键事实、决定与结果:"
|
|
163
|
+
),
|
|
164
|
+
},
|
|
165
|
+
{
|
|
166
|
+
"role": "user",
|
|
167
|
+
"content": json.dumps(oldest, ensure_ascii=False),
|
|
168
|
+
},
|
|
169
|
+
],
|
|
170
|
+
tools=[],
|
|
171
|
+
)
|
|
172
|
+
except Exception:
|
|
173
|
+
return self._drop_oldest(messages, keep)
|
|
174
|
+
text = (summary.text or "").strip()
|
|
175
|
+
if not text:
|
|
176
|
+
return self._drop_oldest(messages, keep)
|
|
177
|
+
return [{"role": "system", "content": f"[summary] {text}"}] + messages[-keep:]
|
|
178
|
+
|
|
179
|
+
def _drop_oldest(self, messages: list[dict], keep: int) -> list[dict]:
|
|
180
|
+
window = messages[-keep:]
|
|
181
|
+
if window and window[0]["role"] not in ("user", "system"):
|
|
182
|
+
for m in reversed(messages[:-keep]):
|
|
183
|
+
if m["role"] in ("user", "system"):
|
|
184
|
+
return [m] + window
|
|
185
|
+
return window
|
|
186
|
+
|
|
187
|
+
def run(self, task: str) -> AgentResult:
|
|
188
|
+
result = AgentResult()
|
|
189
|
+
messages = [
|
|
190
|
+
{"role": "system", "content": SYSTEM_PROMPT},
|
|
191
|
+
{"role": "user", "content": task},
|
|
192
|
+
]
|
|
193
|
+
if self.memory is not None:
|
|
194
|
+
for chunk in self.memory.top_k_chunks(task):
|
|
195
|
+
messages.append(
|
|
196
|
+
{"role": "system", "content": f"[memory] {chunk['chunk']}"}
|
|
197
|
+
)
|
|
198
|
+
call_uid = 0
|
|
199
|
+
fail_seq = 0
|
|
200
|
+
fail_tool: str | None = None
|
|
201
|
+
max_fail_seq = 0
|
|
202
|
+
self._compress_calls = 0
|
|
203
|
+
self._tool_calls = []
|
|
204
|
+
self.state.fire("task_submitted", "loop")
|
|
205
|
+
while result.steps_used < self.config.max_steps:
|
|
206
|
+
if self._check_budget(messages):
|
|
207
|
+
messages = self._compress(messages)
|
|
208
|
+
response = self.llm.complete(messages, build_request_tools(self.registry))
|
|
209
|
+
result.steps_used += 1
|
|
210
|
+
if not response.tool_calls:
|
|
211
|
+
final = response.text or "任务完成"
|
|
212
|
+
self._emit_text(final)
|
|
213
|
+
messages.append({"role": "assistant", "content": final})
|
|
214
|
+
result.text = final
|
|
215
|
+
return self._finish(result, messages, max_fail_seq)
|
|
216
|
+
self._emit_text(response.text)
|
|
217
|
+
assistant_call = []
|
|
218
|
+
for i, call in enumerate(response.tool_calls):
|
|
219
|
+
assistant_call.append({
|
|
220
|
+
"id": f"call_{call_uid}",
|
|
221
|
+
"type": "function",
|
|
222
|
+
"function": {
|
|
223
|
+
"name": call["name"],
|
|
224
|
+
"arguments": json.dumps(call["arguments"], ensure_ascii=False),
|
|
225
|
+
},
|
|
226
|
+
})
|
|
227
|
+
call_uid += 1
|
|
228
|
+
messages.append({
|
|
229
|
+
"role": "assistant",
|
|
230
|
+
"content": response.text,
|
|
231
|
+
"tool_calls": assistant_call,
|
|
232
|
+
})
|
|
233
|
+
for i, call in enumerate(response.tool_calls):
|
|
234
|
+
tool_id = f"call_{call_uid - len(response.tool_calls) + i}"
|
|
235
|
+
tool_result = self.pipeline(call, self.context_for_tool())
|
|
236
|
+
result.tool_results.append(tool_result)
|
|
237
|
+
self._tool_calls.append({"name": call["name"], "arguments": call["arguments"]})
|
|
238
|
+
messages.append({
|
|
239
|
+
"role": "tool",
|
|
240
|
+
"tool_call_id": tool_id,
|
|
241
|
+
"name": call["name"],
|
|
242
|
+
"content": json.dumps(
|
|
243
|
+
self._result_to_dict(tool_result), ensure_ascii=False
|
|
244
|
+
),
|
|
245
|
+
})
|
|
246
|
+
norm = self._result_to_dict(tool_result)
|
|
247
|
+
failed = norm.get("status") != "success" or bool(norm.get("error"))
|
|
248
|
+
if failed:
|
|
249
|
+
if call["name"] == fail_tool:
|
|
250
|
+
fail_seq += 1
|
|
251
|
+
else:
|
|
252
|
+
fail_seq = 1
|
|
253
|
+
fail_tool = call["name"]
|
|
254
|
+
max_fail_seq = max(max_fail_seq, fail_seq)
|
|
255
|
+
if fail_seq >= self.config.failure_budget:
|
|
256
|
+
final = (
|
|
257
|
+
f"连续失败 {fail_seq} 次(工具 {fail_tool}),"
|
|
258
|
+
f"超过失败预算 {self.config.failure_budget},停止重试。"
|
|
259
|
+
)
|
|
260
|
+
self._emit_text(final)
|
|
261
|
+
messages.append({"role": "assistant", "content": final})
|
|
262
|
+
result.text = final
|
|
263
|
+
return self._finish(result, messages, max_fail_seq)
|
|
264
|
+
else:
|
|
265
|
+
fail_seq = 0
|
|
266
|
+
fail_tool = None
|
|
267
|
+
final = f"达到步数上限 {self.config.max_steps},任务终止,未挂死。"
|
|
268
|
+
self._emit_text(final)
|
|
269
|
+
messages.append({"role": "assistant", "content": final})
|
|
270
|
+
result.text = final
|
|
271
|
+
return self._finish(result, messages, max_fail_seq)
|
|
272
|
+
|
|
273
|
+
def _finish(self, result: AgentResult, messages: list[dict], max_fail_seq: int) -> AgentResult:
|
|
274
|
+
result.messages = messages
|
|
275
|
+
self.messages = messages
|
|
276
|
+
result.failed_sequence = max_fail_seq
|
|
277
|
+
self.state.fire("final_answer", "loop")
|
|
278
|
+
self._finalize(result)
|
|
279
|
+
return result
|
|
280
|
+
|
|
281
|
+
def _finalize(self, result: AgentResult) -> None:
|
|
282
|
+
self.hooks.session_data["tool_calls"] = list(self._tool_calls)
|
|
283
|
+
self.hooks.session_data["policy_changes"] = self.policy.changes()
|
|
284
|
+
self.hooks.session_end(self.messages)
|
|
285
|
+
if self.hooks.transcript_dir is not None:
|
|
286
|
+
files = [p for p in Path(self.hooks.transcript_dir).glob("*.json")]
|
|
287
|
+
if files:
|
|
288
|
+
result.transcript_path = str(
|
|
289
|
+
max(files, key=lambda p: p.stat().st_mtime)
|
|
290
|
+
)
|
|
291
|
+
if self.memory is not None and self.llm is not None:
|
|
292
|
+
self._consolidate()
|
|
293
|
+
|
|
294
|
+
def _consolidate(self) -> None:
|
|
295
|
+
try:
|
|
296
|
+
response = self.llm.complete(
|
|
297
|
+
[
|
|
298
|
+
{
|
|
299
|
+
"role": "system",
|
|
300
|
+
"content": (
|
|
301
|
+
"请总结本次会话的关键事实、决策与工具结果,"
|
|
302
|
+
"作为长期记忆保存:"
|
|
303
|
+
),
|
|
304
|
+
}
|
|
305
|
+
]
|
|
306
|
+
+ self.messages,
|
|
307
|
+
tools=[],
|
|
308
|
+
)
|
|
309
|
+
except Exception as exc:
|
|
310
|
+
self.warnings.append(f"memory consolidation failed: {exc}")
|
|
311
|
+
return
|
|
312
|
+
summary = (response.text or "").strip()
|
|
313
|
+
if not summary:
|
|
314
|
+
self.warnings.append("memory consolidation skipped: empty summary")
|
|
315
|
+
return
|
|
316
|
+
try:
|
|
317
|
+
self.memory.save(
|
|
318
|
+
f"session-summary-{datetime.now():%Y%m%d-%H%M%S}", summary
|
|
319
|
+
)
|
|
320
|
+
except Exception as exc:
|
|
321
|
+
self.warnings.append(f"memory save failed: {exc}")
|
|
322
|
+
|
|
323
|
+
@staticmethod
|
|
324
|
+
def _result_to_dict(tool_result) -> dict:
|
|
325
|
+
if isinstance(tool_result, ToolResult):
|
|
326
|
+
return {
|
|
327
|
+
"status": tool_result.status,
|
|
328
|
+
"output": tool_result.output,
|
|
329
|
+
"error": tool_result.error,
|
|
330
|
+
"exit_code": tool_result.exit_code,
|
|
331
|
+
}
|
|
332
|
+
if isinstance(tool_result, dict):
|
|
333
|
+
return tool_result
|
|
334
|
+
return {"status": "success", "output": str(tool_result)}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import tomllib
|
|
4
|
+
import warnings
|
|
5
|
+
from dataclasses import dataclass, field, fields
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
@dataclass
|
|
10
|
+
class Config:
|
|
11
|
+
model: str = "deepseek-chat"
|
|
12
|
+
base_url: str = "https://api.deepseek.com"
|
|
13
|
+
max_steps: int = 50
|
|
14
|
+
failure_budget: int = 3
|
|
15
|
+
tool_timeout: int = 30
|
|
16
|
+
memory_top_k: int = 2
|
|
17
|
+
max_budget_tokens: int = 6000
|
|
18
|
+
compression_keep_turns: int = 10
|
|
19
|
+
compression_max_rounds: int = 3
|
|
20
|
+
workspace: Path = field(default_factory=Path.cwd)
|
|
21
|
+
max_output_bytes: int = 51200
|
|
22
|
+
mcp_servers: list[dict] = field(default_factory=list)
|
|
23
|
+
|
|
24
|
+
@classmethod
|
|
25
|
+
def load(cls, path: Path | None = None) -> Config:
|
|
26
|
+
cfg = cls()
|
|
27
|
+
if path is None:
|
|
28
|
+
return cfg
|
|
29
|
+
path = Path(path)
|
|
30
|
+
if not path.exists():
|
|
31
|
+
return cfg
|
|
32
|
+
try:
|
|
33
|
+
with path.open("rb") as f:
|
|
34
|
+
data = tomllib.load(f)
|
|
35
|
+
except Exception as exc:
|
|
36
|
+
warnings.warn(f"config 解析失败,使用默认值: {exc}")
|
|
37
|
+
return cfg
|
|
38
|
+
known = {f.name for f in fields(cls)}
|
|
39
|
+
for key, value in data.items():
|
|
40
|
+
if key not in known:
|
|
41
|
+
continue
|
|
42
|
+
if key == "workspace":
|
|
43
|
+
value = Path(value)
|
|
44
|
+
setattr(cfg, key, value)
|
|
45
|
+
return cfg
|