many fixes
This commit is contained in:
+26
-4
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>DuckLM WebChat</title>
|
||||
<link rel="stylesheet" href="/static/style.css">
|
||||
<link rel="stylesheet" href="/static/style.css?v=20260523-runtime-status">
|
||||
</head>
|
||||
<body>
|
||||
<div class="app-shell">
|
||||
@@ -53,7 +53,10 @@
|
||||
</section>
|
||||
|
||||
<section class="status-panel" aria-labelledby="status-title">
|
||||
<h2 id="status-title">Runtime</h2>
|
||||
<div class="panel-heading">
|
||||
<h2 id="status-title">Runtime</h2>
|
||||
<button id="refresh-runtime" type="button" title="Refresh runtime status">R</button>
|
||||
</div>
|
||||
<dl>
|
||||
<div>
|
||||
<dt>API</dt>
|
||||
@@ -63,6 +66,18 @@
|
||||
<dt>Model</dt>
|
||||
<dd id="model-status">checking</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Vector</dt>
|
||||
<dd id="vector-status">checking</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Embed</dt>
|
||||
<dd id="embedding-status">checking</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Roles</dt>
|
||||
<dd id="roles-status">checking</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Last task</dt>
|
||||
<dd id="task-status">none</dd>
|
||||
@@ -146,6 +161,6 @@
|
||||
</section>
|
||||
</aside>
|
||||
</div>
|
||||
<script src="/static/app.js"></script>
|
||||
<script src="/static/app.js?v=20260523-runtime-status"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title>DuckLM Memory</title>
|
||||
<link rel="stylesheet" href="/static/style.css">
|
||||
<link rel="stylesheet" href="/static/style.css?v=20260523-runtime-status">
|
||||
</head>
|
||||
<body>
|
||||
<main class="simple-page">
|
||||
@@ -28,6 +28,6 @@
|
||||
<div id="memory-results" class="memory-list"></div>
|
||||
</section>
|
||||
</main>
|
||||
<script src="/static/app.js"></script>
|
||||
<script src="/static/app.js?v=20260523-runtime-status"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
Reference in New Issue
Block a user