Import ducklm runtime
This commit is contained in:
@@ -0,0 +1,2 @@
|
||||
"""Runtime loop and execution coordination."""
|
||||
|
||||
@@ -0,0 +1,148 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from app.core.context_builder import ContextBuilder
|
||||
from app.core.contracts import ExecutionDirective, PermissionDecision, PermissionRequest, RuntimeEvent, TaskCheckpoint, UserTask
|
||||
from app.core.execution_engine import ExecutionEngine
|
||||
from app.core.async_router import AsyncRouter
|
||||
from app.events.event_bus import EventBus
|
||||
from app.events.event_types import CHECKPOINT_SAVED, CONTEXT_BUILT, TASK_AWAITING_PERMISSION, TASK_COMPLETED, TASK_FAILED, TASK_RECEIVED
|
||||
from app.core.permission_service import PermissionService
|
||||
from app.state.checkpoint_store import SQLiteCheckpointStore
|
||||
from app.state.task_state_store import SQLiteTaskStateStore
|
||||
|
||||
|
||||
class AsyncRuntimeLoop:
|
||||
"""Async runtime loop using LLM orchestrator."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
event_bus: EventBus,
|
||||
task_state_store: SQLiteTaskStateStore,
|
||||
checkpoint_store: SQLiteCheckpointStore,
|
||||
context_builder: ContextBuilder,
|
||||
router: AsyncRouter,
|
||||
execution_engine: ExecutionEngine,
|
||||
permission_service: PermissionService,
|
||||
memory_interface=None,
|
||||
) -> None:
|
||||
self._event_bus = event_bus
|
||||
self._task_state_store = task_state_store
|
||||
self._checkpoint_store = checkpoint_store
|
||||
self._context_builder = context_builder
|
||||
self._router = router
|
||||
self._execution_engine = execution_engine
|
||||
self._permission_service = permission_service
|
||||
self._memory_interface = memory_interface
|
||||
|
||||
async def run_task(self, task: UserTask) -> dict[str, object]:
|
||||
state = self._task_state_store.create_task(
|
||||
task.task_id,
|
||||
{
|
||||
"status": "received",
|
||||
"session_id": task.session_id,
|
||||
"plan": None,
|
||||
"task_input": task.input,
|
||||
"task_context": task.context,
|
||||
},
|
||||
)
|
||||
self._publish(task, TASK_RECEIVED, {"status": "received"})
|
||||
|
||||
checkpoint = TaskCheckpoint(task_id=task.task_id, status="received")
|
||||
self._checkpoint_store.save(checkpoint)
|
||||
self._publish(task, CHECKPOINT_SAVED, checkpoint.model_dump(mode="json"))
|
||||
|
||||
context = self._context_builder.build(task=task, checkpoint=checkpoint)
|
||||
self._publish(task, CONTEXT_BUILT, {"keys": sorted(context.keys())})
|
||||
|
||||
directive = await self._router.decide(state=state, context=context, task_id=task.task_id, session_id=task.session_id)
|
||||
|
||||
execution_result = await asyncio.to_thread(
|
||||
self._execution_engine.execute,
|
||||
task=task,
|
||||
directive=directive,
|
||||
)
|
||||
|
||||
state_patch = {"status": execution_result["status"], "last_directive": directive.model_dump(mode="json")}
|
||||
|
||||
if execution_result["status"] == "awaiting_permission":
|
||||
state_patch["pending_permission_request"] = execution_result["result"].get("permission_request")
|
||||
|
||||
self._task_state_store.update_task(task.task_id, state_patch)
|
||||
|
||||
status = execution_result["status"]
|
||||
|
||||
if status == "completed":
|
||||
self._publish(task, TASK_COMPLETED, {"directive": directive.model_dump(mode="json"), "execution_result": execution_result["result"]})
|
||||
elif status == "failed":
|
||||
self._publish(task, TASK_FAILED, {"error": execution_result.get("result", {}).get("error")})
|
||||
|
||||
checkpoint.status = status
|
||||
self._checkpoint_store.save(checkpoint)
|
||||
self._publish(task, CHECKPOINT_SAVED, checkpoint.model_dump(mode="json"))
|
||||
|
||||
# Save task and result to memory for session context
|
||||
self._save_to_memory(task, execution_result, status)
|
||||
|
||||
return {
|
||||
"task_id": task.task_id,
|
||||
"status": status,
|
||||
"directive": directive.model_dump(mode="json"),
|
||||
"result": execution_result.get("result"),
|
||||
"events": list(self._event_bus.get_task_events(task.task_id)),
|
||||
}
|
||||
|
||||
def _publish(self, task: UserTask, event_type: str, payload: dict) -> None:
|
||||
if not self._event_bus:
|
||||
return
|
||||
event = RuntimeEvent(
|
||||
task_id=task.task_id,
|
||||
session_id=task.session_id,
|
||||
sequence=self._event_bus.next_sequence(task.task_id),
|
||||
type=event_type,
|
||||
payload=payload,
|
||||
)
|
||||
self._event_bus.publish(event)
|
||||
|
||||
def _save_to_memory(self, task: UserTask, execution_result: dict, status: str) -> None:
|
||||
"""Save task input and result to memory for session context."""
|
||||
if not self._memory_interface:
|
||||
return
|
||||
|
||||
try:
|
||||
# Save task input as summary
|
||||
self._memory_interface.insert(
|
||||
text=f"User request: {task.input}",
|
||||
kind="summary",
|
||||
source="user",
|
||||
task_id=task.task_id,
|
||||
session_id=task.session_id,
|
||||
weight=0.8,
|
||||
metadata={"status": status},
|
||||
)
|
||||
|
||||
# Save execution result
|
||||
result_text = ""
|
||||
if status == "completed":
|
||||
step_results = execution_result.get("result", {}).get("step_results", [])
|
||||
if step_results:
|
||||
for step in step_results:
|
||||
tool_result = step.get("result", {}).get("result", {})
|
||||
if tool_result.get("output"):
|
||||
result_text += f" | {step.get('step_id')}: {tool_result.get('output')[:200]}"
|
||||
elif status == "failed":
|
||||
result_text = f" | Error: {execution_result.get('result', {}).get('error', 'Unknown')}"
|
||||
|
||||
if result_text:
|
||||
self._memory_interface.insert(
|
||||
text=f"Result: {status}{result_text}",
|
||||
kind="tool_result",
|
||||
source="system",
|
||||
task_id=task.task_id,
|
||||
session_id=task.session_id,
|
||||
weight=0.7,
|
||||
metadata={"status": status},
|
||||
)
|
||||
except Exception as e:
|
||||
import logging
|
||||
logging.getLogger(__name__).warning(f"Failed to save to memory: {e}")
|
||||
@@ -0,0 +1,462 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from threading import RLock
|
||||
from pathlib import Path
|
||||
|
||||
from app.core.config import AppConfig, load_app_config
|
||||
from app.core.context_builder import ContextBuilder
|
||||
from app.core.contracts import UserTask
|
||||
from app.core.execution_engine import ExecutionEngine
|
||||
from app.core.execution_scheduler import ExecutionScheduler
|
||||
from app.core.async_router import AsyncRouter
|
||||
from app.events.event_bus import EventBus
|
||||
from app.events.event_store import SQLiteEventStore
|
||||
from app.memory import MemoryInterface, MemoryStore, VectorIndex
|
||||
from app.memory.write_policy import MemoryWritePolicy
|
||||
from app.models import (
|
||||
CoderAdapter,
|
||||
CriticAdapter,
|
||||
EmbeddingsAdapter,
|
||||
OrchestratorAdapter,
|
||||
create_adapter,
|
||||
)
|
||||
from app.models.async_adapters import AsyncOrchestratorAdapter, AsyncCriticAdapter, AsyncCoderAdapter
|
||||
from app.permissions.approval_store import SQLiteApprovalStore
|
||||
from app.core.permission_service import PermissionService
|
||||
from app.runtime.runtime_loop import RuntimeLoop
|
||||
from app.state.checkpoint_store import SQLiteCheckpointStore
|
||||
from app.state.task_state_store import SQLiteTaskStateStore
|
||||
from app.tools.file_read import FileReadTool
|
||||
from app.tools.file_write import FileWriteTool
|
||||
from app.tools.registry import ToolRegistry
|
||||
from app.tools.sandbox import ToolSandbox
|
||||
from app.tools.shell_exec import ShellExecTool
|
||||
from app.tools.memory_tools import MemoryInsertTool, MemorySearchTool, MemoryListTool
|
||||
|
||||
|
||||
class RuntimeController:
|
||||
"""Composition root for the ducklm runtime."""
|
||||
|
||||
def __init__(self, base_dir: str | Path | None = None) -> None:
|
||||
self.base_dir = Path(base_dir or Path(__file__).resolve().parents[2])
|
||||
self.config: AppConfig = load_app_config(self.base_dir / "config")
|
||||
|
||||
self.event_bus = EventBus(
|
||||
SQLiteEventStore(self.base_dir / "data" / "events" / "events.sqlite3")
|
||||
)
|
||||
self.task_state_store = SQLiteTaskStateStore(
|
||||
self.base_dir / "data" / "state" / "task_state.sqlite3"
|
||||
)
|
||||
self.checkpoint_store = SQLiteCheckpointStore(
|
||||
self.base_dir / "data" / "state" / "checkpoints.sqlite3"
|
||||
)
|
||||
self.approval_store = SQLiteApprovalStore(
|
||||
self.base_dir / "data" / "permissions" / "approvals.sqlite3"
|
||||
)
|
||||
|
||||
self._thinker: OrchestratorAdapter | None = None
|
||||
self._json_compiler: OrchestratorAdapter | None = None
|
||||
self._orchestrator: OrchestratorAdapter | None = None
|
||||
self._coder: CoderAdapter | None = None
|
||||
self._critic: CriticAdapter | None = None
|
||||
self._sys_util: OrchestratorAdapter | None = None
|
||||
self._model_cache: dict[tuple[object, ...], tuple[object, RLock]] = {}
|
||||
self._memory_interface: MemoryInterface | None = None
|
||||
self._memory_policy: MemoryWritePolicy | None = None
|
||||
self.tool_registry = None
|
||||
self.tool_sandbox = None
|
||||
|
||||
self._init_models()
|
||||
self._init_memory()
|
||||
|
||||
runtime_config = self.config.runtime
|
||||
|
||||
self.tool_sandbox = ToolSandbox(
|
||||
allowed_root=self.base_dir,
|
||||
timeout_ms=runtime_config.step_timeout_ms,
|
||||
)
|
||||
|
||||
self.tool_registry = self._create_tool_registry()
|
||||
|
||||
context_config = {
|
||||
"max_context_tokens": runtime_config.max_context_tokens,
|
||||
"context_budgets": runtime_config.context_budgets,
|
||||
"reserve_for_generation_pct": runtime_config.reserve_for_generation_pct,
|
||||
}
|
||||
|
||||
self.context_builder = ContextBuilder(
|
||||
memory_interface=self._memory_interface,
|
||||
tool_registry=self.tool_registry,
|
||||
config=context_config,
|
||||
)
|
||||
|
||||
self._prompts = self._load_prompts()
|
||||
# ensure sys_util prompt is present in prompts dict for router
|
||||
# ensure sys_util prompt is available to router (prompts.json may have "sys_util" key)
|
||||
if "sys_util" not in self._prompts and "prompts" in self.config:
|
||||
self._prompts["sys_util"] = self.config.get("sys_util")
|
||||
|
||||
self.context_builder = ContextBuilder(
|
||||
memory_interface=self._memory_interface,
|
||||
tool_registry=self.tool_registry,
|
||||
config=context_config,
|
||||
)
|
||||
|
||||
self.router = AsyncRouter(
|
||||
thinker=None,
|
||||
json_compiler=None,
|
||||
intent_parser=None,
|
||||
prompts=self._prompts,
|
||||
event_bus=self.event_bus,
|
||||
tool_registry=self.tool_registry,
|
||||
retry_limit=runtime_config.orchestrator_retry_limit,
|
||||
debug=runtime_config.debug if hasattr(runtime_config, 'debug') else False,
|
||||
log_length=runtime_config.debug_orchestrator_log_length if hasattr(runtime_config, 'debug_orchestrator_log_length') else 500,
|
||||
json_fix_retry_limit=runtime_config.json_fix_retry_limit if hasattr(runtime_config, 'json_fix_retry_limit') else 2,
|
||||
json_fix_use_sys_util=runtime_config.json_fix_use_sys_util if hasattr(runtime_config, "json_fix_use_sys_util") else True,
|
||||
intent_classifier=runtime_config.intent_classifier if hasattr(runtime_config, "intent_classifier") else "thinker",
|
||||
)
|
||||
|
||||
self.permission_service = PermissionService(
|
||||
config=self._load_permissions_config(),
|
||||
)
|
||||
|
||||
self.execution_engine = ExecutionEngine(
|
||||
event_bus=self.event_bus,
|
||||
tool_registry=self.tool_registry,
|
||||
permission_service=self.permission_service,
|
||||
scheduler=ExecutionScheduler(
|
||||
retry_limit=runtime_config.planner_retry_limit
|
||||
),
|
||||
critic=self._critic,
|
||||
memory_policy=self._memory_policy,
|
||||
memory_interface=self._memory_interface,
|
||||
prompts=self._prompts,
|
||||
)
|
||||
|
||||
self.runtime_loop = RuntimeLoop(
|
||||
event_bus=self.event_bus,
|
||||
task_state_store=self.task_state_store,
|
||||
checkpoint_store=self.checkpoint_store,
|
||||
context_builder=self.context_builder,
|
||||
router=self.router,
|
||||
execution_engine=self.execution_engine,
|
||||
permission_service=self.permission_service,
|
||||
memory_interface=self._memory_interface,
|
||||
)
|
||||
|
||||
def _load_prompts(self) -> dict[str, str]:
|
||||
prompts_dir = self.base_dir / "config" / "prompts"
|
||||
prompts = {}
|
||||
|
||||
if prompts_dir.is_dir():
|
||||
for md_file in prompts_dir.glob("*.md"):
|
||||
role = md_file.stem
|
||||
prompts[role] = md_file.read_text(encoding="utf-8")
|
||||
|
||||
if prompts:
|
||||
return prompts
|
||||
|
||||
prompts_file = self.base_dir / "config" / "prompts.json"
|
||||
if prompts_file.exists():
|
||||
with open(prompts_file) as f:
|
||||
return json.load(f)
|
||||
return {}
|
||||
|
||||
def _load_permissions_config(self) -> dict:
|
||||
permissions_file = self.base_dir / "config" / "permissions.json"
|
||||
if not permissions_file.exists():
|
||||
return {}
|
||||
with permissions_file.open("r", encoding="utf-8") as handle:
|
||||
return json.load(handle)
|
||||
|
||||
def _init_models(self) -> None:
|
||||
try:
|
||||
memory_config = self.config.runtime.memory_thresholds or {}
|
||||
if memory_config:
|
||||
self._memory_policy = MemoryWritePolicy(
|
||||
store_threshold=memory_config.get("default_store_weight", 0.8),
|
||||
)
|
||||
print("Models policy ready")
|
||||
except Exception as e:
|
||||
print(f"Models init failed: {e}")
|
||||
|
||||
def load_models_at_startup(self) -> None:
|
||||
"""Load all LLM models synchronously. Called from startup hook in executor."""
|
||||
import os
|
||||
os.chdir(str(self.base_dir / "models"))
|
||||
|
||||
try:
|
||||
print("Loading thinker model...")
|
||||
thinker_config = self.config.models.thinker or {}
|
||||
if thinker_config.get("path"):
|
||||
llm, lock = self._get_or_create_llm("thinker", thinker_config)
|
||||
self._thinker = OrchestratorAdapter(llm, system_prompt=self._prompts.get("thinker"), lock=lock)
|
||||
print(f"Thinker loaded: {self._thinker} (model: {thinker_config.get("path")})")
|
||||
|
||||
print("Loading json_compiler model...")
|
||||
compiler_config = self.config.models.json_compiler or {}
|
||||
if compiler_config.get("path"):
|
||||
llm, lock = self._get_or_create_llm("json_compiler", compiler_config)
|
||||
self._json_compiler = OrchestratorAdapter(llm, system_prompt=self._prompts.get("json_compiler"), lock=lock)
|
||||
print(f"JSON Compiler loaded: {self._json_compiler} (model: {compiler_config.get("path")})")
|
||||
|
||||
print("Loading coder model...")
|
||||
coder_config = self.config.models.coder or {}
|
||||
if coder_config.get("path"):
|
||||
llm, lock = self._get_or_create_llm("coder", coder_config)
|
||||
self._coder = CoderAdapter(llm, system_prompt=self._prompts.get("coder"), lock=lock)
|
||||
print(f"Coder loaded: {self._coder} (model: {coder_config.get("path")})")
|
||||
|
||||
print("Loading critic model...")
|
||||
critic_config = self.config.models.critic or {}
|
||||
if critic_config.get("path"):
|
||||
llm, lock = self._get_or_create_llm("critic", critic_config)
|
||||
self._critic = CriticAdapter(llm, system_prompt=self._prompts.get("critic"), lock=lock)
|
||||
print(f"Critic loaded: {self._critic} (model: {critic_config.get("path")})")
|
||||
|
||||
print("Loading sys_util model...")
|
||||
sys_util_config = self.config.models.sys_util or {}
|
||||
if sys_util_config.get("path"):
|
||||
llm, lock = self._get_or_create_llm("sys_util", sys_util_config)
|
||||
self._sys_util = OrchestratorAdapter(llm, system_prompt=self._prompts.get("sys_util"), lock=lock)
|
||||
print(f"Sys_util loaded: {self._sys_util} (model: {sys_util_config.get("path")})")
|
||||
|
||||
print("All models loaded successfully")
|
||||
|
||||
async_thinker = AsyncOrchestratorAdapter(self._thinker) if self._thinker else None
|
||||
async_compiler = AsyncOrchestratorAdapter(self._json_compiler) if self._json_compiler else None
|
||||
async_coder = AsyncCoderAdapter(self._coder) if self._coder else None
|
||||
async_critic = AsyncCriticAdapter(self._critic) if self._critic else None
|
||||
async_sys_util = AsyncOrchestratorAdapter(self._sys_util) if self._sys_util else None
|
||||
|
||||
self.router.set_thinker(async_thinker)
|
||||
self.router.set_json_compiler(async_compiler)
|
||||
self.router.set_sys_util(async_sys_util)
|
||||
self.router.set_tool_registry(self.tool_registry)
|
||||
if async_critic:
|
||||
self.execution_engine.set_critic(async_critic)
|
||||
if async_coder:
|
||||
self.execution_engine.set_coder(async_coder)
|
||||
|
||||
except Exception as e:
|
||||
print(f"Failed to load models at startup: {e}")
|
||||
raise RuntimeError(f"Model loading failed: {e}") from e
|
||||
|
||||
def _model_cache_key(self, model_config: dict) -> tuple[object, ...]:
|
||||
path = str((self.base_dir / "models" / model_config.get("path", "")).resolve())
|
||||
return (
|
||||
path,
|
||||
model_config.get("backend", "cpu"),
|
||||
model_config.get("n_gpu_layers", 0),
|
||||
model_config.get("n_ctx", 4096),
|
||||
)
|
||||
|
||||
def _get_or_create_llm(self, model_type: str, model_config: dict):
|
||||
key = self._model_cache_key(model_config)
|
||||
cached = self._model_cache.get(key)
|
||||
if cached:
|
||||
print(f"Reusing model instance: {model_config.get('path')} for {model_type}")
|
||||
return cached
|
||||
|
||||
llm = create_adapter(model_type, model_config, self.base_dir / "models")
|
||||
lock = RLock()
|
||||
cached = (llm, lock)
|
||||
self._model_cache[key] = cached
|
||||
return cached
|
||||
|
||||
def _init_memory(self) -> None:
|
||||
try:
|
||||
emb_config = self.config.models.embeddings or {}
|
||||
model_path = self.base_dir / emb_config.get("path", "models/all-MiniLM-L6-v2")
|
||||
if not model_path.exists():
|
||||
print(f"Memory init skipped: embeddings model not found at {model_path}")
|
||||
self._memory_interface = None
|
||||
return
|
||||
embeddings = EmbeddingsAdapter(
|
||||
model_path=model_path,
|
||||
embedding_dim=emb_config.get("embedding_dim", 384),
|
||||
)
|
||||
|
||||
store = MemoryStore(
|
||||
self.base_dir / "data" / "memory" / "memory.sqlite3"
|
||||
)
|
||||
vector_index = VectorIndex(
|
||||
index_path=self.base_dir / "data" / "memory" / "index.bin",
|
||||
embedding_dim=embeddings.embedding_dim,
|
||||
)
|
||||
|
||||
self._memory_interface = MemoryInterface(store, vector_index, embeddings)
|
||||
|
||||
except Exception as e:
|
||||
print(f"Memory init failed: {e}")
|
||||
self._memory_interface = None
|
||||
|
||||
def _create_tool_registry(self) -> ToolRegistry:
|
||||
from app.tools.registry import ToolRegistry
|
||||
from app.tools.plugins.shell_exec import Tool as ShellExecTool
|
||||
from app.tools.plugins.file_read import Tool as FileReadTool
|
||||
from app.tools.plugins.file_write import Tool as FileWriteTool
|
||||
from app.tools.plugins.memory_tools import Tool as MemoryTool
|
||||
from app.tools.discover import ToolDiscovery
|
||||
|
||||
registry = ToolRegistry()
|
||||
|
||||
tool_init_map = {
|
||||
"shell_exec": lambda m: ShellExecTool(self.tool_sandbox),
|
||||
"file_read": lambda m: FileReadTool(self.tool_sandbox),
|
||||
"file_write": lambda m: FileWriteTool(self.tool_sandbox),
|
||||
"memory": lambda m: MemoryTool(self._memory_interface),
|
||||
}
|
||||
|
||||
discovery = ToolDiscovery()
|
||||
discovered = discovery.discover()
|
||||
|
||||
for name, data in discovered.items():
|
||||
init_fn = tool_init_map.get(name)
|
||||
if init_fn:
|
||||
tool = init_fn(data.get("manifest", {}))
|
||||
registry.register(tool)
|
||||
registry._schemas[name] = {
|
||||
"description": data.get("manifest", {}).get("description", ""),
|
||||
"args_schema": data.get("manifest", {}).get("args_schema", {}),
|
||||
"requires_permission": data.get("manifest", {}).get("requires_permission", False),
|
||||
}
|
||||
print(f"Registered tool: {name}")
|
||||
else:
|
||||
print(f"No init mapping for tool: {name} - skipping")
|
||||
|
||||
return registry
|
||||
|
||||
@property
|
||||
def orchestrator(self) -> OrchestratorAdapter | None:
|
||||
return self._orchestrator
|
||||
|
||||
@property
|
||||
def coder(self) -> CoderAdapter | None:
|
||||
return self._coder
|
||||
|
||||
@property
|
||||
def critic(self) -> CriticAdapter | None:
|
||||
return self._critic
|
||||
|
||||
@property
|
||||
def memory_interface(self) -> MemoryInterface | None:
|
||||
return self._memory_interface
|
||||
|
||||
def _ensure_orchestrator(self) -> OrchestratorAdapter | None:
|
||||
if self._orchestrator is not None:
|
||||
return self._orchestrator
|
||||
try:
|
||||
orch_config = self.config.models.orchestrator or {}
|
||||
if orch_config.get("path"):
|
||||
llm, lock = self._get_or_create_llm("orchestrator", orch_config)
|
||||
self._orchestrator = OrchestratorAdapter(llm, lock=lock)
|
||||
except Exception as e:
|
||||
print(f"Orchestrator load failed: {e}")
|
||||
return self._orchestrator
|
||||
|
||||
def _ensure_critic(self) -> CriticAdapter | None:
|
||||
if self._critic is not None:
|
||||
return self._critic
|
||||
try:
|
||||
critic_config = self.config.models.critic or {}
|
||||
if critic_config.get("path"):
|
||||
llm, lock = self._get_or_create_llm("critic", critic_config)
|
||||
self._critic = CriticAdapter(llm, lock=lock)
|
||||
except Exception as e:
|
||||
print(f"Critic load failed: {e}")
|
||||
return self._critic
|
||||
|
||||
def handle_task(self, task: UserTask) -> dict[str, object]:
|
||||
return self.runtime_loop.run_task(task)
|
||||
|
||||
def resolve_permission(self, task_id: str, decision: str) -> dict[str, object]:
|
||||
return self.runtime_loop.resolve_permission(
|
||||
task_id=task_id, decision=decision
|
||||
)
|
||||
|
||||
def resolve_secret(self, task_id: str, secret: str) -> dict[str, object]:
|
||||
return self.runtime_loop.resolve_secret(
|
||||
task_id=task_id, secret=secret
|
||||
)
|
||||
|
||||
def resolve_password(self, task_id: str, password: str) -> dict[str, object]:
|
||||
return self.runtime_loop.resolve_password(
|
||||
task_id=task_id, password=password
|
||||
)
|
||||
|
||||
def handle_critic_feedback(
|
||||
self,
|
||||
feedback: str,
|
||||
task_id: str | None = None,
|
||||
session_id: str | None = None,
|
||||
correctness_override: float | None = None,
|
||||
usefulness_override: float | None = None,
|
||||
safety_override: float | None = None,
|
||||
) -> dict[str, object]:
|
||||
if not self._memory_interface:
|
||||
return {"status": "error", "message": "Memory not available"}
|
||||
|
||||
target_task_id = task_id
|
||||
target_session_id = session_id
|
||||
|
||||
if not target_session_id and not target_task_id:
|
||||
return {
|
||||
"status": "error",
|
||||
"message": "Either task_id or session_id must be provided",
|
||||
}
|
||||
|
||||
if not target_session_id and target_task_id:
|
||||
state = self.task_state_store.get_task(target_task_id)
|
||||
if state:
|
||||
target_session_id = state.get("session_id")
|
||||
|
||||
if not target_task_id and target_session_id:
|
||||
recent_tasks = self.task_state_store.get_session_tasks(target_session_id, limit=1)
|
||||
if recent_tasks:
|
||||
target_task_id = recent_tasks[0]["task_id"]
|
||||
|
||||
min_weight = 0.3
|
||||
max_weight = 0.95
|
||||
user_weight = 0.9
|
||||
|
||||
final_weight = max(min_weight, min(max_weight, user_weight))
|
||||
|
||||
metadata = {
|
||||
"feedback_text": feedback,
|
||||
"overrides": {
|
||||
"correctness": correctness_override,
|
||||
"usefulness": usefulness_override,
|
||||
"safety": safety_override,
|
||||
},
|
||||
"source": "user",
|
||||
}
|
||||
|
||||
feedback_text = f"User feedback: {feedback}"
|
||||
if correctness_override is not None:
|
||||
feedback_text += f" | Correctness corrected to: {correctness_override}"
|
||||
if usefulness_override is not None:
|
||||
feedback_text += f" | Usefulness corrected to: {usefulness_override}"
|
||||
if safety_override is not None:
|
||||
feedback_text += f" | Safety corrected to: {safety_override}"
|
||||
|
||||
try:
|
||||
self._memory_interface.insert(
|
||||
text=feedback_text,
|
||||
kind="critique",
|
||||
source="user",
|
||||
task_id=target_task_id,
|
||||
session_id=target_session_id,
|
||||
weight=final_weight,
|
||||
metadata=metadata,
|
||||
)
|
||||
return {
|
||||
"status": "ok",
|
||||
"message": "Feedback saved",
|
||||
"task_id": target_task_id,
|
||||
"session_id": target_session_id,
|
||||
}
|
||||
except Exception as e:
|
||||
return {"status": "error", "message": str(e)}
|
||||
@@ -0,0 +1,504 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
|
||||
from app.core.context_builder import ContextBuilder
|
||||
from app.core.contracts import ExecutionDirective, PermissionDecision, PermissionRequest, RuntimeEvent, SecretRequest, TaskCheckpoint, UserTask
|
||||
from app.core.execution_engine import ExecutionEngine
|
||||
from app.core.async_router import AsyncRouter
|
||||
from app.events.event_bus import EventBus
|
||||
from app.events.event_types import CHECKPOINT_SAVED, CONTEXT_BUILT, TASK_AWAITING_INPUT, TASK_AWAITING_PERMISSION, TASK_COMPLETED, TASK_FAILED, TASK_RECEIVED
|
||||
from app.core.permission_service import PermissionService
|
||||
from app.state.checkpoint_store import SQLiteCheckpointStore
|
||||
from app.state.task_state_store import SQLiteTaskStateStore
|
||||
|
||||
|
||||
class RuntimeLoop:
|
||||
"""Central control loop skeleton coordinating task state and events."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
event_bus: EventBus,
|
||||
task_state_store: SQLiteTaskStateStore,
|
||||
checkpoint_store: SQLiteCheckpointStore,
|
||||
context_builder: ContextBuilder,
|
||||
router: AsyncRouter,
|
||||
execution_engine: ExecutionEngine,
|
||||
permission_service: PermissionService,
|
||||
memory_interface=None,
|
||||
) -> None:
|
||||
self._event_bus = event_bus
|
||||
self._task_state_store = task_state_store
|
||||
self._checkpoint_store = checkpoint_store
|
||||
self._context_builder = context_builder
|
||||
self._router = router
|
||||
self._execution_engine = execution_engine
|
||||
self._permission_service = permission_service
|
||||
self._memory_interface = memory_interface
|
||||
|
||||
def run_task(self, task: UserTask) -> dict[str, object]:
|
||||
# Check input for hard-stop commands BEFORE processing
|
||||
hard_stop_check = self._permission_service.check_shell_command(
|
||||
task_id=task.task_id,
|
||||
session_id=task.session_id,
|
||||
command=task.input,
|
||||
)
|
||||
if hard_stop_check.get("decision") == "hard_stop":
|
||||
# Immediately reject hard-stop commands
|
||||
self._publish(task, TASK_RECEIVED, {"status": "received"})
|
||||
checkpoint = TaskCheckpoint(task_id=task.task_id, status="received")
|
||||
self._checkpoint_store.save(checkpoint)
|
||||
self._publish(task, CHECKPOINT_SAVED, checkpoint.model_dump(mode="json"))
|
||||
|
||||
error_msg = f"⚠️ BLOCKED: {hard_stop_check.get('reason', 'Hard stop command')}"
|
||||
self._publish(task, TASK_FAILED, {
|
||||
"directive": {},
|
||||
"execution_result": {"error": error_msg},
|
||||
})
|
||||
return {
|
||||
"task_id": task.task_id,
|
||||
"status": "failed",
|
||||
"directive": {},
|
||||
"result": {"error": error_msg},
|
||||
"events": [e.model_dump(mode="json") for e in self._event_bus.list_for_task(task.task_id)],
|
||||
}
|
||||
|
||||
state = self._task_state_store.create_task(
|
||||
task.task_id,
|
||||
{
|
||||
"status": "received",
|
||||
"session_id": task.session_id,
|
||||
"plan": None,
|
||||
"task_input": task.input,
|
||||
"task_context": task.context,
|
||||
},
|
||||
)
|
||||
self._publish(task, TASK_RECEIVED, {"status": "received"})
|
||||
|
||||
checkpoint = TaskCheckpoint(task_id=task.task_id, status="received")
|
||||
self._checkpoint_store.save(checkpoint)
|
||||
self._publish(task, CHECKPOINT_SAVED, checkpoint.model_dump(mode="json"))
|
||||
|
||||
context = self._context_builder.build(task=task, checkpoint=checkpoint)
|
||||
self._publish(task, CONTEXT_BUILT, {"keys": sorted(context.keys())})
|
||||
|
||||
directive = asyncio.run(
|
||||
self._router.decide(state=state, context=context, task_id=task.task_id, session_id=task.session_id)
|
||||
)
|
||||
execution_result = self._execution_engine.execute(task=task, directive=directive)
|
||||
state_patch = {"status": execution_result["status"], "last_directive": directive.model_dump(mode="json")}
|
||||
if execution_result["status"] == "awaiting_permission":
|
||||
state_patch["pending_permission_request"] = execution_result["result"]["permission_request"]
|
||||
state_patch["pending_secret_request"] = None
|
||||
state_patch["resolved_permission_decision"] = None
|
||||
elif execution_result["status"] == "awaiting_input":
|
||||
state_patch["pending_permission_request"] = None
|
||||
state_patch["pending_secret_request"] = execution_result["result"]["secret_request"]
|
||||
state_patch["resolved_permission_decision"] = None
|
||||
elif execution_result["status"] == "awaiting_password":
|
||||
state_patch["pending_permission_request"] = None
|
||||
state_patch["pending_secret_request"] = None
|
||||
state_patch["resolved_permission_decision"] = None
|
||||
state_patch["pending_password_request"] = {
|
||||
"command": execution_result["result"].get("command", ""),
|
||||
"reason": "Permission denied - требуется sudo пароль",
|
||||
"attempts": 0,
|
||||
}
|
||||
else:
|
||||
state_patch["pending_permission_request"] = None
|
||||
state_patch["pending_secret_request"] = None
|
||||
state_patch["resolved_permission_decision"] = None
|
||||
self._task_state_store.update_task(task.task_id, state_patch)
|
||||
final_status = str(execution_result["status"])
|
||||
|
||||
# For awaiting states - do NOT mark task as completed, keep it in pending state
|
||||
if final_status in ("awaiting_permission", "awaiting_input", "awaiting_password"):
|
||||
# Task stays in pending state, don't update to completed
|
||||
pass
|
||||
else:
|
||||
self._task_state_store.update_task(task.task_id, {"status": final_status})
|
||||
|
||||
final_checkpoint = TaskCheckpoint(
|
||||
task_id=task.task_id,
|
||||
status=final_status,
|
||||
context_snapshot=context,
|
||||
)
|
||||
self._checkpoint_store.save(final_checkpoint)
|
||||
|
||||
# Generate response after plan execution
|
||||
if final_status == "completed" and execution_result.get("result", {}).get("step_results"):
|
||||
# Format tool results into response
|
||||
step_results = execution_result["result"]["step_results"]
|
||||
response_parts = []
|
||||
for step in step_results:
|
||||
result_data = step.get("result", {})
|
||||
tool_result = result_data.get("result", result_data)
|
||||
if tool_result.get("ok") and tool_result.get("output"):
|
||||
response_parts.append(tool_result["output"])
|
||||
|
||||
if response_parts:
|
||||
# Create respond directive
|
||||
response_text = "\n\n".join(response_parts)
|
||||
respond_directive = ExecutionDirective(
|
||||
type="respond",
|
||||
payload={"text": response_text},
|
||||
)
|
||||
# Add to execution result
|
||||
execution_result["response_directive"] = respond_directive.model_dump(mode="json")
|
||||
|
||||
# Map status to terminal event type
|
||||
if final_status == "completed":
|
||||
terminal_event_type = TASK_COMPLETED
|
||||
elif final_status == "failed":
|
||||
terminal_event_type = TASK_FAILED
|
||||
elif final_status == "awaiting_permission":
|
||||
terminal_event_type = TASK_AWAITING_PERMISSION
|
||||
elif final_status == "awaiting_input":
|
||||
terminal_event_type = TASK_AWAITING_INPUT
|
||||
elif final_status == "awaiting_password":
|
||||
terminal_event_type = TASK_AWAITING_PERMISSION
|
||||
else:
|
||||
terminal_event_type = TASK_FAILED
|
||||
self._publish(
|
||||
task,
|
||||
terminal_event_type,
|
||||
{
|
||||
"directive": directive.model_dump(mode="json"),
|
||||
"execution_result": execution_result["result"],
|
||||
},
|
||||
)
|
||||
|
||||
# Save task and result to memory for session context
|
||||
self._save_to_memory(task, execution_result, final_status)
|
||||
|
||||
return {
|
||||
"task_id": task.task_id,
|
||||
"status": final_status,
|
||||
"directive": directive.model_dump(mode="json"),
|
||||
"result": execution_result["result"],
|
||||
"events": [event.model_dump(mode="json") for event in self._event_bus.list_for_task(task.task_id)],
|
||||
}
|
||||
|
||||
def resolve_permission(self, task_id: str, decision: str) -> dict[str, object]:
|
||||
state = self._task_state_store.get_task(task_id)
|
||||
if not state:
|
||||
return {"task_id": task_id, "status": "failed", "result": {"error": "Unknown task_id"}}
|
||||
|
||||
pending_request_payload = state.get("pending_permission_request")
|
||||
last_directive_payload = state.get("last_directive")
|
||||
if not pending_request_payload or not last_directive_payload:
|
||||
return {"task_id": task_id, "status": "failed", "result": {"error": "No pending permission request"}}
|
||||
|
||||
task = UserTask(
|
||||
task_id=task_id,
|
||||
session_id=state["session_id"],
|
||||
input=state["task_input"],
|
||||
context=state.get("task_context", {}),
|
||||
)
|
||||
# Get command from pending request
|
||||
command = pending_request_payload.get("command", "")
|
||||
|
||||
# Resolve permission using new service
|
||||
resolved = self._permission_service.resolve_permission(
|
||||
task_id=task_id,
|
||||
session_id=state["session_id"],
|
||||
command=command,
|
||||
decision=decision,
|
||||
)
|
||||
|
||||
if decision == "deny":
|
||||
execution_result = {
|
||||
"status": "failed",
|
||||
"result": {
|
||||
"error": "Permission denied by user.",
|
||||
"permission_decision": resolved,
|
||||
},
|
||||
}
|
||||
elif decision == "allow_with_password":
|
||||
directive = ExecutionDirective.model_validate(last_directive_payload)
|
||||
self._task_state_store.update_task(
|
||||
task.task_id,
|
||||
{
|
||||
"status": "awaiting_password",
|
||||
"pending_password_request": {
|
||||
"command": command,
|
||||
"reason": pending_request_payload.get("reason", "Требуется пароль для выполнения команды"),
|
||||
"attempts": 0,
|
||||
},
|
||||
"pending_permission_request": None,
|
||||
},
|
||||
)
|
||||
self._publish(task, TASK_AWAITING_PERMISSION, {
|
||||
"password_required": True,
|
||||
"command": command,
|
||||
})
|
||||
return {
|
||||
"task_id": task_id,
|
||||
"status": "awaiting_password",
|
||||
"result": {"message": "Требуется ввод пароля"},
|
||||
}
|
||||
else:
|
||||
directive = ExecutionDirective.model_validate(last_directive_payload)
|
||||
execution_result = self._execution_engine.execute(
|
||||
task=task,
|
||||
directive=directive,
|
||||
)
|
||||
|
||||
final_status = str(execution_result["status"])
|
||||
if decision != "allow_with_password":
|
||||
self._task_state_store.update_task(
|
||||
task.task_id,
|
||||
{
|
||||
"status": final_status,
|
||||
"pending_permission_request": None,
|
||||
"pending_secret_request": execution_result["result"].get("secret_request")
|
||||
if final_status == "awaiting_input"
|
||||
else None,
|
||||
"resolved_permission_decision": resolved,
|
||||
},
|
||||
)
|
||||
checkpoint = TaskCheckpoint(task_id=task.task_id, status=final_status)
|
||||
self._checkpoint_store.save(checkpoint)
|
||||
self._publish(task, CHECKPOINT_SAVED, checkpoint.model_dump(mode="json"))
|
||||
if final_status == "completed":
|
||||
terminal_event_type = TASK_COMPLETED
|
||||
elif final_status == "awaiting_input":
|
||||
terminal_event_type = TASK_AWAITING_INPUT
|
||||
elif final_status == "awaiting_permission":
|
||||
terminal_event_type = TASK_AWAITING_PERMISSION
|
||||
else:
|
||||
terminal_event_type = TASK_FAILED
|
||||
self._publish(
|
||||
task,
|
||||
terminal_event_type,
|
||||
{
|
||||
"permission_resolution": resolved.model_dump(mode="json") if hasattr(resolved, 'model_dump') else resolved,
|
||||
"execution_result": execution_result["result"],
|
||||
},
|
||||
)
|
||||
|
||||
# Save to memory after permission resolution
|
||||
self._save_to_memory(task, execution_result, final_status)
|
||||
|
||||
return {
|
||||
"task_id": task.task_id,
|
||||
"status": final_status,
|
||||
"result": execution_result["result"],
|
||||
"events": [event.model_dump(mode="json") for event in self._event_bus.list_for_task(task.task_id)],
|
||||
}
|
||||
|
||||
def resolve_secret(self, task_id: str, secret: str) -> dict[str, object]:
|
||||
state = self._task_state_store.get_task(task_id)
|
||||
if not state:
|
||||
return {"task_id": task_id, "status": "failed", "result": {"error": "Unknown task_id"}}
|
||||
pending_secret_payload = state.get("pending_secret_request")
|
||||
last_directive_payload = state.get("last_directive")
|
||||
resolved_permission_payload = state.get("resolved_permission_decision")
|
||||
if not pending_secret_payload or not last_directive_payload:
|
||||
return {"task_id": task_id, "status": "failed", "result": {"error": "No pending secret request"}}
|
||||
if not resolved_permission_payload:
|
||||
return {"task_id": task_id, "status": "failed", "result": {"error": "No resolved permission available"}}
|
||||
|
||||
task = UserTask(
|
||||
task_id=task_id,
|
||||
session_id=state["session_id"],
|
||||
input=state["task_input"],
|
||||
context=state.get("task_context", {}),
|
||||
)
|
||||
_secret_request = SecretRequest.model_validate(pending_secret_payload)
|
||||
directive = ExecutionDirective.model_validate(last_directive_payload)
|
||||
execution_result = self._execution_engine.execute(
|
||||
task=task,
|
||||
directive=directive,
|
||||
permission_override=None,
|
||||
secret_override=secret,
|
||||
)
|
||||
final_status = str(execution_result["status"])
|
||||
self._task_state_store.update_task(
|
||||
task.task_id,
|
||||
{
|
||||
"status": final_status,
|
||||
"pending_secret_request": None,
|
||||
"resolved_permission_decision": None,
|
||||
},
|
||||
)
|
||||
checkpoint = TaskCheckpoint(task_id=task.task_id, status=final_status)
|
||||
self._checkpoint_store.save(checkpoint)
|
||||
self._publish(task, CHECKPOINT_SAVED, checkpoint.model_dump(mode="json"))
|
||||
if final_status == "completed":
|
||||
terminal_event_type = TASK_COMPLETED
|
||||
elif final_status == "awaiting_input":
|
||||
terminal_event_type = TASK_AWAITING_INPUT
|
||||
elif final_status == "awaiting_permission":
|
||||
terminal_event_type = TASK_AWAITING_PERMISSION
|
||||
else:
|
||||
terminal_event_type = TASK_FAILED
|
||||
self._publish(
|
||||
task,
|
||||
terminal_event_type,
|
||||
{
|
||||
"secret_resolution": {"task_id": task_id},
|
||||
"execution_result": execution_result["result"],
|
||||
},
|
||||
)
|
||||
return {
|
||||
"task_id": task.task_id,
|
||||
"status": final_status,
|
||||
"result": execution_result["result"],
|
||||
"events": [event.model_dump(mode="json") for event in self._event_bus.list_for_task(task.task_id)],
|
||||
}
|
||||
|
||||
def resolve_password(self, task_id: str, password: str) -> dict[str, object]:
|
||||
state = self._task_state_store.get_task(task_id)
|
||||
if not state:
|
||||
return {"task_id": task_id, "status": "failed", "result": {"error": "Unknown task_id"}}
|
||||
|
||||
pending_password_payload = state.get("pending_password_request")
|
||||
last_directive_payload = state.get("last_directive")
|
||||
if not pending_password_payload or not last_directive_payload:
|
||||
return {"task_id": task_id, "status": "failed", "result": {"error": "No pending password request"}}
|
||||
|
||||
current_attempt = pending_password_payload.get("attempts", 0) + 1
|
||||
|
||||
task = UserTask(
|
||||
task_id=task_id,
|
||||
session_id=state["session_id"],
|
||||
input=state["task_input"],
|
||||
context=state.get("task_context", {}),
|
||||
)
|
||||
directive = ExecutionDirective.model_validate(last_directive_payload)
|
||||
|
||||
execution_result = self._execution_engine.execute(
|
||||
task=task,
|
||||
directive=directive,
|
||||
password_override=password,
|
||||
)
|
||||
|
||||
final_status = str(execution_result["status"])
|
||||
|
||||
if final_status == "failed":
|
||||
error_msg = execution_result.get("result", {}).get("error", "")
|
||||
is_password_error = "permission denied" in error_msg.lower() or "incorrect password" in error_msg.lower()
|
||||
|
||||
if is_password_error and current_attempt < 3:
|
||||
self._task_state_store.update_task(
|
||||
task.task_id,
|
||||
{
|
||||
"status": "awaiting_password",
|
||||
"pending_password_request": {
|
||||
"command": pending_password_payload.get("command"),
|
||||
"reason": pending_password_payload.get("reason"),
|
||||
"attempts": current_attempt,
|
||||
},
|
||||
},
|
||||
)
|
||||
self._publish(task, TASK_AWAITING_PERMISSION, {
|
||||
"password_attempt_failed": True,
|
||||
"attempts": current_attempt,
|
||||
"max_attempts": 3,
|
||||
"message": "Неверный пароль. Попробуйте снова.",
|
||||
})
|
||||
return {
|
||||
"task_id": task_id,
|
||||
"status": "awaiting_password",
|
||||
"result": {"error": "Неверный пароль", "attempts": current_attempt, "max_attempts": 3},
|
||||
}
|
||||
else:
|
||||
self._task_state_store.update_task(
|
||||
task.task_id,
|
||||
{
|
||||
"status": "failed",
|
||||
"pending_password_request": None,
|
||||
"password_attempts": current_attempt,
|
||||
},
|
||||
)
|
||||
self._publish(task, TASK_FAILED, {
|
||||
"password_failed": True,
|
||||
"attempts": current_attempt,
|
||||
"message": "Неверный пароль (3 попытки). Передаю решение модели.",
|
||||
"execution_result": execution_result["result"],
|
||||
})
|
||||
return {
|
||||
"task_id": task_id,
|
||||
"status": "failed",
|
||||
"result": {
|
||||
"error": "Password failed after 3 attempts",
|
||||
"attempts": current_attempt,
|
||||
"message": "Пользователь 3 раза ввёл неверный пароль. Решение за вами.",
|
||||
},
|
||||
}
|
||||
|
||||
self._task_state_store.update_task(
|
||||
task.task_id,
|
||||
{
|
||||
"status": final_status,
|
||||
"pending_password_request": None,
|
||||
},
|
||||
)
|
||||
checkpoint = TaskCheckpoint(task_id=task.task_id, status=final_status)
|
||||
self._checkpoint_store.save(checkpoint)
|
||||
self._publish(task, TASK_COMPLETED, {"execution_result": execution_result["result"]})
|
||||
|
||||
# Save to memory after password resolution
|
||||
self._save_to_memory(task, execution_result, final_status)
|
||||
|
||||
return {
|
||||
"task_id": task.task_id,
|
||||
"status": final_status,
|
||||
"result": execution_result["result"],
|
||||
"events": [event.model_dump(mode="json") for event in self._event_bus.list_for_task(task.task_id)],
|
||||
}
|
||||
|
||||
def _publish(self, task: UserTask, event_type: str, payload: dict[str, object]) -> None:
|
||||
event = RuntimeEvent(
|
||||
task_id=task.task_id,
|
||||
session_id=task.session_id,
|
||||
sequence=self._event_bus.next_sequence(task.task_id),
|
||||
type=event_type,
|
||||
payload=payload,
|
||||
)
|
||||
self._event_bus.publish(event)
|
||||
|
||||
def _save_to_memory(self, task: UserTask, execution_result: dict, status: str) -> None:
|
||||
"""Save task input and result to memory for session context."""
|
||||
if not self._memory_interface:
|
||||
return
|
||||
|
||||
try:
|
||||
# Save task input as summary
|
||||
self._memory_interface.insert(
|
||||
text=f"User request: {task.input}",
|
||||
kind="summary",
|
||||
source="user",
|
||||
task_id=task.task_id,
|
||||
session_id=task.session_id,
|
||||
weight=0.8,
|
||||
metadata={"status": status},
|
||||
)
|
||||
|
||||
# Save execution result
|
||||
result_text = ""
|
||||
if status == "completed":
|
||||
step_results = execution_result.get("result", {}).get("step_results", [])
|
||||
if step_results:
|
||||
for step in step_results:
|
||||
tool_result = step.get("result", {}).get("result", {})
|
||||
if tool_result.get("output"):
|
||||
result_text += f" | {step.get('step_id')}: {tool_result.get('output')[:200]}"
|
||||
elif status == "failed":
|
||||
result_text = f" | Error: {execution_result.get('result', {}).get('error', 'Unknown')}"
|
||||
|
||||
if result_text:
|
||||
self._memory_interface.insert(
|
||||
text=f"Result: {status}{result_text}",
|
||||
kind="tool_result",
|
||||
source="system",
|
||||
task_id=task.task_id,
|
||||
session_id=task.session_id,
|
||||
weight=0.7,
|
||||
metadata={"status": status},
|
||||
)
|
||||
except Exception as e:
|
||||
# Log but don't fail the task
|
||||
import logging
|
||||
logging.getLogger(__name__).warning(f"Failed to save to memory: {e}")
|
||||
Reference in New Issue
Block a user