diff --git a/CURRENT_STATE.md b/CURRENT_STATE.md index 0258725..9351b05 100644 --- a/CURRENT_STATE.md +++ b/CURRENT_STATE.md @@ -69,6 +69,7 @@ WebChat доступен через FastAPI на `http://127.0.0.1:8000/`. - reflection - experience records - Skill candidate selection теперь используется в обычном и streaming chat. +- `scripts/duck.sh` и `scripts/duck-mtp.sh` управляют всем локальным стеком: Qdrant, llama-server и DuckLM API. - `scripts/duck.sh status --probe` и `scripts/duck-mtp.sh status --probe` показывают live-состояние DuckLM runtime, model backend и vector memory. - Structured utility-outputs валидируются локально по JSON schema; это защищает tool loop и memory writes от мусора модели. - Live E2E выявил и исправил два runtime-дефекта: большие stdout больше не раздувают следующий planning prompt, повторяющиеся identical actions больше не исполняются повторно. diff --git a/README.md b/README.md index a511f72..165a2b5 100644 --- a/README.md +++ b/README.md @@ -14,10 +14,14 @@ bash scripts/duck.sh start Open `http://127.0.0.1:8000/`. +`duck.sh` starts and stops the local Qdrant vector memory service together with +`llama-server` and the DuckLM API. Use `status --probe` for live backend checks. + Useful commands: ```bash bash scripts/duck.sh status +bash scripts/duck.sh status --probe bash scripts/duck.sh logs --follow bash scripts/duck.sh restart bash scripts/duck.sh stop diff --git a/docs/how_to_run.md b/docs/how_to_run.md index 61bb29f..603397b 100644 --- a/docs/how_to_run.md +++ b/docs/how_to_run.md @@ -26,8 +26,9 @@ chat and accept that it can slow down the next request. bash scripts/duck.sh start ``` -This starts both processes: +This starts the local stack: +- Qdrant vector memory on `http://127.0.0.1:6333/` - `llama-server` on `http://127.0.0.1:8081/v1` - DuckLM API/WebChat on `http://127.0.0.1:8000/` @@ -97,5 +98,4 @@ curl http://127.0.0.1:8000/v1/approvals/pending ```bash bash scripts/duck.sh stop -docker compose -f docker-compose.memory.yml down ``` diff --git a/docs/memory_architecture.md b/docs/memory_architecture.md index 4f40f7f..87c7e4c 100644 --- a/docs/memory_architecture.md +++ b/docs/memory_architecture.md @@ -1,5 +1,68 @@ # Memory Architecture -Semantic memory uses Qdrant as the vector store. Embeddings come from `/v1/embeddings` when the model backend supports it. +DuckLM currently has two memory layers: -If embeddings are unavailable, `VectorMemory` fails explicitly with `EmbeddingsUnavailableError`; it does not invent a local embedding algorithm. +- SQLite memory in `duck_core.memory.store.MemoryStore` for durable structured records. +- Vector memory in `duck_core.memory.vector_memory.VectorMemory` for semantic search through Qdrant. + +## SQLite Memory + +SQLite is the primary durable store. Runtime writes memory records after +`memory_policy` decides that a completed task contains reusable information. +Manual memory records can also be added through `/v1/memory` and the WebChat +memory drawer. + +SQLite memory remains available even when Qdrant is down. + +## Vector Memory + +Vector memory stores the same useful memory summaries in Qdrant when vector +storage is configured and reachable. Qdrant is managed by the local service +scripts: + +```bash +bash scripts/duck.sh start +bash scripts/duck.sh status --probe +bash scripts/duck.sh stop +``` + +The MTP stack uses the same memory lifecycle through `scripts/duck-mtp.sh`. + +## Embeddings + +The default embedding source is a local `sentence-transformers` model: + +```text +./models/all-MiniLM-L6-v2 +``` + +`VectorMemory` lazy-loads that model only when it needs to write or search +vectors. Health checks do not load the embedding model; they only probe Qdrant. + +A remote OpenAI-compatible embeddings endpoint can be used by setting +`embeddings_base_url`, but the normal local stack does not rely on +`llama-server` embeddings. + +If no embedding source is configured, `VectorMemory` raises +`EmbeddingsUnavailableError`. It does not silently invent fallback embeddings. + +## Status And Verification + +Runtime status is available through: + +```bash +curl --noproxy '*' 'http://127.0.0.1:8000/v1/status?probe=true' +``` + +`scripts/duck.sh status --probe` prints the same backend result plus Docker +Compose state for Qdrant. WebChat also shows model and vector memory state in +the Runtime panel. + +The live smoke test for Qdrant write/search is: + +```bash +.venv/bin/python -m pytest tests/smoke/test_vector_memory_live.py -q +``` + +The test skips when Qdrant is not reachable, and runs a real add/search cycle +when the local stack is up. diff --git a/duck_core/api.py b/duck_core/api.py index a2f4296..1c428dd 100644 --- a/duck_core/api.py +++ b/duck_core/api.py @@ -245,12 +245,15 @@ def create_app() -> FastAPI: memory_records = await relevant_memory( body.message, conversation.workspace, conversation.conversation_id ) + memory_sufficient_to_answer = False # Use recall-role to filter relevant memories via LLM if memory_records and runtime.context_builder._model_client is not None: try: - memory_records = await runtime.context_builder.recall_relevant_memory( + recall_decision = await runtime.context_builder.recall_relevant_memory_decision( body.message, memory_records ) + memory_records = recall_decision.records + memory_sufficient_to_answer = recall_decision.sufficient_to_answer except Exception: pass # Fallback to unfiltered memory_records result = await runtime.run_chat( @@ -262,6 +265,7 @@ def create_app() -> FastAPI: skill_summary=await selected_skill_summary(body.message), reasoning=body.reasoning, reflect=bool(settings.enable_reflection), + skip_action_loop=memory_sufficient_to_answer, ) await conversations.add_message( conversation.conversation_id, @@ -377,6 +381,16 @@ def create_app() -> FastAPI: memory_records = await relevant_memory( body.message, conversation.workspace, conversation.conversation_id ) + memory_sufficient_to_answer = False + if memory_records and runtime.context_builder._model_client is not None: + try: + recall_decision = await runtime.context_builder.recall_relevant_memory_decision( + body.message, memory_records + ) + memory_records = recall_decision.records + memory_sufficient_to_answer = recall_decision.sufficient_to_answer + except Exception: + pass task = await task_store.create_task( body.message, conversation.workspace, body.debug ) @@ -407,9 +421,17 @@ def create_app() -> FastAPI: "planning", "Планирую, нужны ли локальные действия...", ) - tool_observations = await runtime._run_action_loop( - task.task_id, messages, conversation.workspace - ) + tool_observations = [] + if not memory_sufficient_to_answer: + tool_observations = await runtime._run_action_loop( + task.task_id, messages, conversation.workspace + ) + else: + await event_store.append( + task.task_id, + "action_loop_skipped", + {"reason": "recall_sufficient_to_answer"}, + ) if tool_observations: yield runtime_status( task.task_id, diff --git a/duck_core/context_builder.py b/duck_core/context_builder.py index 6c50757..aafcc4e 100644 --- a/duck_core/context_builder.py +++ b/duck_core/context_builder.py @@ -2,6 +2,7 @@ from __future__ import annotations import json import logging +from dataclasses import dataclass from typing import Any from duck_core.tasks.state import TaskState @@ -12,6 +13,13 @@ logger = logging.getLogger(__name__) _CHARS_PER_TOKEN = 4 +@dataclass +class RecallDecision: + records: list[dict[str, str]] + sufficient_to_answer: bool = False + reasoning: str = "" + + def estimate_tokens(text: str) -> int: """Rough token estimate based on character count.""" return max(len(text) // _CHARS_PER_TOKEN, 1) @@ -64,20 +72,27 @@ class ContextBuilder: Returns only the memories that are relevant to the query. Falls back to returning all records if LLM is unavailable. """ + return (await self.recall_relevant_memory_decision(query, memory_records)).records + + async def recall_relevant_memory_decision( + self, + query: str, + memory_records: list[dict[str, str]], + ) -> RecallDecision: if not memory_records or self._model_client is None: - return memory_records + return RecallDecision(records=memory_records, sufficient_to_answer=False) try: return await self._llm_recall(query, memory_records) except Exception as exc: logger.warning("Recall failed, using all memories: %s", exc) - return memory_records + return RecallDecision(records=memory_records, sufficient_to_answer=False) async def _llm_recall( self, query: str, memory_records: list[dict[str, str]], - ) -> list[dict[str, str]]: + ) -> RecallDecision: """Call recall-role LLM to identify relevant memories.""" memories_text = "\n".join( f"[{m.get('memory_id', i)}] {m.get('text', '')}" @@ -98,15 +113,19 @@ class ContextBuilder: "name": "recall_result", "schema": { "type": "object", - "required": ["relevant_ids", "reasoning"], - "additionalProperties": False, - "properties": { - "relevant_ids": { - "type": "array", - "items": {"type": "string"}, - }, - "reasoning": {"type": "string"}, + "required": ["relevant_ids", "reasoning"], + "additionalProperties": False, + "properties": { + "relevant_ids": { + "type": "array", + "items": {"type": "string"}, }, + "sufficient_to_answer": { + "type": "boolean", + "description": "True when selected memories are enough to answer without local tools/actions.", + }, + "reasoning": {"type": "string"}, + }, }, "strict": True, }, @@ -115,8 +134,16 @@ class ContextBuilder: data = json.loads(response.content) relevant_ids = set(data.get("relevant_ids", [])) if not relevant_ids: - return [] - return [m for i, m in enumerate(memory_records) if m.get("memory_id", str(i)) in relevant_ids] + return RecallDecision(records=[], sufficient_to_answer=False, reasoning=str(data.get("reasoning", ""))) + records = [ + m for i, m in enumerate(memory_records) + if m.get("memory_id", str(i)) in relevant_ids + ] + return RecallDecision( + records=records, + sufficient_to_answer=bool(data.get("sufficient_to_answer", False)) and bool(records), + reasoning=str(data.get("reasoning", "")), + ) def build_basic_messages( self, diff --git a/duck_core/runtime_loop.py b/duck_core/runtime_loop.py index f2d8541..43bc9eb 100644 --- a/duck_core/runtime_loop.py +++ b/duck_core/runtime_loop.py @@ -20,6 +20,7 @@ from duck_core.tools.gateway import ToolGateway logger = logging.getLogger(__name__) ACTION_DIRECTIVE_SCHEMA = load_json_schema("duck_core/schemas/action_directive.schema.json") MAX_TOOL_OBSERVATION_TEXT_CHARS = 2000 +MAX_MEMORY_TRANSCRIPT_CHARS = 6000 @dataclass @@ -67,6 +68,7 @@ class RuntimeLoop: skill_summary: str | None = None, reflect: bool = True, reasoning: ReasoningMode | None = None, + skip_action_loop: bool = False, ) -> ChatResult: task = await self.task_store.create_task(message, workspace, debug) await self.event_store.append( @@ -78,7 +80,15 @@ class RuntimeLoop: messages = await self.context_builder.build_async_messages( task, history_messages, memory_records, skill_summary=skill_summary ) - tool_observations = await self._run_action_loop(task.task_id, messages, workspace) + tool_observations = [] + if not skip_action_loop: + tool_observations = await self._run_action_loop(task.task_id, messages, workspace) + else: + await self.event_store.append( + task.task_id, + "action_loop_skipped", + {"reason": "recall_sufficient_to_answer"}, + ) if any(observation.get("requires_approval") for observation in tool_observations): await self.task_store.waiting_for_approval(task.task_id) await self.event_store.append( @@ -288,7 +298,8 @@ class RuntimeLoop: if self.memory_store is None: return try: - decision = await self.memory_policy.classify(final_response, task_id) + transcript = await self._build_memory_policy_transcript(task_id, final_response) + decision = await self.memory_policy.classify(transcript, task_id) await self.event_store.append( task_id, "memory_policy_decision", @@ -326,6 +337,47 @@ class RuntimeLoop: {"error": str(exc)}, ) + async def _build_memory_policy_transcript(self, task_id: str, final_response: str) -> str: + task = await self.task_store.get_task(task_id) + parts = [] + if task is not None: + parts.append(f"User message:\n{task.user_message}") + if task.workspace: + parts.append(f"Workspace:\n{task.workspace}") + parts.append(f"Assistant final response:\n{final_response}") + + event_summaries = [] + for event in await self.event_store.list_events(task_id): + summary = self._summarize_memory_event(event.event_type, event.payload) + if summary: + event_summaries.append(f"- {event.event_type}: {summary}") + if event_summaries: + parts.append("Relevant runtime events:\n" + "\n".join(event_summaries)) + + transcript = "\n\n".join(parts) + if len(transcript) > MAX_MEMORY_TRANSCRIPT_CHARS: + return transcript[:MAX_MEMORY_TRANSCRIPT_CHARS] + "\n...[truncated]" + return transcript + + def _summarize_memory_event(self, event_type: str, payload: dict[str, Any]) -> str: + if event_type == "action_directive": + intent = payload.get("intent") + hints = payload.get("memory_hints") or [] + if hints: + return f"intent={intent}; memory_hints={hints}" + return f"intent={intent}" if intent else "" + if event_type == "tool_call_finished": + result = payload.get("result") or {} + output = str(result.get("output") or result.get("error") or "").strip() + if len(output) > 500: + output = output[:500] + "...[truncated]" + return "{} ok={} {}".format(payload.get("tool"), result.get("ok"), output).strip() + if event_type == "tool_approval_requested": + return str(payload.get("reason") or payload.get("tool") or "") + if event_type == "tool_call_skipped": + return str(payload.get("reason") or "") + return "" + async def _run_reflection(self, task_id: str) -> None: """Run critic reflection on completed task and record experience.""" if self.experience_recorder is None: @@ -463,6 +515,18 @@ class RuntimeLoop: if seen_action_keys is not None: seen_action_keys.add(action_key) tool_name = str(action.get("tool", "")) + if tool_name not in gateway.tools: + await self.event_store.append( + task_id, + "tool_call_skipped", + { + "index": index, + "tool": tool_name, + "reason": "unknown_tool", + "action": action, + }, + ) + continue await self.event_store.append( task_id, "tool_call_started", diff --git a/duck_core/web/static/app.js b/duck_core/web/static/app.js index d2c46e1..4f3ad08 100644 --- a/duck_core/web/static/app.js +++ b/duck_core/web/static/app.js @@ -894,19 +894,46 @@ async function sendMessage() { async function checkRuntime() { try { - await jsonFetch("/health"); + const status = await jsonFetch("/v1/status?probe=true"); + const services = status.services || {}; + const llama = services.llama || {}; + const vector = services.vector_memory || {}; + const roles = Object.keys(status.models?.roles || {}).sort(); + setStatus("#api-status", "online", "ok"); + setStatus("#model-status", serviceStatusText(llama), serviceTone(llama)); + setStatus("#vector-status", serviceStatusText(vector), serviceTone(vector)); + setStatus("#embedding-status", compactEmbeddingSource(vector.embedding_source), vector.configured ? "ok" : "warn"); + setStatus("#roles-status", roles.length ? String(roles.length) : "none", roles.length ? "ok" : "warn"); } catch { setStatus("#api-status", "offline", "bad"); + setStatus("#model-status", "unknown", "bad"); + setStatus("#vector-status", "unknown", "bad"); + setStatus("#embedding-status", "unknown", "bad"); + setStatus("#roles-status", "unknown", "bad"); } +} - try { - const roles = await jsonFetch("/v1/models/ping"); - const ok = Object.values(roles).every((item) => item.ok); - setStatus("#model-status", ok ? "online" : "degraded", ok ? "ok" : "warn"); - } catch { - setStatus("#model-status", "offline", "bad"); +function serviceStatusText(service) { + if (!service || service.probed === false) return "not probed"; + if (service.ok === true) { + return service.latency_ms !== undefined ? `ok ${Math.round(service.latency_ms)}ms` : "ok"; } + if (service.ok === false) return "failed"; + return service.configured === false ? "disabled" : "unknown"; +} + +function serviceTone(service) { + if (!service || service.ok === false) return "bad"; + if (service.ok === true) return "ok"; + return service.configured === false ? "warn" : "neutral"; +} + +function compactEmbeddingSource(source) { + if (!source) return "unknown"; + if (source.startsWith("local:")) return source.slice(6).split("/").filter(Boolean).pop() || "local"; + if (source.startsWith("remote:")) return "remote"; + return source; } function bindChat() { @@ -928,6 +955,9 @@ function bindChat() { document.querySelector("#reload-chat")?.addEventListener("click", () => { if (state.currentConversationId) selectConversation(state.currentConversationId).catch(console.error); }); + document.querySelector("#refresh-runtime")?.addEventListener("click", () => { + checkRuntime().catch(console.error); + }); document.querySelector("#activity-open")?.addEventListener("click", () => { openActivity("events"); }); @@ -1114,7 +1144,9 @@ async function renderMemoryPageResults(query) { if (!results.length) { const empty = document.createElement("p"); empty.className = "compact-empty"; - empty.textContent = "No memories found."; + empty.textContent = query.trim() + ? "No matching memories." + : "No memories yet. Add one here or let DuckLM store useful task results automatically."; container.append(empty); return; } diff --git a/duck_core/web/templates/index.html b/duck_core/web/templates/index.html index 3d02776..dfc97f8 100644 --- a/duck_core/web/templates/index.html +++ b/duck_core/web/templates/index.html @@ -4,7 +4,7 @@