ltcai 0.1.9 → 0.1.11
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.
- package/README.md +141 -313
- package/docs/CHANGELOG.md +227 -0
- package/docs/architecture.md +121 -0
- package/docs/mcp-tools.md +116 -0
- package/docs/privacy.md +74 -0
- package/docs/public-deploy.md +137 -0
- package/docs/security-model.md +121 -0
- package/ltcai_cli.py +2 -2
- package/package.json +1 -1
- package/server.py +997 -261
- package/skills/SKILL_TEMPLATE.md +61 -29
- package/skills/code_review/SKILL.md +28 -0
- package/skills/code_review/examples.md +59 -0
- package/skills/code_review/risk.json +9 -0
- package/skills/code_review/schema.json +65 -0
- package/skills/data_analysis/SKILL.md +28 -0
- package/skills/data_analysis/examples.md +62 -0
- package/skills/data_analysis/risk.json +9 -0
- package/skills/data_analysis/schema.json +61 -0
- package/skills/file_edit/SKILL.md +33 -0
- package/skills/file_edit/examples.md +45 -0
- package/skills/file_edit/risk.json +9 -0
- package/skills/file_edit/schema.json +60 -0
- package/skills/summarize_document/SKILL.md +68 -0
- package/skills/summarize_document/examples.md +65 -0
- package/skills/summarize_document/risk.json +9 -0
- package/skills/summarize_document/schema.json +71 -0
- package/skills/web_search/SKILL.md +28 -0
- package/skills/web_search/examples.md +61 -0
- package/skills/web_search/risk.json +9 -0
- package/skills/web_search/schema.json +62 -0
- package/tests/integration/__pycache__/__init__.cpython-314.pyc +0 -0
- package/tests/integration/__pycache__/test_api.cpython-314-pytest-9.0.3.pyc +0 -0
- package/tests/unit/__pycache__/test_tools.cpython-314-pytest-9.0.3.pyc +0 -0
- package/tests/unit/test_tools.py +194 -1
- package/tools.py +264 -4
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
# Skill: summarize_document
|
|
2
|
+
|
|
3
|
+
## 메타데이터
|
|
4
|
+
- **버전**: 0.1.0
|
|
5
|
+
- **카테고리**: document
|
|
6
|
+
- **위험도**: low
|
|
7
|
+
- **필요 권한**: local_read
|
|
8
|
+
|
|
9
|
+
## 설명
|
|
10
|
+
텍스트 파일(.txt, .md, .pdf, .docx)을 읽어 핵심 내용을 요약하고, 섹션별 요점과 키워드를 추출한다.
|
|
11
|
+
|
|
12
|
+
## 거버넌스
|
|
13
|
+
|
|
14
|
+
`risk.json` 참고. 요약: `risk=read`, `destructive=false`, `shell=false`, `network=false`, `auto_approve=true`, `sandbox=home`, `rollback=none`
|
|
15
|
+
|
|
16
|
+
## 트리거 조건
|
|
17
|
+
|
|
18
|
+
호출해야 하는 상황:
|
|
19
|
+
- 사용자가 "이 문서 요약해줘", "핵심만 뽑아줘", "이 파일 내용이 뭐야?"라고 요청할 때
|
|
20
|
+
- 에이전트가 Discover 단계에서 긴 문서의 구조를 빠르게 파악해야 할 때
|
|
21
|
+
- 여러 문서를 비교하기 전 각 문서의 핵심 파악이 필요할 때
|
|
22
|
+
|
|
23
|
+
호출하면 **안** 되는 상황:
|
|
24
|
+
- 문서를 수정해야 할 때 → `edit_file` 사용
|
|
25
|
+
- CSV/Excel 데이터 분석 → `data_analysis` 사용
|
|
26
|
+
- 코드 파일 분석 → `code_review` 사용
|
|
27
|
+
|
|
28
|
+
## Side Effects
|
|
29
|
+
|
|
30
|
+
| 항목 | 내용 |
|
|
31
|
+
|------|------|
|
|
32
|
+
| 파일 변경 | 없음 |
|
|
33
|
+
| 생성 파일 | 없음 |
|
|
34
|
+
| 프로세스 | 없음 |
|
|
35
|
+
| 네트워크 | 없음 |
|
|
36
|
+
|
|
37
|
+
## Rollback
|
|
38
|
+
|
|
39
|
+
없음. 읽기 전용 작업.
|
|
40
|
+
|
|
41
|
+
## 입력 스키마
|
|
42
|
+
`schema.json` 참고.
|
|
43
|
+
|
|
44
|
+
## 출력 스키마
|
|
45
|
+
`schema.json` 참고.
|
|
46
|
+
|
|
47
|
+
## 실행 조건
|
|
48
|
+
- LLM 모델이 로드되어 있어야 함
|
|
49
|
+
- 파일이 존재해야 하며 .txt/.md/.pdf/.docx 형식이어야 함
|
|
50
|
+
- 파일 크기 20 MB 이하
|
|
51
|
+
|
|
52
|
+
## 예제
|
|
53
|
+
`examples.md` 참고.
|
|
54
|
+
|
|
55
|
+
## 실패 처리
|
|
56
|
+
| 에러 코드 | 원인 | 처리 방법 |
|
|
57
|
+
|-----------|------|-----------|
|
|
58
|
+
| `INVALID_INPUT` | path 누락 | 파일 경로 입력 |
|
|
59
|
+
| `FILE_NOT_FOUND` | 경로에 파일 없음 | 경로 확인 |
|
|
60
|
+
| `UNSUPPORTED_FORMAT` | 지원하지 않는 형식 | .txt/.md/.pdf/.docx로 변환 후 재시도 |
|
|
61
|
+
| `SIZE_LIMIT` | 20 MB 초과 | 파일 분할 후 재시도 |
|
|
62
|
+
| `MODEL_NOT_LOADED` | LLM 미로드 | `/model` 명령으로 모델 선택 |
|
|
63
|
+
|
|
64
|
+
## 테스트 케이스
|
|
65
|
+
```python
|
|
66
|
+
# tests/unit/test_tools.py::test_summarize_document_md
|
|
67
|
+
# tests/unit/test_tools.py::test_summarize_document_unsupported
|
|
68
|
+
```
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
# summarize_document — Examples
|
|
2
|
+
|
|
3
|
+
## 1. Markdown file summary (success)
|
|
4
|
+
|
|
5
|
+
**Input**
|
|
6
|
+
```json
|
|
7
|
+
{ "path": "~/project/README.md", "style": "bullet" }
|
|
8
|
+
```
|
|
9
|
+
**Output**
|
|
10
|
+
```json
|
|
11
|
+
{
|
|
12
|
+
"success": true,
|
|
13
|
+
"result": {
|
|
14
|
+
"title": "README.md",
|
|
15
|
+
"summary": "• Lattice AI는 Apple Silicon 기반 로컬 AI 에이전트\n• FastAPI 서버 + VS Code 익스텐션 + Telegram bot 구조\n• MLX 및 클라우드 모델(OpenAI/Groq) 지원",
|
|
16
|
+
"keywords": ["Lattice AI", "MLX", "FastAPI", "VS Code", "Telegram", "local LLM"],
|
|
17
|
+
"sections": [
|
|
18
|
+
{ "heading": "Installation", "summary": "pip install ltcai 후 ltcai start로 실행" },
|
|
19
|
+
{ "heading": "Features", "summary": "채팅, 에이전트 모드, 파일 편집, 웹 검색 등 지원" }
|
|
20
|
+
],
|
|
21
|
+
"word_count": 1240
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
## 2. PDF summary with focus section (success)
|
|
27
|
+
|
|
28
|
+
**Input**
|
|
29
|
+
```json
|
|
30
|
+
{ "path": "~/docs/report.pdf", "style": "paragraph", "focus_sections": ["결론", "권고사항"], "max_length": 300 }
|
|
31
|
+
```
|
|
32
|
+
**Output**
|
|
33
|
+
```json
|
|
34
|
+
{
|
|
35
|
+
"success": true,
|
|
36
|
+
"result": {
|
|
37
|
+
"title": "report.pdf",
|
|
38
|
+
"summary": "보고서의 결론: 시스템 성능이 전분기 대비 23% 향상되었으며, 추가 최적화를 위해 캐시 레이어 도입이 권고됩니다.",
|
|
39
|
+
"keywords": ["성능", "최적화", "캐시", "권고"],
|
|
40
|
+
"word_count": 8500
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
## 3. Unsupported format (failure)
|
|
46
|
+
|
|
47
|
+
**Input**
|
|
48
|
+
```json
|
|
49
|
+
{ "path": "~/data/sales.csv" }
|
|
50
|
+
```
|
|
51
|
+
**Output**
|
|
52
|
+
```json
|
|
53
|
+
{ "success": false, "error": "UNSUPPORTED_FORMAT", "message": "Supported formats: txt, md, pdf, docx. Use data_analysis for CSV files." }
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
## 4. File not found (failure)
|
|
57
|
+
|
|
58
|
+
**Input**
|
|
59
|
+
```json
|
|
60
|
+
{ "path": "~/docs/nonexistent.md" }
|
|
61
|
+
```
|
|
62
|
+
**Output**
|
|
63
|
+
```json
|
|
64
|
+
{ "success": false, "error": "FILE_NOT_FOUND", "message": "No such file: /home/user/docs/nonexistent.md" }
|
|
65
|
+
```
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "http://json-schema.org/draft-07/schema#",
|
|
3
|
+
"title": "summarize_document",
|
|
4
|
+
"input": {
|
|
5
|
+
"required": ["path"],
|
|
6
|
+
"optional": ["max_length", "style", "focus_sections"],
|
|
7
|
+
"properties": {
|
|
8
|
+
"path": { "type": "string", "description": "절대 경로 또는 ~/로 시작하는 경로 (.txt, .md, .pdf, .docx)" },
|
|
9
|
+
"max_length": { "type": "integer", "default": 500, "description": "요약 최대 글자 수" },
|
|
10
|
+
"style": { "type": "string", "enum": ["bullet","paragraph","table"], "default": "bullet", "description": "출력 형식" },
|
|
11
|
+
"focus_sections": { "type": "array", "items": { "type": "string" }, "description": "특정 섹션만 요약할 때 섹션 제목 목록" }
|
|
12
|
+
}
|
|
13
|
+
},
|
|
14
|
+
"output": {
|
|
15
|
+
"oneOf": [
|
|
16
|
+
{
|
|
17
|
+
"title": "success",
|
|
18
|
+
"properties": {
|
|
19
|
+
"success": { "const": true },
|
|
20
|
+
"result": {
|
|
21
|
+
"properties": {
|
|
22
|
+
"title": { "type": "string", "description": "문서 제목 또는 파일명" },
|
|
23
|
+
"summary": { "type": "string", "description": "핵심 요약" },
|
|
24
|
+
"keywords": { "type": "array", "items": { "type": "string" }, "description": "주요 키워드 (최대 10개)" },
|
|
25
|
+
"sections": {
|
|
26
|
+
"type": "array",
|
|
27
|
+
"items": {
|
|
28
|
+
"properties": {
|
|
29
|
+
"heading": { "type": "string" },
|
|
30
|
+
"summary": { "type": "string" }
|
|
31
|
+
},
|
|
32
|
+
"required": ["heading","summary"]
|
|
33
|
+
},
|
|
34
|
+
"description": "섹션별 요약 (있는 경우)"
|
|
35
|
+
},
|
|
36
|
+
"word_count": { "type": "integer" }
|
|
37
|
+
},
|
|
38
|
+
"required": ["title","summary","keywords"]
|
|
39
|
+
}
|
|
40
|
+
},
|
|
41
|
+
"required": ["success","result"]
|
|
42
|
+
},
|
|
43
|
+
{
|
|
44
|
+
"title": "failure",
|
|
45
|
+
"properties": {
|
|
46
|
+
"success": { "const": false },
|
|
47
|
+
"error": { "type": "string", "enum": ["INVALID_INPUT","FILE_NOT_FOUND","UNSUPPORTED_FORMAT","SIZE_LIMIT","MODEL_NOT_LOADED"] },
|
|
48
|
+
"message": { "type": "string" }
|
|
49
|
+
},
|
|
50
|
+
"required": ["success","error","message"]
|
|
51
|
+
}
|
|
52
|
+
]
|
|
53
|
+
},
|
|
54
|
+
"evals": [
|
|
55
|
+
{
|
|
56
|
+
"id": "markdown_summary",
|
|
57
|
+
"input": { "path": "__TEST__/README.md", "style": "bullet" },
|
|
58
|
+
"pass_criteria": "success == true and len(keywords) >= 1"
|
|
59
|
+
},
|
|
60
|
+
{
|
|
61
|
+
"id": "unsupported_format",
|
|
62
|
+
"input": { "path": "__TEST__/data.csv" },
|
|
63
|
+
"pass_criteria": "error == UNSUPPORTED_FORMAT"
|
|
64
|
+
},
|
|
65
|
+
{
|
|
66
|
+
"id": "missing_path",
|
|
67
|
+
"input": {},
|
|
68
|
+
"pass_criteria": "error == INVALID_INPUT"
|
|
69
|
+
}
|
|
70
|
+
]
|
|
71
|
+
}
|
|
@@ -9,6 +9,34 @@
|
|
|
9
9
|
## 설명
|
|
10
10
|
외부 검색 엔진(DuckDuckGo/Brave)을 통해 웹 검색을 수행하고 상위 결과를 반환한다.
|
|
11
11
|
|
|
12
|
+
## 거버넌스
|
|
13
|
+
|
|
14
|
+
`policies/policy.md` 참고. 요약: `risk=read`, `destructive=false`, `shell=false`, `network=true`, `auto_approve=true`, `sandbox=system`, `rollback=none`
|
|
15
|
+
|
|
16
|
+
## 트리거 조건
|
|
17
|
+
|
|
18
|
+
호출해야 하는 상황:
|
|
19
|
+
- 사용자가 "검색해줘", "찾아봐줘", "최신 정보 알려줘"라고 요청할 때
|
|
20
|
+
- 에이전트가 Discover 단계에서 외부 문서/API/라이브러리 정보를 수집해야 할 때
|
|
21
|
+
- LLM의 학습 데이터 이후 최신 정보(라이브러리 버전, 뉴스 등)가 필요할 때
|
|
22
|
+
|
|
23
|
+
호출하면 **안** 되는 상황:
|
|
24
|
+
- 로컬 파일 내용 검색 시 → `grep` 사용
|
|
25
|
+
- LLM이 이미 알고 있는 일반 지식 질문 시
|
|
26
|
+
|
|
27
|
+
## Side Effects
|
|
28
|
+
|
|
29
|
+
| 항목 | 내용 |
|
|
30
|
+
|------|------|
|
|
31
|
+
| 파일 변경 | 없음 |
|
|
32
|
+
| 생성 파일 | 없음 |
|
|
33
|
+
| 프로세스 | 없음 |
|
|
34
|
+
| 네트워크 | 외부 검색 API로 검색어 전송 (DuckDuckGo/Brave) |
|
|
35
|
+
|
|
36
|
+
## Rollback
|
|
37
|
+
|
|
38
|
+
없음. 읽기 전용 네트워크 요청.
|
|
39
|
+
|
|
12
40
|
## 입력 스키마
|
|
13
41
|
```json
|
|
14
42
|
{
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
# web_search — Examples
|
|
2
|
+
|
|
3
|
+
## 1. Basic search (success)
|
|
4
|
+
|
|
5
|
+
**Input**
|
|
6
|
+
```json
|
|
7
|
+
{ "query": "FastAPI 비동기 처리", "num_results": 3 }
|
|
8
|
+
```
|
|
9
|
+
**Output**
|
|
10
|
+
```json
|
|
11
|
+
{
|
|
12
|
+
"success": true,
|
|
13
|
+
"result": {
|
|
14
|
+
"query": "FastAPI 비동기 처리",
|
|
15
|
+
"results": [
|
|
16
|
+
{ "title": "FastAPI - Async", "url": "https://fastapi.tiangolo.com/async/", "snippet": "FastAPI는 Python의 asyncio를 완벽히 지원합니다..." }
|
|
17
|
+
]
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
## 2. Search with language (success)
|
|
23
|
+
|
|
24
|
+
**Input**
|
|
25
|
+
```json
|
|
26
|
+
{ "query": "Python type hints best practices", "num_results": 5, "lang": "en-US" }
|
|
27
|
+
```
|
|
28
|
+
**Output**
|
|
29
|
+
```json
|
|
30
|
+
{
|
|
31
|
+
"success": true,
|
|
32
|
+
"result": {
|
|
33
|
+
"query": "Python type hints best practices",
|
|
34
|
+
"results": [
|
|
35
|
+
{ "title": "PEP 484 – Type Hints", "url": "https://peps.python.org/pep-0484/", "snippet": "This PEP introduces a standard syntax for type annotations..." }
|
|
36
|
+
]
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
## 3. Empty query (failure)
|
|
42
|
+
|
|
43
|
+
**Input**
|
|
44
|
+
```json
|
|
45
|
+
{ "query": "" }
|
|
46
|
+
```
|
|
47
|
+
**Output**
|
|
48
|
+
```json
|
|
49
|
+
{ "success": false, "error": "INVALID_INPUT", "message": "query is required" }
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
## 4. Network error (failure)
|
|
53
|
+
|
|
54
|
+
**Input**
|
|
55
|
+
```json
|
|
56
|
+
{ "query": "offline test" }
|
|
57
|
+
```
|
|
58
|
+
**Output**
|
|
59
|
+
```json
|
|
60
|
+
{ "success": false, "error": "NETWORK_ERROR", "message": "외부 검색 API에 연결할 수 없습니다. 잠시 후 재시도하세요." }
|
|
61
|
+
```
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "http://json-schema.org/draft-07/schema#",
|
|
3
|
+
"title": "web_search",
|
|
4
|
+
"input": {
|
|
5
|
+
"required": ["query"],
|
|
6
|
+
"optional": ["num_results", "lang"],
|
|
7
|
+
"properties": {
|
|
8
|
+
"query": { "type": "string", "description": "검색어" },
|
|
9
|
+
"num_results": { "type": "integer", "default": 5, "minimum": 1, "maximum": 20 },
|
|
10
|
+
"lang": { "type": "string", "default": "ko-KR" }
|
|
11
|
+
}
|
|
12
|
+
},
|
|
13
|
+
"output": {
|
|
14
|
+
"oneOf": [
|
|
15
|
+
{
|
|
16
|
+
"title": "success",
|
|
17
|
+
"properties": {
|
|
18
|
+
"success": { "const": true },
|
|
19
|
+
"result": {
|
|
20
|
+
"properties": {
|
|
21
|
+
"query": { "type": "string" },
|
|
22
|
+
"results": {
|
|
23
|
+
"type": "array",
|
|
24
|
+
"items": {
|
|
25
|
+
"properties": {
|
|
26
|
+
"title": { "type": "string" },
|
|
27
|
+
"url": { "type": "string", "format": "uri" },
|
|
28
|
+
"snippet": { "type": "string" }
|
|
29
|
+
},
|
|
30
|
+
"required": ["title","url","snippet"]
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
},
|
|
34
|
+
"required": ["query","results"]
|
|
35
|
+
}
|
|
36
|
+
},
|
|
37
|
+
"required": ["success","result"]
|
|
38
|
+
},
|
|
39
|
+
{
|
|
40
|
+
"title": "failure",
|
|
41
|
+
"properties": {
|
|
42
|
+
"success": { "const": false },
|
|
43
|
+
"error": { "type": "string", "enum": ["INVALID_INPUT","NETWORK_ERROR","TIMEOUT"] },
|
|
44
|
+
"message": { "type": "string" }
|
|
45
|
+
},
|
|
46
|
+
"required": ["success","error","message"]
|
|
47
|
+
}
|
|
48
|
+
]
|
|
49
|
+
},
|
|
50
|
+
"evals": [
|
|
51
|
+
{
|
|
52
|
+
"id": "basic_search",
|
|
53
|
+
"input": { "query": "FastAPI async", "num_results": 3 },
|
|
54
|
+
"pass_criteria": "success == true and len(results) >= 1"
|
|
55
|
+
},
|
|
56
|
+
{
|
|
57
|
+
"id": "empty_query",
|
|
58
|
+
"input": { "query": "" },
|
|
59
|
+
"pass_criteria": "error == INVALID_INPUT"
|
|
60
|
+
}
|
|
61
|
+
]
|
|
62
|
+
}
|
|
Binary file
|
|
Binary file
|
package/tests/unit/test_tools.py
CHANGED
|
@@ -5,7 +5,28 @@ from pathlib import Path
|
|
|
5
5
|
|
|
6
6
|
sys.path.insert(0, str(Path(__file__).parent.parent.parent))
|
|
7
7
|
|
|
8
|
-
|
|
8
|
+
import tools as tools_module
|
|
9
|
+
from tools import (
|
|
10
|
+
ToolError,
|
|
11
|
+
edit_file,
|
|
12
|
+
grep,
|
|
13
|
+
local_list,
|
|
14
|
+
local_read,
|
|
15
|
+
local_write,
|
|
16
|
+
read_document,
|
|
17
|
+
read_file,
|
|
18
|
+
todo_read,
|
|
19
|
+
todo_write,
|
|
20
|
+
write_file,
|
|
21
|
+
)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
@pytest.fixture
|
|
25
|
+
def workspace(tmp_path, monkeypatch):
|
|
26
|
+
"""Redirect tools' AGENT_ROOT to a temp directory for the duration of a test."""
|
|
27
|
+
monkeypatch.setattr(tools_module, "AGENT_ROOT", tmp_path)
|
|
28
|
+
tools_module.ensure_agent_root()
|
|
29
|
+
return tmp_path
|
|
9
30
|
|
|
10
31
|
|
|
11
32
|
# ---------------------------------------------------------------------------
|
|
@@ -125,3 +146,175 @@ def test_read_document_csv(tmp_path):
|
|
|
125
146
|
result = read_document(str(f))
|
|
126
147
|
# should not raise; returns some text content
|
|
127
148
|
assert result is not None
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
# ---------------------------------------------------------------------------
|
|
152
|
+
# read_file (workspace, with line numbers and offset/limit)
|
|
153
|
+
# ---------------------------------------------------------------------------
|
|
154
|
+
|
|
155
|
+
def test_read_file_returns_numbered_view(workspace):
|
|
156
|
+
(workspace / "a.txt").write_text("alpha\nbeta\ngamma\n")
|
|
157
|
+
result = read_file("a.txt")
|
|
158
|
+
assert result["total_lines"] == 3
|
|
159
|
+
assert result["start_line"] == 1
|
|
160
|
+
assert result["end_line"] == 3
|
|
161
|
+
assert "1\talpha" in result["numbered"]
|
|
162
|
+
assert "3\tgamma" in result["numbered"]
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def test_read_file_offset_and_limit(workspace):
|
|
166
|
+
(workspace / "a.txt").write_text("\n".join(f"line{i}" for i in range(1, 11)))
|
|
167
|
+
result = read_file("a.txt", offset=3, limit=2)
|
|
168
|
+
assert result["start_line"] == 4
|
|
169
|
+
assert result["end_line"] == 5
|
|
170
|
+
assert result["content"].splitlines() == ["line4", "line5"]
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
def test_read_file_disable_line_numbers(workspace):
|
|
174
|
+
(workspace / "a.txt").write_text("only\n")
|
|
175
|
+
result = read_file("a.txt", line_numbers=False)
|
|
176
|
+
assert "numbered" not in result
|
|
177
|
+
assert result["content"] == "only\n"
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
# ---------------------------------------------------------------------------
|
|
181
|
+
# edit_file
|
|
182
|
+
# ---------------------------------------------------------------------------
|
|
183
|
+
|
|
184
|
+
def test_edit_file_replaces_unique_match(workspace):
|
|
185
|
+
(workspace / "code.py").write_text("def foo():\n return 1\n")
|
|
186
|
+
result = edit_file("code.py", "return 1", "return 42")
|
|
187
|
+
assert (workspace / "code.py").read_text() == "def foo():\n return 42\n"
|
|
188
|
+
assert result["replacements"] == 1
|
|
189
|
+
assert result["first_edit_line"] == 2
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
def test_edit_file_missing_string_raises(workspace):
|
|
193
|
+
(workspace / "code.py").write_text("hello\n")
|
|
194
|
+
with pytest.raises(ToolError, match="not found"):
|
|
195
|
+
edit_file("code.py", "missing", "world")
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
def test_edit_file_ambiguous_raises_unless_replace_all(workspace):
|
|
199
|
+
(workspace / "code.py").write_text("x = 1\nx = 1\n")
|
|
200
|
+
with pytest.raises(ToolError, match="ambiguous"):
|
|
201
|
+
edit_file("code.py", "x = 1", "x = 2")
|
|
202
|
+
result = edit_file("code.py", "x = 1", "x = 2", replace_all=True)
|
|
203
|
+
assert result["replacements"] == 2
|
|
204
|
+
assert (workspace / "code.py").read_text() == "x = 2\nx = 2\n"
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
def test_edit_file_rejects_identical(workspace):
|
|
208
|
+
(workspace / "code.py").write_text("same\n")
|
|
209
|
+
with pytest.raises(ToolError, match="identical"):
|
|
210
|
+
edit_file("code.py", "same", "same")
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
# ---------------------------------------------------------------------------
|
|
214
|
+
# grep
|
|
215
|
+
# ---------------------------------------------------------------------------
|
|
216
|
+
|
|
217
|
+
def test_grep_finds_regex_matches(workspace):
|
|
218
|
+
(workspace / "a.py").write_text("def foo():\n return 1\n\ndef bar():\n return 2\n")
|
|
219
|
+
(workspace / "b.py").write_text("x = 1\n")
|
|
220
|
+
result = grep(r"^def \w+", path=".")
|
|
221
|
+
paths = sorted({m["path"] for m in result["matches"]})
|
|
222
|
+
assert "a.py" in paths
|
|
223
|
+
assert result["files_with_matches"] == 1
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
def test_grep_respects_glob(workspace):
|
|
227
|
+
(workspace / "a.py").write_text("needle\n")
|
|
228
|
+
(workspace / "a.txt").write_text("needle\n")
|
|
229
|
+
result = grep("needle", path=".", glob="*.py")
|
|
230
|
+
paths = [m["path"] for m in result["matches"]]
|
|
231
|
+
assert "a.py" in paths
|
|
232
|
+
assert "a.txt" not in paths
|
|
233
|
+
|
|
234
|
+
|
|
235
|
+
def test_grep_case_insensitive(workspace):
|
|
236
|
+
(workspace / "a.txt").write_text("HELLO world\n")
|
|
237
|
+
result = grep("hello", path=".", case_insensitive=True)
|
|
238
|
+
assert any("HELLO" in m["match"] for m in result["matches"])
|
|
239
|
+
|
|
240
|
+
|
|
241
|
+
def test_grep_context_lines(workspace):
|
|
242
|
+
(workspace / "a.txt").write_text("before\nhit\nafter\n")
|
|
243
|
+
result = grep("hit", path=".", context_lines=1)
|
|
244
|
+
assert result["matches"]
|
|
245
|
+
ctx_lines = [c["text"] for c in result["matches"][0]["context"]]
|
|
246
|
+
assert "before" in ctx_lines and "after" in ctx_lines
|
|
247
|
+
|
|
248
|
+
|
|
249
|
+
def test_grep_invalid_regex_raises(workspace):
|
|
250
|
+
(workspace / "a.txt").write_text("hello\n")
|
|
251
|
+
with pytest.raises(ToolError, match="regex"):
|
|
252
|
+
grep("[unterminated", path=".")
|
|
253
|
+
|
|
254
|
+
|
|
255
|
+
def test_grep_skips_binary_dirs(workspace):
|
|
256
|
+
(workspace / "node_modules").mkdir()
|
|
257
|
+
(workspace / "node_modules" / "x.js").write_text("needle\n")
|
|
258
|
+
(workspace / "src.py").write_text("needle\n")
|
|
259
|
+
result = grep("needle", path=".")
|
|
260
|
+
paths = [m["path"] for m in result["matches"]]
|
|
261
|
+
assert "src.py" in paths
|
|
262
|
+
assert all("node_modules" not in p for p in paths)
|
|
263
|
+
|
|
264
|
+
|
|
265
|
+
# ---------------------------------------------------------------------------
|
|
266
|
+
# todo_read / todo_write
|
|
267
|
+
# ---------------------------------------------------------------------------
|
|
268
|
+
|
|
269
|
+
def test_todo_read_empty_when_unset(workspace):
|
|
270
|
+
result = todo_read()
|
|
271
|
+
assert result["todos"] == []
|
|
272
|
+
|
|
273
|
+
|
|
274
|
+
def test_todo_write_round_trip(workspace):
|
|
275
|
+
todos = [
|
|
276
|
+
{"id": "1", "content": "design API", "status": "completed"},
|
|
277
|
+
{"id": "2", "content": "write tests", "status": "in_progress"},
|
|
278
|
+
{"id": "3", "content": "deploy", "status": "pending"},
|
|
279
|
+
]
|
|
280
|
+
todo_write(todos)
|
|
281
|
+
fresh = todo_read()
|
|
282
|
+
assert [t["content"] for t in fresh["todos"]] == ["design API", "write tests", "deploy"]
|
|
283
|
+
assert fresh["todos"][1]["status"] == "in_progress"
|
|
284
|
+
|
|
285
|
+
|
|
286
|
+
def test_todo_write_rejects_invalid_status(workspace):
|
|
287
|
+
with pytest.raises(ToolError, match="status"):
|
|
288
|
+
todo_write([{"id": "1", "content": "x", "status": "blocked"}])
|
|
289
|
+
|
|
290
|
+
|
|
291
|
+
def test_todo_write_rejects_missing_content(workspace):
|
|
292
|
+
with pytest.raises(ToolError, match="content"):
|
|
293
|
+
todo_write([{"id": "1", "content": "", "status": "pending"}])
|
|
294
|
+
|
|
295
|
+
|
|
296
|
+
def test_todo_write_warns_multiple_in_progress(workspace):
|
|
297
|
+
result = todo_write([
|
|
298
|
+
{"id": "1", "content": "a", "status": "in_progress"},
|
|
299
|
+
{"id": "2", "content": "b", "status": "in_progress"},
|
|
300
|
+
])
|
|
301
|
+
assert result["warning"]
|
|
302
|
+
|
|
303
|
+
|
|
304
|
+
# ---------------------------------------------------------------------------
|
|
305
|
+
# Sandbox: workspace tools must not escape AGENT_ROOT
|
|
306
|
+
# ---------------------------------------------------------------------------
|
|
307
|
+
|
|
308
|
+
def test_read_file_blocks_path_escape(workspace):
|
|
309
|
+
with pytest.raises(ToolError, match="escapes"):
|
|
310
|
+
read_file("../../etc/passwd")
|
|
311
|
+
|
|
312
|
+
|
|
313
|
+
def test_edit_file_blocks_path_escape(workspace):
|
|
314
|
+
with pytest.raises(ToolError, match="escapes"):
|
|
315
|
+
edit_file("../../etc/passwd", "a", "b")
|
|
316
|
+
|
|
317
|
+
|
|
318
|
+
def test_grep_blocks_path_escape(workspace):
|
|
319
|
+
with pytest.raises(ToolError, match="escapes"):
|
|
320
|
+
grep("x", path="../../etc")
|