fixes
This commit is contained in:
+46
-17
@@ -23,7 +23,7 @@ class CriticFeedbackRequest(BaseModel):
|
||||
usefulness_override: float | None = None
|
||||
safety_override: float | None = None
|
||||
|
||||
from app.core.permission_resolution import PermissionResolutionRequest, SecretResolutionRequest, PasswordResolutionRequest
|
||||
from app.core.permission_resolution import PermissionResolutionRequest, SecretResolutionRequest, PasswordResolutionRequest, ReviewResolutionRequest
|
||||
from app.core.contracts import UserTask
|
||||
from app.runtime.runtime_controller import RuntimeController
|
||||
from app.streaming.manager import StreamingManager
|
||||
@@ -33,19 +33,24 @@ from app.streaming.manager import StreamingManager
|
||||
async def lifespan(app: FastAPI):
|
||||
"""Load models on startup."""
|
||||
print("Lifespan: Starting model loading...")
|
||||
loop = asyncio.get_event_loop()
|
||||
|
||||
def load_models():
|
||||
try:
|
||||
print("Lifespan: Loading models...")
|
||||
runtime.load_models_at_startup()
|
||||
print("Lifespan: Models loaded")
|
||||
except Exception as e:
|
||||
print(f"Lifespan: Failed to load models: {e}")
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
|
||||
await loop.run_in_executor(None, load_models)
|
||||
try:
|
||||
print("Lifespan: Loading models...")
|
||||
runtime.load_models_at_startup()
|
||||
print("Lifespan: Models loaded")
|
||||
|
||||
# Rebuild vector index if empty but memory store has data.
|
||||
if runtime._memory_interface:
|
||||
store_count = runtime._memory_interface.count()
|
||||
if store_count > 0:
|
||||
idx_count = runtime._memory_interface._vector_index.element_count
|
||||
if idx_count == 0:
|
||||
print(f"Lifespan: Rebuilding vector index ({store_count} entries)...")
|
||||
runtime._memory_interface.reindex()
|
||||
print("Lifespan: Vector index rebuilt")
|
||||
except Exception as e:
|
||||
print(f"Lifespan: Failed to load models: {e}")
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
|
||||
yield # Server runs here
|
||||
|
||||
@@ -80,24 +85,44 @@ def list_events(limit: int = 500) -> dict[str, object]:
|
||||
|
||||
@app.post("/chat")
|
||||
def chat(task: UserTask) -> dict[str, object]:
|
||||
submit = getattr(runtime, "submit_task", None)
|
||||
if callable(submit):
|
||||
return submit(task)
|
||||
return runtime.handle_task(task)
|
||||
|
||||
|
||||
@app.post("/permissions/resolve")
|
||||
def resolve_permission(request: PermissionResolutionRequest) -> dict[str, object]:
|
||||
submit = getattr(runtime, "submit_permission_resolution", None)
|
||||
if callable(submit):
|
||||
return submit(task_id=request.task_id, decision=request.decision)
|
||||
return runtime.resolve_permission(task_id=request.task_id, decision=request.decision)
|
||||
|
||||
|
||||
@app.post("/secrets/resolve")
|
||||
def resolve_secret(request: SecretResolutionRequest) -> dict[str, object]:
|
||||
submit = getattr(runtime, "submit_secret_resolution", None)
|
||||
if callable(submit):
|
||||
return submit(task_id=request.task_id, secret=request.secret)
|
||||
return runtime.resolve_secret(task_id=request.task_id, secret=request.secret)
|
||||
|
||||
|
||||
@app.post("/password/resolve")
|
||||
def resolve_password(request: PasswordResolutionRequest) -> dict[str, object]:
|
||||
submit = getattr(runtime, "submit_password_resolution", None)
|
||||
if callable(submit):
|
||||
return submit(task_id=request.task_id, password=request.password)
|
||||
return runtime.resolve_password(task_id=request.task_id, password=request.password)
|
||||
|
||||
|
||||
@app.post("/review/resolve")
|
||||
def resolve_review(request: ReviewResolutionRequest) -> dict[str, object]:
|
||||
submit = getattr(runtime, "submit_review_resolution", None)
|
||||
if callable(submit):
|
||||
return submit(task_id=request.task_id, decision=request.decision, correction=request.correction)
|
||||
return runtime.resolve_review(task_id=request.task_id, decision=request.decision, correction=request.correction)
|
||||
|
||||
|
||||
@app.post("/critic/feedback")
|
||||
def critic_feedback(request: CriticFeedbackRequest) -> dict[str, object]:
|
||||
feedback = runtime.handle_critic_feedback(
|
||||
@@ -130,11 +155,15 @@ async def stream_task(websocket: WebSocket, task_id: str) -> None:
|
||||
queue = streaming.subscribe(task_id)
|
||||
try:
|
||||
while True:
|
||||
event = await asyncio.wait_for(queue.get(), timeout=15)
|
||||
try:
|
||||
event = await asyncio.wait_for(queue.get(), timeout=30)
|
||||
except asyncio.TimeoutError:
|
||||
await websocket.send_json({"type": "heartbeat", "task_id": task_id})
|
||||
continue
|
||||
await websocket.send_json(event.model_dump(mode="json"))
|
||||
if event.type in {"task_completed", "task_failed", "task_awaiting_permission", "task_awaiting_input"}:
|
||||
if event.type in {"task_completed", "task_failed", "task_awaiting_permission", "task_awaiting_input", "task_awaiting_review"}:
|
||||
break
|
||||
except (asyncio.TimeoutError, WebSocketDisconnect):
|
||||
except WebSocketDisconnect:
|
||||
pass
|
||||
finally:
|
||||
streaming.unsubscribe(task_id, queue)
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 17 KiB |
+1013
-739
File diff suppressed because it is too large
Load Diff
@@ -322,6 +322,14 @@ class AsyncRouter:
|
||||
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}")
|
||||
|
||||
# Active memory recall results
|
||||
memory_recall = context.get("memory_recall")
|
||||
if memory_recall:
|
||||
prompt_lines.append("\n=== ИЗ ДОЛГОВРЕМЕННОЙ ПАМЯТИ (ACTIVE RECALL) ===")
|
||||
prompt_lines.append(f"Поисковый запрос: {memory_recall.get('query', '')}")
|
||||
prompt_lines.append(memory_recall.get("summary", ""))
|
||||
prompt_lines.append("=== КОНЕЦ ПАМЯТИ ===")
|
||||
|
||||
prompt_lines.extend([
|
||||
"",
|
||||
f"AVAILABLE TOOLS (JSON):",
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
import shlex
|
||||
from typing import Any
|
||||
|
||||
from app.core.permission_service import PermissionService
|
||||
|
||||
|
||||
class CommandAnalyzer:
|
||||
"""Deterministic shell action analyzer for structured critic evidence."""
|
||||
|
||||
_SPLIT_RE = re.compile(r"\s*(?:&&|;)\s*")
|
||||
|
||||
def __init__(self, permission_service: PermissionService) -> None:
|
||||
self._permission_service = permission_service
|
||||
|
||||
def analyze(self, command: str, task_id: str, session_id: str) -> dict[str, Any]:
|
||||
segments = [segment.strip() for segment in self._SPLIT_RE.split(command) if segment.strip()]
|
||||
root_required: list[str] = []
|
||||
elevated: list[str] = []
|
||||
unelevated_root: list[str] = []
|
||||
|
||||
for segment in segments:
|
||||
normalized, is_elevated = self._strip_sudo(segment)
|
||||
check = self._permission_service.check_shell_command(
|
||||
task_id=task_id,
|
||||
session_id=session_id,
|
||||
command=normalized,
|
||||
)
|
||||
if check.get("requires_sudo"):
|
||||
root_required.append(normalized)
|
||||
if is_elevated:
|
||||
elevated.append(normalized)
|
||||
else:
|
||||
unelevated_root.append(normalized)
|
||||
|
||||
diagnosis_type = "privilege_scope_error" if unelevated_root else "ok"
|
||||
return {
|
||||
"type": diagnosis_type,
|
||||
"command": command,
|
||||
"segments": segments,
|
||||
"root_required_segments": root_required,
|
||||
"elevated_segments": elevated,
|
||||
"unelevated_root_segments": unelevated_root,
|
||||
}
|
||||
|
||||
def _strip_sudo(self, segment: str) -> tuple[str, bool]:
|
||||
try:
|
||||
parts = shlex.split(segment)
|
||||
except ValueError:
|
||||
return segment, segment.strip().startswith("sudo ")
|
||||
if not parts or parts[0] != "sudo":
|
||||
return segment, False
|
||||
index = 1
|
||||
while index < len(parts) and parts[index].startswith("-"):
|
||||
index += 1
|
||||
if index < len(parts) and parts[index - 1] in {"-p", "--prompt"}:
|
||||
index += 1
|
||||
return " ".join(shlex.quote(part) for part in parts[index:]), True
|
||||
+5
-1
@@ -38,6 +38,8 @@ class PermissionsConfig(BaseModel):
|
||||
class RuntimeConfig(BaseModel):
|
||||
step_timeout_ms: int = 30_000
|
||||
task_timeout_ms: int = 300_000
|
||||
shell_command_timeout_ms: int = 3_600_000
|
||||
shell_idle_timeout_ms: int = 600_000
|
||||
planner_retry_limit: int = 2
|
||||
tool_retry_limit: int = 1
|
||||
replan_limit: int = 1
|
||||
@@ -55,6 +57,7 @@ class RuntimeConfig(BaseModel):
|
||||
reserve_for_generation_pct: int = 25
|
||||
orchestrator_retry_limit: int = 2
|
||||
intent_classifier: str = "thinker"
|
||||
recall_model: str = "sys_util"
|
||||
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)
|
||||
@@ -64,6 +67,8 @@ class RuntimeConfig(BaseModel):
|
||||
debug_orchestrator_log_length: int = 500
|
||||
json_fix_retry_limit: int = 2
|
||||
json_fix_use_sys_util: bool = True
|
||||
recall_model: str = "json_compiler"
|
||||
critic_retry_limit: int = 2
|
||||
|
||||
|
||||
class AppConfig(BaseModel):
|
||||
@@ -86,4 +91,3 @@ def load_app_config(config_dir: str | Path) -> AppConfig:
|
||||
permissions=PermissionsConfig.model_validate(_load_json(config_path / "permissions.json")),
|
||||
runtime=RuntimeConfig.model_validate(_load_json(config_path / "runtime.json")),
|
||||
)
|
||||
|
||||
|
||||
+242
-22
@@ -13,8 +13,10 @@ from app.core.contracts import (
|
||||
RuntimeEvent,
|
||||
SecretRequest,
|
||||
ToolCall,
|
||||
ToolResult,
|
||||
UserTask,
|
||||
)
|
||||
from app.core.command_analyzer import CommandAnalyzer
|
||||
from app.core.execution_scheduler import ExecutionScheduler
|
||||
from app.events.event_bus import EventBus
|
||||
from app.events.event_types import (
|
||||
@@ -29,6 +31,7 @@ from app.events.event_types import (
|
||||
STEPPED_COMPLETED,
|
||||
TOOL_CALLED,
|
||||
TOOL_COMPLETED,
|
||||
TOOL_OUTPUT_CHUNK,
|
||||
)
|
||||
from app.models.async_adapters import AsyncCriticAdapter, AsyncCoderAdapter
|
||||
from app.memory.write_policy import MemoryWritePolicy
|
||||
@@ -49,6 +52,8 @@ class ExecutionEngine:
|
||||
memory_interface: MemoryInterface | None = None,
|
||||
prompts: dict[str, str] | None = None,
|
||||
recovery_limit: int = 1,
|
||||
critic_retry_limit: int = 2,
|
||||
command_analyzer: CommandAnalyzer | None = None,
|
||||
) -> None:
|
||||
self._event_bus = event_bus
|
||||
self._tool_registry = tool_registry
|
||||
@@ -60,6 +65,8 @@ class ExecutionEngine:
|
||||
self._memory_interface = memory_interface
|
||||
self._prompts = prompts or {}
|
||||
self._recovery_limit = recovery_limit
|
||||
self._critic_retry_limit = critic_retry_limit
|
||||
self._command_analyzer = command_analyzer
|
||||
|
||||
def set_critic(self, critic: AsyncCriticAdapter) -> None:
|
||||
self._critic = critic
|
||||
@@ -103,9 +110,10 @@ class ExecutionEngine:
|
||||
return {
|
||||
"status": "completed",
|
||||
"result": {
|
||||
"message": f"Runtime accepted task: {task.input}",
|
||||
"message": scheduled.payload.get("text", f"Runtime accepted task: {task.input}"),
|
||||
"mode": scheduled.payload.get("mode", "direct_response"),
|
||||
},
|
||||
"directive": scheduled.model_dump(mode="json"),
|
||||
}
|
||||
|
||||
if scheduled.type == "coder":
|
||||
@@ -179,6 +187,7 @@ class ExecutionEngine:
|
||||
|
||||
completed_steps: set[str] = set()
|
||||
step_results: list[dict[str, Any]] = []
|
||||
critic_retries_used = 0 # Track critic→replan cycles
|
||||
|
||||
ready_steps = self._get_ready_steps(graph, completed_steps)
|
||||
|
||||
@@ -212,10 +221,15 @@ class ExecutionEngine:
|
||||
password_override=password_override,
|
||||
)
|
||||
|
||||
# If tool needs permission - return immediately, don't continue execution
|
||||
if result.get("status") == "awaiting_permission":
|
||||
# If tool needs human input/review - return immediately.
|
||||
if result.get("status") in (
|
||||
"awaiting_permission",
|
||||
"awaiting_input",
|
||||
"awaiting_password",
|
||||
"awaiting_review",
|
||||
):
|
||||
return {
|
||||
"status": "awaiting_permission",
|
||||
"status": result.get("status"),
|
||||
"result": result.get("result", {}),
|
||||
"step_results": step_results,
|
||||
}
|
||||
@@ -231,7 +245,76 @@ class ExecutionEngine:
|
||||
"status": result.get("status"),
|
||||
})
|
||||
|
||||
# === Critic evaluation ===
|
||||
if self._critic and result.get("status") == "completed":
|
||||
critic_score = self._evaluate_with_critic(task, step, result)
|
||||
if critic_score:
|
||||
result["critic_score"] = {
|
||||
"correctness": critic_score.correctness,
|
||||
"usefulness": critic_score.usefulness,
|
||||
"safety": critic_score.safety,
|
||||
"memory_store": critic_score.memory_store,
|
||||
"weight": critic_score.weight,
|
||||
"explanation": critic_score.explanation,
|
||||
}
|
||||
self._save_critique_to_memory(task, step, critic_score)
|
||||
|
||||
# Check if step result is satisfactory
|
||||
min_correctness = 0.5
|
||||
if critic_score.correctness < min_correctness:
|
||||
# Step failed critic check — try to recover
|
||||
if critic_retries_used < self._critic_retry_limit and step.kind != "respond":
|
||||
critic_retries_used += 1
|
||||
self._publish(task, CRITIC_RESULT, {
|
||||
"step_id": step.id,
|
||||
"score": critic_score.model_dump(mode="json"),
|
||||
"action": "retry",
|
||||
"retry": critic_retries_used,
|
||||
})
|
||||
# Retry the same step — rebuild directive
|
||||
retry_directive = ExecutionDirective(
|
||||
type=step.kind,
|
||||
payload={"tool": step.tool, "args": step.args},
|
||||
requires_permission=step.requires_confirmation,
|
||||
reason=step.description,
|
||||
)
|
||||
retry_result = self._execute_tool(
|
||||
task=task,
|
||||
directive=retry_directive,
|
||||
permission_override=permission_override,
|
||||
secret_override=secret_override,
|
||||
password_override=password_override,
|
||||
)
|
||||
if retry_result.get("status") == "completed":
|
||||
result = retry_result
|
||||
step_results[-1]["result"] = result
|
||||
# Re-evaluate after retry
|
||||
critic_score2 = self._evaluate_with_critic(task, step, result)
|
||||
if critic_score2 and critic_score2.correctness >= min_correctness:
|
||||
# Retry succeeded
|
||||
continue
|
||||
# If retry also failed, continue to next step
|
||||
else:
|
||||
self._publish(task, CRITIC_RESULT, {
|
||||
"step_id": step.id,
|
||||
"score": critic_score.model_dump(mode="json"),
|
||||
"action": "give_up",
|
||||
"reason": f"Critic retry limit ({self._critic_retry_limit}) reached",
|
||||
})
|
||||
|
||||
# Handle failed step
|
||||
if result.get("status") == "failed":
|
||||
review = self._build_failed_step_review(task, step, result)
|
||||
if review:
|
||||
return {
|
||||
"status": "awaiting_review",
|
||||
"result": {
|
||||
"error": f"Step {step.id} requires review before replanning",
|
||||
"failed_step": step.id,
|
||||
"step_results": step_results,
|
||||
"review": review,
|
||||
},
|
||||
}
|
||||
recovery = self._recover_failed_step(
|
||||
task=task,
|
||||
step=step,
|
||||
@@ -266,16 +349,6 @@ class ExecutionEngine:
|
||||
},
|
||||
}
|
||||
|
||||
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 {
|
||||
@@ -286,6 +359,31 @@ class ExecutionEngine:
|
||||
},
|
||||
}
|
||||
|
||||
def _build_failed_step_review(self, task: UserTask, step, result: dict[str, Any]) -> dict[str, Any] | None:
|
||||
if step.tool != "shell_exec" or not self._command_analyzer:
|
||||
return None
|
||||
command = str((step.args or {}).get("command", ""))
|
||||
if not command:
|
||||
return None
|
||||
diagnosis = self._command_analyzer.analyze(
|
||||
command=command,
|
||||
task_id=task.task_id,
|
||||
session_id=task.session_id,
|
||||
)
|
||||
if diagnosis.get("type") == "ok":
|
||||
return None
|
||||
return {
|
||||
"step_id": step.id,
|
||||
"tool": step.tool,
|
||||
"command": command,
|
||||
"diagnosis": diagnosis,
|
||||
"critic_assessment": {
|
||||
"classification": "model_planning_error",
|
||||
"needs_replan": True,
|
||||
"explanation": "Structured command analysis found a model action error before recovery.",
|
||||
},
|
||||
}
|
||||
|
||||
def _recover_failed_step(
|
||||
self,
|
||||
task: UserTask,
|
||||
@@ -496,11 +594,23 @@ Previous step results:
|
||||
step,
|
||||
score: CriticScore,
|
||||
) -> None:
|
||||
"""Save critic evaluation as critique entry in memory."""
|
||||
"""Save critic evaluation as critique entry in memory, using MemoryWritePolicy."""
|
||||
if not self._memory_interface:
|
||||
return
|
||||
|
||||
try:
|
||||
# Check with policy before saving
|
||||
if self._memory_policy:
|
||||
decision = self._memory_policy.decide(
|
||||
critic_score=score,
|
||||
memory_type="critique",
|
||||
session_id=task.session_id,
|
||||
)
|
||||
if decision == "skip":
|
||||
logger.info(f"MemoryWritePolicy skipped critique for {step.tool}")
|
||||
return
|
||||
# For "store_with_weight", we could adjust weight, but critic score already has weight
|
||||
|
||||
tool_name = step.tool
|
||||
tool_args = step.args or {}
|
||||
args_str = ", ".join([f"{k}={v}" for k, v in tool_args.items()])
|
||||
@@ -537,6 +647,26 @@ Previous step results:
|
||||
base_prompt = self._prompts.get("critic", "")
|
||||
tool_result = result.get("result", {})
|
||||
|
||||
# Truncate long outputs to avoid exceeding context window
|
||||
# Keep output under ~2000 chars to leave room for prompt + generation
|
||||
output = tool_result.get("output", "")
|
||||
if isinstance(output, str) and len(output) > 2000:
|
||||
output = output[:2000] + "\n... [truncated]"
|
||||
elif not isinstance(output, str):
|
||||
output_str = json.dumps(output, ensure_ascii=False)
|
||||
if len(output_str) > 2000:
|
||||
output = output_str[:2000] + "\n... [truncated]"
|
||||
else:
|
||||
output = output_str
|
||||
|
||||
# Build a compact result representation
|
||||
compact_result = {
|
||||
"ok": tool_result.get("ok"),
|
||||
"output": output,
|
||||
"error": tool_result.get("error"),
|
||||
"exit_code": tool_result.get("metadata", {}).get("exit_code"),
|
||||
}
|
||||
|
||||
return f"""{base_prompt}
|
||||
|
||||
Step: {step.description}
|
||||
@@ -544,7 +674,7 @@ Tool: {step.tool}
|
||||
Args: {step.args}
|
||||
|
||||
Result:
|
||||
{json.dumps(tool_result, indent=2)}
|
||||
{json.dumps(compact_result, indent=2, ensure_ascii=False)}
|
||||
|
||||
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": "..."}}"""
|
||||
@@ -618,9 +748,16 @@ Evaluate and respond with JSON:
|
||||
return {"status": "failed", "result": {"error": f"Unknown tool: {tool_name}. Available tools: {available_tools}"}}
|
||||
|
||||
permission_result = None
|
||||
|
||||
|
||||
# If permission_override is provided, skip permission check
|
||||
if permission_override is not None:
|
||||
permission_result = {
|
||||
"decision": permission_override.decision,
|
||||
"command": tool_args.get("command", ""),
|
||||
"cached": True,
|
||||
}
|
||||
# Check permission for shell_exec and file_write
|
||||
if tool_name == "shell_exec":
|
||||
elif tool_name == "shell_exec":
|
||||
permission_result = self._permission_service.check_shell_command(
|
||||
task_id=task.task_id,
|
||||
session_id=task.session_id,
|
||||
@@ -693,7 +830,13 @@ Evaluate and respond with JSON:
|
||||
|
||||
if tool_name == "shell_exec":
|
||||
command = str(tool_args.get("command", ""))
|
||||
if command.startswith("sudo ") and secret_override is None:
|
||||
|
||||
# Determine if sudo password is needed:
|
||||
# 1. Command explicitly starts with "sudo"
|
||||
# 2. Command is a known sudo-requiring command (apt, systemctl, etc.) — flagged by permission service
|
||||
needs_password = command.startswith("sudo ") or (permission_result is not None and permission_result.get("requires_sudo", False))
|
||||
|
||||
if needs_password and secret_override is None:
|
||||
secret_request = SecretRequest(
|
||||
task_id=task.task_id,
|
||||
session_id=task.session_id,
|
||||
@@ -709,8 +852,12 @@ Evaluate and respond with JSON:
|
||||
"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 '):]}"
|
||||
if needs_password and secret_override is not None:
|
||||
# Inject sudo -S for explicit sudo commands, or prepend sudo -S for implicit ones
|
||||
if command.startswith("sudo "):
|
||||
tool_args["command"] = f"sudo -S -p '' {command[len('sudo '):]}"
|
||||
else:
|
||||
tool_args["command"] = f"sudo -S -p '' {command}"
|
||||
tool_args["stdin_secret"] = f"{secret_override}\n"
|
||||
|
||||
tool_call = ToolCall(
|
||||
@@ -720,10 +867,43 @@ Evaluate and respond with JSON:
|
||||
step_id="step-1",
|
||||
)
|
||||
self._publish(task, TOOL_CALLED, tool_call.model_dump(mode="json"))
|
||||
if tool_name == "shell_exec":
|
||||
tool_args["__output_callback"] = lambda stream, chunk: self._publish(
|
||||
task,
|
||||
TOOL_OUTPUT_CHUNK,
|
||||
{
|
||||
"tool": tool_name,
|
||||
"step_id": "step-1",
|
||||
"stream": stream,
|
||||
"chunk": chunk,
|
||||
},
|
||||
)
|
||||
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
|
||||
metadata = tool_result.metadata or {}
|
||||
needs_sudo = metadata.get("needs_sudo", False)
|
||||
sudo_auth_failed = metadata.get("sudo_auth_failed", False) or self._looks_like_sudo_auth_failure(tool_result)
|
||||
|
||||
if tool_name == "shell_exec" and not tool_result.ok and sudo_auth_failed:
|
||||
original_command = str(directive.payload.get("args", {}).get("command", tool_args.get("command", "")))
|
||||
secret_request = SecretRequest(
|
||||
task_id=task.task_id,
|
||||
session_id=task.session_id,
|
||||
kind="sudo_password",
|
||||
prompt="Sudo password incorrect. Try again",
|
||||
command=original_command,
|
||||
)
|
||||
self._publish(task, SECRET_REQUESTED, secret_request.model_dump(mode="json"))
|
||||
return {
|
||||
"status": "awaiting_input",
|
||||
"result": {
|
||||
"error": "Sudo password failed",
|
||||
"secret_request": secret_request.model_dump(mode="json"),
|
||||
"attempt_failed": True,
|
||||
"tool_result": tool_result.model_dump(mode="json"),
|
||||
},
|
||||
}
|
||||
|
||||
if not tool_result.ok and needs_sudo:
|
||||
return {
|
||||
@@ -737,11 +917,51 @@ Evaluate and respond with JSON:
|
||||
},
|
||||
}
|
||||
|
||||
if tool_name == "shell_exec" and not tool_result.ok and self._command_analyzer:
|
||||
original_command = str(directive.payload.get("args", {}).get("command", tool_args.get("command", "")))
|
||||
diagnosis = self._command_analyzer.analyze(
|
||||
command=original_command,
|
||||
task_id=task.task_id,
|
||||
session_id=task.session_id,
|
||||
)
|
||||
if diagnosis.get("type") != "ok":
|
||||
return {
|
||||
"status": "awaiting_review",
|
||||
"result": {
|
||||
"error": "Tool action requires review before replanning",
|
||||
"review": {
|
||||
"step_id": "step-1",
|
||||
"tool": tool_name,
|
||||
"command": original_command,
|
||||
"diagnosis": diagnosis,
|
||||
"critic_assessment": {
|
||||
"classification": "model_planning_error",
|
||||
"needs_replan": True,
|
||||
"explanation": "Structured command analysis found a model action error before recovery.",
|
||||
},
|
||||
},
|
||||
"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 _looks_like_sudo_auth_failure(self, tool_result: ToolResult) -> bool:
|
||||
output = f"{tool_result.output or ''}\n{tool_result.error or ''}".lower()
|
||||
return any(
|
||||
marker in output
|
||||
for marker in (
|
||||
"incorrect password",
|
||||
"incorrect password attempt",
|
||||
"sudo: no password was provided",
|
||||
"sorry, try again",
|
||||
"authentication failure",
|
||||
)
|
||||
)
|
||||
|
||||
def _publish(self, task: UserTask, event_type: str, payload: dict[str, Any]) -> None:
|
||||
if not self._event_bus:
|
||||
return
|
||||
|
||||
@@ -16,3 +16,9 @@ class SecretResolutionRequest(BaseModel):
|
||||
class PasswordResolutionRequest(BaseModel):
|
||||
task_id: str
|
||||
password: str
|
||||
|
||||
|
||||
class ReviewResolutionRequest(BaseModel):
|
||||
task_id: str
|
||||
decision: str
|
||||
correction: str | None = None
|
||||
|
||||
@@ -76,8 +76,9 @@ class PermissionService:
|
||||
"decision": "allowed_always",
|
||||
"command": normalized,
|
||||
"cached": True,
|
||||
"requires_sudo": _requires_sudo(normalized),
|
||||
}
|
||||
|
||||
|
||||
if command_hash in cache.get("allowed_once", {}):
|
||||
cached = cache["allowed_once"][command_hash]
|
||||
if cached.get("task_id") == task_id:
|
||||
@@ -85,6 +86,7 @@ class PermissionService:
|
||||
"decision": "allowed_once",
|
||||
"command": normalized,
|
||||
"cached": True,
|
||||
"requires_sudo": _requires_sudo(normalized),
|
||||
}
|
||||
|
||||
# Check hard stop
|
||||
@@ -116,16 +118,21 @@ class PermissionService:
|
||||
# Check no_always category
|
||||
category = self._get_category(normalized)
|
||||
can_always = self._categories.get(category, {}).get("allow_always", True)
|
||||
|
||||
|
||||
# Check if command requires sudo (e.g. apt, systemctl without explicit sudo prefix)
|
||||
requires_sudo = _requires_sudo(normalized)
|
||||
|
||||
# Need user confirmation
|
||||
return {
|
||||
result = {
|
||||
"decision": "prompt",
|
||||
"command": normalized,
|
||||
"category": category,
|
||||
"allow_always": can_always,
|
||||
"requires_sudo": requires_sudo,
|
||||
"task_id": task_id,
|
||||
"session_id": session_id,
|
||||
}
|
||||
return result
|
||||
|
||||
def check_write_path(
|
||||
self,
|
||||
@@ -242,29 +249,51 @@ class PermissionService:
|
||||
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()
|
||||
|
||||
cmd_lower = command.lower().strip()
|
||||
cmd_tokens = cmd_lower.split()
|
||||
|
||||
for hs in hard_stop_commands:
|
||||
if hs.lower() in cmd_lower:
|
||||
hs_lower = hs.lower().strip()
|
||||
# For "rm -rf /" and "rm -rf /*", only match exact command
|
||||
# Don't match "rm -rf /tmp/nonexistent" as hard stop
|
||||
if hs_lower in ("rm -rf /", "rm -rf /*"):
|
||||
if cmd_lower == hs_lower:
|
||||
return True
|
||||
continue
|
||||
# For other patterns, use substring match
|
||||
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
|
||||
cmd_lower = command.lower().strip()
|
||||
cmd_first_word = cmd_lower.split()[0] if cmd_lower.split() else ""
|
||||
|
||||
# Check no_always category — match by first word or known multi-word prefixes
|
||||
no_always = self._categories.get("no_always", {}).get("commands", [])
|
||||
for cmd in no_always:
|
||||
if cmd in cmd_lower:
|
||||
for pattern in no_always:
|
||||
pat_lower = pattern.lower().strip()
|
||||
# Match if first word matches (e.g. "apt" matches "apt list --upgradable")
|
||||
# or if command starts with the pattern (e.g. "systemctl stop" matches "systemctl stop nginx")
|
||||
if cmd_first_word == pat_lower or cmd_lower.startswith(pat_lower + " "):
|
||||
return "no_always"
|
||||
|
||||
|
||||
# Check hard_stop by first word
|
||||
hard_stop = self._categories.get("hard_stop", {}).get("commands", [])
|
||||
for pattern in hard_stop:
|
||||
pat_lower = pattern.lower().strip()
|
||||
if cmd_first_word == pat_lower or cmd_lower.startswith(pat_lower + " "):
|
||||
return "hard_stop"
|
||||
|
||||
# Default to normal
|
||||
return "normal"
|
||||
|
||||
|
||||
SUDO_COMMANDS = {
|
||||
"sudo",
|
||||
"apt", "apt-get", "dpkg", "yum", "dnf", "pacman", "zypper",
|
||||
"systemctl", "service", "mount", "umount",
|
||||
"shutdown", "reboot", "halt", "poweroff",
|
||||
|
||||
@@ -2,12 +2,15 @@ TASK_RECEIVED = "task_received"
|
||||
CONTEXT_BUILT = "context_built"
|
||||
STEP_STARTED = "step_started"
|
||||
TOOL_CALLED = "tool_called"
|
||||
TOOL_OUTPUT_CHUNK = "tool_output_chunk"
|
||||
TOOL_COMPLETED = "tool_completed"
|
||||
PERMISSION_REQUESTED = "permission_requested"
|
||||
PERMISSION_RESOLVED = "permission_resolved"
|
||||
TASK_AWAITING_PERMISSION = "task_awaiting_permission"
|
||||
SECRET_REQUESTED = "secret_requested"
|
||||
TASK_AWAITING_INPUT = "task_awaiting_input"
|
||||
TASK_AWAITING_REVIEW = "task_awaiting_review"
|
||||
REVIEW_RESOLVED = "review_resolved"
|
||||
CHECKPOINT_SAVED = "checkpoint_saved"
|
||||
TASK_COMPLETED = "task_completed"
|
||||
TASK_FAILED = "task_failed"
|
||||
@@ -29,3 +32,4 @@ THINKER_CALLED = "thinker_called"
|
||||
THINKER_RESULT = "thinker_result"
|
||||
JSON_COMPILER_CALLED = "json_compiler_called"
|
||||
JSON_COMPILER_RESULT = "json_compiler_result"
|
||||
MEMORY_RECALL_USED = "memory_recall_used"
|
||||
|
||||
+12
-2
@@ -101,14 +101,24 @@ class MemoryInterface:
|
||||
def count(self) -> int:
|
||||
return self._store.count()
|
||||
|
||||
def reindex(self) -> None:
|
||||
def reindex(self) -> int:
|
||||
"""Rebuild vector index from all entries in memory store.
|
||||
Returns number of indexed entries."""
|
||||
entries = self._store.get_all(limit=10000)
|
||||
self._vector_index.save()
|
||||
# Delete old index file and re-initialize from scratch
|
||||
import os
|
||||
if self._vector_index._index_path and self._vector_index._index_path.exists():
|
||||
self._vector_index._index_path.unlink()
|
||||
self._vector_index._index = None
|
||||
self._vector_index._init_index()
|
||||
count = 0
|
||||
for entry in entries:
|
||||
text = entry.text
|
||||
embedding = self._embeddings.encode(text)
|
||||
self._vector_index.insert(entry.id, embedding)
|
||||
count += 1
|
||||
self._vector_index.save()
|
||||
return count
|
||||
|
||||
def close(self) -> None:
|
||||
self._store.close()
|
||||
|
||||
@@ -0,0 +1,205 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
from app.core.contracts import MemoryEntry
|
||||
from app.memory.interface import MemoryInterface
|
||||
from app.models.async_adapters import AsyncOrchestratorAdapter
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
RECALL_PROMPT_TEMPLATE = """Определи, нужно ли искать в долговременной памяти для ответа на этот запрос.
|
||||
|
||||
Запрос: "{task_input}"
|
||||
|
||||
ИСКАТЬ в памяти если запрос:
|
||||
- Содержит вопрос о пользователе (имя, предпочтения, история)
|
||||
- Содержит отсылки к прошлым разговорам или действиям
|
||||
- Содержит местоимения без контекста ("он", "это", "тот файл")
|
||||
- Просит вспомнить, повторить, рассказать о прошлом
|
||||
- Спрашивает "что ты помнишь", "как меня зовут", "что я говорил"
|
||||
|
||||
НЕ ИСКАТЬ если:
|
||||
- Приветствие или прощание
|
||||
- Простая команда (ls, pwd, echo)
|
||||
- Общий вопрос не связанный с прошлым
|
||||
|
||||
Ответь ТОЛЬКО JSON:
|
||||
{{"should_recall": true, "search_query": "поисковый запрос"}}
|
||||
или
|
||||
{{"should_recall": false, "reason": "краткая причина"}}"""
|
||||
|
||||
|
||||
class MemoryRecallService:
|
||||
"""Активное воспоминание: система сама решает, что и когда искать в памяти."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
memory_interface: MemoryInterface | None,
|
||||
recall_model: AsyncOrchestratorAdapter | None,
|
||||
) -> None:
|
||||
self._memory = memory_interface
|
||||
self._model = recall_model
|
||||
|
||||
async def recall(
|
||||
self,
|
||||
task_input: str,
|
||||
top_k: int = 5,
|
||||
) -> dict[str, Any]:
|
||||
"""
|
||||
Определяет необходимость воспоминания и выполняет поиск.
|
||||
|
||||
Возвращает:
|
||||
{
|
||||
"should_recall": bool,
|
||||
"reason": str,
|
||||
"query": str,
|
||||
"results": list[MemoryEntry],
|
||||
"summary": str, # краткая сводка для оркестратора
|
||||
}
|
||||
"""
|
||||
if not self._memory or not self._model:
|
||||
with open("/tmp/recall_debug.log", "a") as f:
|
||||
f.write(f"SKIP: memory={self._memory is not None}, model={self._model is not None}\n")
|
||||
return self._empty_result("memory_or_model_unavailable")
|
||||
|
||||
# 1. LLM решает, нужно ли искать
|
||||
decision = await self._classify(task_input)
|
||||
with open("/tmp/recall_debug.log", "a") as f:
|
||||
f.write(f"DECISION type={type(decision)} value={decision}\n")
|
||||
if not isinstance(decision, dict):
|
||||
return self._empty_result("invalid_decision_type")
|
||||
if not decision.get("should_recall"):
|
||||
return self._empty_result(decision.get("reason", "not_needed"))
|
||||
|
||||
search_query = decision.get("search_query", task_input)
|
||||
logger.info(f"Memory recall: query='{search_query}', reason='{decision.get('reason')}'")
|
||||
|
||||
# 2. Векторный поиск
|
||||
try:
|
||||
raw_results = self._memory.search(query=search_query, top_k=top_k)
|
||||
except Exception as e:
|
||||
logger.warning(f"Memory search failed: {e}")
|
||||
return self._empty_result("search_failed")
|
||||
|
||||
# 3. Фильтрация: убираем пустые и слишком нерелевантные
|
||||
filtered = self._filter(raw_results)
|
||||
|
||||
if not filtered:
|
||||
return self._empty_result("no_relevant_results")
|
||||
|
||||
# 4. Сводка для оркестратора
|
||||
summary = self._summarize(filtered, search_query)
|
||||
|
||||
return {
|
||||
"should_recall": True,
|
||||
"reason": decision.get("reason", ""),
|
||||
"query": search_query,
|
||||
"results": filtered,
|
||||
"summary": summary,
|
||||
}
|
||||
|
||||
async def _classify(self, task_input: str) -> dict[str, Any]:
|
||||
"""LLM-классификация: нужно ли искать в памяти."""
|
||||
prompt = RECALL_PROMPT_TEMPLATE.format(task_input=task_input)
|
||||
|
||||
try:
|
||||
raw = await self._model.generate(prompt, max_tokens=512)
|
||||
data = self._parse_json(raw)
|
||||
if "should_recall" in data:
|
||||
return data
|
||||
logger.warning(f"Recall classification missing 'should_recall': {raw[:200]}")
|
||||
return {"should_recall": False, "reason": "parse_error"}
|
||||
except Exception as e:
|
||||
logger.warning(f"Recall classification failed: {e}")
|
||||
return {"should_recall": False, "reason": "classification_error"}
|
||||
|
||||
def _filter(
|
||||
self,
|
||||
results: list[tuple[MemoryEntry, float]],
|
||||
min_score: float = 0.3,
|
||||
) -> list[MemoryEntry]:
|
||||
"""Фильтрует результаты по score и убирает дубликаты."""
|
||||
seen_texts: set[str] = set()
|
||||
filtered: list[MemoryEntry] = []
|
||||
|
||||
for entry, score in results:
|
||||
if score < min_score:
|
||||
continue
|
||||
# Нормализуем текст для дедупликации
|
||||
normalized = entry.text.strip().lower()[:100]
|
||||
if normalized in seen_texts:
|
||||
continue
|
||||
seen_texts.add(normalized)
|
||||
filtered.append(entry)
|
||||
|
||||
return filtered
|
||||
|
||||
def _summarize(
|
||||
self,
|
||||
results: list[MemoryEntry],
|
||||
query: str,
|
||||
) -> str:
|
||||
"""Краткая сводка найденного для оркестратора."""
|
||||
parts = [f"По запросу '{query}' найдено {len(results)} записей:"]
|
||||
for i, entry in enumerate(results[:5], 1):
|
||||
text_preview = entry.text[:120].replace("\n", " ")
|
||||
parts.append(f" {i}. [{entry.kind}] {text_preview}")
|
||||
return "\n".join(parts)
|
||||
|
||||
def _parse_json(self, raw: str) -> dict[str, Any]:
|
||||
"""Извлекает JSON из ответа модели, пропуская рассуждения перед ним."""
|
||||
try:
|
||||
json_start = raw.find("{")
|
||||
json_end = raw.rfind("}") + 1
|
||||
|
||||
if json_start < 0 or json_end <= 0:
|
||||
return {}
|
||||
|
||||
# Пробуем весь текст от первого { до последнего }
|
||||
try:
|
||||
data = json.loads(raw[json_start:json_end])
|
||||
if isinstance(data, dict):
|
||||
return data
|
||||
except json.JSONDecodeError:
|
||||
pass
|
||||
|
||||
# Ищем все возможные начала JSON
|
||||
candidates = []
|
||||
pos = 0
|
||||
while True:
|
||||
pos = raw.find("{", pos)
|
||||
if pos < 0:
|
||||
break
|
||||
candidates.append(pos)
|
||||
pos += 1
|
||||
|
||||
# Пробуем каждый candidate с конца
|
||||
for start in reversed(candidates):
|
||||
end = raw.rfind("}") + 1
|
||||
if end <= start:
|
||||
continue
|
||||
try:
|
||||
data = json.loads(raw[start:end])
|
||||
if isinstance(data, dict):
|
||||
return data
|
||||
except json.JSONDecodeError:
|
||||
continue
|
||||
|
||||
return {}
|
||||
except Exception as e:
|
||||
with open("/tmp/recall_debug.log", "a") as f:
|
||||
f.write(f"PARSE ERROR: {e}\n")
|
||||
return {}
|
||||
|
||||
@staticmethod
|
||||
def _empty_result(reason: str) -> dict[str, Any]:
|
||||
return {
|
||||
"should_recall": False,
|
||||
"reason": reason,
|
||||
"query": "",
|
||||
"results": [],
|
||||
"summary": "",
|
||||
}
|
||||
@@ -1,11 +1,13 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from concurrent.futures import Future, ThreadPoolExecutor
|
||||
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.command_analyzer import CommandAnalyzer
|
||||
from app.core.contracts import UserTask
|
||||
from app.core.execution_engine import ExecutionEngine
|
||||
from app.core.execution_scheduler import ExecutionScheduler
|
||||
@@ -13,6 +15,7 @@ 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.recall import MemoryRecallService
|
||||
from app.memory.write_policy import MemoryWritePolicy
|
||||
from app.models import (
|
||||
CoderAdapter,
|
||||
@@ -64,6 +67,8 @@ class RuntimeController:
|
||||
self._model_cache: dict[tuple[object, ...], tuple[object, RLock]] = {}
|
||||
self._memory_interface: MemoryInterface | None = None
|
||||
self._memory_policy: MemoryWritePolicy | None = None
|
||||
self._background_executor = ThreadPoolExecutor(max_workers=4, thread_name_prefix="ducklm-task")
|
||||
self._background_tasks: dict[str, Future[dict[str, object]]] = {}
|
||||
self.tool_registry = None
|
||||
self.tool_sandbox = None
|
||||
|
||||
@@ -75,6 +80,8 @@ class RuntimeController:
|
||||
self.tool_sandbox = ToolSandbox(
|
||||
allowed_root=self.base_dir,
|
||||
timeout_ms=runtime_config.step_timeout_ms,
|
||||
command_timeout_ms=runtime_config.shell_command_timeout_ms,
|
||||
idle_timeout_ms=runtime_config.shell_idle_timeout_ms,
|
||||
)
|
||||
|
||||
self.tool_registry = self._create_tool_registry()
|
||||
@@ -121,6 +128,7 @@ class RuntimeController:
|
||||
self.permission_service = PermissionService(
|
||||
config=self._load_permissions_config(),
|
||||
)
|
||||
self.command_analyzer = CommandAnalyzer(self.permission_service)
|
||||
|
||||
self.execution_engine = ExecutionEngine(
|
||||
event_bus=self.event_bus,
|
||||
@@ -134,6 +142,8 @@ class RuntimeController:
|
||||
memory_interface=self._memory_interface,
|
||||
prompts=self._prompts,
|
||||
recovery_limit=runtime_config.tool_retry_limit,
|
||||
critic_retry_limit=runtime_config.critic_retry_limit,
|
||||
command_analyzer=self.command_analyzer,
|
||||
)
|
||||
|
||||
self.runtime_loop = RuntimeLoop(
|
||||
@@ -194,35 +204,35 @@ class RuntimeController:
|
||||
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(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(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(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(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(f"Sys_util loaded: {self._sys_util} (model: {sys_util_config.get('path')})")
|
||||
|
||||
print("All models loaded successfully")
|
||||
|
||||
@@ -241,6 +251,28 @@ class RuntimeController:
|
||||
if async_coder:
|
||||
self.execution_engine.set_coder(async_coder)
|
||||
|
||||
# Create MemoryRecallService using the configured model (default: sys_util)
|
||||
# Reuses already-loaded async adapter — no duplicate model loading
|
||||
recall_model_name = self.config.runtime.recall_model
|
||||
recall_async_model = {
|
||||
"sys_util": async_sys_util,
|
||||
"thinker": async_thinker,
|
||||
"json_compiler": async_compiler,
|
||||
"critic": async_critic,
|
||||
"coder": async_coder,
|
||||
}.get(recall_model_name, async_sys_util)
|
||||
|
||||
self._recall_service = MemoryRecallService(
|
||||
memory_interface=self._memory_interface,
|
||||
recall_model=recall_async_model,
|
||||
)
|
||||
self.runtime_loop.set_recall_service(self._recall_service)
|
||||
print(f"MemoryRecallService initialized with model: {recall_model_name}")
|
||||
|
||||
# Set memory policy in runtime loop
|
||||
self.runtime_loop.set_memory_policy(self._memory_policy)
|
||||
print(f"MemoryWritePolicy set: {self._memory_policy is not None}")
|
||||
|
||||
except Exception as e:
|
||||
print(f"Failed to load models at startup: {e}")
|
||||
raise RuntimeError(f"Model loading failed: {e}") from e
|
||||
@@ -375,21 +407,76 @@ class RuntimeController:
|
||||
def handle_task(self, task: UserTask) -> dict[str, object]:
|
||||
return self.runtime_loop.run_task(task)
|
||||
|
||||
def submit_task(self, task: UserTask) -> dict[str, object]:
|
||||
self._background_tasks[task.task_id] = self._background_executor.submit(
|
||||
self.handle_task,
|
||||
task,
|
||||
)
|
||||
return {"task_id": task.task_id, "status": "accepted"}
|
||||
|
||||
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 submit_permission_resolution(self, task_id: str, decision: str) -> dict[str, object]:
|
||||
if not self.task_state_store.get_task(task_id):
|
||||
return {"task_id": task_id, "status": "failed", "result": {"error": "Unknown task_id"}}
|
||||
self._background_tasks[task_id] = self._background_executor.submit(
|
||||
self.resolve_permission,
|
||||
task_id,
|
||||
decision,
|
||||
)
|
||||
return {"task_id": task_id, "status": "accepted"}
|
||||
|
||||
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 submit_secret_resolution(self, task_id: str, secret: str) -> dict[str, object]:
|
||||
if not self.task_state_store.get_task(task_id):
|
||||
return {"task_id": task_id, "status": "failed", "result": {"error": "Unknown task_id"}}
|
||||
self._background_tasks[task_id] = self._background_executor.submit(
|
||||
self.resolve_secret,
|
||||
task_id,
|
||||
secret,
|
||||
)
|
||||
return {"task_id": task_id, "status": "accepted"}
|
||||
|
||||
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 resolve_review(self, task_id: str, decision: str, correction: str | None = None) -> dict[str, object]:
|
||||
return self.runtime_loop.resolve_review(
|
||||
task_id=task_id,
|
||||
decision=decision,
|
||||
correction=correction,
|
||||
)
|
||||
|
||||
def submit_review_resolution(self, task_id: str, decision: str, correction: str | None = None) -> dict[str, object]:
|
||||
if not self.task_state_store.get_task(task_id):
|
||||
return {"task_id": task_id, "status": "failed", "result": {"error": "Unknown task_id"}}
|
||||
self._background_tasks[task_id] = self._background_executor.submit(
|
||||
self.resolve_review,
|
||||
task_id,
|
||||
decision,
|
||||
correction,
|
||||
)
|
||||
return {"task_id": task_id, "status": "accepted"}
|
||||
|
||||
def submit_password_resolution(self, task_id: str, password: str) -> dict[str, object]:
|
||||
if not self.task_state_store.get_task(task_id):
|
||||
return {"task_id": task_id, "status": "failed", "result": {"error": "Unknown task_id"}}
|
||||
self._background_tasks[task_id] = self._background_executor.submit(
|
||||
self.resolve_password,
|
||||
task_id,
|
||||
password,
|
||||
)
|
||||
return {"task_id": task_id, "status": "accepted"}
|
||||
|
||||
def handle_critic_feedback(
|
||||
self,
|
||||
feedback: str,
|
||||
|
||||
+223
-39
@@ -3,16 +3,46 @@ 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.contracts import CriticScore, 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.events.event_types import CHECKPOINT_SAVED, CONTEXT_BUILT, MEMORY_RECALL_USED, MEMORY_WRITE_DECIDED, REVIEW_RESOLVED, TASK_AWAITING_INPUT, TASK_AWAITING_PERMISSION, TASK_AWAITING_REVIEW, TASK_COMPLETED, TASK_FAILED, TASK_RECEIVED
|
||||
from app.core.permission_service import PermissionService
|
||||
from app.memory.recall import MemoryRecallService
|
||||
from app.memory.write_policy import MemoryWritePolicy
|
||||
from app.state.checkpoint_store import SQLiteCheckpointStore
|
||||
from app.state.task_state_store import SQLiteTaskStateStore
|
||||
|
||||
|
||||
def _build_response_directive(execution_result: dict) -> dict | None:
|
||||
"""Build a response_directive from step_results or direct output for the client."""
|
||||
result = execution_result.get("result", {})
|
||||
|
||||
# Case 1: step_results from plan execution
|
||||
step_results = result.get("step_results")
|
||||
if 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(str(tool_result["output"]))
|
||||
if response_parts:
|
||||
response_text = "\n\n".join(response_parts)
|
||||
return ExecutionDirective(
|
||||
type="respond", payload={"text": response_text}
|
||||
).model_dump(mode="json")
|
||||
|
||||
# Case 2: direct tool output (e.g. from resolve_secret -> execute_tool)
|
||||
if result.get("ok") and result.get("output"):
|
||||
return ExecutionDirective(
|
||||
type="respond", payload={"text": str(result["output"])}
|
||||
).model_dump(mode="json")
|
||||
|
||||
return None
|
||||
|
||||
|
||||
class RuntimeLoop:
|
||||
"""Central control loop skeleton coordinating task state and events."""
|
||||
|
||||
@@ -26,6 +56,8 @@ class RuntimeLoop:
|
||||
execution_engine: ExecutionEngine,
|
||||
permission_service: PermissionService,
|
||||
memory_interface=None,
|
||||
recall_service: MemoryRecallService | None = None,
|
||||
memory_policy: MemoryWritePolicy | None = None,
|
||||
) -> None:
|
||||
self._event_bus = event_bus
|
||||
self._task_state_store = task_state_store
|
||||
@@ -35,6 +67,14 @@ class RuntimeLoop:
|
||||
self._execution_engine = execution_engine
|
||||
self._permission_service = permission_service
|
||||
self._memory_interface = memory_interface
|
||||
self._recall_service = recall_service
|
||||
self._memory_policy = memory_policy
|
||||
|
||||
def set_recall_service(self, recall_service: MemoryRecallService) -> None:
|
||||
self._recall_service = recall_service
|
||||
|
||||
def set_memory_policy(self, policy: MemoryWritePolicy | None) -> None:
|
||||
self._memory_policy = policy
|
||||
|
||||
def run_task(self, task: UserTask) -> dict[str, object]:
|
||||
# Check input for hard-stop commands BEFORE processing
|
||||
@@ -82,6 +122,23 @@ class RuntimeLoop:
|
||||
context = self._context_builder.build(task=task, checkpoint=checkpoint)
|
||||
self._publish(task, CONTEXT_BUILT, {"keys": sorted(context.keys())})
|
||||
|
||||
# Active memory recall: system decides if it needs to search memory
|
||||
recall_result = asyncio.run(self._run_recall(task))
|
||||
if recall_result["should_recall"]:
|
||||
context["memory_recall"] = {
|
||||
"query": recall_result["query"],
|
||||
"summary": recall_result["summary"],
|
||||
"entries": [
|
||||
{"text": e.text, "kind": e.kind, "weight": e.weight}
|
||||
for e in recall_result["results"]
|
||||
],
|
||||
}
|
||||
self._publish(task, MEMORY_RECALL_USED, {
|
||||
"query": recall_result["query"],
|
||||
"results_count": len(recall_result["results"]),
|
||||
"reason": recall_result["reason"],
|
||||
})
|
||||
|
||||
directive = asyncio.run(
|
||||
self._router.decide(state=state, context=context, task_id=task.task_id, session_id=task.session_id)
|
||||
)
|
||||
@@ -104,15 +161,21 @@ class RuntimeLoop:
|
||||
"reason": "Permission denied - требуется sudo пароль",
|
||||
"attempts": 0,
|
||||
}
|
||||
elif execution_result["status"] == "awaiting_review":
|
||||
state_patch["pending_permission_request"] = None
|
||||
state_patch["pending_secret_request"] = None
|
||||
state_patch["resolved_permission_decision"] = None
|
||||
state_patch["pending_review"] = execution_result["result"]["review"]
|
||||
else:
|
||||
state_patch["pending_permission_request"] = None
|
||||
state_patch["pending_secret_request"] = None
|
||||
state_patch["resolved_permission_decision"] = None
|
||||
state_patch["pending_review"] = 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"):
|
||||
if final_status in ("awaiting_permission", "awaiting_input", "awaiting_password", "awaiting_review"):
|
||||
# Task stays in pending state, don't update to completed
|
||||
pass
|
||||
else:
|
||||
@@ -125,9 +188,9 @@ class RuntimeLoop:
|
||||
)
|
||||
self._checkpoint_store.save(final_checkpoint)
|
||||
|
||||
# Generate response after plan execution
|
||||
# Generate response for user
|
||||
# Case 1: step_results from 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:
|
||||
@@ -135,16 +198,21 @@ class RuntimeLoop:
|
||||
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")
|
||||
execution_result["response_directive"] = ExecutionDirective(
|
||||
type="respond", payload={"text": response_text}
|
||||
).model_dump(mode="json")
|
||||
|
||||
# Case 2: respond directive from orchestrator (direct response, no steps)
|
||||
if final_status == "completed" and not execution_result.get("response_directive"):
|
||||
# Use the original directive from router.decide()
|
||||
if hasattr(directive, "type") and directive.type == "respond":
|
||||
if directive.payload.get("text"):
|
||||
execution_result["response_directive"] = directive.model_dump(mode="json")
|
||||
elif isinstance(directive, dict) and directive.get("type") == "respond":
|
||||
if directive.get("payload", {}).get("text"):
|
||||
execution_result["response_directive"] = directive
|
||||
|
||||
# Map status to terminal event type
|
||||
if final_status == "completed":
|
||||
@@ -155,6 +223,8 @@ class RuntimeLoop:
|
||||
terminal_event_type = TASK_AWAITING_PERMISSION
|
||||
elif final_status == "awaiting_input":
|
||||
terminal_event_type = TASK_AWAITING_INPUT
|
||||
elif final_status == "awaiting_review":
|
||||
terminal_event_type = TASK_AWAITING_REVIEW
|
||||
elif final_status == "awaiting_password":
|
||||
terminal_event_type = TASK_AWAITING_PERMISSION
|
||||
else:
|
||||
@@ -175,7 +245,10 @@ class RuntimeLoop:
|
||||
"task_id": task.task_id,
|
||||
"status": final_status,
|
||||
"directive": directive.model_dump(mode="json"),
|
||||
"result": execution_result["result"],
|
||||
"result": {
|
||||
**execution_result["result"],
|
||||
"response_directive": execution_result.get("response_directive"),
|
||||
},
|
||||
"events": [event.model_dump(mode="json") for event in self._event_bus.list_for_task(task.task_id)],
|
||||
}
|
||||
|
||||
@@ -254,6 +327,9 @@ class RuntimeLoop:
|
||||
"pending_secret_request": execution_result["result"].get("secret_request")
|
||||
if final_status == "awaiting_input"
|
||||
else None,
|
||||
"pending_review": execution_result["result"].get("review")
|
||||
if final_status == "awaiting_review"
|
||||
else None,
|
||||
"resolved_permission_decision": resolved,
|
||||
},
|
||||
)
|
||||
@@ -266,6 +342,8 @@ class RuntimeLoop:
|
||||
terminal_event_type = TASK_AWAITING_INPUT
|
||||
elif final_status == "awaiting_permission":
|
||||
terminal_event_type = TASK_AWAITING_PERMISSION
|
||||
elif final_status == "awaiting_review":
|
||||
terminal_event_type = TASK_AWAITING_REVIEW
|
||||
else:
|
||||
terminal_event_type = TASK_FAILED
|
||||
self._publish(
|
||||
@@ -283,7 +361,10 @@ class RuntimeLoop:
|
||||
return {
|
||||
"task_id": task.task_id,
|
||||
"status": final_status,
|
||||
"result": execution_result["result"],
|
||||
"result": {
|
||||
**execution_result["result"],
|
||||
"response_directive": _build_response_directive(execution_result),
|
||||
},
|
||||
"events": [event.model_dump(mode="json") for event in self._event_bus.list_for_task(task.task_id)],
|
||||
}
|
||||
|
||||
@@ -314,12 +395,15 @@ class RuntimeLoop:
|
||||
secret_override=secret,
|
||||
)
|
||||
final_status = str(execution_result["status"])
|
||||
pending_review = execution_result["result"].get("review") if final_status == "awaiting_review" else None
|
||||
pending_secret = execution_result["result"].get("secret_request") if final_status == "awaiting_input" else None
|
||||
self._task_state_store.update_task(
|
||||
task.task_id,
|
||||
{
|
||||
"status": final_status,
|
||||
"pending_secret_request": None,
|
||||
"resolved_permission_decision": None,
|
||||
"pending_secret_request": pending_secret,
|
||||
"resolved_permission_decision": resolved_permission_payload if final_status == "awaiting_input" else None,
|
||||
"pending_review": pending_review,
|
||||
},
|
||||
)
|
||||
checkpoint = TaskCheckpoint(task_id=task.task_id, status=final_status)
|
||||
@@ -331,6 +415,8 @@ class RuntimeLoop:
|
||||
terminal_event_type = TASK_AWAITING_INPUT
|
||||
elif final_status == "awaiting_permission":
|
||||
terminal_event_type = TASK_AWAITING_PERMISSION
|
||||
elif final_status == "awaiting_review":
|
||||
terminal_event_type = TASK_AWAITING_REVIEW
|
||||
else:
|
||||
terminal_event_type = TASK_FAILED
|
||||
self._publish(
|
||||
@@ -344,10 +430,55 @@ class RuntimeLoop:
|
||||
return {
|
||||
"task_id": task.task_id,
|
||||
"status": final_status,
|
||||
"result": execution_result["result"],
|
||||
"result": {
|
||||
**execution_result["result"],
|
||||
"response_directive": _build_response_directive(execution_result),
|
||||
},
|
||||
"events": [event.model_dump(mode="json") for event in self._event_bus.list_for_task(task.task_id)],
|
||||
}
|
||||
|
||||
def resolve_review(self, task_id: str, decision: str, correction: str | None = None) -> 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_review = state.get("pending_review")
|
||||
if not pending_review:
|
||||
return {"task_id": task_id, "status": "failed", "result": {"error": "No pending review"}}
|
||||
|
||||
task = UserTask(
|
||||
task_id=task_id,
|
||||
session_id=state["session_id"],
|
||||
input=state["task_input"],
|
||||
context={
|
||||
**state.get("task_context", {}),
|
||||
"previous_action_review": {
|
||||
"decision": decision,
|
||||
"correction": correction,
|
||||
"review": pending_review,
|
||||
},
|
||||
},
|
||||
)
|
||||
self._publish(task, REVIEW_RESOLVED, {
|
||||
"decision": decision,
|
||||
"correction": correction,
|
||||
"review": pending_review,
|
||||
})
|
||||
if self._memory_interface:
|
||||
try:
|
||||
self._memory_interface.insert(
|
||||
text=f"User reviewed model action as {decision}. Correction: {correction or ''}. Review: {pending_review}",
|
||||
kind="critique",
|
||||
source="user",
|
||||
task_id=task_id,
|
||||
session_id=state["session_id"],
|
||||
weight=0.9 if decision == "wrong_action" else 0.5,
|
||||
metadata={"decision": decision, "review": pending_review},
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
self._task_state_store.update_task(task_id, {"pending_review": None, "status": "replanning"})
|
||||
return self.run_task(task)
|
||||
|
||||
def resolve_password(self, task_id: str, password: str) -> dict[str, object]:
|
||||
state = self._task_state_store.get_task(task_id)
|
||||
if not state:
|
||||
@@ -445,7 +576,10 @@ class RuntimeLoop:
|
||||
return {
|
||||
"task_id": task.task_id,
|
||||
"status": final_status,
|
||||
"result": execution_result["result"],
|
||||
"result": {
|
||||
**execution_result["result"],
|
||||
"response_directive": _build_response_directive(execution_result),
|
||||
},
|
||||
"events": [event.model_dump(mode="json") for event in self._event_bus.list_for_task(task.task_id)],
|
||||
}
|
||||
|
||||
@@ -459,22 +593,61 @@ class RuntimeLoop:
|
||||
)
|
||||
self._event_bus.publish(event)
|
||||
|
||||
async def _run_recall(self, task: UserTask) -> dict:
|
||||
"""Run active memory recall before orchestration."""
|
||||
if not self._recall_service:
|
||||
return {"should_recall": False, "reason": "no_recall_service", "query": "", "results": [], "summary": ""}
|
||||
try:
|
||||
return await self._recall_service.recall(task_input=task.input)
|
||||
except Exception as e:
|
||||
return {"should_recall": False, "reason": f"recall_error: {e}", "query": "", "results": [], "summary": ""}
|
||||
|
||||
def _save_to_memory(self, task: UserTask, execution_result: dict, status: str) -> None:
|
||||
"""Save task input and result to memory for session context."""
|
||||
"""Save task input and result to memory for session context, using MemoryWritePolicy."""
|
||||
if not self._memory_interface:
|
||||
return
|
||||
|
||||
try:
|
||||
# Build a synthetic critic_score for policy based on task status
|
||||
# For summary/tool_result without real critic, we derive from execution outcome
|
||||
if status == "completed":
|
||||
synthetic_score = CriticScore(
|
||||
correctness=0.9, usefulness=0.8, safety=0.95,
|
||||
memory_store=True, weight=0.85, explanation="Task completed successfully"
|
||||
)
|
||||
elif status == "failed":
|
||||
synthetic_score = CriticScore(
|
||||
correctness=0.2, usefulness=0.3, safety=0.7,
|
||||
memory_store=True, weight=0.5, explanation="Task failed — store for learning"
|
||||
)
|
||||
else:
|
||||
synthetic_score = CriticScore(
|
||||
correctness=0.5, usefulness=0.5, safety=0.8,
|
||||
memory_store=False, weight=0.3, explanation=f"Status: {status}"
|
||||
)
|
||||
|
||||
# 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},
|
||||
)
|
||||
decision = "store"
|
||||
if self._memory_policy:
|
||||
decision = self._memory_policy.decide(
|
||||
critic_score=synthetic_score,
|
||||
memory_type="summary",
|
||||
session_id=task.session_id,
|
||||
)
|
||||
if decision in ("store", "store_with_weight"):
|
||||
weight = synthetic_score.weight if decision == "store_with_weight" else 0.8
|
||||
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=weight,
|
||||
metadata={"status": status, "policy_decision": decision},
|
||||
)
|
||||
self._publish(task, MEMORY_WRITE_DECIDED, {
|
||||
"kind": "summary", "decision": decision, "text_preview": task.input[:80]
|
||||
})
|
||||
|
||||
# Save execution result
|
||||
result_text = ""
|
||||
@@ -489,16 +662,27 @@ class RuntimeLoop:
|
||||
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},
|
||||
)
|
||||
decision = "store"
|
||||
if self._memory_policy:
|
||||
decision = self._memory_policy.decide(
|
||||
critic_score=synthetic_score,
|
||||
memory_type="tool_result",
|
||||
session_id=task.session_id,
|
||||
)
|
||||
if decision in ("store", "store_with_weight"):
|
||||
weight = synthetic_score.weight if decision == "store_with_weight" else 0.7
|
||||
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=weight,
|
||||
metadata={"status": status, "policy_decision": decision},
|
||||
)
|
||||
self._publish(task, MEMORY_WRITE_DECIDED, {
|
||||
"kind": "tool_result", "decision": decision, "text_preview": result_text[:80]
|
||||
})
|
||||
except Exception as e:
|
||||
# Log but don't fail the task
|
||||
import logging
|
||||
logging.getLogger(__name__).warning(f"Failed to save to memory: {e}")
|
||||
|
||||
@@ -2,6 +2,7 @@ from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from collections import defaultdict
|
||||
from dataclasses import dataclass
|
||||
|
||||
from app.core.contracts import RuntimeEvent
|
||||
from app.events.event_bus import EventBus
|
||||
@@ -12,7 +13,7 @@ class StreamingManager:
|
||||
|
||||
def __init__(self, event_bus: EventBus) -> None:
|
||||
self._event_bus = event_bus
|
||||
self._subscribers: dict[str, list[asyncio.Queue[RuntimeEvent]]] = defaultdict(list)
|
||||
self._subscribers: dict[str, list[StreamSubscriber]] = defaultdict(list)
|
||||
self._event_bus.subscribe(self._on_event)
|
||||
|
||||
def replay_events(self, task_id: str) -> list[RuntimeEvent]:
|
||||
@@ -20,17 +21,26 @@ class StreamingManager:
|
||||
|
||||
def subscribe(self, task_id: str) -> asyncio.Queue[RuntimeEvent]:
|
||||
queue: asyncio.Queue[RuntimeEvent] = asyncio.Queue()
|
||||
self._subscribers[task_id].append(queue)
|
||||
self._subscribers[task_id].append(
|
||||
StreamSubscriber(loop=asyncio.get_running_loop(), queue=queue)
|
||||
)
|
||||
return queue
|
||||
|
||||
def unsubscribe(self, task_id: str, queue: asyncio.Queue[RuntimeEvent]) -> None:
|
||||
listeners = self._subscribers.get(task_id, [])
|
||||
if queue in listeners:
|
||||
listeners.remove(queue)
|
||||
for listener in list(listeners):
|
||||
if listener.queue is queue:
|
||||
listeners.remove(listener)
|
||||
break
|
||||
if not listeners and task_id in self._subscribers:
|
||||
del self._subscribers[task_id]
|
||||
|
||||
def _on_event(self, event: RuntimeEvent) -> None:
|
||||
for queue in self._subscribers.get(event.task_id, []):
|
||||
queue.put_nowait(event)
|
||||
for listener in list(self._subscribers.get(event.task_id, [])):
|
||||
listener.loop.call_soon_threadsafe(listener.queue.put_nowait, event)
|
||||
|
||||
|
||||
@dataclass
|
||||
class StreamSubscriber:
|
||||
loop: asyncio.AbstractEventLoop
|
||||
queue: asyncio.Queue[RuntimeEvent]
|
||||
|
||||
@@ -5,6 +5,20 @@ from app.tools.base import BaseTool
|
||||
from app.tools.sandbox import ToolSandbox
|
||||
|
||||
|
||||
def _detect_sudo_auth_failure(output: str) -> bool:
|
||||
normalized = output.lower()
|
||||
return any(
|
||||
marker in normalized
|
||||
for marker in (
|
||||
"incorrect password",
|
||||
"incorrect password attempt",
|
||||
"sudo: no password was provided",
|
||||
"sorry, try again",
|
||||
"authentication failure",
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
class Tool(BaseTool):
|
||||
name = "shell_exec"
|
||||
description = "Execute shell commands"
|
||||
@@ -18,16 +32,24 @@ class Tool(BaseTool):
|
||||
return ToolResult(tool=self.name, ok=False, error="Missing command", metadata={"exit_code": -1})
|
||||
cwd = args.get("cwd")
|
||||
stdin_secret = args.get("stdin_secret")
|
||||
output_callback = args.get("__output_callback")
|
||||
completed = self._sandbox.run_shell(
|
||||
command=command,
|
||||
cwd=str(cwd) if cwd else None,
|
||||
stdin_data=str(stdin_secret) if stdin_secret is not None else None,
|
||||
output_callback=output_callback if callable(output_callback) else None,
|
||||
)
|
||||
output = completed.stdout if completed.returncode == 0 else completed.stderr or completed.stdout
|
||||
sudo_auth_failed = completed.returncode != 0 and _detect_sudo_auth_failure(
|
||||
f"{completed.stdout}\n{completed.stderr}"
|
||||
)
|
||||
return ToolResult(
|
||||
tool=self.name,
|
||||
ok=completed.returncode == 0,
|
||||
output=output,
|
||||
error=None if completed.returncode == 0 else f"Command failed with exit code {completed.returncode}",
|
||||
metadata={"exit_code": completed.returncode},
|
||||
metadata={
|
||||
"exit_code": completed.returncode,
|
||||
"sudo_auth_failed": sudo_auth_failed,
|
||||
},
|
||||
)
|
||||
|
||||
+107
-7
@@ -1,16 +1,28 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import signal
|
||||
import subprocess
|
||||
import threading
|
||||
import time
|
||||
from pathlib import Path
|
||||
from typing import Callable
|
||||
|
||||
|
||||
class ToolSandbox:
|
||||
"""Applies simple working directory and timeout restrictions."""
|
||||
|
||||
def __init__(self, allowed_root: str | Path, timeout_ms: int) -> None:
|
||||
def __init__(
|
||||
self,
|
||||
allowed_root: str | Path,
|
||||
timeout_ms: int,
|
||||
command_timeout_ms: int | None = None,
|
||||
idle_timeout_ms: int | None = None,
|
||||
) -> None:
|
||||
self._allowed_root = Path(allowed_root).resolve()
|
||||
self._timeout_seconds = max(timeout_ms / 1000, 1)
|
||||
self._timeout_seconds = max(timeout_ms / 1000, 0.001)
|
||||
self._command_timeout_seconds = max((command_timeout_ms or timeout_ms) / 1000, 0.001)
|
||||
self._idle_timeout_seconds = max((idle_timeout_ms or timeout_ms) / 1000, 0.001)
|
||||
|
||||
def ensure_path_allowed(self, path: str | Path) -> Path:
|
||||
resolved = Path(path).expanduser().resolve()
|
||||
@@ -23,17 +35,105 @@ class ToolSandbox:
|
||||
command: str,
|
||||
cwd: str | Path | None = None,
|
||||
stdin_data: str | None = None,
|
||||
output_callback: Callable[[str, str], None] | None = None,
|
||||
) -> subprocess.CompletedProcess[str]:
|
||||
working_directory = self.ensure_path_allowed(cwd or self._allowed_root)
|
||||
env = {"PATH": os.environ.get("PATH", "")}
|
||||
return subprocess.run(
|
||||
if output_callback is None:
|
||||
return subprocess.run(
|
||||
command,
|
||||
shell=True,
|
||||
cwd=str(working_directory),
|
||||
env=env,
|
||||
text=True,
|
||||
capture_output=True,
|
||||
input=stdin_data,
|
||||
timeout=self._command_timeout_seconds,
|
||||
check=False,
|
||||
)
|
||||
|
||||
process = subprocess.Popen(
|
||||
command,
|
||||
shell=True,
|
||||
cwd=str(working_directory),
|
||||
env=env,
|
||||
text=True,
|
||||
capture_output=True,
|
||||
input=stdin_data,
|
||||
timeout=self._timeout_seconds,
|
||||
check=False,
|
||||
stdin=subprocess.PIPE if stdin_data is not None else None,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.PIPE,
|
||||
start_new_session=True,
|
||||
)
|
||||
|
||||
stdout_chunks: list[str] = []
|
||||
stderr_chunks: list[str] = []
|
||||
output_lock = threading.Lock()
|
||||
last_output_at = time.monotonic()
|
||||
|
||||
if stdin_data is not None and process.stdin is not None:
|
||||
process.stdin.write(stdin_data)
|
||||
process.stdin.close()
|
||||
|
||||
def read_stream(stream_name: str) -> None:
|
||||
stream = process.stdout if stream_name == "stdout" else process.stderr
|
||||
if stream is None:
|
||||
return
|
||||
chunks = stdout_chunks if stream_name == "stdout" else stderr_chunks
|
||||
try:
|
||||
for line in iter(stream.readline, ""):
|
||||
if not line:
|
||||
break
|
||||
chunks.append(line)
|
||||
nonlocal last_output_at
|
||||
with output_lock:
|
||||
last_output_at = time.monotonic()
|
||||
output_callback(stream_name, line)
|
||||
finally:
|
||||
stream.close()
|
||||
|
||||
stdout_thread = threading.Thread(target=read_stream, args=("stdout",), daemon=True)
|
||||
stderr_thread = threading.Thread(target=read_stream, args=("stderr",), daemon=True)
|
||||
stdout_thread.start()
|
||||
stderr_thread.start()
|
||||
|
||||
timed_out = False
|
||||
timeout_reason: str | None = None
|
||||
started_at = time.monotonic()
|
||||
return_code: int | None = None
|
||||
while return_code is None:
|
||||
return_code = process.poll()
|
||||
if return_code is not None:
|
||||
break
|
||||
|
||||
now = time.monotonic()
|
||||
with output_lock:
|
||||
idle_for = now - last_output_at
|
||||
if now - started_at > self._command_timeout_seconds:
|
||||
timed_out = True
|
||||
timeout_reason = f"Command timed out after {self._command_timeout_seconds:.0f}s"
|
||||
break
|
||||
if idle_for > self._idle_timeout_seconds:
|
||||
timed_out = True
|
||||
timeout_reason = f"Command produced no output for {self._idle_timeout_seconds:.0f}s"
|
||||
break
|
||||
time.sleep(0.1)
|
||||
|
||||
if timed_out:
|
||||
try:
|
||||
os.killpg(process.pid, signal.SIGKILL)
|
||||
except ProcessLookupError:
|
||||
pass
|
||||
except PermissionError:
|
||||
process.kill()
|
||||
return_code = process.wait()
|
||||
timeout_message = f"{timeout_reason}\n"
|
||||
stderr_chunks.append(timeout_message)
|
||||
output_callback("stderr", timeout_message)
|
||||
|
||||
stdout_thread.join(timeout=1)
|
||||
stderr_thread.join(timeout=1)
|
||||
return subprocess.CompletedProcess(
|
||||
args=command,
|
||||
returncode=return_code if not timed_out else -9,
|
||||
stdout="".join(stdout_chunks),
|
||||
stderr="".join(stderr_chunks),
|
||||
)
|
||||
|
||||
+26
-7
@@ -5,6 +5,22 @@ from app.tools.base import BaseTool
|
||||
from app.tools.sandbox import ToolSandbox
|
||||
|
||||
|
||||
def _detect_sudo_auth_failure(output: str) -> bool:
|
||||
normalized = output.lower()
|
||||
return any(
|
||||
marker in normalized
|
||||
for marker in (
|
||||
"incorrect password",
|
||||
"incorrect password attempt",
|
||||
"sudo: no password was provided",
|
||||
"sudo: password incorrect",
|
||||
"sorry, try again",
|
||||
"authentication failure",
|
||||
"wrong password",
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
class ShellExecTool(BaseTool):
|
||||
name = "shell_exec"
|
||||
|
||||
@@ -18,6 +34,7 @@ class ShellExecTool(BaseTool):
|
||||
cwd = args.get("cwd")
|
||||
stdin_secret = args.get("stdin_secret")
|
||||
password = args.get("password")
|
||||
output_callback = args.get("__output_callback")
|
||||
|
||||
if password:
|
||||
command = f'echo "{password}" | sudo -S {command}'
|
||||
@@ -26,21 +43,23 @@ class ShellExecTool(BaseTool):
|
||||
command=command,
|
||||
cwd=str(cwd) if cwd else None,
|
||||
stdin_data=str(stdin_secret) if stdin_secret is not None else None,
|
||||
output_callback=output_callback if callable(output_callback) else None,
|
||||
)
|
||||
output = completed.stdout if completed.returncode == 0 else completed.stderr or completed.stdout
|
||||
error_output = completed.stderr or completed.stdout
|
||||
is_sudo_error = (
|
||||
completed.returncode != 0 and
|
||||
("permission denied" in error_output.lower() or
|
||||
"incorrect password" in error_output.lower() or
|
||||
"sudo: password incorrect" in error_output.lower() or
|
||||
"wrong password" in error_output.lower())
|
||||
sudo_auth_failed = completed.returncode != 0 and _detect_sudo_auth_failure(
|
||||
f"{completed.stdout}\n{completed.stderr}"
|
||||
)
|
||||
needs_sudo = completed.returncode != 0 and "permission denied" in error_output.lower() and not sudo_auth_failed
|
||||
|
||||
return ToolResult(
|
||||
tool=self.name,
|
||||
ok=completed.returncode == 0,
|
||||
output=output,
|
||||
error=None if completed.returncode == 0 else f"Command failed with exit code {completed.returncode}",
|
||||
metadata={"exit_code": completed.returncode, "needs_sudo": is_sudo_error},
|
||||
metadata={
|
||||
"exit_code": completed.returncode,
|
||||
"needs_sudo": needs_sudo,
|
||||
"sudo_auth_failed": sudo_auth_failed,
|
||||
},
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user