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_N_GPU_LAYERS=20
|
||||||
DUCK_PARALLEL=1
|
DUCK_PARALLEL=1
|
||||||
DUCK_LLAMA_DEVICE=Vulkan0
|
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_HOST=127.0.0.1
|
||||||
|
|
||||||
DUCK_API_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
|
Дата обновления: 2026-05-21
|
||||||
**Версия:** 0.2.0
|
Рабочая копия: `/home/mirivlad/git/ducklm`
|
||||||
**Расположение:** `~/git/ducklm_2`
|
Git remote: `origin/main`
|
||||||
|
|
||||||
---
|
## Общий статус
|
||||||
|
|
||||||
## Последние изменения (Phase 1-7)
|
DuckLM сейчас является рабочим локальным MVP/beta runtime поверх `llama-server`.
|
||||||
|
|
||||||
### Phase 1: MemoryPolicy — LLM-классификация памяти ✅
|
Система реализует основной цикл из `Ducklm.md`:
|
||||||
- `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`
|
|
||||||
|
|
||||||
### Phase 2: Рефлексия (Critic) — автоматический вызов ✅
|
```text
|
||||||
- `_run_reflection()` в RuntimeLoop — transcript из event store → critic → experience
|
сообщение -> задача -> контекст -> action directive -> tools -> observations
|
||||||
- Параметр `reflect: bool = True` в `run_chat()`
|
-> thinker response -> task events -> memory/reflection/experience
|
||||||
- `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-сервер, а полноценный когнитивный цикл:
|
|
||||||
|
|
||||||
```
|
|
||||||
состояние → контекст → мышление → намерение → действие → наблюдение → рефлексия → память → опыт
|
|
||||||
```
|
```
|
||||||
|
|
||||||
Ключевая идея: 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. Использование готовых компонентов
|
## Недавние исправления
|
||||||
|
|
||||||
| Компонент | Источник |
|
- WebChat теперь не выглядит зависшим во время action/planning: backend стримит `runtime_status`.
|
||||||
|-----------|----------|
|
- В чат больше не выводятся лишние дубли tool-output; tool events показываются компактно.
|
||||||
| LLM inference | `llama-server` (llama.cpp, собранный с Vulkan) |
|
- Внешние пути за пределами workspace не падают простой ошибкой, а требуют approval.
|
||||||
| Хранение состояния | SQLite (aiosqlite) |
|
- Streaming endpoints теперь запускают тот же post-processing, что и обычный `/v1/chat`:
|
||||||
| Векторная память | Qdrant (через docker-compose) |
|
- memory policy
|
||||||
| HTTP API | FastAPI |
|
- memory store/vector store
|
||||||
| Web-интерфейс | Jinja2 + ванильный JS |
|
- reflection
|
||||||
| Валидация данных | Pydantic |
|
- experience records
|
||||||
| Конфигурация | PyYAML + python-dotenv |
|
- 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)
|
- Qdrant и локальная embedding-модель должны быть доступны отдельно; при ошибках vector memory деградирует без падения runtime.
|
||||||
- **HTTP API** — для кодера, тестов и внешних агентов
|
- Token speed считается приближённо по текущему estimator, а не по tokenizer конкретной модели.
|
||||||
- CLI не входит в обязательную часть (если понадобится — тонкий клиент поверх HTTP API)
|
- 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). Различие между ролями задаётся комбинацией:
|
```bash
|
||||||
- system prompt
|
. .venv/bin/activate
|
||||||
- temperature
|
ruff check .
|
||||||
- max_output_tokens
|
pytest tests/smoke -q
|
||||||
- response_format / structured_output
|
git diff --check
|
||||||
- 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
|
|
||||||
```
|
```
|
||||||
|
|
||||||
Output limits по ролям:
|
Ожидаемый результат на 2026-05-21: smoke tests проходят.
|
||||||
- thinker: 8192
|
|
||||||
- critic: 4096
|
|
||||||
- coder: 16384
|
|
||||||
- action: 2048
|
|
||||||
- summary: 4096
|
|
||||||
|
|
||||||
---
|
## Запуск
|
||||||
|
|
||||||
## 3. Целевая архитектура (из ТЗ)
|
```bash
|
||||||
|
. .venv/bin/activate
|
||||||
```
|
bash scripts/llama/start_main.sh start
|
||||||
┌─────────────┐
|
python -m duck_core.api
|
||||||
│ 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
|
|
||||||
└────────────┘
|
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
Открыть WebChat:
|
||||||
|
|
||||||
## 4. Что реализовано (текущее состояние)
|
```text
|
||||||
|
http://127.0.0.1:8000/
|
||||||
|
```
|
||||||
|
|
||||||
### 4.1. Полностью реализованные компоненты
|
Проверить API:
|
||||||
|
|
||||||
#### Конфигурация и настройки
|
```bash
|
||||||
- **`duck_core/config.py`** — `Settings` dataclass, загрузка из `.env`, кэширование через `lru_cache`
|
curl --noproxy '*' http://127.0.0.1:8000/health
|
||||||
- **`config/models.yaml`** — 5 ролей (thinker, critic, coder, action, summary), все на `local-main` (порт 8081)
|
curl --noproxy '*' http://127.0.0.1:8000/v1/models/roles
|
||||||
- **`.env`** / **`.env.example`** — полная конфигурация путей, портов, GPU, Qdrant
|
```
|
||||||
|
|
||||||
#### 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)
|
1. Пройти live E2E checklist в WebChat на реальной модели.
|
||||||
- **`duck_core/tasks/state.py`** — `TaskState` (Pydantic модель)
|
2. Если Qdrant нужен постоянно, добавить отдельную health-индикацию vector memory в `/v1/status`.
|
||||||
- **`duck_core/tasks/store.py`** — `TaskStore`: create, update_status, complete, fail, cancel, waiting_for_approval, get, list
|
3. При необходимости заменить keyword skill selection на LLM-based selection.
|
||||||
- **`duck_core/events/store.py`** — `EventStore`: append (с авто-increment sequence), list_events, list_by_type
|
4. Позже мигрировать FastAPI startup на lifespan.
|
||||||
- **`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.
|
|
||||||
|
|
|
||||||
|
|
@ -207,6 +207,7 @@ def create_app() -> FastAPI:
|
||||||
body.debug,
|
body.debug,
|
||||||
history_messages=history,
|
history_messages=history,
|
||||||
memory_records=memory_records,
|
memory_records=memory_records,
|
||||||
|
skill_summary=await selected_skill_summary(body.message),
|
||||||
)
|
)
|
||||||
await conversations.add_message(
|
await conversations.add_message(
|
||||||
conversation.conversation_id,
|
conversation.conversation_id,
|
||||||
|
|
@ -252,6 +253,21 @@ def create_app() -> FastAPI:
|
||||||
if record.text
|
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")
|
@app.get("/v1/conversations")
|
||||||
async def list_conversations() -> list[dict[str, Any]]:
|
async def list_conversations() -> list[dict[str, Any]]:
|
||||||
return [conversation.model_dump() for conversation in await conversations.list()]
|
return [conversation.model_dump() for conversation in await conversations.list()]
|
||||||
|
|
@ -327,7 +343,10 @@ def create_app() -> FastAPI:
|
||||||
generation_stats = GenerationStats()
|
generation_stats = GenerationStats()
|
||||||
try:
|
try:
|
||||||
messages = await runtime.context_builder.build_async_messages(
|
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(
|
yield runtime_status(
|
||||||
task.task_id,
|
task.task_id,
|
||||||
|
|
@ -454,6 +473,7 @@ def create_app() -> FastAPI:
|
||||||
"generation_stats": generation_stats.summary(),
|
"generation_stats": generation_stats.summary(),
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
asyncio.create_task(runtime.complete_postprocessing(task.task_id, content))
|
||||||
yield sse(
|
yield sse(
|
||||||
"done",
|
"done",
|
||||||
{
|
{
|
||||||
|
|
@ -562,7 +582,10 @@ def create_app() -> FastAPI:
|
||||||
tool_observation = await runtime._run_approved_or_denied_action(
|
tool_observation = await runtime._run_approved_or_denied_action(
|
||||||
task_id, approval.normalized_action, approval.decision
|
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]
|
tool_observations = [tool_observation]
|
||||||
yield runtime_status(
|
yield runtime_status(
|
||||||
task_id,
|
task_id,
|
||||||
|
|
@ -682,6 +705,7 @@ def create_app() -> FastAPI:
|
||||||
"generation_stats": generation_stats.summary(),
|
"generation_stats": generation_stats.summary(),
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
asyncio.create_task(runtime.complete_postprocessing(task_id, content))
|
||||||
yield sse(
|
yield sse(
|
||||||
"done",
|
"done",
|
||||||
{
|
{
|
||||||
|
|
@ -755,7 +779,10 @@ def create_app() -> FastAPI:
|
||||||
approval.decision,
|
approval.decision,
|
||||||
password=body.password,
|
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]
|
tool_observations = [tool_observation]
|
||||||
yield runtime_status(
|
yield runtime_status(
|
||||||
task_id,
|
task_id,
|
||||||
|
|
@ -849,6 +876,7 @@ def create_app() -> FastAPI:
|
||||||
"generation_stats": generation_stats.summary(),
|
"generation_stats": generation_stats.summary(),
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
asyncio.create_task(runtime.complete_postprocessing(task_id, content))
|
||||||
yield sse(
|
yield sse(
|
||||||
"done",
|
"done",
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -61,6 +61,7 @@ class RuntimeLoop:
|
||||||
debug: bool = False,
|
debug: bool = False,
|
||||||
history_messages: list[dict[str, str]] | None = None,
|
history_messages: list[dict[str, str]] | None = None,
|
||||||
memory_records: list[dict[str, str]] | None = None,
|
memory_records: list[dict[str, str]] | None = None,
|
||||||
|
skill_summary: str | None = None,
|
||||||
reflect: bool = True,
|
reflect: bool = True,
|
||||||
) -> ChatResult:
|
) -> ChatResult:
|
||||||
task = await self.task_store.create_task(message, workspace, debug)
|
task = await self.task_store.create_task(message, workspace, debug)
|
||||||
|
|
@ -71,7 +72,7 @@ class RuntimeLoop:
|
||||||
)
|
)
|
||||||
try:
|
try:
|
||||||
messages = await self.context_builder.build_async_messages(
|
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)
|
tool_observations = await self._run_action_loop(task.task_id, messages, workspace)
|
||||||
if any(observation.get("requires_approval") for observation in tool_observations):
|
if any(observation.get("requires_approval") for observation in tool_observations):
|
||||||
|
|
@ -130,9 +131,7 @@ class RuntimeLoop:
|
||||||
"reasoning_content": response.reasoning_content,
|
"reasoning_content": response.reasoning_content,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
await self._run_memory_policy(task.task_id, response.content)
|
await self.complete_postprocessing(task.task_id, response.content, reflect=reflect)
|
||||||
if reflect:
|
|
||||||
await self._run_reflection(task.task_id)
|
|
||||||
return ChatResult(
|
return ChatResult(
|
||||||
task_id=task.task_id,
|
task_id=task.task_id,
|
||||||
status="completed",
|
status="completed",
|
||||||
|
|
@ -151,6 +150,13 @@ class RuntimeLoop:
|
||||||
reasoning_content=None,
|
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:
|
async def continue_after_approval(self, task_id: str, approval_id: str) -> ChatResult:
|
||||||
if self.approval_service is None:
|
if self.approval_service is None:
|
||||||
return ChatResult(
|
return ChatResult(
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
from fastapi.testclient import TestClient
|
from fastapi.testclient import TestClient
|
||||||
import json
|
import json
|
||||||
import re
|
import re
|
||||||
|
import time
|
||||||
|
|
||||||
from duck_core.model_client import ModelResponse
|
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
|
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):
|
def test_stream_chat_endpoint_executes_tool_before_streaming_answer(tmp_path, monkeypatch):
|
||||||
monkeypatch.setenv("DUCK_DB_PATH", str(tmp_path / "duck.sqlite3"))
|
monkeypatch.setenv("DUCK_DB_PATH", str(tmp_path / "duck.sqlite3"))
|
||||||
(tmp_path / "note.txt").write_text("stream tool content")
|
(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
|
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):
|
def test_stream_chat_requests_approval_for_directory_outside_workspace(tmp_path, monkeypatch):
|
||||||
monkeypatch.setenv("DUCK_DB_PATH", str(tmp_path / "duck.sqlite3"))
|
monkeypatch.setenv("DUCK_DB_PATH", str(tmp_path / "duck.sqlite3"))
|
||||||
workspace = tmp_path / "workspace"
|
workspace = tmp_path / "workspace"
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue