eva-agent/docs/superpowers/plans/2026-10-24-eva-agent-mvp.md
Andrey Epifancev 487391eddc docs: change eva-agent-mvp и план (#1)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-24 04:30:41 +04:00

18 KiB
Raw Blame History

eva-agent MVP — план реализации

For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (- [ ]) syntax for tracking.

Goal: агент на виртуалке банка выполняет команды модуля wiki хаба: wiki_sync (дерево EVA Wiki под корнем → пачки страниц хабу) и wiki_fetch (вложение до 10 МБ → хранилище хаба), с журналом, транспортом через общую папку и самообновлением, как jira-agent.

Architecture: один файл src/agent.py (Python 3.9, stdlib) — копия общих частей jira-agent v14 (HTTP, FolderTransport, HubClient, Journal, Runner, Updater, CLI) плюс EvaClient (JSON-RPC 2.2), LinkParser (классификация ссылок HTML) и Executor с do_wiki_sync/do_wiki_fetch; src/run.py — дословная копия запускателя.

Tech Stack: Python 3.9.6 (/usr/bin/python3), только stdlib, unittest.

Spec: контракт — ../../jira-hub/.wt/wiki/openspec/changes/wiki-module/ (worktree хаба, ветка feat/91-wiki, PR jira-hub#92): specs/wiki/spec.md («Синхронизация вики», «Вложения вики по запросу»), design.md решения 10, 11, 14 и Context; решения агента — openspec/changes/eva-agent-mvp/design.md этого репозитория (1–11). Код хаба, принимающий агента: src/jira_hub/modules/wiki/{stage.py,sync.py,attachments.py,commands.py} в том же worktree хаба.

Global Constraints

  • Python 3.9.6, только стандартная библиотека; синтаксис 3.10+ (match, X | Y в аннотациях функций) и сторонние импорты запрещены — ловит tests/test_py39.py.
  • Тесты: /usr/bin/python3 -W error::ResourceWarning -m unittest discover -s tests -t . — зелёный весь набор после каждой задачи.
  • AGENT_VERSION = 1 — ровно одна строка AGENT_VERSION = <целое> в src/agent.py.
  • src/run.py — байт в байт ../../jira-agent/src/run.py (сверка cmp в тесте test_run_py_is_copy, путь к jira-agent — ../../jira-agent/src/run.py от корня worktree; если файла нет — тест пропускается с причиной).
  • Токен Eva — только в agent.json (не в git: .gitignore получает agent.json, *.jsonl, update.json, *.lock); в логах и отчётах его нет (тест test_token_not_logged).
  • Протокол хаба: POST /agent/claim, GET /agent/settings, GET /agent/commands/{id}, POST /agent/commands/{id}/result {status, result, error, jira_calls, rate_limited}, GET /agent/release, POST /agent/files {content} → {file, size}, POST /agent/wiki/pages {command_id, pages}. Форма страницы — stage._check хаба.
  • Не трогать репозитории jira-agent (только читать), jira-hub (только читать и запускать тестовый хаб во временном JIRA_HUB_HOME), живой хаб и ~/.local/share/jira-hub.
  • Коммиты — по-русски, последней строкой Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>.

Review Focus

  1. Дерево Eva с циклом (страница — родитель сама себе или через tree_parent) и страница с двумя родителями — обход конечен, страница в отчёте один раз: test_tree_cycle_and_two_parents (Task 4).
  2. HTML с относительными и абсолютными адресами, миниатюрами .meta/thumbnail.jpg, static/, чужим хостом, DOC- в адресе другого хоста — классификация как в design 6: tests/test_links.py (Task 3).
  3. Eva отвечает 429 с Retry-After, 503, обрывом соединения, ошибкой JSON-RPC — повторы и итоговый failed с текстом без токена: tests/test_eva_client.py (Task 2).
  4. Вложение без Content-Length и больше 10 МБ — чтение обрывается на 10 МБ + 1 байт, failed с текстом спеки: test_fetch_too_big_streaming (Task 5).
  5. Перезапуск агента посреди wiki_sync (журнал: started без finished) — команда выполняется заново, хаб получает полный набор пачек и один отчёт: test_recover_resyncs (Task 4).

Task 1: Каркас — общие части jira-agent, конфиг, запускатель

Files:

  • Create: src/agent.py, src/run.py (копия), src/agent.example.json, tests/{__init__.py,_common.py,fake_hub.py,fake_relay.py,test_py39.py,test_clients.py,test_folder.py,test_journal.py,test_runner.py,test_update.py,test_launcher.py,test_self_update_e2e.py}, .gitignore (дописать)
  • Modify: CLAUDE.md, ARCHITECTURE.md (заполнить шаблоны по образцу ../../jira-agent/CLAUDE.md и ARCHITECTURE.md, для eva-agent), README.md (создать: назначение, установка — Task 6 допишет)

Interfaces:

  • Produces:

    • load_config(path) -> dict (обязательны hub_url, hub_token, eva.base_url, eva.token; folder: — абсолютный путь; умолчания как у jira-agent).
    • Копия из jira-agent без изменений смысла: Http, HttpError, RedirectAway, TooBig, FolderTransport, Journal, Updater, acquire_lock, launcher_version, cmd_run, cmd_prune, main.
    • HubClient — без jira-маршрутов (state, known_keys, history), плюс wiki_pages(command_id, pages) -> dict.
    • Runner — вызывает Executor.run(cmd) -> dict ({status, result, error, jira_calls, rate_limited}), как в jira-agent.
    • Executor(eva, hub, settings) с do_wiki_sync/do_wiki_fetch, в этой задаче — failed «не реализовано».
    • cmd_check(cfg) -> int проверяет только хаб; Eva добавит Task 6.
  • Тесты общих частей переносятся из ../../jira-agent/tests/ с заменой: Jira → нет, ensure_* → команда-заглушка wiki_fetch с failed. Сценарии журнала, папки, 401/403-паузы, самообновления и запускателя сохраняются.

  • Step 1: перенести тесты общих частей, они упадут — agent.py нет.

  • Step 2: собрать src/agent.py из jira-agent v14:

    • вырезать JiraClient, jira-методы Executor, ConfigGap и проверки Jira;
    • AGENT_VERSION = 1;
    • run.py скопировать.
  • Step 3: весь набор — PASS; test_run_py_is_copy и test_py39 зелёные.

  • Step 4: Commit «Каркас eva-agent: общие части jira-agent v14 (#1)».


Task 2: Клиент Eva

Files:

  • Modify: src/agent.py (класс EvaClient(Http) после HubClient)
  • Create: tests/fake_eva.py, tests/test_eva_client.py

Interfaces:

  • Produces:

    • EvaClient(cfg_eva: dict, timeout, verify_ssl, ca_bundle), его методы:
      • call(method, **kwargs) -> Any — POST <base>/api/?m=<method>, Bearer; повторы по design 4; error в ответе — EvaError("<method>: <error>");
      • get_doc(code, fields) -> dict | None;
      • list_docs(flt, fields) -> list[dict] — страницы по 200 через slice; обёрнутый результат {items|result} разворачивается;
      • download(url, limit) -> bytes — поток с обрывом на limit + 1; больше лимита — TooBig.
    • Свойства: calls (число вызовов), rate_limited (число 429), pacing (пауза, с; ставит Runner из wiki_pacing).
    • tests/fake_eva.py: FakeEva — http.server на порту 0:
      • хранит документы {id, code, name, parent, tree_parent, project, orderno, text};
      • поддерживает фильтры code ==, project.code ==, tree_parent.code ==, slice;
      • сценарии сбоев: fail_next(status, times, retry_after=None), rpc_error_next(text);
      • файлы /files/... с Content-Length или без него;
      • проверяет Authorization.
  • Step 1: Падающие тесты:

    • test_call_ok;
    • test_retry_429_retry_after — ждал Retry-After через подменённый sleep;
    • test_retry_503_then_ok;
    • test_gives_up_after_4;
    • test_rpc_error;
    • test_list_pages_200 — 450 документов за три вызова;
    • test_pacing_between_calls;
    • test_download_limit — без Content-Length, 10 МБ + 1;
    • test_token_not_logged — токен не встречается ни в логе, ни в тексте ошибок.
  • Step 2: FAIL. Step 3: Реализовать. Step 4: PASS.

  • Step 5: Commit «Клиент Eva JSON-RPC (#1)».


Task 3: Разбор ссылок страницы

Files:

  • Modify: src/agent.py (функция classify_links(html, base_url) -> tuple[list, list] и class _LinkParser(HTMLParser))
  • Create: tests/test_links.py, tests/fixtures/pages/*.html (синтетические, форматы адресов из design хаба, Context; текст ТЗ не копировать)

Interfaces:

  • Produces: classify_links(html: str, base_url: str) -> (attachments: [{ref, url, name}], links: [{ref, code}]) по design 6–7, в порядке появления, без склейки повторов.

  • Step 1: Падающие тесты:

def test_image_relative_files()       # src="/files/obj/CmfDocument/CmfDocument%3Aa08/CmfDocument%3A<uuid>/img.png?version=1" →
                                      # url "https://eva.test/files/obj/…/img.png" (без query), name "img.png"
def test_thumbnail_maps_to_file()     # ".../model.xlsx.meta/thumbnail.jpg?version=1" → url ".../model.xlsx", name из alt или "model.xlsx"
def test_file_link_same_url()         # <a href=".../model.xlsx">model.xlsx</a> → тот же url, что у миниатюры
def test_static_ignored()             # src="static/attach-file.png" → ни в одном списке
def test_page_link_relative()         # href="project/Document/DOC-019834" → links [{ref, code: "DOC-019834"}]
def test_foreign_host()               # href="https://other.test/files/x.png" и "https://other.test/project/Document/DOC-1" → пусто
def test_name_unquoted()              # ".../%D0%9F%D0%9C%D0%9F.xlsx" без alt и текста → name "ПМП.xlsx"
def test_ref_exact()                  # ref равен атрибуту байт в байт (с &amp; → как отдаёт HTMLParser: unescape; проверить, что хаб ищет так же — см. convert.py хаба)

Для test_ref_exact надо сверить, с чем хаб сравнивает ref: с сырым значением атрибута или с unescape. Это видно в ../../jira-hub/.wt/wiki/src/jira_hub/modules/wiki/convert.py. Записать, как сделано, в отчёт и в design (решение 12).

  • Step 2: FAIL. Step 3: Реализовать. Step 4: PASS.
  • Step 5: Commit «Разбор ссылок страниц Eva (#1)».

Task 4: Команда wiki_sync

Files:

  • Modify: src/agent.py (Executor.do_wiki_sync, функции build_children(docs), walk(root, children)), tests/fake_hub.py (POST /agent/wiki/pages — записывает пачки; проверка формы по образцу stage._check хаба: code, title, html строки; ≤ 50 страниц)
  • Create: tests/test_wiki_sync.py

Interfaces:

  • Consumes: EvaClient (Task 2), classify_links (Task 3), HubClient.wiki_pages (Task 1).

  • Produces: Executor.do_wiki_sync(params {label, root}) -> {status, result {root_title, codes, eva_calls}, error, jira_calls, rate_limited} по design 5; пачки — MAX_PAGES = 50, MAX_BATCH_BYTES = 4 * 1024 * 1024.

  • Step 1: Падающие тесты:

def test_sync_tree_order()            # корень DOC-1 (project P), дети DOC-3 (orderno 1), DOC-2 (orderno 2), внук DOC-4 под DOC-3 →
                                      # codes [DOC-1, DOC-3, DOC-4, DOC-2]; parent_code; url ".../project/Document/DOC-3"
def test_sync_batches_50()            # 120 страниц → пачки 50, 50, 20; codes — 120
def test_sync_batch_bytes()           # страницы по 1,5 МБ → пачка не больше 4 МБ
def test_tree_cycle_and_two_parents() # A.parent=B, B.tree_parent=A; C с parent=корень и tree_parent=DOC-3 → каждый код один раз
def test_root_without_project()       # у корня нет project → обход через tree_parent.code
def test_root_missing()               # get_doc → None → failed «страница DOC-9 не найдена или нет прав»
def test_eva_failure_reported()       # Eva 503 четыре раза подряд → failed, текст содержит "503", токена в тексте нет
def test_hub_rejects_batch()          # fake_hub 422 на пачку → failed с detail хаба
def test_recover_resyncs()            # журнал started без finished, команда claimed в хабе → выполняется заново: полные пачки, один done
def test_counts()                     # jira_calls == eva.calls, rate_limited == числу 429
  • Step 2: FAIL. Step 3: Реализовать. Step 4: PASS.
  • Step 5: Commit «Команда wiki_sync (#1)».

Task 5: Команда wiki_fetch

Files:

  • Modify: src/agent.py (Executor.do_wiki_fetch)
  • Create: tests/test_wiki_fetch.py

Interfaces:

  • Produces: Executor.do_wiki_fetch(params {attachment_id, url}) -> {status, result {file, size}, …} по design 8; использует HubClient.upload_file (как у jira-agent fetch_attachment).

  • Step 1: Падающие тесты:

    • test_fetch_ok — 300 КБ; done, size 307200; файл у fake_hub совпадает побайтно;
    • test_fetch_foreign_host — failed «чужой хост», у fake_eva нет запросов;
    • test_fetch_too_big_header — Content-Length 11 МБ, тело не читается;
    • test_fetch_too_big_streaming — без Content-Length;
    • test_fetch_404 — «файл не найден в Eva»;
    • test_fetch_empty_file — 0 байт; done, size 0: хаб принимает пустой файл от агента.
  • Step 2: FAIL. Step 3: Реализовать. Step 4: PASS.

  • Step 5: Commit «Команда wiki_fetch (#1)».


Task 6: check, документация, сквозной прогон с хабом

Files:

  • Modify: src/agent.py (cmd_check — хаб и Eva по design 10), README.md, CLAUDE.md, ARCHITECTURE.md, openspec/changes/eva-agent-mvp/design.md (решения исполнителей), src/agent.example.json
  • Create: scripts/e2e-hub.sh, tests/test_check.py

Interfaces:

  • scripts/e2e-hub.sh:

    • поднимает тестовый хаб из ../../jira-hub/.wt/wiki: uv run --project <путь> jira-hub serve --port 0, неудобно — тогда фиксированный свободный порт, например 8795, с JIRA_HUB_HOME=$(mktemp -d);
    • поднимает FakeEva с деревом из 5 страниц, картинкой и файлом;
    • заводит проект E2E с wiki_root через POST /projects клиентским токеном из jira-hub tokens;
    • запускает агент src/agent.py run с токеном агента wiki до первого done; нужен флаг --once или тайм-аут — решает исполнитель, без изменения протокола;
    • проверяет GET /wiki/tree?project=E2E (5 страниц), GET /wiki/pages/<код> (Markdown со ссылкой attachment:), затем POST /wiki/attachments/<id>/fetch и GET …/content;
    • печатает «e2e: ok»; хаб и фейк гасятся по trap.
  • README — установка на виртуалку:

    • каталог рядом с jira-agent (например C:\…\eva-agent\ или ~/eva-agent/);
    • файлы agent.py, run.py, agent.json (из примера: hub_url = тот же folder:<путь>, что у jira-agent, hub_token = «агент wiki» из jira-hub tokens, eva.base_url, eva.token — профиль Eva → Безопасность);
    • python3 agent.py check, затем python3 run.py;
    • автозапуск — как у jira-agent;
    • выкатка новых версий — uv run --project ../jira-hub jira-hub agent publish --module wiki src/agent.py.
  • Step 1: тесты test_check (хаб ok и Eva ok → 0; Eva 401 → 3, «eva: …»). FAIL → реализовать → PASS.

  • Step 2: bash scripts/e2e-hub.sh → «e2e: ok». Хаб — только из worktree feat/91-wiki и только во временном JIRA_HUB_HOME.

  • Step 3: документация.

  • Step 4: весь набор — PASS.

  • Step 5: Commit «check, README, сквозной прогон с хабом (#1)».