Import ducklm runtime

This commit is contained in:
2026-05-10 23:37:56 +08:00
parent fd1a045488
commit dc8267880a
90 changed files with 9171 additions and 0 deletions
+2
View File
@@ -0,0 +1,2 @@
"""Core orchestration components."""
+509
View File
@@ -0,0 +1,509 @@
from __future__ import annotations
import asyncio
import json
import logging
from typing import Any
from app.core.contracts import ExecutionDirective
from app.core.intent_parser import IntentParser
from app.events.event_bus import EventBus
from app.events.event_types import (
ORCHESTRATOR_CALLED,
ORCHESTRATOR_FALLBACK_USED,
ORCHESTRATOR_RETRY,
ORCHESTRATOR_RESULT,
ORCHESTRATOR_UNAVAILABLE,
THINKER_CALLED,
THINKER_RESULT,
JSON_COMPILER_CALLED,
JSON_COMPILER_RESULT,
)
from app.models.async_adapters import AsyncOrchestratorAdapter
logger = logging.getLogger(__name__)
class AsyncRouter:
"""Async router using Thinker + JSON Compiler pipeline."""
def __init__(
self,
thinker: AsyncOrchestratorAdapter | None = None,
json_compiler: AsyncOrchestratorAdapter | None = None,
intent_parser: IntentParser | None = None,
prompts: dict[str, str] | None = None,
event_bus: EventBus | None = None,
tool_registry=None,
retry_limit: int = 2,
debug: bool = False,
log_length: int = 500,
json_fix_retry_limit: int = 2,
json_fix_use_sys_util: bool = True,
intent_classifier: str = "thinker",
) -> None:
self._thinker = thinker
self._json_compiler = json_compiler
self._intent_classifier = intent_classifier
self._sys_util = None
self._intent_parser = intent_parser or IntentParser()
self._prompts = prompts or {}
self._event_bus = event_bus
self._tool_registry = tool_registry
self._retry_limit = retry_limit
self._debug = debug
self._log_length = log_length
self._json_fix_retry_limit = json_fix_retry_limit
self._json_fix_use_sys_util = json_fix_use_sys_util
self._orchestrator = None # Set separately if needed for classification
def set_event_bus(self, event_bus: EventBus) -> None:
self._event_bus = event_bus
def set_thinker(self, thinker: AsyncOrchestratorAdapter) -> None:
self._thinker = thinker
def set_json_compiler(self, json_compiler: AsyncOrchestratorAdapter) -> None:
self._json_compiler = json_compiler
def set_sys_util(self, sys_util: AsyncOrchestratorAdapter) -> None:
self._sys_util = sys_util
def set_orchestrator(self, orchestrator: AsyncOrchestratorAdapter) -> None:
self._orchestrator = orchestrator
def set_tool_registry(self, tool_registry) -> None:
self._tool_registry = tool_registry
async def decide(
self,
state: dict[str, Any],
context: dict[str, Any],
task_id: str | None = None,
session_id: str | None = None,
) -> ExecutionDirective:
task_context = context.get("task_context", {})
requested_tool = task_context.get("requested_tool")
task_summary = str(context.get("task_summary", ""))
if requested_tool:
self._emit_event(
ORCHESTRATOR_RESULT,
{"reason": "explicit_tool_request", "tool": requested_tool},
task_id,
session_id,
)
return ExecutionDirective(
type="tool",
payload={
"tool": requested_tool,
"args": task_context.get("tool_args", {}),
},
requires_permission=requested_tool in {"shell_exec", "file_write"},
confidence=0.9,
reason="Task context explicitly requested a tool execution.",
)
if self._thinker is None:
fallback = self._fallback_directive(task_summary)
self._emit_event(
ORCHESTRATOR_FALLBACK_USED,
{"reason": "thinker_unavailable", "directive": fallback.model_dump(mode="json")},
task_id,
session_id,
)
return fallback
if self._json_compiler is None:
fallback = self._fallback_directive(task_summary)
self._emit_event(
ORCHESTRATOR_FALLBACK_USED,
{"reason": "json_compiler_unavailable", "directive": fallback.model_dump(mode="json")},
task_id,
session_id,
)
return fallback
mode_hint = await self._classify_intent(task_summary)
thinker_prompt = self._build_thinker_prompt(task_summary, context, mode_hint)
for thinker_attempt in range(self._retry_limit + 1):
if thinker_attempt > 0:
self._emit_event(
ORCHESTRATOR_RETRY,
{"attempt": thinker_attempt, "prompt": thinker_prompt},
task_id,
session_id,
)
thinker_prompt = self._add_thinker_feedback(thinker_prompt, last_thinker_error, thinker_attempt)
self._emit_event(
THINKER_CALLED,
{"attempt": thinker_attempt, "mode": mode_hint},
task_id,
session_id,
)
try:
thinker_result = await self._thinker.generate(thinker_prompt)
except Exception as e:
logger.warning(f"Thinker generate failed: {e}")
last_thinker_error = str(e)
continue
logger.info(f"Thinker result (attempt {thinker_attempt + 1}): {thinker_result}")
self._emit_event(
THINKER_RESULT,
{"result": thinker_result, "attempt": thinker_attempt},
task_id,
session_id,
)
# If mode_hint is conversation, only allow respond type
if mode_hint == "conversation" and not self._is_simple_response(thinker_result):
# Check if Thinker is trying to create an execution plan instead
if any(word in thinker_result.lower() for word in ["шаг", "step", "выполнить", "execute", "shell", "команда"]):
# Override to conversation-only response
respond_text = self._extract_conversation_response(thinker_result)
self._emit_event(
ORCHESTRATOR_RESULT,
{"directive": {"type": "respond", "payload": {"text": respond_text}}, "mode_violation": True},
task_id,
session_id,
)
return ExecutionDirective(
type="respond",
payload={"text": respond_text},
requires_permission=False,
reason="Mode violation: conversation only",
)
if self._is_simple_response(thinker_result):
json_compiler_prompt = self._build_json_compiler_prompt(thinker_result)
else:
json_compiler_prompt = self._build_json_compiler_prompt(thinker_result)
for compiler_attempt in range(self._json_fix_retry_limit + 1):
self._emit_event(
JSON_COMPILER_CALLED,
{"attempt": compiler_attempt, "plan": thinker_result},
task_id,
session_id,
)
try:
compiler_result = await self._json_compiler.generate(json_compiler_prompt)
except Exception as e:
logger.warning(f"JSON Compiler generate failed: {e}")
compiler_result = None
if compiler_result:
logger.info(f"JSON Compiler result (attempt {compiler_attempt + 1}): {compiler_result}")
self._emit_event(
JSON_COMPILER_RESULT,
{"result": compiler_result, "attempt": compiler_attempt},
task_id,
session_id,
)
directive = self._validate_directive(compiler_result, mode_hint) if compiler_result else None
if directive is not None:
directive = self._guard_rail_check(directive)
self._emit_event(
ORCHESTRATOR_RESULT,
{"directive": directive.model_dump(mode="json"), "thinker_attempt": thinker_attempt, "compiler_attempt": compiler_attempt},
task_id,
session_id,
)
return directive
if compiler_result:
logger.warning(f"JSON Compiler validation failed, attempting fix (attempt {compiler_attempt + 1})")
fix_result = await self._fix_invalid_json(compiler_result, compiler_attempt, task_id, session_id)
if fix_result:
fixed_directive = self._validate_directive(fix_result, mode_hint)
if fixed_directive is not None:
fixed_directive = self._guard_rail_check(fixed_directive)
self._emit_event(
ORCHESTRATOR_RESULT,
{"directive": fixed_directive.model_dump(mode="json"), "fixed": True},
task_id,
session_id,
)
return fixed_directive
last_thinker_error = f"JSON Compiler failed after {self._json_fix_retry_limit + 1} attempts"
self._emit_event(
ORCHESTRATOR_UNAVAILABLE,
{"reason": "retry_exhausted", "last_error": last_thinker_error},
task_id,
session_id,
)
raise RuntimeError(f"Thinker/Compiler pipeline failed after {self._retry_limit + 1} attempts")
def _fallback_directive(self, task_summary: str) -> ExecutionDirective:
parsed = self._intent_parser.parse(task_summary)
if parsed:
return parsed
return ExecutionDirective(
type="respond",
payload={"text": f"Runtime accepted task: {task_summary}"},
requires_permission=False,
confidence=0.4,
reason="Fallback response because local orchestration models are not loaded.",
)
def _is_simple_response(self, thinker_result: str) -> bool:
result_lower = thinker_result.lower().strip()
return result_lower.startswith("ответ:") or result_lower.startswith("response:") or "не нужно" in result_lower
def _extract_conversation_response(self, thinker_result: str) -> str:
"""Extract text response from thinker result for conversation mode."""
result_lower = thinker_result.lower()
# Skip the ПЛАН lines, just get the ОТВЕТ part
lines = thinker_result.split('\n')
response_lines = []
capture = False
for line in lines:
if line.strip().lower().startswith('ответ:') or line.strip().lower().startswith('response:'):
capture = True
response_lines.append(line)
elif capture and line.strip():
# Check if this is a new ПЛАН or step
if line.strip().lower().startswith('план') or line.strip().lower().startswith('step'):
break
response_lines.append(line)
if response_lines:
return '\n'.join(response_lines).replace('ответ:', '').replace('response:', '').strip()
# Fallback: return first few sentences
sentences = thinker_result.split('.')[:3]
return '. '.join(sentences).strip()
def _build_thinker_prompt(
self, task_summary: str, context: dict[str, Any], mode_hint: str
) -> str:
base_prompt = self._prompts.get("thinker", "")
memory_context = context.get("memory_context", [])
tools_json = "[]"
if self._tool_registry:
schemas = self._tool_registry.list_schemas()
tools_json = json.dumps(schemas, ensure_ascii=False, indent=2)
prompt_lines = [
base_prompt,
"",
f"Task: {task_summary}",
f"Mode hint: {mode_hint}",
]
if memory_context:
memory_text = "\n".join([f"- {m.get('text', '')}" for m in memory_context[:5]])
prompt_lines.append(f"\nRelevant memory:\n{memory_text}")
session_history = context.get("session_history", [])
if session_history:
history_text = "\n".join([f"- {h.get('text', '')}" for h in session_history[:3]])
prompt_lines.append(f"\nPrevious requests in this session:\n{history_text}")
prompt_lines.extend([
"",
f"AVAILABLE TOOLS (JSON):",
tools_json,
"",
])
return "\n".join(prompt_lines)
def _build_json_compiler_prompt(self, thinker_result: str) -> str:
base_prompt = self._prompts.get("json_compiler", "")
prompt_lines = [
base_prompt,
"",
"Thinker's plan:",
thinker_result,
"",
]
return "\n".join(prompt_lines)
def _determine_mode_from_context(self, context: dict[str, Any]) -> str:
"""Legacy method - kept for compatibility"""
task_summary = str(context.get("task_summary", "")).lower()
keywords = ["запусти", "выполни", "создай", "напиши", "удали", "run", "execute", "create"]
for kw in keywords:
if kw in task_summary:
return "execution"
return "conversation"
async def _classify_intent(self, task_summary: str) -> str:
"""LLM-based intent classification"""
if self._intent_classifier == "orchestrator" and self._orchestrator:
classifier_model = self._orchestrator
else:
classifier_model = self._thinker
if not classifier_model:
logger.warning("No classifier model available, using default")
return "conversation"
classification_prompt = f"""Классифицируй запрос пользователя: "{task_summary}"
Правила:
- execution: пользователь ХОЧЕТ выполнить действие (проверить, запустить, создать, удалить, найти, прочитать, записать)
- conversation: пользователь просто отвечает, задаёт вопрос или хочет информацию
- clarification_needed: непонятно что делать
Ответь ОДНИМ словом: execution / conversation / clarification_needed"""
try:
result = await classifier_model.generate(classification_prompt)
result = result.strip().lower()
# Extract first word - LLM often adds explanation
first_word = result.split()[0] if result.split() else ""
# Validate result is one of allowed values
allowed = {"execution", "conversation", "clarification_needed"}
if first_word in allowed:
logger.info(f"Intent classified: {first_word} for task: {task_summary}")
return first_word
if result in allowed:
logger.info(f"Intent classified: {result} for task: {task_summary}")
return result
logger.warning(f"Invalid classification result: {result}, defaulting to conversation")
return "conversation"
except Exception as e:
logger.warning(f"Intent classification failed: {e}, defaulting to conversation")
return "conversation"
def _validate_directive(self, output: str, mode_hint: str) -> ExecutionDirective | None:
if not output:
return None
try:
json_start = output.find("{")
json_end = output.rfind("}") + 1
if json_start < 0 or json_end <= 0:
return None
json_str = output[json_start:json_end]
data = json.loads(json_str)
if "type" not in data:
return None
msg_type = data.get("type", "")
payload = data.get("payload", {})
if msg_type == "step" and "tool" in payload:
tool = payload.get("tool", "")
args = payload.get("args", {})
payload = {"tool": tool, "args": args}
if msg_type == "plan":
payload = {"steps": payload.get("steps", [])}
return ExecutionDirective(
type=msg_type,
payload=payload,
confidence=data.get("confidence", 0.9),
reason=data.get("reason", ""),
)
except (json.JSONDecodeError, ValueError, TypeError) as e:
logger.warning(f"Directive JSON validation failed: {e}")
return None
def _guard_rail_check(self, directive: ExecutionDirective) -> ExecutionDirective:
tool_name = directive.payload.get("tool", "")
if tool_name in {"shell_exec", "file_write", "file_delete"}:
return ExecutionDirective(
type=directive.type,
payload=directive.payload,
requires_permission=True,
confidence=directive.confidence,
reason=directive.reason,
)
return directive
def _add_thinker_feedback(self, prompt: str, error: str, attempt: int) -> str:
feedback = f"\n[ATTEMPT {attempt + 1} FAILED: {error}]\n"
feedback += "Provide a valid semantic plan.\n"
return prompt + feedback
def _emit_event(
self,
event_type: str,
payload: dict[str, Any],
task_id: str | None,
session_id: str | None,
) -> None:
if self._event_bus and task_id:
from app.core.contracts import RuntimeEvent
event = RuntimeEvent(
task_id=task_id,
session_id=session_id or "unknown",
sequence=self._event_bus.next_sequence(task_id),
type=event_type,
payload=payload,
)
self._event_bus.publish(event)
SYS_UTIL_PROMPT = None
async def _fix_invalid_json(self, invalid_result: str, attempt: int, task_id: str | None, session_id: str | None) -> str | None:
"""Try to fix invalid JSON using sys_util model."""
if not self._sys_util:
return None
first_brace = invalid_result.find('{')
last_brace = invalid_result.rfind('}')
if first_brace < 0 or last_brace <= first_brace:
return None
truncated_json = invalid_result[first_brace:last_brace + 1]
error_msg = ""
try:
json.loads(truncated_json)
except json.JSONDecodeError as e:
error_msg = str(e)
sys_util_prompt = (
self._prompts.get("sys_util")
if self._prompts
else self.SYS_UTIL_PROMPT or (
"You are a STRICT JSON repair engine. "
"Your job is ONLY to fix invalid JSON syntax. "
"You MUST output valid JSON or nothing else."
)
)
fix_prompt = f"""{sys_util_prompt}
{error_msg}
Fixed JSON:"""
try:
logger.info(f"JSON fix using sys_util model (attempt {attempt + 1})")
fixed_result = await self._sys_util.generate(fix_prompt)
fixed_first = fixed_result.find('{')
fixed_last = fixed_result.rfind('}')
if fixed_first >= 0 and fixed_last > fixed_first:
return fixed_result[fixed_first:fixed_last + 1]
return None
except Exception as e:
logger.warning(f"JSON fix failed: {e}")
return None
+89
View File
@@ -0,0 +1,89 @@
from __future__ import annotations
import json
from pathlib import Path
from typing import Any
from pydantic import BaseModel, Field
class ModelsConfig(BaseModel):
orchestrator_path: str = "models/llama.gguf"
coder_path: str = "models/xcoder.gguf"
critic_path: str = "models/gemma.gguf"
embeddings_path: str = "models/all-MiniLM-L6-v2"
inference: dict[str, Any] = Field(default_factory=dict)
thinker: dict[str, Any] = Field(default_factory=dict)
json_compiler: dict[str, Any] = Field(default_factory=dict)
orchestrator: dict[str, Any] = Field(default_factory=dict)
coder: dict[str, Any] = Field(default_factory=dict)
critic: dict[str, Any] = Field(default_factory=dict)
sys_util: dict[str, Any] = Field(default_factory=dict)
embeddings: dict[str, Any] = Field(default_factory=dict)
class PromptsConfig(BaseModel):
orchestration_prompt: str = ""
planning_prompt: str = ""
coder_prompt: str = ""
critic_prompt: str = ""
class PermissionsConfig(BaseModel):
dangerous_commands: dict[str, str] = Field(default_factory=dict)
sensitive_paths: list[str] = Field(default_factory=list)
default_approval_behavior: str = "ask_always"
class RuntimeConfig(BaseModel):
step_timeout_ms: int = 30_000
task_timeout_ms: int = 300_000
planner_retry_limit: int = 2
tool_retry_limit: int = 1
replan_limit: int = 1
max_execution_steps: int = 20
retrieval_top_k: int = 5
max_context_tokens: int = 8192
context_budgets: dict[str, int] = Field(default_factory=lambda: {
"system": 512,
"task": 512,
"memory": 2048,
"execution": 2048,
"tools": 1024,
"safety": 512,
})
reserve_for_generation_pct: int = 25
orchestrator_retry_limit: int = 2
intent_classifier: str = "thinker"
memory_thresholds: dict[str, float] = Field(default_factory=dict)
critic_fallback_policy: str = "continue_without_critic"
checkpoint_policy: dict[str, Any] = Field(default_factory=dict)
event_retention_policy: dict[str, Any] = Field(default_factory=dict)
streaming_settings: dict[str, Any] = Field(default_factory=dict)
debug: bool = False
debug_orchestrator_log_length: int = 500
json_fix_retry_limit: int = 2
json_fix_use_sys_util: bool = True
class AppConfig(BaseModel):
models: ModelsConfig
prompts: PromptsConfig
permissions: PermissionsConfig
runtime: RuntimeConfig
def _load_json(path: Path) -> dict[str, Any]:
with path.open("r", encoding="utf-8") as handle:
return json.load(handle)
def load_app_config(config_dir: str | Path) -> AppConfig:
config_path = Path(config_dir)
return AppConfig(
models=ModelsConfig.model_validate(_load_json(config_path / "models.json")),
prompts=PromptsConfig.model_validate(_load_json(config_path / "prompts.json")),
permissions=PermissionsConfig.model_validate(_load_json(config_path / "permissions.json")),
runtime=RuntimeConfig.model_validate(_load_json(config_path / "runtime.json")),
)
+172
View File
@@ -0,0 +1,172 @@
from __future__ import annotations
import logging
from typing import Any
from app.core.contracts import TaskCheckpoint, UserTask
logger = logging.getLogger(__name__)
DEFAULT_BUDGETS = {
"system": 512,
"task": 512,
"memory": 2048,
"execution": 2048,
"tools": 1024,
"safety": 512,
}
class ContextBuilder:
def __init__(
self,
memory_interface=None,
tool_registry=None,
config: dict[str, Any] | None = None,
) -> None:
self._memory = memory_interface
self._tool_registry = tool_registry
self._config = config or {}
self._max_tokens = self._config.get("max_context_tokens", 8192)
self._budgets = self._config.get("context_budgets", DEFAULT_BUDGETS)
self._reserve_pct = self._config.get("reserve_for_generation_pct", 25)
def build(
self,
task: UserTask,
checkpoint: TaskCheckpoint | None = None,
query: str | None = None,
) -> dict[str, Any]:
task_summary = task.input
search_query = query or task_summary
session_id = task.session_id
memory_context = []
if self._memory:
memory_context = self._retrieve_memory(search_query, session_id=session_id)
budgets = self._calculate_budgets()
reserved = self._reserve_for_generation()
system_budget = budgets.get("system", 512)
task_budget = budgets.get("task", 512)
safety_budget = budgets.get("safety", 512)
memory_budget = budgets.get("memory", 2048)
truncated_memory = self._truncate_memory(
memory_context, memory_budget
)
# Get session history for follow-up context
session_history = self._get_session_history(session_id)
context = {
"system_prompt": "",
"task_summary": task_summary[:task_budget],
"task_context": task.context,
"memory_context": truncated_memory,
"session_history": session_history,
"execution_context": checkpoint.model_dump() if checkpoint else {},
"tool_context": self._get_tool_context(),
"safety_context": {},
"constraints": {
"budgets": budgets,
"reserved_for_generation": reserved,
"original_memory_count": len(memory_context),
"truncated_memory_count": len(truncated_memory),
},
}
return context
def _get_tool_context(self) -> list[dict[str, Any]]:
"""Expose available tools to orchestrator."""
if not self._tool_registry:
return []
tools = []
for name in self._tool_registry.list_names():
tool = self._tool_registry.get(name)
tools.append({
"name": name,
"description": getattr(tool, "description", ""),
})
return tools
def _calculate_budgets(self) -> dict[str, int]:
return dict(self._budgets)
def _reserve_for_generation(self) -> int:
return int(self._max_tokens * self._reserve_pct / 100)
def _retrieve_memory(
self,
query: str,
session_id: str | None = None,
top_k: int = 5,
) -> list[dict[str, Any]]:
if not self._memory:
return []
try:
results = self._memory.search(query, top_k=top_k, session_id=session_id)
return [
{
"id": entry.id,
"text": entry.text,
"kind": entry.kind,
"source": entry.source,
"weight": entry.weight,
"score": score,
}
for entry, score in results
]
except Exception as e:
logger.warning(f"Memory retrieval failed: {e}")
return []
def _get_session_history(self, session_id: str | None = None) -> list[dict[str, Any]]:
"""Get previous task summaries from the same session for context."""
if not self._memory or not session_id:
return []
try:
# Get recent entries from same session
entries = self._memory.get_by_session(session_id, limit=5)
# Filter to only task summaries
summaries = [
{
"id": entry.id,
"text": entry.text,
"kind": entry.kind,
"source": entry.source,
"weight": entry.weight,
}
for entry in entries
if entry.kind in ("summary", "tool_result")
]
return summaries
except Exception as e:
logger.warning(f"Session history retrieval failed: {e}")
return []
def _truncate_memory(
self,
memory_context: list[dict[str, Any]],
budget: int,
) -> list[dict[str, Any]]:
if not memory_context:
return []
estimated_per_entry = 50
max_entries = max(budget // estimated_per_entry, 1)
if len(memory_context) > max_entries:
return memory_context[:max_entries]
return memory_context
def estimate_tokens(self, text: str) -> int:
if not text:
return 0
return len(text.split()) * 4 // 3
+148
View File
@@ -0,0 +1,148 @@
from __future__ import annotations
from datetime import datetime, timezone
from typing import Any, Literal
from uuid import uuid4
from pydantic import BaseModel, Field
def utc_now() -> datetime:
return datetime.now(timezone.utc)
class UserTask(BaseModel):
task_id: str = Field(default_factory=lambda: str(uuid4()))
session_id: str = Field(default_factory=lambda: str(uuid4()))
input: str
context: dict[str, Any] = Field(default_factory=dict)
created_at: datetime = Field(default_factory=utc_now)
class PlanStep(BaseModel):
id: str
kind: Literal["tool", "coder", "memory", "respond"]
tool: str | None = None
args: dict[str, Any] = Field(default_factory=dict)
description: str
requires_confirmation: bool = False
depends_on: list[str] = Field(default_factory=list)
class ToolCall(BaseModel):
tool: str
args: dict[str, Any] = Field(default_factory=dict)
task_id: str
step_id: str
class ToolResult(BaseModel):
tool: str
ok: bool
output: Any = None
error: str | None = None
metadata: dict[str, Any] = Field(default_factory=dict)
class CoderRequest(BaseModel):
mode: Literal["generate", "fix", "refactor"]
instruction: str
context: dict[str, Any] = Field(default_factory=dict)
task_id: str
class CriticScore(BaseModel):
correctness: float = Field(ge=0.0, le=1.0)
usefulness: float = Field(ge=0.0, le=1.0)
safety: float = Field(ge=0.0, le=1.0)
memory_store: bool
weight: float = Field(ge=0.0, le=1.0)
explanation: str
class MemoryEntry(BaseModel):
id: str = Field(default_factory=lambda: str(uuid4()))
text: str
kind: Literal["tool_result", "plan", "critique", "fact", "summary", "user_preference"]
source: Literal["tool", "critic", "user", "system"]
weight: float = Field(ge=0.0, le=1.0)
task_id: str | None = None
session_id: str | None = None
metadata: dict[str, Any] = Field(default_factory=dict)
created_at: datetime = Field(default_factory=utc_now)
embedding_model: str
embedding_dim: int
class PermissionDecision(BaseModel):
action_type: str
pattern: str
decision: Literal["allow_once", "allow_always", "deny", "ask_always"]
created_at: datetime = Field(default_factory=utc_now)
class RuntimeEvent(BaseModel):
event_id: str = Field(default_factory=lambda: str(uuid4()))
task_id: str
session_id: str
sequence: int
type: str
timestamp: datetime = Field(default_factory=utc_now)
payload: dict[str, Any] = Field(default_factory=dict)
causation_id: str | None = None
correlation_id: str = Field(default_factory=lambda: str(uuid4()))
class TaskCheckpoint(BaseModel):
task_id: str
status: str
active_step_id: str | None = None
plan_snapshot: dict[str, Any] = Field(default_factory=dict)
context_snapshot: dict[str, Any] = Field(default_factory=dict)
updated_at: datetime = Field(default_factory=utc_now)
class PermissionRequest(BaseModel):
task_id: str
session_id: str
action_type: str
pattern: str
command: str | None = None
path: str | None = None
requires_password: bool = False
class SecretRequest(BaseModel):
task_id: str
session_id: str
kind: str
prompt: str
command: str | None = None
class PasswordRequest(BaseModel):
task_id: str
session_id: str
command: str
reason: str
attempts: int = 0
max_attempts: int = 3
class ExecutionDirective(BaseModel):
type: Literal[
"plan",
"tool",
"coder",
"respond",
"replan",
"store_memory",
"request_permission",
"complete",
"fail",
"noop",
]
payload: dict[str, Any] = Field(default_factory=dict)
requires_permission: bool = False
confidence: float = Field(ge=0.0, le=1.0, default=0.0)
reason: str = ""
+591
View File
@@ -0,0 +1,591 @@
from __future__ import annotations
import asyncio
import json
import logging
from typing import Any
from app.core.contracts import (
CriticScore,
ExecutionDirective,
PermissionDecision,
PermissionRequest,
RuntimeEvent,
SecretRequest,
ToolCall,
UserTask,
)
from app.core.execution_scheduler import ExecutionScheduler
from app.events.event_bus import EventBus
from app.events.event_types import (
CRITIC_CALLED,
CRITIC_RESULT,
PERMISSION_REQUESTED,
PERMISSION_RESOLVED,
PLAN_FAILED,
PLAN_STARTED,
SECRET_REQUESTED,
STEP_STARTED,
STEPPED_COMPLETED,
TOOL_CALLED,
TOOL_COMPLETED,
)
from app.models.async_adapters import AsyncCriticAdapter, AsyncCoderAdapter
from app.memory.write_policy import MemoryWritePolicy
from app.memory.interface import MemoryInterface
logger = logging.getLogger(__name__)
class ExecutionEngine:
def __init__(
self,
event_bus: EventBus,
tool_registry,
permission_service,
scheduler: ExecutionScheduler | None = None,
critic: AsyncCriticAdapter | None = None,
memory_policy: MemoryWritePolicy | None = None,
memory_interface: MemoryInterface | None = None,
prompts: dict[str, str] | None = None,
) -> None:
self._event_bus = event_bus
self._tool_registry = tool_registry
self._permission_service = permission_service
self._scheduler = scheduler or ExecutionScheduler()
self._critic = critic
self._coder: AsyncCoderAdapter | None = None
self._memory_policy = memory_policy
self._memory_interface = memory_interface
self._prompts = prompts or {}
def set_critic(self, critic: AsyncCriticAdapter) -> None:
self._critic = critic
def set_coder(self, coder: AsyncCoderAdapter) -> None:
self._coder = coder
def set_memory_policy(self, policy: MemoryWritePolicy) -> None:
self._memory_policy = policy
def execute(
self,
task: UserTask,
directive: ExecutionDirective,
permission_override: PermissionDecision | None = None,
secret_override: str | None = None,
password_override: str | None = None,
) -> dict[str, Any]:
scheduled = self._scheduler.next_directive(directive)
self._publish(task, STEP_STARTED, {"directive_type": scheduled.type})
if scheduled.type == "plan":
return self._execute_plan(
task=task,
directive=scheduled,
permission_override=permission_override,
secret_override=secret_override,
password_override=password_override,
)
if scheduled.type == "tool":
return self._execute_tool(
task=task,
directive=scheduled,
permission_override=permission_override,
secret_override=secret_override,
password_override=password_override,
)
if scheduled.type == "respond":
return {
"status": "completed",
"result": {
"message": f"Runtime accepted task: {task.input}",
"mode": scheduled.payload.get("mode", "direct_response"),
},
}
if scheduled.type == "coder":
return self._execute_coder(
task=task,
directive=scheduled,
)
if scheduled.type == "fail":
return {
"status": "failed",
"result": {"error": scheduled.reason or "Execution failed."},
}
return {
"status": "completed",
"result": {
"message": "Directive accepted.",
"directive_type": scheduled.type,
},
}
def _execute_plan(
self,
task: UserTask,
directive: ExecutionDirective,
permission_override: PermissionDecision | None = None,
secret_override: str | None = None,
password_override: str | None = None,
) -> dict[str, Any]:
# Unified format: {"type": "plan", "payload": {"steps": [...]}}
# Need to extract steps from nested payload
import json
payload = directive.payload
steps_data = []
# If payload has "steps" directly, use them
if "steps" in payload:
steps_data = payload.get("steps", [])
# If payload is a string (JSON), parse it
elif isinstance(payload, str) and payload.strip().startswith("{"):
try:
parsed = json.loads(payload)
steps_data = parsed.get("payload", {}).get("steps", [])
except:
steps_data = []
if steps_data:
plan_json = json.dumps({"type": "plan", "payload": {"steps": steps_data}})
else:
plan_json = json.dumps(payload)
plan_steps = self._scheduler.parse_plan_steps(plan_json, task.task_id)
if not plan_steps:
return {
"status": "failed",
"result": {"error": "Failed to parse plan steps from directive"},
}
if not self._scheduler.validate_no_cycles(plan_steps):
self._publish(task, PLAN_FAILED, {"error": "Cycle detected in plan"})
return {
"status": "failed",
"result": {"error": "Cycle detected in plan"},
}
graph = self._scheduler.build_task_graph(plan_steps)
self._publish(task, PLAN_STARTED, {"steps": len(plan_steps)})
completed_steps: set[str] = set()
step_results: list[dict[str, Any]] = []
ready_steps = self._get_ready_steps(graph, completed_steps)
while ready_steps:
step = ready_steps.pop(0)
# Handle respond kind directly without tool execution
if step.kind == "respond":
result = {
"status": "completed",
"result": {
"message": step.args.get("text", step.description),
},
}
else:
step_directive = ExecutionDirective(
type=step.kind,
payload={
"tool": step.tool,
"args": step.args,
},
requires_permission=step.requires_confirmation,
reason=step.description,
)
result = self._execute_tool(
task=task,
directive=step_directive,
permission_override=permission_override,
secret_override=secret_override,
password_override=password_override,
)
# If tool needs permission - return immediately, don't continue execution
if result.get("status") == "awaiting_permission":
return {
"status": "awaiting_permission",
"result": result.get("result", {}),
"step_results": step_results,
}
step_results.append({
"step_id": step.id,
"result": result,
})
completed_steps.add(step.id)
self._publish(task, STEPPED_COMPLETED, {
"step_id": step.id,
"status": result.get("status"),
})
# If tool needs permission or failed - return immediately, don't continue execution
if result.get("status") == "failed":
return {
"status": "failed",
"result": {
"error": f"Step {step.id} failed",
"failed_step": step.id,
"step_results": step_results,
},
}
requires_execution = directive.payload.get("requires_execution", True)
if requires_execution and self._critic:
critic_result = self._evaluate_with_critic(
task, step, result
)
if critic_result:
# Convert to dict for JSON serialization
result["critic_score"] = critic_result.model_dump(mode="json") if hasattr(critic_result, 'model_dump') else dict(critic_result)
self._save_critique_to_memory(task, step, critic_result)
ready_steps = self._get_ready_steps(graph, completed_steps)
return {
"status": "completed",
"result": {
"message": f"Plan executed: {len(completed_steps)} steps completed",
"step_results": step_results,
},
}
def _get_ready_steps(
self,
graph: dict[str, Any],
completed: set[str],
) -> list:
if not graph or not graph.get("nodes"):
return []
step_map: dict = graph.get("step_map", {})
ready = []
for node in graph["nodes"]:
node_id = node["id"]
if node_id in completed:
continue
deps = node.get("depends_on", [])
if all(dep in completed for dep in deps):
step = step_map.get(node_id)
if step:
ready.append(step)
return ready
def _evaluate_with_critic(
self,
task: UserTask,
step,
result: dict[str, Any],
) -> CriticScore | None:
if not self._critic:
return None
critic_prompt = self._build_critic_prompt(step, result)
self._publish(task, CRITIC_CALLED, {"step_id": step.id})
try:
critic_output = asyncio.run(self._critic.generate(critic_prompt))
score = self._parse_critic_score(critic_output)
self._publish(task, CRITIC_RESULT, {
"step_id": step.id,
"score": score.model_dump(mode="json") if score else None,
})
if score:
result["critic_score"] = {
"correctness": score.correctness,
"usefulness": score.usefulness,
"safety": score.safety,
"memory_store": score.memory_store,
"weight": score.weight,
"explanation": score.explanation,
}
return score
except Exception as e:
logger.warning(f"Critic evaluation failed: {e}")
self._publish(task, CRITIC_RESULT, {
"step_id": step.id,
"error": str(e),
})
return None
def _save_critique_to_memory(
self,
task: UserTask,
step,
score: CriticScore,
) -> None:
"""Save critic evaluation as critique entry in memory."""
if not self._memory_interface:
return
try:
tool_name = step.tool
tool_args = step.args or {}
args_str = ", ".join([f"{k}={v}" for k, v in tool_args.items()])
critique_text = f"Tool: {tool_name}({args_str}) | Task: {task.input[:100]} | Scores: correctness={score.correctness}, usefulness={score.usefulness}, safety={score.safety} | {score.explanation}"
metadata = {
"task_input": task.input,
"tool": tool_name,
"args": tool_args,
"step_id": step.id,
"scores": {
"correctness": score.correctness,
"usefulness": score.usefulness,
"safety": score.safety,
},
}
self._memory_interface.insert(
text=critique_text,
kind="critique",
source="critic",
task_id=task.task_id,
session_id=task.session_id,
weight=score.weight,
metadata=metadata,
)
logger.info(f"Saved critique to memory: {tool_name} task_id={task.task_id}")
except Exception as e:
logger.warning(f"Failed to save critique to memory: {e}")
def _build_critic_prompt(self, step, result: dict[str, Any]) -> str:
base_prompt = self._prompts.get("critic", "")
tool_result = result.get("result", {})
return f"""{base_prompt}
Step: {step.description}
Tool: {step.tool}
Args: {step.args}
Result:
{json.dumps(tool_result, indent=2)}
Evaluate and respond with JSON:
{{"correctness": 0.0-1.0, "usefulness": 0.0-1.0, "safety": 0.0-1.0, "memory_store": true|false, "weight": 0.0-1.0, "explanation": "..."}}"""
def _parse_critic_score(self, output: str) -> CriticScore | None:
try:
json_start = output.find("{")
json_end = output.rfind("}") + 1
if json_start < 0:
return None
json_str = output[json_start:json_end]
data = json.loads(json_str)
return CriticScore(
correctness=data.get("correctness", 0.5),
usefulness=data.get("usefulness", 0.5),
safety=data.get("safety", 1.0),
memory_store=data.get("memory_store", False),
weight=data.get("weight", 0.5),
explanation=data.get("explanation", ""),
)
except (json.JSONDecodeError, ValueError, TypeError) as e:
logger.warning(f"Critic score parsing failed: {e}")
return None
def _execute_coder(
self,
task: UserTask,
directive: ExecutionDirective,
) -> dict[str, Any]:
if not self._coder:
return {"status": "failed", "result": {"error": "Coder model not available"}}
coder_task = directive.payload.get("task", "")
if not coder_task:
return {"status": "failed", "result": {"error": "Missing task for coder"}}
try:
output = asyncio.run(self._coder.generate(coder_task))
return {
"status": "completed",
"result": {"code": output},
}
except Exception as e:
logger.warning(f"Coder execution failed: {e}")
return {"status": "failed", "result": {"error": str(e)}}
def _execute_tool(
self,
task: UserTask,
directive: ExecutionDirective,
permission_override: PermissionDecision | None = None,
secret_override: str | None = None,
password_override: str | None = None,
) -> dict[str, Any]:
tool_name = str(directive.payload.get("tool", "")).strip()
tool_args = dict(directive.payload.get("args", {}))
if password_override:
tool_args["password"] = password_override
if not tool_name:
return {"status": "failed", "result": {"error": "Missing tool name"}}
# Tool-first: validate tool exists in registry
available_tools = self._tool_registry.list_names()
if tool_name not in available_tools:
return {"status": "failed", "result": {"error": f"Unknown tool: {tool_name}. Available tools: {available_tools}"}}
permission_result = None
# Check permission for shell_exec and file_write
if tool_name == "shell_exec":
permission_result = self._permission_service.check_shell_command(
task_id=task.task_id,
session_id=task.session_id,
command=str(tool_args.get("command", "")),
)
elif tool_name == "file_write":
# Allow writing to runtime data directory without permission check
write_path = str(tool_args.get("path", ""))
if "allowed_commands.json" in write_path or "/data/runtime" in write_path:
# Internal system write - allow without permission
permission_result = {"decision": "allowed", "path": write_path}
else:
permission_result = self._permission_service.check_write_path(
task_id=task.task_id,
session_id=task.session_id,
path=write_path,
)
# Handle permission result
if permission_result:
decision = permission_result.get("decision", "unknown")
# Hard stop - deny execution
if decision == "hard_stop":
self._publish(task, PERMISSION_REQUESTED, permission_result)
return {
"status": "failed",
"result": {
"error": f"Command blocked: {permission_result.get('reason', 'Hard stop command')}",
"command": permission_result.get("command", ""),
},
}
# Cached - already allowed
if decision in ("allowed_always", "allowed") or permission_result.get("cached"):
self._publish(task, PERMISSION_RESOLVED, permission_result)
# Need user confirmation - return immediately, don't continue execution
elif decision == "prompt":
self._publish(task, PERMISSION_REQUESTED, permission_result)
return {
"status": "awaiting_permission",
"result": {
"error": "Permission required before execution.",
"permission_request": permission_result,
},
}
# Hard stop - return immediately
elif decision == "deny":
self._publish(task, PERMISSION_RESOLVED, permission_result)
return {
"status": "failed",
"result": {
"error": "Permission denied",
"command": permission_result.get("command", ""),
},
}
# Deny
elif decision == "deny":
self._publish(task, PERMISSION_RESOLVED, permission_result)
return {
"status": "failed",
"result": {
"error": "Permission denied",
"command": permission_result.get("command", ""),
},
}
if tool_name == "shell_exec":
command = str(tool_args.get("command", ""))
if command.startswith("sudo ") and secret_override is None:
secret_request = SecretRequest(
task_id=task.task_id,
session_id=task.session_id,
kind="sudo_password",
prompt="Sudo password required",
command=command,
)
self._publish(task, SECRET_REQUESTED, secret_request.model_dump(mode="json"))
return {
"status": "awaiting_input",
"result": {
"error": "Secret required",
"secret_request": secret_request.model_dump(mode="json"),
},
}
if command.startswith("sudo ") and secret_override is not None:
tool_args["command"] = f"sudo -S -p '' {command[len('sudo '):]}"
tool_args["stdin_secret"] = f"{secret_override}\n"
tool_call = ToolCall(
tool=tool_name,
args=tool_args,
task_id=task.task_id,
step_id="step-1",
)
self._publish(task, TOOL_CALLED, tool_call.model_dump(mode="json"))
tool_result = self._tool_registry.get(tool_name).execute(task=task, args=tool_args)
self._publish(task, TOOL_COMPLETED, tool_result.model_dump(mode="json"))
needs_sudo = tool_result.metadata.get("needs_sudo", False) if tool_result.metadata else False
if not tool_result.ok and needs_sudo:
return {
"status": "awaiting_password",
"result": {
"task_id": task.task_id,
"needs_sudo": True,
"command": tool_args.get("command", ""),
"error": tool_result.error or "Permission denied",
"tool_result": tool_result.model_dump(mode="json"),
},
}
return {
"status": "completed" if tool_result.ok else "failed",
"result": tool_result.model_dump(mode="json"),
}
def _publish(self, task: UserTask, event_type: str, payload: dict[str, Any]) -> 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)
+212
View File
@@ -0,0 +1,212 @@
from __future__ import annotations
import json
import logging
from collections import deque
from typing import Any
from app.core.contracts import ExecutionDirective, PlanStep
logger = logging.getLogger(__name__)
class ExecutionScheduler:
def __init__(self, retry_limit: int = 2) -> None:
self._retry_limit = retry_limit
def parse_plan_steps(
self,
json_str: str,
task_id: str | None = None,
) -> list[PlanStep]:
try:
json_start = json_str.find("{")
json_end = json_str.rfind("}") + 1
if json_start < 0:
return []
json_str = json_str[json_start:json_end]
data = json.loads(json_str)
# Unified format: {"type": "plan", "payload": {"steps": [...]}}
# or direct: {"type": "step", "payload": {"tool": "...", "args": {...}}}
if isinstance(data, dict):
msg_type = data.get("type", "")
# Single step format: {"type": "step", "payload": {"tool": ..., "args": ...}}
if msg_type == "step":
payload = data.get("payload", {})
step = {
"id": "step-0",
"kind": "tool",
"tool": payload.get("tool"),
"args": payload.get("args", {}),
"description": payload.get("description", ""),
"depends_on": payload.get("depends_on", []),
}
data = [step]
# Plan format: {"type": "plan", "payload": {"steps": [...]}}
elif msg_type == "plan":
payload = data.get("payload", {})
steps_data = payload.get("steps", [])
# Normalize steps: handle {"type": "step", "payload": {"tool": ...}}
normalized = []
for step in steps_data:
if isinstance(step, dict) and step.get("type") == "step":
inner = step.get("payload", {})
normalized.append({
"tool": inner.get("tool"),
"args": inner.get("args", {}),
"description": inner.get("description", ""),
"depends_on": inner.get("depends_on", []),
})
else:
normalized.append(step)
steps_data = normalized
data = steps_data if steps_data else []
# Old format compatibility
elif "steps" in data:
data = data["steps"]
elif "plan" in data:
data = data["plan"]
else:
data = [data]
elif isinstance(data, str):
data = json.loads(data)
if isinstance(data, dict):
data = [data]
steps = []
for i, step_data in enumerate(data):
if isinstance(step_data, str):
step_data = {"id": f"step-{i}", "kind": "respond", "text": step_data}
if not isinstance(step_data, dict):
continue
step_data.setdefault("id", f"step-{i}")
# Tool-first: scheduler получает tool напрямую, без трансформаций
# kind определяется по наличию tool name
# args передаются напрямую
if step_data.get("tool"):
step_data["kind"] = "tool"
step_data.setdefault("kind", step_data.get("kind", "respond"))
step_data.setdefault("tool", step_data.get("tool"))
step_data.setdefault("args", step_data.get("args", {}))
step_data.setdefault("description", step_data.get("description", ""))
step_data.setdefault("requires_confirmation", False)
step_data.setdefault("depends_on", [])
if "description" not in step_data:
step_data["description"] = f"Step {i}"
steps.append(PlanStep(**step_data))
return steps
except (json.JSONDecodeError, ValueError, TypeError) as e:
logger.warning(f"Plan parsing failed: {e}")
return []
def validate_no_cycles(self, steps: list[PlanStep]) -> bool:
if not steps:
return True
graph: dict[str, set[str]] = {}
for step in steps:
graph[step.id] = set(step.depends_on)
visited: set[str] = set()
rec_stack: set[str] = set()
def has_cycle(node: str) -> bool:
if node in rec_stack:
return True
if node in visited:
return False
visited.add(node)
rec_stack.add(node)
for dep in graph.get(node, []):
if has_cycle(dep):
return True
rec_stack.remove(node)
return False
for step in steps:
if step.id not in visited:
if has_cycle(step.id):
logger.warning(f"Cycle detected in plan: {step.id}")
return False
return True
def build_task_graph(
self,
steps: list[PlanStep],
) -> dict[str, Any]:
if not steps:
return {"nodes": [], "edges": []}
if not self.validate_no_cycles(steps):
return {"nodes": [], "edges": [], "error": "Cycle detected in plan"}
nodes = []
edges = []
step_map = {s.id: s for s in steps}
for step in steps:
nodes.append({
"id": step.id,
"kind": step.kind,
"tool": step.tool,
"args": step.args,
"ready": len(step.depends_on) == 0,
})
for dep_id in step.depends_on:
edges.append({
"from": dep_id,
"to": step.id,
})
return {"nodes": nodes, "edges": edges, "step_map": step_map}
def get_ready_steps(
self,
graph: dict[str, Any],
completed: set[str],
) -> list[PlanStep]:
if not graph or not graph.get("nodes"):
return []
step_map: dict[str, PlanStep] = graph.get("step_map", {})
ready = []
for node in graph["nodes"]:
node_id = node["id"]
if node_id in completed:
continue
deps = node.get("depends_on", [])
if all(dep in completed for dep in deps):
step = step_map.get(node_id)
if step:
ready.append(step)
return ready
def next_directive(
self,
directive: ExecutionDirective,
) -> ExecutionDirective:
return directive
+106
View File
@@ -0,0 +1,106 @@
from __future__ import annotations
import re
from typing import Any
from app.core.contracts import ExecutionDirective
SHELL_PREFIXES = (
"run ",
"execute ",
"launch ",
"запусти ",
"выполни ",
"выполнить ",
)
MEMORY_STORE_PATTERNS = (
r"запомни\s+(.+)",
r"сохрани\s+(.+)",
r"запиши\s+(.+)",
r"remember\s+(.+)",
r"save\s+(.+)",
)
MEMORY_SEARCH_PATTERNS = (
r"найди\s+(.+)",
r"вспомни\s+(.+)",
r"search\s+(.+)",
r"find\s+(.+)",
)
class IntentParser:
"""Extracts explicit tool intents from natural-language task text."""
def __init__(self) -> None:
self._store_patterns = [re.compile(p, re.IGNORECASE) for p in MEMORY_STORE_PATTERNS]
self._search_patterns = [re.compile(p, re.IGNORECASE) for p in MEMORY_SEARCH_PATTERNS]
def parse(self, task_input: str) -> ExecutionDirective | None:
normalized = task_input.strip()
lowered = normalized.lower()
if matched := self._match_patterns(self._store_patterns, normalized):
return ExecutionDirective(
type="tool",
payload={
"tool": "memory_insert",
"args": {
"text": matched.group(1).strip(),
"kind": "fact",
"source": "user",
},
},
requires_permission=False,
confidence=0.85,
reason="User explicitly requested to store in memory.",
)
if matched := self._match_patterns(self._search_patterns, normalized):
return ExecutionDirective(
type="tool",
payload={
"tool": "memory_search",
"args": {"query": matched.group(1).strip()},
},
requires_permission=False,
confidence=0.85,
reason="User explicitly requested to search memory.",
)
for prefix in SHELL_PREFIXES:
if lowered.startswith(prefix):
command = normalized[len(prefix) :].strip()
if command:
return ExecutionDirective(
type="tool",
payload={
"tool": "shell_exec",
"args": {"command": command},
},
requires_permission=True,
confidence=0.92,
reason="Natural-language task explicitly requested shell execution.",
)
quoted = re.match(r"^`(.+)`$", normalized)
if quoted:
return ExecutionDirective(
type="tool",
payload={
"tool": "shell_exec",
"args": {"command": quoted.group(1)},
},
requires_permission=True,
confidence=0.75,
reason="Backticked input treated as direct shell command.",
)
return None
def _match_patterns(self, patterns: list[re.Pattern], text: str):
for pattern in patterns:
if match := pattern.match(text):
return match
return None
+18
View File
@@ -0,0 +1,18 @@
from __future__ import annotations
from pydantic import BaseModel
class PermissionResolutionRequest(BaseModel):
task_id: str
decision: str
class SecretResolutionRequest(BaseModel):
task_id: str
secret: str
class PasswordResolutionRequest(BaseModel):
task_id: str
password: str
+341
View File
@@ -0,0 +1,341 @@
from __future__ import annotations
import hashlib
import json
import logging
import os
import re
import shlex
from pathlib import Path
from typing import Any
logger = logging.getLogger(__name__)
class PermissionService:
"""Permission-first model - user is the authority."""
def __init__(self, config: dict[str, Any] | None = None, cache_file: Path | None = None):
self._config = config or self._load_config()
self._settings = self._config.get("settings", {})
self._cache_file = cache_file
self._categories = self._config.get("command_categories", {})
self._path_settings = self._config.get("path_settings", {})
self._legacy_dangerous_commands = self._config.get("dangerous_commands", {})
self._legacy_sensitive_paths = self._config.get("sensitive_paths", [])
def _load_config(self) -> dict[str, Any]:
try:
config_path = Path(__file__).parents[2] / "config" / "permissions.json"
with open(config_path) as f:
return json.load(f)
except Exception as e:
logger.warning(f"Failed to load permissions config: {e}")
return {"settings": {}, "command_categories": {}}
def _get_cache_file(self) -> Path:
if self._cache_file:
return self._cache_file
base_dir = Path(__file__).parents[2]
cache_relative = self._settings.get("cache_file", "data/runtime/allowed_commands.json")
return base_dir / cache_relative
def _load_cache(self) -> dict[str, Any]:
cache_file = self._get_cache_file()
try:
if cache_file.exists():
with open(cache_file) as f:
return json.load(f)
except Exception as e:
logger.warning(f"Failed to load cache: {e}")
return {"allowed_once": {}, "allowed_always": {}}
def _save_cache(self, cache: dict[str, Any]) -> None:
cache_file = self._get_cache_file()
cache_file.parent.mkdir(parents=True, exist_ok=True)
with open(cache_file, "w") as f:
json.dump(cache, f, indent=2)
def check_shell_command(
self,
task_id: str,
session_id: str,
command: str,
) -> dict[str, Any]:
"""Check if shell command requires permission."""
normalized = self._normalize_command(command)
command_hash = self._hash_command(normalized)
cache = self._load_cache()
# Check cache first
if command_hash in cache.get("allowed_always", {}):
return {
"decision": "allowed_always",
"command": normalized,
"cached": True,
}
if command_hash in cache.get("allowed_once", {}):
cached = cache["allowed_once"][command_hash]
if cached.get("task_id") == task_id:
return {
"decision": "allowed_once",
"command": normalized,
"cached": True,
}
# Check hard stop
if self._is_hard_stop(normalized):
return {
"decision": "hard_stop",
"command": normalized,
"reason": "Hard stop command - execution denied",
}
if not self._categories and self._legacy_dangerous_commands:
if self._matches_legacy_dangerous(normalized):
return {
"decision": "prompt",
"command": normalized,
"category": "legacy_dangerous",
"allow_always": False,
"task_id": task_id,
"session_id": session_id,
}
return {
"decision": "allowed",
"command": normalized,
"category": "legacy_safe",
"task_id": task_id,
"session_id": session_id,
}
# Check no_always category
category = self._get_category(normalized)
can_always = self._categories.get(category, {}).get("allow_always", True)
# Need user confirmation
return {
"decision": "prompt",
"command": normalized,
"category": category,
"allow_always": can_always,
"task_id": task_id,
"session_id": session_id,
}
def check_write_path(
self,
task_id: str,
session_id: str,
path: str,
) -> dict[str, Any]:
"""Check if write path requires permission."""
if not self._path_settings and self._legacy_sensitive_paths:
if any(path.startswith(sensitive) for sensitive in self._legacy_sensitive_paths):
return {
"decision": "prompt",
"path": path,
"task_id": task_id,
"session_id": session_id,
}
return {"decision": "allowed", "path": path}
allow_write_paths = self._path_settings.get("allow_write_paths", [])
# Check if path is in allowed list
for allowed in allow_write_paths:
if path.startswith(allowed):
return {"decision": "allowed", "path": path}
# Otherwise require permission
return {
"decision": "prompt",
"path": path,
"task_id": task_id,
"session_id": session_id,
}
def resolve_permission(
self,
task_id: str,
session_id: str,
command: str,
decision: str,
) -> dict[str, Any]:
"""Resolve permission decision from user."""
normalized = self._normalize_command(command)
command_hash = self._hash_command(normalized)
cache = self._load_cache()
if decision == "allow_once":
cache.setdefault("allowed_once", {})[command_hash] = {
"command": normalized,
"task_id": task_id,
"session_id": session_id,
}
self._save_cache(cache)
return {"status": "allowed_once", "command": normalized}
elif decision == "allow_always":
cache.setdefault("allowed_always", {})[command_hash] = {
"command": normalized,
"task_id": task_id,
"session_id": session_id,
}
self._save_cache(cache)
return {"status": "allowed_always", "command": normalized}
elif decision == "deny":
return {"status": "denied", "command": normalized}
return {"status": "unknown", "decision": decision}
def clear_cache(self) -> dict[str, Any]:
"""Clear permission cache."""
cache = {"allowed_once": {}, "allowed_always": {}}
self._save_cache(cache)
return {"status": "cache_cleared"}
def _normalize_command(self, command: str) -> str:
"""Normalize command for consistent hashing."""
if not self._settings.get("normalize_commands", True):
return command.strip()
normalized = command.strip()
# Split chained commands if enabled
if self._settings.get("split_chained", True):
# Replace ; and || with && for splitting
normalized = normalized.replace(";", " && ")
normalized = normalized.replace("||", " && ")
# Resolve environment variables
try:
normalized = os.path.expandvars(normalized)
except:
pass
# Resolve home directory
normalized = normalized.replace("~", os.path.expanduser("~"))
# Remove extra whitespace
normalized = " ".join(normalized.split())
return normalized
def _hash_command(self, command: str) -> str:
"""Generate hash for command."""
return hashlib.sha256(command.encode()).hexdigest()[:16]
def _matches_legacy_dangerous(self, command: str) -> bool:
cmd_lower = command.lower()
for pattern in self._legacy_dangerous_commands:
if pattern.lower() in cmd_lower:
return True
return False
def _is_hard_stop(self, command: str) -> bool:
"""Check if command is hard stop."""
hard_stop_commands = self._categories.get("hard_stop", {}).get("commands", [])
cmd_lower = command.lower()
for hs in hard_stop_commands:
if hs.lower() in cmd_lower:
return True
return False
def _get_category(self, command: str) -> str:
"""Get command category."""
cmd_lower = command.lower()
# Check no_always category
no_always = self._categories.get("no_always", {}).get("commands", [])
for cmd in no_always:
if cmd in cmd_lower:
return "no_always"
# Default to normal
return "normal"
SUDO_COMMANDS = {
"apt", "apt-get", "dpkg", "yum", "dnf", "pacman", "zypper",
"systemctl", "service", "mount", "umount",
"shutdown", "reboot", "halt", "poweroff",
"useradd", "usermod", "userdel", "groupadd", "groupmod",
"chmod", "chown", "chgrp",
"iptables", "ufw",
"kill", "killall", "pkill",
}
def _requires_sudo(command: str) -> bool:
"""Check if command requires sudo."""
if not command:
return False
cmd_lower = command.lower().strip()
first_word = cmd_lower.split()[0] if cmd_lower.split() else ""
return first_word in SUDO_COMMANDS
class PermissionRequest:
"""Permission request to user."""
def __init__(
self,
task_id: str,
session_id: str,
command: str,
category: str = "normal",
allow_always: bool = True,
) -> None:
self.task_id = task_id
self.session_id = session_id
self.command = command
self.category = category
self.allow_always = allow_always
self.requires_password = _requires_sudo(command)
def to_dict(self) -> dict[str, Any]:
return {
"task_id": self.task_id,
"session_id": self.session_id,
"command": self.command,
"category": self.category,
"allow_always": self.allow_always,
"requires_password": self.requires_password,
"buttons": self._get_buttons(),
}
def _get_buttons(self) -> list[dict[str, str]]:
buttons = [{"action": "deny", "label": "Запретить"}]
if self.allow_always:
buttons.insert(0, {"action": "allow_always", "label": "Разрешить навсегда"})
if self.requires_password:
buttons.insert(0, {"action": "allow_with_password", "label": "Разрешить с паролем"})
else:
buttons.insert(0, {"action": "allow_once", "label": "Разрешить"})
return buttons
class PermissionDecision:
"""Permission decision."""
def __init__(
self,
decision: str,
command: str | None = None,
cached: bool = False,
) -> None:
self.decision = decision
self.command = command
self.cached = cached