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:
Yuriy 2026-01-31 11:54:33 +03:00
parent e76236dc55
commit f2f33e24d6
19 changed files with 1014 additions and 31 deletions

28
.github/workflows/protocol-check.yml vendored Normal file
View 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

View File

@ -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
View 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

View File

@ -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
View File

@ -0,0 +1,96 @@
# Protocol v1 — контракт papa-yu
Краткий документ (12 страницы): что гарантируется, лимиты, логирование, 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
View 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.

View 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.

View 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
}
}

View 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
}
}

View 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
}
}

View 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"
}
}

View 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"
}
}

View 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
}
}

View 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
}
}

View File

@ -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",

View 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)
}

View File

@ -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);
}
}
}
}
}
}

View File

@ -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();

View File

@ -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` — защита служебных файлов