Protocol v1 release: golden traces, CI, make/npm, policy
- Golden traces: docs/golden_traces/v1/, format protocol/request/context/result - trace_to_golden bin, golden_traces_v1_validate test - Makefile: make golden, make test-protocol - npm scripts: golden, test-protocol - CI: .github/workflows/protocol-check.yml - PROTOCOL_V1.md, PROTOCOL_V2_PLAN.md - Policy for updating golden traces in README Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
parent
e76236dc55
commit
f2f33e24d6
28
.github/workflows/protocol-check.yml
vendored
Normal file
28
.github/workflows/protocol-check.yml
vendored
Normal file
@ -0,0 +1,28 @@
|
||||
name: Protocol v1 check
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main, master]
|
||||
pull_request:
|
||||
branches: [main, master]
|
||||
|
||||
jobs:
|
||||
protocol:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Install Rust
|
||||
uses: dtolnay/rust-toolchain@stable
|
||||
|
||||
- name: Cache cargo
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: |
|
||||
~/.cargo/registry
|
||||
~/.cargo/git
|
||||
target
|
||||
key: cargo-${{ runner.os }}-${{ hashFiles('**/Cargo.lock') }}
|
||||
|
||||
- name: golden_traces_v1_validate
|
||||
run: cd src-tauri && cargo test golden_traces_v1_validate --no-fail-fast
|
||||
16
CHANGELOG.md
16
CHANGELOG.md
@ -8,6 +8,12 @@
|
||||
|
||||
## [2.4.4] — 2025-01-31
|
||||
|
||||
### Protocol stability (v1)
|
||||
|
||||
- **Schema version:** `LLM_PLAN_SCHEMA_VERSION=1`, `x_schema_version` в схеме, `schema_hash` (sha256) в trace.
|
||||
- **Версионирование:** при изменении контракта ответа LLM — увеличивать schema_version; trace содержит schema_version и schema_hash для воспроизводимости.
|
||||
- **Рекомендуемый тег:** `v1.0.0` или `v0.x` — зафиксировать «стабильный релиз» перед введением v2.
|
||||
|
||||
### Добавлено
|
||||
|
||||
- **UX:** история сессий по проекту — блок «История сессий» с раскрывающимся списком сессий (дата, количество событий, последнее сообщение); обновление списка после agentic run.
|
||||
@ -35,7 +41,15 @@
|
||||
- **Capability detection:** при ошибке API response_format — автоматический retry без response_format (Ollama и др.).
|
||||
- **Schema version:** `x_schema_version` в llm_response_schema.json; schema_hash (sha256) в trace; LLM_PLAN_SCHEMA_VERSION в prompt.
|
||||
- **Кеш контекста:** read_file/search/logs/env кешируются в plan-цикле; CONTEXT_CACHE_HIT/MISS.
|
||||
- **Контекст-диета:** PAPAYU_CONTEXT_MAX_FILES=8, MAX_FILE_CHARS=20k, MAX_TOTAL_CHARS=120k; head+tail truncation; CONTEXT_DIET_APPLIED.
|
||||
- **Контекст-диета:** PAPAYU_CONTEXT_MAX_FILES=8, MAX_FILE_CHARS=20k, MAX_TOTAL_CHARS=120k; head+tail truncation; MIN_CHARS_FOR_PRIORITY0=4k; CONTEXT_DIET_APPLIED.
|
||||
- **Trace:** context_stats (files_count, dropped, total_chars, logs_chars, truncated) и cache_stats (hits/misses по env/logs/read/search, hit_rate).
|
||||
- **Кеш logs:** ключ Logs включает `last_n` — разные last_n не пересекаются.
|
||||
- **Golden traces:** эталонные fixtures в `docs/golden_traces/v1/` — формат protocol/request/context/result (без raw_content). Тест `golden_traces_v1_validate` валидирует schema_version, schema_hash, JSON schema, validate_actions, NO_CHANGES при apply+empty. Конвертер `trace_to_golden` (cargo run --bin trace_to_golden).
|
||||
- **Compatibility matrix:** в PROTOCOL_V1.md — Provider Compatibility таблица и 5 поведенческих гарантий.
|
||||
- **PROTOCOL_V2_PLAN.md:** план v2 (PATCH_FILE, REPLACE_RANGE, base_sha256).
|
||||
- **make/npm shortcuts:** `make golden` (trace→fixture), `make test-protocol` (golden_traces_v1_validate).
|
||||
- **CI:** `.github/workflows/protocol-check.yml` — golden_traces_v1_validate на push/PR.
|
||||
- **Политика golden traces:** в docs/golden_traces/README.md — когда/как обновлять, при смене schema_hash.
|
||||
|
||||
### Изменено
|
||||
|
||||
|
||||
24
Makefile
Normal file
24
Makefile
Normal file
@ -0,0 +1,24 @@
|
||||
.PHONY: golden golden-latest test-protocol test-all
|
||||
|
||||
# make golden TRACE_ID=<id> — из .papa-yu/traces/<id>.json
|
||||
# make golden — из последней трассы (golden-latest)
|
||||
golden:
|
||||
@if [ -n "$$TRACE_ID" ]; then \
|
||||
cd src-tauri && cargo run --bin trace_to_golden -- "$$TRACE_ID"; \
|
||||
else \
|
||||
$(MAKE) golden-latest; \
|
||||
fi
|
||||
|
||||
golden-latest:
|
||||
@LATEST=$$(ls -t .papa-yu/traces/*.json 2>/dev/null | head -1); \
|
||||
if [ -z "$$LATEST" ]; then \
|
||||
echo "No traces in .papa-yu/traces/. Run with PAPAYU_TRACE=1, propose fixes, then make golden."; \
|
||||
exit 1; \
|
||||
fi; \
|
||||
cd src-tauri && cargo run --bin trace_to_golden -- "../$$LATEST"
|
||||
|
||||
test-protocol:
|
||||
cd src-tauri && cargo test golden_traces_v1_validate
|
||||
|
||||
test-all:
|
||||
cd src-tauri && cargo test
|
||||
@ -83,7 +83,9 @@ LLM должен вернуть **только валидный JSON** — ли
|
||||
|
||||
**Кеш контекста:** read_file/search/logs/env кешируются в пределах plan-цикла. Логи: CONTEXT_CACHE_HIT, CONTEXT_CACHE_MISS.
|
||||
|
||||
**Контекст-диета:** PAPAYU_CONTEXT_MAX_FILES=8, PAPAYU_CONTEXT_MAX_FILE_CHARS=20000, PAPAYU_CONTEXT_MAX_TOTAL_CHARS=120000. Файлы: head+tail truncation. Лог: CONTEXT_DIET_APPLIED.
|
||||
**Контекст-диета:** см. раздел «Контекст-диета» ниже.
|
||||
|
||||
**Trace:** при `PAPAYU_TRACE=1` в трассу добавляются `context_stats` (context_files_count, context_files_dropped_count, context_total_chars, context_logs_chars, context_truncated_files_count) и `cache_stats` (hits/misses по типам env/logs/read/search, hit_rate).
|
||||
|
||||
### Fix-plan режим (user.output_format)
|
||||
|
||||
@ -128,6 +130,30 @@ LLM должен вернуть **только валидный JSON** — ли
|
||||
|
||||
---
|
||||
|
||||
---
|
||||
|
||||
## Контекст-диета (поведение рантайма)
|
||||
|
||||
Контекст может быть урезан для контроля стоимости токенов и стабильности ответов.
|
||||
|
||||
**Env-переменные лимитов:**
|
||||
| Переменная | По умолчанию | Описание |
|
||||
|------------|--------------|----------|
|
||||
| `PAPAYU_CONTEXT_MAX_FILES` | 8 | Макс. число FILE/SEARCH/LOGS/ENV блоков в FULFILLED_CONTEXT |
|
||||
| `PAPAYU_CONTEXT_MAX_FILE_CHARS` | 20000 | Макс. символов на один файл (read_file) |
|
||||
| `PAPAYU_CONTEXT_MAX_TOTAL_CHARS` | 120000 | Макс. символов всего блока FULFILLED_CONTEXT |
|
||||
| `PAPAYU_CONTEXT_MAX_LOG_CHARS` | 12000 | Резерв для логов (в текущей реализации не используется) |
|
||||
|
||||
**Порядок урезания:** при нехватке budget — search hits, logs; FILE-блоки (запрошенные read_file) — последними; для priority=0 файлов гарантируется минимум 4k chars даже при нехватке total budget.
|
||||
|
||||
**Truncation:** при превышении MAX_FILE_CHARS — head+tail (60/40) с маркером `...[TRUNCATED N chars]...`.
|
||||
|
||||
**Лог:** `CONTEXT_DIET_APPLIED files=N dropped=M truncated=T total_chars=C` при dropped>0 или truncated>0.
|
||||
|
||||
**Trace:** в `context_stats` — context_files_count, context_files_dropped_count, context_total_chars, context_logs_chars, context_truncated_files_count.
|
||||
|
||||
---
|
||||
|
||||
## context_requests (типы запросов)
|
||||
|
||||
| type | Обязательные поля | Описание |
|
||||
|
||||
96
docs/PROTOCOL_V1.md
Normal file
96
docs/PROTOCOL_V1.md
Normal file
@ -0,0 +1,96 @@
|
||||
# Protocol v1 — контракт papa-yu
|
||||
|
||||
Краткий документ (1–2 страницы): что гарантируется, лимиты, логирование, PLAN→APPLY, strict/best-effort.
|
||||
|
||||
---
|
||||
|
||||
## Версионирование
|
||||
|
||||
- **schema_version:** 1
|
||||
- **schema_hash:** sha256 от `llm_response_schema.json` (в trace)
|
||||
- При изменении контракта — увеличивать schema_version; v2 — новый документ.
|
||||
|
||||
---
|
||||
|
||||
## Гарантии
|
||||
|
||||
1. **JSON:** ответ LLM парсится; при неудаче — 1 repair-ретрай с подсказкой.
|
||||
2. **Валидация:** path (no `../`, absolute, `~`), конфликты действий, content (no NUL, pseudo-binary).
|
||||
3. **UPDATE base:** в APPLY каждый UPDATE_FILE — только для файлов, прочитанных в Plan.
|
||||
4. **Protected paths:** `.env`, `*.pem`, `*.key`, `id_rsa*`, `**/secrets/**` — запрещены.
|
||||
5. **Apply:** snapshot → apply → auto_check; при падении check — rollback.
|
||||
|
||||
---
|
||||
|
||||
## Лимиты
|
||||
|
||||
| Область | Переменная | По умолчанию |
|
||||
|---------|------------|--------------|
|
||||
| path_len | — | 240 |
|
||||
| actions | — | 200 |
|
||||
| total_content_bytes | — | 5MB |
|
||||
| context_files | PAPAYU_CONTEXT_MAX_FILES | 8 |
|
||||
| file_chars | PAPAYU_CONTEXT_MAX_FILE_CHARS | 20000 |
|
||||
| context_total | PAPAYU_CONTEXT_MAX_TOTAL_CHARS | 120000 |
|
||||
|
||||
---
|
||||
|
||||
## Логирование
|
||||
|
||||
| Событие | Где |
|
||||
|---------|-----|
|
||||
| LLM_REQUEST_SENT | stderr (model, schema_version, provider, token_budget, input_chars) |
|
||||
| LLM_RESPONSE_OK, LLM_RESPONSE_REPAIR | stderr |
|
||||
| VALIDATION_FAILED | stderr (code, reason) |
|
||||
| CONTEXT_CACHE_HIT, CONTEXT_CACHE_MISS | stderr (key) |
|
||||
| CONTEXT_DIET_APPLIED | stderr (files, dropped, truncated, total_chars) |
|
||||
| APPLY_SUCCESS, APPLY_ROLLBACK, PREVIEW_READY | stderr |
|
||||
|
||||
**Trace (PAPAYU_TRACE=1):** `.papa-yu/traces/<trace_id>.json` — config_snapshot, context_stats, cache_stats, validated_json, schema_version, schema_hash.
|
||||
|
||||
---
|
||||
|
||||
## PLAN → APPLY
|
||||
|
||||
1. **Plan:** `plan: <текст>` или «исправь/почини» → LLM возвращает `actions: []`, `summary`, `context_requests`.
|
||||
2. **Apply:** `apply: <текст>` или «ok»/«применяй» при сохранённом плане → LLM применяет план с тем же context.
|
||||
3. **NO_CHANGES:** при пустом `actions` в APPLY — `summary` обязан начинаться с `NO_CHANGES:`.
|
||||
|
||||
---
|
||||
|
||||
## Strict / best-effort
|
||||
|
||||
- **strict (PAPAYU_LLM_STRICT_JSON=1):** `response_format: json_schema` в API; при ошибке schema — repair-ретрай.
|
||||
- **best-effort:** без response_format; извлечение из ```json ... ```; при ошибке — repair-ретрай.
|
||||
- **Capability detection:** при 4xx/5xx с упоминанием response_format — retry без него.
|
||||
|
||||
---
|
||||
|
||||
## Кеш контекста
|
||||
|
||||
read_file, search, logs, env кешируются в plan-цикле. Ключ Logs: `{source, last_n}` — разные last_n не пересекаются.
|
||||
|
||||
---
|
||||
|
||||
## Контекст-диета
|
||||
|
||||
При превышении лимитов — урезание: search → logs → файлы низкого приоритета; FILE (read_file) — последними; для priority=0 гарантия минимум 4k chars.
|
||||
|
||||
---
|
||||
|
||||
## Provider Compatibility
|
||||
|
||||
| Provider | Endpoint | `response_format: json_schema` | `strict` | OneOf (array/object) | Режим |
|
||||
|----------|----------|--------------------------------:|---------:|---------------------:|-------|
|
||||
| OpenAI | `/v1/chat/completions` | ✅ | ✅ | ✅ | strict + local validate |
|
||||
| OpenAI-compatible (часть) | разные | ⚠️ | ⚠️ | ⚠️ | best-effort + local validate + repair |
|
||||
| Ollama | `/api/chat` | ❌ (часто) | ❌ | ⚠️ | best-effort + local validate + repair |
|
||||
|
||||
**Поведенческие гарантии:**
|
||||
1. Если `response_format` не поддержан провайдером → fallback и лог `LLM_RESPONSE_FORMAT_FALLBACK`.
|
||||
2. Локальная schema validation выполняется всегда (если schema compile ok).
|
||||
3. Repair-ретрай выполняется один раз при невалидном JSON.
|
||||
4. Если после repair невалидно → Err.
|
||||
5. Capability detection: при 4xx/5xx с упоминанием response_format/json_schema → retry без него.
|
||||
|
||||
Ускоряет диагностику: если Ollama/локальная модель «тупит» — выключи `PAPAYU_LLM_STRICT_JSON` или оставь пустым.
|
||||
74
docs/PROTOCOL_V2_PLAN.md
Normal file
74
docs/PROTOCOL_V2_PLAN.md
Normal file
@ -0,0 +1,74 @@
|
||||
# План Protocol v2
|
||||
|
||||
Минимальный набор изменений для v2 — без «воды».
|
||||
|
||||
---
|
||||
|
||||
## 3.1. Главная цель v2
|
||||
|
||||
Снизить риск/стоимость «UPDATE_FILE целиком» и улучшить точность правок:
|
||||
- частичные патчи,
|
||||
- «операции редактирования» вместо полной перезаписи.
|
||||
|
||||
---
|
||||
|
||||
## 3.2. Минимальный набор изменений
|
||||
|
||||
### A) Новый action kind: `PATCH_FILE`
|
||||
|
||||
Вместо полного `content`, передаётся unified diff:
|
||||
|
||||
```json
|
||||
{ "kind": "PATCH_FILE", "path": "src/app.py", "patch": "@@ -1,3 +1,4 @@\n..." }
|
||||
```
|
||||
|
||||
- Валидация патча локально.
|
||||
- Применение патча транзакционно.
|
||||
- Preview diff становится тривиальным.
|
||||
|
||||
### B) Новый action kind: `REPLACE_RANGE`
|
||||
|
||||
Если unified diff сложен:
|
||||
|
||||
```json
|
||||
{
|
||||
"kind": "REPLACE_RANGE",
|
||||
"path": "src/app.py",
|
||||
"start_line": 120,
|
||||
"end_line": 180,
|
||||
"content": "новый блок"
|
||||
}
|
||||
```
|
||||
|
||||
Плюсы: проще валидировать. Минусы: зависит от line numbers (хрупко при изменениях).
|
||||
|
||||
### C) «Base hash» для UPDATE/PATCH
|
||||
|
||||
Исключить race (файл изменился между plan/apply):
|
||||
|
||||
```json
|
||||
{ "kind": "PATCH_FILE", "path": "...", "base_sha256": "...", "patch": "..." }
|
||||
```
|
||||
|
||||
Если hash не совпал → Err и переход в PLAN.
|
||||
|
||||
---
|
||||
|
||||
## 3.3. Совместимость v1/v2
|
||||
|
||||
- `schema_version=1` → нынешний формат (UPDATE_FILE, CREATE_FILE, …).
|
||||
- `schema_version=2` → допускает `PATCH_FILE` / `REPLACE_RANGE` и расширенные поля.
|
||||
|
||||
В коде:
|
||||
- Компилировать обе схемы: `llm_response_schema_v1.json`, `llm_response_schema_v2.json`.
|
||||
- Выбор активной по env: `PAPAYU_PROTOCOL_VERSION=1|2`.
|
||||
- Валидация/парсер: сначала проверить schema v2 (если включена), иначе v1.
|
||||
|
||||
---
|
||||
|
||||
## 3.4. Порядок внедрения v2 без риска
|
||||
|
||||
1. Добавить v2 schema + валидаторы + apply engine, **не включая по умолчанию**.
|
||||
2. Добавить «LLM prompt v2» (рекомендовать PATCH_FILE вместо UPDATE_FILE).
|
||||
3. Прогнать на своих проектах и собрать golden traces v2.
|
||||
4. Когда стабильно — сделать v2 дефолтом, сохранив совместимость v1.
|
||||
56
docs/golden_traces/README.md
Normal file
56
docs/golden_traces/README.md
Normal file
@ -0,0 +1,56 @@
|
||||
# Golden traces — эталонные артефакты
|
||||
|
||||
Фиксируют детерминированные результаты papa-yu без зависимости от LLM.
|
||||
Позволяют ловить регрессии в валидации, парсинге, диете, кеше.
|
||||
|
||||
## Структура
|
||||
|
||||
```
|
||||
docs/golden_traces/
|
||||
README.md
|
||||
v1/ # Protocol v1 fixtures
|
||||
001_fix_bug_plan.json
|
||||
002_fix_bug_apply.json
|
||||
...
|
||||
```
|
||||
|
||||
## Формат fixture (без секретов)
|
||||
|
||||
Минимальный стабильный JSON:
|
||||
- `protocol` — schema_version, schema_hash
|
||||
- `request` — mode, input_chars, token_budget, strict_json, provider, model
|
||||
- `context` — context_digest (опц.), context_stats, cache_stats
|
||||
- `result` — validated_json (объект), validation_outcome, error_code
|
||||
|
||||
Без raw_content, без секретов.
|
||||
|
||||
## Генерация из трасс
|
||||
|
||||
```bash
|
||||
cd src-tauri
|
||||
cargo run --bin trace_to_golden -- <trace_id> [output_path]
|
||||
cargo run --bin trace_to_golden -- <path/to/trace.json> [output_path]
|
||||
```
|
||||
|
||||
Читает trace из `.papa-yu/traces/<trace_id>.json` или из файла. Пишет в `docs/golden_traces/v1/`.
|
||||
|
||||
## Регрессионный тест
|
||||
|
||||
```bash
|
||||
cargo test golden_traces_v1_validate
|
||||
# или
|
||||
make test-protocol
|
||||
npm run test-protocol
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Политика обновления golden traces
|
||||
|
||||
**Когда обновлять:** только при намеренном изменении протокола или валидатора (path/content/conflicts, schema, диета).
|
||||
|
||||
**Как обновлять:** `trace_to_golden` — `make golden` (из последней трассы) или `make golden TRACE_ID=<id>`.
|
||||
|
||||
**Как добавлять новый сценарий:** выполни propose с PAPAYU_TRACE=1, затем `make golden` и сохрани вывод в `v1/NNN_<name>.json` с номером NNN.
|
||||
|
||||
**При смене schema_hash:** либо bump schema_version (новый документ v2), либо обнови все fixtures (`trace_to_golden` на свежие трассы) и зафиксируй в CHANGELOG.
|
||||
43
docs/golden_traces/v1/001_fix_bug_plan.json
Normal file
43
docs/golden_traces/v1/001_fix_bug_plan.json
Normal file
@ -0,0 +1,43 @@
|
||||
{
|
||||
"protocol": {
|
||||
"schema_version": 1,
|
||||
"schema_hash": "4410694d339f9c7b81566da48aabd498ae9d15a229d1d69b10fe7a2eb8d96e7e"
|
||||
},
|
||||
"request": {
|
||||
"mode": "plan",
|
||||
"input_chars": 12000,
|
||||
"token_budget": 4096,
|
||||
"strict_json": true,
|
||||
"provider": "openai",
|
||||
"model": "gpt-4o-mini"
|
||||
},
|
||||
"context": {
|
||||
"context_stats": {
|
||||
"context_files_count": 1,
|
||||
"context_files_dropped_count": 0,
|
||||
"context_total_chars": 1500,
|
||||
"context_logs_chars": 0,
|
||||
"context_truncated_files_count": 0
|
||||
},
|
||||
"cache_stats": {
|
||||
"env_hits": 0,
|
||||
"env_misses": 1,
|
||||
"logs_hits": 0,
|
||||
"logs_misses": 0,
|
||||
"read_hits": 0,
|
||||
"read_misses": 1,
|
||||
"search_hits": 0,
|
||||
"search_misses": 0,
|
||||
"hit_rate": 0.0
|
||||
}
|
||||
},
|
||||
"result": {
|
||||
"validated_json": {
|
||||
"actions": [],
|
||||
"summary": "Диагноз: ошибка в main. План: заменить println! аргумент.",
|
||||
"context_requests": [{"type": "read_file", "path": "src/main.rs"}]
|
||||
},
|
||||
"validation_outcome": "ok",
|
||||
"error_code": null
|
||||
}
|
||||
}
|
||||
48
docs/golden_traces/v1/002_fix_bug_apply.json
Normal file
48
docs/golden_traces/v1/002_fix_bug_apply.json
Normal file
@ -0,0 +1,48 @@
|
||||
{
|
||||
"protocol": {
|
||||
"schema_version": 1,
|
||||
"schema_hash": "4410694d339f9c7b81566da48aabd498ae9d15a229d1d69b10fe7a2eb8d96e7e"
|
||||
},
|
||||
"request": {
|
||||
"mode": "apply",
|
||||
"input_chars": 15000,
|
||||
"token_budget": 4096,
|
||||
"strict_json": true,
|
||||
"provider": "openai",
|
||||
"model": "gpt-4o-mini"
|
||||
},
|
||||
"context": {
|
||||
"context_stats": {
|
||||
"context_files_count": 1,
|
||||
"context_files_dropped_count": 0,
|
||||
"context_total_chars": 1800,
|
||||
"context_logs_chars": 0,
|
||||
"context_truncated_files_count": 0
|
||||
},
|
||||
"cache_stats": {
|
||||
"env_hits": 0,
|
||||
"env_misses": 1,
|
||||
"logs_hits": 0,
|
||||
"logs_misses": 0,
|
||||
"read_hits": 1,
|
||||
"read_misses": 0,
|
||||
"search_hits": 0,
|
||||
"search_misses": 0,
|
||||
"hit_rate": 0.5
|
||||
}
|
||||
},
|
||||
"result": {
|
||||
"validated_json": {
|
||||
"actions": [
|
||||
{
|
||||
"kind": "UPDATE_FILE",
|
||||
"path": "src/main.rs",
|
||||
"content": "fn main() {\n println!(\"fix\");\n}\n"
|
||||
}
|
||||
],
|
||||
"summary": "Исправлена функция main."
|
||||
},
|
||||
"validation_outcome": "ok",
|
||||
"error_code": null
|
||||
}
|
||||
}
|
||||
45
docs/golden_traces/v1/003_generate_project_apply.json
Normal file
45
docs/golden_traces/v1/003_generate_project_apply.json
Normal file
@ -0,0 +1,45 @@
|
||||
{
|
||||
"protocol": {
|
||||
"schema_version": 1,
|
||||
"schema_hash": "4410694d339f9c7b81566da48aabd498ae9d15a229d1d69b10fe7a2eb8d96e7e"
|
||||
},
|
||||
"request": {
|
||||
"mode": "apply",
|
||||
"input_chars": 8000,
|
||||
"token_budget": 4096,
|
||||
"strict_json": true,
|
||||
"provider": "openai",
|
||||
"model": "gpt-4o-mini"
|
||||
},
|
||||
"context": {
|
||||
"context_stats": {
|
||||
"context_files_count": 0,
|
||||
"context_files_dropped_count": 0,
|
||||
"context_total_chars": 800,
|
||||
"context_logs_chars": 0,
|
||||
"context_truncated_files_count": 0
|
||||
},
|
||||
"cache_stats": {
|
||||
"env_hits": 1,
|
||||
"env_misses": 0,
|
||||
"logs_hits": 0,
|
||||
"logs_misses": 0,
|
||||
"read_hits": 0,
|
||||
"read_misses": 0,
|
||||
"search_hits": 0,
|
||||
"search_misses": 0,
|
||||
"hit_rate": 0.5
|
||||
}
|
||||
},
|
||||
"result": {
|
||||
"validated_json": {
|
||||
"actions": [
|
||||
{"kind": "CREATE_DIR", "path": "src"},
|
||||
{"kind": "CREATE_FILE", "path": "README.md", "content": "# Project\n\n## Run\n\n`cargo run`\n"}
|
||||
],
|
||||
"summary": "Созданы папка src и README."
|
||||
},
|
||||
"validation_outcome": "ok",
|
||||
"error_code": null
|
||||
}
|
||||
}
|
||||
44
docs/golden_traces/v1/004_protected_path_block.json
Normal file
44
docs/golden_traces/v1/004_protected_path_block.json
Normal file
@ -0,0 +1,44 @@
|
||||
{
|
||||
"protocol": {
|
||||
"schema_version": 1,
|
||||
"schema_hash": "4410694d339f9c7b81566da48aabd498ae9d15a229d1d69b10fe7a2eb8d96e7e"
|
||||
},
|
||||
"request": {
|
||||
"mode": "apply",
|
||||
"input_chars": 5000,
|
||||
"token_budget": 4096,
|
||||
"strict_json": false,
|
||||
"provider": "ollama",
|
||||
"model": "llama3.2"
|
||||
},
|
||||
"context": {
|
||||
"context_stats": {
|
||||
"context_files_count": 0,
|
||||
"context_files_dropped_count": 0,
|
||||
"context_total_chars": 500,
|
||||
"context_logs_chars": 0,
|
||||
"context_truncated_files_count": 0
|
||||
},
|
||||
"cache_stats": {
|
||||
"env_hits": 0,
|
||||
"env_misses": 1,
|
||||
"logs_hits": 0,
|
||||
"logs_misses": 0,
|
||||
"read_hits": 0,
|
||||
"read_misses": 0,
|
||||
"search_hits": 0,
|
||||
"search_misses": 0,
|
||||
"hit_rate": 0.0
|
||||
}
|
||||
},
|
||||
"result": {
|
||||
"validated_json": {
|
||||
"actions": [
|
||||
{"kind": "UPDATE_FILE", "path": ".env", "content": "FOO=bar\n"}
|
||||
],
|
||||
"summary": "Updated .env"
|
||||
},
|
||||
"validation_outcome": "err",
|
||||
"error_code": "protected or non-text file: .env"
|
||||
}
|
||||
}
|
||||
44
docs/golden_traces/v1/005_update_without_base_block.json
Normal file
44
docs/golden_traces/v1/005_update_without_base_block.json
Normal file
@ -0,0 +1,44 @@
|
||||
{
|
||||
"protocol": {
|
||||
"schema_version": 1,
|
||||
"schema_hash": "4410694d339f9c7b81566da48aabd498ae9d15a229d1d69b10fe7a2eb8d96e7e"
|
||||
},
|
||||
"request": {
|
||||
"mode": "apply",
|
||||
"input_chars": 10000,
|
||||
"token_budget": 4096,
|
||||
"strict_json": true,
|
||||
"provider": "openai",
|
||||
"model": "gpt-4o-mini"
|
||||
},
|
||||
"context": {
|
||||
"context_stats": {
|
||||
"context_files_count": 1,
|
||||
"context_files_dropped_count": 0,
|
||||
"context_total_chars": 2000,
|
||||
"context_logs_chars": 0,
|
||||
"context_truncated_files_count": 0
|
||||
},
|
||||
"cache_stats": {
|
||||
"env_hits": 0,
|
||||
"env_misses": 1,
|
||||
"logs_hits": 0,
|
||||
"logs_misses": 0,
|
||||
"read_hits": 1,
|
||||
"read_misses": 0,
|
||||
"search_hits": 0,
|
||||
"search_misses": 0,
|
||||
"hit_rate": 0.5
|
||||
}
|
||||
},
|
||||
"result": {
|
||||
"validated_json": {
|
||||
"actions": [
|
||||
{"kind": "UPDATE_FILE", "path": "src/secret.rs", "content": "// changed"}
|
||||
],
|
||||
"summary": "Updated"
|
||||
},
|
||||
"validation_outcome": "err",
|
||||
"error_code": "ERR_UPDATE_WITHOUT_BASE: UPDATE_FILE path 'src/secret.rs' not read in plan"
|
||||
}
|
||||
}
|
||||
43
docs/golden_traces/v1/006_context_diet_applied.json
Normal file
43
docs/golden_traces/v1/006_context_diet_applied.json
Normal file
@ -0,0 +1,43 @@
|
||||
{
|
||||
"protocol": {
|
||||
"schema_version": 1,
|
||||
"schema_hash": "4410694d339f9c7b81566da48aabd498ae9d15a229d1d69b10fe7a2eb8d96e7e"
|
||||
},
|
||||
"request": {
|
||||
"mode": "plan",
|
||||
"input_chars": 100000,
|
||||
"token_budget": 4096,
|
||||
"strict_json": true,
|
||||
"provider": "openai",
|
||||
"model": "gpt-4o-mini"
|
||||
},
|
||||
"context": {
|
||||
"context_stats": {
|
||||
"context_files_count": 6,
|
||||
"context_files_dropped_count": 3,
|
||||
"context_total_chars": 118000,
|
||||
"context_logs_chars": 5000,
|
||||
"context_truncated_files_count": 2
|
||||
},
|
||||
"cache_stats": {
|
||||
"env_hits": 1,
|
||||
"env_misses": 0,
|
||||
"logs_hits": 0,
|
||||
"logs_misses": 1,
|
||||
"read_hits": 2,
|
||||
"read_misses": 4,
|
||||
"search_hits": 1,
|
||||
"search_misses": 1,
|
||||
"hit_rate": 0.4444444444444444
|
||||
}
|
||||
},
|
||||
"result": {
|
||||
"validated_json": {
|
||||
"actions": [],
|
||||
"summary": "Диагноз: требуется больше контекста.",
|
||||
"context_requests": []
|
||||
},
|
||||
"validation_outcome": "ok",
|
||||
"error_code": null
|
||||
}
|
||||
}
|
||||
42
docs/golden_traces/v1/007_no_changes_apply.json
Normal file
42
docs/golden_traces/v1/007_no_changes_apply.json
Normal file
@ -0,0 +1,42 @@
|
||||
{
|
||||
"protocol": {
|
||||
"schema_version": 1,
|
||||
"schema_hash": "4410694d339f9c7b81566da48aabd498ae9d15a229d1d69b10fe7a2eb8d96e7e"
|
||||
},
|
||||
"request": {
|
||||
"mode": "apply",
|
||||
"input_chars": 5000,
|
||||
"token_budget": 4096,
|
||||
"strict_json": true,
|
||||
"provider": "openai",
|
||||
"model": "gpt-4o-mini"
|
||||
},
|
||||
"context": {
|
||||
"context_stats": {
|
||||
"context_files_count": 1,
|
||||
"context_files_dropped_count": 0,
|
||||
"context_total_chars": 1000,
|
||||
"context_logs_chars": 0,
|
||||
"context_truncated_files_count": 0
|
||||
},
|
||||
"cache_stats": {
|
||||
"env_hits": 0,
|
||||
"env_misses": 1,
|
||||
"logs_hits": 0,
|
||||
"logs_misses": 0,
|
||||
"read_hits": 0,
|
||||
"read_misses": 1,
|
||||
"search_hits": 0,
|
||||
"search_misses": 0,
|
||||
"hit_rate": 0.0
|
||||
}
|
||||
},
|
||||
"result": {
|
||||
"validated_json": {
|
||||
"actions": [],
|
||||
"summary": "NO_CHANGES: Проверка завершена, правок не требуется."
|
||||
},
|
||||
"validation_outcome": "ok",
|
||||
"error_code": null
|
||||
}
|
||||
}
|
||||
@ -7,7 +7,9 @@
|
||||
"build": "tsc -b && vite build",
|
||||
"preview": "vite preview",
|
||||
"tauri": "tauri",
|
||||
"icons:export": "node scripts/export-icon.js"
|
||||
"icons:export": "node scripts/export-icon.js",
|
||||
"golden": "cd src-tauri && cargo run --bin trace_to_golden --",
|
||||
"test-protocol": "cd src-tauri && cargo test golden_traces_v1_validate"
|
||||
},
|
||||
"dependencies": {
|
||||
"@tauri-apps/api": "^2.0.0",
|
||||
|
||||
118
src-tauri/src/bin/trace_to_golden.rs
Normal file
118
src-tauri/src/bin/trace_to_golden.rs
Normal file
@ -0,0 +1,118 @@
|
||||
//! Преобразует trace из .papa-yu/traces/<trace_id>.json в golden fixture.
|
||||
//!
|
||||
//! Использование:
|
||||
//! cargo run --bin trace_to_golden -- <trace_id> [output_path]
|
||||
//! cargo run --bin trace_to_golden -- <path/to/trace.json> [output_path]
|
||||
|
||||
use sha2::{Digest, Sha256};
|
||||
use std::env;
|
||||
use std::fs;
|
||||
use std::path::Path;
|
||||
|
||||
fn schema_hash() -> String {
|
||||
let schema_raw = include_str!("../../config/llm_response_schema.json");
|
||||
let mut hasher = Sha256::new();
|
||||
hasher.update(schema_raw.as_bytes());
|
||||
format!("{:x}", hasher.finalize())
|
||||
}
|
||||
|
||||
fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
let args: Vec<String> = env::args().collect();
|
||||
if args.len() < 2 {
|
||||
eprintln!("Usage: trace_to_golden <trace_id|path/to/trace.json> [output_path]");
|
||||
std::process::exit(1);
|
||||
}
|
||||
let input = &args[1];
|
||||
let output = args.get(2).map(|s| s.as_str());
|
||||
|
||||
let content = if Path::new(input).is_file() {
|
||||
fs::read_to_string(input)?
|
||||
} else {
|
||||
let manifest_dir = env::var("CARGO_MANIFEST_DIR").unwrap_or_else(|_| ".".into());
|
||||
let trace_path = Path::new(&manifest_dir)
|
||||
.join("../.papa-yu/traces")
|
||||
.join(format!("{}.json", input));
|
||||
fs::read_to_string(&trace_path)
|
||||
.map_err(|e| format!("read {}: {}", trace_path.display(), e))?
|
||||
};
|
||||
|
||||
let trace: serde_json::Value = serde_json::from_str(&content)?;
|
||||
let golden = trace_to_golden_format(&trace)?;
|
||||
let out_json = serde_json::to_string_pretty(&golden)?;
|
||||
|
||||
let out_path = match output {
|
||||
Some(p) => p.to_string(),
|
||||
None => {
|
||||
let manifest_dir = env::var("CARGO_MANIFEST_DIR").unwrap_or_else(|_| ".".into());
|
||||
let name = trace.get("trace_id").and_then(|v| v.as_str()).unwrap_or("out");
|
||||
format!(
|
||||
"{}/../docs/golden_traces/v1/{}_golden.json",
|
||||
manifest_dir, name
|
||||
)
|
||||
}
|
||||
};
|
||||
fs::create_dir_all(Path::new(&out_path).parent().unwrap_or(Path::new(".")))?;
|
||||
fs::write(&out_path, out_json)?;
|
||||
println!("Written: {}", out_path);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn trace_to_golden_format(trace: &serde_json::Value) -> Result<serde_json::Value, Box<dyn std::error::Error>> {
|
||||
let schema_version = trace
|
||||
.get("schema_version")
|
||||
.or_else(|| trace.get("config_snapshot").and_then(|c| c.get("schema_version")))
|
||||
.cloned()
|
||||
.unwrap_or(serde_json::json!(1));
|
||||
let schema_hash_val = trace
|
||||
.get("schema_hash")
|
||||
.or_else(|| trace.get("config_snapshot").and_then(|c| c.get("schema_hash")))
|
||||
.cloned()
|
||||
.unwrap_or_else(|| serde_json::Value::String(schema_hash()));
|
||||
|
||||
let validated = trace.get("validated_json").cloned();
|
||||
let validated_obj = validated
|
||||
.as_ref()
|
||||
.and_then(|v| v.as_str())
|
||||
.and_then(|s| serde_json::from_str(s).ok())
|
||||
.or_else(|| validated.clone())
|
||||
.unwrap_or(serde_json::Value::Null);
|
||||
|
||||
let config = trace.get("config_snapshot").and_then(|c| c.as_object());
|
||||
let strict_json = config
|
||||
.and_then(|c| c.get("strict_json"))
|
||||
.and_then(|v| v.as_str())
|
||||
.map(|s| !s.is_empty() && matches!(s.to_lowercase().as_str(), "1" | "true" | "yes"))
|
||||
.unwrap_or(false);
|
||||
|
||||
let validation_outcome = if trace.get("event").and_then(|v| v.as_str()) == Some("VALIDATION_FAILED") {
|
||||
"err"
|
||||
} else {
|
||||
"ok"
|
||||
};
|
||||
let error_code = trace.get("error").and_then(|v| v.as_str()).map(String::from);
|
||||
|
||||
let golden = serde_json::json!({
|
||||
"protocol": {
|
||||
"schema_version": schema_version,
|
||||
"schema_hash": schema_hash_val
|
||||
},
|
||||
"request": {
|
||||
"mode": trace.get("mode").unwrap_or(&serde_json::Value::Null).clone(),
|
||||
"input_chars": trace.get("input_chars").unwrap_or(&serde_json::Value::Null).clone(),
|
||||
"token_budget": config.and_then(|c| c.get("max_tokens")).unwrap_or(&serde_json::Value::Null).clone(),
|
||||
"strict_json": strict_json,
|
||||
"provider": trace.get("provider").unwrap_or(&serde_json::Value::Null).clone(),
|
||||
"model": trace.get("model").unwrap_or(&serde_json::Value::Null).clone()
|
||||
},
|
||||
"context": {
|
||||
"context_stats": trace.get("context_stats").cloned().unwrap_or(serde_json::Value::Null),
|
||||
"cache_stats": trace.get("cache_stats").cloned().unwrap_or(serde_json::Value::Null)
|
||||
},
|
||||
"result": {
|
||||
"validated_json": validated_obj,
|
||||
"validation_outcome": validation_outcome,
|
||||
"error_code": error_code
|
||||
}
|
||||
});
|
||||
Ok(golden)
|
||||
}
|
||||
@ -861,6 +861,7 @@ pub async fn plan(
|
||||
let mut repair_done = false;
|
||||
let mut skip_response_format = false; // capability detection: fallback при ошибке response_format
|
||||
let mut context_cache = context::ContextCache::new();
|
||||
let mut last_context_stats: Option<context::ContextStats> = None;
|
||||
|
||||
let (last_actions, last_summary_override, last_plan_json, last_context_for_return) = loop {
|
||||
let effective_response_format = if skip_response_format {
|
||||
@ -1059,7 +1060,8 @@ pub async fn plan(
|
||||
Some(&mut context_cache),
|
||||
Some(&trace_id),
|
||||
);
|
||||
user_message.push_str(&fulfilled);
|
||||
last_context_stats = Some(fulfilled.context_stats);
|
||||
user_message.push_str(&fulfilled.content);
|
||||
round += 1;
|
||||
continue;
|
||||
}
|
||||
@ -1102,6 +1104,27 @@ pub async fn plan(
|
||||
"actions_count": last_actions.len(),
|
||||
"validated_json": last_plan_json,
|
||||
});
|
||||
if let Some(ref cs) = last_context_stats {
|
||||
trace_val["context_stats"] = serde_json::json!({
|
||||
"context_files_count": cs.context_files_count,
|
||||
"context_files_dropped_count": cs.context_files_dropped_count,
|
||||
"context_total_chars": cs.context_total_chars,
|
||||
"context_logs_chars": cs.context_logs_chars,
|
||||
"context_truncated_files_count": cs.context_truncated_files_count,
|
||||
});
|
||||
}
|
||||
let cache_stats = context_cache.stats();
|
||||
trace_val["cache_stats"] = serde_json::json!({
|
||||
"env_hits": cache_stats.env_hits,
|
||||
"env_misses": cache_stats.env_misses,
|
||||
"logs_hits": cache_stats.logs_hits,
|
||||
"logs_misses": cache_stats.logs_misses,
|
||||
"read_hits": cache_stats.read_hits,
|
||||
"read_misses": cache_stats.read_misses,
|
||||
"search_hits": cache_stats.search_hits,
|
||||
"search_misses": cache_stats.search_misses,
|
||||
"hit_rate": cache_stats.hit_rate(),
|
||||
});
|
||||
write_trace(path, &trace_id, &mut trace_val);
|
||||
|
||||
Ok(AgentPlan {
|
||||
@ -1123,6 +1146,8 @@ mod tests {
|
||||
validate_update_without_base, FIX_PLAN_SYSTEM_PROMPT, LLM_PLAN_SCHEMA_VERSION,
|
||||
};
|
||||
use crate::types::{Action, ActionKind};
|
||||
use std::fs;
|
||||
use std::path::Path;
|
||||
|
||||
#[test]
|
||||
fn test_schema_version_is_one() {
|
||||
@ -1349,4 +1374,97 @@ mod tests {
|
||||
assert_eq!(actions.len(), 1);
|
||||
assert_eq!(actions[0].path, "src");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn golden_traces_v1_validate() {
|
||||
let dir = Path::new(env!("CARGO_MANIFEST_DIR")).join("../../docs/golden_traces/v1");
|
||||
if !dir.exists() {
|
||||
return;
|
||||
}
|
||||
let expected_schema_hash = schema_hash();
|
||||
for entry in fs::read_dir(&dir).unwrap() {
|
||||
let path = entry.unwrap().path();
|
||||
if path.extension().and_then(|s| s.to_str()) != Some("json") {
|
||||
continue;
|
||||
}
|
||||
let name = path.file_name().unwrap().to_string_lossy();
|
||||
let s = fs::read_to_string(&path).unwrap_or_else(|_| panic!("read {}", name));
|
||||
let v: serde_json::Value =
|
||||
serde_json::from_str(&s).unwrap_or_else(|e| panic!("{}: json {}", name, e));
|
||||
|
||||
assert_eq!(
|
||||
v.get("protocol")
|
||||
.and_then(|p| p.get("schema_version"))
|
||||
.and_then(|x| x.as_u64()),
|
||||
Some(1),
|
||||
"{}: schema_version",
|
||||
name
|
||||
);
|
||||
let sh = v.get("protocol")
|
||||
.and_then(|p| p.get("schema_hash"))
|
||||
.and_then(|x| x.as_str())
|
||||
.unwrap_or("");
|
||||
assert_eq!(sh, expected_schema_hash, "{}: schema_hash", name);
|
||||
|
||||
let validated = v.get("result")
|
||||
.and_then(|r| r.get("validated_json"))
|
||||
.cloned()
|
||||
.unwrap_or(serde_json::Value::Null);
|
||||
if validated.is_null() {
|
||||
continue;
|
||||
}
|
||||
super::validate_json_against_schema(&validated).unwrap_or_else(|e| {
|
||||
panic!("{}: schema validation: {}", name, e)
|
||||
});
|
||||
|
||||
let validated_str = serde_json::to_string(&validated).unwrap();
|
||||
let parsed = super::parse_plan_response(&validated_str)
|
||||
.unwrap_or_else(|e| panic!("{}: parse validated_json: {}", name, e));
|
||||
|
||||
if v.get("result")
|
||||
.and_then(|r| r.get("validation_outcome"))
|
||||
.and_then(|x| x.as_str())
|
||||
== Some("ok")
|
||||
{
|
||||
assert!(
|
||||
validate_actions(&parsed.actions).is_ok(),
|
||||
"{}: validate_actions",
|
||||
name
|
||||
);
|
||||
}
|
||||
|
||||
let mode = v.get("request")
|
||||
.and_then(|r| r.get("mode"))
|
||||
.and_then(|x| x.as_str())
|
||||
.unwrap_or("");
|
||||
if mode == "apply" && parsed.actions.is_empty() {
|
||||
let summary = validated
|
||||
.get("summary")
|
||||
.and_then(|x| x.as_str())
|
||||
.unwrap_or("");
|
||||
assert!(
|
||||
summary.starts_with("NO_CHANGES:"),
|
||||
"{}: apply with empty actions requires NO_CHANGES: prefix in summary",
|
||||
name
|
||||
);
|
||||
}
|
||||
|
||||
let ctx_stats = v.get("context").and_then(|c| c.get("context_stats"));
|
||||
let cache_stats = v.get("context").and_then(|c| c.get("cache_stats"));
|
||||
if let Some(stats) = ctx_stats {
|
||||
for key in ["context_files_count", "context_total_chars"] {
|
||||
if let Some(n) = stats.get(key).and_then(|x| x.as_u64()) {
|
||||
assert!(n <= 1_000_000, "{}: {} reasonable", name, key);
|
||||
}
|
||||
}
|
||||
}
|
||||
if let Some(stats) = cache_stats {
|
||||
for key in ["env_hits", "env_misses", "read_hits", "read_misses"] {
|
||||
if let Some(n) = stats.get(key).and_then(|x| x.as_u64()) {
|
||||
assert!(n <= 1_000_000, "{}: cache {} reasonable", name, key);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -47,16 +47,58 @@ pub enum ContextCacheKey {
|
||||
Search { query: String, glob: Option<String> },
|
||||
}
|
||||
|
||||
/// Статистика кеша (hits/misses по типам).
|
||||
#[derive(Default, Clone, Debug)]
|
||||
pub struct CacheStats {
|
||||
pub env_hits: u32,
|
||||
pub env_misses: u32,
|
||||
pub logs_hits: u32,
|
||||
pub logs_misses: u32,
|
||||
pub read_hits: u32,
|
||||
pub read_misses: u32,
|
||||
pub search_hits: u32,
|
||||
pub search_misses: u32,
|
||||
}
|
||||
|
||||
impl CacheStats {
|
||||
pub fn total_hits(&self) -> u32 {
|
||||
self.env_hits + self.logs_hits + self.read_hits + self.search_hits
|
||||
}
|
||||
pub fn total_misses(&self) -> u32 {
|
||||
self.env_misses + self.logs_misses + self.read_misses + self.search_misses
|
||||
}
|
||||
pub fn hit_rate(&self) -> f64 {
|
||||
let t = self.total_hits() + self.total_misses();
|
||||
if t == 0 {
|
||||
0.0
|
||||
} else {
|
||||
self.total_hits() as f64 / t as f64
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Статистика контекста (диета).
|
||||
#[derive(Default, Clone, Debug)]
|
||||
pub struct ContextStats {
|
||||
pub context_files_count: u32,
|
||||
pub context_files_dropped_count: u32,
|
||||
pub context_total_chars: usize,
|
||||
pub context_logs_chars: usize,
|
||||
pub context_truncated_files_count: u32,
|
||||
}
|
||||
|
||||
/// Кеш контекста для сессии (plan-цикла).
|
||||
#[derive(Default)]
|
||||
pub struct ContextCache {
|
||||
map: HashMap<ContextCacheKey, String>,
|
||||
pub cache_stats: CacheStats,
|
||||
}
|
||||
|
||||
impl ContextCache {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
map: HashMap::new(),
|
||||
cache_stats: CacheStats::default(),
|
||||
}
|
||||
}
|
||||
|
||||
@ -67,6 +109,10 @@ impl ContextCache {
|
||||
pub fn put(&mut self, key: ContextCacheKey, value: String) {
|
||||
self.map.insert(key, value);
|
||||
}
|
||||
|
||||
pub fn stats(&self) -> &CacheStats {
|
||||
&self.cache_stats
|
||||
}
|
||||
}
|
||||
|
||||
/// Собирает базовый контекст перед первым запросом к модели: env, команды из project prefs.
|
||||
@ -130,6 +176,12 @@ fn gather_env() -> String {
|
||||
lines.join("\n")
|
||||
}
|
||||
|
||||
/// Результат fulfill_context_requests: текст + статистика контекста.
|
||||
pub struct FulfillResult {
|
||||
pub content: String,
|
||||
pub context_stats: ContextStats,
|
||||
}
|
||||
|
||||
/// Выполняет context_requests от модели и возвращает текст для добавления в user message.
|
||||
/// Использует кеш, если передан; логирует CONTEXT_CACHE_HIT/MISS при trace_id.
|
||||
pub fn fulfill_context_requests(
|
||||
@ -138,8 +190,9 @@ pub fn fulfill_context_requests(
|
||||
max_log_lines: usize,
|
||||
mut cache: Option<&mut ContextCache>,
|
||||
trace_id: Option<&str>,
|
||||
) -> String {
|
||||
) -> FulfillResult {
|
||||
let mut parts = Vec::new();
|
||||
let mut logs_chars: usize = 0;
|
||||
for r in requests {
|
||||
let obj = match r.as_object() {
|
||||
Some(o) => o,
|
||||
@ -160,12 +213,15 @@ pub fn fulfill_context_requests(
|
||||
end,
|
||||
};
|
||||
let content = if let Some(ref mut c) = cache {
|
||||
if let Some(v) = c.get(&key) {
|
||||
let hit = c.get(&key).map(|v| v.clone());
|
||||
if let Some(v) = hit {
|
||||
c.cache_stats.read_hits += 1;
|
||||
if let Some(tid) = trace_id {
|
||||
eprintln!("[{}] CONTEXT_CACHE_HIT key=read_file path={}", tid, path);
|
||||
}
|
||||
v.clone()
|
||||
v
|
||||
} else {
|
||||
c.cache_stats.read_misses += 1;
|
||||
let v = read_file_snippet(project_root, path, start as usize, end as usize);
|
||||
let out = format!("FILE[{}]:\n{}", path, v);
|
||||
if let Some(tid) = trace_id {
|
||||
@ -189,12 +245,15 @@ pub fn fulfill_context_requests(
|
||||
glob: glob.clone(),
|
||||
};
|
||||
let content = if let Some(ref mut c) = cache {
|
||||
if let Some(v) = c.get(&key) {
|
||||
let hit = c.get(&key).map(|v| v.clone());
|
||||
if let Some(v) = hit {
|
||||
c.cache_stats.search_hits += 1;
|
||||
if let Some(tid) = trace_id {
|
||||
eprintln!("[{}] CONTEXT_CACHE_HIT key=search query={}", tid, query);
|
||||
}
|
||||
v.clone()
|
||||
v
|
||||
} else {
|
||||
c.cache_stats.search_misses += 1;
|
||||
let hits = search_in_project(project_root, query, glob.as_deref());
|
||||
let out = format!("SEARCH[{}]:\n{}", query, hits.join("\n"));
|
||||
if let Some(tid) = trace_id {
|
||||
@ -220,14 +279,17 @@ pub fn fulfill_context_requests(
|
||||
source: source.to_string(),
|
||||
last_n,
|
||||
};
|
||||
let content = if let Some(ref mut c) = cache {
|
||||
if let Some(v) = c.get(&key) {
|
||||
if let Some(tid) = trace_id {
|
||||
eprintln!("[{}] CONTEXT_CACHE_HIT key=logs source={}", tid, source);
|
||||
}
|
||||
v.clone()
|
||||
} else {
|
||||
let v = format!(
|
||||
let content = if let Some(ref mut c) = cache {
|
||||
let hit = c.get(&key).map(|v| v.clone());
|
||||
if let Some(v) = hit {
|
||||
c.cache_stats.logs_hits += 1;
|
||||
if let Some(tid) = trace_id {
|
||||
eprintln!("[{}] CONTEXT_CACHE_HIT key=logs source={}", tid, source);
|
||||
}
|
||||
v
|
||||
} else {
|
||||
c.cache_stats.logs_misses += 1;
|
||||
let v = format!(
|
||||
"LOGS[{}]: (last_n={}; приложение не имеет доступа к логам runtime — передай вывод в запросе)\n",
|
||||
source, last_n
|
||||
);
|
||||
@ -243,18 +305,22 @@ pub fn fulfill_context_requests(
|
||||
source, last_n
|
||||
)
|
||||
};
|
||||
logs_chars += content.len();
|
||||
parts.push(content);
|
||||
}
|
||||
"env" => {
|
||||
let key = ContextCacheKey::Env;
|
||||
let content = if let Some(ref mut c) = cache {
|
||||
if let Some(v) = c.get(&key) {
|
||||
if let Some(tid) = trace_id {
|
||||
eprintln!("[{}] CONTEXT_CACHE_HIT key=env", tid);
|
||||
}
|
||||
v.clone()
|
||||
} else {
|
||||
let v = format!("ENV (повторно):\n{}", gather_env());
|
||||
let content = if let Some(ref mut c) = cache {
|
||||
let hit = c.get(&key).map(|v| v.clone());
|
||||
if let Some(v) = hit {
|
||||
c.cache_stats.env_hits += 1;
|
||||
if let Some(tid) = trace_id {
|
||||
eprintln!("[{}] CONTEXT_CACHE_HIT key=env", tid);
|
||||
}
|
||||
v
|
||||
} else {
|
||||
c.cache_stats.env_misses += 1;
|
||||
let v = format!("ENV (повторно):\n{}", gather_env());
|
||||
if let Some(tid) = trace_id {
|
||||
eprintln!("[{}] CONTEXT_CACHE_MISS key=env size={}", tid, v.len());
|
||||
}
|
||||
@ -270,27 +336,47 @@ pub fn fulfill_context_requests(
|
||||
}
|
||||
}
|
||||
if parts.is_empty() {
|
||||
String::new()
|
||||
FulfillResult {
|
||||
content: String::new(),
|
||||
context_stats: ContextStats::default(),
|
||||
}
|
||||
} else {
|
||||
let max_files = context_max_files();
|
||||
let max_total = context_max_total_chars();
|
||||
const MIN_CHARS_FOR_PRIORITY0: usize = 4096;
|
||||
let header = "\n\nFULFILLED_CONTEXT:\n";
|
||||
let mut total_chars = header.len();
|
||||
let mut result_parts = Vec::with_capacity(parts.len().min(max_files));
|
||||
let mut dropped = 0;
|
||||
let mut truncated = 0;
|
||||
for (_i, p) in parts.iter().enumerate() {
|
||||
if result_parts.len() >= max_files {
|
||||
dropped += 1;
|
||||
continue;
|
||||
}
|
||||
let part_len = p.len() + if result_parts.is_empty() { 0 } else { 2 };
|
||||
let budget_left = max_total.saturating_sub(total_chars);
|
||||
if total_chars + part_len > max_total && !result_parts.is_empty() {
|
||||
dropped += 1;
|
||||
let is_file = p.starts_with("FILE[");
|
||||
if is_file && budget_left >= MIN_CHARS_FOR_PRIORITY0 {
|
||||
let to_add = if p.len() > budget_left {
|
||||
truncated += 1;
|
||||
let head = (budget_left as f32 * 0.6) as usize;
|
||||
format!("{}...[TRUNCATED]...", &p[..head.min(p.len())])
|
||||
} else {
|
||||
p.clone()
|
||||
};
|
||||
total_chars += to_add.len() + if result_parts.is_empty() { 0 } else { 2 };
|
||||
result_parts.push(to_add);
|
||||
} else {
|
||||
dropped += 1;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
let to_add = if total_chars + part_len > max_total {
|
||||
let allowed = max_total - total_chars - 30;
|
||||
if allowed > 100 {
|
||||
truncated += 1;
|
||||
format!("{}...[TRUNCATED]...", &p[..allowed.min(p.len())])
|
||||
} else {
|
||||
p.clone()
|
||||
@ -301,15 +387,24 @@ pub fn fulfill_context_requests(
|
||||
total_chars += to_add.len() + if result_parts.is_empty() { 0 } else { 2 };
|
||||
result_parts.push(to_add);
|
||||
}
|
||||
let content = format!("{}{}", header, result_parts.join("\n\n"));
|
||||
let files_in_result = result_parts.iter().filter(|s| s.starts_with("FILE[")).count() as u32;
|
||||
let context_stats = ContextStats {
|
||||
context_files_count: files_in_result,
|
||||
context_files_dropped_count: dropped as u32,
|
||||
context_total_chars: total_chars,
|
||||
context_logs_chars: logs_chars,
|
||||
context_truncated_files_count: truncated,
|
||||
};
|
||||
if let Some(tid) = trace_id {
|
||||
if dropped > 0 {
|
||||
if dropped > 0 || truncated > 0 {
|
||||
eprintln!(
|
||||
"[{}] CONTEXT_DIET_APPLIED files={} dropped={} total_chars={}",
|
||||
tid, result_parts.len(), dropped, total_chars
|
||||
"[{}] CONTEXT_DIET_APPLIED files={} dropped={} truncated={} total_chars={}",
|
||||
tid, result_parts.len(), dropped, truncated, total_chars
|
||||
);
|
||||
}
|
||||
}
|
||||
format!("{}{}", header, result_parts.join("\n\n"))
|
||||
FulfillResult { content, context_stats }
|
||||
}
|
||||
}
|
||||
|
||||
@ -529,6 +624,21 @@ mod tests {
|
||||
assert!(cache.get(&key).is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_cache_logs_key_includes_last_n() {
|
||||
let mut cache = ContextCache::new();
|
||||
cache.put(
|
||||
ContextCacheKey::Logs { source: "runtime".to_string(), last_n: 200 },
|
||||
"LOGS last_n=200".to_string(),
|
||||
);
|
||||
cache.put(
|
||||
ContextCacheKey::Logs { source: "runtime".to_string(), last_n: 500 },
|
||||
"LOGS last_n=500".to_string(),
|
||||
);
|
||||
assert!(cache.get(&ContextCacheKey::Logs { source: "runtime".to_string(), last_n: 200 }).unwrap().contains("200"));
|
||||
assert!(cache.get(&ContextCacheKey::Logs { source: "runtime".to_string(), last_n: 500 }).unwrap().contains("500"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_context_diet_max_files() {
|
||||
let max = context_max_files();
|
||||
|
||||
@ -8,6 +8,13 @@ tests/
|
||||
└── fixtures/ # Тестовые фикстуры (минимальные проекты)
|
||||
├── minimal-node/ # Node.js проект без README
|
||||
└── minimal-rust/ # Rust проект без README
|
||||
|
||||
docs/golden_traces/ # Эталонные трассы (регрессия, без raw_content)
|
||||
├── README.md
|
||||
└── v1/ # Protocol v1 fixtures
|
||||
001_fix_bug_plan.json
|
||||
002_fix_bug_apply.json
|
||||
...
|
||||
```
|
||||
|
||||
## Юнит-тесты (Rust)
|
||||
@ -20,6 +27,7 @@ cargo test
|
||||
```
|
||||
|
||||
Текущие тесты покрывают:
|
||||
- `golden_traces_v1_validate` — валидация fixtures в `docs/golden_traces/v1/` (schema_version, schema_hash, parse, validate_actions, NO_CHANGES)
|
||||
- `detect_project_type` — определение типа проекта
|
||||
- `get_project_limits` — лимиты по типу проекта
|
||||
- `is_protected_file` — защита служебных файлов
|
||||
|
||||
Loading…
Reference in New Issue
Block a user