Stabilize streaming runtime completion
This commit is contained in:
parent
9e5d5bbd5b
commit
e6b82f0376
|
|
@ -8,7 +8,7 @@ DUCK_CTX_SIZE=4096
|
|||
DUCK_N_GPU_LAYERS=20
|
||||
DUCK_PARALLEL=1
|
||||
DUCK_LLAMA_DEVICE=Vulkan0
|
||||
DUCK_LLAMA_EXTRA_ARGS="--reasoning-budget 512 --cache-ram 0"
|
||||
DUCK_LLAMA_EXTRA_ARGS="--reasoning off --cache-ram 0"
|
||||
DUCK_HOST=127.0.0.1
|
||||
|
||||
DUCK_API_HOST=127.0.0.1
|
||||
|
|
|
|||
535
CURRENT_STATE.md
535
CURRENT_STATE.md
|
|
@ -1,462 +1,131 @@
|
|||
# DuckLM — Текущее состояние проекта
|
||||
# DuckLM — текущее состояние проекта
|
||||
|
||||
**Дата анализа:** 2026-05-21
|
||||
**Версия:** 0.2.0
|
||||
**Расположение:** `~/git/ducklm_2`
|
||||
Дата обновления: 2026-05-21
|
||||
Рабочая копия: `/home/mirivlad/git/ducklm`
|
||||
Git remote: `origin/main`
|
||||
|
||||
---
|
||||
## Общий статус
|
||||
|
||||
## Последние изменения (Phase 1-7)
|
||||
DuckLM сейчас является рабочим локальным MVP/beta runtime поверх `llama-server`.
|
||||
|
||||
### Phase 1: MemoryPolicy — LLM-классификация памяти ✅
|
||||
- `duck_core/memory/policy.py` — переписан с нуля: LLM-классификация через critic-роль
|
||||
- Роль `memory_policy` добавлена в `config/models.yaml` + промпт
|
||||
- Интегрирован в RuntimeLoop: `_run_memory_policy()` после каждой задачи
|
||||
- События: `memory_policy_decision`, `memory_stored`, `memory_policy_failed`
|
||||
- 6 тестов в `tests/smoke/test_memory_policy.py`
|
||||
Система реализует основной цикл из `Ducklm.md`:
|
||||
|
||||
### Phase 2: Рефлексия (Critic) — автоматический вызов ✅
|
||||
- `_run_reflection()` в RuntimeLoop — transcript из event store → critic → experience
|
||||
- Параметр `reflect: bool = True` в `run_chat()`
|
||||
- `ExperienceRecorder` передаётся через `create_app()`
|
||||
- События: `reflection_completed`, `reflection_failed`
|
||||
- 3 теста в `tests/smoke/test_reflection.py`
|
||||
|
||||
### Phase 3-4: ContextBuilder v2 + Summary-роль ✅
|
||||
- Полностью переписан `duck_core/context_builder.py`
|
||||
- Token budget awareness, приоритизация, суммаризация через summary-роль
|
||||
- `estimate_tokens()`, `estimate_messages_tokens()` утилиты
|
||||
- Подключён model_client для LLM-суммаризации
|
||||
- 11 тестов в `tests/smoke/test_context_builder.py`
|
||||
|
||||
### Phase 5: VectorMemory — интеграция ✅
|
||||
- `VectorMemory` добавлен в RuntimeLoop и `create_app()`
|
||||
- **Локальная модель эмбеддингов**: `all-MiniLM-L6-v2` (384 размерности, sentence-transformers)
|
||||
- При `memory_stored` также сохраняется в Qdrant (graceful fallback при ошибках)
|
||||
- Поддержка двух режимов: локальная модель + remote `/v1/embeddings` endpoint
|
||||
- `sentence-transformers` добавлен в зависимости `pyproject.toml`
|
||||
- 4 теста в `tests/smoke/test_vector_memory_integration.py`
|
||||
|
||||
### Embeddings — архитектура
|
||||
|
||||
### Phase 6: Recall-роль ✅
|
||||
- Роль `recall` добавлена в `config/models.yaml` + промпт `prompts/roles/recall.md`
|
||||
- `ContextBuilder.recall_relevant_memory()` — LLM-фильтрация релевантных воспоминаний
|
||||
- Интегрирован в `/v1/chat` endpoint
|
||||
|
||||
### Phase 7: Coder-роль — интеграция ✅
|
||||
- `CoderTool` создан в `duck_core/tools/coder.py`
|
||||
- Зарегистрирован в `ToolGateway.default()`
|
||||
- Описан в `prompts/roles/action.md`
|
||||
|
||||
### Статус тестов
|
||||
- **72 из 73** smoke-тестов проходят
|
||||
- 1 тест (`test_llama_server_connection_live_skip_by_env`) требует живой llama-server
|
||||
|
||||
---
|
||||
|
||||
## 1. Что такое DuckLM
|
||||
|
||||
DuckLM — это **локальная агентная система (cognitive runtime)**, работающая поверх локальных языковых моделей через `llama-server`. Это не inference-сервер, а полноценный когнитивный цикл:
|
||||
|
||||
```
|
||||
состояние → контекст → мышление → намерение → действие → наблюдение → рефлексия → память → опыт
|
||||
```text
|
||||
сообщение -> задача -> контекст -> action directive -> tools -> observations
|
||||
-> thinker response -> task events -> memory/reflection/experience
|
||||
```
|
||||
|
||||
Ключевая идея: DuckLM — это **оркестратор**, который управляет задачами, инструментами, памятью, навыками и рефлексией, используя локальные LLM через OpenAI-совместимый API (`llama-server`).
|
||||
WebChat доступен через FastAPI на `http://127.0.0.1:8000/`.
|
||||
Основной `llama-server` ожидается на `http://127.0.0.1:8081/v1`.
|
||||
|
||||
---
|
||||
## Что реализовано
|
||||
|
||||
## 2. Архитектурные принципы
|
||||
- FastAPI HTTP API и WebChat.
|
||||
- ConversationStore для диалогов и истории.
|
||||
- TaskStore и EventStore в SQLite.
|
||||
- ModelClient с логическими ролями из `config/models.yaml`.
|
||||
- Роли: `thinker`, `critic`, `coder`, `action`, `summary`, `memory_policy`, `recall`.
|
||||
- SSE streaming chat: reasoning/content deltas, runtime status events, final stats.
|
||||
- Runtime status в чате для долгих этапов: planning, running_tool(s), answering.
|
||||
- Min/avg/max token speed в конце ответа.
|
||||
- ToolGateway:
|
||||
- `file_read`
|
||||
- `file_write`
|
||||
- `list_dir`
|
||||
- `search_files`
|
||||
- `shell_exec_safe`
|
||||
- `coder`
|
||||
- Workspace guard с approval для доступа к внешним путям.
|
||||
- Approval flow:
|
||||
- allow once
|
||||
- allow forever by exact normalized action hash
|
||||
- deny
|
||||
- sudo password flow
|
||||
- Command audit для shell-команд.
|
||||
- SkillRegistry и API/UI для skills.
|
||||
- Автоматический выбор candidate skill по ключевым словам и добавление skill summary в context.
|
||||
- MemoryStore в SQLite.
|
||||
- MemoryPolicy через LLM role `memory_policy` с fallback в безопасный no-store режим.
|
||||
- VectorMemory adapter для Qdrant с локальной embedding-моделью или remote embeddings endpoint.
|
||||
- Recall-фильтрация памяти через `recall` role.
|
||||
- Reflection через `critic` role.
|
||||
- ExperienceRecorder и skill update proposals.
|
||||
- Scripts для llama-server, verification и benchmark.
|
||||
- Docker compose для Qdrant.
|
||||
- Smoke tests.
|
||||
|
||||
### 2.1. Использование готовых компонентов
|
||||
## Недавние исправления
|
||||
|
||||
| Компонент | Источник |
|
||||
|-----------|----------|
|
||||
| LLM inference | `llama-server` (llama.cpp, собранный с Vulkan) |
|
||||
| Хранение состояния | SQLite (aiosqlite) |
|
||||
| Векторная память | Qdrant (через docker-compose) |
|
||||
| HTTP API | FastAPI |
|
||||
| Web-интерфейс | Jinja2 + ванильный JS |
|
||||
| Валидация данных | Pydantic |
|
||||
| Конфигурация | PyYAML + python-dotenv |
|
||||
- WebChat теперь не выглядит зависшим во время action/planning: backend стримит `runtime_status`.
|
||||
- В чат больше не выводятся лишние дубли tool-output; tool events показываются компактно.
|
||||
- Внешние пути за пределами workspace не падают простой ошибкой, а требуют approval.
|
||||
- Streaming endpoints теперь запускают тот же post-processing, что и обычный `/v1/chat`:
|
||||
- memory policy
|
||||
- memory store/vector store
|
||||
- reflection
|
||||
- experience records
|
||||
- Skill candidate selection теперь используется в обычном и streaming chat.
|
||||
|
||||
**Не пишется с нуля:** inference server, model scheduler, vector DB, OpenAI API, MCP, песочница, workflow engine.
|
||||
## Соответствие этапам из Ducklm.md
|
||||
|
||||
**Пишется с нуля:** Duck Core (runtime loop, context builder, model client, event store, tool gateway, approvals, skills, experience, memory policy, FastAPI API, WebChat).
|
||||
| Этап | Статус | Комментарий |
|
||||
| --- | --- | --- |
|
||||
| 1. Project skeleton / FastAPI / WebChat | Готово | Проект запускается, WebChat не пустой |
|
||||
| 2. llama-server roles / ModelClient | Готово | Одна модель может обслуживать все роли |
|
||||
| 3. Chat/tasks/events/WebChat response | Готово | Есть обычный и streaming chat |
|
||||
| 4. cognition_response vs action_directive | Готово | Action role structured JSON, thinker отдельно |
|
||||
| 5. ToolGateway | Готово | File/search/shell/coder tools, event log |
|
||||
| 6. Approvals | Готово | UI и API approvals, allow_once/forever/deny |
|
||||
| 7. Skills | Готово | Registry, API/UI, candidate skill injection |
|
||||
| 8. Reflection/Experience | Готово | Reflection после completed задач, experience records |
|
||||
| 9. Memory/VectorMemory | Готово частично | SQLite memory готова; Qdrant зависит от запущенного сервиса и embeddings |
|
||||
| 10. MTP/benchmark | Готово как experimental | MTP script есть, action по умолчанию остаётся на main endpoint |
|
||||
|
||||
### 2.2. Web/API first
|
||||
## Остаточные ограничения
|
||||
|
||||
- **WebChat** — интерфейс для человека (порт 8000)
|
||||
- **HTTP API** — для кодера, тестов и внешних агентов
|
||||
- CLI не входит в обязательную часть (если понадобится — тонкий клиент поверх HTTP API)
|
||||
- Qdrant и локальная embedding-модель должны быть доступны отдельно; при ошибках vector memory деградирует без падения runtime.
|
||||
- Token speed считается приближённо по текущему estimator, а не по tokenizer конкретной модели.
|
||||
- Skill selection сейчас keyword-based. LLM selection можно добавить позже, если понадобится.
|
||||
- WebChat остаётся lightweight vanilla JS UI; это не production frontend framework.
|
||||
- `@app.on_event("startup")` работает, но FastAPI предупреждает, что lifespan API современнее.
|
||||
|
||||
### 2.3. Роли моделей — логические, не физические
|
||||
## Проверка
|
||||
|
||||
Роли: `thinker`, `critic`, `coder`, `action`, `summary`, `recall`, `sys_util`.
|
||||
Последняя полная проверка:
|
||||
|
||||
Все роли в текущей конфигурации указывают на одну физическую модель (`local-main` на порту 8081). Различие между ролями задаётся комбинацией:
|
||||
- system prompt
|
||||
- temperature
|
||||
- max_output_tokens
|
||||
- response_format / structured_output
|
||||
- memory scope
|
||||
- tool permissions
|
||||
|
||||
### 2.4. Token budget
|
||||
|
||||
```
|
||||
DUCK_CTX_SIZE=4096 (в .env, хотя в коде дефолт 65536)
|
||||
DUCK_MAX_INPUT_TOKENS=49152
|
||||
DUCK_MAX_RECENT_EVENTS_TOKENS=12000
|
||||
DUCK_MAX_MEMORY_TOKENS=8000
|
||||
DUCK_MAX_SKILL_TOKENS=6000
|
||||
```bash
|
||||
. .venv/bin/activate
|
||||
ruff check .
|
||||
pytest tests/smoke -q
|
||||
git diff --check
|
||||
```
|
||||
|
||||
Output limits по ролям:
|
||||
- thinker: 8192
|
||||
- critic: 4096
|
||||
- coder: 16384
|
||||
- action: 2048
|
||||
- summary: 4096
|
||||
Ожидаемый результат на 2026-05-21: smoke tests проходят.
|
||||
|
||||
---
|
||||
## Запуск
|
||||
|
||||
## 3. Целевая архитектура (из ТЗ)
|
||||
|
||||
```
|
||||
┌─────────────┐
|
||||
│ WebChat │ ← интерфейс человека
|
||||
└──────┬──────┘
|
||||
│
|
||||
▼
|
||||
┌─────────────┐
|
||||
│ FastAPI │ ← интерфейс кодера, тестов и агентов
|
||||
└──────┬──────┘
|
||||
│
|
||||
▼
|
||||
┌─────────────────────────────────────┐
|
||||
│ Duck Core │
|
||||
│ RuntimeLoop, TaskState, │
|
||||
│ ContextBuilder, ModelClient, │
|
||||
│ SkillRegistry, ToolGateway, │
|
||||
│ ApprovalService, Reflection, │
|
||||
│ MemoryPolicy, ExperienceRecorder │
|
||||
└──────┬──────────────┬───────────────┘
|
||||
│ │
|
||||
▼ ▼
|
||||
┌────────────┐ ┌──────────────────┐
|
||||
│llama-server│ │ SQLite/PostgreSQL│
|
||||
│OpenAI-comp.│ │ events/tasks/ │
|
||||
└────────────┘ │ approvals │
|
||||
│ └──────────────────┘
|
||||
▼
|
||||
┌────────────┐
|
||||
│ Qdrant │ ← semantic memory
|
||||
└────────────┘
|
||||
```bash
|
||||
. .venv/bin/activate
|
||||
bash scripts/llama/start_main.sh start
|
||||
python -m duck_core.api
|
||||
```
|
||||
|
||||
---
|
||||
Открыть WebChat:
|
||||
|
||||
## 4. Что реализовано (текущее состояние)
|
||||
```text
|
||||
http://127.0.0.1:8000/
|
||||
```
|
||||
|
||||
### 4.1. Полностью реализованные компоненты
|
||||
Проверить API:
|
||||
|
||||
#### Конфигурация и настройки
|
||||
- **`duck_core/config.py`** — `Settings` dataclass, загрузка из `.env`, кэширование через `lru_cache`
|
||||
- **`config/models.yaml`** — 5 ролей (thinker, critic, coder, action, summary), все на `local-main` (порт 8081)
|
||||
- **`.env`** / **`.env.example`** — полная конфигурация путей, портов, GPU, Qdrant
|
||||
```bash
|
||||
curl --noproxy '*' http://127.0.0.1:8000/health
|
||||
curl --noproxy '*' http://127.0.0.1:8000/v1/models/roles
|
||||
```
|
||||
|
||||
#### ModelClient
|
||||
- **`duck_core/model_client.py`** — ролевая маршрутизация вызовов к llama-server
|
||||
- `chat()` — синхронный вызов с измерением latency, usage
|
||||
- `stream_chat()` — streaming через SSE (reasoning_delta + content_delta)
|
||||
- `ping()` — проверка доступности всех ролей
|
||||
- Автоматическая подстановка system prompt из файла
|
||||
- Автоматический `response_format: json_schema` для action-роли
|
||||
## Что делать следующим
|
||||
|
||||
#### Хранение состояния (SQLite)
|
||||
- **`duck_core/tasks/state.py`** — `TaskState` (Pydantic модель)
|
||||
- **`duck_core/tasks/store.py`** — `TaskStore`: create, update_status, complete, fail, cancel, waiting_for_approval, get, list
|
||||
- **`duck_core/events/store.py`** — `EventStore`: append (с авто-increment sequence), list_events, list_by_type
|
||||
- **`duck_core/conversations/store.py`** — `ConversationStore`: create, ensure, get, list, add_message, list_messages, get_conversation_id_for_task
|
||||
- **`duck_core/approvals/service.py`** — `ApprovalService`: create_pending, pending, get, allow_once, allow_forever, deny, is_allowed_forever
|
||||
- **`duck_core/experience/recorder.py`** — `ExperienceRecorder`: record, list_records, get_record, write_skill_update_proposal
|
||||
- **`duck_core/memory/store.py`** — `MemoryStore`: add, list, search (LIKE), relevant (scope-aware), infer_scope, _normalize_scope
|
||||
|
||||
#### Runtime Loop
|
||||
- **`duck_core/runtime_loop.py`** — ядро когнитивного цикла:
|
||||
- `run_chat()` — полный цикл: создание задачи → action loop → thinker → завершение
|
||||
- `continue_after_approval()` — продолжение после одобрения действия
|
||||
- `_run_action_loop()` — итеративный цикл вызова инструментов (max 4 итерации)
|
||||
- `_run_action_tools()` — парсинг action directive от модели → вызов ToolGateway
|
||||
- `_append_command_audit()` — аудит shell-команд через event store
|
||||
- Обработка requires_approval → пауза с ожиданием решения пользователя
|
||||
|
||||
#### Context Builder
|
||||
- **`duck_core/context_builder.py`** — `ContextBuilder.build_basic_messages()`: собирает сообщения из memory records, history и текущего user message
|
||||
|
||||
#### Tools
|
||||
- **`duck_core/tools/base.py`** — `ToolResult` (Pydantic), `Tool` (Protocol)
|
||||
- **`duck_core/tools/gateway.py`** — `ToolGateway`: маршрутизация action → конкретный инструмент
|
||||
- **`duck_core/tools/file_read.py`** — `FileReadTool`: чтение файлов внутри workspace, запрет .env/.ssh/shadow
|
||||
- **`duck_core/tools/file_write.py`** — `FileWriteTool`: запись внутри workspace, защита от перезаписи
|
||||
- **`duck_core/tools/list_dir.py`** — `ListDirTool`: листинг директории внутри workspace
|
||||
- **`duck_core/tools/search_files.py`** — `SearchFilesTool`: текстовый поиск по файлам (glob, case_sensitive)
|
||||
- **`duck_core/tools/shell_exec_safe.py`** — `ShellExecSafeTool`: allowlist + blocklist + approval
|
||||
- **`duck_core/tools/command_policy.py`** — `CommandPolicy`: классификация команд (readonly, system, destructive, dangerous fragments)
|
||||
- **`duck_core/tools/paths.py`** — `resolve_workspace_path()`: защита от path traversal
|
||||
|
||||
#### Approvals
|
||||
- **`duck_core/approvals/service.py`** — полный цикл согласований:
|
||||
- Создание pending approval с SHA256-хешем действия
|
||||
- Решения: allow_once, allow_forever, deny
|
||||
- Проверка is_allowed_forever по хешу действия
|
||||
- normalized_action хранится в JSON
|
||||
|
||||
#### Skills
|
||||
- **`duck_core/skills/registry.py`** — `SkillRegistry`: загрузка из `*/skill.yaml`, парсинг procedure/examples/notes, поиск по ключевым словам
|
||||
- **`skills/analyze_project/`** — единственный скилл: анализ структуры проекта
|
||||
|
||||
#### Experience & Reflection
|
||||
- **`duck_core/experience/recorder.py`** — запись результатов задач, предложения по обновлению скиллов
|
||||
- **`duck_core/reflection.py`** — `Reflection.reflect()`: вызов critic-роли для анализа транскрипта задачи
|
||||
|
||||
#### Memory
|
||||
- **`duck_core/memory/store.py`** — `MemoryStore`: хранение в SQLite с поддержкой scope (global/workspace/conversation), importance, полнотекстовый поиск (LIKE)
|
||||
- **`duck_core/memory/policy.py`** — `MemoryPolicy`: заглушка (всегда should_store=False)
|
||||
- **`duck_core/memory/vector_memory.py`** — `VectorMemory`: интеграция с Qdrant для семантического поиска (требует embeddings endpoint)
|
||||
|
||||
#### FastAPI API
|
||||
- **`duck_core/api.py`** — полный HTTP API (878 строк):
|
||||
- `POST /v1/chat` — основной чат с сохранением в conversation
|
||||
- `POST /v1/chat/stream` — streaming чат через SSE
|
||||
- `POST /v1/tasks/{task_id}/continue/stream` — продолжение после одобрения
|
||||
- `POST /v1/tasks/{task_id}/password/stream` — ввод sudo-пароля
|
||||
- `GET/POST /v1/conversations` — управление диалогами
|
||||
- `GET /v1/tasks`, `GET /v1/tasks/{task_id}/events` — инспекция задач
|
||||
- `GET /v1/approvals/pending`, `POST /v1/approvals/{id}/allow_once|allow_forever|deny`
|
||||
- `GET /v1/skills`, `GET /v1/skills/{skill_id}`
|
||||
- `GET /v1/experience`
|
||||
- `POST /v1/memory`, `GET /v1/memory`, `GET /v1/memory/search`
|
||||
- `GET /v1/models/roles`, `GET /v1/models/ping`
|
||||
- `GET /health`, `GET /v1/status`
|
||||
- Веб-страницы: `/`, `/approvals`, `/skills`, `/memory`, `/experience`
|
||||
|
||||
#### WebChat UI
|
||||
- **`duck_core/web/templates/index.html`** — полноценный WebChat с sidebar, conversation list, activity drawer
|
||||
- **`duck_core/web/static/app.js`** (997 строк) — клиентская логика:
|
||||
- SSE streaming с парсингом reasoning_delta, content_delta, tool_call_started/finished, tool_approval_requested, tool_password_requested
|
||||
- Инлайн-терминалы для отображения вызовов инструментов
|
||||
- Инлайн-кнопки одобрения/запрета действий
|
||||
- Форма ввода sudo-пароля
|
||||
- Activity drawer с вкладками Events/Commands/Memory
|
||||
- Управление диалогами (create, select, load history)
|
||||
- Enter для отправки, Shift+Enter для новой строки
|
||||
- **`duck_core/web/static/style.css`** (1002 строки) — светлая тема, responsive layout
|
||||
|
||||
#### Скрипты
|
||||
- **`scripts/llama/start_main.sh`** — управление llama-server (start/stop/restart/status/logs)
|
||||
- **`scripts/llama/start_thinker_mtp_experimental.sh`** — экспериментальный MTP endpoint
|
||||
- **`scripts/llama/build_vulkan.sh`** — сборка llama.cpp с Vulkan
|
||||
- **`scripts/llama/healthcheck.sh`** — проверка здоровья llama-server
|
||||
- **`scripts/verify/`** — 7 верификационных скриптов (basic_chat, file_write_read, tool_blocking, models_roles, skills, experience, memory)
|
||||
- **`scripts/bench/bench_runtime.py`** — бенчмарк
|
||||
|
||||
#### Тесты
|
||||
- 18 smoke-тестов в `tests/smoke/`:
|
||||
- `test_models_config.py`, `test_model_client.py`, `test_api_health.py`
|
||||
- `test_event_log.py`, `test_action_directive_schema.py`
|
||||
- `test_tool_gateway.py`, `test_approvals.py`
|
||||
- `test_skill_registry.py`, `test_experience_recorder.py`
|
||||
- `test_vector_memory.py`, `test_memory_store.py`
|
||||
- `test_chat_api.py`, `test_conversations.py`
|
||||
- `test_runtime_reasoning.py`, `test_runtime_tools.py`
|
||||
- `test_llama_server_connection.py`, `test_llama_service_script.py`
|
||||
|
||||
#### Документация
|
||||
- 12 файлов в `docs/`:
|
||||
- `architecture.md`, `how_to_run.md`, `how_to_test.md`
|
||||
- `web_api.md`, `model_roles.md`, `tool_gateway.md`
|
||||
- `memory_architecture.md`, `experience_learning.md`, `skills.md`
|
||||
- `local_llama_server.md`, `performance_mtp.md`
|
||||
- `superpowers/plans/2026-05-19-ducklm-runtime.md` — план реализации
|
||||
|
||||
#### Docker
|
||||
- **`docker-compose.memory.yml`** — Qdrant (порты 6333/6334)
|
||||
|
||||
#### Сборка и запуск
|
||||
- **`pyproject.toml`** — зависимости: fastapi, uvicorn, httpx, pydantic, pyyaml, jinja2, python-dotenv, jsonschema, aiosqlite, qdrant-client
|
||||
- **`Makefile`** — цели: duck-up, duck-llama-main, duck-api, duck-dev, duck-smoke, duck-test, duck-verify
|
||||
- **`data/duck.sqlite3`** — рабочая БД SQLite
|
||||
- **`workspace/`** — рабочая директория для инструментов
|
||||
|
||||
---
|
||||
|
||||
## 5. План разработки (из `docs/superpowers/plans/2026-05-19-ducklm-runtime.md`)
|
||||
|
||||
План состоит из 4 задач:
|
||||
|
||||
### Task 1: Tests First
|
||||
- Написать smoke tests для всех компонентов
|
||||
- ✅ **Выполнено** — 18 тестов созданы
|
||||
|
||||
### Task 2: Runtime Core
|
||||
- pyproject.toml, .env.example, config/models.yaml
|
||||
- config.py, model_client.py, events/store.py, tasks/store.py, tasks/state.py
|
||||
- context_builder.py, runtime_loop.py, api.py
|
||||
- ✅ **Выполнено** — все компоненты реализованы
|
||||
|
||||
### Task 3: Stage Adapters
|
||||
- tools/*, approvals/service.py, skills/registry.py
|
||||
- experience/recorder.py, reflection.py, memory/*
|
||||
- schemas/action_directive.schema.json
|
||||
- ✅ **Выполнено** — все компоненты реализованы
|
||||
|
||||
### Task 4: Project Surface
|
||||
- scripts/llama/*, scripts/verify/*, scripts/bench/*
|
||||
- web/templates/*, web/static/*
|
||||
- skills/analyze_project/*
|
||||
- docker-compose.memory.yml, Makefile, README.md, docs/*
|
||||
- ✅ **Выполнено** — все компоненты реализованы
|
||||
|
||||
---
|
||||
|
||||
## 6. Отступления от плана / дополнительные возможности
|
||||
|
||||
### 6.1. Что добавлено сверх плана
|
||||
|
||||
1. **ConversationStore** — полноценное управление диалогами (conversations + conversation_messages таблицы), чего не было явно в плане Task 2. План упоминал только tasks и events.
|
||||
|
||||
2. **Streaming API** — `POST /v1/chat/stream` с SSE, `POST /v1/tasks/{id}/continue/stream`, `POST /v1/tasks/{id}/password/stream`. В плане не было явно указано streaming.
|
||||
|
||||
3. **Password flow** — полный цикл запроса sudo-пароля: `requires_password` → `tool_password_requested` → `/v1/tasks/{id}/password/stream`. В плане не было детализировано.
|
||||
|
||||
4. **Activity Drawer в WebChat** — боковая панель с вкладками Events/Commands/Memory, инлайн-терминалы для инструментов, инлайн-одобрения. Значительно больше, чем «пустая WebChat-страница» из этапа 1 ТЗ.
|
||||
|
||||
5. **Command Audit** — отдельный тип события `command_audit` для shell-команд с полной метаданной (action_type, risk_level, blocked, approved, returncode).
|
||||
|
||||
6. **Scope-aware Memory** — трёхуровневая система скоупов (global/workspace/conversation) с автоматическим infer_scope.
|
||||
|
||||
7. **Skill update proposals** — автоматическая запредложений по обновлению скиллов в `skills/_proposals/`.
|
||||
|
||||
8. **18 тестов вместо 11 запланированных** — добавлены: `test_chat_api`, `test_conversations`, `test_runtime_reasoning`, `test_runtime_tools`, `test_llama_server_connection`, `test_llama_service_script`, `test_memory_store`.
|
||||
|
||||
### 6.2. Что не реализовано (или реализовано частично)
|
||||
|
||||
1. **MemoryPolicy — заглушка.** `MemoryPolicy.classify()` всегда возвращает `should_store=False`. Нет LLM-классификации для автоматического сохранения памяти.
|
||||
|
||||
2. **ContextBuilder — минимальный.** Нет суммаризации старых events, нет обрезки по token budget, нет приоритизации контекста. Просто склеивает memory + history + user message.
|
||||
|
||||
3. **Critic не вызывается автоматически.** `Reflection.reflect()` есть, но не интегрирован в RuntimeLoop — нет автоматической рефлексии после завершения задачи.
|
||||
|
||||
4. **Summary роль не используется.** Нет автоматической суммаризации контекста при превышении budget.
|
||||
|
||||
5. **Coder роль не используется в основном потоке.** RuntimeLoop вызывает только action и thinker.
|
||||
|
||||
6. **Recall роль не определена в конфиге.** В ТЗ упоминается recall, но в `config/models.yaml` её нет.
|
||||
|
||||
7. **Sys_util роль не определена в конфиге.** Аналогично.
|
||||
|
||||
8. **VectorMemory не интегрирован в RuntimeLoop.** Qdrant-поиск не подключён к основному циклу (MemoryStore использует LIKE-поиск, а не векторный).
|
||||
|
||||
9. **WebChat — светлая тема.** В памяти пользователя указано предпочтение тёмной темы, но CSS реализован светлый (`color-scheme: light`, белый фон).
|
||||
|
||||
10. **Нет CLI.** Упомянуто в ТЗ как необязательное, но если понадобится — нужно делать.
|
||||
|
||||
11. **Нет автоматического применения skill patches.** Предложения пишутся в `skills/_proposals/`, но не применяются автоматически.
|
||||
|
||||
### 6.3. Технические заметки
|
||||
|
||||
- **Модель:** Qwen3.6 35B A3B, два варианта — nonMTP (основной, порт 8081) и MTP (экспериментальный, порт 8085)
|
||||
- **GPU:** Radeon RX580, Vulkan backend, 20 GPU layers
|
||||
- **llama-server бинарник:** `./vendor/llama.cpp/build/bin/llama-server`
|
||||
- **ctx_size в .env:** 4096 (хотя в коде Settings дефолт 65536)
|
||||
- **reasoning-budget:** 512 в .env, `--reasoning-budget 512 --cache-ram 0`
|
||||
- **Python:** 3.13 (по путям `__pycache__`)
|
||||
|
||||
---
|
||||
|
||||
## 7. Структура базы данных (SQLite)
|
||||
|
||||
Таблицы:
|
||||
- **tasks** — задачи (task_id, status, user_message, workspace, debug, final_response, created_at, updated_at)
|
||||
- **events** — события (id, task_id, sequence, event_type, payload_json, created_at)
|
||||
- **conversations** — диалоги (id, conversation_id, title, workspace, created_at, updated_at)
|
||||
- **conversation_messages** — сообщения диалогов (id, conversation_id, role, content, reasoning_content, task_id, status, created_at)
|
||||
- **approvals** — согласования (id, approval_id, task_id, action_hash, normalized_action_json, status, decision, created_at, updated_at)
|
||||
- **experience_records** — записи опыта (id, task_id, skill_id, summary, result, what_worked_json, what_failed_json, reusable_lesson, suggested_skill_patch, confidence, created_at)
|
||||
- **memories** — память (id, memory_id, text, scope, workspace, conversation_id, memory_type, importance, metadata_json, created_at, updated_at)
|
||||
|
||||
---
|
||||
|
||||
## 8. Когнитивный цикл (как работает RuntimeLoop)
|
||||
|
||||
1. Пользователь отправляет сообщение → `POST /v1/chat` или `/v1/chat/stream`
|
||||
2. Создаётся Task + событие `task_created`
|
||||
3. ContextBuilder собирает сообщения (memory + history + user message)
|
||||
4. **Action loop** (до 4 итераций):
|
||||
- Модель `action` генерирует JSON directive (schema: action_directive.schema.json)
|
||||
- ToolGateway выполняет каждый action через соответствующий инструмент
|
||||
- Если команда требует approval → пауза, создание Approval, ожидание решения
|
||||
- Если sudo → запрос пароля
|
||||
- Результаты собираются как tool_observations
|
||||
5. Thinker получает все tool_observations и формирует финальный ответ
|
||||
6. Задача завершена → `task_completed`
|
||||
7. (Опционально) Reflection через critic — **не автоматизировано**
|
||||
|
||||
---
|
||||
|
||||
## 9. Статус готовности
|
||||
|
||||
| Компонент | Статус |
|
||||
|-----------|--------|
|
||||
| Конфигурация | ✅ Готово |
|
||||
| ModelClient | ✅ Готово |
|
||||
| TaskStore / EventStore | ✅ Готово |
|
||||
| ConversationStore | ✅ Готово |
|
||||
| RuntimeLoop | ✅ Готово |
|
||||
| ContextBuilder | ⚠️ Минимальный |
|
||||
| ToolGateway + Tools | ✅ Готово |
|
||||
| ApprovalService | ✅ Готово |
|
||||
| SkillRegistry | ✅ Готово |
|
||||
| ExperienceRecorder | ✅ Готово |
|
||||
| Reflection | ⚠️ Не интегрирован в loop |
|
||||
| MemoryStore (SQLite) | ✅ Готово |
|
||||
| MemoryPolicy | ✅ LLM-based (Phase 1) |
|
||||
| VectorMemory (Qdrant) | ✅ Интегрирован (Phase 5) |
|
||||
| FastAPI API | ✅ Готово |
|
||||
| WebChat UI | ✅ Готово (светлая тема) |
|
||||
| Streaming | ✅ Готово |
|
||||
| Password flow | ✅ Готово |
|
||||
| Smoke tests | ✅ 74 теста |
|
||||
| Docs | ✅ 12 файлов |
|
||||
| Scripts | ✅ Готово |
|
||||
|
||||
---
|
||||
|
||||
## Архитектура эмбеддингов
|
||||
|
||||
### Локальная модель (основной режим)
|
||||
- **Модель**: `all-MiniLM-L6-v2` (sentence-transformers, 384 размерности)
|
||||
- **Расположение**: `./models/all-MiniLM-L6-v2/` (safetensors формат)
|
||||
- **Библиотека**: `sentence-transformers` (добавлен в pyproject.toml)
|
||||
- **Использование**: `VectorMemory._local_embed()` — загрузка модели через `SentenceTransformer`, кодирование в thread pool
|
||||
|
||||
### Remote endpoint (fallback)
|
||||
- **Endpoint**: `/v1/embeddings` на llama-server или OpenAI-совместимом сервере
|
||||
- **Использование**: `VectorMemory._remote_embed()` — HTTP POST запрос
|
||||
|
||||
### Поток данных
|
||||
1. Зача завершена → `_run_memory_policy()` → LLM классифицирует → `MemoryDecision`
|
||||
2. Если `should_store=True` → `MemoryStore.add()` (SQLite) + `VectorMemory.add_memory()` (Qdrant)
|
||||
3. При следующем запросе → `MemoryStore.relevant()` (SQLite LIKE) + `VectorMemory.search_memory()` (semantic)
|
||||
4. Recall-роль фильтрует релевантные воспоминания через LLM
|
||||
|
||||
### Зависимости
|
||||
- `sentence-transformers` — для локальной модели
|
||||
- `qdrant-client` — для Qdrant (уже был)
|
||||
- Qdrant запускается через `docker-compose.memory.yml` (порт 6333)
|
||||
| Docker (Qdrant) | ✅ Готово |
|
||||
|
||||
**Общий вывод:** Все 4 задачи плана реализованы. Система представляет собой работающий skeleton с полным когнитивным циклом. Основные направления для дальнейшего развития: интеграция рефлексии и summary в основной цикл, LLM-based MemoryPolicy, векторная память, тёмная тема, расширение ContextBuilder.
|
||||
1. Пройти live E2E checklist в WebChat на реальной модели.
|
||||
2. Если Qdrant нужен постоянно, добавить отдельную health-индикацию vector memory в `/v1/status`.
|
||||
3. При необходимости заменить keyword skill selection на LLM-based selection.
|
||||
4. Позже мигрировать FastAPI startup на lifespan.
|
||||
|
|
|
|||
|
|
@ -207,6 +207,7 @@ def create_app() -> FastAPI:
|
|||
body.debug,
|
||||
history_messages=history,
|
||||
memory_records=memory_records,
|
||||
skill_summary=await selected_skill_summary(body.message),
|
||||
)
|
||||
await conversations.add_message(
|
||||
conversation.conversation_id,
|
||||
|
|
@ -252,6 +253,21 @@ def create_app() -> FastAPI:
|
|||
if record.text
|
||||
]
|
||||
|
||||
async def selected_skill_summary(user_request: str) -> str | None:
|
||||
candidates = await skills.find_candidate_skills(user_request, limit=1)
|
||||
if not candidates:
|
||||
return None
|
||||
skill = candidates[0].skill
|
||||
parts = [
|
||||
f"{skill.id}: {skill.title}",
|
||||
skill.description,
|
||||
]
|
||||
if skill.procedure:
|
||||
parts.append("Procedure:\n" + skill.procedure.strip())
|
||||
if skill.success_criteria:
|
||||
parts.append("Success criteria:\n- " + "\n- ".join(skill.success_criteria))
|
||||
return "\n\n".join(part for part in parts if part)
|
||||
|
||||
@app.get("/v1/conversations")
|
||||
async def list_conversations() -> list[dict[str, Any]]:
|
||||
return [conversation.model_dump() for conversation in await conversations.list()]
|
||||
|
|
@ -327,7 +343,10 @@ def create_app() -> FastAPI:
|
|||
generation_stats = GenerationStats()
|
||||
try:
|
||||
messages = await runtime.context_builder.build_async_messages(
|
||||
task, history, memory_records
|
||||
task,
|
||||
history,
|
||||
memory_records,
|
||||
skill_summary=await selected_skill_summary(body.message),
|
||||
)
|
||||
yield runtime_status(
|
||||
task.task_id,
|
||||
|
|
@ -454,6 +473,7 @@ def create_app() -> FastAPI:
|
|||
"generation_stats": generation_stats.summary(),
|
||||
},
|
||||
)
|
||||
asyncio.create_task(runtime.complete_postprocessing(task.task_id, content))
|
||||
yield sse(
|
||||
"done",
|
||||
{
|
||||
|
|
@ -562,7 +582,10 @@ def create_app() -> FastAPI:
|
|||
tool_observation = await runtime._run_approved_or_denied_action(
|
||||
task_id, approval.normalized_action, approval.decision
|
||||
)
|
||||
messages = await runtime.context_builder.build_async_messages(task)
|
||||
messages = await runtime.context_builder.build_async_messages(
|
||||
task,
|
||||
skill_summary=await selected_skill_summary(task.user_message),
|
||||
)
|
||||
tool_observations = [tool_observation]
|
||||
yield runtime_status(
|
||||
task_id,
|
||||
|
|
@ -682,6 +705,7 @@ def create_app() -> FastAPI:
|
|||
"generation_stats": generation_stats.summary(),
|
||||
},
|
||||
)
|
||||
asyncio.create_task(runtime.complete_postprocessing(task_id, content))
|
||||
yield sse(
|
||||
"done",
|
||||
{
|
||||
|
|
@ -755,7 +779,10 @@ def create_app() -> FastAPI:
|
|||
approval.decision,
|
||||
password=body.password,
|
||||
)
|
||||
messages = await runtime.context_builder.build_async_messages(task)
|
||||
messages = await runtime.context_builder.build_async_messages(
|
||||
task,
|
||||
skill_summary=await selected_skill_summary(task.user_message),
|
||||
)
|
||||
tool_observations = [tool_observation]
|
||||
yield runtime_status(
|
||||
task_id,
|
||||
|
|
@ -849,6 +876,7 @@ def create_app() -> FastAPI:
|
|||
"generation_stats": generation_stats.summary(),
|
||||
},
|
||||
)
|
||||
asyncio.create_task(runtime.complete_postprocessing(task_id, content))
|
||||
yield sse(
|
||||
"done",
|
||||
{
|
||||
|
|
|
|||
|
|
@ -61,6 +61,7 @@ class RuntimeLoop:
|
|||
debug: bool = False,
|
||||
history_messages: list[dict[str, str]] | None = None,
|
||||
memory_records: list[dict[str, str]] | None = None,
|
||||
skill_summary: str | None = None,
|
||||
reflect: bool = True,
|
||||
) -> ChatResult:
|
||||
task = await self.task_store.create_task(message, workspace, debug)
|
||||
|
|
@ -71,7 +72,7 @@ class RuntimeLoop:
|
|||
)
|
||||
try:
|
||||
messages = await self.context_builder.build_async_messages(
|
||||
task, history_messages, memory_records
|
||||
task, history_messages, memory_records, skill_summary=skill_summary
|
||||
)
|
||||
tool_observations = await self._run_action_loop(task.task_id, messages, workspace)
|
||||
if any(observation.get("requires_approval") for observation in tool_observations):
|
||||
|
|
@ -130,9 +131,7 @@ class RuntimeLoop:
|
|||
"reasoning_content": response.reasoning_content,
|
||||
},
|
||||
)
|
||||
await self._run_memory_policy(task.task_id, response.content)
|
||||
if reflect:
|
||||
await self._run_reflection(task.task_id)
|
||||
await self.complete_postprocessing(task.task_id, response.content, reflect=reflect)
|
||||
return ChatResult(
|
||||
task_id=task.task_id,
|
||||
status="completed",
|
||||
|
|
@ -151,6 +150,13 @@ class RuntimeLoop:
|
|||
reasoning_content=None,
|
||||
)
|
||||
|
||||
async def complete_postprocessing(
|
||||
self, task_id: str, final_response: str, reflect: bool = True
|
||||
) -> None:
|
||||
await self._run_memory_policy(task_id, final_response)
|
||||
if reflect:
|
||||
await self._run_reflection(task_id)
|
||||
|
||||
async def continue_after_approval(self, task_id: str, approval_id: str) -> ChatResult:
|
||||
if self.approval_service is None:
|
||||
return ChatResult(
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
from fastapi.testclient import TestClient
|
||||
import json
|
||||
import re
|
||||
import time
|
||||
|
||||
from duck_core.model_client import ModelResponse
|
||||
|
||||
|
|
@ -55,6 +56,79 @@ def test_stream_chat_endpoint_emits_sse_reasoning_and_content(tmp_path, monkeypa
|
|||
assert "answer" in body
|
||||
|
||||
|
||||
def test_stream_chat_runs_memory_policy_and_reflection_after_completion(tmp_path, monkeypatch):
|
||||
monkeypatch.setenv("DUCK_DB_PATH", str(tmp_path / "duck.sqlite3"))
|
||||
|
||||
async def fake_chat(self, role, messages, temperature=None, max_output_tokens=None, response_format=None):
|
||||
if role == "action":
|
||||
content = {
|
||||
"kind": "action_directive",
|
||||
"intent": "answer directly",
|
||||
"risk_level": "none",
|
||||
"actions": [],
|
||||
}
|
||||
elif role == "memory_policy":
|
||||
content = {
|
||||
"should_store": True,
|
||||
"memory_type": "preference",
|
||||
"summary": "User wants streamed chats to retain memory.",
|
||||
"importance": 0.8,
|
||||
"scope": "workspace",
|
||||
"metadata": {},
|
||||
}
|
||||
elif role == "critic":
|
||||
return ModelResponse(
|
||||
role=role,
|
||||
model="local-main",
|
||||
content="Task completed. Reusable lesson: streamed tasks need post-processing.",
|
||||
reasoning_content=None,
|
||||
raw={},
|
||||
latency_ms=1.0,
|
||||
)
|
||||
else:
|
||||
raise AssertionError(f"unexpected role: {role}")
|
||||
return ModelResponse(
|
||||
role=role,
|
||||
model="local-main",
|
||||
content=json.dumps(content),
|
||||
reasoning_content=None,
|
||||
raw={},
|
||||
latency_ms=1.0,
|
||||
)
|
||||
|
||||
async def fake_stream_chat(self, role, messages):
|
||||
assert role == "thinker"
|
||||
yield {"type": "content_delta", "delta": "streamed answer"}
|
||||
|
||||
monkeypatch.setattr("duck_core.model_client.ModelClient.chat", fake_chat)
|
||||
monkeypatch.setattr("duck_core.model_client.ModelClient.stream_chat", fake_stream_chat)
|
||||
with TestClient(create_app()) as client:
|
||||
with client.stream(
|
||||
"POST",
|
||||
"/v1/chat/stream",
|
||||
json={"message": "remember this", "workspace": str(tmp_path), "debug": True},
|
||||
) as response:
|
||||
body = "".join(response.iter_text())
|
||||
task_id = re.search(r'"task_id"\s*:\s*"([^"]+)"', body).group(1)
|
||||
events = []
|
||||
for _ in range(20):
|
||||
events = client.get(f"/v1/tasks/{task_id}/events").json()
|
||||
if any(event["event_type"] == "reflection_completed" for event in events):
|
||||
break
|
||||
time.sleep(0.05)
|
||||
event_types = [event["event_type"] for event in events]
|
||||
memory = client.get("/v1/memory", params={"workspace": str(tmp_path)}).json()
|
||||
experience = client.get("/v1/experience").json()
|
||||
|
||||
assert response.status_code == 200
|
||||
assert "event: done" in body
|
||||
assert "memory_policy_decision" in event_types
|
||||
assert "memory_stored" in event_types
|
||||
assert "reflection_completed" in event_types
|
||||
assert memory["results"][0]["text"] == "User wants streamed chats to retain memory."
|
||||
assert len(experience) == 1
|
||||
|
||||
|
||||
def test_stream_chat_endpoint_executes_tool_before_streaming_answer(tmp_path, monkeypatch):
|
||||
monkeypatch.setenv("DUCK_DB_PATH", str(tmp_path / "duck.sqlite3"))
|
||||
(tmp_path / "note.txt").write_text("stream tool content")
|
||||
|
|
@ -115,6 +189,49 @@ def test_stream_chat_endpoint_executes_tool_before_streaming_answer(tmp_path, mo
|
|||
assert "event: done" in body
|
||||
|
||||
|
||||
def test_stream_chat_injects_candidate_skill_summary(tmp_path, monkeypatch):
|
||||
monkeypatch.setenv("DUCK_DB_PATH", str(tmp_path / "duck.sqlite3"))
|
||||
captured_messages = []
|
||||
|
||||
async def fake_chat(self, role, messages, temperature=None, max_output_tokens=None, response_format=None):
|
||||
assert role == "action"
|
||||
captured_messages.extend(messages)
|
||||
return ModelResponse(
|
||||
role=role,
|
||||
model="local-main",
|
||||
content=json.dumps(
|
||||
{
|
||||
"kind": "action_directive",
|
||||
"intent": "answer directly",
|
||||
"risk_level": "none",
|
||||
"actions": [],
|
||||
}
|
||||
),
|
||||
reasoning_content=None,
|
||||
raw={},
|
||||
latency_ms=1.0,
|
||||
)
|
||||
|
||||
async def fake_stream_chat(self, role, messages):
|
||||
yield {"type": "content_delta", "delta": "ok"}
|
||||
|
||||
monkeypatch.setattr("duck_core.model_client.ModelClient.chat", fake_chat)
|
||||
monkeypatch.setattr("duck_core.model_client.ModelClient.stream_chat", fake_stream_chat)
|
||||
client = TestClient(create_app())
|
||||
|
||||
with client.stream(
|
||||
"POST",
|
||||
"/v1/chat/stream",
|
||||
json={"message": "analyze repository structure", "workspace": str(tmp_path), "debug": True},
|
||||
) as response:
|
||||
body = "".join(response.iter_text())
|
||||
|
||||
assert response.status_code == 200
|
||||
assert "event: done" in body
|
||||
assert any("Active skill:" in message["content"] for message in captured_messages)
|
||||
assert any("analyze_project" in message["content"] for message in captured_messages)
|
||||
|
||||
|
||||
def test_stream_chat_requests_approval_for_directory_outside_workspace(tmp_path, monkeypatch):
|
||||
monkeypatch.setenv("DUCK_DB_PATH", str(tmp_path / "duck.sqlite3"))
|
||||
workspace = tmp_path / "workspace"
|
||||
|
|
|
|||
Loading…
Reference in New Issue